package main import ( "strings" "time" "github.com/gin-gonic/gin" "github.com/hrfee/mediabrowser" "github.com/itchyny/timefmt-go" "github.com/lithammer/shortuuid/v3" "gopkg.in/ini.v1" ) func respond(code int, message string, gc *gin.Context) { resp := stringResponse{} if code == 200 || code == 204 { resp.Response = message } else { resp.Error = message } gc.JSON(code, resp) gc.Abort() } func respondBool(code int, val bool, gc *gin.Context) { resp := boolResponse{} if !val { resp.Error = true } else { resp.Success = true } gc.JSON(code, resp) gc.Abort() } func (app *appContext) loadStrftime() { app.datePattern = app.config.Section("messages").Key("date_format").String() app.timePattern = `%H:%M` if val, _ := app.config.Section("messages").Key("use_24h").Bool(); !val { app.timePattern = `%I:%M %p` } return } func (app *appContext) prettyTime(dt time.Time) (date, time string) { date = timefmt.Format(dt, app.datePattern) time = timefmt.Format(dt, app.timePattern) return } func (app *appContext) formatDatetime(dt time.Time) string { d, t := app.prettyTime(dt) return d + " " + t } // https://stackoverflow.com/questions/36530251/time-since-with-months-and-years/36531443#36531443 THANKS func timeDiff(a, b time.Time) (year, month, day, hour, min, sec int) { if a.Location() != b.Location() { b = b.In(a.Location()) } if a.After(b) { a, b = b, a } y1, M1, d1 := a.Date() y2, M2, d2 := b.Date() h1, m1, s1 := a.Clock() h2, m2, s2 := b.Clock() year = int(y2 - y1) month = int(M2 - M1) day = int(d2 - d1) hour = int(h2 - h1) min = int(m2 - m1) sec = int(s2 - s1) // Normalize negative values if sec < 0 { sec += 60 min-- } if min < 0 { min += 60 hour-- } if hour < 0 { hour += 24 day-- } if day < 0 { // days in month: t := time.Date(y1, M1, 32, 0, 0, 0, 0, time.UTC) day += 32 - t.Day() month-- } if month < 0 { month += 12 year-- } return } // Routes from now on! // @Summary Resets a user's password with a PIN, and optionally set a new password if given. // @Produce json // @Success 200 {object} boolResponse // @Success 400 {object} PasswordValidation // @Failure 500 {object} boolResponse // @Param ResetPasswordDTO body ResetPasswordDTO true "Pin and optional Password." // @Router /reset [post] // @tags Other func (app *appContext) ResetSetPassword(gc *gin.Context) { var req ResetPasswordDTO gc.BindJSON(&req) validation := app.validator.validate(req.Password) captcha := app.config.Section("captcha").Key("enabled").MustBool(false) valid := true for _, val := range validation { if !val { valid = false } } if !valid || req.PIN == "" { app.info.Printf("%s: Password reset failed: Invalid password", req.PIN) gc.JSON(400, validation) return } isInternal := false if captcha && !app.verifyCaptcha(req.PIN, req.PIN, req.CaptchaText, true) { app.info.Printf("%s: PWR Failed: Captcha Incorrect", req.PIN) respond(400, "errorCaptcha", gc) return } var userID, username string if reset, ok := app.internalPWRs[req.PIN]; ok { isInternal = true if time.Now().After(reset.Expiry) { app.info.Printf("Password reset failed: PIN \"%s\" has expired", reset.PIN) respondBool(401, false, gc) delete(app.internalPWRs, req.PIN) return } userID = reset.ID username = reset.Username status, err := app.jf.ResetPasswordAdmin(userID) if !(status == 200 || status == 204) || err != nil { app.err.Printf("Password Reset failed (%d): %v", status, err) respondBool(status, false, gc) return } delete(app.internalPWRs, req.PIN) } else { resp, status, err := app.jf.ResetPassword(req.PIN) if status != 200 || err != nil || !resp.Success { app.err.Printf("Password Reset failed (%d): %v", status, err) respondBool(status, false, gc) return } if req.Password == "" || len(resp.UsersReset) == 0 { respondBool(200, false, gc) return } username = resp.UsersReset[0] } var user mediabrowser.User var status int var err error if isInternal { user, status, err = app.jf.UserByID(userID, false) } else { user, status, err = app.jf.UserByName(username, false) } if status != 200 || err != nil { app.err.Printf("Failed to get user \"%s\" (%d): %v", username, status, err) respondBool(500, false, gc) return } app.storage.SetActivityKey(shortuuid.New(), Activity{ Type: ActivityResetPassword, UserID: user.ID, SourceType: ActivityUser, Source: user.ID, Time: time.Now(), }, gc, true) prevPassword := req.PIN if isInternal { prevPassword = "" } status, err = app.jf.SetPassword(user.ID, prevPassword, req.Password) if !(status == 200 || status == 204) || err != nil { app.err.Printf("Failed to change password for \"%s\" (%d): %v", username, status, err) respondBool(500, false, gc) return } if app.config.Section("ombi").Key("enabled").MustBool(false) { // Silently fail for changing ombi passwords if (status != 200 && status != 204) || err != nil { app.err.Printf("Failed to get user \"%s\" from jellyfin/emby (%d): %v", username, status, err) respondBool(200, true, gc) return } ombiUser, status, err := app.getOmbiUser(user.ID) if status != 200 || err != nil { app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", username, status, err) respondBool(200, true, gc) return } ombiUser["password"] = req.Password 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) respondBool(200, true, gc) return } app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"]) } respondBool(200, true, gc) } // @Summary Get jfa-go configuration. // @Produce json // @Success 200 {object} settings "Uses the same format as config-base.json" // @Router /config [get] // @Security Bearer // @tags Configuration func (app *appContext) GetConfig(gc *gin.Context) { app.info.Println("Config requested") resp := app.configBase // Load language options formOptions := app.storage.lang.User.getOptions() fl := resp.Sections["ui"].Settings["language-form"] fl.Options = formOptions fl.Value = app.config.Section("ui").Key("language-form").MustString("en-us") pwrOptions := app.storage.lang.PasswordReset.getOptions() pl := resp.Sections["password_resets"].Settings["language"] pl.Options = pwrOptions pl.Value = app.config.Section("password_resets").Key("language").MustString("en-us") adminOptions := app.storage.lang.Admin.getOptions() al := resp.Sections["ui"].Settings["language-admin"] al.Options = adminOptions al.Value = app.config.Section("ui").Key("language-admin").MustString("en-us") emailOptions := app.storage.lang.Email.getOptions() el := resp.Sections["email"].Settings["language"] el.Options = emailOptions el.Value = app.config.Section("email").Key("language").MustString("en-us") telegramOptions := app.storage.lang.Email.getOptions() tl := resp.Sections["telegram"].Settings["language"] tl.Options = telegramOptions tl.Value = app.config.Section("telegram").Key("language").MustString("en-us") if updater == "" { delete(resp.Sections, "updates") for i, v := range resp.Order { if v == "updates" { resp.Order = append(resp.Order[:i], resp.Order[i+1:]...) break } } } if PLATFORM == "windows" { delete(resp.Sections["smtp"].Settings, "ssl_cert") for i, v := range resp.Sections["smtp"].Order { if v == "ssl_cert" { sect := resp.Sections["smtp"] sect.Order = append(sect.Order[:i], sect.Order[i+1:]...) resp.Sections["smtp"] = sect } } } if !MatrixE2EE() { delete(resp.Sections["matrix"].Settings, "encryption") for i, v := range resp.Sections["matrix"].Order { if v == "encryption" { sect := resp.Sections["matrix"] sect.Order = append(sect.Order[:i], sect.Order[i+1:]...) resp.Sections["matrix"] = sect } } } for sectName, section := range resp.Sections { for settingName, setting := range section.Settings { val := app.config.Section(sectName).Key(settingName) s := resp.Sections[sectName].Settings[settingName] switch setting.Type { case "text", "email", "select", "password", "note": s.Value = val.MustString("") case "number": s.Value = val.MustInt(0) case "bool": s.Value = val.MustBool(false) } resp.Sections[sectName].Settings[settingName] = s } } if discordEnabled { r, err := app.discord.ListRoles() if err == nil { roles := make([][2]string, len(r)+1) roles[0] = [2]string{"", "None"} for i, role := range r { roles[i+1] = role } s := resp.Sections["discord"].Settings["apply_role"] s.Options = roles resp.Sections["discord"].Settings["apply_role"] = s } } resp.Sections["ui"].Settings["language-form"] = fl resp.Sections["ui"].Settings["language-admin"] = al resp.Sections["email"].Settings["language"] = el resp.Sections["password_resets"].Settings["language"] = pl resp.Sections["telegram"].Settings["language"] = tl resp.Sections["discord"].Settings["language"] = tl resp.Sections["matrix"].Settings["language"] = tl // if setting := resp.Sections["invite_emails"].Settings["url_base"]; setting.Value == "" { // setting.Value = strings.TrimSuffix(resp.Sections["password_resets"].Settings["url_base"].Value.(string), "/invite") // resp.Sections["invite_emails"].Settings["url_base"] = setting // } // if setting := resp.Sections["password_resets"].Settings["url_base"]; setting.Value == "" { // setting.Value = strings.TrimSuffix(resp.Sections["invite_emails"].Settings["url_base"].Value.(string), "/invite") // resp.Sections["password_resets"].Settings["url_base"] = setting // } gc.JSON(200, resp) } // @Summary Modify app config. // @Produce json // @Param appConfig body configDTO true "Config split into sections as in config.ini, all values as strings." // @Success 200 {object} boolResponse // @Failure 500 {object} stringResponse // @Router /config [post] // @Security Bearer // @tags Configuration func (app *appContext) ModifyConfig(gc *gin.Context) { app.info.Println("Config modification requested") var req configDTO gc.BindJSON(&req) // Load a new config, as we set various default values in app.config that shouldn't be stored. tempConfig, _ := ini.Load(app.configPath) for section, settings := range req { if section != "restart-program" { _, err := tempConfig.GetSection(section) if err != nil { tempConfig.NewSection(section) } for setting, value := range settings.(map[string]interface{}) { if section == "email" && setting == "method" && value == "disabled" { value = "" } if (section == "discord" || section == "matrix") && setting == "language" { tempConfig.Section("telegram").Key("language").SetValue(value.(string)) } else if value.(string) != app.config.Section(section).Key(setting).MustString("") { tempConfig.Section(section).Key(setting).SetValue(value.(string)) } } } } tempConfig.Section("").Key("first_run").SetValue("false") if err := tempConfig.SaveTo(app.configPath); err != nil { app.err.Printf("Failed to save config to \"%s\": %v", app.configPath, err) respond(500, err.Error(), gc) return } app.debug.Println("Config saved") gc.JSON(200, map[string]bool{"success": true}) if req["restart-program"] != nil && req["restart-program"].(bool) { app.info.Println("Restarting...") if TRAY { TRAYRESTART <- true } else { RESTART <- true } // Safety Sleep (Ensure shutdown tasks get done) time.Sleep(time.Second) } app.loadConfig() // Reinitialize password validator on config change, as opposed to every applicable request like in python. if _, ok := req["password_validation"]; ok { app.debug.Println("Reinitializing validator") validatorConf := ValidatorConf{ "length": app.config.Section("password_validation").Key("min_length").MustInt(0), "uppercase": app.config.Section("password_validation").Key("upper").MustInt(0), "lowercase": app.config.Section("password_validation").Key("lower").MustInt(0), "number": app.config.Section("password_validation").Key("number").MustInt(0), "special": app.config.Section("password_validation").Key("special").MustInt(0), } if !app.config.Section("password_validation").Key("enabled").MustBool(false) { for key := range validatorConf { validatorConf[key] = 0 } } app.validator.init(validatorConf) } } // @Summary Returns whether there's a new update, and extra info if there is. // @Produce json // @Success 200 {object} checkUpdateDTO // @Router /config/update [get] // @Security Bearer // @tags Configuration func (app *appContext) CheckUpdate(gc *gin.Context) { if !app.newUpdate { app.update = Update{} } gc.JSON(200, checkUpdateDTO{New: app.newUpdate, Update: app.update}) } // @Summary Apply an update. // @Produce json // @Success 200 {object} boolResponse // @Success 400 {object} stringResponse // @Success 500 {object} boolResponse // @Router /config/update [post] // @Security Bearer // @tags Configuration func (app *appContext) ApplyUpdate(gc *gin.Context) { if !app.update.CanUpdate { respond(400, "Update is manual", gc) return } err := app.update.update() if err != nil { app.err.Printf("Failed to apply update: %v", err) respondBool(500, false, gc) return } if PLATFORM == "windows" { respondBool(500, true, gc) return } respondBool(200, true, gc) app.HardRestart() } // @Summary Logout by deleting refresh token from cookies. // @Produce json // @Success 200 {object} boolResponse // @Failure 500 {object} stringResponse // @Router /logout [post] // @Security Bearer // @tags Other func (app *appContext) Logout(gc *gin.Context) { cookie, err := gc.Cookie("refresh") if err != nil { app.debug.Printf("Couldn't get cookies: %s", err) respond(500, "Couldn't fetch cookies", gc) return } app.invalidTokens = append(app.invalidTokens, cookie) gc.SetCookie("refresh", "invalid", -1, "/", gc.Request.URL.Hostname(), true, true) respondBool(200, true, gc) } // @Summary Returns a map of available language codes to their full names, usable in the lang query parameter. // @Produce json // @Success 200 {object} langDTO // @Failure 500 {object} stringResponse // @Param page path string true "admin/form/setup/email/pwr" // @Router /lang/{page} [get] // @tags Other func (app *appContext) GetLanguages(gc *gin.Context) { page := gc.Param("page") resp := langDTO{} switch page { case "form", "user": for key, lang := range app.storage.lang.User { resp[key] = lang.Meta.Name } case "admin": for key, lang := range app.storage.lang.Admin { resp[key] = lang.Meta.Name } case "setup": for key, lang := range app.storage.lang.Setup { resp[key] = lang.Meta.Name } case "email": for key, lang := range app.storage.lang.Email { resp[key] = lang.Meta.Name } case "pwr": for key, lang := range app.storage.lang.PasswordReset { resp[key] = lang.Meta.Name } } if len(resp) == 0 { respond(500, "Couldn't get languages", gc) return } gc.JSON(200, resp) } // @Summary Serves a translations for pages "admin" or "form". // @Produce json // @Success 200 {object} adminLang // @Failure 400 {object} boolResponse // @Param page path string true "admin or form." // @Param language path string true "language code, e.g en-us." // @Router /lang/{page}/{language} [get] // @tags Other func (app *appContext) ServeLang(gc *gin.Context) { page := gc.Param("page") lang := strings.Replace(gc.Param("file"), ".json", "", 1) if page == "admin" { gc.JSON(200, app.storage.lang.Admin[lang]) return } else if page == "form" || page == "user" { gc.JSON(200, app.storage.lang.User[lang]) return } respondBool(400, false, gc) } // @Summary Restarts the program. No response means success. // @Router /restart [post] // @Security Bearer // @tags Other func (app *appContext) restart(gc *gin.Context) { app.info.Println("Restarting...") err := app.Restart() if err != nil { app.err.Printf("Couldn't restart, try restarting manually: %v", err) } } // @Summary Returns the last 100 lines of the log. // @Router /log [get] // @Success 200 {object} LogDTO // @Security Bearer // @tags Other func (app *appContext) GetLog(gc *gin.Context) { gc.JSON(200, LogDTO{lineCache.String()}) } // no need to syscall.exec anymore! func (app *appContext) Restart() error { if TRAY { TRAYRESTART <- true } else { RESTART <- true } // Safety Sleep (Ensure shutdown tasks get done) time.Sleep(time.Second) return nil }