Merge branch 'fix/public-title-inject' into merge/v272-combined
This commit is contained in:
@@ -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 & B <x> $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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user