mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-11-12 21:30:10 +00:00
Harvey Tindall
7c76b58ab8
Adds a "User Label" invite field, which is a label applied to users created with it. This commit contains everything apart from the code to apply it on account creation.
437 lines
13 KiB
Go
437 lines
13 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
|
|
)
|
|
|
|
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 {
|
|
continue
|
|
}
|
|
expiry := data.ValidTill
|
|
if !currentTime.After(expiry) {
|
|
continue
|
|
}
|
|
app.debug.Printf("Housekeeping: Deleting old invite %s", data.Code)
|
|
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)
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
match = false
|
|
app.storage.DeleteInvitesKey(code)
|
|
} else if used {
|
|
del := false
|
|
newInv := inv
|
|
if newInv.RemainingUses == 1 {
|
|
del = true
|
|
app.storage.DeleteInvitesKey(code)
|
|
} 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))
|
|
// 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]))
|
|
}
|
|
var invite Invite
|
|
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", inviteCode)
|
|
if discordEnabled && !strings.Contains(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(inviteCode, 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", inviteCode, 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", inviteCode, invite.SendTo, err)
|
|
} else {
|
|
app.info.Printf("%s: Sent invite email to \"%s\"", inviteCode, req.SendTo)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if req.Profile != "" {
|
|
if _, ok := app.storage.GetProfileKey(req.Profile); ok {
|
|
invite.Profile = req.Profile
|
|
} else {
|
|
invite.Profile = "Default"
|
|
}
|
|
}
|
|
app.storage.SetInvitesKey(inviteCode, invite)
|
|
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)
|
|
var ok bool
|
|
_, ok = app.storage.GetInvitesKey(req.Code)
|
|
if ok {
|
|
app.storage.DeleteInvitesKey(req.Code)
|
|
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)
|
|
}
|