Compare commits

...

2 Commits

Author SHA1 Message Date
Christoph Haas
b6a27616c1 allow setting a base-path for the web UI and API (#583) 2025-12-13 00:25:52 +01:00
h44z
8cc937b031 Custom templates (#594)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* allow custom mail templates (#533)

* allow to override embedded frontend (#533)
2025-12-10 23:10:43 +01:00
23 changed files with 447 additions and 49 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,7 +11,12 @@ core:
web:
external_url: http://localhost:8888
base_path: ""
request_logging: true
frontend_filepath: ""
mail:
templates_path: ""
webhook:
url: ""

View File

@@ -74,6 +74,7 @@ mail:
from: Wireguard Portal <noreply@wireguard.local>
link_only: false
allow_peer_email: false
templates_path: ""
auth:
oidc: []
@@ -87,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
@@ -96,6 +98,7 @@ web:
expose_host_info: false
cert_file: ""
key_File: ""
frontend_filepath: ""
webhook:
url: ""
@@ -485,6 +488,11 @@ To send emails to all peers that have a valid email-address as user-identifier,
If false, and the peer has no valid user record linked, emails will not be sent.
If a peer has linked a valid user, the email address is always taken from the user record.
### `templates_path`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_MAIL_TEMPLATES_PATH`
- **Description:** Path to the email template files that override embedded templates. Check [usage documentation](../usage/mail-templates.md) for an example.`
---
## Auth
@@ -793,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`
@@ -841,6 +856,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

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

@@ -0,0 +1,49 @@
WireGuard Portal sends emails when you share a configuration with a user.
By default, the application uses embedded templates. You can fully customize these emails by pointing the Portal
to a folder containing your own templates. If the folder is empty on startup, the default embedded templates
are written there to get you started.
## Configuration
To enable custom templates, set the `mail.templates_path` option in the application configuration file
or the `WG_PORTAL_MAIL_TEMPLATES_PATH` environment variable to a valid folder path.
For example:
```yaml
mail:
# ... other mail options ...
# 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: "/opt/wg-portal/mail-templates"
```
## Template files and names
The system expects the following template names. Place files with these names in your `templates_path` to override the defaults.
You do not need to override all templates, only the ones you want to customize should be present.
- Text templates (`.gotpl`):
- `mail_with_link.gotpl`
- `mail_with_attachment.gotpl`
- HTML templates (`.gohtml`):
- `mail_with_link.gohtml`
- `mail_with_attachment.gohtml`
Both [text](https://pkg.go.dev/text/template) and [HTML templates](https://pkg.go.dev/html/template) are standard Go
templates and receive the following data fields, depending on the email type:
- Common fields:
- `PortalUrl` (string) - external URL of the Portal
- `PortalName` (string) - site title/company name
- `User` (*domain.User) - the recipient user (may be partially populated when sending to a peer email)
- Link email (`mail_with_link.*`):
- `Link` (string) - the download link
- Attachment email (`mail_with_attachment.*`):
- `ConfigFileName` (string) - filename of the attached WireGuard config
- `QrcodePngName` (string) - CID content-id of the embedded QR code image
Tip: You can inspect the embedded templates in the repository under [`internal/app/mail/tpl_files/`](https://github.com/h44z/wg-portal/tree/master/internal/app/mail/tpl_files) for reference.
When the directory at `templates_path` is empty, these files are copied to your folder so you can edit them in place.

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

@@ -4,10 +4,14 @@ import (
"context"
"fmt"
"html/template"
"io"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/go-pkgz/routegroup"
@@ -35,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
}
@@ -75,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...)
@@ -126,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
@@ -147,38 +181,193 @@ 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")
})
s.server.HandleFiles("/app", http.FS(fsMust(fs.Sub(frontendStatics, "frontend-dist"))))
// 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)
} 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)
useEmbeddedFrontend = false
}
}
}
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)
}
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()
})
}

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

@@ -72,7 +72,7 @@ func NewMailManager(
users UserDatabaseRepo,
wg WireguardDatabaseRepo,
) (*Manager, error) {
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl, cfg.Web.SiteTitle)
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl, cfg.Web.SiteTitle, cfg.Mail.TemplatesPath)
if err != nil {
return nil, fmt.Errorf("failed to initialize template handler: %w", err)
}

View File

@@ -6,6 +6,10 @@ import (
"fmt"
htmlTemplate "html/template"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"text/template"
"github.com/h44z/wg-portal/internal/domain"
@@ -22,15 +26,50 @@ type TemplateHandler struct {
textTemplates *template.Template
}
func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error) {
func newTemplateHandler(portalUrl, portalName string, basePath string) (*TemplateHandler, error) {
// Always parse embedded defaults first
htmlTemplateCache, err := htmlTemplate.New("Html").ParseFS(TemplateFiles, "tpl_files/*.gohtml")
if err != nil {
return nil, fmt.Errorf("failed to parse html template files: %w", err)
return nil, fmt.Errorf("failed to parse embedded html template files: %w", err)
}
txtTemplateCache, err := template.New("Txt").ParseFS(TemplateFiles, "tpl_files/*.gotpl")
if err != nil {
return nil, fmt.Errorf("failed to parse text template files: %w", err)
return nil, fmt.Errorf("failed to parse embedded text template files: %w", err)
}
// If a basePath is provided, ensure existence, populate if empty, then parse to override
if basePath != "" {
if err := os.MkdirAll(basePath, 0755); err != nil {
return nil, fmt.Errorf("failed to create templates base directory %s: %w", basePath, err)
}
hasTemplates, err := dirHasTemplates(basePath)
if err != nil {
return nil, fmt.Errorf("failed to inspect templates directory: %w", err)
}
// If no templates present, copy embedded defaults to directory
if !hasTemplates {
if err := copyEmbeddedTemplates(basePath); err != nil {
return nil, fmt.Errorf("failed to populate templates directory: %w", err)
}
}
// Parse files from basePath to override embedded ones.
// Only parse when matches exist to allow partial overrides without errors.
if matches, _ := filepath.Glob(filepath.Join(basePath, "*.gohtml")); len(matches) > 0 {
slog.Debug("parsing html email templates from base path", "base-path", basePath, "files", matches)
if htmlTemplateCache, err = htmlTemplateCache.ParseFiles(matches...); err != nil {
return nil, fmt.Errorf("failed to parse html templates from base path: %w", err)
}
}
if matches, _ := filepath.Glob(filepath.Join(basePath, "*.gotpl")); len(matches) > 0 {
slog.Debug("parsing text email templates from base path", "base-path", basePath, "files", matches)
if txtTemplateCache, err = txtTemplateCache.ParseFiles(matches...); err != nil {
return nil, fmt.Errorf("failed to parse text templates from base path: %w", err)
}
}
}
handler := &TemplateHandler{
@@ -43,24 +82,71 @@ func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error)
return handler, nil
}
// dirHasTemplates checks whether directory contains any .gohtml or .gotpl files.
func dirHasTemplates(basePath string) (bool, error) {
entries, err := os.ReadDir(basePath)
if err != nil {
return false, err
}
for _, e := range entries {
if e.IsDir() {
continue
}
ext := filepath.Ext(e.Name())
if ext == ".gohtml" || ext == ".gotpl" {
return true, nil
}
}
return false, nil
}
// copyEmbeddedTemplates writes embedded templates into basePath.
func copyEmbeddedTemplates(basePath string) error {
list, err := fs.ReadDir(TemplateFiles, "tpl_files")
if err != nil {
return err
}
for _, entry := range list {
if entry.IsDir() {
continue
}
name := entry.Name()
// Only copy known template extensions
if ext := filepath.Ext(name); ext != ".gohtml" && ext != ".gotpl" {
continue
}
data, err := TemplateFiles.ReadFile(filepath.Join("tpl_files", name))
if err != nil {
return err
}
out := filepath.Join(basePath, name)
if err := os.WriteFile(out, data, 0644); err != nil {
return err
}
}
return nil
}
// GetConfigMail returns the text and html template for the mail with a link.
func (c TemplateHandler) GetConfigMail(user *domain.User, link string) (io.Reader, io.Reader, error) {
var tplBuff bytes.Buffer
var htmlTplBuff bytes.Buffer
err := c.textTemplates.ExecuteTemplate(&tplBuff, "mail_with_link.gotpl", map[string]any{
"User": user,
"Link": link,
"PortalUrl": c.portalUrl,
"User": user,
"Link": link,
"PortalUrl": c.portalUrl,
"PortalName": c.portalName,
})
if err != nil {
return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gotpl: %w", err)
}
err = c.htmlTemplates.ExecuteTemplate(&htmlTplBuff, "mail_with_link.gohtml", map[string]any{
"User": user,
"Link": link,
"PortalUrl": c.portalUrl,
"User": user,
"Link": link,
"PortalUrl": c.portalUrl,
"PortalName": c.portalName,
})
if err != nil {
return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gohtml: %w", err)

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"),
@@ -157,6 +158,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")
@@ -195,6 +197,7 @@ func defaultConfig() *Config {
From: getEnvStr("WG_PORTAL_MAIL_FROM", "Wireguard Portal <noreply@wireguard.local>"),
LinkOnly: getEnvBool("WG_PORTAL_MAIL_LINK_ONLY", false),
AllowPeerEmail: getEnvBool("WG_PORTAL_MAIL_ALLOW_PEER_EMAIL", false),
TemplatesPath: getEnvStr("WG_PORTAL_MAIL_TEMPLATES_PATH", ""),
}
cfg.Webhook.Url = getEnvStr("WG_PORTAL_WEBHOOK_URL", "") // no webhook by default

View File

@@ -43,4 +43,8 @@ type MailConfig struct {
LinkOnly bool `yaml:"link_only"`
// AllowPeerEmail specifies whether emails should be sent to peers which have no valid user account linked, but an email address is set as "user".
AllowPeerEmail bool `yaml:"allow_peer_email"`
// TemplatesPath is an optional base path on the filesystem that contains email templates (.gotpl and .gohtml).
// If the directory exists but is empty, the embedded default templates will be written there on startup.
// If templates are present in the directory, they override the embedded defaults.
TemplatesPath string `yaml:"templates_path"`
}

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.
@@ -27,8 +31,20 @@ 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() {
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
}

View File

@@ -81,6 +81,7 @@ nav:
- Reverse Proxy (HTTPS): documentation/getting-started/reverse-proxy.md
- Configuration:
- Overview: documentation/configuration/overview.md
- Mail templates: documentation/configuration/mail-templates.md
- Examples: documentation/configuration/examples.md
- Usage:
- General: documentation/usage/general.md
@@ -88,6 +89,7 @@ nav:
- LDAP: documentation/usage/ldap.md
- Security: documentation/usage/security.md
- Webhooks: documentation/usage/webhooks.md
- Mail Templates: documentation/usage/mail-templates.md
- REST API: documentation/rest-api/api-doc.md
- Upgrade: documentation/upgrade/v1.md
- Monitoring: documentation/monitoring/prometheus.md