Merge branch 'fix/public-title-inject' into merge/v272-combined

This commit is contained in:
Muse
2026-10-24 16:35:04 +00:00
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)
}