|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
|
|
|
"net/http"
|
|
|
|
"os"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/golang-jwt/jwt"
|
|
|
|
"github.com/hrfee/jfa-go/jellyseerr"
|
|
|
|
lm "github.com/hrfee/jfa-go/logmessages"
|
|
|
|
"github.com/lithammer/shortuuid/v3"
|
|
|
|
"github.com/timshannon/badgerhold/v4"
|
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
REFERRAL_EXPIRY_DAYS = 90
|
|
|
|
)
|
|
|
|
|
|
|
|
// @Summary Returns the logged-in user's Jellyfin ID & Username, and other details.
|
|
|
|
// @Produce json
|
|
|
|
// @Success 200 {object} MyDetailsDTO
|
|
|
|
// @Router /my/details [get]
|
|
|
|
// @tags User Page
|
|
|
|
func (app *appContext) MyDetails(gc *gin.Context) {
|
|
|
|
resp := MyDetailsDTO{
|
|
|
|
Id: gc.GetString("jfId"),
|
|
|
|
}
|
|
|
|
|
|
|
|
user, err := app.jf.UserByID(resp.Id, false)
|
|
|
|
if err != nil {
|
|
|
|
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
|
|
|
|
respond(500, "Failed to get user", gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
resp.Username = user.Name
|
|
|
|
resp.Admin = user.Policy.IsAdministrator
|
|
|
|
resp.AccountsAdmin = false
|
|
|
|
if !app.config.Section("ui").Key("allow_all").MustBool(false) {
|
|
|
|
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
|
|
|
|
if emailStore, ok := app.storage.GetEmailsKey(resp.Id); ok {
|
|
|
|
resp.AccountsAdmin = emailStore.Admin
|
|
|
|
}
|
|
|
|
resp.AccountsAdmin = resp.AccountsAdmin || (adminOnly && resp.Admin)
|
|
|
|
}
|
|
|
|
resp.Disabled = user.Policy.IsDisabled
|
|
|
|
|
|
|
|
if exp, ok := app.storage.GetUserExpiryKey(user.ID); ok {
|
|
|
|
resp.Expiry = exp.Expiry.Unix()
|
|
|
|
}
|
|
|
|
|
|
|
|
if emailEnabled {
|
|
|
|
resp.Email = &MyDetailsContactMethodsDTO{}
|
|
|
|
if email, ok := app.storage.GetEmailsKey(user.ID); ok && email.Addr != "" {
|
|
|
|
resp.Email.Value = email.Addr
|
|
|
|
resp.Email.Enabled = email.Contact
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if discordEnabled {
|
|
|
|
resp.Discord = &MyDetailsContactMethodsDTO{}
|
|
|
|
if discord, ok := app.storage.GetDiscordKey(user.ID); ok {
|
|
|
|
resp.Discord.Value = RenderDiscordUsername(discord)
|
|
|
|
resp.Discord.Enabled = discord.Contact
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if telegramEnabled {
|
|
|
|
resp.Telegram = &MyDetailsContactMethodsDTO{}
|
|
|
|
if telegram, ok := app.storage.GetTelegramKey(user.ID); ok {
|
|
|
|
resp.Telegram.Value = telegram.Username
|
|
|
|
resp.Telegram.Enabled = telegram.Contact
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if matrixEnabled {
|
|
|
|
resp.Matrix = &MyDetailsContactMethodsDTO{}
|
|
|
|
if matrix, ok := app.storage.GetMatrixKey(user.ID); ok {
|
|
|
|
resp.Matrix.Value = matrix.UserID
|
|
|
|
resp.Matrix.Enabled = matrix.Contact
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if app.config.Section("user_page").Key("referrals").MustBool(false) {
|
|
|
|
// 1. Look for existing template bound to this Jellyfin ID
|
|
|
|
// If one exists, that means its just for us and so we
|
|
|
|
// can use it directly.
|
|
|
|
inv := Invite{}
|
|
|
|
err := app.storage.db.FindOne(&inv, badgerhold.Where("ReferrerJellyfinID").Eq(resp.Id))
|
|
|
|
if err == nil {
|
|
|
|
resp.HasReferrals = true
|
|
|
|
} else {
|
|
|
|
// 2. Look for a template matching the key found in the user storage
|
|
|
|
// Since this key is shared between users in a profile, we make a copy.
|
|
|
|
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
|
|
|
|
err = app.storage.db.Get(user.ReferralTemplateKey, &inv)
|
|
|
|
if ok && err == nil {
|
|
|
|
resp.HasReferrals = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
gc.JSON(200, resp)
|
|
|
|
}
|
|
|
|
|
|
|
|
// @Summary Sets whether to notify yourself through telegram/discord/matrix/email 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 /my/contact [post]
|
|
|
|
// @Security Bearer
|
|
|
|
// @tags User Page
|
|
|
|
func (app *appContext) SetMyContactMethods(gc *gin.Context) {
|
|
|
|
var req SetContactMethodsDTO
|
|
|
|
gc.BindJSON(&req)
|
|
|
|
req.ID = gc.GetString("jfId")
|
|
|
|
if req.ID == "" {
|
|
|
|
respondBool(400, false, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
app.setContactMethods(req, gc)
|
|
|
|
}
|
|
|
|
|
|
|
|
// @Summary Logout by deleting refresh token from cookies.
|
|
|
|
// @Produce json
|
|
|
|
// @Success 200 {object} boolResponse
|
|
|
|
// @Failure 500 {object} stringResponse
|
|
|
|
// @Router /my/logout [post]
|
|
|
|
// @Security Bearer
|
|
|
|
// @tags User Page
|
|
|
|
func (app *appContext) LogoutUser(gc *gin.Context) {
|
|
|
|
cookie, err := gc.Cookie("user-refresh")
|
|
|
|
if err != nil {
|
|
|
|
msg := fmt.Sprintf(lm.FailedGetCookies, "user-refresh", err)
|
|
|
|
app.debug.Println(msg)
|
|
|
|
respond(500, msg, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
app.invalidTokens = append(app.invalidTokens, cookie)
|
|
|
|
gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true)
|
|
|
|
respondBool(200, true, gc)
|
|
|
|
}
|
|
|
|
|
|
|
|
// @Summary confirm an action (e.g. changing an email address.)
|
|
|
|
// @Produce json
|
|
|
|
// @Param jwt path string true "jwt confirmation code"
|
|
|
|
// @Router /my/confirm/{jwt} [post]
|
|
|
|
// @Success 200 {object} boolResponse
|
|
|
|
// @Failure 400 {object} stringResponse
|
|
|
|
// @Failure 404
|
|
|
|
// @Success 303
|
|
|
|
// @Failure 500 {object} stringResponse
|
|
|
|
// @tags User Page
|
|
|
|
func (app *appContext) ConfirmMyAction(gc *gin.Context) {
|
|
|
|
app.confirmMyAction(gc, "")
|
|
|
|
}
|
|
|
|
|
|
|
|
func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
|
|
|
|
var claims jwt.MapClaims
|
|
|
|
var target ConfirmationTarget
|
|
|
|
var id string
|
|
|
|
fail := func() {
|
|
|
|
gcHTML(gc, 404, "404.html", gin.H{
|
|
|
|
"cssClass": app.cssClass,
|
|
|
|
"cssVersion": cssVersion,
|
|
|
|
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
// Validate key
|
|
|
|
if key == "" {
|
|
|
|
key = gc.Param("jwt")
|
|
|
|
}
|
|
|
|
token, err := jwt.Parse(key, checkToken)
|
|
|
|
if err != nil {
|
|
|
|
app.err.Printf(lm.FailedParseJWT, err)
|
|
|
|
fail()
|
|
|
|
// respond(500, "unknownError", gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
claims, ok := token.Claims.(jwt.MapClaims)
|
|
|
|
if !ok {
|
|
|
|
app.err.Println(lm.FailedCastJWT)
|
|
|
|
fail()
|
|
|
|
// respond(500, "unknownError", gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
expiry := time.Unix(int64(claims["exp"].(float64)), 0)
|
|
|
|
if !(ok && token.Valid && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) {
|
|
|
|
app.err.Println(lm.InvalidJWT)
|
|
|
|
fail()
|
|
|
|
// respond(400, "invalidKey", gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
target = ConfirmationTarget(int(claims["target"].(float64)))
|
|
|
|
id = claims["id"].(string)
|
|
|
|
|
|
|
|
// Perform an Action
|
|
|
|
if target == NoOp {
|
|
|
|
gc.Redirect(http.StatusSeeOther, "/my/account")
|
|
|
|
return
|
|
|
|
} else if target == UserEmailChange {
|
|
|
|
app.modifyEmail(id, claims["email"].(string))
|
|
|
|
|
|
|
|
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
|
|
|
Type: ActivityContactLinked,
|
|
|
|
UserID: gc.GetString("jfId"),
|
|
|
|
SourceType: ActivityUser,
|
|
|
|
Source: gc.GetString("jfId"),
|
|
|
|
Value: "email",
|
|
|
|
Time: time.Now(),
|
|
|
|
}, gc, true)
|
|
|
|
|
|
|
|
app.info.Printf(lm.UserEmailAdjusted, gc.GetString("jfId"))
|
|
|
|
gc.Redirect(http.StatusSeeOther, "/my/account")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// @Summary Modify your email address.
|
|
|
|
// @Produce json
|
|
|
|
// @Param ModifyMyEmailDTO body ModifyMyEmailDTO true "New email address."
|
|
|
|
// @Success 200 {object} boolResponse
|
|
|
|
// @Failure 400 {object} stringResponse
|
|
|
|
// @Failure 401 {object} stringResponse
|
|
|
|
// @Failure 500 {object} stringResponse
|
|
|
|
// @Router /my/email [post]
|
|
|
|
// @Security Bearer
|
|
|
|
// @tags User Page
|
|
|
|
func (app *appContext) ModifyMyEmail(gc *gin.Context) {
|
|
|
|
var req ModifyMyEmailDTO
|
|
|
|
gc.BindJSON(&req)
|
|
|
|
if !strings.ContainsRune(req.Email, '@') {
|
|
|
|
respond(400, "Invalid Email Address", gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
id := gc.GetString("jfId")
|
|
|
|
|
|
|
|
// We'll use the ConfirmMyAction route to do the work, even if we don't need to confirm the address.
|
|
|
|
claims := jwt.MapClaims{
|
|
|
|
"valid": true,
|
|
|
|
"id": id,
|
|
|
|
"email": req.Email,
|
|
|
|
"type": "confirmation",
|
|
|
|
"target": UserEmailChange,
|
|
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
|
|
}
|
|
|
|
tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
|
|
key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
app.err.Printf(lm.FailedSignJWT, err)
|
|
|
|
respond(500, "errorUnknown", gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) {
|
|
|
|
user, err := app.jf.UserByID(id, false)
|
|
|
|
name := ""
|
|
|
|
if err == nil {
|
|
|
|
name = user.Name
|
|
|
|
}
|
|
|
|
app.debug.Printf(lm.EmailConfirmationRequired, id)
|
|
|
|
respond(401, "confirmEmail", gc)
|
|
|
|
msg, err := app.email.constructConfirmation("", name, key, app, false)
|
|
|
|
if err != nil {
|
|
|
|
app.err.Printf(lm.FailedConstructConfirmationEmail, id, err)
|
|
|
|
} else if err := app.email.send(msg, req.Email); err != nil {
|
|
|
|
app.err.Printf(lm.FailedSendConfirmationEmail, id, req.Email, err)
|
|
|
|
} else {
|
|
|
|
app.err.Printf(lm.SentConfirmationEmail, id, req.Email)
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
app.confirmMyAction(gc, key)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// @Summary Returns a 10-minute, one-use Discord server invite
|
|
|
|
// @Produce json
|
|
|
|
// @Success 200 {object} DiscordInviteDTO
|
|
|
|
// @Failure 400 {object} boolResponse
|
|
|
|
// @Failure 401 {object} boolResponse
|
|
|
|
// @Failure 500 {object} boolResponse
|
|
|
|
// @Param invCode path string true "invite Code"
|
|
|
|
// @Router /my/discord/invite [get]
|
|
|
|
// @Security Bearer
|
|
|
|
// @tags User Page
|
|
|
|
func (app *appContext) MyDiscordServerInvite(gc *gin.Context) {
|
|
|
|
if app.discord.InviteChannel.Name == "" {
|
|
|
|
respondBool(400, false, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
invURL, iconURL := app.discord.NewTempInvite(10*60, 1)
|
|
|
|
if invURL == "" {
|
|
|
|
respondBool(500, false, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
gc.JSON(200, DiscordInviteDTO{invURL, iconURL})
|
|
|
|
}
|
|
|
|
|
|
|
|
// @Summary Returns a linking PIN for discord/telegram
|
|
|
|
// @Produce json
|
|
|
|
// @Success 200 {object} GetMyPINDTO
|
|
|
|
// @Failure 400 {object} stringResponse
|
|
|
|
// Param service path string true "discord/telegram"
|
|
|
|
// @Router /my/pin/{service} [get]
|
|
|
|
// @Security Bearer
|
|
|
|
// @tags User Page
|
|
|
|
func (app *appContext) GetMyPIN(gc *gin.Context) {
|
|
|
|
service := gc.Param("service")
|
|
|
|
resp := GetMyPINDTO{}
|
|
|
|
switch service {
|
|
|
|
case "discord":
|
|
|
|
resp.PIN = app.discord.NewAssignedAuthToken(gc.GetString("jfId"))
|
|
|
|
break
|
|
|
|
case "telegram":
|
|
|
|
resp.PIN = app.telegram.NewAssignedAuthToken(gc.GetString("jfId"))
|
|
|
|
break
|
|
|
|
default:
|
|
|
|
respond(400, "invalid service", gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
gc.JSON(200, resp)
|
|
|
|
}
|
|
|
|
|
|
|
|
// @Summary Returns true/false on whether or not your discord PIN was verified, and assigns the discord user to you.
|
|
|
|
// @Produce json
|
|
|
|
// @Success 200 {object} boolResponse
|
|
|
|
// @Failure 401 {object} boolResponse
|
|
|
|
// @Param pin path string true "PIN code to check"
|
|
|
|
// @Router /my/discord/verified/{pin} [get]
|
|
|
|
// @Security Bearer
|
|
|
|
// @tags User Page
|
|
|
|
func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
|
|
|
|
pin := gc.Param("pin")
|
|
|
|
dcUser, ok := app.discord.AssignedUserVerified(pin, gc.GetString("jfId"))
|
|
|
|
app.discord.DeleteVerifiedToken(pin)
|
|
|
|
if !ok {
|
|
|
|
respondBool(200, false, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(dcUser.ID) {
|
|
|
|
respondBool(400, false, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
existingUser, ok := app.storage.GetDiscordKey(gc.GetString("jfId"))
|
|
|
|
if ok {
|
|
|
|
dcUser.Lang = existingUser.Lang
|
|
|
|
dcUser.Contact = existingUser.Contact
|
|
|
|
}
|
|
|
|
app.storage.SetDiscordKey(gc.GetString("jfId"), dcUser)
|
|
|
|
|
|
|
|
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
|
|
|
|
jellyseerr.FieldDiscord: dcUser.ID,
|
|
|
|
jellyseerr.FieldDiscordEnabled: dcUser.Contact,
|
|
|
|
}); err != nil {
|
|
|
|
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
|
|
|
Type: ActivityContactLinked,
|
|
|
|
UserID: gc.GetString("jfId"),
|
|
|
|
SourceType: ActivityUser,
|
|
|
|
Source: gc.GetString("jfId"),
|
|
|
|
Value: "discord",
|
|
|
|
Time: time.Now(),
|
|
|
|
}, gc, true)
|
|
|
|
|
|
|
|
respondBool(200, true, gc)
|
|
|
|
}
|
|
|
|
|
|
|
|
// @Summary Returns true/false on whether or not your telegram PIN was verified, and assigns the telegram user to you.
|
|
|
|
// @Produce json
|
|
|
|
// @Success 200 {object} boolResponse
|
|
|
|
// @Failure 401 {object} boolResponse
|
|
|
|
// @Param pin path string true "PIN code to check"
|
|
|
|
// @Router /my/telegram/verified/{pin} [get]
|
|
|
|
// @Security Bearer
|
|
|
|
// @tags User Page
|
|
|
|
func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
|
|
|
|
pin := gc.Param("pin")
|
|
|
|
token, ok := app.telegram.AssignedTokenVerified(pin, gc.GetString("jfId"))
|
|
|
|
app.telegram.DeleteVerifiedToken(pin)
|
|
|
|
if !ok {
|
|
|
|
respondBool(200, false, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(token.Username) {
|
|
|
|
respondBool(400, false, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
tgUser := TelegramUser{
|
|
|
|
ChatID: token.ChatID,
|
|
|
|
Username: token.Username,
|
|
|
|
Contact: true,
|
|
|
|
}
|
|
|
|
if lang, ok := app.telegram.languages[tgUser.ChatID]; ok {
|
|
|
|
tgUser.Lang = lang
|
|
|
|
}
|
|
|
|
|
|
|
|
existingUser, ok := app.storage.GetTelegramKey(gc.GetString("jfId"))
|
|
|
|
if ok {
|
|
|
|
tgUser.Lang = existingUser.Lang
|
|
|
|
tgUser.Contact = existingUser.Contact
|
|
|
|
}
|
|
|
|
app.storage.SetTelegramKey(gc.GetString("jfId"), tgUser)
|
|
|
|
|
|
|
|
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
|
|
|
|
jellyseerr.FieldTelegram: tgUser.ChatID,
|
|
|
|
jellyseerr.FieldTelegramEnabled: tgUser.Contact,
|
|
|
|
}); err != nil {
|
|
|
|
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
|
|
|
Type: ActivityContactLinked,
|
|
|
|
UserID: gc.GetString("jfId"),
|
|
|
|
SourceType: ActivityUser,
|
|
|
|
Source: gc.GetString("jfId"),
|
|
|
|
Value: "telegram",
|
|
|
|
Time: time.Now(),
|
|
|
|
}, gc, true)
|
|
|
|
|
|
|
|
respondBool(200, true, gc)
|
|
|
|
}
|
|
|
|
|
|
|
|
// @Summary Generate and send a new PIN to your given matrix user.
|
|
|
|
// @Produce json
|
|
|
|
// @Success 200 {object} boolResponse
|
|
|
|
// @Failure 400 {object} stringResponse
|
|
|
|
// @Failure 401 {object} boolResponse
|
|
|
|
// @Failure 500 {object} boolResponse
|
|
|
|
// @Param MatrixSendPINDTO body MatrixSendPINDTO true "User's Matrix ID."
|
|
|
|
// @Router /my/matrix/user [post]
|
|
|
|
// @Security Bearer
|
|
|
|
// @tags User Page
|
|
|
|
func (app *appContext) MatrixSendMyPIN(gc *gin.Context) {
|
|
|
|
var req MatrixSendPINDTO
|
|
|
|
gc.BindJSON(&req)
|
|
|
|
if req.UserID == "" {
|
|
|
|
respond(400, "errorNoUserID", gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if app.config.Section("matrix").Key("require_unique").MustBool(false) {
|
|
|
|
for _, u := range app.storage.GetMatrix() {
|
|
|
|
if req.UserID == u.UserID {
|
|
|
|
respondBool(400, false, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
ok := app.matrix.SendStart(req.UserID)
|
|
|
|
if !ok {
|
|
|
|
respondBool(500, false, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
respondBool(200, true, gc)
|
|
|
|
}
|
|
|
|
|
|
|
|
// @Summary Check whether your matrix PIN is valid, and link the account to yours if so.
|
|
|
|
// @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"
|
|
|
|
// @Param userID path string true "Matrix User ID"
|
|
|
|
// @Router /my/matrix/verified/{userID}/{pin} [get]
|
|
|
|
// @Security Bearer
|
|
|
|
// @tags User Page
|
|
|
|
func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
|
|
|
|
userID := gc.Param("userID")
|
|
|
|
pin := gc.Param("pin")
|
|
|
|
user, ok := app.matrix.tokens[pin]
|
|
|
|
if !ok {
|
|
|
|
app.debug.Printf(lm.InvalidPIN, pin)
|
|
|
|
respondBool(200, false, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if user.User.UserID != userID {
|
|
|
|
app.debug.Printf(lm.UnauthorizedPIN, pin)
|
|
|
|
respondBool(200, false, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
mxUser := *user.User
|
|
|
|
mxUser.Contact = true
|
|
|
|
existingUser, ok := app.storage.GetMatrixKey(gc.GetString("jfId"))
|
|
|
|
if ok {
|
|
|
|
mxUser.Lang = existingUser.Lang
|
|
|
|
mxUser.Contact = existingUser.Contact
|
|
|
|
}
|
|
|
|
|
|
|
|
app.storage.SetMatrixKey(gc.GetString("jfId"), mxUser)
|
|
|
|
|
|
|
|
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
|
|
|
Type: ActivityContactLinked,
|
|
|
|
UserID: gc.GetString("jfId"),
|
|
|
|
SourceType: ActivityUser,
|
|
|
|
Source: gc.GetString("jfId"),
|
|
|
|
Value: "matrix",
|
|
|
|
Time: time.Now(),
|
|
|
|
}, gc, true)
|
|
|
|
|
|
|
|
delete(app.matrix.tokens, pin)
|
|
|
|
respondBool(200, true, gc)
|
|
|
|
}
|
|
|
|
|
|
|
|
// @Summary unlink the Discord account from your Jellyfin user. Always succeeds.
|
|
|
|
// @Produce json
|
|
|
|
// @Success 200 {object} boolResponse
|
|
|
|
// @Router /my/discord [delete]
|
|
|
|
// @Security Bearer
|
|
|
|
// @Tags User Page
|
|
|
|
func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
|
|
|
|
app.storage.DeleteDiscordKey(gc.GetString("jfId"))
|
|
|
|
|
|
|
|
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
|
|
|
|
jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
|
|
|
|
jellyseerr.FieldDiscordEnabled: false,
|
|
|
|
}); err != nil {
|
|
|
|
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
|
|
|
Type: ActivityContactUnlinked,
|
|
|
|
UserID: gc.GetString("jfId"),
|
|
|
|
SourceType: ActivityUser,
|
|
|
|
Source: gc.GetString("jfId"),
|
|
|
|
Value: "discord",
|
|
|
|
Time: time.Now(),
|
|
|
|
}, gc, true)
|
|
|
|
|
|
|
|
respondBool(200, true, gc)
|
|
|
|
}
|
|
|
|
|
|
|
|
// @Summary unlink the Telegram account from your Jellyfin user. Always succeeds.
|
|
|
|
// @Produce json
|
|
|
|
// @Success 200 {object} boolResponse
|
|
|
|
// @Router /my/telegram [delete]
|
|
|
|
// @Security Bearer
|
|
|
|
// @Tags User Page
|
|
|
|
func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
|
|
|
|
app.storage.DeleteTelegramKey(gc.GetString("jfId"))
|
|
|
|
|
|
|
|
if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
|
|
|
|
jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
|
|
|
|
jellyseerr.FieldTelegramEnabled: false,
|
|
|
|
}); err != nil {
|
|
|
|
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
|
|
|
Type: ActivityContactUnlinked,
|
|
|
|
UserID: gc.GetString("jfId"),
|
|
|
|
SourceType: ActivityUser,
|
|
|
|
Source: gc.GetString("jfId"),
|
|
|
|
Value: "telegram",
|
|
|
|
Time: time.Now(),
|
|
|
|
}, gc, true)
|
|
|
|
|
|
|
|
respondBool(200, true, gc)
|
|
|
|
}
|
|
|
|
|
|
|
|
// @Summary unlink the Matrix account from your Jellyfin user. Always succeeds.
|
|
|
|
// @Produce json
|
|
|
|
// @Success 200 {object} boolResponse
|
|
|
|
// @Router /my/matrix [delete]
|
|
|
|
// @Security Bearer
|
|
|
|
// @Tags User Page
|
|
|
|
func (app *appContext) UnlinkMyMatrix(gc *gin.Context) {
|
|
|
|
app.storage.DeleteMatrixKey(gc.GetString("jfId"))
|
|
|
|
|
|
|
|
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
|
|
|
Type: ActivityContactUnlinked,
|
|
|
|
UserID: gc.GetString("jfId"),
|
|
|
|
SourceType: ActivityUser,
|
|
|
|
Source: gc.GetString("jfId"),
|
|
|
|
Value: "matrix",
|
|
|
|
Time: time.Now(),
|
|
|
|
}, gc, true)
|
|
|
|
|
|
|
|
respondBool(200, true, gc)
|
|
|
|
}
|
|
|
|
|
|
|
|
// @Summary Generate & send a password reset link if the given username/email/contact method exists. Doesn't give you any info about it's success.
|
|
|
|
// @Produce json
|
|
|
|
// @Param address path string true "address/contact method associated w/ your account."
|
|
|
|
// @Success 204 {object} boolResponse
|
|
|
|
// @Failure 400 {object} boolResponse
|
|
|
|
// @Failure 500 {object} boolResponse
|
|
|
|
// @Router /my/password/reset/{address} [post]
|
|
|
|
// @Tags User Page
|
|
|
|
func (app *appContext) ResetMyPassword(gc *gin.Context) {
|
|
|
|
// All requests should take 1 second, to make it harder to tell if a success occured or not.
|
|
|
|
timerWait := make(chan bool)
|
|
|
|
cancel := time.AfterFunc(1*time.Second, func() {
|
|
|
|
timerWait <- true
|
|
|
|
})
|
|
|
|
usernameAllowed := app.config.Section("user_page").Key("allow_pwr_username").MustBool(true)
|
|
|
|
emailAllowed := app.config.Section("user_page").Key("allow_pwr_email").MustBool(true)
|
|
|
|
contactMethodAllowed := app.config.Section("user_page").Key("allow_pwr_contact_method").MustBool(true)
|
|
|
|
address := gc.Param("address")
|
|
|
|
if address == "" {
|
|
|
|
cancel.Stop()
|
|
|
|
respondBool(400, false, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
var pwr InternalPWR
|
|
|
|
var err error
|
|
|
|
|
|
|
|
jfUser, ok := app.ReverseUserSearch(address, usernameAllowed, emailAllowed, contactMethodAllowed)
|
|
|
|
if !ok {
|
|
|
|
app.debug.Printf(lm.FailedGetUsers, lm.Jellyfin, "no results")
|
|
|
|
|
|
|
|
for range timerWait {
|
|
|
|
respondBool(204, true, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
pwr, err = app.GenInternalReset(jfUser.ID)
|
|
|
|
if err != nil {
|
|
|
|
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
|
|
|
|
for range timerWait {
|
|
|
|
respondBool(204, true, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if app.internalPWRs == nil {
|
|
|
|
app.internalPWRs = map[string]InternalPWR{}
|
|
|
|
}
|
|
|
|
app.internalPWRs[pwr.PIN] = pwr
|
|
|
|
// FIXME: Send to all contact methods
|
|
|
|
msg, err := app.email.constructReset(
|
|
|
|
PasswordReset{
|
|
|
|
Pin: pwr.PIN,
|
|
|
|
Username: pwr.Username,
|
|
|
|
Expiry: pwr.Expiry,
|
|
|
|
Internal: true,
|
|
|
|
}, app, false,
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
|
|
|
|
for range timerWait {
|
|
|
|
respondBool(204, true, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
return
|
|
|
|
} else if err := app.sendByID(msg, jfUser.ID); err != nil {
|
|
|
|
app.err.Printf(lm.FailedSendPWRMessage, pwr.Username, "?", err)
|
|
|
|
} else {
|
|
|
|
app.info.Printf(lm.SentPWRMessage, pwr.Username, "?")
|
|
|
|
}
|
|
|
|
for range timerWait {
|
|
|
|
respondBool(204, true, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// @Summary Change your password, given the old one and the new one.
|
|
|
|
// @Produce json
|
|
|
|
// @Param ChangeMyPasswordDTO body ChangeMyPasswordDTO true "User's old & new passwords."
|
|
|
|
// @Success 204 {object} boolResponse
|
|
|
|
// @Failure 400 {object} PasswordValidation
|
|
|
|
// @Failure 401 {object} boolResponse
|
|
|
|
// @Failure 500 {object} boolResponse
|
|
|
|
// @Router /my/password [post]
|
|
|
|
// @Security Bearer
|
|
|
|
// @Tags User Page
|
|
|
|
func (app *appContext) ChangeMyPassword(gc *gin.Context) {
|
|
|
|
var req ChangeMyPasswordDTO
|
|
|
|
gc.BindJSON(&req)
|
|
|
|
if req.Old == "" || req.New == "" {
|
|
|
|
respondBool(400, false, gc)
|
|
|
|
}
|
|
|
|
validation := app.validator.validate(req.New)
|
|
|
|
for _, val := range validation {
|
|
|
|
if !val {
|
|
|
|
gc.JSON(400, validation)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
user, err := app.jf.UserByID(gc.GetString("jfId"), false)
|
|
|
|
if err != nil {
|
|
|
|
app.err.Printf(lm.FailedGetUser, gc.GetString("jfId"), lm.Jellyfin, err)
|
|
|
|
respondBool(500, false, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
// Authenticate as user to confirm old password.
|
|
|
|
user, err = app.authJf.Authenticate(user.Name, req.Old)
|
|
|
|
if err != nil {
|
|
|
|
respondBool(401, false, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
err = app.jf.SetPassword(gc.GetString("jfId"), req.Old, req.New)
|
|
|
|
if err != nil {
|
|
|
|
respondBool(500, false, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
|
|
|
Type: ActivityChangePassword,
|
|
|
|
UserID: user.ID,
|
|
|
|
SourceType: ActivityUser,
|
|
|
|
Source: user.ID,
|
|
|
|
Time: time.Now(),
|
|
|
|
}, gc, true)
|
|
|
|
|
|
|
|
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
|
|
|
func() {
|
|
|
|
ombiUser, err := app.getOmbiUser(gc.GetString("jfId"))
|
|
|
|
if err != nil {
|
|
|
|
app.err.Printf(lm.FailedGetUser, user.Name, lm.Ombi, err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
ombiUser["password"] = req.New
|
|
|
|
err = app.ombi.ModifyUser(ombiUser)
|
|
|
|
if err != nil {
|
|
|
|
app.err.Printf(lm.FailedChangePassword, lm.Ombi, ombiUser["userName"], err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
app.debug.Printf(lm.ChangePassword, lm.Ombi, ombiUser["userName"])
|
|
|
|
}()
|
|
|
|
}
|
|
|
|
cookie, err := gc.Cookie("user-refresh")
|
|
|
|
if err == nil {
|
|
|
|
app.invalidTokens = append(app.invalidTokens, cookie)
|
|
|
|
gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true)
|
|
|
|
} else {
|
|
|
|
app.debug.Printf(lm.FailedGetCookies, "user-refresh", err)
|
|
|
|
}
|
|
|
|
respondBool(204, true, gc)
|
|
|
|
}
|
|
|
|
|
|
|
|
// @Summary Get or generate a new referral code.
|
|
|
|
// @Produce json
|
|
|
|
// @Success 200 {object} GetMyReferralRespDTO
|
|
|
|
// @Failure 400 {object} boolResponse
|
|
|
|
// @Failure 401 {object} boolResponse
|
|
|
|
// @Failure 500 {object} boolResponse
|
|
|
|
// @Router /my/referral [get]
|
|
|
|
// @Security Bearer
|
|
|
|
// @Tags User Page
|
|
|
|
func (app *appContext) GetMyReferral(gc *gin.Context) {
|
|
|
|
// 1. Look for existing template bound to this Jellyfin ID
|
|
|
|
// If one exists, that means its just for us and so we
|
|
|
|
// can use it directly.
|
|
|
|
inv := Invite{}
|
|
|
|
err := app.storage.db.FindOne(&inv, badgerhold.Where("ReferrerJellyfinID").Eq(gc.GetString("jfId")))
|
|
|
|
if err != nil {
|
|
|
|
// 2. Look for a template matching the key found in the user storage
|
|
|
|
// Since this key is shared between users in a profile, we make a copy.
|
|
|
|
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
|
|
|
|
err = app.storage.db.Get(user.ReferralTemplateKey, &inv)
|
|
|
|
if !ok || err != nil || user.ReferralTemplateKey == "" {
|
|
|
|
app.debug.Printf(lm.FailedGetReferralTemplate, user.ReferralTemplateKey, err)
|
|
|
|
respondBool(400, false, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
inv.Code = GenerateInviteCode()
|
|
|
|
expiryDelta := inv.ValidTill.Sub(inv.Created)
|
|
|
|
inv.Created = time.Now()
|
|
|
|
if inv.UseReferralExpiry {
|
|
|
|
inv.ValidTill = inv.Created.Add(expiryDelta)
|
|
|
|
} else {
|
|
|
|
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
|
|
|
|
}
|
|
|
|
inv.IsReferral = true
|
|
|
|
inv.ReferrerJellyfinID = gc.GetString("jfId")
|
|
|
|
app.storage.SetInvitesKey(inv.Code, inv)
|
|
|
|
} else if time.Now().After(inv.ValidTill) {
|
|
|
|
// 3. We found an invite for us, but it's expired.
|
|
|
|
// We delete it from storage, and put it back with a fresh code and expiry.
|
|
|
|
// If UseReferralExpiry is enabled, we delete it and return nothing.
|
|
|
|
app.storage.DeleteInvitesKey(inv.Code)
|
|
|
|
if inv.UseReferralExpiry {
|
|
|
|
app.debug.Printf(lm.DeleteOldReferral, inv.Code)
|
|
|
|
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
|
|
|
|
if ok {
|
|
|
|
user.ReferralTemplateKey = ""
|
|
|
|
app.storage.SetEmailsKey(gc.GetString("jfId"), user)
|
|
|
|
}
|
|
|
|
app.debug.Printf("Ignoring referral request, expired.")
|
|
|
|
respondBool(400, false, gc)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
app.debug.Printf(lm.RenewOldReferral, inv.Code)
|
|
|
|
inv.Code = GenerateInviteCode()
|
|
|
|
inv.Created = time.Now()
|
|
|
|
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
|
|
|
|
app.storage.SetInvitesKey(inv.Code, inv)
|
|
|
|
}
|
|
|
|
gc.JSON(200, GetMyReferralRespDTO{
|
|
|
|
Code: inv.Code,
|
|
|
|
RemainingUses: inv.RemainingUses,
|
|
|
|
NoLimit: inv.NoLimit,
|
|
|
|
Expiry: inv.ValidTill.Unix(),
|
|
|
|
UseExpiry: inv.UseReferralExpiry,
|
|
|
|
})
|
|
|
|
}
|