B5: theme store — hub-faithful install pipeline, GitHub install, embedded fallback theme

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.
This commit is contained in:
Muse
2026-10-22 23:19:26 +00:00
parent 1763f94a5f
commit 7ad65652ec
6 changed files with 1198 additions and 21 deletions
+149 -21
View File
@@ -14,13 +14,17 @@ import (
"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.
// 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 {
@@ -101,6 +105,13 @@ func readInsideTheme(root, relative string) ([]byte, bool) {
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
@@ -116,8 +127,7 @@ func serveThemeAsset(c *gin.Context, path string, data []byte) {
c.Data(http.StatusOK, contentType, data)
return
}
sum := sha256.Sum256(data)
etag := "\"" + hex.EncodeToString(sum[:])[:32] + "\""
etag := probeThemeETag(data)
c.Header("Content-Type", contentType)
c.Header("Cache-Control", "no-cache")
c.Header("ETag", etag)
@@ -128,30 +138,148 @@ func serveThemeAsset(c *gin.Context, path string, data []byte) {
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).
// 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 {
dist, ok := probeExternalTheme(ProbeCurrentTheme())
if !ok {
return false
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 data, found := readInsideTheme(dist, rel); found {
serveThemeAsset(c, rel, data)
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 := readInsideTheme(dist, "index.html"); found {
serveThemeAsset(c, "index.html", data)
if data, found := probeEmbeddedFile("index.html"); found {
serveProbeThemeFile(c, "index.html", data)
return true
}
return false
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 stqfdyr
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

@@ -0,0 +1,17 @@
{
"name": "默认主题",
"short": "default",
"description": "内置状态面板,黑白配色",
"version": "1.4.1",
"author": "stqfdyr",
"url": "https://github.com/monitor-probe/monitor-theme-default",
"config": [
{
"key": "show_summary",
"type": "boolean",
"label": "显示汇总",
"default": true,
"help": "节点列表上方的节点数、最忙节点、流量和网速四格"
}
]
}
+240
View File
@@ -0,0 +1,240 @@
package service
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Item 7 B5: installing and updating themes from GitHub (hub api.rs
// install_theme / update_theme / fetch_theme port). The trust model
// is the upload's: the operator vouches for the repository. Only
// <owner>/<repo> is ever taken from a manifest's url or a pasted
// address — every address fetched is constructed here, from
// api.github.com and github.com alone, so no theme-supplied URL is
// ever followed. The archive download (the part a blocked network
// cannot reach) may go through the operator's github_proxy setting;
// the release lookup never does.
// probeThemeArchiveName is the only asset name ever fetched.
const probeThemeArchiveName = "theme.tar.gz"
// probePathSegment is one URL path segment an address may be built
// from: nothing that opens a new segment or escapes the current one.
func probePathSegment(segment string) bool {
if segment == "" || segment == "." || segment == ".." {
return false
}
for i := 0; i < len(segment); i++ {
c := segment[i]
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' ||
c == '-' || c == '_' || c == '.') {
return false
}
}
return true
}
// probeGitHubRepo reads the <owner>/<repo> a theme's url or a pasted
// address names, where it names a GitHub repository at all (hub
// github_repo). A link to a branch or file is still a link to the
// repository; neither part reaches the addresses built from it.
func probeGitHubRepo(url string) (owner, repo string, ok bool) {
rest, found := strings.CutPrefix(url, "https://github.com/")
if !found {
return "", "", false
}
owner, rest, found = strings.Cut(rest, "/")
if !found {
return "", "", false
}
repo = rest
if i := strings.IndexAny(repo, "/?#"); i >= 0 {
repo = repo[:i]
}
repo = strings.TrimSuffix(repo, ".git")
if !probePathSegment(owner) || !probePathSegment(repo) {
return "", "", false
}
return owner, repo, true
}
type probeRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
} `json:"assets"`
}
// probeLatestRelease reads a repository's latest release from the
// GitHub API (unauthenticated; a User-Agent is required or GitHub
// answers 403). Refusals are worded for the operator (hub
// latest_theme).
func probeLatestRelease(owner, repo string) (*probeRelease, error) {
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequest(http.MethodGet,
fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo), nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "L-UI theme store")
resp, err := client.Do(req)
why := ""
if err != nil {
why = "hub 连不上 api.github.com,检查它的网络"
} else {
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusNotFound:
// A private repository answers the same as a missing one.
why = "仓库不存在,或者还没有正式 release"
case resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests:
why = "GitHub 限制了这台机器的请求次数,过一小时再试"
case resp.StatusCode != http.StatusOK:
why = "GitHub 接口出错,稍后再试"
default:
var release probeRelease
if derr := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&release); derr != nil {
why = "GitHub 的回复无法识别,稍后再试"
} else {
return &release, nil
}
}
}
return nil, themeRefuse("读不到 %s/%s 的最新 release:%s", owner, repo, why)
}
// probeFetchTheme downloads the release's theme.tar.gz and installs
// it through the same validation an upload undergoes. expect names
// the theme it must replace, as for InstallProbeTheme.
func probeFetchTheme(owner, repo string, release *probeRelease, expect string) (ProbeTheme, error) {
tag := release.TagName
if !probePathSegment(tag) {
return ProbeTheme{}, themeRefuse("release 的 tag %q 不能出现在下载地址里", tag)
}
hasAsset := false
for _, asset := range release.Assets {
if asset.Name == probeThemeArchiveName {
hasAsset = true
break
}
}
if !hasAsset {
return ProbeTheme{}, themeRefuse("release %s 里没有 %s", tag, probeThemeArchiveName)
}
direct := fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/%s",
owner, repo, tag, probeThemeArchiveName)
download := probeProxied(direct)
viaProxy := download != direct
unreachable := func() error {
if viaProxy {
return themeRefuse("经 GitHub 代理下载 %s 失败,换一个代理,或清空代理让 hub 直连", probeThemeArchiveName)
}
return themeRefuse("下载 %s 失败:hub 连不上 github.com 时,在设置里填 GitHub 代理", probeThemeArchiveName)
}
client := &http.Client{Timeout: 75 * time.Second}
req, err := http.NewRequest(http.MethodGet, download, nil)
if err != nil {
return ProbeTheme{}, err
}
req.Header.Set("User-Agent", "L-UI theme store")
resp, err := client.Do(req)
if err != nil {
return ProbeTheme{}, unreachable()
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ProbeTheme{}, unreachable()
}
// The transfer stops at Content-Length, so checking it checks
// the body: GitHub always sends one; a proxy that omits it is
// refused rather than read unbounded.
switch size := resp.ContentLength; {
case size < 0:
return ProbeTheme{}, themeRefuse("下载没有给出大小,无法确认它在 %d MiB 以内", MaxThemeArchive/1024/1024)
case size > MaxThemeArchive:
return ProbeTheme{}, themeRefuse("主题包 %d MiB,超过 %d MiB 的上限",
size/1024/1024, MaxThemeArchive/1024/1024)
}
archive, err := io.ReadAll(io.LimitReader(resp.Body, MaxThemeArchive+1))
if err != nil || int64(len(archive)) > MaxThemeArchive {
return ProbeTheme{}, unreachable()
}
// Checked here as well as in the unpacker, whose answer is
// written for an upload: a proxy may answer with a page of its
// own — a block notice, a sign-in wall — under a 200.
if !bytes.HasPrefix(archive, themeGzipMagic) {
if viaProxy {
return ProbeTheme{}, themeRefuse("GitHub 代理返回的不是主题包,换一个代理,或清空代理让 hub 直连")
}
return ProbeTheme{}, themeRefuse("release %s 里的 %s 不是 gzip 格式,包本身有问题,请联系主题作者",
tag, probeThemeArchiveName)
}
return InstallProbeTheme(bytes.NewReader(archive), expect)
}
// InstallProbeThemeFromRepo installs a theme from the latest release
// of the GitHub repository the operator pasted (hub install_theme).
func InstallProbeThemeFromRepo(rawURL string) (ProbeTheme, error) {
url := strings.TrimSpace(rawURL)
if strings.HasPrefix(url, "github.com/") {
url = "https://" + url
}
owner, repo, ok := probeGitHubRepo(url)
if !ok {
return ProbeTheme{}, themeRefuse("填主题的 GitHub 仓库地址,形如 https://github.com/作者/仓库")
}
release, err := probeLatestRelease(owner, repo)
if err != nil {
return ProbeTheme{}, err
}
return probeFetchTheme(owner, repo, release, "")
}
// ProbeThemeUpdate reinstalls one theme from the latest release of
// the repository its manifest names (hub update_theme). The version
// comparison comes first, which is all most invocations do, making
// this also the check-for-updates action: equal tag (leading v
// stripped) means up to date; anything else installs, including a
// deliberate downgrade.
func ProbeThemeUpdate(short string) (updated bool, version string, err error) {
list, err := ProbeThemes()
if err != nil {
return false, "", err
}
var installed *ProbeTheme
for i := range list {
if list[i].Short == short {
installed = &list[i]
break
}
}
if installed == nil {
return false, "", themeRefuse("没有这个主题")
}
owner, repo, ok := probeGitHubRepo(installed.URL)
if !ok {
return false, "", themeRefuse("这个主题的 url 不是 https://github.com/<owner>/<repo>,只能手动上传新包")
}
release, err := probeLatestRelease(owner, repo)
if err != nil {
return false, "", err
}
if strings.TrimPrefix(release.TagName, "v") == installed.Version {
return false, installed.Version, nil
}
// Constrained to the theme it may replace. The built-in theme has
// no directory until this runs: updating it writes one, which
// then serves in place of the embedded copy until deleted.
theme, err := probeFetchTheme(owner, repo, release, short)
if err != nil {
return false, "", err
}
return true, theme.Version, nil
}
+771
View File
@@ -0,0 +1,771 @@
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"
"strings"
"syscall"
"unicode/utf8"
)
// 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 theme
// tab edits (settings KV keys, hub names).
type ProbePublicSettings struct {
PublicPage bool `json:"publicPage"`
SiteName string `json:"siteName"`
Site string `json:"site"`
GitHubProxy string `json:"githubProxy"`
}
// GetProbePublicSettings reads the settings block.
func GetProbePublicSettings() ProbePublicSettings {
proxy, _ := probeSetting("github_proxy")
return ProbePublicSettings{
PublicPage: ProbePublicPageEnabled(),
SiteName: ProbeSiteName(),
Site: ProbeSiteURL(),
GitHubProxy: proxy,
}
}
// 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 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
}
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
}