mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-12-22 17:10:10 +00:00
Harvey Tindall
ba67fa7536
When enabled, an account for the user is created on both Jellyfin and Ombi. Account defaults can be stored similarly to jf.
393 lines
12 KiB
Go
393 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/gin-contrib/pprof"
|
|
"github.com/gin-contrib/static"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/lithammer/shortuuid/v3"
|
|
"gopkg.in/ini.v1"
|
|
)
|
|
|
|
// Username is JWT!
|
|
type User struct {
|
|
UserID string `json:"id"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type appContext struct {
|
|
// defaults *Config
|
|
config *ini.File
|
|
config_path string
|
|
configBase_path string
|
|
configBase map[string]interface{}
|
|
data_path string
|
|
local_path string
|
|
cssFile string
|
|
bsVersion int
|
|
jellyfinLogin bool
|
|
users []User
|
|
invalidTokens []string
|
|
jf *Jellyfin
|
|
authJf *Jellyfin
|
|
ombi *Ombi
|
|
datePattern string
|
|
timePattern string
|
|
storage Storage
|
|
validator Validator
|
|
email Emailer
|
|
info, debug, err *log.Logger
|
|
host string
|
|
port int
|
|
version string
|
|
quit chan os.Signal
|
|
}
|
|
|
|
func GenerateSecret(length int) (string, error) {
|
|
bytes := make([]byte, length)
|
|
_, err := rand.Read(bytes)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return base64.URLEncoding.EncodeToString(bytes), err
|
|
}
|
|
|
|
func setGinLogger(router *gin.Engine, debugMode bool) {
|
|
if debugMode {
|
|
router.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
|
return fmt.Sprintf("[GIN/DEBUG] %s: %s(%s) => %d in %s; %s\n",
|
|
param.TimeStamp.Format("15:04:05"),
|
|
param.Method,
|
|
param.Path,
|
|
param.StatusCode,
|
|
param.Latency,
|
|
func() string {
|
|
if param.ErrorMessage != "" {
|
|
return "Error: " + param.ErrorMessage
|
|
}
|
|
return ""
|
|
}(),
|
|
)
|
|
}))
|
|
} else {
|
|
router.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
|
return fmt.Sprintf("[GIN] %s(%s) => %d\n",
|
|
param.Method,
|
|
param.Path,
|
|
param.StatusCode,
|
|
)
|
|
}))
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
fmt.Printf("jfa-go version: %s (%s)\n", VERSION, COMMIT)
|
|
// app encompasses essentially all useful functions.
|
|
app := new(appContext)
|
|
|
|
/*
|
|
set default config, data and local paths
|
|
also, confusing naming here. data_path is not the internal 'data' directory, rather the users .config/jfa-go folder.
|
|
local_path is the internal 'data' directory.
|
|
*/
|
|
userConfigDir, _ := os.UserConfigDir()
|
|
app.data_path = filepath.Join(userConfigDir, "jfa-go")
|
|
app.config_path = filepath.Join(app.data_path, "config.ini")
|
|
executable, _ := os.Executable()
|
|
app.local_path = filepath.Join(filepath.Dir(executable), "data")
|
|
|
|
app.info = log.New(os.Stdout, "[INFO] ", log.Ltime)
|
|
app.err = log.New(os.Stdout, "[ERROR] ", log.Ltime|log.Lshortfile)
|
|
|
|
dataPath := flag.String("data", app.data_path, "alternate path to data directory.")
|
|
configPath := flag.String("config", app.config_path, "alternate path to config file.")
|
|
host := flag.String("host", "", "alternate address to host web ui on.")
|
|
port := flag.Int("port", 0, "alternate port to host web ui on.")
|
|
debug := flag.Bool("debug", false, "Enables debug logging and exposes pprof.")
|
|
|
|
flag.Parse()
|
|
|
|
// attempt to apply command line flags correctly
|
|
if app.config_path == *configPath && app.data_path != *dataPath {
|
|
app.data_path = *dataPath
|
|
app.config_path = filepath.Join(app.data_path, "config.ini")
|
|
} else if app.config_path != *configPath && app.data_path == *dataPath {
|
|
app.config_path = *configPath
|
|
} else {
|
|
app.config_path = *configPath
|
|
app.data_path = *dataPath
|
|
}
|
|
|
|
// env variables are necessary because syscall.Exec for self-restarts doesn't doesn't work with arguments for some reason.
|
|
|
|
if v := os.Getenv("JFA_CONFIGPATH"); v != "" {
|
|
app.config_path = v
|
|
}
|
|
if v := os.Getenv("JFA_DATAPATH"); v != "" {
|
|
app.data_path = v
|
|
}
|
|
|
|
os.Setenv("JFA_CONFIGPATH", app.config_path)
|
|
os.Setenv("JFA_DATAPATH", app.data_path)
|
|
|
|
var firstRun bool
|
|
if _, err := os.Stat(app.data_path); os.IsNotExist(err) {
|
|
os.Mkdir(app.data_path, 0700)
|
|
}
|
|
if _, err := os.Stat(app.config_path); os.IsNotExist(err) {
|
|
firstRun = true
|
|
dConfigPath := filepath.Join(app.local_path, "config-default.ini")
|
|
var dConfig *os.File
|
|
dConfig, err = os.Open(dConfigPath)
|
|
if err != nil {
|
|
app.err.Fatalf("Couldn't find default config file \"%s\"", dConfigPath)
|
|
}
|
|
defer dConfig.Close()
|
|
var nConfig *os.File
|
|
nConfig, err := os.Create(app.config_path)
|
|
if err != nil {
|
|
app.err.Fatalf("Couldn't open config file for writing: \"%s\"", app.config_path)
|
|
}
|
|
defer nConfig.Close()
|
|
_, err = io.Copy(nConfig, dConfig)
|
|
if err != nil {
|
|
app.err.Fatalf("Couldn't copy default config. To do this manually, copy\n%s\nto\n%s", dConfigPath, app.config_path)
|
|
}
|
|
app.info.Printf("Copied default configuration to \"%s\"", app.config_path)
|
|
}
|
|
|
|
var debugMode bool
|
|
var address string
|
|
if app.loadConfig() != nil {
|
|
app.err.Fatalf("Failed to load config file \"%s\"", app.config_path)
|
|
}
|
|
app.version = app.config.Section("jellyfin").Key("version").String()
|
|
// read from config...
|
|
debugMode = app.config.Section("ui").Key("debug").MustBool(false)
|
|
// then from flag
|
|
if *debug {
|
|
debugMode = true
|
|
}
|
|
if debugMode {
|
|
app.info.Println("WARNING: Don't use debug mode in production, as it exposes pprof on the network.")
|
|
app.debug = log.New(os.Stdout, "[DEBUG] ", log.Ltime|log.Lshortfile)
|
|
} else {
|
|
app.debug = log.New(ioutil.Discard, "", 0)
|
|
}
|
|
|
|
if !firstRun {
|
|
app.host = app.config.Section("ui").Key("host").String()
|
|
app.port = app.config.Section("ui").Key("port").MustInt(8056)
|
|
|
|
if *host != app.host && *host != "" {
|
|
app.host = *host
|
|
}
|
|
if *port != app.port && *port > 0 {
|
|
app.port = *port
|
|
}
|
|
|
|
if h := os.Getenv("JFA_HOST"); h != "" {
|
|
app.host = h
|
|
if p := os.Getenv("JFA_PORT"); p != "" {
|
|
var port int
|
|
_, err := fmt.Sscan(p, &port)
|
|
if err == nil {
|
|
app.port = port
|
|
}
|
|
}
|
|
}
|
|
|
|
address = fmt.Sprintf("%s:%d", app.host, app.port)
|
|
|
|
app.debug.Printf("Loaded config file \"%s\"", app.config_path)
|
|
|
|
if app.config.Section("ui").Key("bs5").MustBool(false) {
|
|
app.cssFile = "bs5-jf.css"
|
|
app.bsVersion = 5
|
|
} else {
|
|
app.cssFile = "bs4-jf.css"
|
|
app.bsVersion = 4
|
|
}
|
|
|
|
app.debug.Println("Loading storage")
|
|
|
|
// app.storage.invite_path = filepath.Join(app.data_path, "invites.json")
|
|
app.storage.invite_path = app.config.Section("files").Key("invites").String()
|
|
app.storage.loadInvites()
|
|
// app.storage.emails_path = filepath.Join(app.data_path, "emails.json")
|
|
app.storage.emails_path = app.config.Section("files").Key("emails").String()
|
|
app.storage.loadEmails()
|
|
// app.storage.policy_path = filepath.Join(app.data_path, "user_template.json")
|
|
app.storage.policy_path = app.config.Section("files").Key("user_template").String()
|
|
app.storage.loadPolicy()
|
|
// app.storage.configuration_path = filepath.Join(app.data_path, "user_configuration.json")
|
|
app.storage.policy_path = app.config.Section("files").Key("user_configuration").String()
|
|
app.storage.loadConfiguration()
|
|
// app.storage.displayprefs_path = filepath.Join(app.data_path, "user_displayprefs.json")
|
|
app.storage.displayprefs_path = app.config.Section("files").Key("user_displayprefs").String()
|
|
app.storage.loadDisplayprefs()
|
|
|
|
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
|
app.storage.ombi_path = app.config.Section("files").Key("ombi_template").String()
|
|
app.storage.loadOmbiTemplate()
|
|
app.ombi = newOmbi(
|
|
app.config.Section("ombi").Key("server").String(),
|
|
app.config.Section("ombi").Key("api_key").String(),
|
|
true,
|
|
)
|
|
}
|
|
|
|
app.configBase_path = filepath.Join(app.local_path, "config-base.json")
|
|
config_base, _ := ioutil.ReadFile(app.configBase_path)
|
|
json.Unmarshal(config_base, &app.configBase)
|
|
|
|
themes := map[string]string{
|
|
"Jellyfin (Dark)": fmt.Sprintf("bs%d-jf.css", app.bsVersion),
|
|
"Bootstrap (Light)": fmt.Sprintf("bs%d.css", app.bsVersion),
|
|
"Custom CSS": "",
|
|
}
|
|
if val, ok := themes[app.config.Section("ui").Key("theme").String()]; ok {
|
|
app.cssFile = val
|
|
}
|
|
app.debug.Printf("Using css file \"%s\"", app.cssFile)
|
|
secret, err := GenerateSecret(16)
|
|
if err != nil {
|
|
app.err.Fatal(err)
|
|
}
|
|
os.Setenv("JFA_SECRET", secret)
|
|
app.jellyfinLogin = true
|
|
if val, _ := app.config.Section("ui").Key("jellyfin_login").Bool(); !val {
|
|
app.jellyfinLogin = false
|
|
user := User{}
|
|
user.UserID = shortuuid.New()
|
|
user.Username = app.config.Section("ui").Key("username").String()
|
|
user.Password = app.config.Section("ui").Key("password").String()
|
|
app.users = append(app.users, user)
|
|
} else {
|
|
app.debug.Println("Using Jellyfin for authentication")
|
|
}
|
|
|
|
server := app.config.Section("jellyfin").Key("server").String()
|
|
app.jf, _ = newJellyfin(server, "jfa-go", app.version, "hrfee-arch", "hrfee-arch")
|
|
var status int
|
|
_, status, err = app.jf.authenticate(app.config.Section("jellyfin").Key("username").String(), app.config.Section("jellyfin").Key("password").String())
|
|
if status != 200 || err != nil {
|
|
app.err.Fatalf("Failed to authenticate with Jellyfin @ %s: Code %d", server, status)
|
|
}
|
|
app.info.Printf("Authenticated with %s", server)
|
|
app.authJf, _ = newJellyfin(server, "jfa-go", app.version, "auth", "auth")
|
|
|
|
app.loadStrftime()
|
|
|
|
validatorConf := ValidatorConf{
|
|
"characters": app.config.Section("password_validation").Key("min_length").MustInt(0),
|
|
"uppercase characters": app.config.Section("password_validation").Key("upper").MustInt(0),
|
|
"lowercase characters": app.config.Section("password_validation").Key("lower").MustInt(0),
|
|
"numbers": app.config.Section("password_validation").Key("number").MustInt(0),
|
|
"special characters": 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)
|
|
|
|
app.email.init(app)
|
|
|
|
inviteDaemon := NewRepeater(time.Duration(60*time.Second), app)
|
|
go inviteDaemon.Run()
|
|
|
|
if app.config.Section("password_resets").Key("enabled").MustBool(false) {
|
|
go app.StartPWR()
|
|
}
|
|
} else {
|
|
debugMode = false
|
|
address = "0.0.0.0:8056"
|
|
}
|
|
|
|
app.info.Println("Loading routes")
|
|
if debugMode {
|
|
gin.SetMode(gin.DebugMode)
|
|
} else {
|
|
gin.SetMode(gin.ReleaseMode)
|
|
}
|
|
router := gin.New()
|
|
|
|
setGinLogger(router, debugMode)
|
|
|
|
router.Use(gin.Recovery())
|
|
router.Use(static.Serve("/", static.LocalFile(filepath.Join(app.local_path, "static"), false)))
|
|
router.LoadHTMLGlob(filepath.Join(app.local_path, "templates", "*"))
|
|
router.NoRoute(app.NoRouteHandler)
|
|
if debugMode {
|
|
app.debug.Println("Loading pprof")
|
|
pprof.Register(router)
|
|
}
|
|
if !firstRun {
|
|
router.GET("/", app.AdminPage)
|
|
router.GET("/getToken", app.getToken)
|
|
router.POST("/newUser", app.NewUser)
|
|
router.Use(static.Serve("/invite/", static.LocalFile(filepath.Join(app.local_path, "static"), false)))
|
|
router.GET("/invite/:invCode", app.InviteProxy)
|
|
api := router.Group("/", app.webAuth())
|
|
router.POST("/logout", app.Logout)
|
|
api.POST("/generateInvite", app.GenerateInvite)
|
|
api.GET("/getInvites", app.GetInvites)
|
|
api.POST("/setNotify", app.SetNotify)
|
|
api.POST("/deleteInvite", app.DeleteInvite)
|
|
api.GET("/getUsers", app.GetUsers)
|
|
api.POST("/modifyUsers", app.ModifyEmails)
|
|
api.POST("/setDefaults", app.SetDefaults)
|
|
api.GET("/getConfig", app.GetConfig)
|
|
api.POST("/modifyConfig", app.ModifyConfig)
|
|
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
|
api.GET("/getOmbiUsers", app.OmbiUsers)
|
|
api.POST("/setOmbiDefaults", app.SetOmbiDefaults)
|
|
}
|
|
app.info.Printf("Starting router @ %s", address)
|
|
} else {
|
|
router.GET("/", func(gc *gin.Context) {
|
|
gc.HTML(200, "setup.html", gin.H{})
|
|
})
|
|
router.POST("/testJF", app.TestJF)
|
|
router.POST("/modifyConfig", app.ModifyConfig)
|
|
app.info.Printf("Loading setup @ %s", address)
|
|
}
|
|
|
|
srv := &http.Server{
|
|
Addr: address,
|
|
Handler: router,
|
|
}
|
|
go func() {
|
|
if err := srv.ListenAndServe(); err != nil {
|
|
app.err.Printf("Failure serving: %s", err)
|
|
}
|
|
}()
|
|
app.quit = make(chan os.Signal)
|
|
signal.Notify(app.quit, os.Interrupt)
|
|
<-app.quit
|
|
app.info.Println("Shutting down...")
|
|
|
|
cntx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
|
defer cancel()
|
|
if err := srv.Shutdown(cntx); err != nil {
|
|
app.err.Fatalf("Server shutdown error: %s", err)
|
|
}
|
|
}
|