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