mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-11-13 22:00:10 +00:00
Harvey Tindall
a97bccc88f
added in the same way as ombi profiles. Most code is copy-pasted and adjusted from ombi (especially on web), so maybe this can be merged in the future. Also, profile names are url-escaped like announcement template names were not too long ago. API client has "LogRequestBodies" option which just dumps the request body when enabled (useful for recreating reqs in the jellyseerr swagger UI). User.Name() helper returns a name from all three possible values in the struct.
809 lines
28 KiB
Go
809 lines
28 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"html/template"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/golang-jwt/jwt"
|
|
"github.com/gomarkdown/markdown"
|
|
"github.com/hrfee/mediabrowser"
|
|
"github.com/lithammer/shortuuid/v3"
|
|
"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) {
|
|
// Hack to fix the common URL base /accounts
|
|
if app.URLBase == "/accounts" && strings.HasPrefix(gc.Request.URL.String(), "/accounts/user/") {
|
|
return ""
|
|
}
|
|
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)
|
|
jellyseerrEnabled := app.config.Section("jellyseerr").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)
|
|
fontLicense, err := fs.ReadFile(localFS, filepath.Join("web", "fonts", "OFL.txt"))
|
|
if err != nil {
|
|
app.debug.Printf("Failed to load OFL.txt: %s", err)
|
|
}
|
|
|
|
license += "---Hanken Grotesk---\n\n"
|
|
license += string(fontLicense)
|
|
|
|
if builtBy == "" {
|
|
builtBy = "???"
|
|
}
|
|
|
|
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,
|
|
"jellyseerrEnabled": jellyseerrEnabled,
|
|
"linkResetEnabled": app.config.Section("password_resets").Key("link_reset").MustBool(false),
|
|
"notifications": notificationsEnabled,
|
|
"version": version,
|
|
"commit": commit,
|
|
"buildTime": buildTime,
|
|
"builtBy": builtBy,
|
|
"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,
|
|
"userPageEnabled": app.config.Section("user_page").Key("enabled").MustBool(false),
|
|
"showUserPageLink": app.config.Section("user_page").Key("show_link").MustBool(true),
|
|
"referralsEnabled": app.config.Section("user_page").Key("enabled").MustBool(false) && app.config.Section("user_page").Key("referrals").MustBool(false),
|
|
"loginAppearance": app.config.Section("ui").Key("login_appearance").MustString("clear"),
|
|
})
|
|
}
|
|
|
|
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)
|
|
jellyseerrEnabled := app.config.Section("jellyseerr").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,
|
|
"jellyseerrEnabled": jellyseerrEnabled,
|
|
"pwrEnabled": app.config.Section("password_resets").Key("enabled").MustBool(false),
|
|
"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].validationStringsJSON,
|
|
"language": app.storage.lang.User[lang].JSON,
|
|
"langName": lang,
|
|
"jfLink": app.config.Section("ui").Key("redirect_url").String(),
|
|
"requirements": app.validator.getCriteria(),
|
|
"referralsEnabled": app.config.Section("user_page").Key("enabled").MustBool(false) && app.config.Section("user_page").Key("referrals").MustBool(false),
|
|
}
|
|
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 != ""
|
|
}
|
|
if data["linkResetEnabled"].(bool) {
|
|
data["resetPasswordUsername"] = app.config.Section("user_page").Key("allow_pwr_username").MustBool(true)
|
|
data["resetPasswordEmail"] = app.config.Section("user_page").Key("allow_pwr_email").MustBool(true)
|
|
data["resetPasswordContactMethod"] = app.config.Section("user_page").Key("allow_pwr_contact_method").MustBool(true)
|
|
}
|
|
|
|
pageMessagesExist := map[string]bool{}
|
|
pageMessages := map[string]CustomContent{}
|
|
pageMessages["Login"], pageMessagesExist["Login"] = app.storage.GetCustomContentKey("UserLogin")
|
|
pageMessages["Page"], pageMessagesExist["Page"] = app.storage.GetCustomContentKey("UserPage")
|
|
|
|
for name, msg := range pageMessages {
|
|
if !pageMessagesExist[name] {
|
|
continue
|
|
}
|
|
data[name+"MessageEnabled"] = msg.Enabled
|
|
if !msg.Enabled {
|
|
continue
|
|
}
|
|
// We don't template here, since the username is only known after login.
|
|
data[name+"MessageContent"] = template.HTML(markdown.ToHTML([]byte(msg.Content), nil, markdownRenderer))
|
|
}
|
|
|
|
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),
|
|
"jellyseerrEnabled": app.config.Section("jellyseerr").Key("enabled").MustBool(false),
|
|
"customSuccessCard": false,
|
|
}
|
|
pwr, isInternal := app.internalPWRs[pin]
|
|
// if isInternal && setPassword {
|
|
if 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
|
|
data["captcha"] = app.config.Section("captcha").Key("enabled").MustBool(false)
|
|
data["reCAPTCHA"] = app.config.Section("captcha").Key("recaptcha").MustBool(false)
|
|
data["reCAPTCHASiteKey"] = app.config.Section("captcha").Key("recaptcha_site_key").MustString("")
|
|
data["pwrPIN"] = pin
|
|
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 && !setPassword {
|
|
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)
|
|
}
|
|
|
|
// Only log PWRs we know the user for.
|
|
if username != "" {
|
|
jfUser, status, err := app.jf.UserByName(username, false)
|
|
if err == nil && status == 200 {
|
|
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
|
Type: ActivityResetPassword,
|
|
UserID: jfUser.ID,
|
|
SourceType: ActivityUser,
|
|
Source: jfUser.ID,
|
|
Time: time.Now(),
|
|
}, gc, true)
|
|
}
|
|
}
|
|
|
|
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")
|
|
isPWR := gc.Query("pwr") == "true"
|
|
captchaID := gc.Param("captchaID")
|
|
var inv Invite
|
|
var capt Captcha
|
|
ok := true
|
|
if !isPWR {
|
|
inv, ok = app.storage.GetInvitesKey(code)
|
|
if !ok {
|
|
gcHTML(gc, 404, "invalidCode.html", gin.H{
|
|
"urlBase": app.getURLBase(gc),
|
|
"cssClass": app.cssClass,
|
|
"cssVersion": cssVersion,
|
|
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
|
|
})
|
|
}
|
|
if inv.Captchas != nil {
|
|
capt, ok = inv.Captchas[captchaID]
|
|
} else {
|
|
ok = false
|
|
}
|
|
} else {
|
|
capt, ok = app.pwrCaptchas[code]
|
|
}
|
|
if !ok {
|
|
respondBool(400, false, gc)
|
|
return
|
|
}
|
|
gc.Data(200, "image/png", capt.Image)
|
|
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")
|
|
isPWR := gc.Query("pwr") == "true"
|
|
var inv Invite
|
|
ok := true
|
|
if !isPWR {
|
|
inv, ok = app.storage.GetInvitesKey(code)
|
|
}
|
|
|
|
if !ok {
|
|
gcHTML(gc, 404, "invalidCode.html", gin.H{
|
|
"urlBase": app.getURLBase(gc),
|
|
"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 !isPWR && inv.Captchas == nil {
|
|
inv.Captchas = map[string]Captcha{}
|
|
}
|
|
captchaID := genAuthToken()
|
|
var buf bytes.Buffer
|
|
if err := capt.WriteImage(bufio.NewWriter(&buf)); err != nil {
|
|
app.err.Printf("Failed to render captcha: %v", err)
|
|
respondBool(500, false, gc)
|
|
return
|
|
}
|
|
if isPWR {
|
|
if app.pwrCaptchas == nil {
|
|
app.pwrCaptchas = map[string]Captcha{}
|
|
}
|
|
app.pwrCaptchas[code] = Captcha{
|
|
Answer: capt.Text,
|
|
Image: buf.Bytes(),
|
|
Generated: time.Now(),
|
|
}
|
|
} else {
|
|
inv.Captchas[captchaID] = Captcha{
|
|
Answer: capt.Text,
|
|
Image: buf.Bytes(),
|
|
Generated: time.Now(),
|
|
}
|
|
app.storage.SetInvitesKey(code, inv)
|
|
}
|
|
gc.JSON(200, genCaptchaDTO{captchaID})
|
|
return
|
|
}
|
|
|
|
func (app *appContext) verifyCaptcha(code, id, text string, isPWR bool) bool {
|
|
reCAPTCHA := app.config.Section("captcha").Key("recaptcha").MustBool(false)
|
|
if !reCAPTCHA {
|
|
// internal CAPTCHA
|
|
var c Captcha
|
|
ok := true
|
|
if !isPWR {
|
|
inv, ok := app.storage.GetInvitesKey(code)
|
|
if !ok || (!isPWR && inv.Captchas == nil) {
|
|
app.debug.Printf("Couldn't find invite \"%s\"", code)
|
|
return false
|
|
}
|
|
c, ok = inv.Captchas[id]
|
|
} else {
|
|
c, ok = app.pwrCaptchas[code]
|
|
}
|
|
if !ok {
|
|
app.debug.Printf("Couldn't find Captcha \"%s\"", id)
|
|
return false
|
|
}
|
|
return strings.ToLower(c.Answer) == 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")
|
|
isPWR := gc.Query("pwr") == "true"
|
|
captchaID := gc.Param("captchaID")
|
|
text := gc.Param("text")
|
|
var inv Invite
|
|
var capt Captcha
|
|
var ok bool
|
|
if !isPWR {
|
|
inv, ok = app.storage.GetInvitesKey(code)
|
|
if !ok {
|
|
gcHTML(gc, 404, "invalidCode.html", gin.H{
|
|
"urlBase": app.getURLBase(gc),
|
|
"cssClass": app.cssClass,
|
|
"cssVersion": cssVersion,
|
|
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
|
|
})
|
|
return
|
|
}
|
|
if inv.Captchas != nil {
|
|
capt, ok = inv.Captchas[captchaID]
|
|
} else {
|
|
ok = false
|
|
}
|
|
} else {
|
|
capt, ok = app.pwrCaptchas[code]
|
|
}
|
|
if !ok {
|
|
respondBool(400, false, gc)
|
|
return
|
|
}
|
|
if strings.ToLower(capt.Answer) != 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.GetInvitesKey(code)
|
|
if !ok {
|
|
gcHTML(gc, 404, "invalidCode.html", gin.H{
|
|
"urlBase": app.getURLBase(gc),
|
|
"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) {
|
|
fail := func() {
|
|
gcHTML(gc, 404, "404.html", gin.H{
|
|
"urlBase": app.getURLBase(gc),
|
|
"cssClass": app.cssClass,
|
|
"cssVersion": cssVersion,
|
|
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
|
|
})
|
|
}
|
|
var req newUserDTO
|
|
if app.ConfirmationKeys == nil {
|
|
fail()
|
|
return
|
|
}
|
|
|
|
invKeys, ok := app.ConfirmationKeys[code]
|
|
if !ok {
|
|
fail()
|
|
return
|
|
}
|
|
req, ok = invKeys[key]
|
|
if !ok {
|
|
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)
|
|
expiry := time.Unix(int64(claims["exp"].(float64)), 0)
|
|
if !(ok && token.Valid && claims["invite"].(string) == code && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) {
|
|
fail()
|
|
app.debug.Printf("Invalid key")
|
|
return
|
|
}
|
|
f, success := app.newUser(req, true, gc)
|
|
if !success {
|
|
app.err.Printf("Failed to create new user")
|
|
// Not meant for us. Calling this will be a mess, but at least it might give us some information.
|
|
f(gc)
|
|
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{
|
|
"urlBase": app.getURLBase(gc),
|
|
"cssClass": app.cssClass,
|
|
"cssVersion": cssVersion,
|
|
"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,
|
|
})
|
|
}
|
|
delete(invKeys, key)
|
|
app.confirmationKeysLock.Lock()
|
|
app.ConfirmationKeys[code] = invKeys
|
|
app.confirmationKeysLock.Unlock()
|
|
return
|
|
}
|
|
email := ""
|
|
if invite, ok := app.storage.GetInvitesKey(code); ok {
|
|
email = invite.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)
|
|
|
|
userPageAddress := app.config.Section("invite_emails").Key("url_base").String()
|
|
if userPageAddress == "" {
|
|
userPageAddress = app.config.Section("password_resets").Key("url_base").String()
|
|
}
|
|
userPageAddress += "/my/account"
|
|
|
|
fromUser := ""
|
|
if inv.ReferrerJellyfinID != "" {
|
|
sender, status, err := app.jf.UserByID(inv.ReferrerJellyfinID, false)
|
|
if status == 200 && err == nil {
|
|
fromUser = sender.Name
|
|
}
|
|
}
|
|
|
|
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,
|
|
"customSuccessCard": 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(""),
|
|
"userPageEnabled": app.config.Section("user_page").Key("enabled").MustBool(false),
|
|
"userPageAddress": userPageAddress,
|
|
"fromUser": fromUser,
|
|
}
|
|
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 msg, ok := app.storage.GetCustomContentKey("PostSignupCard"); ok && msg.Enabled {
|
|
data["customSuccessCard"] = true
|
|
// We don't template here, since the username is only known after login.
|
|
data["customSuccessCardContent"] = template.HTML(markdown.ToHTML(
|
|
[]byte(templateEmail(
|
|
msg.Content,
|
|
msg.Variables,
|
|
msg.Conditionals,
|
|
map[string]interface{}{
|
|
"username": "{username}",
|
|
"myAccountURL": userPageAddress,
|
|
},
|
|
),
|
|
), nil, markdownRenderer,
|
|
))
|
|
}
|
|
|
|
// 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{
|
|
"urlBase": app.getURLBase(gc),
|
|
"cssClass": app.cssClass,
|
|
"cssVersion": cssVersion,
|
|
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
|
|
})
|
|
}
|