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) gcHTML(gc, http.StatusOK, "user.html", gin.H{ "urlBase": app.getURLBase(gc), "cssClass": app.cssClass, "cssVersion": cssVersion, "contactMessage": app.config.Section("ui").Key("contact_message").String(), "emailEnabled": emailEnabled, "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.Admin[lang].Strings, "quantityStrings": app.storage.lang.Admin[lang].QuantityStrings, "language": app.storage.lang.Admin[lang].JSON, "langName": lang, }) } 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": `/` + app.config.Section("discord").Key("start_command").MustString("start") + ``, "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(), }) }