You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
jfa-go/views.go

646 lines
22 KiB

package main
import (
"encoding/json"
"html/template"
"io"
"io/fs"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
"github.com/hrfee/mediabrowser"
"github.com/steambap/captcha"
)
var cssVersion string
var css = []string{cssVersion + "bundle.css", "remixicon.css"}
var cssHeader string
func (app *appContext) loadCSSHeader() string {
l := len(css)
h := ""
for i, f := range css {
h += "<" + app.URLBase + "/css/" + f + ">; rel=preload; as=style"
if l > 1 && i != (l-1) {
h += ", "
}
}
return h
}
func (app *appContext) getURLBase(gc *gin.Context) string {
if strings.HasPrefix(gc.Request.URL.String(), app.URLBase) {
return app.URLBase
}
return ""
}
func gcHTML(gc *gin.Context, code int, file string, templ gin.H) {
gc.Header("Cache-Control", "no-cache")
gc.HTML(code, file, templ)
}
func (app *appContext) pushResources(gc *gin.Context, page Page) {
var toPush []string
switch page {
case AdminPage:
toPush = []string{"/js/admin.js", "/js/theme.js", "/js/lang.js", "/js/modal.js", "/js/tabs.js", "/js/invites.js", "/js/accounts.js", "/js/settings.js", "/js/profiles.js", "/js/common.js"}
break
case UserPage:
toPush = []string{"/js/user.js", "/js/theme.js", "/js/lang.js", "/js/modal.js", "/js/common.js"}
break
default:
toPush = []string{}
}
if pusher := gc.Writer.Pusher(); pusher != nil {
app.debug.Println("Using HTTP2 Server push")
for _, f := range toPush {
if err := pusher.Push(app.URLBase+f, nil); err != nil {
app.debug.Printf("Failed HTTP2 ServerPush of \"%s\": %+v", f, err)
}
}
}
gc.Header("Link", cssHeader)
}
type Page int
const (
AdminPage Page = iota + 1
FormPage
PWRPage
UserPage
OtherPage
)
func (app *appContext) getLang(gc *gin.Context, page Page, chosen string) string {
lang := gc.Query("lang")
cookie, err := gc.Cookie("lang")
if lang != "" {
switch page {
case AdminPage:
if _, ok := app.storage.lang.Admin[lang]; ok {
gc.SetCookie("lang", lang, (365 * 3600), "/", gc.Request.URL.Hostname(), true, true)
return lang
}
case FormPage, UserPage:
if _, ok := app.storage.lang.User[lang]; ok {
gc.SetCookie("lang", lang, (365 * 3600), "/", gc.Request.URL.Hostname(), true, true)
return lang
}
case PWRPage:
if _, ok := app.storage.lang.PasswordReset[lang]; ok {
gc.SetCookie("lang", lang, (365 * 3600), "/", gc.Request.URL.Hostname(), true, true)
return lang
}
}
}
if cookie != "" && err == nil {
switch page {
case AdminPage:
if _, ok := app.storage.lang.Admin[cookie]; ok {
return cookie
}
case FormPage, UserPage:
if _, ok := app.storage.lang.User[cookie]; ok {
return cookie
}
case PWRPage:
if _, ok := app.storage.lang.PasswordReset[cookie]; ok {
return cookie
}
}
}
return chosen
}
func (app *appContext) AdminPage(gc *gin.Context) {
app.pushResources(gc, AdminPage)
lang := app.getLang(gc, AdminPage, app.storage.lang.chosenAdminLang)
emailEnabled, _ := app.config.Section("invite_emails").Key("enabled").Bool()
notificationsEnabled, _ := app.config.Section("notifications").Key("enabled").Bool()
ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false)
jfAdminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
jfAllowAll := app.config.Section("ui").Key("allow_all").MustBool(false)
var license string
l, err := fs.ReadFile(localFS, "LICENSE")
if err != nil {
app.debug.Printf("Failed to load LICENSE: %s", err)
license = ""
}
license = string(l)
gcHTML(gc, http.StatusOK, "admin.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": "",
"emailEnabled": emailEnabled,
"telegramEnabled": telegramEnabled,
"discordEnabled": discordEnabled,
"matrixEnabled": matrixEnabled,
"ombiEnabled": ombiEnabled,
"linkResetEnabled": app.config.Section("password_resets").Key("link_reset").MustBool(false),
"notifications": notificationsEnabled,
"version": version,
"commit": commit,
"username": !app.config.Section("email").Key("no_username").MustBool(false),
"strings": app.storage.lang.Admin[lang].Strings,
"quantityStrings": app.storage.lang.Admin[lang].QuantityStrings,
"language": app.storage.lang.Admin[lang].JSON,
"langName": lang,
"license": license,
"jellyfinLogin": app.jellyfinLogin,
"jfAdminOnly": jfAdminOnly,
"jfAllowAll": jfAllowAll,
})
}
func (app *appContext) MyUserPage(gc *gin.Context) {
app.pushResources(gc, UserPage)
lang := app.getLang(gc, UserPage, app.storage.lang.chosenUserLang)
emailEnabled, _ := app.config.Section("invite_emails").Key("enabled").Bool()
notificationsEnabled, _ := app.config.Section("notifications").Key("enabled").Bool()
ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false)
data := gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
"emailEnabled": emailEnabled,
"emailRequired": app.config.Section("email").Key("required").MustBool(false),
"telegramEnabled": telegramEnabled,
"discordEnabled": discordEnabled,
"matrixEnabled": matrixEnabled,
"ombiEnabled": ombiEnabled,
"linkResetEnabled": app.config.Section("password_resets").Key("link_reset").MustBool(false),
"notifications": notificationsEnabled,
"username": !app.config.Section("email").Key("no_username").MustBool(false),
"strings": app.storage.lang.User[lang].Strings,
"validationStrings": app.storage.lang.User[lang].ValidationStrings,
"language": app.storage.lang.User[lang].JSON,
"langName": lang,
}
if telegramEnabled {
data["telegramUsername"] = app.telegram.username
data["telegramURL"] = app.telegram.link
data["telegramRequired"] = app.config.Section("telegram").Key("required").MustBool(false)
}
if matrixEnabled {
data["matrixRequired"] = app.config.Section("matrix").Key("required").MustBool(false)
data["matrixUser"] = app.matrix.userID
}
if discordEnabled {
data["discordUsername"] = app.discord.username
data["discordRequired"] = app.config.Section("discord").Key("required").MustBool(false)
data["discordSendPINMessage"] = template.HTML(app.storage.lang.User[lang].Strings.template("sendPINDiscord", tmpl{
"command": `<span class="text-black dark:text-white font-mono">/` + app.config.Section("discord").Key("start_command").MustString("start") + `</span>`,
"server_channel": app.discord.serverChannelName,
}))
data["discordServerName"] = app.discord.serverName
data["discordInviteLink"] = app.discord.inviteChannelName != ""
}
gcHTML(gc, http.StatusOK, "user.html", data)
}
func (app *appContext) ResetPassword(gc *gin.Context) {
isBot := strings.Contains(gc.Request.Header.Get("User-Agent"), "Bot")
setPassword := app.config.Section("password_resets").Key("set_password").MustBool(false)
pin := gc.Query("pin")
if pin == "" {
app.NoRouteHandler(gc)
return
}
app.pushResources(gc, PWRPage)
lang := app.getLang(gc, PWRPage, app.storage.lang.chosenPWRLang)
data := gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
"strings": app.storage.lang.PasswordReset[lang].Strings,
"success": false,
"ombiEnabled": app.config.Section("ombi").Key("enabled").MustBool(false),
}
pwr, isInternal := app.internalPWRs[pin]
if isInternal && setPassword {
data["helpMessage"] = app.config.Section("ui").Key("help_message").String()
data["successMessage"] = app.config.Section("ui").Key("success_message").String()
data["jfLink"] = app.config.Section("ui").Key("redirect_url").String()
data["redirectToJellyfin"] = app.config.Section("ui").Key("auto_redirect").MustBool(false)
data["validate"] = app.config.Section("password_validation").Key("enabled").MustBool(false)
data["requirements"] = app.validator.getCriteria()
data["strings"] = app.storage.lang.PasswordReset[lang].Strings
data["validationStrings"] = app.storage.lang.User[lang].validationStringsJSON
data["notifications"] = app.storage.lang.User[lang].notificationsJSON
data["langName"] = lang
data["passwordReset"] = true
data["telegramEnabled"] = false
data["discordEnabled"] = false
data["matrixEnabled"] = false
gcHTML(gc, http.StatusOK, "form-loader.html", data)
return
}
defer gcHTML(gc, http.StatusOK, "password-reset.html", data)
// If it's a bot, pretend to be a success so the preview is nice.
if isBot {
app.debug.Println("PWR: Ignoring magic link visit from bot")
data["success"] = true
data["pin"] = "NO-BO-TS"
return
}
// if reset, ok := app.internalPWRs[pin]; ok {
// status, err := app.jf.ResetPasswordAdmin(reset.ID)
// if !(status == 200 || status == 204) || err != nil {
// app.err.Printf("Password Reset failed (%d): %v", status, err)
// return
// }
// status, err = app.jf.SetPassword(reset.ID, "", pin)
// if !(status == 200 || status == 204) || err != nil {
// app.err.Printf("Password Reset failed (%d): %v", status, err)
// return
// }
// data["success"] = true
// data["pin"] = pin
// }
var resp mediabrowser.PasswordResetResponse
var status int
var err error
var username string
if !isInternal {
resp, status, err = app.jf.ResetPassword(pin)
} else if time.Now().After(pwr.Expiry) {
app.debug.Printf("Ignoring PWR request due to expired internal PIN: %s", pin)
app.NoRouteHandler(gc)
return
} else {
status, err = app.jf.ResetPasswordAdmin(pwr.ID)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Password Reset failed (%d): %v", status, err)
} else {
status, err = app.jf.SetPassword(pwr.ID, "", pin)
}
username = pwr.Username
}
if (status == 200 || status == 204) && err == nil && (isInternal || resp.Success) {
data["success"] = true
data["pin"] = pin
if !isInternal {
username = resp.UsersReset[0]
}
} else {
app.err.Printf("Password Reset failed (%d): %v", status, err)
}
if app.config.Section("ombi").Key("enabled").MustBool(false) {
jfUser, status, err := app.jf.UserByName(username, false)
if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" from jellyfin/emby (%d): %v", username, status, err)
return
}
ombiUser, status, err := app.getOmbiUser(jfUser.ID)
if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", username, status, err)
return
}
ombiUser["password"] = pin
status, err = app.ombi.ModifyUser(ombiUser)
if status != 200 || err != nil {
app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err)
return
}
app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"])
}
}
// @Summary returns the captcha image corresponding to the given ID.
// @Param code path string true "invite code"
// @Param captchaID path string true "captcha ID"
// @Tags Other
// @Router /captcha/img/{code}/{captchaID} [get]
func (app *appContext) GetCaptcha(gc *gin.Context) {
code := gc.Param("invCode")
captchaID := gc.Param("captchaID")
inv, ok := app.storage.invites[code]
if !ok {
gcHTML(gc, 404, "invalidCode.html", gin.H{
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
var capt *captcha.Data
if inv.Captchas != nil {
capt = inv.Captchas[captchaID]
}
if capt == nil {
respondBool(400, false, gc)
return
}
if err := capt.WriteImage(gc.Writer); err != nil {
app.err.Printf("Failed to write CAPTCHA image: %v", err)
respondBool(500, false, gc)
return
}
gc.Status(200)
return
}
// @Summary Generates a new captcha and returns it's ID. This can then be included in a request to /captcha/img/{id} to get an image.
// @Produce json
// @Param code path string true "invite code"
// @Success 200 {object} genCaptchaDTO
// @Router /captcha/gen/{code} [get]
// @Security Bearer
// @tags Users
func (app *appContext) GenCaptcha(gc *gin.Context) {
code := gc.Param("invCode")
inv, ok := app.storage.invites[code]
if !ok {
gcHTML(gc, 404, "invalidCode.html", gin.H{
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
capt, err := captcha.New(300, 100)
if err != nil {
app.err.Printf("Failed to generate captcha: %v", err)
respondBool(500, false, gc)
return
}
if inv.Captchas == nil {
inv.Captchas = map[string]*captcha.Data{}
}
captchaID := genAuthToken()
inv.Captchas[captchaID] = capt
app.storage.invites[code] = inv
app.storage.storeInvites()
gc.JSON(200, genCaptchaDTO{captchaID})
return
}
func (app *appContext) verifyCaptcha(code, id, text string) bool {
reCAPTCHA := app.config.Section("captcha").Key("recaptcha").MustBool(false)
if !reCAPTCHA {
// internal CAPTCHA
inv, ok := app.storage.invites[code]
if !ok || inv.Captchas == nil {
app.debug.Printf("Couldn't find invite \"%s\"", code)
return false
}
c, ok := inv.Captchas[id]
if !ok {
app.debug.Printf("Couldn't find Captcha \"%s\"", id)
return false
}
return strings.ToLower(c.Text) == strings.ToLower(text)
}
// reCAPTCHA
msg := ReCaptchaRequestDTO{
Secret: app.config.Section("captcha").Key("recaptcha_secret_key").MustString(""),
Response: text,
}
// Why doesn't this endpoint accept JSON???
urlencode := url.Values{}
urlencode.Set("secret", msg.Secret)
urlencode.Set("response", msg.Response)
req, _ := http.NewRequest("POST", "https://www.google.com/recaptcha/api/siteverify", strings.NewReader(urlencode.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode != 200 {
app.err.Printf("Failed to read reCAPTCHA status (%d): %+v\n", resp.Status, err)
return false
}
defer resp.Body.Close()
var data ReCaptchaResponseDTO
body, err := io.ReadAll(resp.Body)
err = json.Unmarshal(body, &data)
if err != nil {
app.err.Printf("Failed to unmarshal reCAPTCHA response: %+v\n", err)
return false
}
hostname := app.config.Section("captcha").Key("recaptcha_hostname").MustString("")
if strings.ToLower(data.Hostname) != strings.ToLower(hostname) && data.Hostname != "" {
app.debug.Printf("Invalidating reCAPTCHA request: Hostnames didn't match (Wanted \"%s\", got \"%s\"\n", hostname, data.Hostname)
return false
}
if len(data.ErrorCodes) > 0 {
app.err.Printf("reCAPTCHA returned errors: %+v\n", data.ErrorCodes)
return false
}
return data.Success
}
// @Summary returns 204 if the given Captcha contents is correct for the corresponding captcha ID and invite code.
// @Param code path string true "invite code"
// @Param captchaID path string true "captcha ID"
// @Param text path string true "Captcha text"
// @Success 204
// @Tags Other
// @Router /captcha/verify/{code}/{captchaID}/{text} [get]
func (app *appContext) VerifyCaptcha(gc *gin.Context) {
code := gc.Param("invCode")
captchaID := gc.Param("captchaID")
text := gc.Param("text")
inv, ok := app.storage.invites[code]
if !ok {
gcHTML(gc, 404, "invalidCode.html", gin.H{
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
return
}
var capt *captcha.Data
if inv.Captchas != nil {
capt = inv.Captchas[captchaID]
}
if capt == nil {
respondBool(400, false, gc)
return
}
if strings.ToLower(capt.Text) != strings.ToLower(text) {
respondBool(400, false, gc)
return
}
respondBool(204, true, gc)
return
}
func (app *appContext) InviteProxy(gc *gin.Context) {
app.pushResources(gc, FormPage)
code := gc.Param("invCode")
lang := app.getLang(gc, FormPage, app.storage.lang.chosenUserLang)
/* Don't actually check if the invite is valid, just if it exists, just so the page loads quicker. Invite is actually checked on submit anyway. */
// if app.checkInvite(code, false, "") {
inv, ok := app.storage.invites[code]
if !ok {
gcHTML(gc, 404, "invalidCode.html", gin.H{
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
return
}
if key := gc.Query("key"); key != "" && app.config.Section("email_confirmation").Key("enabled").MustBool(false) {
validKey := false
keyIndex := -1
for i, k := range inv.Keys {
if k == key {
validKey = true
keyIndex = i
break
}
}
fail := func() {
gcHTML(gc, 404, "404.html", gin.H{
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
if !validKey {
fail()
return
}
token, err := jwt.Parse(key, checkToken)
if err != nil {
fail()
app.err.Printf("Failed to parse key: %s", err)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
expiryUnix := int64(claims["exp"].(float64))
if err != nil {
fail()
app.err.Printf("Failed to parse key expiry: %s", err)
return
}
expiry := time.Unix(expiryUnix, 0)
if !(ok && token.Valid && claims["invite"].(string) == code && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) {
fail()
app.debug.Printf("Invalid key")
return
}
req := newUserDTO{
Email: claims["email"].(string),
Username: claims["username"].(string),
Password: claims["password"].(string),
Code: claims["invite"].(string),
}
_, success := app.newUser(req, true)
if !success {
fail()
return
}
jfLink := app.config.Section("ui").Key("redirect_url").String()
if app.config.Section("ui").Key("auto_redirect").MustBool(false) {
gc.Redirect(301, jfLink)
} else {
gcHTML(gc, http.StatusOK, "create-success.html", gin.H{
"cssClass": app.cssClass,
"strings": app.storage.lang.User[lang].Strings,
"successMessage": app.config.Section("ui").Key("success_message").String(),
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
"jfLink": jfLink,
})
}
inv, ok := app.storage.invites[code]
if ok {
l := len(inv.Keys)
inv.Keys[l-1], inv.Keys[keyIndex] = inv.Keys[keyIndex], inv.Keys[l-1]
app.storage.invites[code] = inv
}
return
}
email := app.storage.invites[code].SendTo
if strings.Contains(email, "Failed") || !strings.Contains(email, "@") {
email = ""
}
telegram := telegramEnabled && app.config.Section("telegram").Key("show_on_reg").MustBool(true)
discord := discordEnabled && app.config.Section("discord").Key("show_on_reg").MustBool(true)
matrix := matrixEnabled && app.config.Section("matrix").Key("show_on_reg").MustBool(true)
data := gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
"helpMessage": app.config.Section("ui").Key("help_message").String(),
"successMessage": app.config.Section("ui").Key("success_message").String(),
"jfLink": app.config.Section("ui").Key("redirect_url").String(),
"redirectToJellyfin": app.config.Section("ui").Key("auto_redirect").MustBool(false),
"validate": app.config.Section("password_validation").Key("enabled").MustBool(false),
"requirements": app.validator.getCriteria(),
"email": email,
"username": !app.config.Section("email").Key("no_username").MustBool(false),
"strings": app.storage.lang.User[lang].Strings,
"validationStrings": app.storage.lang.User[lang].validationStringsJSON,
"notifications": app.storage.lang.User[lang].notificationsJSON,
"code": code,
"confirmation": app.config.Section("email_confirmation").Key("enabled").MustBool(false),
"userExpiry": inv.UserExpiry,
"userExpiryMonths": inv.UserMonths,
"userExpiryDays": inv.UserDays,
"userExpiryHours": inv.UserHours,
"userExpiryMinutes": inv.UserMinutes,
"userExpiryMessage": app.storage.lang.User[lang].Strings.get("yourAccountIsValidUntil"),
"langName": lang,
"passwordReset": false,
"telegramEnabled": telegram,
"discordEnabled": discord,
"matrixEnabled": matrix,
"emailRequired": app.config.Section("email").Key("required").MustBool(false),
"captcha": app.config.Section("captcha").Key("enabled").MustBool(false),
"reCAPTCHA": app.config.Section("captcha").Key("recaptcha").MustBool(false),
"reCAPTCHASiteKey": app.config.Section("captcha").Key("recaptcha_site_key").MustString(""),
}
if telegram {
data["telegramPIN"] = app.telegram.NewAuthToken()
data["telegramUsername"] = app.telegram.username
data["telegramURL"] = app.telegram.link
data["telegramRequired"] = app.config.Section("telegram").Key("required").MustBool(false)
}
if matrix {
data["matrixRequired"] = app.config.Section("matrix").Key("required").MustBool(false)
data["matrixUser"] = app.matrix.userID
}
if discord {
data["discordPIN"] = app.discord.NewAuthToken()
data["discordUsername"] = app.discord.username
data["discordRequired"] = app.config.Section("discord").Key("required").MustBool(false)
data["discordSendPINMessage"] = template.HTML(app.storage.lang.User[lang].Strings.template("sendPINDiscord", tmpl{
"command": `<span class="text-black dark:text-white font-mono">/` + app.config.Section("discord").Key("start_command").MustString("start") + `</span>`,
"server_channel": app.discord.serverChannelName,
}))
data["discordServerName"] = app.discord.serverName
data["discordInviteLink"] = app.discord.inviteChannelName != ""
}
// if discordEnabled {
// pin := ""
// for _, token := range app.discord.tokens {
// if
gcHTML(gc, http.StatusOK, "form-loader.html", data)
}
func (app *appContext) NoRouteHandler(gc *gin.Context) {
app.pushResources(gc, OtherPage)
gcHTML(gc, 404, "404.html", gin.H{
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}