Files
3x-ui-agent/internal/web/service/probe_theme.go
T
Muse 7ad65652ec B5: theme store — hub-faithful install pipeline, GitHub install, embedded fallback theme
Install/remove/list/select with hub limits and refusal texts, chunked
upload receiver, site icons, public-surface settings; vendored
upstream default theme as the embedded fallback (MIT, stqfdyr).
The '.' root dir entry of ./-prefixed packages (guboysky LuminaPlus)
is the package root, not a path escape.
2026-10-22 23:19:26 +00:00

287 lines
9.1 KiB
Go

package service
import (
"crypto/sha256"
"encoding/hex"
"mime"
"net/http"
"os"
"path/filepath"
"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)
}
// serveProbeThemeFile serves one resolved theme file, stamping the
// shell's icon URLs on the way out.
func serveProbeThemeFile(c *gin.Context, path string, data []byte) {
if path == "index.html" {
data = stampProbeIcons(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
}