From e00537f5d32b83687fdb2bdec5d6864b5f49af95 Mon Sep 17 00:00:00 2001 From: wha7ev9r Date: Thu, 30 Jul 2026 13:14:47 +0800 Subject: [PATCH 1/3] feat: add persistent GitHub release download sources Store custom GitHub Releases sources in SQLite so they survive restarts, then stream the pinned asset of the latest stable release through DevBox with one click from the new Releases page. - store: release_sources table (case-insensitive owner/repo, unique per owner/repo/asset) with create/list/get/delete helpers - dashboard: /api/release-sources CRUD, one-time download tickets and a streaming /api/release-download endpoint - dashboard: validate github.com/{owner}/{repo}/releases URLs and asset names, cache latest-release lookups for 5m to save GitHub API quota - dashboard: resolve assets by verified asset ID and restrict download redirects to github.com and *.githubusercontent.com - web: Releases page with add form, source list and download/delete - tests: store CRUD/persistence plus handler, ticket and redirect tests --- README.md | 14 +- internal/dashboard/dashboard.go | 30 +- internal/dashboard/release.go | 474 +++++++++++++++++++++++++++++ internal/dashboard/release_test.go | 201 ++++++++++++ internal/server/server.go | 11 +- internal/store/sqlite.go | 73 ++++- internal/store/sqlite_test.go | 52 ++++ web/src/App.vue | 1 + web/src/api/client.ts | 50 +++ web/src/router/index.ts | 2 + web/src/views/Releases.vue | 237 +++++++++++++++ web/src/views/Settings.vue | 1 + 12 files changed, 1130 insertions(+), 16 deletions(-) create mode 100644 internal/dashboard/release.go create mode 100644 internal/dashboard/release_test.go create mode 100644 internal/store/sqlite_test.go create mode 100644 web/src/views/Releases.vue diff --git a/README.md b/README.md index f27e2f7..29e333b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ | HuggingFace 加速 | 代理 `https://huggingface.co` 模型文件下载 | | Git Clone 加速 | 代理 GitHub / GitLab 的 clone、archive、raw 请求 | | GitHub API 加速 | 代理 `https://api.github.com`(解决国内 GitHub API 超时) | +| GitHub Release 下载 | 在面板持久化 Release 源,通过 DevBox 中转下载最新稳定版本中的指定资产 | | Docker v2 Auth | Token 认证代理,让 `docker pull` 不依赖直接访问上游 | | 镜像搜索 | Dashboard 搜索 npm、Docker Hub、PyPI、Conda、RubyGems、Cargo、NuGet 包 | | IP 限流 | 滚动时间窗口限流防滥用,白名单免限速,黑名单直接拒绝 | @@ -375,8 +376,19 @@ Dashboard 采用极客轻量控制台风格,面向开发者高效扫读: - Mirrors:镜像启停、上游地址修改、缓存 TTL 查看 - Git Proxy:GitHub / GitLab clone、archive、raw 命令生成与复制 - Search:npm、Docker Hub、PyPI 搜索与安装命令复制 +- Releases:保存 GitHub Releases 地址与固定资产名,一键通过 DevBox 下载最新稳定版本中的资产 - Settings:版本/运行信息、IP 限流白名单/黑名单配置 +### GitHub Release 下载 + +在 `Releases` 页面添加下载源,需要填写: + +- 名称,例如 `March7thAssistant` +- GitHub Releases 地址,例如 `https://github.com/moesnow/March7thAssistant/releases` +- 最新稳定版本中固定的资产文件名,例如 `update.7z` + +保存时 DevBox 会校验仓库与资产是否存在,之后点击 `download` 会重新确认最新稳定版本并把资产流式中转给浏览器。源保存在 `/data` 的 SQLite 数据库中,重启后仍然保留;当前仅支持公开仓库。 + ## 本地开发 ```bash @@ -392,7 +404,7 @@ docker build -t devbox:latest . ## 数据持久化 -容器 `/data` 目录存储 SQLite 数据库和缓存文件,建议映射到 Docker volume: +容器 `/data` 目录存储 SQLite 数据库(含 Release 下载源)和缓存文件,建议映射到 Docker volume: ```bash docker run -d -p 8080:8080 -v devbox-data:/data devbox:latest diff --git a/internal/dashboard/dashboard.go b/internal/dashboard/dashboard.go index d77f7a2..8702ad4 100644 --- a/internal/dashboard/dashboard.go +++ b/internal/dashboard/dashboard.go @@ -16,12 +16,18 @@ import ( ) type Dashboard struct { - store *store.Store - authToken string - publicURL string - rlConfig RateLimitConfigAccessor - saveConfig func() error - alertEngine *alert.Engine + store *store.Store + authToken string + publicURL string + rlConfig RateLimitConfigAccessor + saveConfig func() error + alertEngine *alert.Engine + releaseHTTP *http.Client + downloadHTTP *http.Client + releaseCacheMu sync.Mutex + releaseCache map[string]cachedRelease + ticketMu sync.Mutex + downloadTickets map[string]downloadTicket healthMu sync.RWMutex healthCache map[string]cachedHealth @@ -60,10 +66,14 @@ type cachedHealth struct { func New(st *store.Store, authToken string, publicURL string) *Dashboard { d := &Dashboard{ - store: st, - authToken: authToken, - publicURL: publicURL, - healthCache: make(map[string]cachedHealth), + store: st, + authToken: authToken, + publicURL: publicURL, + healthCache: make(map[string]cachedHealth), + releaseHTTP: &http.Client{Timeout: 15 * time.Second}, + downloadHTTP: newReleaseDownloadClient(), + releaseCache: make(map[string]cachedRelease), + downloadTickets: make(map[string]downloadTicket), } go d.backgroundHealthCheck() return d diff --git a/internal/dashboard/release.go b/internal/dashboard/release.go new file mode 100644 index 0000000..3f3e9ff --- /dev/null +++ b/internal/dashboard/release.go @@ -0,0 +1,474 @@ +package dashboard + +import ( + "crypto/rand" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "mime" + "net/http" + "net/url" + "path" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "devbox/internal/store" +) + +const ( + githubAPIBase = "https://api.github.com" + githubAPIVersion = "2026-03-10" + releaseCacheTTL = 5 * time.Minute + downloadTicketTTL = 2 * time.Minute + maxDownloadTickets = 4096 +) + +var githubNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + +type githubRelease struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + PublishedAt string `json:"published_at"` + Assets []githubAsset `json:"assets"` +} + +type githubAsset struct { + ID int64 `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + ContentType string `json:"content_type"` + Digest string `json:"digest"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +type cachedRelease struct { + release githubRelease + expiresAt time.Time +} + +type downloadTicket struct { + sourceID int64 + owner string + repo string + asset githubAsset + tagName string + expiresAt time.Time +} + +type releaseSourceView struct { + store.ReleaseSource + RepositoryURL string `json:"repositoryUrl"` + TagName string `json:"tagName,omitempty"` + ReleaseURL string `json:"releaseUrl,omitempty"` + PublishedAt string `json:"publishedAt,omitempty"` + AssetSize int64 `json:"assetSize,omitempty"` + Digest string `json:"digest,omitempty"` + Available bool `json:"available"` + Error string `json:"error,omitempty"` +} + +func (d *Dashboard) ReleaseSourcesHandler(w http.ResponseWriter, r *http.Request) { + if !d.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + switch r.Method { + case http.MethodGet: + d.listReleaseSources(w, r, r.URL.Query().Get("refresh") == "1") + case http.MethodPost: + d.createReleaseSource(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (d *Dashboard) ReleaseSourceHandler(w http.ResponseWriter, r *http.Request) { + if !d.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/release-sources/"), "/") + parts := strings.Split(rest, "/") + id, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil || id <= 0 { + http.Error(w, "invalid release source id", http.StatusBadRequest) + return + } + + if len(parts) == 1 && r.Method == http.MethodDelete { + deleted, err := d.store.DeleteReleaseSource(id) + if err != nil { + http.Error(w, "delete release source failed", http.StatusInternalServerError) + return + } + if !deleted { + http.Error(w, "release source not found", http.StatusNotFound) + return + } + writeJSON(w, map[string]string{"status": "ok"}) + return + } + + if len(parts) == 2 && parts[1] == "download-ticket" && r.Method == http.MethodPost { + d.createDownloadTicket(w, r, id) + return + } + http.Error(w, "not found", http.StatusNotFound) +} + +func (d *Dashboard) listReleaseSources(w http.ResponseWriter, r *http.Request, refresh bool) { + sources, err := d.store.ListReleaseSources() + if err != nil { + http.Error(w, "list release sources failed", http.StatusInternalServerError) + return + } + + views := make([]releaseSourceView, len(sources)) + type sourceGroup struct { + source store.ReleaseSource + indexes []int + } + groups := make(map[string]*sourceGroup) + for i, source := range sources { + key := strings.ToLower(source.Owner + "/" + source.Repo) + if group, ok := groups[key]; ok { + group.indexes = append(group.indexes, i) + } else { + groups[key] = &sourceGroup{source: source, indexes: []int{i}} + } + } + + semaphore := make(chan struct{}, 4) + var wg sync.WaitGroup + for _, group := range groups { + wg.Add(1) + go func(group *sourceGroup) { + defer wg.Done() + semaphore <- struct{}{} + defer func() { <-semaphore }() + release, err := d.latestRelease(r, group.source.Owner, group.source.Repo, refresh) + for _, index := range group.indexes { + errorMessage := "" + if err != nil { + errorMessage = err.Error() + } + views[index] = d.releaseSourceViewFromRelease(sources[index], release, errorMessage) + } + }(group) + } + wg.Wait() + writeJSON(w, views) +} + +func (d *Dashboard) createReleaseSource(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + ReleaseURL string `json:"releaseUrl"` + AssetName string `json:"assetName"` + } + if !readJSON(r, &req) { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + name := strings.TrimSpace(req.Name) + assetName := strings.TrimSpace(req.AssetName) + owner, repo, err := parseGitHubReleasesURL(req.ReleaseURL) + if err != nil || name == "" || len(name) > 80 || !validAssetName(assetName) { + http.Error(w, "invalid name, GitHub Releases URL, or asset name", http.StatusBadRequest) + return + } + + release, err := d.latestRelease(r, owner, repo, true) + if err != nil { + http.Error(w, "get latest release: "+err.Error(), http.StatusBadGateway) + return + } + if _, ok := findAsset(release, assetName); !ok { + http.Error(w, "asset not found in latest release", http.StatusUnprocessableEntity) + return + } + + source, err := d.store.CreateReleaseSource(name, owner, repo, assetName) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + http.Error(w, "release source already exists", http.StatusConflict) + return + } + http.Error(w, "save release source failed", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + writeJSON(w, d.releaseSourceViewFromRelease(source, release, "")) +} + +func (d *Dashboard) releaseSourceViewFromRelease(source store.ReleaseSource, release githubRelease, releaseErr string) releaseSourceView { + view := releaseSourceView{ + ReleaseSource: source, + RepositoryURL: fmt.Sprintf("https://github.com/%s/%s", source.Owner, source.Repo), + TagName: release.TagName, + ReleaseURL: release.HTMLURL, + PublishedAt: release.PublishedAt, + Error: releaseErr, + } + if releaseErr != "" { + return view + } + asset, ok := findAsset(release, source.AssetName) + if !ok { + view.Error = "asset not found in latest release" + return view + } + view.Available = true + view.AssetSize = asset.Size + view.Digest = asset.Digest + return view +} + +func (d *Dashboard) createDownloadTicket(w http.ResponseWriter, r *http.Request, sourceID int64) { + source, err := d.store.GetReleaseSource(sourceID) + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "release source not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, "get release source failed", http.StatusInternalServerError) + return + } + + release, err := d.latestRelease(r, source.Owner, source.Repo, true) + if err != nil { + http.Error(w, "get latest release: "+err.Error(), http.StatusBadGateway) + return + } + asset, ok := findAsset(release, source.AssetName) + if !ok { + http.Error(w, "asset not found in latest release", http.StatusUnprocessableEntity) + return + } + ticket, err := randomTicket() + if err != nil { + http.Error(w, "create download ticket failed", http.StatusInternalServerError) + return + } + + now := time.Now() + d.ticketMu.Lock() + for key, entry := range d.downloadTickets { + if now.After(entry.expiresAt) { + delete(d.downloadTickets, key) + } + } + if len(d.downloadTickets) >= maxDownloadTickets { + d.ticketMu.Unlock() + http.Error(w, "too many pending download tickets", http.StatusTooManyRequests) + return + } + d.downloadTickets[ticket] = downloadTicket{ + sourceID: source.ID, + owner: source.Owner, + repo: source.Repo, + asset: asset, + tagName: release.TagName, + expiresAt: now.Add(downloadTicketTTL), + } + d.ticketMu.Unlock() + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, map[string]string{ + "ticket": ticket, + "fileName": asset.Name, + "tagName": release.TagName, + }) +} + +func (d *Dashboard) ReleaseDownloadHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + ticketValue := r.URL.Query().Get("ticket") + d.ticketMu.Lock() + ticket, ok := d.downloadTickets[ticketValue] + if ok { + delete(d.downloadTickets, ticketValue) + } + d.ticketMu.Unlock() + if !ok || time.Now().After(ticket.expiresAt) { + http.Error(w, "invalid or expired download ticket", http.StatusUnauthorized) + return + } + + target := fmt.Sprintf("%s/repos/%s/%s/releases/assets/%d", githubAPIBase, + url.PathEscape(ticket.owner), url.PathEscape(ticket.repo), ticket.asset.ID) + upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil) + if err != nil { + http.Error(w, "create upstream request failed", http.StatusInternalServerError) + return + } + upstreamReq.Header.Set("Accept", "application/octet-stream") + setGitHubHeaders(upstreamReq) + resp, err := d.downloadHTTP.Do(upstreamReq) + if err != nil { + slog.Error("release download upstream failed", "source_id", ticket.sourceID, "error", err) + http.Error(w, "release download upstream failed", http.StatusBadGateway) + return + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + slog.Warn("release download upstream status", "source_id", ticket.sourceID, "status", resp.StatusCode) + http.Error(w, "release download upstream returned "+resp.Status, http.StatusBadGateway) + return + } + + contentType := resp.Header.Get("Content-Type") + if strings.Contains(strings.ToLower(contentType), "application/json") { + http.Error(w, "GitHub returned asset metadata instead of file content", http.StatusBadGateway) + return + } + if contentType == "" { + contentType = ticket.asset.ContentType + } + if contentType == "" { + contentType = "application/octet-stream" + } + w.Header().Set("Content-Type", contentType) + if contentLength := resp.Header.Get("Content-Length"); contentLength != "" { + w.Header().Set("Content-Length", contentLength) + } else if ticket.asset.Size > 0 { + w.Header().Set("Content-Length", strconv.FormatInt(ticket.asset.Size, 10)) + } + if etag := resp.Header.Get("ETag"); etag != "" { + w.Header().Set("ETag", etag) + } + w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": ticket.asset.Name})) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusOK) + if _, err := io.Copy(w, resp.Body); err != nil && !errors.Is(err, r.Context().Err()) { + slog.Warn("release download interrupted", "source_id", ticket.sourceID, "tag", ticket.tagName, "error", err) + } +} + +func (d *Dashboard) latestRelease(r *http.Request, owner, repo string, refresh bool) (githubRelease, error) { + key := strings.ToLower(owner + "/" + repo) + if !refresh { + d.releaseCacheMu.Lock() + entry, ok := d.releaseCache[key] + d.releaseCacheMu.Unlock() + if ok && time.Now().Before(entry.expiresAt) { + return entry.release, nil + } + } + + target := fmt.Sprintf("%s/repos/%s/%s/releases/latest", githubAPIBase, url.PathEscape(owner), url.PathEscape(repo)) + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil) + if err != nil { + return githubRelease{}, err + } + req.Header.Set("Accept", "application/vnd.github+json") + setGitHubHeaders(req) + resp, err := d.releaseHTTP.Do(req) + if err != nil { + return githubRelease{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return githubRelease{}, fmt.Errorf("GitHub API returned %s", resp.Status) + } + var release githubRelease + if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&release); err != nil { + return githubRelease{}, fmt.Errorf("decode GitHub release: %w", err) + } + d.releaseCacheMu.Lock() + d.releaseCache[key] = cachedRelease{release: release, expiresAt: time.Now().Add(releaseCacheTTL)} + d.releaseCacheMu.Unlock() + return release, nil +} + +func parseGitHubReleasesURL(rawURL string) (string, string, error) { + u, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || u.Scheme != "https" || !strings.EqualFold(u.Hostname(), "github.com") || u.Port() != "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return "", "", errors.New("invalid GitHub Releases URL") + } + parts := strings.Split(strings.Trim(u.EscapedPath(), "/"), "/") + if len(parts) != 3 || !strings.EqualFold(parts[2], "releases") { + return "", "", errors.New("URL must end with /owner/repo/releases") + } + owner, err := url.PathUnescape(parts[0]) + if err != nil { + return "", "", err + } + repo, err := url.PathUnescape(parts[1]) + if err != nil { + return "", "", err + } + if !githubNamePattern.MatchString(owner) || !githubNamePattern.MatchString(repo) || len(owner) > 100 || len(repo) > 100 { + return "", "", errors.New("invalid GitHub owner or repository") + } + return owner, repo, nil +} + +func validAssetName(name string) bool { + if name == "" || len(name) > 255 || name != path.Base(name) || strings.ContainsAny(name, `/\\`) || name == "." || name == ".." { + return false + } + for _, char := range name { + if char < 0x20 || char == 0x7f { + return false + } + } + return true +} + +func findAsset(release githubRelease, name string) (githubAsset, bool) { + for _, asset := range release.Assets { + if asset.Name == name && asset.ID > 0 { + return asset, true + } + } + return githubAsset{}, false +} + +func randomTicket() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func setGitHubHeaders(req *http.Request) { + req.Header.Set("User-Agent", "DevBox-Release-Downloader") + req.Header.Set("X-GitHub-Api-Version", githubAPIVersion) +} + +func newReleaseDownloadClient() *http.Client { + return &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("too many redirects") + } + if req.URL.Scheme != "https" || req.URL.User != nil || !allowedGitHubDownloadHost(req.URL.Hostname()) { + return errors.New("unsafe release download redirect") + } + req.Header.Del("Authorization") + return nil + }, + } +} + +func allowedGitHubDownloadHost(host string) bool { + host = strings.ToLower(host) + return host == "api.github.com" || host == "github.com" || strings.HasSuffix(host, ".githubusercontent.com") +} diff --git a/internal/dashboard/release_test.go b/internal/dashboard/release_test.go new file mode 100644 index 0000000..a858ac6 --- /dev/null +++ b/internal/dashboard/release_test.go @@ -0,0 +1,201 @@ +package dashboard + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + + "devbox/internal/store" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +func TestParseGitHubReleasesURL(t *testing.T) { + tests := []struct { + url string + valid bool + }{ + {"https://github.com/moesnow/March7thAssistant/releases", true}, + {"https://github.com/moesnow/March7thAssistant/releases/", true}, + {"http://github.com/moesnow/March7thAssistant/releases", false}, + {"https://example.com/moesnow/March7thAssistant/releases", false}, + {"https://github.com/moesnow/March7thAssistant", false}, + {"https://user@github.com/moesnow/March7thAssistant/releases", false}, + {"https://github.com/moesnow/March7thAssistant/releases?x=1", false}, + {"https://github.com/moesnow%2Fother/repo/releases", false}, + } + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + owner, repo, err := parseGitHubReleasesURL(tt.url) + if tt.valid && (err != nil || owner != "moesnow" || repo != "March7thAssistant") { + t.Fatalf("expected valid URL, owner=%q repo=%q err=%v", owner, repo, err) + } + if !tt.valid && err == nil { + t.Fatal("expected invalid URL") + } + }) + } +} + +func TestReleaseSourceCreateTicketAndDownload(t *testing.T) { + dashboard := newReleaseTestDashboard(t) + releaseJSON := `{ + "tag_name":"v1.2.3", + "html_url":"https://github.com/moesnow/March7thAssistant/releases/tag/v1.2.3", + "published_at":"2026-07-26T05:18:51Z", + "assets":[{"id":42,"name":"update.7z","size":7,"content_type":"application/x-7z-compressed","digest":"sha256:test"}] + }` + dashboard.releaseHTTP = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.String() != githubAPIBase+"/repos/moesnow/March7thAssistant/releases/latest" { + t.Fatalf("unexpected release URL: %s", req.URL) + } + if req.Header.Get("X-GitHub-Api-Version") != githubAPIVersion { + t.Fatal("missing GitHub API version header") + } + return testResponse(http.StatusOK, "application/json", releaseJSON), nil + })} + + body := `{"name":"M7A","releaseUrl":"https://github.com/moesnow/March7thAssistant/releases","assetName":"update.7z"}` + createReq := authorizedRequest(http.MethodPost, "/api/release-sources", body) + createRec := httptest.NewRecorder() + dashboard.ReleaseSourcesHandler(createRec, createReq) + if createRec.Code != http.StatusCreated { + t.Fatalf("create status=%d body=%s", createRec.Code, createRec.Body.String()) + } + if got := createRec.Header().Get("Content-Type"); !strings.Contains(got, "application/json") { + t.Fatalf("unexpected content type %q", got) + } + var source releaseSourceView + if err := json.NewDecoder(createRec.Body).Decode(&source); err != nil { + t.Fatal(err) + } + if !source.Available || source.TagName != "v1.2.3" || source.AssetSize != 7 { + t.Fatalf("unexpected source: %+v", source) + } + + ticketReq := authorizedRequest(http.MethodPost, "/api/release-sources/1/download-ticket", "") + ticketRec := httptest.NewRecorder() + dashboard.ReleaseSourceHandler(ticketRec, ticketReq) + if ticketRec.Code != http.StatusOK { + t.Fatalf("ticket status=%d body=%s", ticketRec.Code, ticketRec.Body.String()) + } + var ticketResult map[string]string + if err := json.NewDecoder(ticketRec.Body).Decode(&ticketResult); err != nil { + t.Fatal(err) + } + if ticketResult["ticket"] == "" { + t.Fatal("empty download ticket") + } + + dashboard.downloadHTTP = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.String() != githubAPIBase+"/repos/moesnow/March7thAssistant/releases/assets/42" { + t.Fatalf("unexpected asset URL: %s", req.URL) + } + if req.Header.Get("Accept") != "application/octet-stream" { + t.Fatal("missing asset accept header") + } + resp := testResponse(http.StatusOK, "application/x-7z-compressed", "payload") + resp.Header.Set("Content-Length", "7") + return resp, nil + })} + downloadURL := "/api/release-download?ticket=" + ticketResult["ticket"] + downloadRec := httptest.NewRecorder() + dashboard.ReleaseDownloadHandler(downloadRec, httptest.NewRequest(http.MethodGet, downloadURL, nil)) + if downloadRec.Code != http.StatusOK || downloadRec.Body.String() != "payload" { + t.Fatalf("download status=%d body=%q", downloadRec.Code, downloadRec.Body.String()) + } + if disposition := downloadRec.Header().Get("Content-Disposition"); !strings.Contains(disposition, "update.7z") { + t.Fatalf("unexpected disposition %q", disposition) + } + + replayRec := httptest.NewRecorder() + dashboard.ReleaseDownloadHandler(replayRec, httptest.NewRequest(http.MethodGet, downloadURL, nil)) + if replayRec.Code != http.StatusUnauthorized { + t.Fatalf("replayed ticket status=%d", replayRec.Code) + } +} + +func TestReleaseHandlersRequireAuth(t *testing.T) { + dashboard := newReleaseTestDashboard(t) + rec := httptest.NewRecorder() + dashboard.ReleaseSourcesHandler(rec, httptest.NewRequest(http.MethodGet, "/api/release-sources", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status=%d", rec.Code) + } +} + +func TestReleaseDownloadRedirectPolicy(t *testing.T) { + client := newReleaseDownloadClient() + check := client.CheckRedirect + via := []*http.Request{{URL: mustURL(t, "https://api.github.com/start")}} + for _, target := range []string{ + "https://github.com/file", + "https://objects.githubusercontent.com/file", + "https://release-assets.githubusercontent.com/file", + } { + if err := check(&http.Request{URL: mustURL(t, target), Header: make(http.Header)}, via); err != nil { + t.Fatalf("expected allowed redirect %s: %v", target, err) + } + } + for _, target := range []string{ + "http://objects.githubusercontent.com/file", + "https://githubusercontent.com.example.com/file", + "https://example.com/file", + } { + if err := check(&http.Request{URL: mustURL(t, target), Header: make(http.Header)}, via); err == nil { + t.Fatalf("expected blocked redirect %s", target) + } + } +} + +func newReleaseTestDashboard(t *testing.T) *Dashboard { + t.Helper() + st, err := store.New(filepath.Join(t.TempDir(), "devbox.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + return &Dashboard{ + store: st, + authToken: "secret", + releaseCache: make(map[string]cachedRelease), + downloadTickets: make(map[string]downloadTicket), + } +} + +func authorizedRequest(method, target, body string) *http.Request { + req := httptest.NewRequest(method, target, bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer secret") + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + return req +} + +func testResponse(status int, contentType, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Header: http.Header{"Content-Type": []string{contentType}}, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func mustURL(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + return u +} diff --git a/internal/server/server.go b/internal/server/server.go index 4e78a79..480dfdd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -166,6 +166,9 @@ func (s *Server) Start() error { mux.HandleFunc("/api/auth/login", s.dash.LoginHandler) mux.HandleFunc("/api/auth/check", s.dash.AuthCheckHandler) mux.HandleFunc("/api/search", s.search.Search) + mux.HandleFunc("/api/release-sources", s.dash.ReleaseSourcesHandler) + mux.HandleFunc("/api/release-sources/", s.dash.ReleaseSourceHandler) + mux.HandleFunc("/api/release-download", s.dash.ReleaseDownloadHandler) // Docker v2 registry API — proxy handles auth transparently mux.HandleFunc("/v2/", s.wrapWithDynamicStats(registryStatsName, s.registryV2Handler)) @@ -413,10 +416,10 @@ func (s *Server) proxyRegistryRequest(w http.ResponseWriter, r *http.Request, ta w.Header().Set("Www-Authenticate", wwAuth) } w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") - w.WriteHeader(401) - _, _ = io.Copy(w, resp.Body) - return -} + w.WriteHeader(401) + _, _ = io.Copy(w, resp.Body) + return + } // Copy response headers w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 00dbf2a..a9f7cc7 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "os" + "path/filepath" "time" _ "modernc.org/sqlite" @@ -14,7 +15,7 @@ type Store struct { } func New(path string) (*Store, error) { - if err := os.MkdirAll(path[:len(path)-len("/devbox.db")], 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return nil, fmt.Errorf("create data dir: %w", err) } // Enable WAL mode and busy timeout for better concurrency @@ -48,6 +49,15 @@ func (s *Store) initSchema() error { error_msg TEXT DEFAULT '', checked_at DATETIME DEFAULT CURRENT_TIMESTAMP ); + CREATE TABLE IF NOT EXISTS release_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + owner TEXT NOT NULL COLLATE NOCASE, + repo TEXT NOT NULL COLLATE NOCASE, + asset_name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(owner, repo, asset_name) + ); CREATE INDEX IF NOT EXISTS idx_traffic_mirror ON traffic(mirror); CREATE INDEX IF NOT EXISTS idx_traffic_created ON traffic(created_at); ` @@ -55,6 +65,67 @@ func (s *Store) initSchema() error { return err } +type ReleaseSource struct { + ID int64 `json:"id"` + Name string `json:"name"` + Owner string `json:"owner"` + Repo string `json:"repo"` + AssetName string `json:"assetName"` + CreatedAt string `json:"createdAt"` +} + +func (s *Store) CreateReleaseSource(name, owner, repo, assetName string) (ReleaseSource, error) { + result, err := s.db.Exec( + "INSERT INTO release_sources (name, owner, repo, asset_name, created_at) VALUES (?, ?, ?, ?, ?)", + name, owner, repo, assetName, time.Now().UTC().Format(time.RFC3339), + ) + if err != nil { + return ReleaseSource{}, err + } + id, err := result.LastInsertId() + if err != nil { + return ReleaseSource{}, err + } + return s.GetReleaseSource(id) +} + +func (s *Store) ListReleaseSources() ([]ReleaseSource, error) { + rows, err := s.db.Query( + "SELECT id, name, owner, repo, asset_name, created_at FROM release_sources ORDER BY id", + ) + if err != nil { + return nil, err + } + defer rows.Close() + + sources := make([]ReleaseSource, 0) + for rows.Next() { + var source ReleaseSource + if err := rows.Scan(&source.ID, &source.Name, &source.Owner, &source.Repo, &source.AssetName, &source.CreatedAt); err != nil { + return nil, err + } + sources = append(sources, source) + } + return sources, rows.Err() +} + +func (s *Store) GetReleaseSource(id int64) (ReleaseSource, error) { + var source ReleaseSource + err := s.db.QueryRow( + "SELECT id, name, owner, repo, asset_name, created_at FROM release_sources WHERE id = ?", id, + ).Scan(&source.ID, &source.Name, &source.Owner, &source.Repo, &source.AssetName, &source.CreatedAt) + return source, err +} + +func (s *Store) DeleteReleaseSource(id int64) (bool, error) { + result, err := s.db.Exec("DELETE FROM release_sources WHERE id = ?", id) + if err != nil { + return false, err + } + count, err := result.RowsAffected() + return count > 0, err +} + func (s *Store) RecordTraffic(mirror, method, path string, bytesIn, bytesOut, status int) error { _, err := s.db.Exec( "INSERT INTO traffic (mirror, method, path, bytes_in, bytes_out, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go new file mode 100644 index 0000000..0d9e5de --- /dev/null +++ b/internal/store/sqlite_test.go @@ -0,0 +1,52 @@ +package store + +import ( + "path/filepath" + "testing" +) + +func TestReleaseSourceCRUDAndPersistence(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "devbox.db") + st, err := New(dbPath) + if err != nil { + t.Fatal(err) + } + + created, err := st.CreateReleaseSource("M7A", "moesnow", "March7thAssistant", "update.7z") + if err != nil { + t.Fatal(err) + } + if created.ID == 0 || created.Name != "M7A" || created.AssetName != "update.7z" { + t.Fatalf("unexpected source: %+v", created) + } + if _, err := st.CreateReleaseSource("duplicate", "moesnow", "March7thAssistant", "update.7z"); err == nil { + t.Fatal("expected duplicate source error") + } + if _, err := st.CreateReleaseSource("case duplicate", "MOESNOW", "march7thassistant", "update.7z"); err == nil { + t.Fatal("expected case-insensitive duplicate source error") + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + + st, err = New(dbPath) + if err != nil { + t.Fatal(err) + } + defer st.Close() + sources, err := st.ListReleaseSources() + if err != nil { + t.Fatal(err) + } + if len(sources) != 1 || sources[0].ID != created.ID { + t.Fatalf("source was not persisted: %+v", sources) + } + deleted, err := st.DeleteReleaseSource(created.ID) + if err != nil || !deleted { + t.Fatalf("delete source: deleted=%v err=%v", deleted, err) + } + deleted, err = st.DeleteReleaseSource(created.ID) + if err != nil || deleted { + t.Fatalf("delete missing source: deleted=%v err=%v", deleted, err) + } +} diff --git a/web/src/App.vue b/web/src/App.vue index 72f4396..12bc265 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -6,6 +6,7 @@ const navItems = [ { to: '/mirrors', label: 'Mirrors', code: 'mir' }, { to: '/gitproxy', label: 'Git Proxy', code: 'git' }, { to: '/search', label: 'Search', code: 'find' }, + { to: '/releases', label: 'Releases', code: 'rel' }, { to: '/settings', label: 'Settings', code: 'cfg' }, ] diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 22e8a53..a6c014c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -120,3 +120,53 @@ export async function updateRateLimitConfig(config: { }) { return api.put("/config/ratelimit", config).then((r) => r.data); } + +export interface ReleaseSource { + id: number; + name: string; + owner: string; + repo: string; + assetName: string; + createdAt: string; + repositoryUrl: string; + tagName?: string; + releaseUrl?: string; + publishedAt?: string; + assetSize?: number; + digest?: string; + available: boolean; + error?: string; +} + +export async function getReleaseSources( + refresh = false, +): Promise { + return api + .get("/release-sources", { params: refresh ? { refresh: "1" } : undefined }) + .then((r) => r.data); +} + +export async function createReleaseSource(source: { + name: string; + releaseUrl: string; + assetName: string; +}): Promise { + return api.post("/release-sources", source).then((r) => r.data); +} + +export async function deleteReleaseSource(id: number) { + return api.delete(`/release-sources/${id}`).then((r) => r.data); +} + +export async function createReleaseDownloadTicket(id: number): Promise<{ + ticket: string; + fileName: string; + tagName: string; +}> { + return api.post(`/release-sources/${id}/download-ticket`).then((r) => r.data); +} + +export function getReleaseDownloadUrl(ticket: string): string { + const base = import.meta.env.VITE_API_BASE_URL || "/api"; + return `${base.replace(/\/$/, "")}/release-download?ticket=${encodeURIComponent(ticket)}`; +} diff --git a/web/src/router/index.ts b/web/src/router/index.ts index e38ac20..cb0600a 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -5,6 +5,7 @@ import GitProxy from '../views/GitProxy.vue' import Settings from '../views/Settings.vue' import Login from '../views/Login.vue' import Search from '../views/Search.vue' +import Releases from '../views/Releases.vue' import { isLoggedIn, checkAuthRequired, initAuth } from '../api/client' const routes = [ @@ -13,6 +14,7 @@ const routes = [ { path: '/mirrors', component: Mirrors }, { path: '/gitproxy', component: GitProxy }, { path: '/search', component: Search }, + { path: '/releases', component: Releases }, { path: '/settings', component: Settings }, ] diff --git a/web/src/views/Releases.vue b/web/src/views/Releases.vue new file mode 100644 index 0000000..594cd33 --- /dev/null +++ b/web/src/views/Releases.vue @@ -0,0 +1,237 @@ + + + diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index 59b84b4..2676734 100644 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -58,6 +58,7 @@ const capabilities = [ 'Mirror Search', 'IP Rate Limiting', 'HuggingFace Proxy', + 'Release Downloads', ] From 5d775ec1bbf229e440325f79cf57ca0003ca4209 Mon Sep 17 00:00:00 2001 From: wha7ev9r Date: Thu, 30 Jul 2026 13:30:05 +0800 Subject: [PATCH 2/3] fix: address release download review findings - dashboard: resolve the pinned asset from the cached latest release and only re-query GitHub when the cache is cold or no longer lists it, so downloads stop spending an API call each time - dashboard: bound dial, TLS handshake and response-header waits on the download client while leaving Client.Timeout unset for large assets - web: keep a zero asset size rendering as "0 B" instead of "-" - tests: assert the warm cache serves tickets, stale caches refresh and the download client keeps bounded connection timeouts --- internal/dashboard/release.go | 84 +++++++++++++++++++++++------- internal/dashboard/release_test.go | 45 ++++++++++++++++ web/src/views/Releases.vue | 2 +- 3 files changed, 112 insertions(+), 19 deletions(-) diff --git a/internal/dashboard/release.go b/internal/dashboard/release.go index 3f3e9ff..ddc1cf6 100644 --- a/internal/dashboard/release.go +++ b/internal/dashboard/release.go @@ -10,6 +10,7 @@ import ( "io" "log/slog" "mime" + "net" "net/http" "net/url" "path" @@ -32,6 +33,8 @@ const ( var githubNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) +var errAssetNotFound = errors.New("asset not found in latest release") + type githubRelease struct { TagName string `json:"tag_name"` HTMLURL string `json:"html_url"` @@ -188,13 +191,13 @@ func (d *Dashboard) createReleaseSource(w http.ResponseWriter, r *http.Request) return } - release, err := d.latestRelease(r, owner, repo, true) - if err != nil { - http.Error(w, "get latest release: "+err.Error(), http.StatusBadGateway) + release, _, err := d.latestReleaseAsset(r, owner, repo, assetName) + if errors.Is(err, errAssetNotFound) { + http.Error(w, errAssetNotFound.Error(), http.StatusUnprocessableEntity) return } - if _, ok := findAsset(release, assetName); !ok { - http.Error(w, "asset not found in latest release", http.StatusUnprocessableEntity) + if err != nil { + http.Error(w, "get latest release: "+err.Error(), http.StatusBadGateway) return } @@ -226,7 +229,7 @@ func (d *Dashboard) releaseSourceViewFromRelease(source store.ReleaseSource, rel } asset, ok := findAsset(release, source.AssetName) if !ok { - view.Error = "asset not found in latest release" + view.Error = errAssetNotFound.Error() return view } view.Available = true @@ -246,14 +249,13 @@ func (d *Dashboard) createDownloadTicket(w http.ResponseWriter, r *http.Request, return } - release, err := d.latestRelease(r, source.Owner, source.Repo, true) - if err != nil { - http.Error(w, "get latest release: "+err.Error(), http.StatusBadGateway) + release, asset, err := d.latestReleaseAsset(r, source.Owner, source.Repo, source.AssetName) + if errors.Is(err, errAssetNotFound) { + http.Error(w, errAssetNotFound.Error(), http.StatusUnprocessableEntity) return } - asset, ok := findAsset(release, source.AssetName) - if !ok { - http.Error(w, "asset not found in latest release", http.StatusUnprocessableEntity) + if err != nil { + http.Error(w, "get latest release: "+err.Error(), http.StatusBadGateway) return } ticket, err := randomTicket() @@ -360,13 +362,10 @@ func (d *Dashboard) ReleaseDownloadHandler(w http.ResponseWriter, r *http.Reques } func (d *Dashboard) latestRelease(r *http.Request, owner, repo string, refresh bool) (githubRelease, error) { - key := strings.ToLower(owner + "/" + repo) + key := releaseCacheKey(owner, repo) if !refresh { - d.releaseCacheMu.Lock() - entry, ok := d.releaseCache[key] - d.releaseCacheMu.Unlock() - if ok && time.Now().Before(entry.expiresAt) { - return entry.release, nil + if release, ok := d.cachedLatestRelease(owner, repo); ok { + return release, nil } } @@ -396,6 +395,40 @@ func (d *Dashboard) latestRelease(r *http.Request, owner, repo string, refresh b return release, nil } +// latestReleaseAsset resolves the pinned asset from the cached latest release and +// only queries GitHub when the cache is cold or no longer lists the asset, so +// repeated downloads do not spend the unauthenticated API quota. +func (d *Dashboard) latestReleaseAsset(r *http.Request, owner, repo, assetName string) (githubRelease, githubAsset, error) { + if release, ok := d.cachedLatestRelease(owner, repo); ok { + if asset, found := findAsset(release, assetName); found { + return release, asset, nil + } + } + release, err := d.latestRelease(r, owner, repo, true) + if err != nil { + return githubRelease{}, githubAsset{}, err + } + asset, found := findAsset(release, assetName) + if !found { + return release, githubAsset{}, errAssetNotFound + } + return release, asset, nil +} + +func (d *Dashboard) cachedLatestRelease(owner, repo string) (githubRelease, bool) { + d.releaseCacheMu.Lock() + defer d.releaseCacheMu.Unlock() + entry, ok := d.releaseCache[releaseCacheKey(owner, repo)] + if !ok || !time.Now().Before(entry.expiresAt) { + return githubRelease{}, false + } + return entry.release, true +} + +func releaseCacheKey(owner, repo string) string { + return strings.ToLower(owner + "/" + repo) +} + func parseGitHubReleasesURL(rawURL string) (string, string, error) { u, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || u.Scheme != "https" || !strings.EqualFold(u.Hostname(), "github.com") || u.Port() != "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { @@ -455,6 +488,21 @@ func setGitHubHeaders(req *http.Request) { func newReleaseDownloadClient() *http.Client { return &http.Client{ + // No client-wide timeout: assets stream for minutes. Connection setup and + // the wait for response headers are bounded by the transport instead. + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 10, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + ResponseHeaderTimeout: 30 * time.Second, + }, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return errors.New("too many redirects") diff --git a/internal/dashboard/release_test.go b/internal/dashboard/release_test.go index a858ac6..8ed26ea 100644 --- a/internal/dashboard/release_test.go +++ b/internal/dashboard/release_test.go @@ -3,6 +3,7 @@ package dashboard import ( "bytes" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -10,6 +11,7 @@ import ( "path/filepath" "strings" "testing" + "time" "devbox/internal/store" ) @@ -55,6 +57,7 @@ func TestReleaseSourceCreateTicketAndDownload(t *testing.T) { "published_at":"2026-07-26T05:18:51Z", "assets":[{"id":42,"name":"update.7z","size":7,"content_type":"application/x-7z-compressed","digest":"sha256:test"}] }` + releaseCalls := 0 dashboard.releaseHTTP = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { if req.URL.String() != githubAPIBase+"/repos/moesnow/March7thAssistant/releases/latest" { t.Fatalf("unexpected release URL: %s", req.URL) @@ -62,6 +65,7 @@ func TestReleaseSourceCreateTicketAndDownload(t *testing.T) { if req.Header.Get("X-GitHub-Api-Version") != githubAPIVersion { t.Fatal("missing GitHub API version header") } + releaseCalls++ return testResponse(http.StatusOK, "application/json", releaseJSON), nil })} @@ -96,6 +100,9 @@ func TestReleaseSourceCreateTicketAndDownload(t *testing.T) { if ticketResult["ticket"] == "" { t.Fatal("empty download ticket") } + if releaseCalls != 1 { + t.Fatalf("expected the warm release cache to serve the ticket, GitHub calls=%d", releaseCalls) + } dashboard.downloadHTTP = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { if req.URL.String() != githubAPIBase+"/repos/moesnow/March7thAssistant/releases/assets/42" { @@ -134,6 +141,44 @@ func TestReleaseHandlersRequireAuth(t *testing.T) { } } +func TestLatestReleaseAssetRefreshesStaleCache(t *testing.T) { + dashboard := newReleaseTestDashboard(t) + dashboard.releaseCache[releaseCacheKey("moesnow", "March7thAssistant")] = cachedRelease{ + release: githubRelease{TagName: "v1.0.0"}, + expiresAt: time.Now().Add(releaseCacheTTL), + } + calls := 0 + dashboard.releaseHTTP = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + calls++ + body := `{"tag_name":"v1.2.3","assets":[{"id":42,"name":"update.7z","size":7}]}` + return testResponse(http.StatusOK, "application/json", body), nil + })} + + req := httptest.NewRequest(http.MethodGet, "/api/release-sources", nil) + release, asset, err := dashboard.latestReleaseAsset(req, "moesnow", "March7thAssistant", "update.7z") + if err != nil || calls != 1 || release.TagName != "v1.2.3" || asset.ID != 42 { + t.Fatalf("stale cache was not refreshed: calls=%d release=%+v asset=%+v err=%v", calls, release, asset, err) + } + + if _, _, err := dashboard.latestReleaseAsset(req, "moesnow", "March7thAssistant", "missing.7z"); !errors.Is(err, errAssetNotFound) { + t.Fatalf("expected errAssetNotFound, got %v", err) + } +} + +func TestReleaseDownloadClientTimeouts(t *testing.T) { + client := newReleaseDownloadClient() + if client.Timeout != 0 { + t.Fatalf("client timeout must stay unset for large downloads, got %s", client.Timeout) + } + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatalf("unexpected transport %T", client.Transport) + } + if transport.ResponseHeaderTimeout <= 0 || transport.TLSHandshakeTimeout <= 0 || transport.DialContext == nil { + t.Fatalf("transport does not bound connection setup: %+v", transport) + } +} + func TestReleaseDownloadRedirectPolicy(t *testing.T) { client := newReleaseDownloadClient() check := client.CheckRedirect diff --git a/web/src/views/Releases.vue b/web/src/views/Releases.vue index 594cd33..ee17f53 100644 --- a/web/src/views/Releases.vue +++ b/web/src/views/Releases.vue @@ -113,7 +113,7 @@ function flashStatus(message: string) { } function formatBytes(value?: number) { - if (!value) return '-' + if (value === undefined || !Number.isFinite(value)) return '-' const units = ['B', 'KB', 'MB', 'GB'] let size = value let unit = 0 From 27439d29b45b45fb2c1124341a97aae37f163dcc Mon Sep 17 00:00:00 2001 From: wha7ev9r Date: Thu, 30 Jul 2026 13:37:16 +0800 Subject: [PATCH 3/3] test: align release cache tests with their fixtures TestLatestReleaseAssetRefreshesStaleCache seeded an unexpired entry, so it never exercised the TTL path its name promised. - rename it to TestLatestReleaseAssetRefreshesWhenCachedAssetMissing, which is what the fixture actually covers - add TestLatestReleaseAssetRefreshesExpiredCache for the TTL path: an expired entry that still lists the asset must be re-queried and recached --- internal/dashboard/release_test.go | 32 ++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/internal/dashboard/release_test.go b/internal/dashboard/release_test.go index 8ed26ea..91d8f42 100644 --- a/internal/dashboard/release_test.go +++ b/internal/dashboard/release_test.go @@ -141,7 +141,7 @@ func TestReleaseHandlersRequireAuth(t *testing.T) { } } -func TestLatestReleaseAssetRefreshesStaleCache(t *testing.T) { +func TestLatestReleaseAssetRefreshesWhenCachedAssetMissing(t *testing.T) { dashboard := newReleaseTestDashboard(t) dashboard.releaseCache[releaseCacheKey("moesnow", "March7thAssistant")] = cachedRelease{ release: githubRelease{TagName: "v1.0.0"}, @@ -157,7 +157,7 @@ func TestLatestReleaseAssetRefreshesStaleCache(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/release-sources", nil) release, asset, err := dashboard.latestReleaseAsset(req, "moesnow", "March7thAssistant", "update.7z") if err != nil || calls != 1 || release.TagName != "v1.2.3" || asset.ID != 42 { - t.Fatalf("stale cache was not refreshed: calls=%d release=%+v asset=%+v err=%v", calls, release, asset, err) + t.Fatalf("unexpired cache without the asset was not refreshed: calls=%d release=%+v asset=%+v err=%v", calls, release, asset, err) } if _, _, err := dashboard.latestReleaseAsset(req, "moesnow", "March7thAssistant", "missing.7z"); !errors.Is(err, errAssetNotFound) { @@ -165,6 +165,34 @@ func TestLatestReleaseAssetRefreshesStaleCache(t *testing.T) { } } +func TestLatestReleaseAssetRefreshesExpiredCache(t *testing.T) { + dashboard := newReleaseTestDashboard(t) + key := releaseCacheKey("moesnow", "March7thAssistant") + // Expired entry that still lists the asset: only a TTL check forces a re-query. + dashboard.releaseCache[key] = cachedRelease{ + release: githubRelease{ + TagName: "v1.0.0", + Assets: []githubAsset{{ID: 7, Name: "update.7z", Size: 3}}, + }, + expiresAt: time.Now().Add(-time.Minute), + } + calls := 0 + dashboard.releaseHTTP = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + calls++ + body := `{"tag_name":"v1.2.3","assets":[{"id":42,"name":"update.7z","size":7}]}` + return testResponse(http.StatusOK, "application/json", body), nil + })} + + req := httptest.NewRequest(http.MethodGet, "/api/release-sources", nil) + release, asset, err := dashboard.latestReleaseAsset(req, "moesnow", "March7thAssistant", "update.7z") + if err != nil || calls != 1 || release.TagName != "v1.2.3" || asset.ID != 42 { + t.Fatalf("expired cache was not refreshed: calls=%d release=%+v asset=%+v err=%v", calls, release, asset, err) + } + if _, _, err := dashboard.latestReleaseAsset(req, "moesnow", "March7thAssistant", "update.7z"); err != nil || calls != 1 { + t.Fatalf("refreshed entry was not cached: calls=%d err=%v", calls, err) + } +} + func TestReleaseDownloadClientTimeouts(t *testing.T) { client := newReleaseDownloadClient() if client.Timeout != 0 {