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
-
+