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.
241 lines
8.3 KiB
Go
241 lines
8.3 KiB
Go
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
|
||
}
|