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.
772 lines
24 KiB
Go
772 lines
24 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"
|
|
"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
|
|
}
|