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-userpage.go b/api-userpage.go index a517ee8..30f50a6 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 = 365 ) // @Summary Returns the logged-in user's Jellyfin ID & Username, and other details. @@ -621,3 +628,62 @@ 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.Find(&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 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 + 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, + }) +} diff --git a/api-users.go b/api-users.go index 28a040e..43a9acb 100644 --- a/api-users.go +++ b/api-users.go @@ -3,12 +3,14 @@ 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" ) // @Summary Creates a new Jellyfin user without an invite. @@ -629,6 +631,58 @@ 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 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 + + } + } 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 { + 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 Send an announcement via email to a given list of users. // @Produce json // @Param announcementDTO body announcementDTO true "Announcement request object" diff --git a/config/config-base.json b/config/config-base.json index 5d4fbe4..f567927 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -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": false, + "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..538cbc6 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,34 @@ + {{ if .referralsEnabled }} + + {{ end }} {{ .strings.modifySettings }} + {{ .strings.enableReferrals }} {{ .strings.extendExpiry }}