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.
287 lines
9.1 KiB
Go
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
|
|
}
|