package main import ( "fmt" "os" "strconv" "strings" "sync" "time" "github.com/dgrijalva/jwt-go" "github.com/gin-gonic/gin" "github.com/hrfee/mediabrowser" "github.com/itchyny/timefmt-go" "github.com/lithammer/shortuuid/v3" "gopkg.in/ini.v1" ) func respond(code int, message string, gc *gin.Context) { resp := stringResponse{} if code == 200 || code == 204 { resp.Response = message } else { resp.Error = message } gc.JSON(code, resp) gc.Abort() } func respondBool(code int, val bool, gc *gin.Context) { resp := boolResponse{} if !val { resp.Error = true } else { resp.Success = true } gc.JSON(code, resp) gc.Abort() } func (app *appContext) loadStrftime() { app.datePattern = app.config.Section("messages").Key("date_format").String() app.timePattern = `%H:%M` if val, _ := app.config.Section("messages").Key("use_24h").Bool(); !val { app.timePattern = `%I:%M %p` } return } func (app *appContext) prettyTime(dt time.Time) (date, time string) { date = timefmt.Format(dt, app.datePattern) time = timefmt.Format(dt, app.timePattern) return } func (app *appContext) formatDatetime(dt time.Time) string { d, t := app.prettyTime(dt) return d + " " + t } // https://stackoverflow.com/questions/36530251/time-since-with-months-and-years/36531443#36531443 THANKS func timeDiff(a, b time.Time) (year, month, day, hour, min, sec int) { if a.Location() != b.Location() { b = b.In(a.Location()) } if a.After(b) { a, b = b, a } y1, M1, d1 := a.Date() y2, M2, d2 := b.Date() h1, m1, s1 := a.Clock() h2, m2, s2 := b.Clock() year = int(y2 - y1) month = int(M2 - M1) day = int(d2 - d1) hour = int(h2 - h1) min = int(m2 - m1) sec = int(s2 - s1) // Normalize negative values if sec < 0 { sec += 60 min-- } if min < 0 { min += 60 hour-- } if hour < 0 { hour += 24 day-- } if day < 0 { // days in month: t := time.Date(y1, M1, 32, 0, 0, 0, 0, time.UTC) day += 32 - t.Day() month-- } if month < 0 { month += 12 year-- } return } func (app *appContext) checkInvites() { currentTime := time.Now() app.storage.loadInvites() changed := false for code, data := range app.storage.invites { expiry := data.ValidTill if !currentTime.After(expiry) { continue } app.debug.Printf("Housekeeping: Deleting old invite %s", code) notify := data.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, data, app, false) if err != nil { app.err.Printf("%s: Failed to construct expiry notification: %v", code, err) } else if err := app.email.send(msg, addr); 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() } changed = true delete(app.storage.invites, code) } if changed { app.storage.storeInvites() } } func (app *appContext) checkInvite(code string, used bool, username string) bool { currentTime := time.Now() app.storage.loadInvites() changed := false inv, match := app.storage.invites[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 if err := app.email.send(msg, addr); 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() } changed = true match = false delete(app.storage.invites, code) } else if used { changed = true del := false newInv := inv if newInv.RemainingUses == 1 { del = true delete(app.storage.invites, 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.invites[code] = newInv } } if changed { app.storage.storeInvites() } return match } func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, error) { ombiUsers, code, err := app.ombi.GetUsers() if err != nil || code != 200 { return nil, code, err } jfUser, code, err := app.jf.UserByID(jfID, false) if err != nil || code != 200 { return nil, code, err } username := jfUser.Name email := "" if e, ok := app.storage.emails[jfID]; ok { email = e.Addr } for _, ombiUser := range ombiUsers { ombiAddr := "" if a, ok := ombiUser["emailAddress"]; ok && a != nil { ombiAddr = a.(string) } if ombiUser["userName"].(string) == username || (ombiAddr == email && email != "") { return ombiUser, code, err } } return nil, 400, fmt.Errorf("Couldn't find user") } // Routes from now on! // @Summary Creates a new Jellyfin user without an invite. // @Produce json // @Param newUserDTO body newUserDTO true "New user request object" // @Success 200 // @Router /users [post] // @Security Bearer // @tags Users func (app *appContext) NewUserAdmin(gc *gin.Context) { respondUser := func(code int, user, email bool, msg string, gc *gin.Context) { resp := newUserResponse{ User: user, Email: email, Error: msg, } gc.JSON(code, resp) gc.Abort() } var req newUserDTO gc.BindJSON(&req) existingUser, _, _ := app.jf.UserByName(req.Username, false) if existingUser.Name != "" { msg := fmt.Sprintf("User already exists named %s", req.Username) app.info.Printf("%s New user failed: %s", req.Username, msg) respondUser(401, false, false, msg, gc) return } user, status, err := app.jf.NewUser(req.Username, req.Password) if !(status == 200 || status == 204) || err != nil { app.err.Printf("%s New user failed (%d): %v", req.Username, status, err) respondUser(401, false, false, err.Error(), gc) return } id := user.ID if app.storage.policy.BlockedTags != nil { status, err = app.jf.SetPolicy(id, app.storage.policy) if !(status == 200 || status == 204 || err == nil) { app.err.Printf("%s: Failed to set user policy (%d): %v", req.Username, status, err) } } if app.storage.configuration.GroupedFolders != nil && len(app.storage.displayprefs) != 0 { status, err = app.jf.SetConfiguration(id, app.storage.configuration) if (status == 200 || status == 204) && err == nil { status, err = app.jf.SetDisplayPreferences(id, app.storage.displayprefs) } if !((status == 200 || status == 204) && err == nil) { app.err.Printf("%s: Failed to set configuration template (%d): %v", req.Username, status, err) } } app.jf.CacheExpiry = time.Now() if emailEnabled { app.storage.emails[id] = EmailAddress{Addr: req.Email, Contact: true} app.storage.storeEmails() } if app.config.Section("ombi").Key("enabled").MustBool(false) { app.storage.loadOmbiTemplate() if len(app.storage.ombi_template) != 0 { errors, code, err := app.ombi.NewUser(req.Username, req.Password, req.Email, app.storage.ombi_template) if err != nil || code != 200 { app.err.Printf("Failed to create Ombi user (%d): %v", code, err) app.debug.Printf("Errors reported by Ombi: %s", strings.Join(errors, ", ")) } else { app.info.Println("Created Ombi user") } } } if emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "" { app.debug.Printf("%s: Sending welcome email to %s", req.Username, req.Email) msg, err := app.email.constructWelcome(req.Username, time.Time{}, app, false) if err != nil { app.err.Printf("%s: Failed to construct welcome email: %v", req.Username, err) respondUser(500, true, false, err.Error(), gc) return } else if err := app.email.send(msg, req.Email); err != nil { app.err.Printf("%s: Failed to send welcome email: %v", req.Username, err) respondUser(500, true, false, err.Error(), gc) return } else { app.info.Printf("%s: Sent welcome email to %s", req.Username, req.Email) } } respondUser(200, true, true, "", gc) } type errorFunc func(gc *gin.Context) // Used on the form & when a users email has been confirmed. func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, success bool) { existingUser, _, _ := app.jf.UserByName(req.Username, false) if existingUser.Name != "" { f = func(gc *gin.Context) { msg := fmt.Sprintf("User %s already exists", req.Username) app.info.Printf("%s: New user failed: %s", req.Code, msg) respond(401, "errorUserExists", gc) } success = false return } var discordUser DiscordUser discordVerified := false if discordEnabled { if req.DiscordPIN == "" { if app.config.Section("discord").Key("required").MustBool(false) { f = func(gc *gin.Context) { app.debug.Printf("%s: New user failed: Discord verification not completed", req.Code) respond(401, "errorDiscordVerification", gc) } success = false return } } else { discordUser, discordVerified = app.discord.verifiedTokens[req.DiscordPIN] if !discordVerified { f = func(gc *gin.Context) { app.debug.Printf("%s: New user failed: Discord PIN was invalid", req.Code) respond(401, "errorInvalidPIN", gc) } success = false return } } } telegramTokenIndex := -1 if telegramEnabled { if req.TelegramPIN == "" { if app.config.Section("telegram").Key("required").MustBool(false) { f = func(gc *gin.Context) { app.debug.Printf("%s: New user failed: Telegram verification not completed", req.Code) respond(401, "errorTelegramVerification", gc) } success = false return } } else { for i, v := range app.telegram.verifiedTokens { if v.Token == req.TelegramPIN { telegramTokenIndex = i break } } if telegramTokenIndex == -1 { f = func(gc *gin.Context) { app.debug.Printf("%s: New user failed: Telegram PIN was invalid", req.Code) respond(401, "errorInvalidPIN", gc) } success = false return } } } if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) && !confirmed { claims := jwt.MapClaims{ "valid": true, "invite": req.Code, "email": req.Email, "username": req.Username, "password": req.Password, "telegramPIN": req.TelegramPIN, "exp": strconv.FormatInt(time.Now().Add(time.Hour*12).Unix(), 10), "type": "confirmation", } tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET"))) if err != nil { f = func(gc *gin.Context) { app.info.Printf("Failed to generate confirmation token: %v", err) respond(500, "errorUnknown", gc) } success = false return } inv := app.storage.invites[req.Code] inv.Keys = append(inv.Keys, key) app.storage.invites[req.Code] = inv app.storage.storeInvites() f = func(gc *gin.Context) { app.debug.Printf("%s: Email confirmation required", req.Code) respond(401, "confirmEmail", gc) msg, err := app.email.constructConfirmation(req.Code, req.Username, key, app, false) if err != nil { app.err.Printf("%s: Failed to construct confirmation email: %v", req.Code, err) } else if err := app.email.send(msg, req.Email); err != nil { app.err.Printf("%s: Failed to send user confirmation email: %v", req.Code, err) } else { app.info.Printf("%s: Sent user confirmation email to \"%s\"", req.Code, req.Email) } } success = false return } user, status, err := app.jf.NewUser(req.Username, req.Password) if !(status == 200 || status == 204) || err != nil { f = func(gc *gin.Context) { app.err.Printf("%s New user failed (%d): %v", req.Code, status, err) respond(401, app.storage.lang.Admin[app.storage.lang.chosenAdminLang].Notifications.get("errorUnknown"), gc) } success = false return } app.storage.loadProfiles() invite := app.storage.invites[req.Code] app.checkInvite(req.Code, true, req.Username) if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) { for address, settings := range invite.Notify { if settings["notify-creation"] { go func() { msg, err := app.email.constructCreated(req.Code, req.Username, req.Email, invite, app, false) if err != nil { app.err.Printf("%s: Failed to construct user creation notification: %v", req.Code, err) } else if err := app.email.send(msg, address); err != nil { app.err.Printf("%s: Failed to send user creation notification: %v", req.Code, err) } else { app.info.Printf("%s: Sent user creation notification to %v", req.Code, address) } }() } } } id := user.ID if invite.Profile != "" { app.debug.Printf("Applying settings from profile \"%s\"", invite.Profile) profile, ok := app.storage.profiles[invite.Profile] if !ok { profile = app.storage.profiles["Default"] } if profile.Policy.BlockedTags != nil { app.debug.Printf("Applying policy from profile \"%s\"", invite.Profile) status, err = app.jf.SetPolicy(id, profile.Policy) if !((status == 200 || status == 204) && err == nil) { app.err.Printf("%s: Failed to set user policy (%d): %v", req.Code, status, err) } } if profile.Configuration.GroupedFolders != nil && len(profile.Displayprefs) != 0 { app.debug.Printf("Applying homescreen from profile \"%s\"", invite.Profile) status, err = app.jf.SetConfiguration(id, profile.Configuration) if (status == 200 || status == 204) && err == nil { status, err = app.jf.SetDisplayPreferences(id, profile.Displayprefs) } if !((status == 200 || status == 204) && err == nil) { app.err.Printf("%s: Failed to set configuration template (%d): %v", req.Code, status, err) } } } // if app.config.Section("password_resets").Key("enabled").MustBool(false) { if req.Email != "" { app.storage.emails[id] = EmailAddress{Addr: req.Email, Contact: true} app.storage.storeEmails() } if app.config.Section("ombi").Key("enabled").MustBool(false) { app.storage.loadOmbiTemplate() if len(app.storage.ombi_template) != 0 { errors, code, err := app.ombi.NewUser(req.Username, req.Password, req.Email, app.storage.ombi_template) if err != nil || code != 200 { app.info.Printf("Failed to create Ombi user (%d): %s", code, err) app.debug.Printf("Errors reported by Ombi: %s", strings.Join(errors, ", ")) } else { app.info.Println("Created Ombi user") } } } expiry := time.Time{} if invite.UserExpiry { app.storage.usersLock.Lock() defer app.storage.usersLock.Unlock() expiry = time.Now().AddDate(0, invite.UserMonths, invite.UserDays).Add(time.Duration((60*invite.UserHours)+invite.UserMinutes) * time.Minute) app.storage.users[id] = expiry if err := app.storage.storeUsers(); err != nil { app.err.Printf("Failed to store user duration: %v", err) } } if discordEnabled && discordVerified { discordUser.Contact = req.DiscordContact if app.storage.discord == nil { app.storage.discord = map[string]DiscordUser{} } app.storage.discord[user.ID] = discordUser if err := app.storage.storeDiscordUsers(); err != nil { app.err.Printf("Failed to store Discord users: %v", err) } else { delete(app.discord.verifiedTokens, req.DiscordPIN) } } if telegramEnabled && telegramTokenIndex != -1 { tgToken := app.telegram.verifiedTokens[telegramTokenIndex] tgUser := TelegramUser{ ChatID: tgToken.ChatID, Username: tgToken.Username, Contact: req.TelegramContact, } if lang, ok := app.telegram.languages[tgToken.ChatID]; ok { tgUser.Lang = lang } if app.storage.telegram == nil { app.storage.telegram = map[string]TelegramUser{} } app.storage.telegram[user.ID] = tgUser if err := app.storage.storeTelegramUsers(); err != nil { app.err.Printf("Failed to store Telegram users: %v", err) } else { app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1], app.telegram.verifiedTokens[telegramTokenIndex] = app.telegram.verifiedTokens[telegramTokenIndex], app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1] app.telegram.verifiedTokens = app.telegram.verifiedTokens[:len(app.telegram.verifiedTokens)-1] } } if (emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "") || telegramTokenIndex != -1 || discordVerified { name := app.getAddressOrName(user.ID) app.debug.Printf("%s: Sending welcome message to %s", req.Username, name) msg, err := app.email.constructWelcome(req.Username, expiry, app, false) if err != nil { app.err.Printf("%s: Failed to construct welcome message: %v", req.Username, err) } else if err := app.sendByID(msg, user.ID); err != nil { app.err.Printf("%s: Failed to send welcome message: %v", req.Username, err) } else { app.info.Printf("%s: Sent welcome message to \"%s\"", req.Username, name) } } app.jf.CacheExpiry = time.Now() success = true return } // @Summary Creates a new Jellyfin user via invite code // @Produce json // @Param newUserDTO body newUserDTO true "New user request object" // @Success 200 {object} PasswordValidation // @Failure 400 {object} PasswordValidation // @Router /newUser [post] // @tags Users func (app *appContext) NewUser(gc *gin.Context) { var req newUserDTO gc.BindJSON(&req) app.debug.Printf("%s: New user attempt", req.Code) if !app.checkInvite(req.Code, false, "") { app.info.Printf("%s New user failed: invalid code", req.Code) respond(401, "errorInvalidCode", gc) return } validation := app.validator.validate(req.Password) valid := true for _, val := range validation { if !val { valid = false } } if !valid { // 200 bcs idk what i did in js app.info.Printf("%s: New user failed: Invalid password", req.Code) gc.JSON(200, validation) return } f, success := app.newUser(req, false) if !success { f(gc) return } code := 200 for _, val := range validation { if !val { code = 400 } } gc.JSON(code, validation) } // @Summary Enable/Disable a list of users, optionally notifying them why. // @Produce json // @Param enableDisableUserDTO body enableDisableUserDTO true "User enable/disable request object" // @Success 200 {object} boolResponse // @Failure 400 {object} stringResponse // @Failure 500 {object} errorListDTO "List of errors" // @Router /users/enable [post] // @Security Bearer // @tags Users func (app *appContext) EnableDisableUsers(gc *gin.Context) { var req enableDisableUserDTO gc.BindJSON(&req) errors := errorListDTO{ "GetUser": map[string]string{}, "SetPolicy": map[string]string{}, } sendMail := messagesEnabled var msg *Message var err error if sendMail { if req.Enabled { msg, err = app.email.constructEnabled(req.Reason, app, false) } else { msg, err = app.email.constructDisabled(req.Reason, app, false) } if err != nil { app.err.Printf("Failed to construct account enabled/disabled emails: %v", err) sendMail = false } } for _, userID := range req.Users { user, status, err := app.jf.UserByID(userID, false) if status != 200 || err != nil { errors["GetUser"][userID] = fmt.Sprintf("%d %v", status, err) app.err.Printf("Failed to get user \"%s\" (%d): %v", userID, status, err) continue } user.Policy.IsDisabled = !req.Enabled status, err = app.jf.SetPolicy(userID, user.Policy) if !(status == 200 || status == 204) || err != nil { errors["SetPolicy"][userID] = fmt.Sprintf("%d %v", status, err) app.err.Printf("Failed to set policy for user \"%s\" (%d): %v", userID, status, err) continue } if sendMail && req.Notify { if err := app.sendByID(msg, userID); err != nil { app.err.Printf("Failed to send account enabled/disabled email: %v", err) continue } } } app.jf.CacheExpiry = time.Now() if len(errors["GetUser"]) != 0 || len(errors["SetPolicy"]) != 0 { gc.JSON(500, errors) return } respondBool(200, true, gc) } // @Summary Delete a list of users, optionally notifying them why. // @Produce json // @Param deleteUserDTO body deleteUserDTO true "User deletion request object" // @Success 200 {object} boolResponse // @Failure 400 {object} stringResponse // @Failure 500 {object} errorListDTO "List of errors" // @Router /users [delete] // @Security Bearer // @tags Users func (app *appContext) DeleteUsers(gc *gin.Context) { var req deleteUserDTO gc.BindJSON(&req) errors := map[string]string{} ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false) sendMail := messagesEnabled var msg *Message var err error if sendMail { msg, err = app.email.constructDeleted(req.Reason, app, false) if err != nil { app.err.Printf("Failed to construct account deletion emails: %v", err) sendMail = false } } for _, userID := range req.Users { if ombiEnabled { ombiUser, code, err := app.getOmbiUser(userID) if code == 200 && err == nil { if id, ok := ombiUser["id"]; ok { status, err := app.ombi.DeleteUser(id.(string)) if err != nil || status != 200 { app.err.Printf("Failed to delete ombi user (%d): %v", status, err) errors[userID] = fmt.Sprintf("Ombi: %d %v, ", status, err) } } } } status, err := app.jf.DeleteUser(userID) if !(status == 200 || status == 204) || err != nil { msg := fmt.Sprintf("%d: %v", status, err) if _, ok := errors[userID]; !ok { errors[userID] = msg } else { errors[userID] += msg } } if sendMail && req.Notify { if err := app.sendByID(msg, userID); err != nil { app.err.Printf("Failed to send account deletion email: %v", err) } } } app.jf.CacheExpiry = time.Now() if len(errors) == len(req.Users) { respondBool(500, false, gc) app.err.Printf("Account deletion failed: %s", errors[req.Users[0]]) return } else if len(errors) != 0 { gc.JSON(500, errors) return } respondBool(200, true, gc) } // @Summary Extend time before the user(s) expiry. // @Produce json // @Param extendExpiryDTO body extendExpiryDTO true "Extend expiry object" // @Success 200 {object} boolResponse // @Failure 400 {object} boolResponse // @Failure 500 {object} boolResponse // @Router /users/extend [post] // @tags Users func (app *appContext) ExtendExpiry(gc *gin.Context) { var req extendExpiryDTO gc.BindJSON(&req) app.info.Printf("Expiry extension requested for %d user(s)", len(req.Users)) if req.Months <= 0 && req.Days <= 0 && req.Hours <= 0 && req.Minutes <= 0 { respondBool(400, false, gc) return } app.storage.usersLock.Lock() defer app.storage.usersLock.Unlock() for _, id := range req.Users { if expiry, ok := app.storage.users[id]; ok { app.storage.users[id] = expiry.AddDate(0, req.Months, req.Days).Add(time.Duration(((60 * req.Hours) + req.Minutes)) * time.Minute) app.debug.Printf("Expiry extended for \"%s\"", id) } } if err := app.storage.storeUsers(); err != nil { app.err.Printf("Failed to store user duration: %v", err) respondBool(500, false, gc) return } respondBool(204, true, gc) } // @Summary Send an announcement via email to a given list of users. // @Produce json // @Param announcementDTO body announcementDTO true "Announcement request object" // @Success 200 {object} boolResponse // @Failure 400 {object} boolResponse // @Failure 500 {object} boolResponse // @Router /users/announce [post] // @Security Bearer // @tags Users func (app *appContext) Announce(gc *gin.Context) { var req announcementDTO gc.BindJSON(&req) if !messagesEnabled { respondBool(400, false, gc) return } msg, err := app.email.constructTemplate(req.Subject, req.Message, app) if err != nil { app.err.Printf("Failed to construct announcement messages: %v", err) respondBool(500, false, gc) return } else if err := app.sendByID(msg, req.Users...); err != nil { app.err.Printf("Failed to send announcement messages: %v", err) respondBool(500, false, gc) return } app.info.Println("Sent announcement messages") respondBool(200, true, gc) } // @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") app.storage.loadInvites() 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 } 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.profiles[req.Profile]; ok { invite.Profile = req.Profile } else { invite.Profile = "Default" } } app.storage.invites[inviteCode] = invite app.storage.storeInvites() 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.storage.loadInvites() app.checkInvites() var invites []inviteDTO for code, inv := range app.storage.invites { _, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime) invite := inviteDTO{ Code: 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, } 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 { var address string if app.config.Section("ui").Key("jellyfin_login").MustBool(false) { app.storage.loadEmails() if addr, ok := app.storage.emails[gc.GetString("jfId")]; ok && addr.Addr != "" { address = addr.Addr } } else { address = app.config.Section("ui").Key("email").String() } if _, ok := inv.Notify[address]; ok { if _, ok = inv.Notify[address]["notify-expiry"]; ok { invite.NotifyExpiry = inv.Notify[address]["notify-expiry"] } if _, ok = inv.Notify[address]["notify-creation"]; ok { invite.NotifyCreation = inv.Notify[address]["notify-creation"] } } } invites = append(invites, invite) } profiles := make([]string, len(app.storage.profiles)) if len(app.storage.profiles) != 0 { profiles[0] = app.storage.defaultProfile i := 1 if len(app.storage.profiles) > 1 { for p := range app.storage.profiles { if p != app.storage.defaultProfile { profiles[i] = p i++ } } } } 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.profiles[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.invites[req.Invite] inv.Profile = req.Profile app.storage.invites[req.Invite] = inv app.storage.storeInvites() respondBool(200, true, gc) } // @Summary Get a list of profiles // @Produce json // @Success 200 {object} getProfilesDTO // @Router /profiles [get] // @Security Bearer // @tags Profiles & Settings func (app *appContext) GetProfiles(gc *gin.Context) { app.storage.loadProfiles() app.debug.Println("Profiles requested") out := getProfilesDTO{ DefaultProfile: app.storage.defaultProfile, Profiles: map[string]profileDTO{}, } for name, p := range app.storage.profiles { out.Profiles[name] = profileDTO{ Admin: p.Admin, LibraryAccess: p.LibraryAccess, FromUser: p.FromUser, } } gc.JSON(200, out) } // @Summary Set the default profile to use. // @Produce json // @Param profileChangeDTO body profileChangeDTO true "Default profile object" // @Success 200 {object} boolResponse // @Failure 500 {object} stringResponse // @Router /profiles/default [post] // @Security Bearer // @tags Profiles & Settings func (app *appContext) SetDefaultProfile(gc *gin.Context) { req := profileChangeDTO{} gc.BindJSON(&req) app.info.Printf("Setting default profile to \"%s\"", req.Name) if _, ok := app.storage.profiles[req.Name]; !ok { app.err.Printf("Profile not found: \"%s\"", req.Name) respond(500, "Profile not found", gc) return } for name, profile := range app.storage.profiles { if name == req.Name { profile.Admin = true app.storage.profiles[name] = profile } else { profile.Admin = false } } app.storage.defaultProfile = req.Name respondBool(200, true, gc) } // @Summary Create a profile based on a Jellyfin user's settings. // @Produce json // @Param newProfileDTO body newProfileDTO true "New profile object" // @Success 200 {object} boolResponse // @Failure 500 {object} stringResponse // @Router /profiles [post] // @Security Bearer // @tags Profiles & Settings func (app *appContext) CreateProfile(gc *gin.Context) { app.info.Println("Profile creation requested") var req newProfileDTO gc.BindJSON(&req) app.jf.CacheExpiry = time.Now() user, status, err := app.jf.UserByID(req.ID, false) if !(status == 200 || status == 204) || err != nil { app.err.Printf("Failed to get user from Jellyfin (%d): %v", status, err) respond(500, "Couldn't get user", gc) return } profile := Profile{ FromUser: user.Name, Policy: user.Policy, } app.debug.Printf("Creating profile from user \"%s\"", user.Name) if req.Homescreen { profile.Configuration = user.Configuration profile.Displayprefs, status, err = app.jf.GetDisplayPreferences(req.ID) if !(status == 200 || status == 204) || err != nil { app.err.Printf("Failed to get DisplayPrefs (%d): %v", status, err) respond(500, "Couldn't get displayprefs", gc) return } } app.storage.loadProfiles() app.storage.profiles[req.Name] = profile app.storage.storeProfiles() app.storage.loadProfiles() respondBool(200, true, gc) } // @Summary Delete an existing profile // @Produce json // @Param profileChangeDTO body profileChangeDTO true "Delete profile object" // @Success 200 {object} boolResponse // @Router /profiles [delete] // @Security Bearer // @tags Profiles & Settings func (app *appContext) DeleteProfile(gc *gin.Context) { req := profileChangeDTO{} gc.BindJSON(&req) name := req.Name if _, ok := app.storage.profiles[name]; ok { if app.storage.defaultProfile == name { app.storage.defaultProfile = "" } delete(app.storage.profiles, name) } app.storage.storeProfiles() 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) app.storage.loadInvites() app.storage.loadEmails() invite, ok := app.storage.invites[code] if !ok { app.err.Printf("%s Notification setting change failed: Invalid code", code) respond(400, "Invalid invite code", gc) return } var address string if app.config.Section("ui").Key("jellyfin_login").MustBool(false) { addr, ok := app.storage.emails[gc.GetString("jfId")] if !ok { app.err.Printf("%s: Couldn't find email address. Make sure it's set", code) app.debug.Printf("%s: User ID \"%s\"", code, gc.GetString("jfId")) respond(500, "Missing user email", gc) return } address = addr.Addr } 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.invites[code] = invite } } if changed { app.storage.storeInvites() } } // @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.invites[req.Code] if ok { delete(app.storage.invites, req.Code) app.storage.storeInvites() 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) } // @Summary Get a list of Jellyfin users. // @Produce json // @Success 200 {object} getUsersDTO // @Failure 500 {object} stringResponse // @Router /users [get] // @Security Bearer // @tags Users func (app *appContext) GetUsers(gc *gin.Context) { app.debug.Println("Users requested") var resp getUsersDTO users, status, err := app.jf.GetUsers(false) resp.UserList = make([]respUser, len(users)) if !(status == 200 || status == 204) || err != nil { app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err) respond(500, "Couldn't get users", gc) return } i := 0 app.storage.usersLock.Lock() defer app.storage.usersLock.Unlock() for _, jfUser := range users { user := respUser{ ID: jfUser.ID, Name: jfUser.Name, Admin: jfUser.Policy.IsAdministrator, Disabled: jfUser.Policy.IsDisabled, } if !jfUser.LastActivityDate.IsZero() { user.LastActive = jfUser.LastActivityDate.Unix() } if email, ok := app.storage.emails[jfUser.ID]; ok { user.Email = email.Addr user.NotifyThroughEmail = email.Contact } expiry, ok := app.storage.users[jfUser.ID] if ok { user.Expiry = expiry.Unix() } if tgUser, ok := app.storage.telegram[jfUser.ID]; ok { user.Telegram = tgUser.Username user.NotifyThroughTelegram = tgUser.Contact } if dc, ok := app.storage.discord[jfUser.ID]; ok { user.Discord = dc.Username + "#" + dc.Discriminator user.DiscordID = dc.ID user.NotifyThroughDiscord = dc.Contact } resp.UserList[i] = user i++ } gc.JSON(200, resp) } // @Summary Get a list of Ombi users. // @Produce json // @Success 200 {object} ombiUsersDTO // @Failure 500 {object} stringResponse // @Router /ombi/users [get] // @Security Bearer // @tags Ombi func (app *appContext) OmbiUsers(gc *gin.Context) { app.debug.Println("Ombi users requested") users, status, err := app.ombi.GetUsers() if err != nil || status != 200 { app.err.Printf("Failed to get users from Ombi (%d): %v", status, err) respond(500, "Couldn't get users", gc) return } userlist := make([]ombiUser, len(users)) for i, data := range users { userlist[i] = ombiUser{ Name: data["userName"].(string), ID: data["id"].(string), } } gc.JSON(200, ombiUsersDTO{Users: userlist}) } // @Summary Set new user defaults for Ombi accounts. // @Produce json // @Param ombiUser body ombiUser true "User to source settings from" // @Success 200 {object} boolResponse // @Failure 500 {object} stringResponse // @Router /ombi/defaults [post] // @Security Bearer // @tags Ombi func (app *appContext) SetOmbiDefaults(gc *gin.Context) { var req ombiUser gc.BindJSON(&req) template, code, err := app.ombi.TemplateByID(req.ID) if err != nil || code != 200 || len(template) == 0 { app.err.Printf("Couldn't get user from Ombi (%d): %v", code, err) respond(500, "Couldn't get user", gc) return } app.storage.ombi_template = template app.storage.storeOmbiTemplate() respondBool(200, true, gc) } // @Summary Modify user's email addresses. // @Produce json // @Param modifyEmailsDTO body modifyEmailsDTO true "Map of userIDs to email addresses" // @Success 200 {object} boolResponse // @Failure 500 {object} stringResponse // @Router /users/emails [post] // @Security Bearer // @tags Users func (app *appContext) ModifyEmails(gc *gin.Context) { var req modifyEmailsDTO gc.BindJSON(&req) app.debug.Println("Email modification requested") users, status, err := app.jf.GetUsers(false) if !(status == 200 || status == 204) || err != nil { app.err.Printf("Failed to get users from Jellyfin (%d): %v", status, err) respond(500, "Couldn't get users", gc) return } ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false) for _, jfUser := range users { id := jfUser.ID if address, ok := req[id]; ok { contact := true if oldAddr, ok := app.storage.emails[id]; ok { contact = oldAddr.Contact } app.storage.emails[id] = EmailAddress{Addr: address, Contact: contact} if ombiEnabled { ombiUser, code, err := app.getOmbiUser(id) if code == 200 && err == nil { ombiUser["emailAddress"] = address code, err = app.ombi.ModifyUser(ombiUser) if code != 200 || err != nil { app.err.Printf("%s: Failed to change ombi email address (%d): %v", ombiUser["userName"].(string), code, err) } } } } } app.storage.storeEmails() app.info.Println("Email list modified") respondBool(200, true, gc) } // @Summary Apply settings to a list of users, either from a profile or from another user. // @Produce json // @Param userSettingsDTO body userSettingsDTO true "Parameters for applying settings" // @Success 200 {object} errorListDTO // @Failure 500 {object} errorListDTO "Lists of errors that occurred while applying settings" // @Router /users/settings [post] // @Security Bearer // @tags Profiles & Settings func (app *appContext) ApplySettings(gc *gin.Context) { app.info.Println("User settings change requested") var req userSettingsDTO gc.BindJSON(&req) applyingFrom := "profile" var policy mediabrowser.Policy var configuration mediabrowser.Configuration var displayprefs map[string]interface{} if req.From == "profile" { app.storage.loadProfiles() // Check profile exists & isn't empty if _, ok := app.storage.profiles[req.Profile]; !ok || app.storage.profiles[req.Profile].Policy.BlockedTags == nil { app.err.Printf("Couldn't find profile \"%s\" or profile was empty", req.Profile) respond(500, "Couldn't find profile", gc) return } if req.Homescreen { if app.storage.profiles[req.Profile].Configuration.GroupedFolders == nil || len(app.storage.profiles[req.Profile].Displayprefs) == 0 { app.err.Printf("No homescreen saved in profile \"%s\"", req.Profile) respond(500, "No homescreen template available", gc) return } configuration = app.storage.profiles[req.Profile].Configuration displayprefs = app.storage.profiles[req.Profile].Displayprefs } policy = app.storage.profiles[req.Profile].Policy } else if req.From == "user" { applyingFrom = "user" app.jf.CacheExpiry = time.Now() user, status, err := app.jf.UserByID(req.ID, false) if !(status == 200 || status == 204) || err != nil { app.err.Printf("Failed to get user from Jellyfin (%d): %v", status, err) respond(500, "Couldn't get user", gc) return } applyingFrom = "\"" + user.Name + "\"" policy = user.Policy if req.Homescreen { displayprefs, status, err = app.jf.GetDisplayPreferences(req.ID) if !(status == 200 || status == 204) || err != nil { app.err.Printf("Failed to get DisplayPrefs (%d): %v", status, err) respond(500, "Couldn't get displayprefs", gc) return } configuration = user.Configuration } } app.info.Printf("Applying settings to %d user(s) from %s", len(req.ApplyTo), applyingFrom) errors := errorListDTO{ "policy": map[string]string{}, "homescreen": map[string]string{}, } for _, id := range req.ApplyTo { status, err := app.jf.SetPolicy(id, policy) if !(status == 200 || status == 204) || err != nil { errors["policy"][id] = fmt.Sprintf("%d: %s", status, err) } if req.Homescreen { status, err = app.jf.SetConfiguration(id, configuration) errorString := "" if !(status == 200 || status == 204) || err != nil { errorString += fmt.Sprintf("Configuration %d: %s ", status, err) } else { status, err = app.jf.SetDisplayPreferences(id, displayprefs) if !(status == 200 || status == 204) || err != nil { errorString += fmt.Sprintf("Displayprefs %d: %s ", status, err) } } if errorString != "" { errors["homescreen"][id] = errorString } } } code := 200 if len(errors["policy"]) == len(req.ApplyTo) || len(errors["homescreen"]) == len(req.ApplyTo) { code = 500 } gc.JSON(code, errors) } // @Summary Get jfa-go configuration. // @Produce json // @Success 200 {object} settings "Uses the same format as config-base.json" // @Router /config [get] // @Security Bearer // @tags Configuration func (app *appContext) GetConfig(gc *gin.Context) { app.info.Println("Config requested") resp := app.configBase // Load language options formOptions := app.storage.lang.Form.getOptions() fl := resp.Sections["ui"].Settings["language-form"] fl.Options = formOptions fl.Value = app.config.Section("ui").Key("language-form").MustString("en-us") pwrOptions := app.storage.lang.PasswordReset.getOptions() pl := resp.Sections["password_resets"].Settings["language"] pl.Options = pwrOptions pl.Value = app.config.Section("password_resets").Key("language").MustString("en-us") adminOptions := app.storage.lang.Admin.getOptions() al := resp.Sections["ui"].Settings["language-admin"] al.Options = adminOptions al.Value = app.config.Section("ui").Key("language-admin").MustString("en-us") emailOptions := app.storage.lang.Email.getOptions() el := resp.Sections["email"].Settings["language"] el.Options = emailOptions el.Value = app.config.Section("email").Key("language").MustString("en-us") telegramOptions := app.storage.lang.Email.getOptions() tl := resp.Sections["telegram"].Settings["language"] tl.Options = telegramOptions tl.Value = app.config.Section("telegram").Key("language").MustString("en-us") if updater == "" { delete(resp.Sections, "updates") for i, v := range resp.Order { if v == "updates" { resp.Order = append(resp.Order[:i], resp.Order[i+1:]...) break } } } if PLATFORM == "windows" { delete(resp.Sections["smtp"].Settings, "ssl_cert") for i, v := range resp.Sections["smtp"].Order { if v == "ssl_cert" { sect := resp.Sections["smtp"] sect.Order = append(sect.Order[:i], sect.Order[i+1:]...) resp.Sections["smtp"] = sect } } } for sectName, section := range resp.Sections { for settingName, setting := range section.Settings { val := app.config.Section(sectName).Key(settingName) s := resp.Sections[sectName].Settings[settingName] switch setting.Type { case "text", "email", "select", "password": s.Value = val.MustString("") case "number": s.Value = val.MustInt(0) case "bool": s.Value = val.MustBool(false) } resp.Sections[sectName].Settings[settingName] = s } } resp.Sections["ui"].Settings["language-form"] = fl resp.Sections["ui"].Settings["language-admin"] = al resp.Sections["email"].Settings["language"] = el resp.Sections["password_resets"].Settings["language"] = pl resp.Sections["telegram"].Settings["language"] = tl gc.JSON(200, resp) } // @Summary Modify app config. // @Produce json // @Param appConfig body configDTO true "Config split into sections as in config.ini, all values as strings." // @Success 200 {object} boolResponse // @Router /config [post] // @Security Bearer // @tags Configuration func (app *appContext) ModifyConfig(gc *gin.Context) { app.info.Println("Config modification requested") var req configDTO gc.BindJSON(&req) // Load a new config, as we set various default values in app.config that shouldn't be stored. tempConfig, _ := ini.Load(app.configPath) for section, settings := range req { if section != "restart-program" { _, err := tempConfig.GetSection(section) if err != nil { tempConfig.NewSection(section) } for setting, value := range settings.(map[string]interface{}) { if section == "email" && setting == "method" && value == "disabled" { value = "" } if value.(string) != app.config.Section(section).Key(setting).MustString("") { tempConfig.Section(section).Key(setting).SetValue(value.(string)) } } } } tempConfig.SaveTo(app.configPath) app.debug.Println("Config saved") gc.JSON(200, map[string]bool{"success": true}) if req["restart-program"] != nil && req["restart-program"].(bool) { app.info.Println("Restarting...") err := app.Restart() if err != nil { app.err.Printf("Couldn't restart, try restarting manually: %s", err) } } app.loadConfig() // Reinitialize password validator on config change, as opposed to every applicable request like in python. if _, ok := req["password_validation"]; ok { app.debug.Println("Reinitializing validator") validatorConf := ValidatorConf{ "length": app.config.Section("password_validation").Key("min_length").MustInt(0), "uppercase": app.config.Section("password_validation").Key("upper").MustInt(0), "lowercase": app.config.Section("password_validation").Key("lower").MustInt(0), "number": app.config.Section("password_validation").Key("number").MustInt(0), "special": app.config.Section("password_validation").Key("special").MustInt(0), } if !app.config.Section("password_validation").Key("enabled").MustBool(false) { for key := range validatorConf { validatorConf[key] = 0 } } app.validator.init(validatorConf) } } // @Summary Get a list of email names and IDs. // @Produce json // @Param lang query string false "Language for email titles." // @Success 200 {object} emailListDTO // @Router /config/emails [get] // @Security Bearer // @tags Configuration func (app *appContext) GetCustomEmails(gc *gin.Context) { lang := gc.Query("lang") if _, ok := app.storage.lang.Email[lang]; !ok { lang = app.storage.lang.chosenEmailLang } gc.JSON(200, emailListDTO{ "UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.customEmails.UserCreated.Enabled}, "InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.customEmails.InviteExpiry.Enabled}, "PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.customEmails.PasswordReset.Enabled}, "UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.customEmails.UserDeleted.Enabled}, "UserDisabled": {Name: app.storage.lang.Email[lang].UserDisabled["name"], Enabled: app.storage.customEmails.UserDisabled.Enabled}, "UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.customEmails.UserEnabled.Enabled}, "InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.customEmails.InviteEmail.Enabled}, "WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.customEmails.WelcomeEmail.Enabled}, "EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.customEmails.EmailConfirmation.Enabled}, "UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.customEmails.UserExpired.Enabled}, }) } // @Summary Sets the corresponding custom email. // @Produce json // @Param customEmail body customEmail true "Content = email (in markdown)." // @Success 200 {object} boolResponse // @Failure 400 {object} boolResponse // @Failure 500 {object} boolResponse // @Param id path string true "ID of email" // @Router /config/emails/{id} [post] // @Security Bearer // @tags Configuration func (app *appContext) SetCustomEmail(gc *gin.Context) { var req customEmail gc.BindJSON(&req) id := gc.Param("id") if req.Content == "" { respondBool(400, false, gc) return } switch id { case "UserCreated": app.storage.customEmails.UserCreated.Content = req.Content app.storage.customEmails.UserCreated.Enabled = true case "InviteExpiry": app.storage.customEmails.InviteExpiry.Content = req.Content app.storage.customEmails.InviteExpiry.Enabled = true case "PasswordReset": app.storage.customEmails.PasswordReset.Content = req.Content app.storage.customEmails.PasswordReset.Enabled = true case "UserDeleted": app.storage.customEmails.UserDeleted.Content = req.Content app.storage.customEmails.UserDeleted.Enabled = true case "UserDisabled": app.storage.customEmails.UserDisabled.Content = req.Content app.storage.customEmails.UserDisabled.Enabled = true case "UserEnabled": app.storage.customEmails.UserEnabled.Content = req.Content app.storage.customEmails.UserEnabled.Enabled = true case "InviteEmail": app.storage.customEmails.InviteEmail.Content = req.Content app.storage.customEmails.InviteEmail.Enabled = true case "WelcomeEmail": app.storage.customEmails.WelcomeEmail.Content = req.Content app.storage.customEmails.WelcomeEmail.Enabled = true case "EmailConfirmation": app.storage.customEmails.EmailConfirmation.Content = req.Content app.storage.customEmails.EmailConfirmation.Enabled = true case "UserExpired": app.storage.customEmails.UserExpired.Content = req.Content app.storage.customEmails.UserExpired.Enabled = true default: respondBool(400, false, gc) return } if app.storage.storeCustomEmails() != nil { respondBool(500, false, gc) return } respondBool(200, true, gc) } // @Summary Enable/Disable custom email. // @Produce json // @Success 200 {object} boolResponse // @Failure 400 {object} boolResponse // @Failure 500 {object} boolResponse // @Param enable/disable path string true "enable/disable" // @Param id path string true "ID of email" // @Router /config/emails/{id}/state/{enable/disable} [post] // @Security Bearer // @tags Configuration func (app *appContext) SetCustomEmailState(gc *gin.Context) { id := gc.Param("id") s := gc.Param("state") enabled := false if s == "enable" { enabled = true } else if s != "disable" { respondBool(400, false, gc) } switch id { case "UserCreated": app.storage.customEmails.UserCreated.Enabled = enabled case "InviteExpiry": app.storage.customEmails.InviteExpiry.Enabled = enabled case "PasswordReset": app.storage.customEmails.PasswordReset.Enabled = enabled case "UserDeleted": app.storage.customEmails.UserDeleted.Enabled = enabled case "UserDisabled": app.storage.customEmails.UserDisabled.Enabled = enabled case "UserEnabled": app.storage.customEmails.UserEnabled.Enabled = enabled case "InviteEmail": app.storage.customEmails.InviteEmail.Enabled = enabled case "WelcomeEmail": app.storage.customEmails.WelcomeEmail.Enabled = enabled case "EmailConfirmation": app.storage.customEmails.EmailConfirmation.Enabled = enabled case "UserExpired": app.storage.customEmails.UserExpired.Enabled = enabled default: respondBool(400, false, gc) return } if app.storage.storeCustomEmails() != nil { respondBool(500, false, gc) return } respondBool(200, true, gc) } // @Summary Returns the custom email (generating it if not set) and list of used variables in it. // @Produce json // @Success 200 {object} customEmailDTO // @Failure 400 {object} boolResponse // @Failure 500 {object} boolResponse // @Param id path string true "ID of email" // @Router /config/emails/{id} [get] // @Security Bearer // @tags Configuration func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) { lang := app.storage.lang.chosenEmailLang id := gc.Param("id") var content string var err error var msg *Message var variables []string var conditionals []string var values map[string]interface{} var writeVars func(variables []string) newEmail := false username := app.storage.lang.Email[lang].Strings.get("username") emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress") switch id { case "UserCreated": content = app.storage.customEmails.UserCreated.Content if content == "" { newEmail = true msg, err = app.email.constructCreated("", "", "", Invite{}, app, true) content = msg.Text } else { variables = app.storage.customEmails.UserCreated.Variables } writeVars = func(variables []string) { app.storage.customEmails.UserCreated.Variables = variables } values = app.email.createdValues("xxxxxx", username, emailAddress, Invite{}, app, false) // app.storage.customEmails.UserCreated = content case "InviteExpiry": content = app.storage.customEmails.InviteExpiry.Content if content == "" { newEmail = true msg, err = app.email.constructExpiry("", Invite{}, app, true) content = msg.Text } else { variables = app.storage.customEmails.InviteExpiry.Variables } writeVars = func(variables []string) { app.storage.customEmails.InviteExpiry.Variables = variables } values = app.email.expiryValues("xxxxxx", Invite{}, app, false) // app.storage.customEmails.InviteExpiry = content case "PasswordReset": content = app.storage.customEmails.PasswordReset.Content if content == "" { newEmail = true msg, err = app.email.constructReset(PasswordReset{}, app, true) content = msg.Text } else { variables = app.storage.customEmails.PasswordReset.Variables } writeVars = func(variables []string) { app.storage.customEmails.PasswordReset.Variables = variables } values = app.email.resetValues(PasswordReset{Pin: "12-34-56", Username: username}, app, false) // app.storage.customEmails.PasswordReset = content case "UserDeleted": content = app.storage.customEmails.UserDeleted.Content if content == "" { newEmail = true msg, err = app.email.constructDeleted("", app, true) content = msg.Text } else { variables = app.storage.customEmails.UserDeleted.Variables } writeVars = func(variables []string) { app.storage.customEmails.UserDeleted.Variables = variables } values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false) // app.storage.customEmails.UserDeleted = content case "UserDisabled": content = app.storage.customEmails.UserDisabled.Content if content == "" { newEmail = true msg, err = app.email.constructDisabled("", app, true) content = msg.Text } else { variables = app.storage.customEmails.UserDisabled.Variables } writeVars = func(variables []string) { app.storage.customEmails.UserDisabled.Variables = variables } values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false) // app.storage.customEmails.UserDeleted = content case "UserEnabled": content = app.storage.customEmails.UserEnabled.Content if content == "" { newEmail = true msg, err = app.email.constructEnabled("", app, true) content = msg.Text } else { variables = app.storage.customEmails.UserEnabled.Variables } writeVars = func(variables []string) { app.storage.customEmails.UserEnabled.Variables = variables } values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false) // app.storage.customEmails.UserEnabled = content case "InviteEmail": content = app.storage.customEmails.InviteEmail.Content if content == "" { newEmail = true msg, err = app.email.constructInvite("", Invite{}, app, true) content = msg.Text } else { variables = app.storage.customEmails.InviteEmail.Variables } writeVars = func(variables []string) { app.storage.customEmails.InviteEmail.Variables = variables } values = app.email.inviteValues("xxxxxx", Invite{}, app, false) // app.storage.customEmails.InviteEmail = content case "WelcomeEmail": content = app.storage.customEmails.WelcomeEmail.Content conditionals = []string{"{yourAccountWillExpire}"} app.storage.customEmails.WelcomeEmail.Conditionals = conditionals if content == "" { newEmail = true msg, err = app.email.constructWelcome("", time.Time{}, app, true) content = msg.Text } else { variables = app.storage.customEmails.WelcomeEmail.Variables } writeVars = func(variables []string) { app.storage.customEmails.WelcomeEmail.Variables = variables } // app.storage.customEmails.WelcomeEmail = content values = app.email.welcomeValues(username, time.Now(), app, false, true) case "EmailConfirmation": content = app.storage.customEmails.EmailConfirmation.Content if content == "" { newEmail = true msg, err = app.email.constructConfirmation("", "", "", app, true) content = msg.Text } else { variables = app.storage.customEmails.EmailConfirmation.Variables } writeVars = func(variables []string) { app.storage.customEmails.EmailConfirmation.Variables = variables } values = app.email.confirmationValues("xxxxxx", username, "xxxxxx", app, false) // app.storage.customEmails.EmailConfirmation = content case "UserExpired": content = app.storage.customEmails.UserExpired.Content if content == "" { newEmail = true msg, err = app.email.constructUserExpired(app, true) content = msg.Text } else { variables = app.storage.customEmails.UserExpired.Variables } writeVars = func(variables []string) { app.storage.customEmails.UserExpired.Variables = variables } values = app.email.userExpiredValues(app, false) // Just send the email html case "Announcement": content = "" default: respondBool(400, false, gc) return } if err != nil { respondBool(500, false, gc) return } if newEmail { variables = make([]string, strings.Count(content, "{")) i := 0 found := false buf := "" for _, c := range content { if !found && c != '{' && c != '}' { continue } found = true buf += string(c) if c == '}' { found = false variables[i] = buf buf = "" i++ } } writeVars(variables) } if variables == nil { variables = []string{} } if app.storage.storeCustomEmails() != nil { respondBool(500, false, gc) return } email, err := app.email.constructTemplate("", "
", app) if err != nil { respondBool(500, false, gc) return } gc.JSON(200, customEmailDTO{Content: content, Variables: variables, Conditionals: conditionals, Values: values, HTML: email.HTML, Plaintext: email.Text}) } // @Summary Returns whether there's a new update, and extra info if there is. // @Produce json // @Success 200 {object} checkUpdateDTO // @Router /config/update [get] // @Security Bearer // @tags Configuration func (app *appContext) CheckUpdate(gc *gin.Context) { if !app.newUpdate { app.update = Update{} } gc.JSON(200, checkUpdateDTO{New: app.newUpdate, Update: app.update}) } // @Summary Apply an update. // @Produce json // @Success 200 {object} boolResponse // @Success 400 {object} stringResponse // @Success 500 {object} boolResponse // @Router /config/update [post] // @Security Bearer // @tags Configuration func (app *appContext) ApplyUpdate(gc *gin.Context) { if !app.update.CanUpdate { respond(400, "Update is manual", gc) return } err := app.update.update() if err != nil { app.err.Printf("Failed to apply update: %v", err) respondBool(500, false, gc) return } if PLATFORM == "windows" { respondBool(500, true, gc) return } respondBool(200, true, gc) app.HardRestart() } // @Summary Logout by deleting refresh token from cookies. // @Produce json // @Success 200 {object} boolResponse // @Failure 500 {object} stringResponse // @Router /logout [post] // @Security Bearer // @tags Other func (app *appContext) Logout(gc *gin.Context) { cookie, err := gc.Cookie("refresh") if err != nil { app.debug.Printf("Couldn't get cookies: %s", err) respond(500, "Couldn't fetch cookies", gc) return } app.invalidTokens = append(app.invalidTokens, cookie) gc.SetCookie("refresh", "invalid", -1, "/", gc.Request.URL.Hostname(), true, true) respondBool(200, true, gc) } // @Summary Returns a map of available language codes to their full names, usable in the lang query parameter. // @Produce json // @Success 200 {object} langDTO // @Failure 500 {object} stringResponse // @Param page path string true "admin/form/setup/email/pwr" // @Router /lang/{page} [get] // @tags Other func (app *appContext) GetLanguages(gc *gin.Context) { page := gc.Param("page") resp := langDTO{} switch page { case "form": for key, lang := range app.storage.lang.Form { resp[key] = lang.Meta.Name } case "admin": for key, lang := range app.storage.lang.Admin { resp[key] = lang.Meta.Name } case "setup": for key, lang := range app.storage.lang.Setup { resp[key] = lang.Meta.Name } case "email": for key, lang := range app.storage.lang.Email { resp[key] = lang.Meta.Name } case "pwr": for key, lang := range app.storage.lang.PasswordReset { resp[key] = lang.Meta.Name } } if len(resp) == 0 { respond(500, "Couldn't get languages", gc) return } gc.JSON(200, resp) } // @Summary Serves a translations for pages "admin" or "form". // @Produce json // @Success 200 {object} adminLang // @Failure 400 {object} boolResponse // @Param page path string true "admin or form." // @Param language path string true "language code, e.g en-us." // @Router /lang/{page}/{language} [get] // @tags Other func (app *appContext) ServeLang(gc *gin.Context) { page := gc.Param("page") lang := strings.Replace(gc.Param("file"), ".json", "", 1) if page == "admin" { gc.JSON(200, app.storage.lang.Admin[lang]) return } else if page == "form" { gc.JSON(200, app.storage.lang.Form[lang]) return } respondBool(400, false, gc) } // @Summary Returns a new Telegram verification PIN, and the bot username. // @Produce json // @Success 200 {object} telegramPinDTO // @Router /telegram/pin [get] // @Security Bearer // @tags Other func (app *appContext) TelegramGetPin(gc *gin.Context) { gc.JSON(200, telegramPinDTO{ Token: app.telegram.NewAuthToken(), Username: app.telegram.username, }) } // @Summary Link a Jellyfin & Telegram user together via a verification PIN. // @Produce json // @Param telegramSetDTO body telegramSetDTO true "Token and user's Jellyfin ID." // @Success 200 {object} boolResponse // @Failure 500 {object} boolResponse // @Failure 400 {object} boolResponse // @Router /users/telegram [post] // @Security Bearer // @tags Other func (app *appContext) TelegramAddUser(gc *gin.Context) { var req telegramSetDTO gc.BindJSON(&req) if req.Token == "" || req.ID == "" { respondBool(400, false, gc) return } tokenIndex := -1 for i, v := range app.telegram.verifiedTokens { if v.Token == req.Token { tokenIndex = i break } } if tokenIndex == -1 { respondBool(500, false, gc) return } tgToken := app.telegram.verifiedTokens[tokenIndex] tgUser := TelegramUser{ ChatID: tgToken.ChatID, Username: tgToken.Username, Contact: true, } if lang, ok := app.telegram.languages[tgToken.ChatID]; ok { tgUser.Lang = lang } if app.storage.telegram == nil { app.storage.telegram = map[string]TelegramUser{} } app.storage.telegram[req.ID] = tgUser err := app.storage.storeTelegramUsers() if err != nil { app.err.Printf("Failed to store Telegram users: %v", err) } else { app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1] app.telegram.verifiedTokens = app.telegram.verifiedTokens[:len(app.telegram.verifiedTokens)-1] } respondBool(200, true, gc) } // @Summary Sets whether to notify a user through telegram or not. // @Produce json // @Param SetContactMethodsDTO body SetContactMethodsDTO true "User's Jellyfin ID and whether or not to notify then through Telegram." // @Success 200 {object} boolResponse // @Success 400 {object} boolResponse // @Success 500 {object} boolResponse // @Router /users/telegram/notify [post] // @Security Bearer // @tags Other func (app *appContext) SetContactMethods(gc *gin.Context) { var req SetContactMethodsDTO gc.BindJSON(&req) if req.ID == "" { respondBool(400, false, gc) return } if tgUser, ok := app.storage.telegram[req.ID]; ok { tgUser.Contact = req.Telegram app.storage.telegram[req.ID] = tgUser if err := app.storage.storeTelegramUsers(); err != nil { respondBool(500, false, gc) app.err.Printf("Telegram: Failed to store users: %v", err) return } msg := "" if !req.Telegram { msg = " not" } app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg) } if dcUser, ok := app.storage.discord[req.ID]; ok { dcUser.Contact = req.Discord app.storage.discord[req.ID] = dcUser if err := app.storage.storeDiscordUsers(); err != nil { respondBool(500, false, gc) app.err.Printf("Discord: Failed to store users: %v", err) return } msg := "" if !req.Discord { msg = " not" } app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg) } if email, ok := app.storage.emails[req.ID]; ok { email.Contact = req.Email app.storage.emails[req.ID] = email if err := app.storage.storeEmails(); err != nil { respondBool(500, false, gc) app.err.Printf("Failed to store emails: %v", err) return } msg := "" if !req.Email { msg = " not" } app.debug.Printf("\"%s\" will%s be notified via Email.", email.Addr, msg) } respondBool(200, true, gc) } // @Summary Returns true/false on whether or not a telegram PIN was verified. Requires bearer auth. // @Produce json // @Success 200 {object} boolResponse // @Param pin path string true "PIN code to check" // @Router /telegram/verified/{pin} [get] // @Security Bearer // @tags Other func (app *appContext) TelegramVerified(gc *gin.Context) { pin := gc.Param("pin") tokenIndex := -1 for i, v := range app.telegram.verifiedTokens { if v.Token == pin { tokenIndex = i break } } // if tokenIndex != -1 { // length := len(app.telegram.verifiedTokens) // app.telegram.verifiedTokens[length-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[length-1] // app.telegram.verifiedTokens = app.telegram.verifiedTokens[:length-1] // } respondBool(200, tokenIndex != -1, gc) } // @Summary Returns true/false on whether or not a telegram PIN was verified. Requires invite code. // @Produce json // @Success 200 {object} boolResponse // @Success 401 {object} boolResponse // @Param pin path string true "PIN code to check" // @Param invCode path string true "invite Code" // @Router /invite/{invCode}/telegram/verified/{pin} [get] // @tags Other func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) { code := gc.Param("invCode") if _, ok := app.storage.invites[code]; !ok { respondBool(401, false, gc) return } pin := gc.Param("pin") tokenIndex := -1 for i, v := range app.telegram.verifiedTokens { if v.Token == pin { tokenIndex = i break } } // if tokenIndex != -1 { // length := len(app.telegram.verifiedTokens) // app.telegram.verifiedTokens[length-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[length-1] // app.telegram.verifiedTokens = app.telegram.verifiedTokens[:length-1] // } respondBool(200, tokenIndex != -1, gc) } // @Summary Returns true/false on whether or not a discord PIN was verified. Requires invite code. // @Produce json // @Success 200 {object} boolResponse // @Failure 401 {object} boolResponse // @Param pin path string true "PIN code to check" // @Param invCode path string true "invite Code" // @Router /invite/{invCode}/discord/verified/{pin} [get] // @tags Other func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) { code := gc.Param("invCode") if _, ok := app.storage.invites[code]; !ok { respondBool(401, false, gc) return } pin := gc.Param("pin") _, ok := app.discord.verifiedTokens[pin] respondBool(200, ok, gc) } // @Summary Returns a list of matching users from a Discord guild, given a username (discriminator optional). // @Produce json // @Success 200 {object} DiscordUsersDTO // @Failure 400 {object} boolResponse // @Failure 500 {object} boolResponse // @Param username path string true "username to search." // @Router /users/discord/{username} [get] // @tags Other func (app *appContext) DiscordGetUsers(gc *gin.Context) { name := gc.Param("username") if name == "" { respondBool(400, false, gc) return } users := app.discord.GetUsers(name) resp := DiscordUsersDTO{Users: make([]DiscordUserDTO, len(users))} for i, u := range users { resp.Users[i] = DiscordUserDTO{ Name: u.User.Username + "#" + u.User.Discriminator, ID: u.User.ID, AvatarURL: u.User.AvatarURL("32"), } } gc.JSON(200, resp) } // @Summary Links a Discord account to a Jellyfin account via user IDs. Notifications are turned on by default. // @Produce json // @Success 200 {object} boolResponse // @Failure 400 {object} boolResponse // @Failure 500 {object} boolResponse // @Param DiscordConnectUserDTO body DiscordConnectUserDTO true "User's Jellyfin ID & Discord ID." // @Router /users/discord [post] // @tags Other func (app *appContext) DiscordConnect(gc *gin.Context) { var req DiscordConnectUserDTO gc.BindJSON(&req) if req.JellyfinID == "" || req.DiscordID == "" { respondBool(400, false, gc) return } user, ok := app.discord.NewUser(req.DiscordID) if !ok { respondBool(500, false, gc) return } app.storage.discord[req.JellyfinID] = user if err := app.storage.storeDiscordUsers(); err != nil { app.err.Printf("Failed to store Discord users: %v", err) respondBool(500, false, gc) return } respondBool(200, true, gc) } // @Summary Restarts the program. No response means success. // @Router /restart [post] // @Security Bearer // @tags Other func (app *appContext) restart(gc *gin.Context) { app.info.Println("Restarting...") err := app.Restart() if err != nil { app.err.Printf("Couldn't restart, try restarting manually: %v", err) } } // no need to syscall.exec anymore! func (app *appContext) Restart() error { RESTART <- true return nil }