package main import ( "fmt" "strconv" "strings" "sync" "time" "github.com/gin-gonic/gin" lm "github.com/hrfee/jfa-go/logmessages" "github.com/itchyny/timefmt-go" "github.com/lithammer/shortuuid/v3" ) 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 } // checkInvites performs general housekeeping on invites, i.e. deleting expired ones and cleaning captcha data. 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.deleteExpiredInvite(data) } } // checkInvite checks the validity of a specific invite, optionally removing it if invalid(ated). 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.deleteExpiredInvite(inv) match = false } 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(), }, nil, false) } 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 } func (app *appContext) deleteExpiredInvite(data Invite) { app.debug.Printf(lm.DeleteOldInvite, 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) } } wait := app.sendAdminExpiryNotification(data) app.storage.DeleteInvitesKey(data.Code) app.storage.SetActivityKey(shortuuid.New(), Activity{ Type: ActivityDeleteInvite, SourceType: ActivityDaemon, InviteCode: data.Code, Value: data.Label, Time: time.Now(), }, nil, false) if wait != nil { wait.Wait() } } func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup { notify := data.Notify if !emailEnabled || !app.config.Section("notifications").Key("enabled").MustBool(false) || len(notify) != 0 { return nil } 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(lm.FailedConstructExpiryAdmin, data.Code, err) } else { // Check whether notify "address" is an email address or Jellyfin ID if strings.Contains(addr, "@") { err = app.email.send(msg, addr) } else { err = app.sendByID(msg, addr) } if err != nil { app.err.Printf(lm.FailedSendExpiryAdmin, data.Code, addr, err) } else { app.info.Printf(lm.SentExpiryAdmin, data.Code, addr) } } }(address) } return &wait } // @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(lm.GenerateInvite) 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 := "" if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) { users := app.discord.GetUsers(req.SendTo) if len(users) == 0 { invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipNoUser, req.SendTo) } else if len(users) > 1 { invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipMultiUser, 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 { // Slight misuse of the template invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err) app.err.Printf(lm.FailedConstructInviteMessage, 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(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err) app.err.Println(invite.SendTo) } else { app.info.Printf(lm.SentInviteMessage, 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(), }, gc, false) 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) { currentTime := time.Now() app.checkInvites() var invites []inviteDTO for _, inv := range app.storage.GetInvites() { if inv.IsReferral { continue } years, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime) months += years * 12 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(lm.FailedParseTime, 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 { 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) } resp := getInvitesDTO{ 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 Invites func (app *appContext) SetProfile(gc *gin.Context) { var req inviteProfileDTO gc.BindJSON(&req) // "" means "Don't apply profile" if _, ok := app.storage.GetProfileKey(req.Profile); !ok && req.Profile != "" { app.err.Printf(lm.FailedGetProfile, 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 { invite, ok := app.storage.GetInvitesKey(code) if !ok { msg := fmt.Sprintf(lm.InvalidInviteCode, code) app.err.Println(msg) respond(400, msg, 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(lm.FailedGetContactMethod, gc.GetString("jfId")) respond(500, fmt.Sprintf(lm.FailedGetContactMethod, "admin"), 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 { */ for _, notifyType := range []string{"notify-expiry", "notify-creation"} { if _, ok := settings[notifyType]; ok && invite.Notify[address][notifyType] != settings[notifyType] { invite.Notify[address][notifyType] = settings[notifyType] app.debug.Printf(lm.SetAdminNotify, notifyType, settings[notifyType], 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) 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(), }, gc, false) app.info.Printf(lm.DeleteInvite, req.Code) respondBool(200, true, gc) return } app.err.Printf(lm.FailedDeleteInvite, req.Code, "invalid code") respond(400, "Code doesn't exist", gc) }