mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-12-28 03:50:10 +00:00
Harvey Tindall
a66c522b73
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.
504 lines
15 KiB
Go
504 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/itchyny/timefmt-go"
|
|
"github.com/lithammer/shortuuid/v3"
|
|
"github.com/timshannon/badgerhold/v4"
|
|
)
|
|
|
|
const (
|
|
CAPTCHA_VALIDITY = 20 * 60 // Seconds
|
|
)
|
|
|
|
// GenerateInviteCode generates an invite code in the correct format.
|
|
func GenerateInviteCode() string {
|
|
// make sure code doesn't begin with number
|
|
inviteCode := shortuuid.New()
|
|
_, err := strconv.Atoi(string(inviteCode[0]))
|
|
for err == nil {
|
|
inviteCode = shortuuid.New()
|
|
_, err = strconv.Atoi(string(inviteCode[0]))
|
|
}
|
|
return inviteCode
|
|
}
|
|
|
|
func (app *appContext) checkInvites() {
|
|
currentTime := time.Now()
|
|
for _, data := range app.storage.GetInvites() {
|
|
captchas := data.Captchas
|
|
captchasExpired := false
|
|
for key, capt := range data.Captchas {
|
|
if time.Now().After(capt.Generated.Add(CAPTCHA_VALIDITY * time.Second)) {
|
|
delete(captchas, key)
|
|
captchasExpired = true
|
|
}
|
|
}
|
|
if captchasExpired {
|
|
data.Captchas = captchas
|
|
app.storage.SetInvitesKey(data.Code, data)
|
|
}
|
|
|
|
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)
|
|
var wait sync.WaitGroup
|
|
for address, settings := range notify {
|
|
if !settings["notify-expiry"] {
|
|
continue
|
|
}
|
|
wait.Add(1)
|
|
go func(addr string) {
|
|
defer wait.Done()
|
|
msg, err := app.email.constructExpiry(data.Code, data, app, false)
|
|
if err != nil {
|
|
app.err.Printf("%s: Failed to construct expiry notification: %v", data.Code, err)
|
|
} else {
|
|
// Check whether notify "address" is an email address of Jellyfin ID
|
|
if strings.Contains(addr, "@") {
|
|
err = app.email.send(msg, addr)
|
|
} else {
|
|
err = app.sendByID(msg, addr)
|
|
}
|
|
if err != nil {
|
|
app.err.Printf("%s: Failed to send expiry notification: %v", data.Code, err)
|
|
} else {
|
|
app.info.Printf("Sent expiry notification to %s", addr)
|
|
}
|
|
}
|
|
}(address)
|
|
}
|
|
wait.Wait()
|
|
}
|
|
app.storage.DeleteInvitesKey(data.Code)
|
|
|
|
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
|
Type: ActivityDeleteInvite,
|
|
SourceType: ActivityDaemon,
|
|
InviteCode: data.Code,
|
|
Value: data.Label,
|
|
Time: time.Now(),
|
|
})
|
|
}
|
|
}
|
|
|
|
func (app *appContext) checkInvite(code string, used bool, username string) bool {
|
|
currentTime := time.Now()
|
|
inv, match := app.storage.GetInvitesKey(code)
|
|
if !match {
|
|
return false
|
|
}
|
|
expiry := inv.ValidTill
|
|
if currentTime.After(expiry) {
|
|
app.debug.Printf("Housekeeping: Deleting old invite %s", code)
|
|
notify := inv.Notify
|
|
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
|
|
app.debug.Printf("%s: Expiry notification", code)
|
|
var wait sync.WaitGroup
|
|
for address, settings := range notify {
|
|
if !settings["notify-expiry"] {
|
|
continue
|
|
}
|
|
wait.Add(1)
|
|
go func(addr string) {
|
|
defer wait.Done()
|
|
msg, err := app.email.constructExpiry(code, inv, app, false)
|
|
if err != nil {
|
|
app.err.Printf("%s: Failed to construct expiry notification: %v", code, err)
|
|
} else {
|
|
// Check whether notify "address" is an email address of Jellyfin ID
|
|
if strings.Contains(addr, "@") {
|
|
err = app.email.send(msg, addr)
|
|
} else {
|
|
err = app.sendByID(msg, addr)
|
|
}
|
|
if err != nil {
|
|
app.err.Printf("%s: Failed to send expiry notification: %v", code, err)
|
|
} else {
|
|
app.info.Printf("Sent expiry notification to %s", addr)
|
|
}
|
|
}
|
|
}(address)
|
|
}
|
|
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{
|
|
Type: ActivityDeleteInvite,
|
|
SourceType: ActivityDaemon,
|
|
InviteCode: code,
|
|
Value: inv.Label,
|
|
Time: time.Now(),
|
|
})
|
|
} else if used {
|
|
del := false
|
|
newInv := inv
|
|
if newInv.RemainingUses == 1 {
|
|
del = true
|
|
app.storage.DeleteInvitesKey(code)
|
|
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
|
Type: ActivityDeleteInvite,
|
|
SourceType: ActivityDaemon,
|
|
InviteCode: code,
|
|
Value: inv.Label,
|
|
Time: time.Now(),
|
|
})
|
|
} else if newInv.RemainingUses != 0 {
|
|
// 0 means infinite i guess?
|
|
newInv.RemainingUses--
|
|
}
|
|
newInv.UsedBy = append(newInv.UsedBy, []string{username, strconv.FormatInt(currentTime.Unix(), 10)})
|
|
if !del {
|
|
app.storage.SetInvitesKey(code, newInv)
|
|
}
|
|
}
|
|
return match
|
|
}
|
|
|
|
// @Summary Create a new invite.
|
|
// @Produce json
|
|
// @Param generateInviteDTO body generateInviteDTO true "New invite request object"
|
|
// @Success 200 {object} boolResponse
|
|
// @Router /invites [post]
|
|
// @Security Bearer
|
|
// @tags Invites
|
|
func (app *appContext) GenerateInvite(gc *gin.Context) {
|
|
var req generateInviteDTO
|
|
app.debug.Println("Generating new invite")
|
|
gc.BindJSON(&req)
|
|
currentTime := time.Now()
|
|
validTill := currentTime.AddDate(0, req.Months, req.Days)
|
|
validTill = validTill.Add(time.Hour*time.Duration(req.Hours) + time.Minute*time.Duration(req.Minutes))
|
|
var invite Invite
|
|
invite.Code = GenerateInviteCode()
|
|
if req.Label != "" {
|
|
invite.Label = req.Label
|
|
}
|
|
if req.UserLabel != "" {
|
|
invite.UserLabel = req.UserLabel
|
|
}
|
|
invite.Created = currentTime
|
|
if req.MultipleUses {
|
|
if req.NoLimit {
|
|
invite.NoLimit = true
|
|
} else {
|
|
invite.RemainingUses = req.RemainingUses
|
|
}
|
|
} else {
|
|
invite.RemainingUses = 1
|
|
}
|
|
invite.UserExpiry = req.UserExpiry
|
|
if invite.UserExpiry {
|
|
invite.UserMonths = req.UserMonths
|
|
invite.UserDays = req.UserDays
|
|
invite.UserHours = req.UserHours
|
|
invite.UserMinutes = req.UserMinutes
|
|
}
|
|
invite.ValidTill = validTill
|
|
if req.SendTo != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
|
|
addressValid := false
|
|
discord := ""
|
|
app.debug.Printf("%s: Sending invite message", invite.Code)
|
|
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
|
|
users := app.discord.GetUsers(req.SendTo)
|
|
if len(users) == 0 {
|
|
invite.SendTo = fmt.Sprintf("Failed: User not found: \"%s\"", req.SendTo)
|
|
} else if len(users) > 1 {
|
|
invite.SendTo = fmt.Sprintf("Failed: Multiple users found: \"%s\"", req.SendTo)
|
|
} else {
|
|
invite.SendTo = req.SendTo
|
|
addressValid = true
|
|
discord = users[0].User.ID
|
|
}
|
|
} else if emailEnabled {
|
|
addressValid = true
|
|
invite.SendTo = req.SendTo
|
|
}
|
|
if addressValid {
|
|
msg, err := app.email.constructInvite(invite.Code, invite, app, false)
|
|
if err != nil {
|
|
invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo)
|
|
app.err.Printf("%s: Failed to construct invite message: %v", invite.Code, err)
|
|
} else {
|
|
var err error
|
|
if discord != "" {
|
|
err = app.discord.SendDM(msg, discord)
|
|
} else {
|
|
err = app.email.send(msg, req.SendTo)
|
|
}
|
|
if err != nil {
|
|
invite.SendTo = fmt.Sprintf("Failed to send to %s", req.SendTo)
|
|
app.err.Printf("%s: %s: %v", invite.Code, invite.SendTo, err)
|
|
} else {
|
|
app.info.Printf("%s: Sent invite email to \"%s\"", invite.Code, req.SendTo)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if req.Profile != "" {
|
|
if _, ok := app.storage.GetProfileKey(req.Profile); ok {
|
|
invite.Profile = req.Profile
|
|
} else {
|
|
invite.Profile = "Default"
|
|
}
|
|
}
|
|
app.storage.SetInvitesKey(invite.Code, invite)
|
|
|
|
// Record activity
|
|
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
|
Type: ActivityCreateInvite,
|
|
UserID: "",
|
|
SourceType: ActivityAdmin,
|
|
Source: gc.GetString("jfId"),
|
|
InviteCode: invite.Code,
|
|
Value: invite.Label,
|
|
Time: time.Now(),
|
|
})
|
|
|
|
respondBool(200, true, gc)
|
|
}
|
|
|
|
// @Summary Get invites.
|
|
// @Produce json
|
|
// @Success 200 {object} getInvitesDTO
|
|
// @Router /invites [get]
|
|
// @Security Bearer
|
|
// @tags Invites
|
|
func (app *appContext) GetInvites(gc *gin.Context) {
|
|
app.debug.Println("Invites requested")
|
|
currentTime := time.Now()
|
|
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,
|
|
Months: months,
|
|
Days: days,
|
|
Hours: hours,
|
|
Minutes: minutes,
|
|
UserExpiry: inv.UserExpiry,
|
|
UserMonths: inv.UserMonths,
|
|
UserDays: inv.UserDays,
|
|
UserHours: inv.UserHours,
|
|
UserMinutes: inv.UserMinutes,
|
|
Created: inv.Created.Unix(),
|
|
Profile: inv.Profile,
|
|
NoLimit: inv.NoLimit,
|
|
Label: inv.Label,
|
|
UserLabel: inv.UserLabel,
|
|
}
|
|
if len(inv.UsedBy) != 0 {
|
|
invite.UsedBy = map[string]int64{}
|
|
for _, pair := range inv.UsedBy {
|
|
// These used to be stored formatted instead of as a unix timestamp.
|
|
unix, err := strconv.ParseInt(pair[1], 10, 64)
|
|
if err != nil {
|
|
date, err := timefmt.Parse(pair[1], app.datePattern+" "+app.timePattern)
|
|
if err != nil {
|
|
app.err.Printf("Failed to parse usedBy time: %v", err)
|
|
}
|
|
unix = date.Unix()
|
|
}
|
|
invite.UsedBy[pair[0]] = unix
|
|
}
|
|
}
|
|
invite.RemainingUses = 1
|
|
if inv.RemainingUses != 0 {
|
|
invite.RemainingUses = inv.RemainingUses
|
|
}
|
|
if inv.SendTo != "" {
|
|
invite.SendTo = inv.SendTo
|
|
}
|
|
if len(inv.Notify) != 0 {
|
|
// app.err.Printf("%s has notify section: %+v, you are %s\n", inv.Code, inv.Notify, gc.GetString("jfId"))
|
|
var addressOrID string
|
|
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
|
|
addressOrID = gc.GetString("jfId")
|
|
} else {
|
|
addressOrID = app.config.Section("ui").Key("email").String()
|
|
}
|
|
if _, ok := inv.Notify[addressOrID]; ok {
|
|
if _, ok = inv.Notify[addressOrID]["notify-expiry"]; ok {
|
|
invite.NotifyExpiry = inv.Notify[addressOrID]["notify-expiry"]
|
|
}
|
|
if _, ok = inv.Notify[addressOrID]["notify-creation"]; ok {
|
|
invite.NotifyCreation = inv.Notify[addressOrID]["notify-creation"]
|
|
}
|
|
}
|
|
}
|
|
invites = append(invites, invite)
|
|
}
|
|
fullProfileList := app.storage.GetProfiles()
|
|
profiles := make([]string, len(fullProfileList))
|
|
if len(profiles) != 0 {
|
|
defaultProfile := app.storage.GetDefaultProfile()
|
|
profiles[0] = defaultProfile.Name
|
|
i := 1
|
|
if len(fullProfileList) > 1 {
|
|
app.storage.db.ForEach(badgerhold.Where("Name").Ne(profiles[0]), func(p *Profile) error {
|
|
profiles[i] = p.Name
|
|
i++
|
|
return nil
|
|
})
|
|
}
|
|
}
|
|
resp := getInvitesDTO{
|
|
Profiles: profiles,
|
|
Invites: invites,
|
|
}
|
|
gc.JSON(200, resp)
|
|
}
|
|
|
|
// @Summary Set profile for an invite
|
|
// @Produce json
|
|
// @Param inviteProfileDTO body inviteProfileDTO true "Invite profile object"
|
|
// @Success 200 {object} boolResponse
|
|
// @Failure 500 {object} stringResponse
|
|
// @Router /invites/profile [post]
|
|
// @Security Bearer
|
|
// @tags Profiles & Settings
|
|
func (app *appContext) SetProfile(gc *gin.Context) {
|
|
var req inviteProfileDTO
|
|
gc.BindJSON(&req)
|
|
app.debug.Printf("%s: Setting profile to \"%s\"", req.Invite, req.Profile)
|
|
// "" means "Don't apply profile"
|
|
if _, ok := app.storage.GetProfileKey(req.Profile); !ok && req.Profile != "" {
|
|
app.err.Printf("%s: Profile \"%s\" not found", req.Invite, req.Profile)
|
|
respond(500, "Profile not found", gc)
|
|
return
|
|
}
|
|
inv, _ := app.storage.GetInvitesKey(req.Invite)
|
|
inv.Profile = req.Profile
|
|
app.storage.SetInvitesKey(req.Invite, inv)
|
|
respondBool(200, true, gc)
|
|
}
|
|
|
|
// @Summary Set notification preferences for an invite.
|
|
// @Produce json
|
|
// @Param setNotifyDTO body setNotifyDTO true "Map of invite codes to notification settings objects"
|
|
// @Success 200
|
|
// @Failure 400 {object} stringResponse
|
|
// @Failure 500 {object} stringResponse
|
|
// @Router /invites/notify [post]
|
|
// @Security Bearer
|
|
// @tags Other
|
|
func (app *appContext) SetNotify(gc *gin.Context) {
|
|
var req map[string]map[string]bool
|
|
gc.BindJSON(&req)
|
|
changed := false
|
|
for code, settings := range req {
|
|
app.debug.Printf("%s: Notification settings change requested", code)
|
|
invite, ok := app.storage.GetInvitesKey(code)
|
|
if !ok {
|
|
app.err.Printf("%s Notification setting change failed: Invalid code", code)
|
|
respond(400, "Invalid invite code", gc)
|
|
return
|
|
}
|
|
var address string
|
|
jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(false)
|
|
if jellyfinLogin {
|
|
var addressAvailable bool = app.getAddressOrName(gc.GetString("jfId")) != ""
|
|
if !addressAvailable {
|
|
app.err.Printf("%s: Couldn't find contact method for admin. Make sure one is set.", code)
|
|
app.debug.Printf("%s: User ID \"%s\"", code, gc.GetString("jfId"))
|
|
respond(500, "Missing user contact method", gc)
|
|
return
|
|
}
|
|
address = gc.GetString("jfId")
|
|
} else {
|
|
address = app.config.Section("ui").Key("email").String()
|
|
}
|
|
if invite.Notify == nil {
|
|
invite.Notify = map[string]map[string]bool{}
|
|
}
|
|
if _, ok := invite.Notify[address]; !ok {
|
|
invite.Notify[address] = map[string]bool{}
|
|
} /*else {
|
|
if _, ok := invite.Notify[address]["notify-expiry"]; !ok {
|
|
*/
|
|
if _, ok := settings["notify-expiry"]; ok && invite.Notify[address]["notify-expiry"] != settings["notify-expiry"] {
|
|
invite.Notify[address]["notify-expiry"] = settings["notify-expiry"]
|
|
app.debug.Printf("%s: Set \"notify-expiry\" to %t for %s", code, settings["notify-expiry"], address)
|
|
changed = true
|
|
}
|
|
if _, ok := settings["notify-creation"]; ok && invite.Notify[address]["notify-creation"] != settings["notify-creation"] {
|
|
invite.Notify[address]["notify-creation"] = settings["notify-creation"]
|
|
app.debug.Printf("%s: Set \"notify-creation\" to %t for %s", code, settings["notify-creation"], address)
|
|
changed = true
|
|
}
|
|
if changed {
|
|
app.storage.SetInvitesKey(code, invite)
|
|
}
|
|
}
|
|
}
|
|
|
|
// @Summary Delete an invite.
|
|
// @Produce json
|
|
// @Param deleteInviteDTO body deleteInviteDTO true "Delete invite object"
|
|
// @Success 200 {object} boolResponse
|
|
// @Failure 400 {object} stringResponse
|
|
// @Router /invites [delete]
|
|
// @Security Bearer
|
|
// @tags Invites
|
|
func (app *appContext) DeleteInvite(gc *gin.Context) {
|
|
var req deleteInviteDTO
|
|
gc.BindJSON(&req)
|
|
app.debug.Printf("%s: Deletion requested", req.Code)
|
|
inv, ok := app.storage.GetInvitesKey(req.Code)
|
|
if ok {
|
|
app.storage.DeleteInvitesKey(req.Code)
|
|
|
|
// Record activity
|
|
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
|
Type: ActivityDeleteInvite,
|
|
SourceType: ActivityAdmin,
|
|
Source: gc.GetString("jfId"),
|
|
InviteCode: req.Code,
|
|
Value: inv.Label,
|
|
Time: time.Now(),
|
|
})
|
|
|
|
app.info.Printf("%s: Invite deleted", req.Code)
|
|
respondBool(200, true, gc)
|
|
return
|
|
}
|
|
app.err.Printf("%s: Deletion failed: Invalid code", req.Code)
|
|
respond(400, "Code doesn't exist", gc)
|
|
}
|