package service import ( "archive/tar" "bufio" "bytes" "compress/gzip" "crypto/rand" "embed" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path" "path/filepath" "sort" "strconv" "strings" "syscall" "unicode/utf8" "github.com/mhsanaei/3x-ui/v3/internal/web/service/probe" ) // Item 7 B5: the theme store (hub frontend.rs + api.rs theme // management port). Themes stay static directory packages on disk — // /themes// — installed from an uploaded or fetched // theme.tar.gz only after full validation, published by a single // rename so the public page is never served from a partial directory. // The monitor-theme-default release package is embedded in the binary // as the fallback theme (MIT, see probe_theme_default/LICENSE), so // the public page answers even with nothing installed. // // A refusal the OPERATOR can act on (bad package, bad address, quota) // is a *ProbeThemeRefusal carrying the hub's own wording; anything // else is a machine fault and surfaces as a plain error. // ProbeTheme is one theme manifest as the panel lists it (hub Theme). type ProbeTheme struct { Name string `json:"name"` Short string `json:"short"` Description string `json:"description"` Version string `json:"version"` Author string `json:"author"` URL string `json:"url"` Config json.RawMessage `json:"config,omitempty"` Selected bool `json:"selected"` Builtin bool `json:"builtin"` Preview bool `json:"preview"` } // ProbeThemeRefusal is an operator-facing refusal (hub Shown). type ProbeThemeRefusal struct{ Msg string } func (e *ProbeThemeRefusal) Error() string { return e.Msg } func themeRefuse(format string, args ...any) *ProbeThemeRefusal { return &ProbeThemeRefusal{Msg: fmt.Sprintf(format, args...)} } // IsThemeRefusal reports whether err is an operator-facing refusal. func IsThemeRefusal(err error) bool { var r *ProbeThemeRefusal return errors.As(err, &r) } // ---- limits (hub frontend.rs / api.rs, verbatim) ---- const ( maxThemeEntries = 2000 maxThemeFile = 8 << 20 maxThemeExpanded = 64 << 20 maxThemePadding = 1 << 20 // Every byte the gzip stream may expand to, headers included. maxThemeStream = maxThemeExpanded + maxThemePadding + (8 << 20) // Upload/download ceiling for one theme.tar.gz. MaxThemeArchive = 32 * 1024 * 1024 ) // Archive failure wordings (hub frontend.rs). const ( themeDamaged = "主题包损坏或不完整(可能没下载完),重新下载 theme.tar.gz 再试" themeTangled = "主题包里有同名的文件和目录,或者文件名过长,包本身有问题,请联系主题作者" themeNotGzip = "选的不是主题包:到主题仓库的 Releases 下载 theme.tar.gz,不要选 Source code" ) var themeGzipMagic = []byte{0x1f, 0x8b} // ---- the embedded fallback theme ---- //go:embed all:probe_theme_default var probeDefaultThemeFS embed.FS const probeDefaultThemeDir = "probe_theme_default" // probeBuiltinManifest parses the embedded default theme's manifest. // It is vendored from the official release package, so a parse // failure is a build defect, not a runtime condition. func probeBuiltinManifest() ProbeTheme { data, err := probeDefaultThemeFS.ReadFile(probeDefaultThemeDir + "/theme.json") if err != nil { return ProbeTheme{Name: "默认主题", Short: "default", Builtin: true, Preview: true} } theme, perr := parseProbeManifest(data) if perr != nil { return ProbeTheme{Name: "默认主题", Short: "default", Builtin: true, Preview: true} } theme.Builtin = true theme.Preview = true return theme } // probeEmbeddedFile reads one file of the embedded theme's dist. func probeEmbeddedFile(relative string) ([]byte, bool) { clean := path.Clean(relative) if clean == "." || strings.HasPrefix(clean, "../") || path.IsAbs(clean) { return nil, false } data, err := probeDefaultThemeFS.ReadFile(probeDefaultThemeDir + "/dist/" + clean) if err != nil { return nil, false } return data, true } // parseProbeManifest decodes a theme.json (hub manifest parse): the // six string fields must be present; config passes through unparsed. func parseProbeManifest(data []byte) (ProbeTheme, error) { var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { return ProbeTheme{}, err } var theme ProbeTheme for _, key := range []string{"name", "short", "description", "version", "author", "url"} { v, ok := raw[key] if !ok { return ProbeTheme{}, fmt.Errorf("theme.json missing %s", key) } var s string if err := json.Unmarshal(v, &s); err != nil { return ProbeTheme{}, fmt.Errorf("theme.json %s is not a string", key) } switch key { case "name": theme.Name = s case "short": theme.Short = s case "description": theme.Description = s case "version": theme.Version = s case "author": theme.Author = s case "url": theme.URL = s } } if cfg, ok := raw["config"]; ok { theme.Config = cfg } return theme, nil } // probeDiskManifest reads the manifest of an installed theme dir // (hub manifest): valid only when the manifest's short matches the // directory name; oversized or unparsable manifests exclude the theme. func probeDiskManifest(root, short string) (ProbeTheme, bool) { data, ok := readInsideTheme(root, "theme.json") if !ok || len(data) > 64*1024 { return ProbeTheme{}, false } theme, err := parseProbeManifest(data) if err != nil || theme.Short != short { return ProbeTheme{}, false } return theme, true } // ProbeThemes lists the embedded fallback first, then every installed // theme (hub themes()): an installed copy of the built-in theme // replaces it in the list, the rest sort by name, and the selected // flag follows the theme setting with a fallback to "default". func ProbeThemes() ([]ProbeTheme, error) { list := []ProbeTheme{probeBuiltinManifest()} root := ProbeThemesRoot() if base, err := filepath.EvalSymlinks(root); err == nil { entries, err := os.ReadDir(base) if err != nil { return nil, err } for _, entry := range entries { if !entry.IsDir() { continue } short := entry.Name() if !ValidThemeShort(short) { continue } if _, ok := probeExternalTheme(short); !ok { continue } dir, err := filepath.EvalSymlinks(filepath.Join(base, short)) if err != nil || !strings.HasPrefix(dir, base+string(os.PathSeparator)) { continue } theme, ok := probeDiskManifest(dir, short) if !ok { continue } theme.Preview = ProbeThemePreview(short) != nil if theme.Short == "default" { list[0] = theme } else { list = append(list, theme) } } } configured := ProbeCurrentTheme() selected := "default" for _, theme := range list { if theme.Short == configured { selected = configured break } } for i := range list { list[i].Selected = list[i].Short == selected } rest := list[1:] sort.SliceStable(rest, func(i, j int) bool { return rest[i].Name < rest[j].Name }) return list, nil } // ProbeThemeSelectable mirrors hub selectable: the empty string and // "default" always are (switching back is the recovery from a broken // external theme); anything else must be installed. func ProbeThemeSelectable(short string) bool { if short == "" || short == "default" { return true } list, err := ProbeThemes() if err != nil { return false } for _, theme := range list { if theme.Short == short { return true } } return false } // SetProbeCurrentTheme switches the public page to a theme, effective // on the next request (the setting is read per request). func SetProbeCurrentTheme(short string) error { if !ProbeThemeSelectable(short) { return themeRefuse("没有这个主题") } return setProbeSetting("theme", short) } // ProbeThemePreview returns a theme's preview.png where it has one // (hub preview): the installed copy's own file, and for the built-in // theme the embedded one when no disk copy provides it. func ProbeThemePreview(short string) []byte { if !ValidThemeShort(short) { return nil } root := filepath.Join(ProbeThemesRoot(), short) if info, err := os.Stat(filepath.Join(root, "preview.png")); err == nil && info.Size() <= maxThemeFile { if data, ok := readInsideTheme(root, "preview.png"); ok { return data } } if short == "default" { if data, err := probeDefaultThemeFS.ReadFile(probeDefaultThemeDir + "/preview.png"); err == nil { return data } } return nil } // ---- installing (hub frontend.rs install/unpack/publish) ---- func themeRandomSuffix() string { var b [8]byte if _, err := rand.Read(b[:]); err != nil { return "0000000000000000" } return hex.EncodeToString(b[:]) } var errThemeStreamOver = errors.New("theme stream over budget") // streamCounter caps the decompressed stream: a gzip header may // declare hundreds of MiB the tar reader would hold in memory before // any entry is seen, so the budget applies to the stream itself. type streamCounter struct { r io.Reader left int64 over bool } func (s *streamCounter) Read(p []byte) (int, error) { if s.left <= 0 { s.over = true return 0, errThemeStreamOver } if int64(len(p)) > s.left { p = p[:s.left] } n, err := s.r.Read(p) s.left -= int64(n) return n, err } // archiveReadError maps a stream read failure the way hub's // archive_error does: a cut or corrupt stream is the archive's fault, // structural write failures are the package's shape, the rest is the // machine's. func archiveReadError(err error) error { if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.ErrClosedPipe) || errors.Is(err, gzip.ErrChecksum) || errors.Is(err, gzip.ErrHeader) || errors.Is(err, tar.ErrHeader) || errors.Is(err, tar.ErrWriteTooLong) { return themeRefuse("%s", themeDamaged) } return err } func archiveWriteError(err error) error { if errors.Is(err, os.ErrExist) || errors.Is(err, syscall.ENOTDIR) || errors.Is(err, syscall.EISDIR) { return themeRefuse("%s", themeTangled) } return err } // unpackProbeTheme expands the archive into into (which it creates), // enforcing every hub limit. Nothing is published by this step. func unpackProbeTheme(archive io.Reader, into string) error { br := bufio.NewReader(archive) magic, err := br.Peek(2) if err != nil || !bytes.Equal(magic, themeGzipMagic) { return themeRefuse("%s", themeNotGzip) } if err := os.Mkdir(into, 0o755); err != nil { return err } gz, err := gzip.NewReader(br) if err != nil { return themeRefuse("%s", themeDamaged) } stream := &streamCounter{r: gz, left: maxThemeStream} tr := tar.NewReader(stream) entriesErr := unpackProbeEntries(tr, into) // Read to the end of the gzip stream, where its checksum lives: // the entries stop at tar's end marker, short of it, so otherwise // an archive altered in transit installs as long as its headers // survived. What follows the marker is record padding, drained // through the same stream budget and bounded in its own right. tail, tailErr := io.Copy(io.Discard, stream) if stream.over { return themeRefuse("主题包解压后超过 %d MiB", maxThemeExpanded>>20) } if entriesErr != nil { return entriesErr } if tailErr != nil { return archiveReadError(tailErr) } if tail > maxThemePadding { return themeRefuse("主题包在 tar 结尾之后还有超过 1 MiB 的数据,包本身有问题,请联系主题作者") } return nil } func unpackProbeEntries(tr *tar.Reader, into string) error { var expanded int64 for seen := 0; ; seen++ { hdr, err := tr.Next() if err == io.EOF { return nil } if err != nil { if errors.Is(err, errThemeStreamOver) { return err } return archiveReadError(err) } if seen >= maxThemeEntries { return themeRefuse("主题包里的条目超过 %d 个", maxThemeEntries) } // Only the entry types a theme consists of (plus pax global // metadata, which writes nothing). A symlink, hard link or // device node is a route to writing where the path check // cannot see. metadata := hdr.Typeflag == tar.TypeXGlobalHeader switch { case hdr.Typeflag == tar.TypeReg || hdr.Typeflag == tar.TypeRegA || hdr.Typeflag == tar.TypeDir: case metadata: default: return themeRefuse("主题包里有不支持的条目:%s", hdr.Name) } if hdr.Size > maxThemeFile { return themeRefuse("%s 超过单个文件 %d MiB 的上限", hdr.Name, maxThemeFile>>20) } if expanded > maxThemeExpanded-hdr.Size { return themeRefuse("主题包解压后超过 %d MiB", maxThemeExpanded>>20) } expanded += hdr.Size if metadata { continue } // The entry must land inside the staging directory: absolute // paths and ".." escapes are refused outright (symlinks are // already refused above, so no parent can redirect the join). clean := path.Clean(hdr.Name) if clean == "." && hdr.Typeflag == tar.TypeDir { // The package root itself: guboysky's LuminaPlus ships a // leading "." directory entry (all other names carry the // "./" prefix too, which Clean strips). Nothing to create — // the staging directory already is the root. continue } if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || path.IsAbs(hdr.Name) { return themeRefuse("主题包里的路径越出了主题目录") } target := filepath.Join(into, filepath.FromSlash(clean)) if !strings.HasPrefix(target, into+string(os.PathSeparator)) { return themeRefuse("主题包里的路径越出了主题目录") } if hdr.Typeflag == tar.TypeDir { if err := os.MkdirAll(target, 0o755); err != nil { return archiveWriteError(err) } continue } if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return archiveWriteError(err) } if info, err := os.Lstat(target); err == nil && info.IsDir() { return themeRefuse("%s", themeTangled) } f, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return archiveWriteError(err) } _, copyErr := io.Copy(f, tr) closeErr := f.Close() if copyErr != nil { if errors.Is(copyErr, errThemeStreamOver) { return copyErr } return archiveReadError(copyErr) } if closeErr != nil { return archiveWriteError(closeErr) } } } // publishProbeTheme checks the unpacked staging directory is a theme // this panel can serve, then moves it into place under the name its // manifest asks for (hub publish): a theme being replaced is renamed // aside and restored if the publish cannot complete. func publishProbeTheme(themesRoot, staging, expect string) (ProbeTheme, error) { manifest, ok := readInsideTheme(staging, "theme.json") if !ok { return ProbeTheme{}, themeRefuse("主题包里没有 theme.json,下载的若是 Source code,换成 Releases 里的 theme.tar.gz") } if len(manifest) > 64*1024 { return ProbeTheme{}, themeRefuse("theme.json 过大") } theme, err := parseProbeManifest(manifest) if err != nil { return ProbeTheme{}, themeRefuse("theme.json 格式不对") } if !ValidThemeShort(theme.Short) { return ProbeTheme{}, themeRefuse("theme.json 里的 short 不能作为目录名:%q", theme.Short) } if expect != "" && expect != theme.Short { return ProbeTheme{}, themeRefuse("这个包里是主题 %q,不是 %q", theme.Short, expect) } if info, err := os.Stat(filepath.Join(staging, "dist", "index.html")); err != nil || info.IsDir() { return ProbeTheme{}, themeRefuse("主题包里没有 dist/index.html") } destination := filepath.Join(themesRoot, theme.Short) replaced := filepath.Join(themesRoot, ".replaced-"+themeRandomSuffix()) _, statErr := os.Stat(destination) replacing := statErr == nil if replacing { if err := os.Rename(destination, replaced); err != nil { return ProbeTheme{}, err } } if err := os.Rename(staging, destination); err != nil { if replacing { _ = os.Rename(replaced, destination) } return ProbeTheme{}, err } if replacing { _ = os.RemoveAll(replaced) } theme.Preview = ProbeThemePreview(theme.Short) != nil return theme, nil } // InstallProbeTheme installs a theme from its theme.tar.gz under the // name its manifest carries. expect, when non-empty, names the short // the caller is replacing: an update may not install a different one. func InstallProbeTheme(archive io.Reader, expect string) (ProbeTheme, error) { root := ProbeThemesRoot() if err := os.MkdirAll(root, 0o755); err != nil { return ProbeTheme{}, err } staging := filepath.Join(root, ".staging-"+themeRandomSuffix()) theme, err := func() (ProbeTheme, error) { if err := unpackProbeTheme(archive, staging); err != nil { return ProbeTheme{}, err } return publishProbeTheme(root, staging, expect) }() if err != nil { _ = os.RemoveAll(staging) return ProbeTheme{}, err } return theme, nil } // RemoveProbeTheme deletes an installed theme (hub remove), including // an installed copy of the built-in one — the embedded copy then // serves in its place. Deleting the theme in use is permitted: the // public page falls back from the next request. func RemoveProbeTheme(short string) error { if !ValidThemeShort(short) { return themeRefuse("没有这个主题") } base, err := filepath.EvalSymlinks(ProbeThemesRoot()) if err != nil { return themeRefuse("没有这个主题") } root, err := filepath.EvalSymlinks(filepath.Join(base, short)) if err != nil || !strings.HasPrefix(root, base+string(os.PathSeparator)) { return themeRefuse("没有这个主题") } if info, err := os.Stat(root); err != nil || !info.IsDir() { return themeRefuse("没有这个主题") } return os.RemoveAll(root) } // ---- chunked upload (hub api.rs receive + upload_theme) ---- const probeUploadName = ".upload.tar.gz" // ReceiveProbeThemeChunk appends one piece of an upload to the // scratch archive, returning its length afterwards. The state of an // upload is the length of the file on disk: a piece continues only // where the last ended, and offset 0 truncates whatever an // interrupted attempt left behind. func ReceiveProbeThemeChunk(offset, total int64, body io.Reader) (int64, error) { if total <= 0 || total > MaxThemeArchive { return 0, themeRefuse("文件必须在 1 字节到 %d MiB 之间", MaxThemeArchive/1024/1024) } if offset > total { return 0, themeRefuse("分片位置越过了文件末尾") } root := ProbeThemesRoot() if err := os.MkdirAll(root, 0o755); err != nil { return 0, err } uploadPath := filepath.Join(root, probeUploadName) var f *os.File var err error if offset == 0 { f, err = os.OpenFile(uploadPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) } else { f, err = os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0o600) if errors.Is(err, os.ErrNotExist) { return 0, themeRefuse("这次上传已经不在了,请从头开始") } } if err != nil { return 0, err } defer f.Close() info, err := f.Stat() if err != nil { return 0, err } if info.Size() != offset { return 0, themeRefuse("分片接不上:已经收到 %d 字节,这一片却从 %d 开始", info.Size(), offset) } written, copyErr := io.Copy(f, io.LimitReader(body, total-offset+1)) if copyErr != nil || written > total-offset { // Undo a partially written piece so a retry aligns again. _ = f.Truncate(offset) if copyErr != nil { return 0, copyErr } return 0, themeRefuse("这一片超出了声明的文件大小") } return offset + written, nil } // FinishProbeThemeUpload installs the completed scratch archive. The // file is first moved off the shared upload name, so a second upload // landing on it cannot change the bytes being unpacked. func FinishProbeThemeUpload() (ProbeTheme, error) { root := ProbeThemesRoot() uploadPath := filepath.Join(root, probeUploadName) source := filepath.Join(root, ".installing-"+themeRandomSuffix()+".tar.gz") if err := os.Rename(uploadPath, source); err != nil { _ = os.Remove(uploadPath) return ProbeTheme{}, err } defer os.Remove(source) f, err := os.Open(source) if err != nil { return ProbeTheme{}, err } defer f.Close() return InstallProbeTheme(f, "") } // ---- site icons (hub frontend.rs site_icon / ICON_PATHS) ---- // probeIconPaths names each icon the public surface answers and the // setting that replaces it. With a site icon set, all of them answer // with it, so the icon follows the site across theme switches. var probeIconPaths = []struct{ Path, Key string }{ {"favicon.svg", "favicon"}, {"favicon.ico", "favicon"}, {"admin/favicon.svg", "favicon"}, {"apple-touch-icon.png", "touch_icon"}, {"apple-touch-icon-precomposed.png", "touch_icon"}, {"admin/apple-touch-icon.png", "touch_icon"}, } // ProbeIconSettingKey returns the setting behind an icon path. func ProbeIconSettingKey(path string) (string, bool) { for _, icon := range probeIconPaths { if icon.Path == path { return icon.Key, true } } return "", false } const maxSiteIcon = 20 * 1024 const siteIconNotImage = "站点图标只支持 PNG、ICO、SVG、WebP、JPEG、GIF" // ProbeSiteIcon decodes a favicon/touch_icon setting — a // data:image/...;base64, URL — into the bytes and the type they // actually are. The declared type is not trusted: the response's type // comes from the bytes. func ProbeSiteIcon(value string) (mime string, data []byte, err error) { rest, ok := strings.CutPrefix(value, "data:image/") if !ok { return "", nil, themeRefuse("%s", siteIconNotImage) } _, payload, ok := strings.Cut(rest, ";base64,") if !ok { return "", nil, themeRefuse("%s", siteIconNotImage) } data, derr := base64.StdEncoding.DecodeString(payload) if derr != nil { return "", nil, themeRefuse("%s", siteIconNotImage) } if len(data) > maxSiteIcon { return "", nil, themeRefuse("站点图标不能超过 20 KiB") } switch { case len(data) >= 4 && data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G': mime = "image/png" case len(data) >= 4 && data[0] == 0 && data[1] == 0 && data[2] == 1 && data[3] == 0: mime = "image/x-icon" case len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff: mime = "image/jpeg" case len(data) >= 4 && string(data[:4]) == "GIF8": mime = "image/gif" case len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP": mime = "image/webp" case utf8.Valid(data) && strings.Contains(string(data), " 365 { return themeRefuse("历史保留天数要在 1 到 365 之间") } if s.FastIntervalSec < 1 || s.FastIntervalSec > 60 { return themeRefuse("快报间隔要在 1 到 60 秒之间") } if err := SetProbePublicPageEnabled(s.PublicPage); err != nil { return err } if err := setProbeSetting("site_name", s.SiteName); err != nil { return err } if err := setProbeSetting("site", s.Site); err != nil { return err } if err := setProbeSetting("retention_days", strconv.FormatInt(s.RetentionDays, 10)); err != nil { return err } if err := setProbeSetting("probe_fast_interval", strconv.Itoa(s.FastIntervalSec)); err != nil { return err } return setProbeSetting("github_proxy", s.GitHubProxy) } // ProbeGitHubProxy is the configured GitHub proxy prefix ("" = none). func ProbeGitHubProxy() string { v, _ := probeSetting("github_proxy") return strings.TrimSpace(v) } // probeProxied places the GitHub proxy in front of a github.com URL // when one is set (hub proxied()). func probeProxied(url string) string { proxy := ProbeGitHubProxy() if proxy == "" { return url } return strings.TrimSuffix(proxy, "/") + "/" + url }