Files
3x-ui-agent/internal/web/service/probe_theme_github.go
T
Muse 7ad65652ec 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.
2026-10-22 23:19:26 +00:00

241 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}