allow setting a base-path for the web UI and API (#583)

This commit is contained in:
Christoph Haas
2025-12-11 18:38:26 +01:00
parent 8cc937b031
commit b6a27616c1
18 changed files with 177 additions and 50 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 709 B

View File

@@ -6,9 +6,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>WireGuard Portal API</title>
<meta name="description" content="WireGuard Portal API">
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/fonts/fontawesome-all.min.css">
<link rel="shortcut icon" type="image/x-icon" href="/app/favicon.ico">
<link rel="stylesheet" href="{{$.BasePath}}/css/bootstrap.min.css">
<link rel="stylesheet" href="{{$.BasePath}}/fonts/fontawesome-all.min.css">
<link rel="shortcut icon" type="image/x-icon" href="{{$.BasePath}}/app/favicon.ico">
</head>
<body id="page-top" class="d-flex flex-column min-vh-100">
@@ -25,7 +25,7 @@
<div class="card-header">SPA Api</div>
<div class="card-body">
<p class="card-text">This endpoint is used by the Single Page Application (the Frontend/UI).</p>
<a href="/api/v0/doc.html" title="API version 0" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
<a href="{{$.BasePath}}/api/v0/doc.html" title="API version 0" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
</div>
</div>
</div>
@@ -34,7 +34,7 @@
<div class="card-header"><b>Version 1</b></div>
<div class="card-body">
<p class="card-text">This is the current main API endpoint.</p>
<a href="/api/v1/doc.html" title="API version 1" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
<a href="{{$.BasePath}}/api/v1/doc.html" title="API version 1" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
</div>
</div>
</div>
@@ -43,17 +43,17 @@
<div class="card-header">Version 2</div>
<div class="card-body">
<p class="card-text">This will be a future API version, it is currently work in progress.</p>
<a href="/api/v2/doc.html" title="API version 2" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
<a href="{{$.BasePath}}/api/v2/doc.html" title="API version 2" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
</div>
</div>
</div>
</div>
</div>
{{template "prt_footer.gohtml" .}}
<script src="/js/jquery.min.js"></script>
<script src="/js/jquery.easing.js"></script>
<script src="/js/popper.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="{{$.BasePath}}/js/jquery.min.js"></script>
<script src="{{$.BasePath}}/js/jquery.easing.js"></script>
<script src="{{$.BasePath}}/js/popper.min.js"></script>
<script src="{{$.BasePath}}/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -3,7 +3,7 @@
<span class="navbar-toggler-icon"></span>
</button>
<a class="navbar-brand" href="/"><img src="/img/header-logo.png" alt="Prolicht"/></a>
<a class="navbar-brand" href="/"><img src="{{$.BasePath}}/img/header-logo.png" alt="Prolicht"/></a>
<div id="topNavbar" class="navbar-collapse collapse">
<ul class="navbar-nav mr-auto mt-2 mt-lg-0">
</ul>

View File

@@ -22,7 +22,7 @@
allow-spec-file-load="false"
allow-spec-file-download="true"
>
<img slot="logo" src="/img/header-logo-small.png" style="width:50px; height:50px"/>
<img slot="logo" src="{{$.BasePath}}/img/header-logo-small.png" style="width:50px; height:50px"/>
<p slot="footer" style="margin:0; padding:16px 36px; background-color:#f76b39; color:#fff; text-align:center;" >
Copyright © WireGuard Portal {{$.Year}}, version {{$.Version}}

View File

@@ -10,6 +10,8 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/go-pkgz/routegroup"
@@ -37,6 +39,7 @@ type ApiEndpointSetupFunc func() (ApiVersion, GroupSetupFn)
type Server struct {
cfg *config.Config
server *routegroup.Bundle
root *routegroup.Bundle // root is the web-root (potentially with path prefix)
tpl *respond.TemplateRenderer
versions map[ApiVersion]*routegroup.Bundle
}
@@ -77,13 +80,42 @@ func NewServer(cfg *config.Config, endpoints ...ApiEndpointSetupFunc) (*Server,
template.Must(template.New("").ParseFS(apiTemplates, "assets/tpl/*.gohtml")),
)
// Serve static files
// Mount base path if configured
s.root = s.server
if s.cfg.Web.BasePath != "" {
s.root = s.server.Mount(s.cfg.Web.BasePath)
}
// Serve static files (under base path if configured)
imgFs := http.FS(fsMust(fs.Sub(apiStatics, "assets/img")))
s.server.HandleFiles("/css", http.FS(fsMust(fs.Sub(apiStatics, "assets/css"))))
s.server.HandleFiles("/js", http.FS(fsMust(fs.Sub(apiStatics, "assets/js"))))
s.server.HandleFiles("/img", imgFs)
s.server.HandleFiles("/fonts", http.FS(fsMust(fs.Sub(apiStatics, "assets/fonts"))))
s.server.HandleFiles("/doc", http.FS(fsMust(fs.Sub(apiStatics, "assets/doc"))))
s.root.HandleFiles("/css", http.FS(fsMust(fs.Sub(apiStatics, "assets/css"))))
s.root.HandleFiles("/js", http.FS(fsMust(fs.Sub(apiStatics, "assets/js"))))
s.root.HandleFiles("/img", imgFs)
s.root.HandleFiles("/fonts", http.FS(fsMust(fs.Sub(apiStatics, "assets/fonts"))))
if cfg.Web.BasePath == "" {
s.root.HandleFiles("/doc", http.FS(fsMust(fs.Sub(apiStatics, "assets/doc"))))
} else {
customV0File, _ := fs.ReadFile(fsMust(fs.Sub(apiStatics, "assets/doc")), "v0_swagger.yaml")
customV1File, _ := fs.ReadFile(fsMust(fs.Sub(apiStatics, "assets/doc")), "v1_swagger.yaml")
customV0File = []byte(strings.Replace(string(customV0File),
"basePath: /api/v0", "basePath: "+cfg.Web.BasePath+"/api/v0", 1))
customV1File = []byte(strings.Replace(string(customV1File),
"basePath: /api/v1", "basePath: "+cfg.Web.BasePath+"/api/v1", 1))
s.root.HandleFunc("GET /doc/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == s.cfg.Web.BasePath+"/doc/v0_swagger.yaml" {
respond.Data(w, http.StatusOK, "application/yaml", customV0File)
return
}
if r.URL.Path == s.cfg.Web.BasePath+"/doc/v1_swagger.yaml" {
respond.Data(w, http.StatusOK, "application/yaml", customV1File)
return
}
respond.Status(w, http.StatusNotFound)
})
}
// Setup routes
s.setupRoutes(endpoints...)
@@ -128,14 +160,14 @@ func (s *Server) Run(ctx context.Context, listenAddress string) {
}
func (s *Server) setupRoutes(endpoints ...ApiEndpointSetupFunc) {
s.server.HandleFunc("GET /api", s.landingPage)
s.root.HandleFunc("GET /api", s.landingPage)
s.versions = make(map[ApiVersion]*routegroup.Bundle)
for _, setupFunc := range endpoints {
version, groupSetupFn := setupFunc()
if _, ok := s.versions[version]; !ok {
s.versions[version] = s.server.Mount(fmt.Sprintf("/api/%s", version))
s.versions[version] = s.root.Mount(fmt.Sprintf("/api/%s", version))
// OpenAPI documentation (via RapiDoc)
s.versions[version].HandleFunc("GET /swagger/index.html", s.rapiDocHandler(version)) // Deprecated: old link
@@ -149,16 +181,17 @@ func (s *Server) setupRoutes(endpoints ...ApiEndpointSetupFunc) {
func (s *Server) setupFrontendRoutes() {
// Serve static files
s.server.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
respond.Redirect(w, r, http.StatusMovedPermanently, "/app")
s.root.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
respond.Redirect(w, r, http.StatusMovedPermanently, s.cfg.Web.BasePath+"/app")
})
s.server.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
respond.Redirect(w, r, http.StatusMovedPermanently, "/app/favicon.ico")
s.root.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
respond.Redirect(w, r, http.StatusMovedPermanently, s.cfg.Web.BasePath+"/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.
useEmbeddedFrontend := true
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)
@@ -181,34 +214,93 @@ func (s *Server) setupFrontendRoutes() {
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
useEmbeddedFrontend = false
}
}
}
// Fallback: serve embedded frontend files
s.server.HandleFiles("/app", http.FS(fsMust(fs.Sub(frontendStatics, "frontend-dist"))))
var fileServer http.Handler
if useEmbeddedFrontend {
fileServer = http.FileServer(http.FS(fsMust(fs.Sub(frontendStatics, "frontend-dist"))))
} else {
fileServer = http.FileServer(http.Dir(s.cfg.Web.FrontendFilePath))
}
fileServer = http.StripPrefix(s.cfg.Web.BasePath+"/app", fileServer)
// Modify index.html and CSS to include the correct base path.
var customIndexFile, customCssFile []byte
var customCssFileName string
if s.cfg.Web.BasePath != "" {
customIndexFile, customCssFile, customCssFileName = s.updateBasePathInFrontend(useEmbeddedFrontend)
}
s.root.HandleFunc("GET /app/", func(w http.ResponseWriter, r *http.Request) {
// serve a custom index.html file with the correct base path applied
if s.cfg.Web.BasePath != "" && r.URL.Path == s.cfg.Web.BasePath+"/app/" {
respond.Data(w, http.StatusOK, "text/html", customIndexFile)
return
}
// serve a custom CSS file with the correct base path applied
if s.cfg.Web.BasePath != "" && r.URL.Path == s.cfg.Web.BasePath+"/app/assets/"+customCssFileName {
respond.Data(w, http.StatusOK, "text/css", customCssFile)
return
}
// pass all other requests to the file server
fileServer.ServeHTTP(w, r)
})
}
func (s *Server) landingPage(w http.ResponseWriter, _ *http.Request) {
s.tpl.HTML(w, http.StatusOK, "index.gohtml", respond.TplData{
"Version": internal.Version,
"Year": time.Now().Year(),
"BasePath": s.cfg.Web.BasePath,
"Version": internal.Version,
"Year": time.Now().Year(),
})
}
func (s *Server) rapiDocHandler(version ApiVersion) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.tpl.HTML(w, http.StatusOK, "rapidoc.gohtml", respond.TplData{
"RapiDocSource": "/js/rapidoc-min.js",
"ApiSpecUrl": fmt.Sprintf("/doc/%s_swagger.yaml", version),
"RapiDocSource": s.cfg.Web.BasePath + "/js/rapidoc-min.js",
"BasePath": s.cfg.Web.BasePath,
"ApiSpecUrl": fmt.Sprintf("%s/doc/%s_swagger.yaml", s.cfg.Web.BasePath, version),
"Version": internal.Version,
"Year": time.Now().Year(),
})
}
}
func (s *Server) updateBasePathInFrontend(useEmbeddedFrontend bool) ([]byte, []byte, string) {
if s.cfg.Web.BasePath == "" {
return nil, nil, "" // nothing to do
}
var customIndexFile []byte
if useEmbeddedFrontend {
customIndexFile, _ = fs.ReadFile(fsMust(fs.Sub(frontendStatics, "frontend-dist")), "index.html")
} else {
customIndexFile, _ = os.ReadFile(filepath.Join(s.cfg.Web.FrontendFilePath, "index.html"))
}
newIndexStr := strings.ReplaceAll(string(customIndexFile), "src=\"/", "src=\""+s.cfg.Web.BasePath+"/")
newIndexStr = strings.ReplaceAll(newIndexStr, "href=\"/", "href=\""+s.cfg.Web.BasePath+"/")
re := regexp.MustCompile(`/app/assets/(index-.+.css)`)
match := re.FindStringSubmatch(newIndexStr)
cssFileName := match[1]
var customCssFile []byte
if useEmbeddedFrontend {
customCssFile, _ = fs.ReadFile(fsMust(fs.Sub(frontendStatics, "frontend-dist")), "assets/"+cssFileName)
} else {
customCssFile, _ = os.ReadFile(filepath.Join(s.cfg.Web.FrontendFilePath, "/assets/", cssFileName))
}
newCssStr := strings.ReplaceAll(string(customCssFile), "/app/assets/", s.cfg.Web.BasePath+"/app/assets/")
return []byte(newIndexStr), []byte(newCssStr), cssFileName
}
func fsMust(f fs.FS, err error) fs.FS {
if err != nil {
panic(err)

View File

@@ -67,7 +67,8 @@ func (e ConfigEndpoint) RegisterRoutes(g *routegroup.Bundle) {
// @Router /config/frontend.js [get]
func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
backendUrl := fmt.Sprintf("%s/api/v0", e.cfg.Web.ExternalUrl)
basePath := e.cfg.Web.BasePath
backendUrl := fmt.Sprintf("%s%s/api/v0", e.cfg.Web.ExternalUrl, basePath)
if request.Header(r, "x-wg-dev") != "" {
referer := request.Header(r, "Referer")
host := "localhost"
@@ -76,12 +77,13 @@ func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc {
if err == nil {
host, port, _ = net.SplitHostPort(parsedReferer.Host)
}
backendUrl = fmt.Sprintf("http://%s:%s/api/v0", host,
port) // override if request comes from frontend started with npm run dev
backendUrl = fmt.Sprintf("http://%s:%s%s/api/v0", host,
port, basePath) // override if request comes from frontend started with npm run dev
}
e.tpl.Render(w, http.StatusOK, "frontend_config.js.gotpl", "text/javascript", map[string]any{
"BackendUrl": backendUrl,
"BasePath": basePath,
"Version": internal.Version,
"SiteTitle": e.cfg.Web.SiteTitle,
"SiteCompanyName": e.cfg.Web.SiteCompanyName,

View File

@@ -1,4 +1,5 @@
WGPORTAL_BACKEND_BASE_URL="{{ $.BackendUrl }}";
WGPORTAL_BASE_PATH="{{ $.BasePath }}";
WGPORTAL_VERSION="{{ $.Version }}";
WGPORTAL_SITE_TITLE="{{ $.SiteTitle }}";
WGPORTAL_SITE_COMPANY_NAME="{{ $.SiteCompanyName }}";

View File

@@ -49,7 +49,11 @@ func NewSessionWrapper(cfg *config.Config) *SessionWrapper {
sessionManager.Cookie.Secure = strings.HasPrefix(cfg.Web.ExternalUrl, "https")
sessionManager.Cookie.HttpOnly = true
sessionManager.Cookie.SameSite = http.SameSiteLaxMode
sessionManager.Cookie.Path = "/"
if cfg.Web.BasePath != "" {
sessionManager.Cookie.Path = cfg.Web.BasePath
} else {
sessionManager.Cookie.Path = "/"
}
sessionManager.Cookie.Persist = false
wrappedSessionManager := &SessionWrapper{sessionManager}

View File

@@ -99,7 +99,7 @@ type Authenticator struct {
}
// NewAuthenticator creates a new Authenticator instance.
func NewAuthenticator(cfg *config.Auth, extUrl string, bus EventBus, users UserManager) (
func NewAuthenticator(cfg *config.Auth, extUrl, basePath string, bus EventBus, users UserManager) (
*Authenticator,
error,
) {
@@ -107,7 +107,7 @@ func NewAuthenticator(cfg *config.Auth, extUrl string, bus EventBus, users UserM
cfg: cfg,
bus: bus,
users: users,
callbackUrlPrefix: fmt.Sprintf("%s/api/v0", extUrl),
callbackUrlPrefix: fmt.Sprintf("%s%s/api/v0", extUrl, basePath),
oauthAuthenticators: make(map[string]AuthenticatorOauth, len(cfg.OpenIDConnect)+len(cfg.OAuth)),
ldapAuthenticators: make(map[string]AuthenticatorLdap, len(cfg.Ldap)),
}