From ee454c5d605b04437f76262d7ae09841d4c5bce2 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Tue, 9 Dec 2025 23:42:32 +0100 Subject: [PATCH] allow to override embedded frontend (#533) --- config.yml.sample | 5 + docs/documentation/configuration/overview.md | 9 ++ internal/app/api/core/server.go | 97 ++++++++++++++++++++ internal/config/config.go | 1 + internal/config/web.go | 4 + 5 files changed, 116 insertions(+) diff --git a/config.yml.sample b/config.yml.sample index 270680a..9e5ef5d 100644 --- a/config.yml.sample +++ b/config.yml.sample @@ -12,6 +12,11 @@ core: web: external_url: http://localhost:8888 request_logging: true + # Optional path where custom frontend files are stored. + # If this folder contains at least one file, it will override the embedded frontend. + # If the folder is empty or does not exist on startup, the embedded frontend will be + # written into it. Leave empty to use the embedded frontend only. + frontend_filepath: "" mail: # Path where custom email templates (.gotpl and .gohtml) are stored. diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index 133f7ae..b926ddd 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -97,6 +97,7 @@ web: expose_host_info: false cert_file: "" key_File: "" + frontend_filepath: "" webhook: url: "" @@ -847,6 +848,14 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio - **Environment Variable:** `WG_PORTAL_WEB_KEY_FILE` - **Description:** (Optional) Path to the TLS certificate key file. +### `frontend_filepath` +- **Default:** *(empty)* +- **Environment Variable:** `WG_PORTAL_WEB_FRONTEND_FILEPATH` +- **Description:** Optional base directory from which the web frontend is served. Check out the [building](../getting-started/sources.md) documentation for more information on how to compile the frontend assets. + - If the directory contains at least one file (recursively), these files are served at `/app`, overriding the embedded frontend assets. + - If the directory is empty or does not exist on startup, the embedded frontend is copied into this directory automatically and then served. + - If left empty, the embedded frontend is served and no files are written to disk. + --- ## Webhook diff --git a/internal/app/api/core/server.go b/internal/app/api/core/server.go index 4cc986c..5ed8746 100644 --- a/internal/app/api/core/server.go +++ b/internal/app/api/core/server.go @@ -4,10 +4,12 @@ import ( "context" "fmt" "html/template" + "io" "io/fs" "log/slog" "net/http" "os" + "path/filepath" "time" "github.com/go-pkgz/routegroup" @@ -155,6 +157,37 @@ func (s *Server) setupFrontendRoutes() { respond.Redirect(w, r, http.StatusMovedPermanently, "/app/favicon.ico") }) + // If a custom frontend path is configured, serve files from there when it contains content. + // If the directory is empty or missing, populate it with the embedded frontend-dist content first. + if s.cfg.Web.FrontendFilePath != "" { + if err := os.MkdirAll(s.cfg.Web.FrontendFilePath, 0755); err != nil { + slog.Error("failed to create frontend base directory", "path", s.cfg.Web.FrontendFilePath, "error", err) + } else { + ok := true + hasFiles, err := dirHasFiles(s.cfg.Web.FrontendFilePath) + if err != nil { + slog.Error("failed to check frontend base directory", "path", s.cfg.Web.FrontendFilePath, "error", err) + ok = false + } + if !hasFiles && ok { + embeddedFS := fsMust(fs.Sub(frontendStatics, "frontend-dist")) + if err := copyEmbedDirToDisk(embeddedFS, s.cfg.Web.FrontendFilePath); err != nil { + slog.Error("failed to populate frontend base directory from embedded assets", + "path", s.cfg.Web.FrontendFilePath, "error", err) + ok = false + } + } + + if ok { + // serve files from FS + slog.Debug("serving frontend files from custom path", "path", s.cfg.Web.FrontendFilePath) + s.server.HandleFiles("/app", http.Dir(s.cfg.Web.FrontendFilePath)) + return + } + } + } + + // Fallback: serve embedded frontend files s.server.HandleFiles("/app", http.FS(fsMust(fs.Sub(frontendStatics, "frontend-dist")))) } @@ -182,3 +215,67 @@ func fsMust(f fs.FS, err error) fs.FS { } return f } + +// dirHasFiles returns true if the directory contains at least one file (non-directory). +func dirHasFiles(dir string) (bool, error) { + d, err := os.Open(dir) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + defer d.Close() + + // Read a few entries; if any entry exists, consider it having files/dirs. + // We want to know if there is at least one file; if only subdirs exist, still treat as content. + entries, err := d.Readdir(-1) + if err != nil { + return false, err + } + for _, e := range entries { + if e.IsDir() { + // check recursively + has, err := dirHasFiles(filepath.Join(dir, e.Name())) + if err == nil && has { + return true, nil + } + continue + } + // regular file + return true, nil + } + return false, nil +} + +// copyEmbedDirToDisk copies the contents of srcFS into dstDir on disk. +func copyEmbedDirToDisk(srcFS fs.FS, dstDir string) error { + return fs.WalkDir(srcFS, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + target := filepath.Join(dstDir, path) + if d.IsDir() { + return os.MkdirAll(target, 0755) + } + // ensure parent dir exists + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + // open source file + f, err := srcFS.Open(path) + if err != nil { + return err + } + defer f.Close() + out, err := os.Create(target) + if err != nil { + return err + } + if _, err := io.Copy(out, f); err != nil { + _ = out.Close() + return err + } + return out.Close() + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index b2de919..d9ffe54 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -157,6 +157,7 @@ func defaultConfig() *Config { SiteCompanyName: getEnvStr("WG_PORTAL_WEB_SITE_COMPANY_NAME", "WireGuard Portal"), CertFile: getEnvStr("WG_PORTAL_WEB_CERT_FILE", ""), KeyFile: getEnvStr("WG_PORTAL_WEB_KEY_FILE", ""), + FrontendFilePath: getEnvStr("WG_PORTAL_WEB_FRONTEND_FILEPATH", ""), } cfg.Advanced.LogLevel = getEnvStr("WG_PORTAL_ADVANCED_LOG_LEVEL", "info") diff --git a/internal/config/web.go b/internal/config/web.go index e4d8dd3..407356c 100644 --- a/internal/config/web.go +++ b/internal/config/web.go @@ -27,6 +27,10 @@ type WebConfig struct { CertFile string `yaml:"cert_file"` // KeyFile is the path to the TLS certificate key file. KeyFile string `yaml:"key_file"` + // FrontendFilePath is an optional path to a folder that contains the frontend files. + // If set and the folder contains at least one file, it overrides the embedded frontend. + // If set and the folder is empty or does not exist, the embedded frontend will be written into it on startup. + FrontendFilePath string `yaml:"frontend_filepath"` } func (c *WebConfig) Sanitize() {