From 54e4a51a7f13548ca19da649d4cc8d56958808db Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sat, 3 Aug 2024 21:23:59 +0100 Subject: [PATCH] users: huge cleanup/dedupe, interface-based third-party services shared "newUser" method is now "NewUserPostVerification", and is shared between all routes which create a jellyfin account. The new "NewUserFromInvite", "NewUserFromAdmin" and "NewUserFromConfirmationKey" are smaller as a result. Discord, Telegram, and Matrix now implement the "ContactMethodLinker" and "ContactMethodUser" interfaces, meaning code is shared a lot between them in the NewUser methods, and the specifics are now in their own files. Ombi/Jellyseerr similarly implement a simpler interface "ThirdPartyService", which simply has ImportUser and AddContactMethod routes. Note these new interface methods are only used for user creation as of yet, but could likely be used in other places. --- api-jellyseerr.go | 66 ++++ api-messages.go | 6 +- api-ombi.go | 89 ++++- api-userpage.go | 4 +- api-users.go | 729 ++++++++++++------------------------- discord.go | 107 ++++-- housekeeping-d.go | 2 +- images/jfa-go-icon.png | Bin images/jfa-go-icon.svg | 0 jellyseerr-d.go | 2 +- logmessages/logmessages.go | 2 +- main.go | 44 ++- matrix.go | 60 ++- models.go | 2 +- router.go | 4 +- storage.go | 72 +++- telegram.go | 62 +++- ts/tsconfig.json | 3 +- users.go | 180 +++++++++ views.go | 189 ++++++---- 20 files changed, 947 insertions(+), 676 deletions(-) mode change 100755 => 100644 images/jfa-go-icon.png mode change 100755 => 100644 images/jfa-go-icon.svg create mode 100644 users.go diff --git a/api-jellyseerr.go b/api-jellyseerr.go index 4ddb4ec..232b6e5 100644 --- a/api-jellyseerr.go +++ b/api-jellyseerr.go @@ -1,10 +1,12 @@ package main import ( + "fmt" "net/url" "strconv" "github.com/gin-gonic/gin" + "github.com/hrfee/jfa-go/jellyseerr" lm "github.com/hrfee/jfa-go/logmessages" ) @@ -97,3 +99,67 @@ func (app *appContext) DeleteJellyseerrProfile(gc *gin.Context) { app.storage.SetProfileKey(profileName, profile) respondBool(204, true, gc) } + +type JellyseerrWrapper struct { + *jellyseerr.Jellyseerr +} + +func (js *JellyseerrWrapper) ImportUser(jellyfinID string, req newUserDTO, profile Profile) (err error, ok bool) { + // Gets existing user (not possible) or imports the given user. + _, err = js.MustGetUser(jellyfinID) + if err != nil { + return + } + ok = true + err = js.ApplyTemplateToUser(jellyfinID, profile.Jellyseerr.User) + if err != nil { + err = fmt.Errorf(lm.FailedApplyTemplate, "user", lm.Jellyseerr, jellyfinID, err) + return + } + err = js.ApplyNotificationsTemplateToUser(jellyfinID, profile.Jellyseerr.Notifications) + if err != nil { + err = fmt.Errorf(lm.FailedApplyTemplate, "notifications", lm.Jellyseerr, jellyfinID, err) + return + } + return +} + +func (js *JellyseerrWrapper) AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error) { + _, err = js.MustGetUser(jellyfinID) + if err != nil { + return + } + contactMethods := map[jellyseerr.NotificationsField]any{} + if emailEnabled { + err = js.ModifyMainUserSettings(jellyfinID, jellyseerr.MainUserSettings{Email: req.Email}) + if err != nil { + // FIXME: This is a little ugly, considering all other errors are unformatted + err = fmt.Errorf(lm.FailedSetEmailAddress, lm.Jellyseerr, jellyfinID, err) + return + } else { + contactMethods[jellyseerr.FieldEmailEnabled] = req.EmailContact + } + } + if discordEnabled && discord != nil { + contactMethods[jellyseerr.FieldDiscord] = discord.ID + contactMethods[jellyseerr.FieldDiscordEnabled] = req.DiscordContact + } + if telegramEnabled && discord != nil { + contactMethods[jellyseerr.FieldTelegram] = telegram.ChatID + contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact + } + if len(contactMethods) > 0 { + err = js.ModifyNotifications(jellyfinID, contactMethods) + if err != nil { + // app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err) + return + } + } + return +} + +func (js *JellyseerrWrapper) Name() string { return lm.Jellyseerr } + +func (js *JellyseerrWrapper) Enabled(app *appContext, profile *Profile) bool { + return profile != nil && profile.Jellyseerr.Enabled && app.config.Section("jellyseerr").Key("enabled").MustBool(false) +} diff --git a/api-messages.go b/api-messages.go index e632c1e..b9ac0a3 100644 --- a/api-messages.go +++ b/api-messages.go @@ -431,7 +431,7 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) { pin := gc.Param("pin") 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) + app.discord.DeleteVerifiedToken(pin) respondBool(400, false, gc) return } @@ -454,7 +454,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) { } pin := gc.Param("pin") user, ok := app.discord.UserVerified(pin) - if ok && app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(user.ID) { + if ok && app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(user.MethodID().(string)) { delete(app.discord.verifiedTokens, pin) respondBool(400, false, gc) return @@ -472,7 +472,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) { // @Router /invite/{invCode}/discord/invite [get] // @tags Other func (app *appContext) DiscordServerInvite(gc *gin.Context) { - if app.discord.inviteChannelName == "" { + if app.discord.InviteChannel.Name == "" { respondBool(400, false, gc) return } diff --git a/api-ombi.go b/api-ombi.go index b9fc870..e38d172 100644 --- a/api-ombi.go +++ b/api-ombi.go @@ -1,19 +1,18 @@ package main import ( + "errors" "fmt" "net/url" + "strings" "github.com/gin-gonic/gin" lm "github.com/hrfee/jfa-go/logmessages" + "github.com/hrfee/jfa-go/ombi" "github.com/hrfee/mediabrowser" ) func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, error) { - ombiUsers, code, err := app.ombi.GetUsers() - if err != nil || code != 200 { - return nil, code, err - } jfUser, code, err := app.jf.UserByID(jfID, false) if err != nil || code != 200 { return nil, code, err @@ -23,6 +22,14 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er if e, ok := app.storage.GetEmailsKey(jfID); ok { email = e.Addr } + return app.ombi.getUser(username, email) +} + +func (ombi *OmbiWrapper) getUser(username string, email string) (map[string]interface{}, int, error) { + ombiUsers, code, err := ombi.GetUsers() + if err != nil || code != 200 { + return nil, code, err + } for _, ombiUser := range ombiUsers { ombiAddr := "" if a, ok := ombiUser["emailAddress"]; ok && a != nil { @@ -32,13 +39,13 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er return ombiUser, code, err } } - return nil, 400, fmt.Errorf("couldn't find user") + return nil, 400, errors.New(lm.NotFound) } // Returns a user with the given name who has been imported from Jellyfin/Emby by Ombi -func (app *appContext) getOmbiImportedUser(name string) (map[string]interface{}, int, error) { +func (ombi *OmbiWrapper) getImportedUser(name string) (map[string]interface{}, int, error) { // Ombi User Types: 3/4 = Emby, 5 = Jellyfin - ombiUsers, code, err := app.ombi.GetUsers() + ombiUsers, code, err := ombi.GetUsers() if err != nil || code != 200 { return nil, code, err } @@ -136,7 +143,11 @@ func (app *appContext) DeleteOmbiProfile(gc *gin.Context) { respondBool(204, true, gc) } -func (app *appContext) applyOmbiProfile(user map[string]interface{}, profile map[string]interface{}) (status int, err error) { +type OmbiWrapper struct { + *ombi.Ombi +} + +func (ombi *OmbiWrapper) applyProfile(user map[string]interface{}, profile map[string]interface{}) (status int, err error) { for k, v := range profile { switch v.(type) { case map[string]interface{}, []interface{}: @@ -147,6 +158,66 @@ func (app *appContext) applyOmbiProfile(user map[string]interface{}, profile map } } } - status, err = app.ombi.ModifyUser(user) + status, err = ombi.ModifyUser(user) return } + +func (ombi *OmbiWrapper) ImportUser(jellyfinID string, req newUserDTO, profile Profile) (err error, ok bool) { + errors, code, err := ombi.NewUser(req.Username, req.Password, req.Email, profile.Ombi) + var ombiUser map[string]interface{} + var status int + if err != nil || code != 200 { + // Check if on the off chance, Ombi's user importer has already added the account. + ombiUser, status, err = ombi.getImportedUser(req.Username) + if status == 200 && err == nil { + // app.info.Println(lm.Ombi + " " + lm.UserExists) + profile.Ombi["password"] = req.Password + status, err = ombi.applyProfile(ombiUser, profile.Ombi) + if status != 200 || err != nil { + err = fmt.Errorf(lm.FailedApplyProfile, lm.Ombi, req.Username, err) + } + } else { + if len(errors) != 0 { + err = fmt.Errorf("%v, %s", err, strings.Join(errors, ", ")) + } + return + } + } + ok = true + return +} + +func (ombi *OmbiWrapper) AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error) { + var ombiUser map[string]interface{} + var status int + ombiUser, status, err = ombi.getUser(req.Username, req.Email) + if status != 200 || err != nil { + return + } + if discordEnabled || telegramEnabled { + dID := "" + tUser := "" + if discord != nil { + dID = discord.ID + } + if telegram != nil { + tUser = telegram.Username + } + var resp string + var status int + resp, status, err = ombi.SetNotificationPrefs(ombiUser, dID, tUser) + if !(status == 200 || status == 204) || err != nil { + if resp != "" { + err = fmt.Errorf("%v, %s", err, resp) + } + return + } + } + return +} + +func (ombi *OmbiWrapper) Name() string { return lm.Ombi } + +func (ombi *OmbiWrapper) Enabled(app *appContext, profile *Profile) bool { + return profile != nil && profile.Ombi != nil && len(profile.Ombi) != 0 && app.config.Section("ombi").Key("enabled").MustBool(false) +} diff --git a/api-userpage.go b/api-userpage.go index 28fc77a..c631a18 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -292,7 +292,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) { // @Security Bearer // @tags User Page func (app *appContext) MyDiscordServerInvite(gc *gin.Context) { - if app.discord.inviteChannelName == "" { + if app.discord.InviteChannel.Name == "" { respondBool(400, false, gc) return } @@ -340,7 +340,7 @@ func (app *appContext) GetMyPIN(gc *gin.Context) { func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) { pin := gc.Param("pin") dcUser, ok := app.discord.AssignedUserVerified(pin, gc.GetString("jfId")) - app.discord.DeleteVerifiedUser(pin) + app.discord.DeleteVerifiedToken(pin) if !ok { respondBool(200, false, gc) return diff --git a/api-users.go b/api-users.go index 8873103..ced54b5 100644 --- a/api-users.go +++ b/api-users.go @@ -23,7 +23,7 @@ import ( // @Router /users [post] // @Security Bearer // @tags Users -func (app *appContext) NewUserAdmin(gc *gin.Context) { +func (app *appContext) NewUserFromAdmin(gc *gin.Context) { respondUser := func(code int, user, email bool, msg string, gc *gin.Context) { resp := newUserResponse{ User: user, @@ -35,263 +35,143 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) { } var req newUserDTO gc.BindJSON(&req) - existingUser, _, _ := app.jf.UserByName(req.Username, false) - if existingUser.Name != "" { - msg := lm.UserExists - app.info.Printf(lm.FailedCreateUser, lm.Jellyfin, req.Username, msg) - respondUser(401, false, false, msg, gc) - return - } - user, status, err := app.jf.NewUser(req.Username, req.Password) - if !(status == 200 || status == 204) || err != nil { - app.err.Printf(lm.FailedCreateUser, lm.Jellyfin, req.Username, err) - respondUser(401, false, false, err.Error(), gc) - return - } - id := user.ID - - // Record activity - app.storage.SetActivityKey(shortuuid.New(), Activity{ - Type: ActivityCreation, - UserID: id, - SourceType: ActivityAdmin, - Source: gc.GetString("jfId"), - Value: user.Name, - Time: time.Now(), - }, gc, false) profile := app.storage.GetDefaultProfile() - if req.Profile != "" && req.Profile != "none" { - if p, ok := app.storage.GetProfileKey(req.Profile); ok { - profile = p - } else { - app.debug.Printf(lm.FailedGetProfile+lm.FallbackToDefault, req.Profile) - } + nu := app.NewUserPostVerification(NewUserParams{ + Req: req, + SourceType: ActivityAdmin, + Source: gc.GetString("jfId"), + ContextForIPLogging: gc, + Profile: &profile, + }) + if !nu.Success { + nu.Log() + } - status, err = app.jf.SetPolicy(id, profile.Policy) - if !(status == 200 || status == 204 || err == nil) { - app.err.Printf(lm.FailedApplyTemplate, "policy", lm.Jellyfin, req.Username, err) - } - status, err = app.jf.SetConfiguration(id, profile.Configuration) - if (status == 200 || status == 204) && err == nil { - status, err = app.jf.SetDisplayPreferences(id, profile.Displayprefs) - } - if !((status == 200 || status == 204) && err == nil) { - app.err.Printf(lm.FailedApplyTemplate, "configuration", lm.Jellyfin, req.Username, err) - } + welcomeMessageSentIfNecessary := true + if nu.Created { + welcomeMessageSentIfNecessary = app.WelcomeNewUser(nu.User, time.Time{}) } - app.jf.CacheExpiry = time.Now() - if emailEnabled { - app.storage.SetEmailsKey(id, EmailAddress{Addr: req.Email, Contact: true}) - } - if app.config.Section("ombi").Key("enabled").MustBool(false) { - if profile.Ombi == nil { - profile.Ombi = map[string]interface{}{} - } - errors, code, err := app.ombi.NewUser(req.Username, req.Password, req.Email, profile.Ombi) - if err != nil || code != 200 { - app.err.Printf(lm.FailedCreateUser, lm.Ombi, req.Username, err) - app.debug.Printf(lm.AdditionalErrors, lm.Ombi, strings.Join(errors, ", ")) - } else { - app.info.Printf(lm.CreateUser, lm.Ombi, req.Username) - } - } - if app.config.Section("jellyseerr").Key("enabled").MustBool(false) { - // Gets existing user (not possible) or imports the given user. - _, err := app.js.MustGetUser(id) - if err != nil { - app.err.Printf(lm.FailedCreateUser, lm.Jellyseerr, req.Username, err) - } else { - app.info.Printf(lm.CreateUser, lm.Jellyseerr, req.Username) - } - err = app.js.ApplyTemplateToUser(id, profile.Jellyseerr.User) - if err != nil { - app.err.Printf(lm.FailedApplyTemplate, "user", lm.Jellyseerr, req.Username, err) - } - err = app.js.ApplyNotificationsTemplateToUser(id, profile.Jellyseerr.Notifications) - if err != nil { - app.err.Printf(lm.FailedApplyTemplate, "notifications", lm.Jellyseerr, req.Username, err) - } - if emailEnabled { - err = app.js.ModifyUser(id, map[jellyseerr.UserField]any{jellyseerr.FieldEmail: req.Email}) - if err != nil { - app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, id, err) - } - } - } - if emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "" { - msg, err := app.email.constructWelcome(req.Username, time.Time{}, app, false) - if err != nil { - app.err.Printf(lm.FailedConstructWelcomeMessage, id, err) - respondUser(500, true, false, err.Error(), gc) - return - } else if err := app.email.send(msg, req.Email); err != nil { - app.err.Printf(lm.FailedSendWelcomeMessage, req.Username, req.Email, err) - respondUser(500, true, false, err.Error(), gc) - return - } else { - app.info.Printf(lm.SentWelcomeMessage, req.Username, req.Email) - } - } - respondUser(200, true, true, "", gc) + + respondUser(nu.Status, nu.Created, welcomeMessageSentIfNecessary, nu.Message, gc) } -type errorFunc func(gc *gin.Context) +// @Summary Creates a new Jellyfin user via invite code +// @Produce json +// @Param newUserDTO body newUserDTO true "New user request object" +// @Success 200 {object} PasswordValidation +// @Failure 400 {object} PasswordValidation +// @Router /newUser [post] +// @tags Users +func (app *appContext) NewUserFromInvite(gc *gin.Context) { + /* + -- STEPS -- + - Validate CAPTCHA + - Validate Invite + - Validate Password + - a) Discord (Require, Verify, ExistingUser, ApplyRole) + b) Telegram (Require, Verify, ExistingUser) + c) Matrix (Require, Verify, ExistingUser) + d) Email (Require, Verify, ExistingUser) (only occurs here, PWRs are sent by mail so do this on their own, kinda) + */ + var req newUserDTO + gc.BindJSON(&req) -// Used on the form & when a users email has been confirmed. -func (app *appContext) newUser(req newUserDTO, confirmed bool, gc *gin.Context) (f errorFunc, success bool) { - existingUser, _, _ := app.jf.UserByName(req.Username, false) - if existingUser.Name != "" { - f = func(gc *gin.Context) { - msg := lm.UserExists - app.info.Printf(lm.FailedCreateUser, lm.Jellyfin, req.Username, msg) - respond(401, "errorUserExists", gc) - } - success = false + // Validate CAPTCHA + if app.config.Section("captcha").Key("enabled").MustBool(false) && !app.verifyCaptcha(req.Code, req.CaptchaID, req.CaptchaText, false) { + app.info.Printf(lm.FailedCreateUser, lm.Jellyfin, req.Username, lm.IncorrectCaptcha) + respond(400, "errorCaptcha", gc) return } - var discordUser DiscordUser - discordVerified := false - if discordEnabled { - if req.DiscordPIN == "" { - if app.config.Section("discord").Key("required").MustBool(false) { - f = func(gc *gin.Context) { - app.info.Printf(lm.FailedLinkUser, lm.Discord, "?", req.Code, lm.AccountUnverified) - respond(401, "errorDiscordVerification", gc) - } - success = false + // Validate Invite + if !app.checkInvite(req.Code, false, "") { + app.info.Printf(lm.FailedCreateUser, lm.Jellyfin, req.Username, fmt.Sprintf(lm.InvalidInviteCode, req.Code)) + respond(401, "errorInvalidCode", gc) + return + } + // Validate Password + validation := app.validator.validate(req.Password) + valid := true + for _, val := range validation { + if !val { + valid = false + break + } + } + if !valid { + // 200 bcs idk what i did in js + gc.JSON(200, validation) + return + } + completeContactMethods := make([]struct { + Verified bool + PIN string + User ContactMethodUser + }, len(app.contactMethods)) + for i, cm := range app.contactMethods { + completeContactMethods[i].PIN = cm.PIN(req) + if completeContactMethods[i].PIN == "" { + if cm.Required() { + app.info.Printf(lm.FailedLinkUser, cm.Name(), "?", req.Code, lm.AccountUnverified) + respond(401, fmt.Sprintf("error%sVerification", cm.Name()), gc) return } } else { - discordUser, discordVerified = app.discord.UserVerified(req.DiscordPIN) - if !discordVerified { - f = func(gc *gin.Context) { - app.info.Printf(lm.FailedLinkUser, lm.Discord, "?", req.Code, fmt.Sprintf(lm.InvalidPIN, req.DiscordPIN)) - respond(401, "errorInvalidPIN", gc) - } - success = false + completeContactMethods[i].User, completeContactMethods[i].Verified = cm.UserVerified(completeContactMethods[i].PIN) + if !completeContactMethods[i].Verified { + app.info.Printf(lm.FailedLinkUser, cm.Name(), "?", req.Code, fmt.Sprintf(lm.InvalidPIN, completeContactMethods[i].PIN)) + respond(401, "errorInvalidPIN", gc) return } - if app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(discordUser.ID) { - f = func(gc *gin.Context) { - app.debug.Printf(lm.FailedLinkUser, lm.Discord, discordUser.ID, req.Code, lm.AccountLinked) - respond(400, "errorAccountLinked", gc) - } - success = false + if cm.UniqueRequired() && cm.Exists(completeContactMethods[i].User) { + app.debug.Printf(lm.FailedLinkUser, cm.Name(), completeContactMethods[i].User.Name(), req.Code, lm.AccountLinked) + respond(400, "errorAccountLinked", gc) return } - err := app.discord.ApplyRole(discordUser.ID) - if err != nil { - f = func(gc *gin.Context) { - app.err.Printf(lm.FailedLinkUser, lm.Discord, discordUser.ID, req.Code, fmt.Sprintf(lm.FailedSetDiscordMemberRole, err)) - respond(401, "error", gc) - } - success = false - return + if err := cm.PostVerificationTasks(completeContactMethods[i].PIN, completeContactMethods[i].User); err != nil { + app.err.Printf(lm.FailedLinkUser, cm.Name(), completeContactMethods[i].User.Name(), req.Code, err) } } } - var matrixUser MatrixUser - matrixVerified := false - if matrixEnabled { - if req.MatrixPIN == "" { - if app.config.Section("matrix").Key("required").MustBool(false) { - f = func(gc *gin.Context) { - app.info.Printf(lm.FailedLinkUser, lm.Matrix, "?", req.Code, lm.AccountUnverified) - respond(401, "errorMatrixVerification", gc) - } - success = false - return - } - } else { - user, ok := app.matrix.tokens[req.MatrixPIN] - if !ok || !user.Verified { - matrixVerified = false - f = func(gc *gin.Context) { - uid := "" - if ok { - uid = user.User.UserID - } - app.info.Printf(lm.FailedLinkUser, lm.Matrix, uid, req.Code, fmt.Sprintf(lm.InvalidPIN, req.MatrixPIN)) - respond(401, "errorInvalidPIN", gc) - } - success = false - return - } - if app.config.Section("matrix").Key("require_unique").MustBool(false) && app.matrix.UserExists(user.User.UserID) { - f = func(gc *gin.Context) { - app.debug.Printf(lm.FailedLinkUser, lm.Matrix, user.User.UserID, req.Code, lm.AccountLinked) - respond(400, "errorAccountLinked", gc) - } - success = false - return - } - matrixVerified = user.Verified - matrixUser = *user.User - - } - } - var tgToken TelegramVerifiedToken - telegramVerified := false - if telegramEnabled { - if req.TelegramPIN == "" { - if app.config.Section("telegram").Key("required").MustBool(false) { - f = func(gc *gin.Context) { - app.info.Printf(lm.FailedLinkUser, lm.Telegram, "?", req.Code, lm.AccountUnverified) - respond(401, "errorTelegramVerification", gc) - } - success = false - return - } - } else { - tgToken, telegramVerified = app.telegram.TokenVerified(req.TelegramPIN) - if !telegramVerified { - f = func(gc *gin.Context) { - app.info.Printf(lm.FailedLinkUser, lm.Telegram, tgToken.Username, req.Code, fmt.Sprintf(lm.InvalidPIN, req.TelegramPIN)) - respond(401, "errorInvalidPIN", 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(lm.FailedLinkUser, lm.Telegram, tgToken.Username, req.Code, lm.AccountLinked) - 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, - "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"))) - if err != nil { - f = func(gc *gin.Context) { - app.info.Printf(lm.FailedSignJWT, err) - respond(500, "errorUnknown", gc) - } - success = false + // Email (FIXME: Potentially make this a ContactMethodLinker) + if emailEnabled { + // Require + if app.config.Section("email").Key("required").MustBool(false) && !strings.Contains(req.Email, "@") { + respond(400, "errorNoEmail", gc) return } - if app.ConfirmationKeys == nil { - app.ConfirmationKeys = map[string]map[string]newUserDTO{} + // ExistingUser + if app.config.Section("email").Key("require_unique").MustBool(false) && req.Email != "" && app.EmailAddressExists(req.Email) { + respond(400, "errorEmailLinked", gc) + return } - 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) { + // Verify + if app.config.Section("email_confirmation").Key("enabled").MustBool(false) { + claims := jwt.MapClaims{ + "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"))) + if err != nil { + app.err.Printf(lm.FailedSignJWT, err) + respond(500, "errorUnknown", gc) + return + } + 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() + app.debug.Printf(lm.EmailConfirmationRequired, req.Username) respond(401, "confirmEmail", gc) msg, err := app.email.constructConfirmation(req.Code, req.Username, key, app, false) @@ -302,25 +182,76 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool, gc *gin.Context) } else { app.err.Printf(lm.SentConfirmationEmail, req.Code, req.Email) } + return } - success = false - return } - user, status, err := app.jf.NewUser(req.Username, req.Password) - if !(status == 200 || status == 204) || err != nil { - f = func(gc *gin.Context) { - app.err.Printf(lm.FailedCreateUser, lm.Jellyfin, req.Username, err) - respond(401, app.storage.lang.Admin[app.storage.lang.chosenAdminLang].Notifications.get("errorUnknown"), gc) + invite, _ := app.storage.GetInvitesKey(req.Code) + + sourceType, source := invite.Source() + + var profile *Profile = nil + if invite.Profile != "" { + p, ok := app.storage.GetProfileKey(invite.Profile) + if !ok { + app.debug.Printf(lm.FailedGetProfile+lm.FallbackToDefault, invite.Profile) + p = app.storage.GetDefaultProfile() } - success = false + profile = &p + } + + // FIXME: Use NewUserPostVerification + nu := app.NewUserPostVerification(NewUserParams{ + Req: req, + SourceType: sourceType, + Source: source, + ContextForIPLogging: gc, + Profile: profile, + }) + if !nu.Success { + nu.Log() + } + if !nu.Created { + respond(nu.Status, nu.Message, gc) return } - 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 { - if settings["notify-creation"] { + + nonEmailContactMethodEnabled := false + for i, c := range completeContactMethods { + if c.Verified { + c.User.SetAllowContactFromDTO(req) + if c.User.AllowContact() { + nonEmailContactMethodEnabled = true + } + app.contactMethods[i].DeleteVerifiedToken(c.PIN) + c.User.Store(&(app.storage)) + } + } + + referralsEnabled := profile != nil && profile.ReferralTemplateKey != "" && app.config.Section("user_page").Key("enabled").MustBool(false) && app.config.Section("user_page").Key("referrals").MustBool(false) + + if (emailEnabled && req.Email != "") || invite.UserLabel != "" || referralsEnabled { + emailStore := EmailAddress{ + Addr: req.Email, + Contact: (req.Email != ""), + Label: invite.UserLabel, + ReferralTemplateKey: profile.ReferralTemplateKey, + } + /// Ensures at least one contact method is enabled. + if nonEmailContactMethodEnabled { + emailStore.Contact = req.EmailContact + } + app.storage.SetEmailsKey(nu.User.ID, emailStore) + } + if emailEnabled { + if app.config.Section("notifications").Key("enabled").MustBool(false) { + for address, settings := range invite.Notify { + if !settings["notify-creation"] { + continue + } + // FIXME: Forgot how "go" works, but these might get killed if not finished + // before we do? go func(addr string) { msg, err := app.email.constructCreated(req.Code, req.Username, req.Email, invite, app, false) if err != nil { @@ -342,275 +273,62 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool, gc *gin.Context) } } } - id := user.ID - // Record activity - sourceType := ActivityAnon - source := "" - if invite.ReferrerJellyfinID != "" { - sourceType = ActivityUser - source = invite.ReferrerJellyfinID - } - - app.storage.SetActivityKey(shortuuid.New(), Activity{ - Type: ActivityCreation, - UserID: id, - SourceType: sourceType, - Source: source, - InviteCode: invite.Code, - Value: user.Name, - Time: time.Now(), - }, gc, true) - - emailStore := EmailAddress{ - Addr: req.Email, - Contact: (req.Email != ""), - } - // Only allow disabling of email contact if some other method is available. - if req.DiscordContact || req.TelegramContact || req.MatrixContact { - emailStore.Contact = req.EmailContact - } - - if invite.UserLabel != "" { - emailStore.Label = invite.UserLabel - } - - var profile Profile - if invite.Profile != "" { - app.debug.Printf(lm.ApplyProfile, invite.Profile) - var ok bool - profile, ok = app.storage.GetProfileKey(invite.Profile) - if !ok { - profile = app.storage.GetDefaultProfile() - } - status, err = app.jf.SetPolicy(id, profile.Policy) - if !((status == 200 || status == 204) && err == nil) { - app.err.Printf(lm.FailedApplyTemplate, "policy", lm.Jellyfin, id, err) - } - status, err = app.jf.SetConfiguration(id, profile.Configuration) - if (status == 200 || status == 204) && err == nil { - status, err = app.jf.SetDisplayPreferences(id, profile.Displayprefs) - } - if !((status == 200 || status == 204) && err == nil) { - app.err.Printf(lm.FailedApplyTemplate, "configuration", lm.Jellyfin, id, err) - } - if app.config.Section("user_page").Key("enabled").MustBool(false) && app.config.Section("user_page").Key("referrals").MustBool(false) && profile.ReferralTemplateKey != "" { - emailStore.ReferralTemplateKey = profile.ReferralTemplateKey - // Store here, just incase email are disabled (whether this is even possible, i don't know) - app.storage.SetEmailsKey(id, emailStore) - - // If UseReferralExpiry is enabled, create the ref now so the clock starts ticking - refInv := Invite{} - err = app.storage.db.Get(profile.ReferralTemplateKey, &refInv) - if refInv.UseReferralExpiry { - refInv.Code = GenerateInviteCode() - expiryDelta := refInv.ValidTill.Sub(refInv.Created) - refInv.Created = time.Now() - refInv.ValidTill = refInv.Created.Add(expiryDelta) - refInv.IsReferral = true - refInv.ReferrerJellyfinID = id - app.storage.SetInvitesKey(refInv.Code, refInv) - } + if referralsEnabled { + refInv := Invite{} + /*err := */ app.storage.db.Get(profile.ReferralTemplateKey, &refInv) + // If UseReferralExpiry is enabled, create the ref now so the clock starts ticking + if refInv.UseReferralExpiry { + refInv.Code = GenerateInviteCode() + expiryDelta := refInv.ValidTill.Sub(refInv.Created) + refInv.Created = time.Now() + refInv.ValidTill = refInv.Created.Add(expiryDelta) + refInv.IsReferral = true + refInv.ReferrerJellyfinID = nu.User.ID + app.storage.SetInvitesKey(refInv.Code, refInv) } } - // if app.config.Section("password_resets").Key("enabled").MustBool(false) { - if req.Email != "" || invite.UserLabel != "" { - app.storage.SetEmailsKey(id, emailStore) - } + expiry := time.Time{} if invite.UserExpiry { expiry = time.Now().AddDate(0, invite.UserMonths, invite.UserDays).Add(time.Duration((60*invite.UserHours)+invite.UserMinutes) * time.Minute) - app.storage.SetUserExpiryKey(id, UserExpiry{Expiry: expiry}) + app.storage.SetUserExpiryKey(nu.User.ID, UserExpiry{Expiry: expiry}) } - if discordVerified { - discordUser.Contact = req.DiscordContact - if app.storage.deprecatedDiscord == nil { - app.storage.deprecatedDiscord = discordStore{} - } - // Note we don't log an activity here, since it's part of creating a user. - app.storage.SetDiscordKey(user.ID, discordUser) - delete(app.discord.verifiedTokens, req.DiscordPIN) - } - if telegramVerified { - tgUser := TelegramUser{ - ChatID: tgToken.ChatID, - Username: tgToken.Username, - Contact: req.TelegramContact, - } - if lang, ok := app.telegram.languages[tgToken.ChatID]; ok { - tgUser.Lang = lang - } - if app.storage.deprecatedTelegram == nil { - app.storage.deprecatedTelegram = 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 { - template := profile.Ombi - errors, code, err := app.ombi.NewUser(req.Username, req.Password, req.Email, template) - accountExists := false - var ombiUser map[string]interface{} - if err != nil || code != 200 { - // Check if on the off chance, Ombi's user importer has already added the account. - ombiUser, status, err = app.getOmbiImportedUser(req.Username) - if status == 200 && err == nil { - app.info.Println(lm.Ombi + " " + lm.UserExists) - accountExists = true - template["password"] = req.Password - status, err = app.applyOmbiProfile(ombiUser, template) - if status != 200 || err != nil { - app.err.Printf(lm.FailedApplyProfile, lm.Ombi, req.Username, err) - } - } else { - app.info.Printf(lm.FailedCreateUser, lm.Ombi, req.Username, err) - app.debug.Printf(lm.AdditionalErrors, lm.Ombi, strings.Join(errors, ", ")) - } - } else { - ombiUser, status, err = app.getOmbiUser(id) - if status != 200 || err != nil { - app.err.Printf(lm.FailedGetUser, id, lm.Ombi, err) - } else { - app.info.Println(lm.CreateUser, lm.Ombi, id) - accountExists = true - } - } - if accountExists { - if discordVerified || telegramVerified { - dID := "" - tUser := "" - if discordVerified { - dID = discordUser.ID - } - 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 { - app.err.Printf(lm.FailedSyncContactMethods, lm.Ombi, err) - app.debug.Printf(lm.AdditionalErrors, lm.Ombi, resp) - } - } - } - } - } - if invite.Profile != "" && app.config.Section("jellyseerr").Key("enabled").MustBool(false) { - if profile.Jellyseerr.Enabled { - // Gets existing user (not possible) or imports the given user. - _, err := app.js.MustGetUser(id) - if err != nil { - app.err.Printf(lm.FailedCreateUser, lm.Jellyseerr, id, err) - } else { - app.info.Printf(lm.CreateUser, lm.Jellyseerr, id) - } - err = app.js.ApplyTemplateToUser(id, profile.Jellyseerr.User) - if err != nil { - app.err.Printf(lm.FailedApplyTemplate, "user", lm.Jellyseerr, id, err) - } - err = app.js.ApplyNotificationsTemplateToUser(id, profile.Jellyseerr.Notifications) - if err != nil { - app.err.Printf(lm.FailedApplyTemplate, "notifications", lm.Jellyseerr, id, err) - } - contactMethods := map[jellyseerr.NotificationsField]any{} - if emailEnabled { - err = app.js.ModifyMainUserSettings(id, jellyseerr.MainUserSettings{Email: req.Email}) - if err != nil { - app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, id, err) - } else { - contactMethods[jellyseerr.FieldEmailEnabled] = req.EmailContact - } - } - if discordVerified { - contactMethods[jellyseerr.FieldDiscord] = discordUser.ID - contactMethods[jellyseerr.FieldDiscordEnabled] = req.DiscordContact - } - if telegramVerified { - u, _ := app.storage.GetTelegramKey(user.ID) - contactMethods[jellyseerr.FieldTelegram] = u.ChatID - contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact - } - if emailEnabled || discordVerified || telegramVerified { - err := app.js.ModifyNotifications(id, contactMethods) - if err != nil { - app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err) - } - } - } - } - if matrixVerified { - matrixUser.Contact = req.MatrixContact - delete(app.matrix.tokens, req.MatrixPIN) - if app.storage.deprecatedMatrix == nil { - app.storage.deprecatedMatrix = matrixStore{} - } - app.storage.SetMatrixKey(user.ID, matrixUser) - } - if (emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "") || telegramVerified || discordVerified || matrixVerified { - name := app.getAddressOrName(user.ID) - msg, err := app.email.constructWelcome(req.Username, expiry, app, false) - if err != nil { - app.err.Printf(lm.FailedConstructWelcomeMessage, id, err) - } else if err := app.sendByID(msg, user.ID); err != nil { - app.err.Printf(lm.FailedSendWelcomeMessage, id, req.Email, err) - } else { - app.info.Printf(lm.SentWelcomeMessage, id, req.Email) - } - } - app.jf.CacheExpiry = time.Now() - success = true - return -} -// @Summary Creates a new Jellyfin user via invite code -// @Produce json -// @Param newUserDTO body newUserDTO true "New user request object" -// @Success 200 {object} PasswordValidation -// @Failure 400 {object} PasswordValidation -// @Router /newUser [post] -// @tags Users -func (app *appContext) NewUser(gc *gin.Context) { - var req newUserDTO - gc.BindJSON(&req) - if app.config.Section("captcha").Key("enabled").MustBool(false) && !app.verifyCaptcha(req.Code, req.CaptchaID, req.CaptchaText, false) { - app.info.Printf(lm.FailedCreateUser, lm.Jellyfin, req.Username, lm.IncorrectCaptcha) - respond(400, "errorCaptcha", gc) - return - } - if !app.checkInvite(req.Code, false, "") { - app.info.Printf(lm.FailedCreateUser, lm.Jellyfin, req.Username, fmt.Sprintf(lm.InvalidInviteCode, req.Code)) - respond(401, "errorInvalidCode", gc) - return - } - validation := app.validator.validate(req.Password) - valid := true - for _, val := range validation { - if !val { - valid = false - break + var discordUser *DiscordUser = nil + var telegramUser *TelegramUser = nil + if app.ombi.Enabled(app, profile) || app.js.Enabled(app, profile) { + // FIXME: figure these out in a nicer way? this relies on the current ordering, + // which may not be fixed. + if discordEnabled { + discordUser = completeContactMethods[0].User.(*DiscordUser) + if telegramEnabled { + telegramUser = completeContactMethods[1].User.(*TelegramUser) + } + } else if telegramEnabled { + telegramUser = completeContactMethods[0].User.(*TelegramUser) } } - if !valid { - // 200 bcs idk what i did in js - gc.JSON(200, validation) - return - } - if emailEnabled { - if app.config.Section("email").Key("required").MustBool(false) && !strings.Contains(req.Email, "@") { - respond(400, "errorNoEmail", gc) - return + + for _, tps := range app.thirdPartyServices { + if !tps.Enabled(app, profile) { + continue } - if app.config.Section("email").Key("require_unique").MustBool(false) && req.Email != "" && app.EmailAddressExists(req.Email) { - respond(400, "errorEmailLinked", gc) - return + // User already created, now we can link contact methods + err := tps.AddContactMethods(nu.User.ID, req, discordUser, telegramUser) + if err != nil { + app.err.Printf(lm.FailedSyncContactMethods, tps.Name(), err) } } - f, success := app.newUser(req, false, gc) + + app.WelcomeNewUser(nu.User, expiry) + + /*responseFunc, logFunc, success := app.NewUser(req, false, gc) if !success { - f(gc) + logFunc() + responseFunc(gc) return - } + }*/ code := 200 for _, val := range validation { if !val { @@ -954,7 +672,7 @@ func (app *appContext) Announce(gc *gin.Context) { return } } - app.info.Printf(lm.SentAnnouncementMessage, userID, "?") + // app.info.Printf(lm.SentAnnouncementMessage, "*", "?") } else { msg, err := app.email.constructTemplate(req.Subject, req.Message, app) if err != nil { @@ -966,8 +684,9 @@ func (app *appContext) Announce(gc *gin.Context) { respondBool(500, false, gc) return } - app.info.Printf(lm.SentAnnouncementMessage, "*", "?") + // app.info.Printf(lm.SentAnnouncementMessage, "*", "?") } + app.info.Printf(lm.SentAnnouncementMessage, "*", "?") respondBool(200, true, gc) } @@ -1455,7 +1174,7 @@ func (app *appContext) ApplySettings(gc *gin.Context) { // newUser["userName"] = user["userName"] // newUser["alias"] = user["alias"] // newUser["emailAddress"] = user["emailAddress"] - status, err = app.applyOmbiProfile(user, ombi) + status, err = app.ombi.applyProfile(user, ombi) if status != 200 || err != nil { errorString += fmt.Sprintf("Apply %d: %v ", status, err) } diff --git a/discord.go b/discord.go index 3e47d7b..23169e6 100644 --- a/discord.go +++ b/discord.go @@ -11,21 +11,21 @@ import ( ) type DiscordDaemon struct { - Stopped bool - ShutdownChannel chan string - bot *dg.Session - username string - 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 - users map[string]DiscordUser // Map of user IDs to users. Added to on first interaction, and loaded from app.storage.discord on start. - roleID string - app *appContext - commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string) - commandIDs []string - commandDescriptions []*dg.ApplicationCommand + Stopped bool + ShutdownChannel chan string + bot *dg.Session + username string + tokens map[string]VerifToken // Map of pins to tokens. + verifiedTokens map[string]DiscordUser // Map of token pins to discord users. + Channel, InviteChannel struct{ ID, Name string } + guildID string + serverChannelName, serverName string + users map[string]DiscordUser // Map of user IDs to users. Added to on first interaction, and loaded from app.storage.discord on start. + roleID string + app *appContext + commandHandlers map[string]func(s *dg.Session, i *dg.InteractionCreate, lang string) + commandIDs []string + commandDescriptions []*dg.ApplicationCommand } func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) { @@ -120,12 +120,12 @@ func (d *DiscordDaemon) run() { d.serverChannelName = guild.Name d.serverName = guild.Name if channel := d.app.config.Section("discord").Key("channel").String(); channel != "" { - d.channelName = channel + d.Channel.Name = channel d.serverChannelName += "/" + channel } if d.app.config.Section("discord").Key("provide_invite").MustBool(false) { if invChannel := d.app.config.Section("discord").Key("invite_channel").String(); invChannel != "" { - d.inviteChannelName = invChannel + d.InviteChannel.Name = invChannel } } err = d.bot.UpdateGameStatus(0, "/"+d.app.config.Section("discord").Key("start_command").MustString("start")) @@ -171,11 +171,11 @@ func (d *DiscordDaemon) ApplyRole(userID string) error { func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconURL string) { var inv *dg.Invite var err error - if d.inviteChannelName == "" { + if d.InviteChannel.Name == "" { d.app.err.Println(lm.FailedCreateDiscordInviteChannel, lm.InviteChannelEmpty) return } - if d.inviteChannelID == "" { + if d.InviteChannel.ID == "" { channels, err := d.bot.GuildChannels(d.guildID) if err != nil { d.app.err.Printf(lm.FailedGetDiscordChannels, err) @@ -188,14 +188,14 @@ func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconU // d.app.err.Printf(lm.FailedGetDiscordChannel, ch.ID, err) // return // } - if channel.Name == d.inviteChannelName { - d.inviteChannelID = channel.ID + if channel.Name == d.InviteChannel.Name { + d.InviteChannel.ID = channel.ID found = true break } } if !found { - d.app.err.Printf(lm.FailedGetDiscordChannel, d.InviteChannelName, lm.NotFound) + d.app.err.Printf(lm.FailedGetDiscordChannel, d.InviteChannel.Name, lm.NotFound) return } } @@ -204,7 +204,7 @@ func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconU // d.app.err.Printf(lm.FailedGetDiscordChannel, d.inviteChannelID, err) // return // } - inv, err = d.bot.ChannelInviteCreate(d.inviteChannelID, dg.Invite{ + inv, err = d.bot.ChannelInviteCreate(d.InviteChannel.ID, dg.Invite{ // Guild: d.bot.State.Guilds[len(d.bot.State.Guilds)-1], // Channel: channel, // Inviter: d.bot.State.User, @@ -451,19 +451,19 @@ func (d *DiscordDaemon) UpdateCommands() { func (d *DiscordDaemon) commandHandler(s *dg.Session, i *dg.InteractionCreate) { if h, ok := d.commandHandlers[i.ApplicationCommandData().Name]; ok { - if i.GuildID != "" && d.channelName != "" { - if d.channelID == "" { + if i.GuildID != "" && d.Channel.Name != "" { + if d.Channel.ID == "" { channel, err := s.Channel(i.ChannelID) if err != nil { d.app.err.Printf(lm.FailedGetDiscordChannel, i.ChannelID, err) d.app.err.Println(lm.MonitorAllDiscordChannels) - d.channelName = "" + d.Channel.Name = "" } - if channel.Name == d.channelName { - d.channelID = channel.ID + if channel.Name == d.Channel.Name { + d.Channel.ID = channel.ID } } - if d.channelID != i.ChannelID { + if d.Channel.ID != i.ChannelID { d.app.debug.Printf(lm.IgnoreOutOfChannelMessage, lm.Discord) return } @@ -739,10 +739,10 @@ func (d *DiscordDaemon) Send(message *Message, channelID ...string) error { } // 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] +func (d *DiscordDaemon) UserVerified(pin string) (ContactMethodUser, bool) { + u, ok := d.verifiedTokens[pin] // delete(d.verifiedTokens, pin) - return + return &u, ok } // AssignedUserVerified returns whether or not a user with the given PIN has been verified, and the token itself. @@ -762,7 +762,44 @@ func (d *DiscordDaemon) UserExists(id string) bool { return err != nil || c > 0 } -// DeleteVerifiedUser removes the token with the given PIN. -func (d *DiscordDaemon) DeleteVerifiedUser(pin string) { - delete(d.verifiedTokens, pin) +// Exists returns whether or not the given user exists. +func (d *DiscordDaemon) Exists(user ContactMethodUser) bool { + return d.UserExists(user.MethodID().(string)) +} + +// DeleteVerifiedToken removes the token with the given PIN. +func (d *DiscordDaemon) DeleteVerifiedToken(PIN string) { + delete(d.verifiedTokens, PIN) +} + +func (d *DiscordDaemon) PIN(req newUserDTO) string { return req.DiscordPIN } + +func (d *DiscordDaemon) Name() string { return lm.Discord } + +func (d *DiscordDaemon) Required() bool { + return d.app.config.Section("discord").Key("required").MustBool(false) +} + +func (d *DiscordDaemon) UniqueRequired() bool { + return d.app.config.Section("discord").Key("require_unique").MustBool(false) +} + +func (d *DiscordDaemon) PostVerificationTasks(PIN string, u ContactMethodUser) error { + err := d.ApplyRole(u.MethodID().(string)) + if err != nil { + return fmt.Errorf(lm.FailedSetDiscordMemberRole, err) + } + return err +} + +func (d *DiscordUser) Name() string { return RenderDiscordUsername(*d) } +func (d *DiscordUser) SetMethodID(id any) { d.ID = id.(string) } +func (d *DiscordUser) MethodID() any { return d.ID } +func (d *DiscordUser) SetJellyfin(id string) { d.JellyfinID = id } +func (d *DiscordUser) Jellyfin() string { return d.JellyfinID } +func (d *DiscordUser) SetAllowContactFromDTO(req newUserDTO) { d.Contact = req.DiscordContact } +func (d *DiscordUser) SetAllowContact(contact bool) { d.Contact = contact } +func (d *DiscordUser) AllowContact() bool { return d.Contact } +func (d *DiscordUser) Store(st *Storage) { + st.SetDiscordKey(d.Jellyfin(), *d) } diff --git a/housekeeping-d.go b/housekeeping-d.go index 2537f94..f62c9d9 100644 --- a/housekeeping-d.go +++ b/housekeeping-d.go @@ -104,7 +104,7 @@ func (app *appContext) clearActivities() { } } if err == badger.ErrTxnTooBig { - app.debug.Printf(lm.AcitivityLogTxnTooBig) + app.debug.Printf(lm.ActivityLogTxnTooBig) list := []Activity{} if errorSource == 0 { app.storage.db.Find(&list, badgerhold.Where("Time").Lt(minAge)) diff --git a/images/jfa-go-icon.png b/images/jfa-go-icon.png old mode 100755 new mode 100644 diff --git a/images/jfa-go-icon.svg b/images/jfa-go-icon.svg old mode 100755 new mode 100644 diff --git a/jellyseerr-d.go b/jellyseerr-d.go index 374cdf9..d0cfb6f 100644 --- a/jellyseerr-d.go +++ b/jellyseerr-d.go @@ -11,7 +11,7 @@ import ( func (app *appContext) SynchronizeJellyseerrUser(jfID string) { user, imported, err := app.js.GetOrImportUser(jfID) if err != nil { - app.debug.Printf(lm.FailedMustGetJellyseerrUser, jfID, err) + app.debug.Printf(lm.FailedImportUser, lm.Jellyseerr, jfID, err) return } if imported { diff --git a/logmessages/logmessages.go b/logmessages/logmessages.go index 57c8d93..277c883 100644 --- a/logmessages/logmessages.go +++ b/logmessages/logmessages.go @@ -115,7 +115,7 @@ const ( FailedGetJellyseerrNotificationPrefs = "Failed to get user \"%s\"'s notification prefs from " + Jellyseerr + ": %v" FailedSyncContactMethods = "Failed to sync contact methods with %s: %v" ImportJellyseerrUser = "Triggered import for " + Jellyseerr + " user \"%s\" (New ID: %d)" - FailedMustGetJellyseerrUser = "Failed to get or trigger import for " + Jellyseerr + " user \"%s\": %v" + FailedImportUser = "Failed to get or trigger import for %s user \"%s\": %v" // api-messages.go FailedGetCustomMessage = "Failed to get custom message \"%s\"" diff --git a/main.go b/main.go index acc9160..8a139a4 100644 --- a/main.go +++ b/main.go @@ -102,8 +102,9 @@ type appContext struct { // Keeping jf name because I can't think of a better one jf *mediabrowser.MediaBrowser authJf *mediabrowser.MediaBrowser - ombi *ombi.Ombi - js *jellyseerr.Jellyseerr + ombi *OmbiWrapper + js *JellyseerrWrapper + thirdPartyServices []ThirdPartyService datePattern string timePattern string storage Storage @@ -112,6 +113,7 @@ type appContext struct { telegram *TelegramDaemon discord *DiscordDaemon matrix *MatrixDaemon + contactMethods []ContactMethodLinker info, debug, err *logger.Logger host string port int @@ -349,27 +351,32 @@ func start(asDaemon, firstCall bool) { } address = fmt.Sprintf("%s:%d", app.host, app.port) + // NOTE: As of writing this, the order in app.thirdPartServices doesn't matter, + // but in future it might (like app.contactMethods does), so append to the end! if app.config.Section("ombi").Key("enabled").MustBool(false) { + app.ombi = &OmbiWrapper{} app.debug.Printf(lm.UsingOmbi) ombiServer := app.config.Section("ombi").Key("server").String() - app.ombi = ombi.NewOmbi( + app.ombi.Ombi = ombi.NewOmbi( ombiServer, app.config.Section("ombi").Key("api_key").String(), common.NewTimeoutHandler("Ombi", ombiServer, true), ) - + app.thirdPartyServices = append(app.thirdPartyServices, app.ombi) } if app.config.Section("jellyseerr").Key("enabled").MustBool(false) { + app.js = &JellyseerrWrapper{} app.debug.Printf(lm.UsingJellyseerr) jellyseerrServer := app.config.Section("jellyseerr").Key("server").String() - app.js = jellyseerr.NewJellyseerr( + app.js.Jellyseerr = jellyseerr.NewJellyseerr( jellyseerrServer, app.config.Section("jellyseerr").Key("api_key").String(), common.NewTimeoutHandler("Jellyseerr", jellyseerrServer, true), ) app.js.AutoImportUsers = app.config.Section("jellyseerr").Key("import_existing").MustBool(false) // app.js.LogRequestBodies = true + app.thirdPartyServices = append(app.thirdPartyServices, app.js) } @@ -511,17 +518,8 @@ func start(asDaemon, firstCall bool) { defer backupDaemon.Shutdown() } - if telegramEnabled { - app.telegram, err = newTelegramDaemon(app) - if err != nil { - app.err.Printf(lm.FailedInitTelegram, err) - telegramEnabled = false - } else { - app.debug.Println(lm.InitTelegram) - go app.telegram.run() - defer app.telegram.Shutdown() - } - } + // NOTE: The order in which these are placed in app.contactMethods matters. + // Add new ones to the end. if discordEnabled { app.discord, err = newDiscordDaemon(app) if err != nil { @@ -531,6 +529,19 @@ func start(asDaemon, firstCall bool) { app.debug.Println(lm.InitDiscord) go app.discord.run() defer app.discord.Shutdown() + app.contactMethods = append(app.contactMethods, app.discord) + } + } + if telegramEnabled { + app.telegram, err = newTelegramDaemon(app) + if err != nil { + app.err.Printf(lm.FailedInitTelegram, err) + telegramEnabled = false + } else { + app.debug.Println(lm.InitTelegram) + go app.telegram.run() + defer app.telegram.Shutdown() + app.contactMethods = append(app.contactMethods, app.telegram) } } if matrixEnabled { @@ -542,6 +553,7 @@ func start(asDaemon, firstCall bool) { app.debug.Println(lm.InitMatrix) go app.matrix.run() defer app.matrix.Shutdown() + app.contactMethods = append(app.contactMethods, app.matrix) } } } else { diff --git a/matrix.go b/matrix.go index f770aa6..f0a4ae0 100644 --- a/matrix.go +++ b/matrix.go @@ -32,15 +32,6 @@ type UnverifiedUser struct { User *MatrixUser } -type MatrixUser struct { - RoomID string - Encrypted bool - UserID string - Lang string - Contact bool - JellyfinID string `badgerhold:"key"` -} - var matrixFilter = mautrix.Filter{ Room: mautrix.RoomFilter{ Timeline: mautrix.FilterPart{ @@ -277,6 +268,57 @@ func (d *MatrixDaemon) UserExists(userID string) bool { return err != nil || c > 0 } +// Exists returns whether or not the given user exists. +func (d *MatrixDaemon) Exists(user ContactMethodUser) bool { + return d.UserExists(user.Name()) +} + // User enters ID on sign-up, a PIN is sent to them. They enter it on sign-up. // Message the user first, to avoid E2EE by default + +func (d *MatrixDaemon) PIN(req newUserDTO) string { return req.MatrixPIN } + +func (d *MatrixDaemon) Name() string { return lm.Matrix } + +func (d *MatrixDaemon) Required() bool { + return d.app.config.Section("telegram").Key("required").MustBool(false) +} + +func (d *MatrixDaemon) UniqueRequired() bool { + return d.app.config.Section("telegram").Key("require_unique").MustBool(false) +} + +// TokenVerified returns whether or not a token with the given PIN has been verified, and the token itself. +func (d *MatrixDaemon) TokenVerified(pin string) (token UnverifiedUser, ok bool) { + token, ok = d.tokens[pin] + // delete(t.verifiedTokens, pin) + return +} + +// DeleteVerifiedToken removes the token with the given PIN. +func (d *MatrixDaemon) DeleteVerifiedToken(PIN string) { + delete(d.tokens, PIN) +} + +func (d *MatrixDaemon) UserVerified(PIN string) (ContactMethodUser, bool) { + token, ok := d.TokenVerified(PIN) + if !ok { + return &MatrixUser{}, false + } + return token.User, ok +} + +func (d *MatrixDaemon) PostVerificationTasks(string, ContactMethodUser) error { return nil } + +func (m *MatrixUser) Name() string { return m.UserID } +func (m *MatrixUser) SetMethodID(id any) { m.UserID = id.(string) } +func (m *MatrixUser) MethodID() any { return m.UserID } +func (m *MatrixUser) SetJellyfin(id string) { m.JellyfinID = id } +func (m *MatrixUser) Jellyfin() string { return m.JellyfinID } +func (m *MatrixUser) SetAllowContactFromDTO(req newUserDTO) { m.Contact = req.MatrixContact } +func (m *MatrixUser) SetAllowContact(contact bool) { m.Contact = contact } +func (m *MatrixUser) AllowContact() bool { return m.Contact } +func (m *MatrixUser) Store(st *Storage) { + st.SetMatrixKey(m.Jellyfin(), *m) +} diff --git a/models.go b/models.go index 399d497..6bd8aec 100644 --- a/models.go +++ b/models.go @@ -31,7 +31,7 @@ type newUserDTO struct { type newUserResponse struct { User bool `json:"user" binding:"required"` // Whether user was created successfully - Email bool `json:"email"` // Whether welcome email was successfully sent (always true if feature is disabled + Email bool `json:"email"` // Whether welcome email was successfully sent (always true if feature is disabled) Error string `json:"error"` // Optional error message. } diff --git a/router.go b/router.go index 221c01d..6d68d48 100644 --- a/router.go +++ b/router.go @@ -135,7 +135,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { router.GET(p+"/lang/:page/:file", app.ServeLang) router.GET(p+"/token/login", app.getTokenLogin) router.GET(p+"/token/refresh", app.getTokenRefresh) - router.POST(p+"/newUser", app.NewUser) + router.POST(p+"/newUser", app.NewUserFromInvite) router.Use(static.Serve(p+"/invite/", app.webFS)) router.GET(p+"/invite/:invCode", app.InviteProxy) if app.config.Section("captcha").Key("enabled").MustBool(false) { @@ -182,7 +182,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { router.POST(p+"/logout", app.Logout) api.DELETE(p+"/users", app.DeleteUsers) api.GET(p+"/users", app.GetUsers) - api.POST(p+"/users", app.NewUserAdmin) + api.POST(p+"/users", app.NewUserFromAdmin) api.POST(p+"/users/extend", app.ExtendExpiry) api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry) api.POST(p+"/users/enable", app.EnableDisableUsers) diff --git a/storage.go b/storage.go index 426328d..df100da 100644 --- a/storage.go +++ b/storage.go @@ -509,6 +509,15 @@ func (st *Storage) GetDefaultProfile() Profile { return defaultProfile } +// MustGetProfileKey returns the profile at key k, or if missing, the default profile. +func (st *Storage) MustGetProfileKey(k string) Profile { + p, ok := st.GetProfileKey(k) + if !ok { + p = st.GetDefaultProfile() + } + return p +} + // GetCustomContent returns a copy of the store. func (st *Storage) GetCustomContent() []CustomContent { result := []CustomContent{} @@ -584,12 +593,35 @@ func (st *Storage) DeleteActivityKey(k string) { st.db.Delete(k, Activity{}) } -type TelegramUser struct { - JellyfinID string `badgerhold:"key"` - ChatID int64 `badgerhold:"index"` - Username string `badgerhold:"index"` - Lang string - Contact bool // Whether to contact through telegram or not +type ThirdPartyService interface { + // ok implies user imported, err can be any issue that occurs during + ImportUser(jellyfinID string, req newUserDTO, profile Profile) (err error, ok bool) + AddContactMethods(jellyfinID string, req newUserDTO, discord *DiscordUser, telegram *TelegramUser) (err error) + Enabled(app *appContext, profile *Profile) bool + Name() string +} + +type ContactMethodLinker interface { + PIN(req newUserDTO) string + Name() string + Required() bool + UniqueRequired() bool + UserVerified(PIN string) (ContactMethodUser, bool) + PostVerificationTasks(PIN string, u ContactMethodUser) error + DeleteVerifiedToken(PIN string) + Exists(ContactMethodUser) bool +} + +type ContactMethodUser interface { + SetMethodID(id any) + MethodID() any + Name() string + SetJellyfin(id string) + Jellyfin() string + SetAllowContactFromDTO(req newUserDTO) + SetAllowContact(contact bool) + AllowContact() bool + Store(st *Storage) } type DiscordUser struct { @@ -602,6 +634,24 @@ type DiscordUser struct { JellyfinID string `json:"-" badgerhold:"key"` } +type TelegramUser struct { + TelegramVerifiedToken + JellyfinID string `badgerhold:"key"` + ChatID int64 `badgerhold:"index"` + Username string `badgerhold:"index"` + Lang string + Contact bool // Whether to contact through telegram or not +} + +type MatrixUser struct { + RoomID string + Encrypted bool + UserID string + Lang string + Contact bool + JellyfinID string `badgerhold:"key"` +} + type EmailAddress struct { Addr string `badgerhold:"index"` Label string // User Label. @@ -686,6 +736,16 @@ type Invite struct { UseReferralExpiry bool `json:"use_referral_expiry"` } +func (invite Invite) Source() (ActivitySource, string) { + sourceType := ActivityAnon + source := "" + if invite.ReferrerJellyfinID != "" { + sourceType = ActivityUser + source = invite.ReferrerJellyfinID + } + return sourceType, source +} + type Captcha struct { Answer string Image []byte // image/png diff --git a/telegram.go b/telegram.go index 4429cfd..91c404d 100644 --- a/telegram.go +++ b/telegram.go @@ -16,9 +16,27 @@ const ( ) type TelegramVerifiedToken struct { - ChatID int64 - Username string - JellyfinID string // optional, for ensuring a user-requested change is only accessed by them. + JellyfinID string `badgerhold:"key"` // optional, for ensuring a user-requested change is only accessed by them. + ChatID int64 `badgerhold:"index"` + Username string `badgerhold:"index"` +} + +func (tv TelegramVerifiedToken) ToUser() *TelegramUser { + return &TelegramUser{ + TelegramVerifiedToken: tv, + } +} + +func (t *TelegramVerifiedToken) Name() string { return t.Username } +func (t *TelegramVerifiedToken) SetMethodID(id any) { t.ChatID = id.(int64) } +func (t *TelegramVerifiedToken) MethodID() any { return t.ChatID } +func (t *TelegramVerifiedToken) SetJellyfin(id string) { t.JellyfinID = id } +func (t *TelegramVerifiedToken) Jellyfin() string { return t.JellyfinID } +func (t *TelegramUser) SetAllowContactFromDTO(req newUserDTO) { t.Contact = req.TelegramContact } +func (t *TelegramUser) SetAllowContact(contact bool) { t.Contact = contact } +func (t *TelegramUser) AllowContact() bool { return t.Contact } +func (t *TelegramUser) Store(st *Storage) { + st.SetTelegramKey(t.Jellyfin(), *t) } // VerifToken stores details about a pending user verification token. @@ -274,7 +292,39 @@ func (t *TelegramDaemon) UserExists(username string) bool { return err != nil || c > 0 } -// DeleteVerifiedToken removes the token with the given PIN. -func (t *TelegramDaemon) DeleteVerifiedToken(pin string) { - delete(t.verifiedTokens, pin) +// Exists returns whether or not the given user exists. +func (t *TelegramDaemon) Exists(user ContactMethodUser) bool { + return t.UserExists(user.Name()) } + +// DeleteVerifiedToken removes the token with the given PIN. +func (t *TelegramDaemon) DeleteVerifiedToken(PIN string) { + delete(t.verifiedTokens, PIN) +} + +func (t *TelegramDaemon) PIN(req newUserDTO) string { return req.TelegramPIN } + +func (t *TelegramDaemon) Name() string { return lm.Telegram } + +func (t *TelegramDaemon) Required() bool { + return t.app.config.Section("telegram").Key("required").MustBool(false) +} + +func (t *TelegramDaemon) UniqueRequired() bool { + return t.app.config.Section("telegram").Key("require_unique").MustBool(false) +} + +func (t *TelegramDaemon) UserVerified(PIN string) (ContactMethodUser, bool) { + token, ok := t.TokenVerified(PIN) + if !ok { + return &TelegramUser{}, false + } + tu := token.ToUser() + if lang, ok := t.languages[tu.ChatID]; ok { + tu.Lang = lang + } + + return tu, ok +} + +func (t *TelegramDaemon) PostVerificationTasks(string, ContactMethodUser) error { return nil } diff --git a/ts/tsconfig.json b/ts/tsconfig.json index 939f873..ae3f676 100644 --- a/ts/tsconfig.json +++ b/ts/tsconfig.json @@ -4,7 +4,8 @@ "target": "es2017", "lib": ["dom", "es2017"], "typeRoots": ["./typings", "../node_modules/@types"], - "moduleResolution": "nodenext", + "module": "esnext", + "moduleResolution": "bundler", "esModuleInterop": true } } diff --git a/users.go b/users.go new file mode 100644 index 0000000..e5525cb --- /dev/null +++ b/users.go @@ -0,0 +1,180 @@ +package main + +import ( + "time" + + "github.com/gin-gonic/gin" + lm "github.com/hrfee/jfa-go/logmessages" + "github.com/hrfee/mediabrowser" + "github.com/lithammer/shortuuid/v3" +) + +// ReponseFunc responds to the user, generally by HTTP response +// The cases when more than this occurs are given below. +type ResponseFunc func(gc *gin.Context) + +// LogFunc prints a log line once called. +type LogFunc func() + +type ContactMethodConf struct { + Email, Discord, Telegram, Matrix bool +} +type ContactMethodUsers struct { + Email emailStore + Discord DiscordUser + Telegram TelegramVerifiedToken + Matrix MatrixUser +} + +type ContactMethodValidation struct { + Verified ContactMethodConf + Users ContactMethodUsers +} + +type NewUserParams struct { + Req newUserDTO + SourceType ActivitySource + Source string + ContextForIPLogging *gin.Context + Profile *Profile +} + +type NewUserData struct { + Created bool + Success bool + User mediabrowser.User + Message string + Status int + Log func() +} + +// FIXME: First load of steps are going in NewUserFromInvite, because they're only used there. +// Make an interface{} for Require/Verify/ExistingUser which all contact daemons respect, then loop through! +/* +-- STEPS -- +- Validate Invite +- Validate CAPTCHA +- Validate Password +- a) Discord (Require, Verify, ExistingUser, ApplyRole) + b) Telegram (Require, Verify, ExistingUser) + c) Matrix (Require, Verify, ExistingUser) + d) Email (Require, Verify, ExistingUser) +* Check for existing user +* Generate JF user +- Delete Invite +* Store Activity +* Store Email +- Store Discord/Telegram/Matrix/Label +- Notify Admin (Doesn't really matter when this happens) +* Apply Profile +* Generate JS, Ombi Users, apply profiles +* Send Welcome Email + + +*/ + +func (app *appContext) NewUserPostVerification(p NewUserParams) (out NewUserData) { + // Some helper functions which will behave as our app.info/error/debug + deferLogInfo := func(s string, args ...any) { + out.Log = func() { + app.info.Printf(s, args) + } + } + /* deferLogDebug := func(s string, args ...any) { + out.Log = func() { + app.debug.Printf(s, args) + } + } */ + deferLogError := func(s string, args ...any) { + out.Log = func() { + app.err.Printf(s, args) + } + } + + existingUser, _, _ := app.jf.UserByName(p.Req.Username, false) + if existingUser.Name != "" { + out.Message = lm.UserExists + deferLogInfo(lm.FailedCreateUser, lm.Jellyfin, p.Req.Username, out.Message) + out.Status = 401 + return + } + + var status int + var err error + out.User, status, err = app.jf.NewUser(p.Req.Username, p.Req.Password) + if !(status == 200 || status == 204) || err != nil { + out.Message = err.Error() + deferLogError(lm.FailedCreateUser, lm.Jellyfin, p.Req.Username, out.Message) + out.Status = 401 + return + } + out.Created = true + // Invalidate Cache to be safe + app.jf.CacheExpiry = time.Now() + + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityCreation, + UserID: out.User.ID, + SourceType: p.SourceType, + Source: p.Source, + InviteCode: p.Req.Code, // Left blank when an admin does this + Value: out.User.Name, + Time: time.Now(), + }, p.ContextForIPLogging, (p.SourceType != ActivityAdmin)) + + if p.Profile != nil { + status, err = app.jf.SetPolicy(out.User.ID, p.Profile.Policy) + if !((status == 200 || status == 204) && err == nil) { + app.err.Printf(lm.FailedApplyTemplate, "policy", lm.Jellyfin, out.User.ID, err) + } + status, err = app.jf.SetConfiguration(out.User.ID, p.Profile.Configuration) + if (status == 200 || status == 204) && err == nil { + status, err = app.jf.SetDisplayPreferences(out.User.ID, p.Profile.Displayprefs) + } + if !((status == 200 || status == 204) && err == nil) { + app.err.Printf(lm.FailedApplyTemplate, "configuration", lm.Jellyfin, out.User.ID, err) + } + + for _, tps := range app.thirdPartyServices { + if !tps.Enabled(app, p.Profile) { + continue + } + // When ok and err != nil, its a non-fatal failure that we lot without the "FailedImportUser". + err, ok := tps.ImportUser(out.User.ID, p.Req, *p.Profile) + if !ok { + app.err.Printf(lm.FailedImportUser, tps.Name(), p.Req.Username, err) + } else if err != nil { + app.info.Println(err) + } + } + } + + // Welcome email is sent by each user of this method separately.. + + out.Status = 200 + out.Success = true + return +} + +func (app *appContext) WelcomeNewUser(user mediabrowser.User, expiry time.Time) (failed bool) { + if !app.config.Section("welcome_email").Key("enabled").MustBool(false) { + // we didn't "fail", we "politely declined" + // failed = true + return + } + failed = true + name := app.getAddressOrName(user.ID) + if name == "" { + return + } + msg, err := app.email.constructWelcome(user.Name, expiry, app, false) + if err != nil { + app.err.Printf(lm.FailedConstructWelcomeMessage, user.ID, err) + } else if err := app.sendByID(msg, user.ID); err != nil { + app.err.Printf(lm.FailedSendWelcomeMessage, user.ID, name, err) + } else { + app.info.Printf(lm.SentWelcomeMessage, user.ID, name) + failed = false + } + return +} diff --git a/views.go b/views.go index 2b66be6..4282479 100644 --- a/views.go +++ b/views.go @@ -237,7 +237,7 @@ func (app *appContext) MyUserPage(gc *gin.Context) { "server_channel": app.discord.serverChannelName, })) data["discordServerName"] = app.discord.serverName - data["discordInviteLink"] = app.discord.inviteChannelName != "" + data["discordInviteLink"] = app.discord.InviteChannel.Name != "" } if data["linkResetEnabled"].(bool) { data["resetPasswordUsername"] = app.config.Section("user_page").Key("allow_pwr_username").MustBool(true) @@ -616,13 +616,108 @@ func (app *appContext) VerifyCaptcha(gc *gin.Context) { return } +func (app *appContext) NewUserFromConfirmationKey(invite Invite, key string, lang string, gc *gin.Context) { + fail := func() { + gcHTML(gc, 404, "404.html", gin.H{ + "urlBase": app.getURLBase(gc), + "cssClass": app.cssClass, + "cssVersion": cssVersion, + "contactMessage": app.config.Section("ui").Key("contact_message").String(), + }) + } + var req newUserDTO + if app.ConfirmationKeys == nil { + fail() + return + } + + invKeys, ok := app.ConfirmationKeys[invite.Code] + if !ok { + fail() + return + } + req, ok = invKeys[key] + if !ok { + fail() + return + } + token, err := jwt.Parse(key, checkToken) + if err != nil { + fail() + app.debug.Printf(lm.FailedParseJWT, err) + return + } + claims, ok := token.Claims.(jwt.MapClaims) + expiry := time.Unix(int64(claims["exp"].(float64)), 0) + if !(ok && token.Valid && claims["invite"].(string) == invite.Code && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) { + fail() + app.debug.Printf(lm.InvalidJWT) + return + } + + sourceType, source := invite.Source() + + var profile *Profile = nil + if invite.Profile != "" { + p, ok := app.storage.GetProfileKey(invite.Profile) + if !ok { + app.debug.Printf(lm.FailedGetProfile+lm.FallbackToDefault, invite.Profile) + p = app.storage.GetDefaultProfile() + } + profile = &p + } + + nu := app.NewUserPostVerification(NewUserParams{ + Req: req, + SourceType: sourceType, + Source: source, + ContextForIPLogging: gc, + Profile: profile, + }) + if !nu.Success { + nu.Log() + } + if !nu.Created { + respond(nu.Status, nu.Message, gc) + fail() + return + } + app.checkInvite(req.Code, true, req.Username) + + jfLink := app.config.Section("ui").Key("redirect_url").String() + if app.config.Section("ui").Key("auto_redirect").MustBool(false) { + gc.Redirect(301, jfLink) + } else { + gcHTML(gc, http.StatusOK, "create-success.html", gin.H{ + "urlBase": app.getURLBase(gc), + "cssClass": app.cssClass, + "cssVersion": cssVersion, + "strings": app.storage.lang.User[lang].Strings, + "successMessage": app.config.Section("ui").Key("success_message").String(), + "contactMessage": app.config.Section("ui").Key("contact_message").String(), + "jfLink": jfLink, + }) + } + app.confirmationKeysLock.Lock() + // Re-fetch invKeys just incase an update occurred + invKeys, ok = app.ConfirmationKeys[invite.Code] + if !ok { + fail() + return + } + delete(invKeys, key) + app.ConfirmationKeys[invite.Code] = invKeys + app.confirmationKeysLock.Unlock() + return +} + func (app *appContext) InviteProxy(gc *gin.Context) { app.pushResources(gc, FormPage) - code := gc.Param("invCode") lang := app.getLang(gc, FormPage, app.storage.lang.chosenUserLang) + /* Don't actually check if the invite is valid, just if it exists, just so the page loads quicker. Invite is actually checked on submit anyway. */ // if app.checkInvite(code, false, "") { - inv, ok := app.storage.GetInvitesKey(code) + invite, ok := app.storage.GetInvitesKey(gc.Param("invCode")) if !ok { gcHTML(gc, 404, "invalidCode.html", gin.H{ "urlBase": app.getURLBase(gc), @@ -632,75 +727,13 @@ func (app *appContext) InviteProxy(gc *gin.Context) { }) return } - if key := gc.Query("key"); key != "" && app.config.Section("email_confirmation").Key("enabled").MustBool(false) { - fail := func() { - gcHTML(gc, 404, "404.html", gin.H{ - "urlBase": app.getURLBase(gc), - "cssClass": app.cssClass, - "cssVersion": cssVersion, - "contactMessage": app.config.Section("ui").Key("contact_message").String(), - }) - } - var req newUserDTO - if app.ConfirmationKeys == nil { - fail() - return - } - invKeys, ok := app.ConfirmationKeys[code] - if !ok { - fail() - return - } - req, ok = invKeys[key] - if !ok { - fail() - return - } - token, err := jwt.Parse(key, checkToken) - if err != nil { - fail() - app.debug.Printf(lm.FailedParseJWT, err) - return - } - claims, ok := token.Claims.(jwt.MapClaims) - expiry := time.Unix(int64(claims["exp"].(float64)), 0) - if !(ok && token.Valid && claims["invite"].(string) == code && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) { - fail() - app.debug.Printf(lm.InvalidJWT) - return - } - f, success := app.newUser(req, true, gc) - if !success { - // Not meant for us. Calling this is bad but at least gives us log output. - f(gc) - fail() - return - } - jfLink := app.config.Section("ui").Key("redirect_url").String() - if app.config.Section("ui").Key("auto_redirect").MustBool(false) { - gc.Redirect(301, jfLink) - } else { - gcHTML(gc, http.StatusOK, "create-success.html", gin.H{ - "urlBase": app.getURLBase(gc), - "cssClass": app.cssClass, - "cssVersion": cssVersion, - "strings": app.storage.lang.User[lang].Strings, - "successMessage": app.config.Section("ui").Key("success_message").String(), - "contactMessage": app.config.Section("ui").Key("contact_message").String(), - "jfLink": jfLink, - }) - } - delete(invKeys, key) - app.confirmationKeysLock.Lock() - app.ConfirmationKeys[code] = invKeys - app.confirmationKeysLock.Unlock() + if key := gc.Query("key"); key != "" && app.config.Section("email_confirmation").Key("enabled").MustBool(false) { + app.NewUserFromConfirmationKey(invite, key, lang, gc) return } - email := "" - if invite, ok := app.storage.GetInvitesKey(code); ok { - email = invite.SendTo - } + + email := invite.SendTo if strings.Contains(email, "Failed") || !strings.Contains(email, "@") { email = "" } @@ -715,8 +748,8 @@ func (app *appContext) InviteProxy(gc *gin.Context) { userPageAddress += "/my/account" fromUser := "" - if inv.ReferrerJellyfinID != "" { - sender, status, err := app.jf.UserByID(inv.ReferrerJellyfinID, false) + if invite.ReferrerJellyfinID != "" { + sender, status, err := app.jf.UserByID(invite.ReferrerJellyfinID, false) if status == 200 && err == nil { fromUser = sender.Name } @@ -738,13 +771,13 @@ func (app *appContext) InviteProxy(gc *gin.Context) { "strings": app.storage.lang.User[lang].Strings, "validationStrings": app.storage.lang.User[lang].validationStringsJSON, "notifications": app.storage.lang.User[lang].notificationsJSON, - "code": code, + "code": invite.Code, "confirmation": app.config.Section("email_confirmation").Key("enabled").MustBool(false), - "userExpiry": inv.UserExpiry, - "userExpiryMonths": inv.UserMonths, - "userExpiryDays": inv.UserDays, - "userExpiryHours": inv.UserHours, - "userExpiryMinutes": inv.UserMinutes, + "userExpiry": invite.UserExpiry, + "userExpiryMonths": invite.UserMonths, + "userExpiryDays": invite.UserDays, + "userExpiryHours": invite.UserHours, + "userExpiryMinutes": invite.UserMinutes, "userExpiryMessage": app.storage.lang.User[lang].Strings.get("yourAccountIsValidUntil"), "langName": lang, "passwordReset": false, @@ -779,7 +812,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) { "server_channel": app.discord.serverChannelName, })) data["discordServerName"] = app.discord.serverName - data["discordInviteLink"] = app.discord.inviteChannelName != "" + data["discordInviteLink"] = app.discord.InviteChannel.Name != "" } if msg, ok := app.storage.GetCustomContentKey("PostSignupCard"); ok && msg.Enabled { data["customSuccessCard"] = true