mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-12-22 09:00:10 +00:00
556 lines
16 KiB
Go
556 lines
16 KiB
Go
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
|
|
}
|