Files
3x-ui/internal/web/controller/dist.go
korsun009 3a2f9b48da feat(web): add network-only PWA installability (#6190)
* feat(web): add network-only PWA installability

Serve the manifest, registration script, network-only service worker, and icons under the runtime web base path so panels remain installable at arbitrary configured URLs.

This does not add offline caching or change panel, API, database, or Xray behavior.

* chore(docs): remove development planning notes

Keep the pull request focused on the PWA implementation, tests, and user-facing verification documentation.

* feat(web): adopt the 3X logo PWA icon set from #1865

Replace the two placeholder SVG icons with the six-size PNG set
(16/24/32/64/192/512) contributed by @Incognito-Coder in PR #1865.
The PNGs have transparent rounded corners, so the manifest entries
drop the maskable purpose claim and rely on the default any.

---------

Co-authored-by: korsun009 <277924786+korsun009@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-08-18 15:33:20 +02:00

155 lines
4.8 KiB
Go

package controller
import (
"bytes"
"encoding/json"
htmlpkg "html"
"io/fs"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
)
var distFS fs.FS
func SetDistFS(fsys fs.FS) {
distFS = fsys
}
var distPageBuildTime = time.Now()
// ServeOpenAPISpec returns the generated OpenAPI 3.0 description of the
// panel API. Postman / Insomnia / openapi-generator consume this URL
// directly; the in-panel Swagger UI page also fetches it. The spec is
// produced at frontend build time by scripts/build-openapi.mjs and
// embedded into the binary via the dist FS.
func ServeOpenAPISpec(c *gin.Context) {
body, err := fs.ReadFile(distFS, "dist/openapi.json")
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"})
return
}
// The embedded spec ships with `servers: [{url: "/"}]`. When the panel runs
// under a non-root web base path, Swagger UI "Try it out" and external
// generators must target that prefix, so rewrite the single server entry to
// the runtime base path before serving.
if basePath := c.GetString("base_path"); basePath != "" && basePath != "/" {
if rebuilt, err := withServerBasePath(body, basePath); err != nil {
logger.Warning("openapi.json: could not inject base path:", err)
} else {
body = rebuilt
}
}
c.Header("Cache-Control", "public, max-age=300")
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
// withServerBasePath rewrites the spec's `servers` entry so requests target the
// panel's configured web base path. Only the top-level `servers` field is
// replaced; every other field is preserved verbatim via json.RawMessage.
func withServerBasePath(spec []byte, basePath string) ([]byte, error) {
var doc map[string]json.RawMessage
if err := json.Unmarshal(spec, &doc); err != nil {
return nil, err
}
servers, err := json.Marshal([]map[string]string{{
"url": strings.TrimSuffix(basePath, "/"),
"description": "Current panel",
}})
if err != nil {
return nil, err
}
doc["servers"] = servers
return json.Marshal(doc)
}
func normalizeWebBasePath(basePath string) string {
if basePath == "" {
return "/"
}
if !strings.HasPrefix(basePath, "/") {
basePath = "/" + basePath
}
if !strings.HasSuffix(basePath, "/") {
basePath += "/"
}
return basePath
}
func pwaHeadInjection(basePath, pageName string) []byte {
if pageName != "index.html" && pageName != "login.html" {
return nil
}
basePath = normalizeWebBasePath(basePath)
return []byte(`<link rel="manifest" href="` + htmlpkg.EscapeString(basePath+"manifest.webmanifest") + `"><script data-cfasync="false" defer src="` + htmlpkg.EscapeString(basePath+"pwa-register.js") + `"></script>`)
}
func serveDistPage(c *gin.Context, name string) {
body, err := fs.ReadFile(distFS, "dist/"+name)
if err != nil {
c.String(http.StatusInternalServerError, "missing embedded page: %s", name)
return
}
basePath := c.GetString("base_path")
if basePath == "" {
basePath = "/"
}
if basePath != "/" {
body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
}
jsEscape := strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
"\n", `\n`,
"\r", `\r`,
"<", `<`,
">", `>`,
"&", `&`,
)
escapedBase := jsEscape.Replace(basePath)
csrfToken, err := session.EnsureCSRFToken(c)
if err != nil {
logger.Warning("Unable to mint CSRF token for", name+":", err)
csrfToken = ""
}
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
basePathMeta := []byte(`<meta name="base-path" content="` + htmlpkg.EscapeString(basePath) + `">`)
nonceAttr := ""
if nonce := c.GetString("csp_nonce"); nonce != "" {
nonceAttr = ` nonce="` + htmlpkg.EscapeString(nonce) + `"`
}
script := `<script data-cfasync="false"` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
if name != "login.html" {
escapedVer := jsEscape.Replace(config.GetPanelVersion())
script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
script += `;window.X_UI_DB_TYPE="` + config.GetDBKind() + `"`
}
script += `;</script>`
inject := []byte(script)
inject = append(inject, csrfMeta...)
inject = append(inject, basePathMeta...)
inject = append(inject, pwaHeadInjection(basePath, name)...)
inject = append(inject, []byte(`</head>`)...)
out := bytes.Replace(body, []byte("</head>"), inject, 1)
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
c.Header("Last-Modified", distPageBuildTime.UTC().Format(http.TimeFormat))
c.Data(http.StatusOK, "text/html; charset=utf-8", out)
}