Files
l-ui/internal/web/service/probe_theme.go
T
Muse 54dc346697 fix(probe): inject configured site name into served theme index title
The public page is an SPA: browsers show the theme package's static
<title> on first paint (a CFSM-era package still says
CF-Server-Monitor) and only switch to the configured title once the
theme JS fetches /api/me. Serve the configured title in the shell
itself so the first paint is already correct.

serveProbeThemeFile now rewrites the first <title> of index.html
with the site_name probe setting — the same setting /api/me's
site_name comes from, so the injected title and the JS rewrite can
never disagree. Theme files on disk are untouched, so any upstream
theme package works unmodified. With no site name configured the
bytes pass through unchanged (no empty title injected). The title
is HTML-escaped and spliced in place, so a $ in the title is safe.

Caching is unchanged and already correct: index.html is served
no-cache with a strong ETag over the final (injected) bytes, so a
title change lands on the next revalidation; hashed assets stay
immutable and pass through byte-identical.
2026-10-24 15:36:31 +00:00

322 lines
11 KiB
Go

package service
import (
"crypto/sha256"
"encoding/hex"
"html"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/gin-gonic/gin"
)
// Item 7 B3+B5: public theme hosting (hub frontend.rs
// serve/disk/asset port). Themes are static directory packages on
// disk — <db folder>/themes/<short>/dist — served by the panel
// process itself at the engine root, next to the contract API. A
// theme's files are only ever read as static bytes, never executed.
// What an installed theme does not answer falls through to the
// embedded fallback theme (B5: monitor-theme-default's release dist,
// vendored under probe_theme_default/), so the public page answers
// even with nothing installed. Site icons (the favicon / touch_icon
// settings) answer ahead of any theme, so the icon follows the site
// across theme switches.
// ProbeThemesRoot is the on-disk themes directory.
func ProbeThemesRoot() string {
return filepath.Join(config.GetDBFolderPath(), "themes")
}
// ValidThemeShort mirrors the hub's valid_short: the name of a theme
// directory, so no separators and nothing empty ([A-Za-z0-9_-]+).
func ValidThemeShort(short string) bool {
if short == "" {
return false
}
for _, r := range short {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') {
return false
}
}
return true
}
// probeExternalTheme resolves the dist directory of an installed
// theme (hub external_theme): the theme counts only when dist and
// its index.html are actually there.
func probeExternalTheme(short string) (string, bool) {
if short == "" {
short = "default"
}
if !ValidThemeShort(short) {
return "", false
}
themes, err := filepath.EvalSymlinks(ProbeThemesRoot())
if err != nil {
return "", false
}
root, err := filepath.EvalSymlinks(filepath.Join(themes, short))
if err != nil || !strings.HasPrefix(root, themes+string(os.PathSeparator)) {
return "", false
}
dist, err := filepath.EvalSymlinks(filepath.Join(root, "dist"))
if err != nil || !strings.HasPrefix(dist, root+string(os.PathSeparator)) {
return "", false
}
info, err := os.Stat(filepath.Join(dist, "index.html"))
if err != nil || info.IsDir() {
return "", false
}
return dist, true
}
// ProbeThemeInstalled reports whether a theme short is selectable
// (hub selectable): an installed external theme.
func ProbeThemeInstalled(short string) bool {
_, ok := probeExternalTheme(short)
return ok
}
// readInsideTheme reads a regular file whose canonical path stays
// below root (hub read_inside), rejecting escapes and symlinks out.
func readInsideTheme(root, relative string) ([]byte, bool) {
canonRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return nil, false
}
file, err := filepath.EvalSymlinks(filepath.Join(canonRoot, relative))
if err != nil || !strings.HasPrefix(file, canonRoot+string(os.PathSeparator)) {
return nil, false
}
info, err := os.Stat(file)
if err != nil || !info.Mode().IsRegular() {
return nil, false
}
data, err := os.ReadFile(file)
if err != nil {
return nil, false
}
return data, true
}
func isThemeAssetPath(p string) bool { return strings.HasPrefix(p, "assets/") }
// probeThemeETag is the strong validator of a served body: half a
// SHA-256 of it, quoted (hub asset()).
func probeThemeETag(data []byte) string {
sum := sha256.Sum256(data)
return "\"" + hex.EncodeToString(sum[:])[:32] + "\""
}
// serveThemeAsset writes one theme file with the caching policy its
// path warrants (hub asset()): hashed files under assets/ are
// immutable for a year; everything else is the SPA shell — no-cache
// plus a strong ETag (half the SHA-256 of the body), so a theme
// switch lands on the next request at the cost of a 304.
func serveThemeAsset(c *gin.Context, path string, data []byte) {
contentType := mime.TypeByExtension(filepath.Ext(path))
if contentType == "" {
contentType = "application/octet-stream"
}
if isThemeAssetPath(path) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Data(http.StatusOK, contentType, data)
return
}
etag := probeThemeETag(data)
c.Header("Content-Type", contentType)
c.Header("Cache-Control", "no-cache")
c.Header("ETag", etag)
if c.GetHeader("If-None-Match") == etag {
c.AbortWithStatus(http.StatusNotModified)
return
}
c.Data(http.StatusOK, contentType, data)
}
// probeThemeChainBytes resolves the bytes a path would be answered
// with, ahead of writing any response: the current theme's own file,
// then the embedded fallback's, then (for the shell paths the stamp
// pass asks about) the index.html either would fall back to.
func probeThemeChainBytes(relative string) ([]byte, bool) {
if dist, ok := probeExternalTheme(ProbeCurrentTheme()); ok {
if data, found := readInsideTheme(dist, relative); found {
return data, true
}
}
if data, found := probeEmbeddedFile(relative); found {
return data, true
}
if !isThemeAssetPath(relative) {
if dist, ok := probeExternalTheme(ProbeCurrentTheme()); ok {
if data, found := readInsideTheme(dist, "index.html"); found {
return data, true
}
}
if data, found := probeEmbeddedFile("index.html"); found {
return data, true
}
}
return nil, false
}
// probeStampIconPaths are the icon URLs the shell stamp names after
// the bytes served for them (hub stamp_icons).
var probeStampIconPaths = []string{
"favicon.svg", "admin/favicon.svg", "apple-touch-icon.png", "admin/apple-touch-icon.png",
}
// stampProbeIcons rewrites each icon URL in the HTML shell to carry
// the ETag of what that URL serves (hub stamp_icons): the URL a theme
// writes is fixed while what it serves is not — a site icon set or
// cleared, a theme switched — and browsers hold a tab's icon by its
// URL without re-asking, so a changed icon must become a new URL.
func stampProbeIcons(html []byte) []byte {
text := string(html)
for _, p := range probeStampIconPaths {
quoted := "\"/" + p + "\""
if !strings.Contains(text, quoted) {
continue
}
var (
data []byte
ok bool
)
if key, isIcon := ProbeIconSettingKey(p); isIcon {
if v := ProbeIconSetting(key); v != "" {
if _, iconBytes, err := ProbeSiteIcon(v); err == nil {
data, ok = iconBytes, true
}
}
}
if !ok {
data, ok = probeThemeChainBytes(p)
}
if !ok {
continue
}
etag := strings.Trim(probeThemeETag(data), "\"")
if len(etag) > 8 {
etag = etag[:8]
}
text = strings.ReplaceAll(text, quoted, "\"/"+p+"?v="+etag+"\"")
}
return []byte(text)
}
// probeTitlePattern matches the single <title> element of an HTML
// shell, case-insensitively and across line breaks.
var probeTitlePattern = regexp.MustCompile(`(?is)<title[^>]*>.*?</title>`)
// injectProbeTitle replaces the shell's static <title> with the
// operator-configured site name, so the first paint already shows the
// configured title instead of whatever the theme package shipped with
// (a CFSM-era package still says CF-Server-Monitor until the theme JS
// rewrites it after fetching /api/me). The value comes from the same
// probe setting /api/me's site_name comes from, so the injected title
// and the JS rewrite can never disagree. When no site name is
// configured the bytes pass through untouched — the theme's own title
// stands and no empty title is ever injected. The title is
// HTML-escaped and spliced in place (never a regex replacement, so a
// "$" in the title cannot be mistaken for an expansion). Only the
// first <title> is replaced; a shell without one passes through.
func injectProbeTitle(htmlBytes []byte) []byte {
title, ok := probeSetting("site_name")
if !ok || strings.TrimSpace(title) == "" {
return htmlBytes
}
loc := probeTitlePattern.FindIndex(htmlBytes)
if loc == nil {
return htmlBytes
}
out := make([]byte, 0, len(htmlBytes)+len(title))
out = append(out, htmlBytes[:loc[0]]...)
out = append(out, []byte("<title>"+html.EscapeString(title)+"</title>")...)
out = append(out, htmlBytes[loc[1]:]...)
return out
}
// serveProbeThemeFile serves one resolved theme file, stamping the
// shell's icon URLs and injecting the configured title on the way out.
func serveProbeThemeFile(c *gin.Context, path string, data []byte) {
if path == "index.html" {
data = stampProbeIcons(data)
data = injectProbeTitle(data)
}
serveThemeAsset(c, path, data)
}
// serveProbeIcon answers a site icon under the shell's caching
// policy (hub icon()): the URL stays while the bytes change, and an
// SVG opened directly is a document at this origin, so the sandbox
// keeps any script in it from running here.
func serveProbeIcon(c *gin.Context, mimeType string, data []byte) {
etag := probeThemeETag(data)
c.Header("Content-Type", mimeType)
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
c.Header("Cache-Control", "no-cache")
c.Header("ETag", etag)
if c.GetHeader("If-None-Match") == etag {
c.AbortWithStatus(http.StatusNotModified)
return
}
c.Data(http.StatusOK, mimeType, data)
}
// ServeProbeThemePath answers one non-API root path (hub respond):
// a set site icon first (unless the request asks for the theme's own
// with ?theme), then the current theme's file, its index.html as the
// SPA fallback for non-asset paths, then the embedded fallback theme
// the same way — a missing hashed asset is a true 404 from the
// fallback, never HTML. Returns false only when nothing at all can
// answer (the embedded theme is part of the binary, so in practice
// the fallback always answers).
func ServeProbeThemePath(c *gin.Context, path string) bool {
if key, isIcon := ProbeIconSettingKey(path); isIcon && c.Request.URL.RawQuery != "theme" {
if v := ProbeIconSetting(key); v != "" {
if mimeType, data, err := ProbeSiteIcon(v); err == nil {
serveProbeIcon(c, mimeType, data)
return true
}
}
}
rel := path
if rel == "" {
rel = "index.html"
}
if dist, ok := probeExternalTheme(ProbeCurrentTheme()); ok {
if data, found := readInsideTheme(dist, rel); found {
serveProbeThemeFile(c, rel, data)
return true
}
if !isThemeAssetPath(rel) {
if data, found := readInsideTheme(dist, "index.html"); found {
serveProbeThemeFile(c, "index.html", data)
return true
}
}
// A missing asset defers to the embedded theme, which issues
// the refusal (hub disk() returning None).
}
if data, found := probeEmbeddedFile(rel); found {
serveProbeThemeFile(c, rel, data)
return true
}
if isThemeAssetPath(rel) {
c.String(http.StatusNotFound, "没有这个文件:/%s", rel)
return true
}
if data, found := probeEmbeddedFile("index.html"); found {
serveProbeThemeFile(c, "index.html", data)
return true
}
return false
}