Files
l-ui/internal/web/service/probe_theme.go
T

159 lines
4.8 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: 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. The
// built-in fallback theme (hub embeds one) lands in B5 with the
// theme store; until then a path no installed theme answers is a 404.
// 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/") }
// 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
}
sum := sha256.Sum256(data)
etag := "\"" + hex.EncodeToString(sum[:])[:32] + "\""
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)
}
// ServeProbeThemePath answers one non-API root path from the current
// theme (hub respond/disk): the file itself when it exists, the
// theme's index.html as the SPA fallback for non-asset paths, and a
// true 404 for missing hashed assets. Returns false when no theme is
// installed to answer at all (caller emits its own 404).
func ServeProbeThemePath(c *gin.Context, path string) bool {
dist, ok := probeExternalTheme(ProbeCurrentTheme())
if !ok {
return false
}
rel := path
if rel == "" {
rel = "index.html"
}
if data, found := readInsideTheme(dist, rel); found {
serveThemeAsset(c, rel, data)
return true
}
if isThemeAssetPath(rel) {
c.String(http.StatusNotFound, "没有这个文件:/%s", rel)
return true
}
if data, found := readInsideTheme(dist, "index.html"); found {
serveThemeAsset(c, "index.html", data)
return true
}
return false
}