Files
3x-ui-agent/internal/web/service/probe_theme_store.go
T
Rogue 29de9761c8
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Docs CI / build (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Waiting to run
Docs Deploy (GitHub Pages) / deploy (push) Blocked by required conditions
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
V2.0.1: listen override fix, sing-box core fetch, probe settings page + fast report, spx removal, core mem env
- 0: sing-box gen overrides fragment listen/port from the inbound row
  (empty listen -> "::"), Update/Add recompute the stored fragment;
  non-loopback E2E against the real core binary.
- 1: agent fetches the sing-box core from the Gitea release when
  missing, self-checks it, and only then latches the switch.
- 2: node list shows the sing-box core version (heartbeat field,
  persisted on the node row), xray version as fallback.
- 3: public-page fallback returns 404 to anonymous visitors when the
  page is disabled.
- 4: settings restart hint bar removed, dead i18n keys cleaned.
- 5: two PUT calls missing JSON headers fixed (audit: only those two),
  batch probe-task creation endpoint + UI.
- 6: cf-probe retirement card and coverage pipeline removed.
- 7: generated vless links no longer carry spx (panel export + sub);
  parser still accepts spx; golden fixtures updated.
- probe batch: dedicated probe settings tab (public-page settings,
  tasks, per-node meta), retention_days editable, per-node traffic
  calibration + stat-interface pinning, runtime metrics split into a
  lightweight fast report (default 3s, 1-60s adjustable).
- 8: sing-box core processes start with GOMEMLIMIT=32MiB, GOGC=50.
- 9: dead /getTwoFactorEnable docs entries removed (frontend call and
  contract entry already gone at base; verified by grep).
2026-10-23 07:53:11 +00:00

795 lines
25 KiB
Go

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 —
// <db folder>/themes/<short>/ — 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), "<svg"):
mime = "image/svg+xml"
default:
return "", nil, themeRefuse("%s", siteIconNotImage)
}
return mime, data, nil
}
// ProbeIconSetting reads one site-icon setting ("" when unset).
func ProbeIconSetting(key string) string {
v, _ := probeSetting(key)
return v
}
// SetProbeIconSetting stores one site-icon setting, validating the
// data URL the way the hub does (empty clears). The bytes are what
// the root icon paths will serve from the next request.
func SetProbeIconSetting(key, value string) error {
if key != "favicon" && key != "touch_icon" {
return themeRefuse("没有这个图标设置")
}
if value != "" {
if _, _, err := ProbeSiteIcon(value); err != nil {
return err
}
}
return setProbeSetting(key, value)
}
// ProbePublicSettings is the public-surface settings block the probe
// settings page edits (settings KV keys, hub names). RetentionDays
// and FastIntervalSec joined in V2.0.1: the retention window already
// existed as the retention_days key (read by the probe store's
// housekeeping), it only lacked an editing surface; the fast-report
// interval drives the agents' lightweight metrics cadence.
type ProbePublicSettings struct {
PublicPage bool `json:"publicPage"`
SiteName string `json:"siteName"`
Site string `json:"site"`
GitHubProxy string `json:"githubProxy"`
RetentionDays int64 `json:"retentionDays"`
FastIntervalSec int `json:"fastIntervalSec"`
}
// GetProbePublicSettings reads the settings block.
func GetProbePublicSettings() ProbePublicSettings {
proxy, _ := probeSetting("github_proxy")
return ProbePublicSettings{
PublicPage: ProbePublicPageEnabled(),
SiteName: ProbeSiteName(),
Site: ProbeSiteURL(),
GitHubProxy: proxy,
RetentionDays: probe.RetentionDays(),
FastIntervalSec: ProbeFastIntervalSec(),
}
}
// SetProbePublicSettings writes the settings block. The GitHub proxy
// is validated as the hub validates it: empty, or an https:// prefix.
func SetProbePublicSettings(s ProbePublicSettings) error {
if s.GitHubProxy != "" && !strings.HasPrefix(s.GitHubProxy, "https://") {
return themeRefuse("GitHub 代理要以 https:// 开头,或者留空")
}
if s.RetentionDays < 1 || s.RetentionDays > 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
}