diff --git a/config.toml b/config.toml index 1c77e58..e206dbc 100644 --- a/config.toml +++ b/config.toml @@ -20,6 +20,14 @@ port = 7474 # #baseUrl = "/autobrr/" +# Base url mode legacy +# This is kept for compatibility with older versions doing url rewrite on the proxy. +# If you use baseUrl you can set this to false and skip any url rewrite in your proxy. +# +# Default: true +# +baseUrlModeLegacy = true + # autobrr logs file # If not defined, logs to stdout # diff --git a/internal/config/config.go b/internal/config/config.go index b3d2475..d7df3ab 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,6 +46,14 @@ port = 7474 # #baseUrl = "/autobrr/" +# Base url mode legacy +# This is kept for compatibility with older versions doing url rewrite on the proxy. +# If you use baseUrl you can set this to false and skip any url rewrite in your proxy. +# +# Default: true +# +baseUrlModeLegacy = true + # autobrr logs file # If not defined, logs to stdout # Make sure to use forward slashes and include the filename with extension. eg: "log/autobrr.log", "C:/autobrr/log/autobrr.log" @@ -224,6 +232,7 @@ func (c *AppConfig) defaults() { LogMaxBackups: 3, DatabaseMaxBackups: 5, BaseURL: "/", + BaseURLModeLegacy: true, SessionSecret: api.GenerateSecureToken(16), CustomDefinitions: "", CheckForUpdates: true, @@ -260,6 +269,10 @@ func (c *AppConfig) loadFromEnv() { c.Config.BaseURL = v } + if v := os.Getenv(prefix + "BASE_URL_MODE_LEGACY"); v != "" { + c.Config.BaseURLModeLegacy = strings.EqualFold(strings.ToLower(v), "true") + } + if v := os.Getenv(prefix + "LOG_LEVEL"); v != "" { c.Config.LogLevel = v } diff --git a/internal/domain/config.go b/internal/domain/config.go index 94d430e..26f99de 100644 --- a/internal/domain/config.go +++ b/internal/domain/config.go @@ -13,6 +13,7 @@ type Config struct { LogMaxSize int `toml:"logMaxSize"` LogMaxBackups int `toml:"logMaxBackups"` BaseURL string `toml:"baseUrl"` + BaseURLModeLegacy bool `toml:"baseUrlModeLegacy"` SessionSecret string `toml:"sessionSecret"` CustomDefinitions string `toml:"customDefinitions"` CheckForUpdates bool `toml:"checkForUpdates"` diff --git a/internal/http/server.go b/internal/http/server.go index db138ae..0535f45 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -127,10 +127,11 @@ func (s Server) Handler() http.Handler { encoder := newEncoder(s.log) - r.Route("/api", func(r chi.Router) { - r.Route("/auth", newAuthHandler(encoder, s.log, s, s.config.Config, s.cookieStore, s.authService).Routes) - r.Route("/healthz", newHealthHandler(encoder, s.db).Routes) - + // Create a separate router for API + apiRouter := chi.NewRouter() + apiRouter.Route("/auth", newAuthHandler(encoder, s.log, s, s.config.Config, s.cookieStore, s.authService).Routes) + apiRouter.Route("/healthz", newHealthHandler(encoder, s.db).Routes) + apiRouter.Group(func(r chi.Router) { r.Group(func(r chi.Router) { r.Use(s.IsAuthenticated) @@ -163,17 +164,46 @@ func (s Server) Handler() http.Handler { }) }) - // serve the web - web.RegisterHandler(r, s.version, s.config.Config.BaseURL) + routeBaseURL := "/" + + webRouter := chi.NewRouter() + + // handle backwards compatibility for base url routing + if s.config.Config.BaseURLModeLegacy { + // this is required to keep assets "url rewritable" via a reverse-proxy + routeAssetBaseURL := "./" + // serve the web + webHandlers := newWebLegacyHandler(s.log, web.DistDirFS, s.version, s.config.Config.BaseURL, routeAssetBaseURL) + webHandlers.RegisterRoutes(webRouter) + } else { + routeBaseURL = s.config.Config.BaseURL + + // serve the web + webHandlers := newWebHandler(s.log, web.DistDirFS, s.version, routeBaseURL, routeBaseURL) + webHandlers.RegisterRoutes(webRouter) + + // add fallback routes when base url is set to inform user to redirect and use /baseurl/ + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + if err := webHandlers.RenderFallbackIndex(w); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) + + r.Get("/*", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + if err := webHandlers.RenderFallbackIndex(w); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) + } + + // Mount the web router under baseUrl + '/' + r.Mount(routeBaseURL, webRouter) + // Mount the API router under baseUrl + '/api' + r.Mount(routeBaseURL+"api", apiRouter) return r } - -func (s Server) index(w http.ResponseWriter, r *http.Request) { - p := web.IndexParams{ - Title: "Dashboard", - Version: s.version, - BaseUrl: s.config.Config.BaseURL, - } - web.Index(w, p) -} diff --git a/internal/http/web.go b/internal/http/web.go new file mode 100644 index 0000000..11f1c42 --- /dev/null +++ b/internal/http/web.go @@ -0,0 +1,265 @@ +// Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package http + +import ( + "bufio" + "bytes" + "html/template" + "io" + "io/fs" + "net/http" + "os" + filePath "path" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog" +) + +type webHandler struct { + log zerolog.Logger + embedFS fs.FS + baseUrl string + assetBaseURL string + version string + files map[string]string +} + +func newWebHandler(log zerolog.Logger, embedFS fs.FS, version, baseURL, assetBaseURL string) *webHandler { + return &webHandler{ + log: log.With().Str("module", "web-assets").Logger(), + embedFS: embedFS, + baseUrl: baseURL, + assetBaseURL: assetBaseURL, + version: version, + files: make(map[string]string), + } +} + +// registerAssets walks the FS Dist dir and registers each file as a route +func (h *webHandler) registerAssets(r *chi.Mux) { + err := fs.WalkDir(h.embedFS, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + //h.log.Trace().Msgf("web assets: skip dir: %s", d.Name()) + return nil + } + + h.log.Trace().Msgf("web assets: found path: %s", path) + + // ignore index.html, so we can render it as a template and inject variables + if path == "index.html" || path == "manifest.webmanifest" || path == ".gitkeep" { + return nil + } + + // use old path.Join to not be os specific + FileFS(r, filePath.Join("/", path), path, h.embedFS) + + h.files[path] = path + + return nil + }) + + if err != nil { + return + } +} + +func (h *webHandler) RegisterRoutes(r *chi.Mux) { + h.registerAssets(r) + + // Serve static files without a prefix + assets, err := fs.Sub(h.embedFS, "assets") + if err != nil { + h.log.Error().Err(err).Msg("could not load assets sub dir") + } + + StaticFSNew(r, h.baseUrl, "/assets", assets) + + p := IndexParams{ + Title: "Dashboard", + Version: h.version, + BaseUrl: h.baseUrl, + AssetBaseUrl: h.assetBaseURL, + } + + // serve on base route + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + if err := h.RenderIndex(w, p); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) + + // handle manifest + r.Get("/manifest.webmanifest", func(w http.ResponseWriter, r *http.Request) { + if err := h.RenderManifest(w, p); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) + + // handle all other routes + r.Get("/*", func(w http.ResponseWriter, r *http.Request) { + file := strings.TrimPrefix(r.RequestURI, h.baseUrl) + + if strings.Contains(file, "favicon.ico") { + fsFile(w, r, "favicon.ico", h.embedFS) + return + } + + if strings.Contains(file, "Inter-Variable.woff2") { + fsFile(w, r, "Inter-Variable.woff2", h.embedFS) + return + } + + // if valid web route then serve html + if validWebRoute(file) || file == "index.html" { + if err := h.RenderIndex(w, p); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + return + } + + // if not valid web route then try and serve files + fsFile(w, r, file, h.embedFS) + }) +} + +func (h *webHandler) RenderIndex(w io.Writer, p IndexParams) error { + return h.parseIndex().Execute(w, p) +} + +func (h *webHandler) parseIndex() *template.Template { + return template.Must(template.New("index.html").ParseFS(h.embedFS, "index.html")) +} + +func (h *webHandler) RenderManifest(w io.Writer, p IndexParams) error { + return h.parseManifest().Execute(w, p) +} + +func (h *webHandler) parseManifest() *template.Template { + return template.Must(template.New("manifest.webmanifest").ParseFS(h.embedFS, "manifest.webmanifest")) +} + +func (h *webHandler) RenderFallbackIndex(w io.Writer) error { + p := IndexParams{ + Title: "autobrr Dashboard", + Version: h.version, + BaseUrl: h.baseUrl, + AssetBaseUrl: h.assetBaseURL, + } + return h.parseFallbackIndex().Execute(w, p) +} + +func (h *webHandler) parseFallbackIndex() *template.Template { + return template.Must(template.New("fallback-index").Parse(` + + + autobrr + + + + Must use base url: {{.BaseUrl}} + + +`)) +} + +type defaultFS struct { + prefix string + fs fs.FS +} + +type IndexParams struct { + Title string + Version string + BaseUrl string + AssetBaseUrl string +} + +func (fs defaultFS) Open(name string) (fs.File, error) { + if fs.fs == nil { + return os.Open(name) + } + return fs.fs.Open(name) +} + +// FileFS registers a new route with path to serve a file from the provided file system. +func FileFS(r *chi.Mux, path, file string, filesystem fs.FS) { + r.Get(path, StaticFileHandler(file, filesystem)) +} + +// StaticFileHandler creates a handler function to serve a file from the provided file system. +func StaticFileHandler(file string, filesystem fs.FS) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + fsFile(w, r, file, filesystem) + } +} + +//// StaticFS registers a new route with path prefix to serve static files from the provided file system. +//func StaticFS(r *chi.Mux, pathPrefix string, filesystem fs.FS) { +// r.Handle(pathPrefix+"*", http.StripPrefix(pathPrefix, http.FileServer(http.FS(filesystem)))) +//} + +// StaticFSNew registers a new route with path prefix to serve static files from the provided file system. +func StaticFSNew(r *chi.Mux, baseUrl, pathPrefix string, filesystem fs.FS) { + r.Handle(pathPrefix+"*", http.StripPrefix(filePath.Join(baseUrl, pathPrefix), http.FileServer(http.FS(filesystem)))) +} + +// fsFile is a helper function to serve a file from the provided file system. +func fsFile(w http.ResponseWriter, r *http.Request, file string, filesystem fs.FS) { + f, err := filesystem.Open(file) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + + data, err := io.ReadAll(bufio.NewReader(f)) + if err != nil { + http.Error(w, "Failed to read the file", http.StatusInternalServerError) + return + } + + reader := bytes.NewReader(data) + http.ServeContent(w, r, file, stat.ModTime(), reader) +} + +var validWebRoutes = []string{"filters", "releases", "settings", "logs", "onboard", "login", "logout"} + +func validWebRoute(route string) bool { + if route == "" || route == "/" { + return true + } + for _, valid := range validWebRoutes { + if strings.HasPrefix(route, valid) || strings.HasPrefix(route, "/"+valid) { + return true + } + } + + return false +} diff --git a/internal/http/web_legacy.go b/internal/http/web_legacy.go new file mode 100644 index 0000000..2c973f0 --- /dev/null +++ b/internal/http/web_legacy.go @@ -0,0 +1,201 @@ +// Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package http + +import ( + "html/template" + "io" + "io/fs" + "net/http" + filePath "path" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog" +) + +type webLegacyHandler struct { + log zerolog.Logger + embedFS fs.FS + baseUrl string + assetBaseURL string + version string + + files map[string]string +} + +func newWebLegacyHandler(log zerolog.Logger, embedFS fs.FS, version, baseURL, assetBaseURL string) *webLegacyHandler { + return &webLegacyHandler{ + log: log.With().Str("module", "web-assets").Logger(), + embedFS: embedFS, + baseUrl: baseURL, + assetBaseURL: assetBaseURL, + version: version, + files: make(map[string]string), + } +} + +// registerAssets walks the FS Dist dir and registers each file as a route +func (h *webLegacyHandler) registerAssets(r *chi.Mux) { + err := fs.WalkDir(h.embedFS, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + //h.log.Trace().Msgf("web assets: skip dir: %s", d.Name()) + return nil + } + + h.log.Trace().Msgf("web assets: found path: %s", path) + + // ignore index.html, so we can render it as a template and inject variables + if path == "index.html" || path == "manifest.webmanifest" || path == ".gitkeep" { + return nil + } + + // use old path.Join to not be os specific + FileFS(r, filePath.Join("/", path), path, h.embedFS) + + h.files[path] = path + + return nil + }) + + if err != nil { + return + } +} + +func (h *webLegacyHandler) RegisterRoutes(r *chi.Mux) { + h.registerAssets(r) + + // Serve static files without a prefix + assets, err := fs.Sub(h.embedFS, "assets") + if err != nil { + h.log.Error().Err(err).Msg("could not load assets sub dir") + } + + StaticFS(r, "/assets", assets) + + p := IndexParams{ + Title: "Dashboard", + Version: h.version, + BaseUrl: h.baseUrl, + AssetBaseUrl: h.assetBaseURL, + } + + // serve on base route + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + if err := h.RenderIndex(w, p); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) + + // handle manifest + r.Get("/manifest.webmanifest", func(w http.ResponseWriter, r *http.Request) { + if err := h.RenderManifest(w, p); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) + + // handle all other routes and files + r.Get("/*", func(w http.ResponseWriter, r *http.Request) { + file := strings.TrimPrefix(r.RequestURI, h.baseUrl) + + if strings.Contains(file, "/assets") { + if strings.HasPrefix(file, "/assets/") { + fsFile(w, r, file, h.embedFS) + return + } + + parts := strings.SplitAfter(file, "/assets/") + if len(parts) > 1 { + fsFile(w, r, "assets/"+parts[1], h.embedFS) + return + } + return + } + + if strings.Contains(file, "favicon.ico") { + fsFile(w, r, "favicon.ico", h.embedFS) + return + } + + if strings.Contains(file, "Inter-Variable.woff2") { + fsFile(w, r, "Inter-Variable.woff2", h.embedFS) + return + } + + // if valid web route then serve html + if validWebRoute(file) || file == "index.html" { + if err := h.RenderIndex(w, p); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + return + } + + // if not valid web route then try and serve files + fsFile(w, r, file, h.embedFS) + }) +} + +func (h *webLegacyHandler) RenderIndex(w io.Writer, p IndexParams) error { + return h.parseIndex().Execute(w, p) +} + +func (h *webLegacyHandler) parseIndex() *template.Template { + return template.Must(template.New("index.html").ParseFS(h.embedFS, "index.html")) +} + +func (h *webLegacyHandler) RenderManifest(w io.Writer, p IndexParams) error { + return h.parseManifest().Execute(w, p) +} + +func (h *webLegacyHandler) parseManifest() *template.Template { + return template.Must(template.New("manifest.webmanifest").ParseFS(h.embedFS, "manifest.webmanifest")) +} + +func (h *webLegacyHandler) RenderFallbackIndex(w io.Writer) error { + p := IndexParams{ + Title: "autobrr Dashboard", + Version: h.version, + BaseUrl: h.baseUrl, + AssetBaseUrl: h.assetBaseURL, + } + return h.parseFallbackIndex().Execute(w, p) +} + +func (h *webLegacyHandler) parseFallbackIndex() *template.Template { + return template.Must(template.New("fallback-index").Parse(` + + + autobrr + + + + Must use base url: {{.BaseUrl}} + + +`)) +} + +// StaticFS registers a new route with path prefix to serve static files from the provided file system. +func StaticFS(r *chi.Mux, pathPrefix string, filesystem fs.FS) { + r.Handle(pathPrefix+"*", http.StripPrefix(pathPrefix, http.FileServer(http.FS(filesystem)))) +} diff --git a/web/build.go b/web/build.go index 3c58cb2..ab94263 100644 --- a/web/build.go +++ b/web/build.go @@ -5,32 +5,13 @@ package web import ( - "bufio" - "bytes" "embed" "fmt" - "html/template" - "io" "io/fs" - "net/http" "os" "path/filepath" - "strings" - - "github.com/go-chi/chi/v5" ) -type defaultFS struct { - prefix string - fs fs.FS -} - -type IndexParams struct { - Title string - Version string - BaseUrl string -} - var ( //go:embed all:dist Dist embed.FS @@ -38,6 +19,11 @@ var ( DistDirFS = MustSubFS(Dist, "dist") ) +type defaultFS struct { + prefix string + fs fs.FS +} + func (fs defaultFS) Open(name string) (fs.File, error) { if fs.fs == nil { return os.Open(name) @@ -74,113 +60,3 @@ func subFS(currentFs fs.FS, root string) (fs.FS, error) { } return fs.Sub(currentFs, root) } - -// FileFS registers a new route with path to serve a file from the provided file system. -func FileFS(r *chi.Mux, path, file string, filesystem fs.FS) { - r.Get(path, StaticFileHandler(file, filesystem)) -} - -// StaticFileHandler creates a handler function to serve a file from the provided file system. -func StaticFileHandler(file string, filesystem fs.FS) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - fsFile(w, r, file, filesystem) - } -} - -// StaticFS registers a new route with path prefix to serve static files from the provided file system. -func StaticFS(r *chi.Mux, pathPrefix string, filesystem fs.FS) { - r.Handle(pathPrefix+"*", http.StripPrefix(pathPrefix, http.FileServer(http.FS(filesystem)))) -} - -// fsFile is a helper function to serve a file from the provided file system. -func fsFile(w http.ResponseWriter, r *http.Request, file string, filesystem fs.FS) { - //fmt.Printf("file: %s\n", file) - f, err := filesystem.Open(file) - if err != nil { - http.Error(w, "File not found", http.StatusNotFound) - return - } - defer f.Close() - - stat, err := f.Stat() - if err != nil { - http.Error(w, "File not found", http.StatusNotFound) - return - } - - data, err := io.ReadAll(bufio.NewReader(f)) - if err != nil { - http.Error(w, "Failed to read the file", http.StatusInternalServerError) - return - } - - reader := bytes.NewReader(data) - http.ServeContent(w, r, file, stat.ModTime(), reader) -} - -var validRoutes = []string{"/", "filters", "releases", "settings", "logs", "onboard", "login", "logout"} - -func validRoute(route string) bool { - for _, valid := range validRoutes { - if strings.Contains(route, valid) { - return true - } - } - - return false -} - -// RegisterHandler register web routes and file serving -func RegisterHandler(c *chi.Mux, version, baseUrl string) { - // Serve static files without a prefix - assets, _ := fs.Sub(DistDirFS, "assets") - static, _ := fs.Sub(DistDirFS, "static") - StaticFS(c, "/assets", assets) - StaticFS(c, "/static", static) - - p := IndexParams{ - Title: "Dashboard", - Version: version, - BaseUrl: baseUrl, - } - - // serve on base route - c.Get("/", func(w http.ResponseWriter, r *http.Request) { - Index(w, p) - }) - - // handle all other routes - c.Get("/*", func(w http.ResponseWriter, r *http.Request) { - file := strings.TrimPrefix(r.RequestURI, "/") - - // if valid web route then serve html - if validRoute(file) || file == "index.html" { - Index(w, p) - return - } - - if strings.Contains(file, "manifest.webmanifest") { - Manifest(w, p) - return - } - - // if not valid web route then try and serve files - fsFile(w, r, file, DistDirFS) - }) -} - -func Index(w io.Writer, p IndexParams) error { - return parseIndex().Execute(w, p) -} - -func parseIndex() *template.Template { - return template.Must(template.New("index.html").ParseFS(Dist, "dist/index.html")) -} - -func Manifest(w io.Writer, p IndexParams) error { - return parseManifest().Execute(w, p) -} - -func parseManifest() *template.Template { - return template.Must(template.New("manifest.webmanifest").ParseFS(Dist, "dist/manifest.webmanifest")) -} diff --git a/web/index.html b/web/index.html index b0e2ec5..22b64f9 100644 --- a/web/index.html +++ b/web/index.html @@ -18,7 +18,7 @@ autobrr - +