referrals: 1/2 generation routes, display route, partial frontend

route for generation/enabling of referral for user(s) done? the frontend
is mostly done, but functionality is not there yet. Route for finding
and displaying referral to user is done. Also the config option for
referral is there, in user page settings.
referrals
Harvey Tindall 11 months ago
parent 423fc4ac80
commit 9c2f27bcdb
No known key found for this signature in database
GPG Key ID: BBC65952848FB1A2

@ -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,

@ -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,
})
}

@ -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"

@ -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."
}
}
},

@ -17,6 +17,7 @@
window.jellyfinLogin = {{ .jellyfinLogin }};
window.jfAdminOnly = {{ .jfAdminOnly }};
window.jfAllowAll = {{ .jfAllowAll }};
window.referralsEnabled = {{ .referralsEnabled }};
</script>
<title>Admin - jfa-go</title>
{{ template "header.html" . }}
@ -107,6 +108,34 @@
</label>
</form>
</div>
{{ if .referralsEnabled }}
<div id="modal-enable-referrals-user" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-enable-referrals-user" href="">
<span class="heading"><span id="header-enable-referrals-user"></span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.enableReferralsDescription }}</p>
<div class="flex flex-row mb-4">
<label class="flex-row-group mr-2">
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-profile" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
</label>
<label class="flex-row-group ml-2">
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-invite">
<span class="button ~neutral @low supra full-width center">{{ .strings.invite }}</span>
</label>
</div>
<div class="select ~neutral @low mb-4">
<select id="enable-referrals-user-profiles"></select>
</div>
<div class="select ~neutral @low mb-4 unfocused">
<select id="enable-referrals-user-invites"></select>
</div>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
</label>
</form>
</div>
{{ end }}
<div id="modal-delete-user" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-delete-user" href="">
<span class="heading"><span id="header-delete-user"></span> <span class="modal-close">&times;</span></span>
@ -613,6 +642,7 @@
</div>
</div>
<span class="col button ~urge @low center max-w-[20%]" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
<span class="col button ~urge @low center max-w-[20%]" id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span>
<span class="col button ~warning @low center max-w-[20%]" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
<div id="accounts-disable-enable-dropdown" class="col dropdown manual pb-0i max-w-[20%]" tabindex="0">
<span class="w-100 button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>

@ -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"

@ -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"`
}

@ -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)
}
}
}
}

@ -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 {

@ -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();

@ -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 += `<option value="${profile}">${profile}</option>`;
}
this._referralsProfileSelect.innerHTML = innerHTML;
})();
(() => {
let innerHTML = "";
// for (let id in this._users) {
// innerHTML += `<option value="${id}">${this._users[id].name}</option>`;
// }
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");

@ -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 {

@ -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),
})
}

Loading…
Cancel
Save