diff --git a/api-invites.go b/api-invites.go index 4d94a0d..8042e2e 100644 --- a/api-invites.go +++ b/api-invites.go @@ -16,6 +16,9 @@ import ( func (app *appContext) checkInvites() { currentTime := time.Now() for _, data := range app.storage.GetInvites() { + if data.IsReferral { + continue + } expiry := data.ValidTill if !currentTime.After(expiry) { continue @@ -222,6 +225,9 @@ func (app *appContext) GetInvites(gc *gin.Context) { app.checkInvites() var invites []inviteDTO for _, inv := range app.storage.GetInvites() { + if inv.IsReferral { + continue + } _, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime) invite := inviteDTO{ Code: inv.Code, diff --git a/api-profiles.go b/api-profiles.go index b118838..3623d33 100644 --- a/api-profiles.go +++ b/api-profiles.go @@ -1,9 +1,11 @@ package main import ( + "strconv" "time" "github.com/gin-gonic/gin" + "github.com/lithammer/shortuuid/v3" "github.com/timshannon/badgerhold/v4" ) @@ -19,13 +21,23 @@ func (app *appContext) GetProfiles(gc *gin.Context) { DefaultProfile: app.storage.GetDefaultProfile().Name, Profiles: map[string]profileDTO{}, } + referralsEnabled := app.config.Section("user_page").Key("referrals").MustBool(false) + baseInv := Invite{} for _, p := range app.storage.GetProfiles() { - out.Profiles[p.Name] = profileDTO{ - Admin: p.Admin, - LibraryAccess: p.LibraryAccess, - FromUser: p.FromUser, - Ombi: p.Ombi != nil, + pdto := profileDTO{ + Admin: p.Admin, + LibraryAccess: p.LibraryAccess, + FromUser: p.FromUser, + Ombi: p.Ombi != nil, + ReferralsEnabled: false, } + if referralsEnabled { + err := app.storage.db.Get(p.ReferralTemplateKey, &baseInv) + if p.ReferralTemplateKey != "" && err == nil { + pdto.ReferralsEnabled = true + } + } + out.Profiles[p.Name] = pdto } gc.JSON(200, out) } @@ -111,3 +123,76 @@ func (app *appContext) DeleteProfile(gc *gin.Context) { app.storage.DeleteProfileKey(name) respondBool(200, true, gc) } + +// @Summary Enable referrals for a profile, sourced from the given invite by its code. +// @Produce json +// @Param profile path string true "name of profile to enable referrals for." +// @Param invite path string true "invite code to create referral template from." +// @Success 200 {object} boolResponse +// @Failure 400 {object} stringResponse +// @Failure 500 {object} stringResponse +// @Router /profiles/referral/{profile}/{invite} [post] +// @Security Bearer +// @tags Profiles & Settings +func (app *appContext) EnableReferralForProfile(gc *gin.Context) { + profileName := gc.Param("profile") + invCode := gc.Param("invite") + inv, ok := app.storage.GetInvitesKey(invCode) + if !ok { + respond(400, "Invalid invite code", gc) + app.err.Printf("\"%s\": Failed to enable referrals: invite not found", profileName) + return + } + profile, ok := app.storage.GetProfileKey(profileName) + if !ok { + respond(400, "Invalid profile", gc) + app.err.Printf("\"%s\": Failed to enable referrals: profile not found", profileName) + return + } + + // Generate new code for referral template + inv.Code = shortuuid.New() + // make sure code doesn't begin with number + _, err := strconv.Atoi(string(inv.Code[0])) + for err == nil { + inv.Code = shortuuid.New() + _, err = strconv.Atoi(string(inv.Code[0])) + } + inv.Created = time.Now() + inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour) + inv.IsReferral = true + // Since this is a template for multiple users, ReferrerJellyfinID is not set. + // inv.ReferrerJellyfinID = ... + + app.storage.SetInvitesKey(inv.Code, inv) + + profile.ReferralTemplateKey = inv.Code + + app.storage.SetProfileKey(profile.Name, profile) + + respondBool(200, true, gc) +} + +// @Summary Disable referrals for a profile, and removes the referral template. no-op if not enabled. +// @Produce json +// @Param profile path string true "name of profile to enable referrals for." +// @Success 200 {object} boolResponse +// @Router /profiles/referral/{profile} [delete] +// @Security Bearer +// @tags Profiles & Settings +func (app *appContext) DisableReferralForProfile(gc *gin.Context) { + profileName := gc.Param("profile") + profile, ok := app.storage.GetProfileKey(profileName) + if !ok { + respondBool(200, true, gc) + return + } + + app.storage.DeleteInvitesKey(profile.ReferralTemplateKey) + + profile.ReferralTemplateKey = "" + + app.storage.SetProfileKey(profileName, profile) + + respondBool(200, true, gc) +} diff --git a/api-userpage.go b/api-userpage.go index a517ee8..dce320f 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -3,11 +3,18 @@ package main import ( "net/http" "os" + "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt" + "github.com/lithammer/shortuuid/v3" + "github.com/timshannon/badgerhold/v4" +) + +const ( + REFERRAL_EXPIRY_DAYS = 90 ) // @Summary Returns the logged-in user's Jellyfin ID & Username, and other details. @@ -74,6 +81,25 @@ func (app *appContext) MyDetails(gc *gin.Context) { } } + if app.config.Section("user_page").Key("referrals").MustBool(false) { + // 1. Look for existing template bound to this Jellyfin ID + // If one exists, that means its just for us and so we + // can use it directly. + inv := Invite{} + err := app.storage.db.FindOne(&inv, badgerhold.Where("ReferrerJellyfinID").Eq(resp.Id)) + if err == nil { + resp.HasReferrals = true + } else { + // 2. Look for a template matching the key found in the user storage + // Since this key is shared between users in a profile, we make a copy. + user, ok := app.storage.GetEmailsKey(gc.GetString("jfId")) + err = app.storage.db.Get(user.ReferralTemplateKey, &inv) + if ok && err == nil { + resp.HasReferrals = true + } + } + } + gc.JSON(200, resp) } @@ -621,3 +647,63 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) { } respondBool(204, true, gc) } + +// @Summary Get or generate a new referral code. +// @Produce json +// @Success 200 {object} GetMyReferralRespDTO +// @Failure 400 {object} boolResponse +// @Failure 401 {object} boolResponse +// @Failure 500 {object} boolResponse +// @Router /my/referral [get] +// @Security Bearer +// @Tags User Page +func (app *appContext) GetMyReferral(gc *gin.Context) { + // 1. Look for existing template bound to this Jellyfin ID + // If one exists, that means its just for us and so we + // can use it directly. + inv := Invite{} + err := app.storage.db.FindOne(&inv, badgerhold.Where("ReferrerJellyfinID").Eq(gc.GetString("jfId"))) + if err != nil { + // 2. Look for a template matching the key found in the user storage + // Since this key is shared between users in a profile, we make a copy. + user, ok := app.storage.GetEmailsKey(gc.GetString("jfId")) + err = app.storage.db.Get(user.ReferralTemplateKey, &inv) + if !ok || err != nil { + app.debug.Printf("Ignoring referral request, couldn't find template.") + respondBool(400, false, gc) + return + } + inv.Code = shortuuid.New() + // make sure code doesn't begin with number + _, err := strconv.Atoi(string(inv.Code[0])) + for err == nil { + inv.Code = shortuuid.New() + _, err = strconv.Atoi(string(inv.Code[0])) + } + inv.Created = time.Now() + inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour) + inv.IsReferral = true + inv.ReferrerJellyfinID = gc.GetString("jfId") + app.storage.SetInvitesKey(inv.Code, inv) + } else if time.Now().After(inv.ValidTill) { + // 3. We found an invite for us, but it's expired. + // We delete it from storage, and put it back with a fresh code and expiry. + app.storage.DeleteInvitesKey(inv.Code) + inv.Code = shortuuid.New() + // make sure code doesn't begin with number + _, err := strconv.Atoi(string(inv.Code[0])) + for err == nil { + inv.Code = shortuuid.New() + _, err = strconv.Atoi(string(inv.Code[0])) + } + inv.Created = time.Now() + inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour) + app.storage.SetInvitesKey(inv.Code, inv) + } + gc.JSON(200, GetMyReferralRespDTO{ + Code: inv.Code, + RemainingUses: inv.RemainingUses, + NoLimit: inv.NoLimit, + Expiry: inv.ValidTill.Unix(), + }) +} diff --git a/api-users.go b/api-users.go index 28a040e..7adc77d 100644 --- a/api-users.go +++ b/api-users.go @@ -3,12 +3,15 @@ package main import ( "fmt" "os" + "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt" "github.com/hrfee/mediabrowser" + "github.com/lithammer/shortuuid/v3" + "github.com/timshannon/badgerhold/v4" ) // @Summary Creates a new Jellyfin user without an invite. @@ -301,6 +304,12 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc } } id := user.ID + + emailStore := EmailAddress{ + Addr: req.Email, + Contact: (req.Email != ""), + } + var profile Profile if invite.Profile != "" { app.debug.Printf("Applying settings from profile \"%s\"", invite.Profile) @@ -322,10 +331,15 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc if !((status == 200 || status == 204) && err == nil) { app.err.Printf("%s: Failed to set configuration template (%d): %v", req.Code, status, 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 app.config.Section("password_resets").Key("enabled").MustBool(false) { if req.Email != "" { - app.storage.SetEmailsKey(id, EmailAddress{Addr: req.Email, Contact: true}) + app.storage.SetEmailsKey(id, emailStore) } expiry := time.Time{} if invite.UserExpiry { @@ -629,6 +643,88 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) { respondBool(204, true, gc) } +// @Summary Enable referrals for the given user(s) based on the rules set in the given invite code, or profile. +// @Produce json +// @Param EnableDisableReferralDTO body EnableDisableReferralDTO true "List of users" +// @Param mode path string true "mode of template sourcing from 'invite' or 'profile'." +// @Param source path string true "invite code or profile name, depending on what mode is." +// @Success 200 {object} boolResponse +// @Failure 400 {object} boolResponse +// @Failure 500 {object} boolResponse +// @Router /users/referral/{mode}/{source} [post] +// @Security Bearer +// @tags Users +func (app *appContext) EnableReferralForUsers(gc *gin.Context) { + var req EnableDisableReferralDTO + gc.BindJSON(&req) + mode := gc.Param("mode") + source := gc.Param("source") + + baseInv := Invite{} + if mode == "profile" { + profile, ok := app.storage.GetProfileKey(source) + err := app.storage.db.Get(profile.ReferralTemplateKey, &baseInv) + if !ok || profile.ReferralTemplateKey == "" || err != nil { + app.debug.Printf("Couldn't find template to source from") + respondBool(400, false, gc) + return + + } + app.debug.Printf("Found referral template in profile: %+v\n", profile.ReferralTemplateKey) + } else if mode == "invite" { + // Get the invite, and modify it to turn it into a referral + err := app.storage.db.Get(source, &baseInv) + if err != nil { + app.debug.Printf("Couldn't find invite to source from") + respondBool(400, false, gc) + return + } + } + for _, u := range req.Users { + // 1. Wipe out any existing referral codes. + app.storage.db.DeleteMatching(Invite{}, badgerhold.Where("ReferrerJellyfinID").Eq(u)) + + // 2. Generate referral invite. + inv := baseInv + inv.Code = shortuuid.New() + // make sure code doesn't begin with number + _, err := strconv.Atoi(string(inv.Code[0])) + for err == nil { + inv.Code = shortuuid.New() + _, err = strconv.Atoi(string(inv.Code[0])) + } + inv.Created = time.Now() + inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour) + inv.IsReferral = true + inv.ReferrerJellyfinID = u + app.storage.SetInvitesKey(inv.Code, inv) + } +} + +// @Summary Disable referrals for the given user(s). +// @Produce json +// @Param EnableDisableReferralDTO body EnableDisableReferralDTO true "List of users" +// @Success 200 {object} boolResponse +// @Router /users/referral [delete] +// @Security Bearer +// @tags Users +func (app *appContext) DisableReferralForUsers(gc *gin.Context) { + var req EnableDisableReferralDTO + gc.BindJSON(&req) + for _, u := range req.Users { + // 1. Delete directly bound template + app.storage.db.DeleteMatching(Invite{}, badgerhold.Where("ReferrerJellyfinID").Eq(u)) + // 2. Check for and delete profile-attached template + user, ok := app.storage.GetEmailsKey(u) + if !ok { + continue + } + user.ReferralTemplateKey = "" + app.storage.SetEmailsKey(u, user) + } + respondBool(200, true, gc) +} + // @Summary Send an announcement via email to a given list of users. // @Produce json // @Param announcementDTO body announcementDTO true "Announcement request object" @@ -833,13 +929,15 @@ func (app *appContext) GetUsers(gc *gin.Context) { } adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true) allowAll := app.config.Section("ui").Key("allow_all").MustBool(false) + referralsEnabled := app.config.Section("user_page").Key("referrals").MustBool(false) i := 0 for _, jfUser := range users { user := respUser{ - ID: jfUser.ID, - Name: jfUser.Name, - Admin: jfUser.Policy.IsAdministrator, - Disabled: jfUser.Policy.IsDisabled, + ID: jfUser.ID, + Name: jfUser.Name, + Admin: jfUser.Policy.IsAdministrator, + Disabled: jfUser.Policy.IsDisabled, + ReferralsEnabled: false, } if !jfUser.LastActivityDate.IsZero() { user.LastActive = jfUser.LastActivityDate.Unix() @@ -868,6 +966,18 @@ func (app *appContext) GetUsers(gc *gin.Context) { user.DiscordID = dcUser.ID user.NotifyThroughDiscord = dcUser.Contact } + // FIXME: Send referral data + referrerInv := Invite{} + if referralsEnabled { + // 1. Directly attached invite. + err := app.storage.db.FindOne(&referrerInv, badgerhold.Where("ReferrerJellyfinID").Eq(jfUser.ID)) + if err == nil { + user.ReferralsEnabled = true + // 2. Referrals via profile template. Shallow check, doesn't look for the thing in the database. + } else if email, ok := app.storage.GetEmailsKey(jfUser.ID); ok && email.ReferralTemplateKey != "" { + user.ReferralsEnabled = true + } + } resp.UserList[i] = user i++ } diff --git a/config/config-base.json b/config/config-base.json index 5d4fbe4..dd96956 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -385,7 +385,7 @@ "enabled": { "name": "Enabled", "required": false, - "requires_restart": false, + "requires_restart": true, "type": "bool", "value": true }, @@ -405,6 +405,22 @@ "depends_true": "enabled", "required": "false", "description": "Click the edit icon next to the \"User Page\" Setting to add custom Markdown messages that will be shown to the user. Note message cards are not private, little effort is required for anyone to view them." + }, + "referrals": { + "name": "User Referrals", + "required": false, + "requires_restart": true, + "type": "bool", + "value": true, + "description": "Users are given their own \"invite\" to send to others." + }, + "referrals_note": { + "name": "Using Referrals:", + "type": "note", + "value": "", + "depends_true": "referrals", + "required": "false", + "description": "Create an invite with your desired settings, then either assign it to a user in the accounts tab, or to a profile in settings." } } }, diff --git a/html/admin.html b/html/admin.html index 81fd381..690a59a 100644 --- a/html/admin.html +++ b/html/admin.html @@ -17,6 +17,7 @@ window.jellyfinLogin = {{ .jellyfinLogin }}; window.jfAdminOnly = {{ .jfAdminOnly }}; window.jfAllowAll = {{ .jfAllowAll }}; + window.referralsEnabled = {{ .referralsEnabled }}; Admin - jfa-go {{ template "header.html" . }} @@ -107,6 +108,48 @@ + {{ if .referralsEnabled }} + + + {{ end }} {{ .strings.modifySettings }} + {{ if .referralsEnabled }} + {{ .strings.enableReferrals }} + {{ end }} {{ .strings.extendExpiry }}