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 }}
{{ .strings.disable }}
diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json
index db9773f..354f7ad 100644
--- a/lang/admin/en-us.json
+++ b/lang/admin/en-us.json
@@ -4,6 +4,7 @@
},
"strings": {
"invites": "Invites",
+ "invite": "Invite",
"accounts": "Accounts",
"settings": "Settings",
"inviteMonths": "Months",
@@ -63,6 +64,8 @@
"markdownSupported": "Markdown is supported.",
"modifySettings": "Modify Settings",
"modifySettingsDescription": "Apply settings from an existing profile, or source them directly from a user.",
+ "enableReferrals": "Enable Referrals",
+ "enableReferralsDescription": "Give users their a referral link similiar to an invite, to send to friends/family. Can be sourced from a referral template in a profile, or from an exsiting invite.",
"applyHomescreenLayout": "Apply homescreen layout",
"sendDeleteNotificationEmail": "Send notification message",
"sendDeleteNotifiationExample": "Your account has been deleted.",
@@ -160,6 +163,10 @@
"singular": "Modify Settings for {n} user",
"plural": "Modify Settings for {n} users"
},
+ "enableReferralsFor": {
+ "singular": "Enable Referrals for {n} user",
+ "plural": "Enable Referrals for {n} users"
+ },
"deleteNUsers": {
"singular": "Delete {n} user",
"plural": "Delete {n} users"
diff --git a/models.go b/models.go
index 7120d06..c075193 100644
--- a/models.go
+++ b/models.go
@@ -414,3 +414,15 @@ type ChangeMyPasswordDTO struct {
Old string `json:"old"`
New string `json:"new"`
}
+
+type GetMyReferralRespDTO struct {
+ Code string `json:"code"`
+ RemainingUses int `json:"remaining-uses"`
+ NoLimit bool `json:"no-limit"`
+ Expiry time.Time `json:"expiry"` // Come back after this time to get a new referral
+}
+
+type EnableDisableReferralDTO struct {
+ Users []string `json:"users"`
+ Enabled bool `json:"enabled"`
+}
diff --git a/router.go b/router.go
index 56754df..bc2ad70 100644
--- a/router.go
+++ b/router.go
@@ -226,6 +226,9 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.DELETE(p+"/profiles/ombi/:profile", app.DeleteOmbiProfile)
}
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)
+ }
if userPageEnabled {
user.GET(p+"/details", app.MyDetails)
@@ -242,6 +245,9 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
user.DELETE(p+"/telegram", app.UnlinkMyTelegram)
user.DELETE(p+"/matrix", app.UnlinkMyMatrix)
user.POST(p+"/password", app.ChangeMyPassword)
+ if app.config.Section("user_page").Key("referrals").MustBool(false) {
+ user.GET(p+"/referral", app.GetMyReferral)
+ }
}
}
}
diff --git a/storage.go b/storage.go
index c7aa0bb..5d8a527 100644
--- a/storage.go
+++ b/storage.go
@@ -429,15 +429,16 @@ type DiscordUser struct {
Discriminator string
Lang string
Contact bool
- JellyfinID string `json:"-" badgerhold:"key"` // Used internally in discord.go
+ JellyfinID string `json:"-" badgerhold:"key"`
}
type EmailAddress struct {
- Addr string `badgerhold:"index"`
- Label string // User Label.
- Contact bool
- Admin bool // Whether or not user is jfa-go admin.
- JellyfinID string `badgerhold:"key"`
+ Addr string `badgerhold:"index"`
+ Label string // User Label.
+ Contact bool
+ Admin bool // Whether or not user is jfa-go admin.
+ JellyfinID string `badgerhold:"key"`
+ ReferralTemplateKey string
}
type customEmails struct {
@@ -470,16 +471,17 @@ type userPageContent struct {
// timePattern: %Y-%m-%dT%H:%M:%S.%f
type Profile struct {
- Name string `badgerhold:"key"`
- Admin bool `json:"admin,omitempty" badgerhold:"index"`
- LibraryAccess string `json:"libraries,omitempty"`
- FromUser string `json:"fromUser,omitempty"`
- Homescreen bool `json:"homescreen"`
- Policy mediabrowser.Policy `json:"policy,omitempty"`
- Configuration mediabrowser.Configuration `json:"configuration,omitempty"`
- Displayprefs map[string]interface{} `json:"displayprefs,omitempty"`
- Default bool `json:"default,omitempty"`
- Ombi map[string]interface{} `json:"ombi,omitempty"`
+ Name string `badgerhold:"key"`
+ Admin bool `json:"admin,omitempty" badgerhold:"index"`
+ LibraryAccess string `json:"libraries,omitempty"`
+ FromUser string `json:"fromUser,omitempty"`
+ Homescreen bool `json:"homescreen"`
+ Policy mediabrowser.Policy `json:"policy,omitempty"`
+ Configuration mediabrowser.Configuration `json:"configuration,omitempty"`
+ Displayprefs map[string]interface{} `json:"displayprefs,omitempty"`
+ Default bool `json:"default,omitempty"`
+ Ombi map[string]interface{} `json:"ombi,omitempty"`
+ ReferralTemplateKey string
}
type Invite struct {
@@ -495,11 +497,14 @@ 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"`
- Captchas map[string]*captcha.Data // Map of Captcha IDs to answers
+ UsedBy [][]string `json:"used-by"`
+ Notify map[string]map[string]bool `json:"notify"`
+ Profile string `json:"profile"`
+ Label string `json:"label,omitempty"`
+ Captchas map[string]*captcha.Data // Map of Captcha IDs to answers
+ IsReferral bool `json:"is_referral" badgerhold:"index"`
+ ReferrerJellyfinID string `json:"referrer_id"`
+ ReferrerTemplateForProfile string
}
type Lang struct {
diff --git a/ts/admin.ts b/ts/admin.ts
index df65535..7b68895 100644
--- a/ts/admin.ts
+++ b/ts/admin.ts
@@ -78,6 +78,10 @@ window.availableProfiles = window.availableProfiles || [];
if (window.linkResetEnabled) {
window.modals.sendPWR = new Modal(document.getElementById("modal-send-pwr"));
}
+
+ if (window.referralsEnabled) {
+ window.modals.enableReferralsUser = new Modal(document.getElementById("modal-enable-referrals-user"));
+ }
})();
var inviteCreator = new createInvite();
diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts
index 3a56bce..180343b 100644
--- a/ts/modules/accounts.ts
+++ b/ts/modules/accounts.ts
@@ -748,9 +748,14 @@ export class accountsList {
private _modifySettings = document.getElementById("accounts-modify-user") as HTMLSpanElement;
private _modifySettingsProfile = document.getElementById("radio-use-profile") as HTMLInputElement;
private _modifySettingsUser = document.getElementById("radio-use-user") as HTMLInputElement;
+ private _enableReferrals = document.getElementById("accounts-enable-referrals") as HTMLSpanElement;
+ private _enableReferralsProfile = document.getElementById("radio-referrals-use-profile") as HTMLInputElement;
+ private _enableReferralsInvite = document.getElementById("radio-referrals-use-invite") as HTMLInputElement;
private _sendPWR = document.getElementById("accounts-send-pwr") as HTMLSpanElement;
private _profileSelect = document.getElementById("modify-user-profiles") as HTMLSelectElement;
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 _search = document.getElementById("accounts-search") as HTMLInputElement;
private _selectAll = document.getElementById("accounts-select-all") as HTMLInputElement;
@@ -1154,6 +1159,9 @@ export class accountsList {
this._selectAll.indeterminate = false;
this._selectAll.checked = false;
this._modifySettings.classList.add("unfocused");
+ if (window.referralsEnabled) {
+ this._enableReferrals.classList.add("unfocused");
+ }
this._deleteUser.classList.add("unfocused");
if (window.emailEnabled || window.telegramEnabled) {
this._announceButton.parentElement.classList.add("unfocused");
@@ -1176,6 +1184,9 @@ export class accountsList {
this._selectAll.indeterminate = true;
}
this._modifySettings.classList.remove("unfocused");
+ if (window.referralsEnabled) {
+ this._enableReferrals.classList.remove("unfocused");
+ }
this._deleteUser.classList.remove("unfocused");
this._deleteUser.textContent = window.lang.quantity("deleteUser", list.length);
if (window.emailEnabled || window.telegramEnabled) {
@@ -1662,6 +1673,60 @@ export class accountsList {
};
window.modals.modifyUser.show();
}
+
+ enableReferrals = () => {
+ const modalHeader = document.getElementById("header-enable-referrals-user");
+ modalHeader.textContent = window.lang.quantity("enableReferralsFor", this._collectUsers().length)
+ let list = this._collectUsers();
+ // FIXME: Collect Profiles, Invite
+ (() => {
+ let innerHTML = "";
+ for (const profile of window.availableProfiles) {
+ innerHTML += ``;
+ }
+ this._referralsProfileSelect.innerHTML = innerHTML;
+ })();
+
+ (() => {
+ let innerHTML = "";
+ // for (let id in this._users) {
+ // innerHTML += ``;
+ // }
+ this._referralsInviteSelect.innerHTML = innerHTML;
+ })();
+
+ const form = document.getElementById("form-enable-referrals-user") as HTMLFormElement;
+ const button = form.querySelector("span.submit") as HTMLSpanElement;
+ this._enableReferralsProfile.checked = true;
+ this._enableReferralsInvite.checked = false;
+ form.onsubmit = (event: Event) => {
+ event.preventDefault();
+ toggleLoader(button);
+ let send = {
+ "users": list
+ };
+ if (this._enableReferralsProfile.checked && !this._enableReferralsInvite.checked) {
+ send["from"] = "profile";
+ send["profile"] = this._referralsProfileSelect.value;
+ } else if (this._enableReferralsInvite.checked && !this._enableReferralsProfile.checked) {
+ send["from"] = "invite";
+ send["id"] = this._referralsInviteSelect.value;
+ }
+ _post("/users/referrals/" + send["from"] + "/" + send["id"], send, (req: XMLHttpRequest) => {
+ if (req.readyState == 4) {
+ toggleLoader(button);
+ if (req.status == 400) {
+ window.notifications.customError("unknownError", window.lang.notif("errorUnknown"));
+ } else if (req.status == 200 || req.status == 204) {
+ window.notifications.customSuccess("enableReferralsSuccess", window.lang.quantity("appliedSettings", this._collectUsers().length));
+ }
+ this.reload();
+ window.modals.enableReferralsUser.close();
+ }
+ });
+ };
+ window.modals.enableReferralsUser.show();
+ }
extendExpiry = (enableUser?: boolean) => {
const list = this._collectUsers();
@@ -1794,6 +1859,31 @@ export class accountsList {
this._modifySettingsProfile.onchange = checkSource;
this._modifySettingsUser.onchange = checkSource;
+ if (window.referralsEnabled) {
+ this._enableReferrals.onclick = this.enableReferrals;
+ const checkReferralSource = () => {
+ const profileSpan = this._enableReferralsProfile.nextElementSibling as HTMLSpanElement;
+ const inviteSpan = this._enableReferralsInvite.nextElementSibling as HTMLSpanElement;
+ if (this._enableReferralsProfile.checked) {
+ this._referralsInviteSelect.parentElement.classList.add("unfocused");
+ this._referralsProfileSelect.parentElement.classList.remove("unfocused")
+ profileSpan.classList.add("@high");
+ profileSpan.classList.remove("@low");
+ inviteSpan.classList.remove("@high");
+ inviteSpan.classList.add("@low");
+ } else {
+ this._referralsInviteSelect.parentElement.classList.remove("unfocused");
+ this._referralsProfileSelect.parentElement.classList.add("unfocused");
+ inviteSpan.classList.add("@high");
+ inviteSpan.classList.remove("@low");
+ profileSpan.classList.remove("@high");
+ profileSpan.classList.add("@low");
+ }
+ };
+ this._enableReferralsProfile.onchange = checkReferralSource;
+ this._enableReferralsInvite.onchange = checkReferralSource;
+ }
+
this._deleteUser.onclick = this.deleteUsers;
this._deleteUser.classList.add("unfocused");
diff --git a/ts/typings/d.ts b/ts/typings/d.ts
index cae4f54..7b440c6 100644
--- a/ts/typings/d.ts
+++ b/ts/typings/d.ts
@@ -40,6 +40,7 @@ declare interface Window {
jellyfinLogin: boolean;
jfAdminOnly: boolean;
jfAllowAll: boolean;
+ referralsEnabled: boolean;
}
declare interface Update {
@@ -113,6 +114,7 @@ declare interface Modals {
pwr?: Modal;
logs: Modal;
email?: Modal;
+ enableReferralsUser?: Modal;
}
interface Invite {
diff --git a/views.go b/views.go
index f2e2dbc..8b2bcf3 100644
--- a/views.go
+++ b/views.go
@@ -173,6 +173,7 @@ func (app *appContext) AdminPage(gc *gin.Context) {
"jfAdminOnly": jfAdminOnly,
"jfAllowAll": jfAllowAll,
"userPageEnabled": app.config.Section("user_page").Key("enabled").MustBool(false),
+ "referralsEnabled": app.config.Section("user_page").Key("enabled").MustBool(false) && app.config.Section("user_page").Key("referrals").MustBool(false),
})
}