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

View File

@@ -84,7 +84,7 @@ func main() {
internal.AssertNoError(err)
userManager.StartBackgroundJobs(ctx)
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager)
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, cfg.Web.BasePath, eventBus, userManager)
internal.AssertNoError(err)
authenticator.StartBackgroundJobs(ctx)

View File

@@ -11,18 +11,11 @@ core:
web:
external_url: http://localhost:8888
base_path: ""
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.
# If the directory is empty on startup, the default embedded templates
# will be written there so you can modify them.
# Leave empty to use embedded templates only.
templates_path: ""
webhook:

View File

@@ -88,6 +88,7 @@ auth:
web:
listening_address: :8888
external_url: http://localhost:8888
base_path: ""
site_company_name: WireGuard Portal
site_title: WireGuard Portal
session_identifier: wgPortalSession
@@ -800,9 +801,16 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio
### `external_url`
- **Default:** `http://localhost:8888`
- **Environment Variable:** `WG_PORTAL_WEB_EXTERNAL_URL`
- **Description:** The URL where a client can access WireGuard Portal. This URL is used for generating links in emails and for performing OAUTH redirects.
- **Description:** The URL where a client can access WireGuard Portal. This URL is used for generating links in emails and for performing OAUTH redirects.
The external URL must not contain a path component or trailing slash. If you want to serve WireGuard Portal on a subpath, use the `base_path` setting.
**Important:** If you are using a reverse proxy, set this to the external URL of the reverse proxy, otherwise login will fail. If you access the portal via IP address, set this to the IP address of the server.
### `base_path`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_WEB_BASE_PATH`
- **Description:** The base path for the web server (e.g., `/wgportal`).
By default (meaning an empty value), the portal will be served from the root path `/`.
### `site_company_name`
- **Default:** `WireGuard Portal`
- **Environment Variable:** `WG_PORTAL_WEB_SITE_COMPANY_NAME`

View File

@@ -84,6 +84,16 @@ web:
external_url: https://wg.domain.com
```
If you want to serve the web interface on a different base-path, you can also set the `web.base_path` option:
```yaml
web:
external_url: https://wg.domain.com
base_path: /subpath
```
The WireGuard Portal will then be available at `https://wg.domain.com/subpath`.
### Built-in TLS
If you prefer to let WireGuard Portal handle TLS itself, you can use the built-in TLS support.

View File

@@ -9,6 +9,7 @@
<script>
// global config, will be overridden by backend if available
let WGPORTAL_BACKEND_BASE_URL="http://localhost:5000/api/v0";
let WGPORTAL_BASE_PATH="";
let WGPORTAL_VERSION="unknown";
let WGPORTAL_SITE_TITLE="WireGuard Portal";
let WGPORTAL_SITE_COMPANY_NAME="WireGuard Portal";

View File

@@ -85,6 +85,7 @@ const languageFlag = computed(() => {
const companyName = ref(WGPORTAL_SITE_COMPANY_NAME);
const wgVersion = ref(WGPORTAL_VERSION);
const currentYear = ref(new Date().getFullYear())
const webBasePath = ref(WGPORTAL_BASE_PATH);
const userDisplayName = computed(() => {
let displayName = "Unknown";
@@ -113,7 +114,7 @@ const userDisplayName = computed(() => {
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="/"><img :alt="companyName" src="/img/header-logo.png" /></a>
<RouterLink class="navbar-brand" :to="{ name: 'home' }"><img :alt="companyName" :src="webBasePath + '/img/header-logo.png'" /></RouterLink>
<button aria-controls="navbarColor01" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler"
data-bs-target="#navbarTop" data-bs-toggle="collapse" type="button">
<span class="navbar-toggler-icon"></span>

View File

@@ -9,6 +9,8 @@ const profile = profileStore()
const settings = settingsStore()
const auth = authStore()
const webBasePath = ref(WGPORTAL_BASE_PATH);
onMounted(async () => {
await profile.LoadUser()
await auth.LoadWebAuthnCredentials()
@@ -241,7 +243,7 @@ const updatePassword = async () => {
</button>
</div>
<div class="col-6">
<a href="/api/v1/doc.html" target="_blank" :alt="$t('settings.api.api-link')">{{ $t('settings.api.api-link') }}</a>
<a :href="webBasePath + '/api/v1/doc.html'" target="_blank" :alt="$t('settings.api.api-link')">{{ $t('settings.api.api-link') }}</a>
</div>
</div>
</div>

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)),
}

View File

@@ -149,6 +149,7 @@ func defaultConfig() *Config {
RequestLogging: getEnvBool("WG_PORTAL_WEB_REQUEST_LOGGING", false),
ExposeHostInfo: getEnvBool("WG_PORTAL_WEB_EXPOSE_HOST_INFO", false),
ExternalUrl: getEnvStr("WG_PORTAL_WEB_EXTERNAL_URL", "http://localhost:8888"),
BasePath: getEnvStr("WG_PORTAL_WEB_BASE_PATH", ""),
ListeningAddress: getEnvStr("WG_PORTAL_WEB_LISTENING_ADDRESS", ":8888"),
SessionIdentifier: getEnvStr("WG_PORTAL_WEB_SESSION_IDENTIFIER", "wgPortalSession"),
SessionSecret: getEnvStr("WG_PORTAL_WEB_SESSION_SECRET", "very_secret"),

View File

@@ -11,6 +11,10 @@ type WebConfig struct {
// ExternalUrl is the URL where a client can access WireGuard Portal.
// This is used for the callback URL of the OAuth providers.
ExternalUrl string `yaml:"external_url"`
// BasePath is an optional URL path prefix under which the whole web UI and API are served.
// Example: "/wg" will make the UI available at /wg/app and the API at /wg/api.
// Empty string means no prefix (served from root path).
BasePath string `yaml:"base_path"`
// ListeningAddress is the address and port for the web server.
ListeningAddress string `yaml:"listening_address"`
// SessionIdentifier is the session identifier for the web frontend.
@@ -35,4 +39,12 @@ type WebConfig struct {
func (c *WebConfig) Sanitize() {
c.ExternalUrl = strings.TrimRight(c.ExternalUrl, "/")
// normalize BasePath: allow empty, otherwise ensure leading slash, no trailing slash
p := strings.TrimSpace(c.BasePath)
p = strings.TrimRight(p, "/")
if p != "" && !strings.HasPrefix(p, "/") {
p = "/" + p
}
c.BasePath = p
}