Feature: Support subpath deployment for Go web UI
Add optional YAMA_WEB_BASE_PATH to mount the whole web UI (index, manifest, static assets, /health, /api/devices, /ws) under a URL prefix such as /remoter behind a reverse proxy. index.html now uses a __BASE_PATH__ placeholder substituted at serve time, the PWA manifest start_url is rooted at the base path, and a mount wrapper redirects the bare prefix to prefix/ and strips it before routing. Include nginx, systemd, and env-file deployment examples for the subpath setup. The shared placeholder is also handled by the MFC server, which serves from the domain root and strips __BASE_PATH__ (to "") so its web remote desktop URLs stay rooted. Co-Authored-By: deepseek-v4-pro
This commit is contained in:
1 parent
32cc7af371
commit
42df128f62
7 files changed
+254
-12
No files matched your search
+50
-7
@@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -30,6 +31,7 @@ type Server struct {
|
||||
loginIPLimit *wsauth.RateLimiter
|
||||
loginUserLimit *wsauth.RateLimiter
|
||||
trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only)
|
||||
basePath string // web UI mount prefix (e.g. "/remoter"); "" = domain root
|
||||
}
|
||||
|
||||
// Config tunes the server's exposed-on-public-HTTPS hardening knobs.
|
||||
@@ -52,6 +54,12 @@ type Config struct {
|
||||
// exposure the header is client-controlled and would let attackers
|
||||
// evade per-IP rate limits.
|
||||
TrustForwardedFor bool
|
||||
// BasePath mounts the whole web UI (index, manifest, static assets,
|
||||
// /health, /api/devices, /ws) under a URL prefix, so a reverse proxy
|
||||
// can serve it from a subpath of an existing domain (e.g.
|
||||
// https://example.com/remoter). Must be empty or a leading-slash,
|
||||
// no-trailing-slash path; "" (default) serves from the domain root.
|
||||
BasePath string
|
||||
}
|
||||
|
||||
// New creates an HTTP server bound to the given port. port=0 disables the server.
|
||||
@@ -68,6 +76,7 @@ func (s *Server) WithConfig(cfg Config) *Server {
|
||||
s.loginIPLimit = cfg.LoginIPLimit
|
||||
s.loginUserLimit = cfg.LoginUserLimit
|
||||
s.trustForwardedFor = cfg.TrustForwardedFor
|
||||
s.basePath = cfg.BasePath
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -96,7 +105,7 @@ func (s *Server) Start() error {
|
||||
|
||||
s.srv = &http.Server{
|
||||
Addr: ":" + strconv.Itoa(s.port),
|
||||
Handler: mux,
|
||||
Handler: s.mount(mux),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
@@ -129,14 +138,41 @@ func (s *Server) Stop() {
|
||||
_ = s.srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// basePathPlaceholder is the token in index.html substituted with the
|
||||
// configured base path at serve time. Empty base path → "" → root URLs.
|
||||
const basePathPlaceholder = "__BASE_PATH__"
|
||||
|
||||
// mount wraps the root-path mux with the configured base path. When basePath
|
||||
// is empty the mux is returned unchanged (domain-root serving, 方案A). Otherwise
|
||||
// the bare prefix redirects to prefix+"/", paths outside the prefix 404, and
|
||||
// matching paths are stripped before reaching the mux (方案B, subpath serving).
|
||||
func (s *Server) mount(h http.Handler) http.Handler {
|
||||
if s.basePath == "" {
|
||||
return h
|
||||
}
|
||||
base := s.basePath // normalized by cmd: leading "/", no trailing "/"
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == base {
|
||||
http.Redirect(w, r, base+"/", http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(r.URL.Path, base+"/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.StripPrefix(base, h).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" && r.URL.Path != "/index.html" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
body := bytes.ReplaceAll(IndexHTML, []byte(basePathPlaceholder), []byte(s.basePath))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_, _ = w.Write(IndexHTML)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -190,21 +226,28 @@ func (s *Server) unauthorized(w http.ResponseWriter) {
|
||||
}
|
||||
|
||||
// PWA manifest. Referenced by <link rel="manifest"> in index.html.
|
||||
// Static JSON, no template needed.
|
||||
const manifestJSON = `{
|
||||
// start_url is rooted at the configured base path so an installed PWA
|
||||
// launches into the right location; "/" when served from the domain root.
|
||||
func (s *Server) manifestJSON() []byte {
|
||||
start := "/"
|
||||
if s.basePath != "" {
|
||||
start = s.basePath + "/"
|
||||
}
|
||||
return []byte(fmt.Sprintf(`{
|
||||
"name": "SimpleRemoter",
|
||||
"short_name": "Remoter",
|
||||
"start_url": "/",
|
||||
"start_url": "%s",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#1a1a2e",
|
||||
"theme_color": "#1a1a2e"
|
||||
}`
|
||||
}`, start))
|
||||
}
|
||||
|
||||
func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = w.Write([]byte(manifestJSON))
|
||||
_, _ = w.Write(s.manifestJSON())
|
||||
}
|
||||
|
||||
// staticHandler returns an http.HandlerFunc that serves a fixed byte slice
|
||||
|
||||
Reference in new issue
Block a user