Skip to content

Commit

Permalink
referrals: add "use expiry" option
Browse files Browse the repository at this point in the history
adds an option when enabling referrals to use the duration of the source
invited (i.e., months, days, hours) for the referral invite. If enabled,
the user won't be able to make a new referral link after it expires. For
referrals enabled for new users via a profile, the clock starts ticking
as soon as the account is created.
  • Loading branch information
hrfee committed Nov 10, 2023
1 parent d0de114 commit a66c522
Show file tree
Hide file tree
Showing 14 changed files with 118 additions and 23 deletions.
19 changes: 18 additions & 1 deletion api-invites.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,24 @@ func (app *appContext) checkInvites() {
app.storage.SetInvitesKey(data.Code, data)
}

if data.IsReferral {
if data.IsReferral && (!data.UseReferralExpiry || data.ReferrerJellyfinID == "") {
continue
}
expiry := data.ValidTill
if !currentTime.After(expiry) {
continue
}

app.debug.Printf("Housekeeping: Deleting old invite %s", data.Code)

// Disable referrals for the user if UseReferralExpiry is enabled, so no new ones are made.
if data.IsReferral && data.UseReferralExpiry && data.ReferrerJellyfinID != "" {
user, ok := app.storage.GetEmailsKey(data.ReferrerJellyfinID)
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(data.ReferrerJellyfinID, user)
}
}
notify := data.Notify
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
app.debug.Printf("%s: Expiry notification", data.Code)
Expand Down Expand Up @@ -136,6 +146,13 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
}
wait.Wait()
}
if inv.IsReferral && inv.ReferrerJellyfinID != "" && inv.UseReferralExpiry {
user, ok := app.storage.GetEmailsKey(inv.ReferrerJellyfinID)
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(inv.ReferrerJellyfinID, user)
}
}
match = false
app.storage.DeleteInvitesKey(code)
app.storage.SetActivityKey(shortuuid.New(), Activity{
Expand Down
12 changes: 10 additions & 2 deletions api-profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,17 @@ func (app *appContext) DeleteProfile(gc *gin.Context) {
// @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."
// @Param useExpiry path string true "with-expiry or none."
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
// @Failure 500 {object} stringResponse
// @Router /profiles/referral/{profile}/{invite} [post]
// @Router /profiles/referral/{profile}/{invite}/{useExpiry} [post]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) EnableReferralForProfile(gc *gin.Context) {
profileName := gc.Param("profile")
invCode := gc.Param("invite")
useExpiry := gc.Param("useExpiry") == "with-expiry"
inv, ok := app.storage.GetInvitesKey(invCode)
if !ok {
respond(400, "Invalid invite code", gc)
Expand All @@ -154,9 +156,15 @@ func (app *appContext) EnableReferralForProfile(gc *gin.Context) {

// Generate new code for referral template
inv.Code = GenerateInviteCode()
expiryDelta := inv.ValidTill.Sub(inv.Created)
inv.Created = time.Now()
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
if useExpiry {
inv.ValidTill = inv.Created.Add(expiryDelta)
} else {
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
}
inv.IsReferral = true
inv.UseReferralExpiry = useExpiry
// Since this is a template for multiple users, ReferrerJellyfinID is not set.
// inv.ReferrerJellyfinID = ...

Expand Down
21 changes: 19 additions & 2 deletions api-userpage.go
Original file line number Diff line number Diff line change
Expand Up @@ -746,21 +746,37 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
// 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 {
if !ok || err != nil || user.ReferralTemplateKey == "" {
app.debug.Printf("Ignoring referral request, couldn't find template.")
respondBool(400, false, gc)
return
}
inv.Code = GenerateInviteCode()
expiryDelta := inv.ValidTill.Sub(inv.Created)
inv.Created = time.Now()
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
if inv.UseReferralExpiry {
inv.ValidTill = inv.Created.Add(expiryDelta)
} else {
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.
// If UseReferralExpiry is enabled, we delete it and return nothing.
app.storage.DeleteInvitesKey(inv.Code)
if inv.UseReferralExpiry {
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
if ok {
user.ReferralTemplateKey = ""
app.storage.SetEmailsKey(gc.GetString("jfId"), user)
}
app.debug.Printf("Ignoring referral request, expired.")
respondBool(400, false, gc)
return
}
inv.Code = GenerateInviteCode()
inv.Created = time.Now()
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
Expand All @@ -771,5 +787,6 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
RemainingUses: inv.RemainingUses,
NoLimit: inv.NoLimit,
Expiry: inv.ValidTill.Unix(),
UseExpiry: inv.UseReferralExpiry,
})
}
26 changes: 23 additions & 3 deletions api-users.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,19 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
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 app.config.Section("password_resets").Key("enabled").MustBool(false) {
Expand Down Expand Up @@ -729,18 +742,19 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
// @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."
// @Param useExpiry path string true "with-expiry or none."
// @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Router /users/referral/{mode}/{source} [post]
// @Router /users/referral/{mode}/{source}/{useExpiry} [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")

useExpiry := gc.Param("useExpiry") == "with-expiry"
baseInv := Invite{}
if mode == "profile" {
profile, ok := app.storage.GetProfileKey(source)
Expand Down Expand Up @@ -768,10 +782,16 @@ func (app *appContext) EnableReferralForUsers(gc *gin.Context) {
// 2. Generate referral invite.
inv := baseInv
inv.Code = GenerateInviteCode()
expiryDelta := inv.ValidTill.Sub(inv.Created)
inv.Created = time.Now()
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
if useExpiry {
inv.ValidTill = inv.Created.Add(expiryDelta)
} else {
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
}
inv.IsReferral = true
inv.ReferrerJellyfinID = u
inv.UseReferralExpiry = useExpiry
app.storage.SetInvitesKey(inv.Code, inv)
}
}
Expand Down
10 changes: 10 additions & 0 deletions html/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@
<div class="select ~neutral @low mb-4 unfocused">
<select id="enable-referrals-user-invites"></select>
</div>
<label class="switch mb-4">
<input type="checkbox" id="enable-referrals-user-expiry">
<span>{{ .strings.useInviteExpiry }}</span>
<span class="flex flex-row support mt-2">{{ .strings.useInviteExpiryNote }}</span>
</label>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
Expand All @@ -144,6 +149,11 @@
<div class="select ~neutral @low mb-4 mt-2">
<select id="enable-referrals-profile-invites"></select>
</div>
<label class="switch mb-4">
<input type="checkbox" id="enable-referrals-profile-expiry">
<span>{{ .strings.useInviteExpiry }}</span>
<span class="flex flex-row support mt-2">{{ .strings.useInviteExpiryNote }}</span>
</label>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
Expand Down
2 changes: 1 addition & 1 deletion html/user.html
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@
<div>
<div class="card @low dark:~d_neutral unfocused" id="card-referrals">
<span class="heading mb-2">{{ .strings.referrals }}</span>
<aside class="aside ~neutral my-4 col">{{ .strings.referralsDescription }}</aside>
<aside class="aside ~neutral my-4 col user-referrals-description"></aside>
<div class="row flex-expand">
<div class="user-referrals-info"></div>
<div class="grid my-2">
Expand Down
2 changes: 2 additions & 0 deletions lang/admin/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@
"disableReferrals": "Disable Referrals",
"enableReferralsDescription": "Give users a personal referral link similiar to an invite, to send to friends/family. Can be sourced from a referral template in a profile, or from an existing invite.",
"enableReferralsProfileDescription": "Give users created with this profile a personal referral link similiar to an invite, to send to friends/family. Create an invite with the desired settings, then select it here. Each referral will then be based on this invite. You can delete the invite once complete.",
"useInviteExpiry": "Set expiry from profile/invite",
"useInviteExpiryNote": "By default, invites expire after 90 days but can be renewed by the user. Enable for the referral to be disabled after the time set.",
"applyHomescreenLayout": "Apply homescreen layout",
"sendDeleteNotificationEmail": "Send notification message",
"sendDeleteNotifiationExample": "Your account has been deleted.",
Expand Down
1 change: 1 addition & 0 deletions lang/form/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"resetSentDescription": "If an account with the given username/contact method exists, a password reset link has been sent via all contact methods available. The code will expire in 30 minutes.",
"changePassword": "Change Password",
"referralsDescription": "Invite friends & family to Jellyfin with this link. Come back here for a new one if it expires.",
"referralsWithExpiryDescription": "Invite friends & family to Jellyfin with this link. The link will be disabled once it expires.",
"copyReferral": "Copy Link",
"invitedBy": "You were invited by user {user}."
},
Expand Down
3 changes: 2 additions & 1 deletion models.go
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,8 @@ type GetMyReferralRespDTO struct {
Code string `json:"code"`
RemainingUses int `json:"remaining_uses"`
NoLimit bool `json:"no_limit"`
Expiry int64 `json:"expiry"` // Come back after this time to get a new referral
Expiry int64 `json:"expiry"` // Come back after this time to get a new referral (if UseExpiry, a new one can't be made).
UseExpiry bool `json:"use_expiry"`
}

type EnableDisableReferralDTO struct {
Expand Down
4 changes: 2 additions & 2 deletions router.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,9 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
}
api.POST(p+"/matrix/login", app.MatrixLogin)
if app.config.Section("user_page").Key("referrals").MustBool(false) {
api.POST(p+"/users/referral/:mode/:source", app.EnableReferralForUsers)
api.POST(p+"/users/referral/:mode/:source/:useExpiry", app.EnableReferralForUsers)
api.DELETE(p+"/users/referral", app.DisableReferralForUsers)
api.POST(p+"/profiles/referral/:profile/:invite", app.EnableReferralForProfile)
api.POST(p+"/profiles/referral/:profile/:invite/:useExpiry", app.EnableReferralForProfile)
api.DELETE(p+"/profiles/referral/:profile", app.DisableReferralForProfile)
}

Expand Down
18 changes: 9 additions & 9 deletions storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -659,15 +659,15 @@ type Invite struct {
UserMinutes int `json:"user-minutes,omitempty"`
SendTo string `json:"email"`
// Used to be stored as formatted time, now as Unix.
UsedBy [][]string `json:"used-by"`
Notify map[string]map[string]bool `json:"notify"`
Profile string `json:"profile"`
Label string `json:"label,omitempty"`
UserLabel string `json:"user_label,omitempty" example:"Friend"` // Label to apply to users created w/ this invite.
Captchas map[string]Captcha // Map of Captcha IDs to images & answers
IsReferral bool `json:"is_referral" badgerhold:"index"`
ReferrerJellyfinID string `json:"referrer_id"`
ReferrerTemplateForProfile string
UsedBy [][]string `json:"used-by"`
Notify map[string]map[string]bool `json:"notify"`
Profile string `json:"profile"`
Label string `json:"label,omitempty"`
UserLabel string `json:"user_label,omitempty" example:"Friend"` // Label to apply to users created w/ this invite.
Captchas map[string]Captcha // Map of Captcha IDs to images & answers
IsReferral bool `json:"is_referral" badgerhold:"index"`
ReferrerJellyfinID string `json:"referrer_id"`
UseReferralExpiry bool `json:"use_referral_expiry"`
}

type Captcha struct {
Expand Down
4 changes: 3 additions & 1 deletion ts/modules/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,7 @@ export class accountsList {
private _userSelect = document.getElementById("modify-user-users") as HTMLSelectElement;
private _referralsProfileSelect = document.getElementById("enable-referrals-user-profiles") as HTMLSelectElement;
private _referralsInviteSelect = document.getElementById("enable-referrals-user-invites") as HTMLSelectElement;
private _referralsExpiry = document.getElementById("enable-referrals-user-expiry") as HTMLInputElement;
private _searchBox = document.getElementById("accounts-search") as HTMLInputElement;
private _search: Search;

Expand Down Expand Up @@ -1578,7 +1579,7 @@ export class accountsList {
send["from"] = "invite";
send["id"] = this._referralsInviteSelect.value;
}
_post("/users/referral/" + send["from"] + "/" + (send["id"] ? send["id"] : send["profile"]), send, (req: XMLHttpRequest) => {
_post("/users/referral/" + send["from"] + "/" + (send["id"] ? send["id"] : send["profile"]) + "/" + (this._referralsExpiry.checked ? "with-expiry" : "none"), send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
if (req.status == 400) {
Expand All @@ -1593,6 +1594,7 @@ export class accountsList {
};
this._enableReferralsProfile.checked = true;
this._enableReferralsInvite.checked = false;
this._referralsExpiry.checked = false;
window.modals.enableReferralsUser.show();
}

Expand Down
4 changes: 3 additions & 1 deletion ts/modules/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export class ProfileEditor {

enableReferrals = (name: string) => {
const referralsInviteSelect = document.getElementById("enable-referrals-profile-invites") as HTMLSelectElement;
const referralsExpiry = document.getElementById("enable-referrals-profile-expiry") as HTMLInputElement;
_get("/invites", null, (req: XMLHttpRequest) => {
if (req.readyState != 4 || req.status != 200) return;

Expand Down Expand Up @@ -257,7 +258,7 @@ export class ProfileEditor {
"invite": referralsInviteSelect.value
};

_post("/profiles/referral/" + send["profile"] + "/" + send["invite"], send, (req: XMLHttpRequest) => {
_post("/profiles/referral/" + send["profile"] + "/" + send["invite"] + "/" + (referralsExpiry.checked ? "with-expiry" : "none"), send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
if (req.status == 400) {
Expand All @@ -270,6 +271,7 @@ export class ProfileEditor {
}
});
};
referralsExpiry.checked = false;
window.modals.profiles.close();
window.modals.enableReferralsProfile.show();
};
Expand Down
15 changes: 15 additions & 0 deletions ts/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ interface MyReferral {
remaining_uses: number;
no_limit: boolean;
expiry: number;
use_expiry: boolean;
}

interface ContactDTO {
Expand Down Expand Up @@ -252,13 +253,15 @@ class ReferralCard {
private _url: string;
private _expiry: Date;
private _expiryUnix: number;
private _useExpiry: boolean;
private _remainingUses: number;
private _noLimit: boolean;

private _button: HTMLButtonElement;
private _infoArea: HTMLDivElement;
private _remainingUsesEl: HTMLSpanElement;
private _expiryEl: HTMLSpanElement;
private _descriptionEl: HTMLSpanElement;

get code(): string { return this._code; }
set code(c: string) {
Expand Down Expand Up @@ -294,11 +297,22 @@ class ReferralCard {
this._expiry = new Date(expiryUnix * 1000);
this._expiryEl.textContent = toDateString(this._expiry);
}

get use_expiry(): boolean { return this._useExpiry; }
set use_expiry(v: boolean) {
this._useExpiry = v;
if (v) {
this._descriptionEl.textContent = window.lang.strings("referralsWithExpiryDescription");
} else {
this._descriptionEl.textContent = window.lang.strings("referralsDescription");
}
}

constructor(card: HTMLElement) {
this._card = card;
this._button = this._card.querySelector(".user-referrals-button") as HTMLButtonElement;
this._infoArea = this._card.querySelector(".user-referrals-info") as HTMLDivElement;
this._descriptionEl = this._card.querySelector(".user-referrals-description") as HTMLSpanElement;

this._infoArea.innerHTML = `
<div class="row my-3">
Expand Down Expand Up @@ -344,6 +358,7 @@ class ReferralCard {
this.no_limit = referral.no_limit;
this.expiry = referral.expiry;
this._card.classList.remove("unfocused");
this.use_expiry = referral.use_expiry;
};
}

Expand Down

0 comments on commit a66c522

Please sign in to comment.