159 lines
4.8 KiB
Go
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
|
|
}
|