mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-12-22 17:10:10 +00:00
Harvey Tindall
ad40d7d8a9
The password reset daemon wasn't being closed on restarts, so an extra pwr would be sent w/ every restart. Restarts & Interrupts (Ctrl-C) rarely worked, as there were multiple listeners to the "RESTART" channel, and I didn't know the message was consumed by whoever got it first, meaning if the main thread didn't get it first, the app wouldn't quit. Listeners are now registered, and the restart message is re-broadcasted until everyone's got it. Fixes #264
536 lines
16 KiB
Go
536 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/hrfee/mediabrowser"
|
|
"github.com/itchyny/timefmt-go"
|
|
"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)
|
|
valid := true
|
|
for _, val := range validation {
|
|
if !val {
|
|
valid = false
|
|
}
|
|
}
|
|
if !valid || req.PIN == "" {
|
|
// 200 bcs idk what i did in js
|
|
app.info.Printf("%s: Password reset failed: Invalid password", req.PIN)
|
|
gc.JSON(400, validation)
|
|
return
|
|
}
|
|
isInternal := false
|
|
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
|
|
}
|
|
} 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
|
|
}
|
|
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 || 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.Form.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":
|
|
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":
|
|
for key, lang := range app.storage.lang.Form {
|
|
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" {
|
|
gc.JSON(200, app.storage.lang.Form[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
|
|
}
|