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:
@@ -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": "节点列表上方的节点数、最忙节点、流量和网速四格"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user