mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-11-09 20:00:12 +00:00
Harvey Tindall
f063b970b4
config-base.yaml is almost identical to json version, except there's no "order" field, as "sections" and "settings" fields are now lists themselves and so Go can parse the correct order. As such, removed enumerate_config.py. Also, rewrote scripts/generate_ini.py in Go as scripts/ini/. Config structure in Go form is now in common/config.go, and is used by jfa-go and the ini script. app.configBase is now untouched once read from config-base.yaml, and instead copied to and patched in app.patchedConfig. Patching occurs at program start and config modification, so GetConfig is now just a couple of lines. Discord role patching still occurs in GetConfig, as the available roles can change regularly. Also added new "Disabled" field to sections, to avoid the nightmare of deleting from an array.
479 lines
13 KiB
Go
479 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/hrfee/jfa-go/common"
|
|
lm "github.com/hrfee/jfa-go/logmessages"
|
|
"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(lm.FailedChangePassword, lm.Jellyfin, "?", lm.InvalidPassword)
|
|
gc.JSON(400, validation)
|
|
return
|
|
}
|
|
isInternal := false
|
|
|
|
if captcha && !app.verifyCaptcha(req.PIN, req.PIN, req.CaptchaText, true) {
|
|
app.info.Printf(lm.FailedChangePassword, lm.Jellyfin, "?", lm.IncorrectCaptcha)
|
|
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(lm.FailedChangePassword, lm.Jellyfin, "?", fmt.Sprintf(lm.ExpiredPIN, reset.PIN))
|
|
respondBool(401, false, gc)
|
|
delete(app.internalPWRs, req.PIN)
|
|
return
|
|
}
|
|
userID = reset.ID
|
|
username = reset.Username
|
|
|
|
err := app.jf.ResetPasswordAdmin(userID)
|
|
if err != nil {
|
|
app.err.Printf(lm.FailedChangePassword, lm.Jellyfin, userID, err)
|
|
respondBool(500, false, gc)
|
|
return
|
|
}
|
|
delete(app.internalPWRs, req.PIN)
|
|
} else {
|
|
resp, err := app.jf.ResetPassword(req.PIN)
|
|
if err != nil || !resp.Success {
|
|
app.err.Printf(lm.FailedChangePassword, lm.Jellyfin, userID, err)
|
|
respondBool(500, false, gc)
|
|
return
|
|
}
|
|
if req.Password == "" || len(resp.UsersReset) == 0 {
|
|
respondBool(200, false, gc)
|
|
return
|
|
}
|
|
username = resp.UsersReset[0]
|
|
}
|
|
|
|
var user mediabrowser.User
|
|
var err error
|
|
if isInternal {
|
|
user, err = app.jf.UserByID(userID, false)
|
|
} else {
|
|
user, err = app.jf.UserByName(username, false)
|
|
}
|
|
if err != nil {
|
|
app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, 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 = ""
|
|
}
|
|
err = app.jf.SetPassword(user.ID, prevPassword, req.Password)
|
|
if err != nil {
|
|
app.err.Printf(lm.FailedChangePassword, lm.Jellyfin, user.ID, err)
|
|
respondBool(500, false, gc)
|
|
return
|
|
}
|
|
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
|
// This makes no sense so has been commented out.
|
|
// It probably did at some point in the past.
|
|
/* Silently fail for changing ombi passwords
|
|
if (status != 200 && status != 204) || err != nil {
|
|
app.err.Printf(lm.FailedGetUser, user.ID, lm.Jellyfin, err)
|
|
respondBool(200, true, gc)
|
|
return
|
|
} */
|
|
ombiUser, err := app.getOmbiUser(user.ID)
|
|
if err != nil {
|
|
app.err.Printf(lm.FailedGetUser, user.ID, lm.Ombi, err)
|
|
respondBool(200, true, gc)
|
|
return
|
|
}
|
|
ombiUser["password"] = req.Password
|
|
err = app.ombi.ModifyUser(ombiUser)
|
|
if err != nil {
|
|
app.err.Printf(lm.FailedChangePassword, lm.Ombi, user.ID, err)
|
|
respondBool(200, true, gc)
|
|
return
|
|
}
|
|
app.debug.Printf(lm.ChangePassword, lm.Ombi, user.ID)
|
|
}
|
|
respondBool(200, true, gc)
|
|
}
|
|
|
|
// @Summary Get jfa-go configuration.
|
|
// @Produce json
|
|
// @Success 200 {object} common.Config "Uses the same format as config-base.json"
|
|
// @Router /config [get]
|
|
// @Security Bearer
|
|
// @tags Configuration
|
|
func (app *appContext) GetConfig(gc *gin.Context) {
|
|
if discordEnabled {
|
|
app.PatchConfigDiscordRoles()
|
|
}
|
|
gc.JSON(200, app.patchedConfig)
|
|
}
|
|
|
|
// @Summary Modify app config.
|
|
// @Produce json
|
|
// @Param appConfig body configDTO true "Config split into sections as in config.ini, all values as strings (lists split with | delimiter)."
|
|
// @Success 200 {object} boolResponse
|
|
// @Failure 500 {object} stringResponse
|
|
// @Router /config [post]
|
|
// @Security Bearer
|
|
// @tags Configuration
|
|
func (app *appContext) ModifyConfig(gc *gin.Context) {
|
|
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.ShadowLoad(app.configPath)
|
|
for _, section := range app.configBase.Sections {
|
|
ns, ok := req[section.Section]
|
|
if !ok {
|
|
continue
|
|
}
|
|
newSection := ns.(map[string]any)
|
|
iniSection, err := tempConfig.GetSection(section.Section)
|
|
if err != nil {
|
|
iniSection, _ = tempConfig.NewSection(section.Section)
|
|
}
|
|
for _, setting := range section.Settings {
|
|
newValue, ok := newSection[setting.Setting]
|
|
if !ok {
|
|
continue
|
|
}
|
|
// Patch disabled to actually be an empty string
|
|
if section.Section == "email" && setting.Setting == "method" && newValue == "disabled" {
|
|
newValue = ""
|
|
}
|
|
// Copy language preference for chatbots to root one in "telegram"
|
|
if (section.Section == "discord" || section.Section == "matrix") && setting.Setting == "language" {
|
|
iniSection.Key("language").SetValue(newValue.(string))
|
|
} else if setting.Type == common.ListType {
|
|
splitValues := strings.Split(newValue.(string), "|")
|
|
// Delete the key first to get rid of any shadow values
|
|
iniSection.DeleteKey(setting.Setting)
|
|
for i, v := range splitValues {
|
|
if i == 0 {
|
|
iniSection.Key(setting.Setting).SetValue(v)
|
|
} else {
|
|
iniSection.Key(setting.Setting).AddShadow(v)
|
|
}
|
|
}
|
|
|
|
} else if newValue.(string) != iniSection.Key(setting.Setting).MustString("") {
|
|
iniSection.Key(setting.Setting).SetValue(newValue.(string))
|
|
}
|
|
}
|
|
}
|
|
|
|
tempConfig.Section("").Key("first_run").SetValue("false")
|
|
if err := tempConfig.SaveTo(app.configPath); err != nil {
|
|
app.err.Printf(lm.FailedWriting, app.configPath, err)
|
|
respond(500, err.Error(), gc)
|
|
return
|
|
}
|
|
app.info.Printf(lm.ModifyConfig, app.configPath)
|
|
gc.JSON(200, map[string]bool{"success": true})
|
|
if req["restart-program"] != nil && req["restart-program"].(bool) {
|
|
app.Restart()
|
|
}
|
|
app.loadConfig()
|
|
// Patch new settings for next GetConfig
|
|
app.PatchConfigBase()
|
|
// Reinitialize password validator on config change, as opposed to every applicable request like in python.
|
|
if _, ok := req["password_validation"]; ok {
|
|
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 {
|
|
app.info.Printf(lm.FailedApplyUpdate, lm.UpdateManual)
|
|
respond(400, lm.UpdateManual, gc)
|
|
return
|
|
}
|
|
err := app.update.update()
|
|
if err != nil {
|
|
app.err.Printf(lm.FailedApplyUpdate, 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 {
|
|
msg := fmt.Sprintf(lm.FailedGetCookies, "refresh", err)
|
|
app.debug.Println(msg)
|
|
respond(500, msg, 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.Restart()
|
|
}
|
|
|
|
// @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 {
|
|
app.info.Println(lm.Restarting)
|
|
if TRAY {
|
|
TRAYRESTART <- true
|
|
} else {
|
|
RESTART <- true
|
|
}
|
|
// Safety Sleep (Ensure shutdown tasks get done)
|
|
time.Sleep(time.Second)
|
|
return nil
|
|
}
|