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.
This commit is contained in:
Muse
2026-10-24 15:36:31 +00:00
parent 739c18ce48
commit 54dc346697
2 changed files with 120 additions and 1 deletions
@@ -668,3 +668,87 @@ func TestProbePublicCountryFlag(t *testing.T) {
admin["country"], admin["country_auto"], admin["country_pin"])
}
}
// Title injection on the served theme shell: the first paint must
// already carry the configured site name, not the theme package's
// static title (a CFSM-era package still says CF-Server-Monitor until
// the theme JS rewrites it after fetching /api/me). The injected value
// is the same site_name setting /api/me reports, so it can never
// disagree with the JS rewrite; with nothing configured the shell
// passes through untouched.
func TestPublicThemeTitleInject(t *testing.T) {
rig := newProbeTestRig(t)
themeDir := filepath.Join(rig.dbDir, "themes", "testtheme", "dist")
if err := os.MkdirAll(filepath.Join(themeDir, "assets"), 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"name":"Test Theme","short":"testtheme","description":"contract test fixture","version":"0.0.1","author":"tests","url":""}`
if err := os.WriteFile(filepath.Join(rig.dbDir, "themes", "testtheme", "theme.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
indexHTML := "<!doctype html><html><head><title>CF-Server-Monitor</title></head><body>shell</body></html>"
if err := os.WriteFile(filepath.Join(themeDir, "index.html"), []byte(indexHTML), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(themeDir, "assets", "app-abc123.js"), []byte("console.log(1)"), 0o644); err != nil {
t.Fatal(err)
}
rig.setSetting(t, "theme", "testtheme")
// The root shell is anonymous content only with the public page
// switched on (V2.0.1 gate); without it the test would 404.
rig.setSetting(t, "public_page", "on")
// Unconfigured: the theme's own title stands, byte-identical.
rec := rig.do(http.MethodGet, "/", nil, false)
if rec.Code != 200 || rec.Body.String() != indexHTML {
t.Fatalf("unconfigured root: %d %q", rec.Code, rec.Body.String())
}
// Configured: the served shell carries the configured title, and
// /api/me reports the same value from the same setting.
rig.setSetting(t, "site_name", "Amo Monitor")
rec = rig.do(http.MethodGet, "/", nil, false)
want := "<!doctype html><html><head><title>Amo Monitor</title></head><body>shell</body></html>"
if rec.Code != 200 || rec.Body.String() != want {
t.Fatalf("injected root: %d %q", rec.Code, rec.Body.String())
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-cache") {
t.Fatalf("injected shell cache: %q", cc)
}
etag1 := rec.Header().Get("ETag")
if etag1 == "" {
t.Fatal("injected shell missing etag")
}
meRec := rig.do(http.MethodGet, "/api/me", nil, false)
var me map[string]any
if err := json.Unmarshal(meRec.Body.Bytes(), &me); err != nil || me["site_name"] != "Amo Monitor" {
t.Fatalf("me site_name: %v err=%v", me["site_name"], err)
}
// Non-HTML assets pass through byte-identical, still immutable.
rec = rig.do(http.MethodGet, "/assets/app-abc123.js", nil, false)
if rec.Code != 200 || rec.Body.String() != "console.log(1)" || !strings.Contains(rec.Header().Get("Cache-Control"), "immutable") {
t.Fatalf("asset with title set: %d %q cc=%q", rec.Code, rec.Body.String(), rec.Header().Get("Cache-Control"))
}
// A title change changes the shell ETag, so no-cache
// revalidation cannot serve the old title; the new ETag 304s.
// The title is HTML-escaped and a "$" in it survives verbatim.
rig.setSetting(t, "site_name", "A & B <x> $1")
rec = rig.do(http.MethodGet, "/", nil, false)
want = "<!doctype html><html><head><title>A &amp; B &lt;x&gt; $1</title></head><body>shell</body></html>"
if rec.Code != 200 || rec.Body.String() != want {
t.Fatalf("escaped root: %d %q", rec.Code, rec.Body.String())
}
etag2 := rec.Header().Get("ETag")
if etag2 == "" || etag2 == etag1 {
t.Fatalf("etag must change with title: %q -> %q", etag1, etag2)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("If-None-Match", etag2)
rec304 := httptest.NewRecorder()
rig.engine.ServeHTTP(rec304, req)
if rec304.Code != 304 {
t.Fatalf("etag revalidation: %d", rec304.Code)
}
}
+36 -1
View File
@@ -3,10 +3,12 @@ package service
import (
"crypto/sha256"
"encoding/hex"
"html"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/config"
@@ -208,11 +210,44 @@ func stampProbeIcons(html []byte) []byte {
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 on the way out.
// 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)
}