package main import ( "context" "crypto/rand" "encoding/base64" "encoding/json" "flag" "fmt" "io" "io/ioutil" "log" "net" "net/http" "os" "os/exec" "os/signal" "path/filepath" "runtime" "strconv" "strings" "time" "github.com/gin-contrib/pprof" "github.com/gin-contrib/static" "github.com/gin-gonic/gin" "github.com/hrfee/jfa-go/common" _ "github.com/hrfee/jfa-go/docs" "github.com/hrfee/jfa-go/mediabrowser" "github.com/hrfee/jfa-go/ombi" "github.com/lithammer/shortuuid/v3" "github.com/logrusorgru/aurora/v3" swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" "gopkg.in/ini.v1" ) var serverTypes = map[string]string{ "jellyfin": "Jellyfin", "emby": "Emby (experimental)", } var serverType = mediabrowser.JellyfinServer var substituteStrings = "" // User is used for auth purposes. type User struct { UserID string `json:"id"` Username string `json:"username"` Password string `json:"password"` } // contains everything the application needs, essentially. Wouldn't do this in the future. type appContext struct { // defaults *Config config *ini.File configPath string configBasePath string configBase settings dataPath string localPath string cssClass string jellyfinLogin bool users []User invalidTokens []string // Keeping jf name because I can't think of a better one jf *mediabrowser.MediaBrowser authJf *mediabrowser.MediaBrowser ombi *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 URLBase string } func (app *appContext) loadHTML(router *gin.Engine) { customPath := app.config.Section("files").Key("html_templates").MustString("") templatePath := filepath.Join(app.localPath, "html") htmlFiles, err := ioutil.ReadDir(templatePath) if err != nil { app.err.Fatalf("Couldn't access template directory: \"%s\"", templatePath) return } loadFiles := make([]string, len(htmlFiles)) for i, f := range htmlFiles { if _, err := os.Stat(filepath.Join(customPath, f.Name())); os.IsNotExist(err) { app.debug.Printf("Using default \"%s\"", f.Name()) loadFiles[i] = filepath.Join(templatePath, f.Name()) } else { app.info.Printf("Using custom \"%s\"", f.Name()) loadFiles[i] = filepath.Join(filepath.Join(customPath, f.Name())) } } router.LoadHTMLFiles(loadFiles...) } 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, ) })) } } var ( PLATFORM string = runtime.GOOS SOCK string = "jfa-go.sock" SRV *http.Server RESTART chan bool DATA, CONFIG, HOST *string PORT *int DEBUG *bool TEST bool SWAGGER *bool ) func test(app *appContext) { fmt.Printf("\n\n----\n\n") settings := map[string]interface{}{ "server": app.jf.Server, "server version": app.jf.ServerInfo.Version, "server name": app.jf.ServerInfo.Name, "authenticated?": app.jf.Authenticated, "access token": app.jf.AccessToken, "username": app.jf.Username, } for n, v := range settings { fmt.Println(n, ":", v) } users, status, err := app.jf.GetUsers(false) fmt.Printf("GetUsers: code %d err %s maplength %d\n", status, err, len(users)) fmt.Printf("View output? [y/n]: ") var choice string fmt.Scanln(&choice) if strings.Contains(choice, "y") { out, err := json.MarshalIndent(users, "", " ") fmt.Print(string(out), err) } fmt.Printf("Enter a user to grab: ") var username string fmt.Scanln(&username) user, status, err := app.jf.UserByName(username, false) fmt.Printf("UserByName (%s): code %d err %s", username, status, err) out, _ := json.MarshalIndent(user, "", " ") fmt.Print(string(out)) } func start(asDaemon, firstCall bool) { // 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.dataPath = filepath.Join(userConfigDir, "jfa-go") app.configPath = filepath.Join(app.dataPath, "config.ini") executable, _ := os.Executable() app.localPath = 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) if firstCall { DATA = flag.String("data", app.dataPath, "alternate path to data directory.") CONFIG = flag.String("config", app.configPath, "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.") SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html") flag.Parse() if *SWAGGER { os.Setenv("SWAGGER", "1") } if *DEBUG { os.Setenv("DEBUG", "1") } } if os.Getenv("SWAGGER") == "1" { *SWAGGER = true } if os.Getenv("DEBUG") == "1" { *DEBUG = true } // attempt to apply command line flags correctly if app.configPath == *CONFIG && app.dataPath != *DATA { app.dataPath = *DATA app.configPath = filepath.Join(app.dataPath, "config.ini") } else if app.configPath != *CONFIG && app.dataPath == *DATA { app.configPath = *CONFIG } else { app.configPath = *CONFIG app.dataPath = *DATA } // 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.configPath = v } if v := os.Getenv("JFA_DATAPATH"); v != "" { app.dataPath = v } os.Setenv("JFA_CONFIGPATH", app.configPath) os.Setenv("JFA_DATAPATH", app.dataPath) var firstRun bool if _, err := os.Stat(app.dataPath); os.IsNotExist(err) { os.Mkdir(app.dataPath, 0700) } if _, err := os.Stat(app.configPath); os.IsNotExist(err) { firstRun = true dConfigPath := filepath.Join(app.localPath, "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.configPath) if err != nil { app.err.Printf("Couldn't open config file for writing: \"%s\"", app.configPath) app.err.Fatalf("Error: %s", err) } 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.configPath) } app.info.Printf("Copied default configuration to \"%s\"", app.configPath) } var debugMode bool var address string if app.loadConfig() != nil { app.err.Fatalf("Failed to load config file \"%s\"", app.configPath) } 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.Print(aurora.Magenta("\n\nWARNING: Don't use debug mode in production, as it exposes pprof on the network.\n\n")) app.debug = log.New(os.Stdout, "[DEBUG] ", log.Ltime|log.Lshortfile) } else { app.debug = log.New(ioutil.Discard, "", 0) } if asDaemon { go func() { socket := SOCK os.Remove(socket) listener, err := net.Listen("unix", socket) if err != nil { app.err.Fatalf("Couldn't establish socket connection at %s\n", SOCK) } c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt) go func() { <-c os.Remove(socket) os.Exit(1) }() defer func() { listener.Close() os.Remove(SOCK) }() for { con, err := listener.Accept() if err != nil { app.err.Printf("Couldn't read message on %s: %s", socket, err) continue } buf := make([]byte, 512) nr, err := con.Read(buf) if err != nil { app.err.Printf("Couldn't read message on %s: %s", socket, err) continue } command := string(buf[0:nr]) if command == "stop" { app.shutdown() } } }() } if !firstRun { app.host = app.config.Section("ui").Key("host").String() if app.config.Section("advanced").Key("tls").MustBool(false) { app.info.Println("Using TLS/HTTP2") app.port = app.config.Section("advanced").Key("tls_port").MustInt(8057) } else { 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.configPath) app.debug.Println("Loading storage") app.storage.invite_path = app.config.Section("files").Key("invites").String() app.storage.loadInvites() app.storage.emails_path = app.config.Section("files").Key("emails").String() app.storage.loadEmails() app.storage.policy_path = app.config.Section("files").Key("user_template").String() app.storage.loadPolicy() app.storage.configuration_path = app.config.Section("files").Key("user_configuration").String() app.storage.loadConfiguration() app.storage.displayprefs_path = app.config.Section("files").Key("user_displayprefs").String() app.storage.loadDisplayprefs() app.storage.profiles_path = app.config.Section("files").Key("user_profiles").String() app.storage.loadProfiles() if !(len(app.storage.policy) == 0 && len(app.storage.configuration) == 0 && len(app.storage.displayprefs) == 0) { app.info.Println("Migrating user template files to new profile format") app.storage.migrateToProfile() for _, path := range [3]string{app.storage.policy_path, app.storage.configuration_path, app.storage.displayprefs_path} { if _, err := os.Stat(path); !os.IsNotExist(err) { dir, fname := filepath.Split(path) newFname := strings.Replace(fname, ".json", ".old.json", 1) err := os.Rename(path, filepath.Join(dir, newFname)) if err != nil { app.err.Fatalf("Failed to rename %s: %s", fname, err) } } } app.info.Println("In case of a problem, your original files have been renamed to .old.json") app.storage.storeProfiles() } if app.config.Section("ombi").Key("enabled").MustBool(false) { app.storage.ombi_path = app.config.Section("files").Key("ombi_template").String() app.storage.loadOmbiTemplate() ombiServer := app.config.Section("ombi").Key("server").String() app.ombi = ombi.NewOmbi( ombiServer, app.config.Section("ombi").Key("api_key").String(), common.NewTimeoutHandler("Ombi", ombiServer, true), ) } app.configBasePath = filepath.Join(app.localPath, "config-base.json") configBase, _ := ioutil.ReadFile(app.configBasePath) json.Unmarshal(configBase, &app.configBase) themes := map[string]string{ "Jellyfin (Dark)": "dark-theme", "Default (Light)": "light-theme", } if app.config.Section("ui").Key("theme").String() == "Bootstrap (Light)" { app.config.Section("ui").Key("theme").SetValue("Default (Light)") } if val, ok := themes[app.config.Section("ui").Key("theme").String()]; ok { app.cssClass = val } 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() cacheTimeout := int(app.config.Section("jellyfin").Key("cache_timeout").MustUint(30)) stringServerType := app.config.Section("jellyfin").Key("type").String() timeoutHandler := common.NewTimeoutHandler("Jellyfin", server, true) if stringServerType == "emby" { serverType = mediabrowser.EmbyServer timeoutHandler = common.NewTimeoutHandler("Emby", server, true) app.info.Println("Using Emby server type") fmt.Println(aurora.Yellow("WARNING: Emby compatibility is experimental, and support is limited.\nPassword resets are not available.")) } else { app.info.Println("Using Jellyfin server type") } app.jf, _ = mediabrowser.NewServer( serverType, server, app.config.Section("jellyfin").Key("client").String(), app.config.Section("jellyfin").Key("version").String(), app.config.Section("jellyfin").Key("device").String(), app.config.Section("jellyfin").Key("device_id").String(), timeoutHandler, cacheTimeout, ) 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) // from 10.7.0, jellyfin may hyphenate user IDs. This checks if the version is equal or higher. checkVersion := func(version string) int { numberStrings := strings.Split(version, ".") n := 0 for _, s := range numberStrings { num, err := strconv.Atoi(s) if err == nil { n += num } } return n } if serverType == mediabrowser.JellyfinServer && checkVersion(app.jf.ServerInfo.Version) >= checkVersion("10.7.0") { // Get users to check if server uses hyphenated userIDs app.jf.GetUsers(false) noHyphens := true for id := range app.storage.emails { if strings.Contains(id, "-") { noHyphens = false break } } if noHyphens == app.jf.Hyphens { var newEmails map[string]interface{} var status int var err error if app.jf.Hyphens { app.info.Println(aurora.Yellow("Your build of Jellyfin appears to hypenate user IDs. Your emails.json file will be modified to match.")) time.Sleep(time.Second * time.Duration(3)) newEmails, status, err = app.hyphenateEmailStorage(app.storage.emails) } else { app.info.Println(aurora.Yellow("Your emails.json file uses hyphens, but the Jellyfin server no longer does. It will be modified.")) time.Sleep(time.Second * time.Duration(3)) newEmails, status, err = app.deHyphenateEmailStorage(app.storage.emails) } if status != 200 || err != nil { app.err.Printf("Failed to get users from Jellyfin: Code %d", status) app.debug.Printf("Error: %s", err) app.err.Fatalf("Couldn't upgrade emails.json") } bakFile := app.storage.emails_path + ".bak" err = storeJSON(bakFile, app.storage.emails) if err != nil { app.err.Fatalf("couldn't store emails.json backup: %s", err) } app.storage.emails = newEmails err = app.storage.storeEmails() if err != nil { app.err.Fatalf("couldn't store emails.json: %s", err) } } } app.storage.lang.FormPath = filepath.Join(app.localPath, "lang", "form") app.storage.lang.AdminPath = filepath.Join(app.localPath, "lang", "admin") app.storage.lang.EmailPath = filepath.Join(app.localPath, "lang", "email") err = app.storage.loadLang() if err != nil { app.info.Fatalf("Failed to load language files: %+v\n", err) } app.authJf, _ = mediabrowser.NewServer(serverType, server, "jfa-go", app.version, "auth", "auth", timeoutHandler, cacheTimeout) app.loadStrftime() 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) if TEST { test(app) os.Exit(0) } inviteDaemon := newRepeater(time.Duration(60*time.Second), app) go inviteDaemon.run() if app.config.Section("password_resets").Key("enabled").MustBool(false) && serverType == mediabrowser.JellyfinServer { 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.localPath, "web"), false))) app.loadHTML(router) router.NoRoute(app.NoRouteHandler) if debugMode { app.debug.Println("Loading pprof") pprof.Register(router) } if !firstRun { router.GET("/", app.AdminPage) router.GET("/accounts", app.AdminPage) router.GET("/settings", app.AdminPage) router.GET("/lang/:page/:file", app.ServeLang) router.GET("/lang/:page", app.GetLanguages) router.GET("/token/login", app.getTokenLogin) router.GET("/token/refresh", app.getTokenRefresh) router.POST("/newUser", app.NewUser) router.Use(static.Serve("/invite/", static.LocalFile(filepath.Join(app.localPath, "web"), false))) router.GET("/invite/:invCode", app.InviteProxy) if *SWAGGER { app.info.Print(aurora.Magenta("\n\nWARNING: Swagger should not be used on a public instance.\n\n")) router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) } api := router.Group("/", app.webAuth()) router.POST("/logout", app.Logout) api.DELETE("/users", app.DeleteUser) api.GET("/users", app.GetUsers) api.POST("/users", app.NewUserAdmin) api.POST("/invites", app.GenerateInvite) api.GET("/invites", app.GetInvites) api.DELETE("/invites", app.DeleteInvite) api.POST("/invites/profile", app.SetProfile) api.GET("/profiles", app.GetProfiles) api.POST("/profiles/default", app.SetDefaultProfile) api.POST("/profiles", app.CreateProfile) api.DELETE("/profiles", app.DeleteProfile) api.POST("/invites/notify", app.SetNotify) api.POST("/users/emails", app.ModifyEmails) // api.POST("/setDefaults", app.SetDefaults) api.POST("/users/settings", app.ApplySettings) api.GET("/config", app.GetConfig) api.POST("/config", app.ModifyConfig) if app.config.Section("ombi").Key("enabled").MustBool(false) { api.GET("/ombi/users", app.OmbiUsers) api.POST("/ombi/defaults", 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("/jellyfin/test", app.TestJF) router.POST("/config", app.ModifyConfig) app.info.Printf("Loading setup @ %s", address) } SRV = &http.Server{ Addr: address, Handler: router, } go func() { if app.config.Section("advanced").Key("tls").MustBool(false) { cert := app.config.Section("advanced").Key("tls_cert").MustString("") key := app.config.Section("advanced").Key("tls_key").MustString("") if err := SRV.ListenAndServeTLS(cert, key); err != nil { app.err.Printf("Failure serving: %s", err) } } else { 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) go func() { for range app.quit { app.shutdown() } }() for range RESTART { 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) } return } } func (app *appContext) shutdown() { 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) } os.Exit(1) } func flagPassed(name string) (found bool) { for _, f := range os.Args { if f == name { found = true } } return } // @title jfa-go internal API // @version 0.2.0 // @description API for the jfa-go frontend // @contact.name Harvey Tindall // @contact.email hrfee@protonmail.ch // @license.name MIT // @license.url https://raw.githubusercontent.com/hrfee/jfa-go/main/LICENSE // @BasePath / // @securityDefinitions.apikey Bearer // @in header // @name Authorization // @securityDefinitions.basic getTokenAuth // @name getTokenAuth // @tag.name Auth // @tag.description --------Get a token here first!-------- // @tag.name Users // @tag.description Jellyfin user related operations. // @tag.name Invites // @tag.description Invite related operations. // @tag.name Profiles & Settings // @tag.description Profile and settings related operations. // @tag.name Configuration // @tag.description jfa-go settings. // @tag.name Ombi // @tag.description Ombi related operations. // @tag.name Other // @tag.description Things that dont fit elsewhere. func printVersion() { fmt.Print(aurora.Sprintf(aurora.Magenta("jfa-go version: %s (%s)\n"), aurora.BrightWhite(VERSION), aurora.White(COMMIT))) } func main() { printVersion() folder := "/tmp" if PLATFORM == "windows" { folder = os.Getenv("TEMP") } SOCK = filepath.Join(folder, SOCK) fmt.Println("Socket:", SOCK) if flagPassed("test") { TEST = true } if flagPassed("start") { args := []string{} for i, f := range os.Args { if f == "start" { args = append(args, "daemon") } else if i != 0 { args = append(args, f) } } cmd := exec.Command(os.Args[0], args...) cmd.Start() os.Exit(1) } else if flagPassed("stop") { con, err := net.Dial("unix", SOCK) if err != nil { fmt.Printf("Couldn't dial socket %s, are you sure jfa-go is running?\n", SOCK) os.Exit(1) } _, err = con.Write([]byte("stop")) if err != nil { fmt.Printf("Couldn't send command to socket %s, are you sure jfa-go is running?\n", SOCK) os.Exit(1) } fmt.Println("Sent.") } else if flagPassed("daemon") { start(true, true) } else { RESTART = make(chan bool, 1) start(false, true) for { printVersion() start(false, false) } } }