mirror of
https://github.com/h44z/wg-portal.git
synced 2025-12-14 18:46:17 +00:00
Compare commits
2 Commits
custom_tem
...
web_base_p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6a27616c1 | ||
|
|
8cc937b031 |
@@ -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)
|
||||
|
||||
|
||||
@@ -11,7 +11,12 @@ core:
|
||||
|
||||
web:
|
||||
external_url: http://localhost:8888
|
||||
base_path: ""
|
||||
request_logging: true
|
||||
frontend_filepath: ""
|
||||
|
||||
mail:
|
||||
templates_path: ""
|
||||
|
||||
webhook:
|
||||
url: ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
49
docs/documentation/usage/mail-templates.md
Normal file
49
docs/documentation/usage/mail-templates.md
Normal 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.
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }}";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user