From 6ebd2394d00dcd8569a6cfd480030ce8d58bb37e Mon Sep 17 00:00:00 2001 From: NewName Date: Tue, 20 May 2025 16:41:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=95=9C=E5=83=8F=E6=90=9C?= =?UTF-8?q?=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ghproxy/public/search.html | 87 ++++++++++++----- ghproxy/search.go | 189 +++++++++++++++++++++---------------- 2 files changed, 175 insertions(+), 101 deletions(-) diff --git a/ghproxy/public/search.html b/ghproxy/public/search.html index 9ffa89c..1a35dd7 100644 --- a/ghproxy/public/search.html +++ b/ghproxy/public/search.html @@ -352,6 +352,45 @@ .back-to-search:hover { text-decoration: underline; } + + .meta-item { + display: inline-flex; + align-items: center; + margin-right: 15px; + color: var(--fontcolor); + opacity: 0.8; + } + + .badge-automated { + background-color: #28a745; + color: white; + margin-left: 5px; + } + + .tag-meta { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin: 10px 0; + font-size: 0.9rem; + color: var(--fontcolor); + opacity: 0.8; + } + + .vulnerability-indicator { + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: 10px; + } + + .arch-item { + background-color: var(--inputcolor); + padding: 5px 10px; + border-radius: 5px; + font-size: 0.8rem; + cursor: help; + } @@ -526,11 +565,7 @@ return; } - console.log('搜索结果:', results); - results.forEach(result => { - console.log('处理结果:', result); - const card = document.createElement('div'); card.className = 'result-card'; @@ -540,23 +575,25 @@ const starCount = result.star_count || 0; const pullCount = result.pull_count || 0; const lastUpdated = result.last_updated || new Date(); - - const stars = starCount ? `⭐ ${formatNumber(starCount)}` : ''; - const pulls = pullCount ? `⬇️ ${formatNumber(pullCount)}` : ''; const repoName = namespace ? `${namespace}/${name}` : name; const officialBadge = result.is_official ? '官方' : ''; const orgBadge = result.organization ? `By ${result.organization}` : ''; + const automatedBadge = result.is_automated ? '自动构建' : ''; card.innerHTML = `
${repoName} ${officialBadge} ${orgBadge} + ${automatedBadge}
${description}
- ${stars} ${pulls} - 更新于 ${formatTimeAgo(lastUpdated)} + + ${starCount > 0 ? `⭐ ${formatNumber(starCount)}` : ''} + ${pullCount > 0 ? `⬇️ ${formatNumber(pullCount)}` : ''} + + 更新于 ${formatTimeAgo(lastUpdated)}
`; @@ -599,8 +636,15 @@
${repoName} ${currentRepo.is_official ? '官方' : ''} + ${currentRepo.organization ? `By ${currentRepo.organization}` : ''} + ${currentRepo.is_automated ? '自动构建' : ''}
${currentRepo.description || '暂无描述'}
+
+ ${currentRepo.star_count > 0 ? `⭐ ${formatNumber(currentRepo.star_count)}` : ''} + ${currentRepo.pull_count > 0 ? `⬇️ ${formatNumber(currentRepo.pull_count)}` : ''} + 更新于 ${formatTimeAgo(currentRepo.last_updated)} +
docker pull ${repoName} @@ -610,32 +654,33 @@ `; let tagsHtml = tags.map(tag => { - const vulnIndicators = Object.entries(tag.vulnerabilities) + const vulnIndicators = Object.entries(tag.vulnerabilities || {}) .map(([level, count]) => count > 0 ? `` : '') .join(''); + const images = tag.images || []; + const architectures = images.map(img => { + const arch = `${img.os}/${img.architecture}${img.variant ? '/' + img.variant : ''}`; + const size = formatSize(img.size); + return `
${arch}
`; + }).join(''); + return `
${tag.name} -
${vulnIndicators}
+ ${vulnIndicators ? `
${vulnIndicators}
` : ''}
- 最后更新: ${formatTimeAgo(tag.last_updated)} - 由 ${tag.last_pusher} 推送 + 最后更新: ${formatTimeAgo(tag.last_updated)} + ${tag.last_pusher ? `由 ${tag.last_pusher} 推送` : ''} + ${tag.full_size ? `大小: ${formatSize(tag.full_size)}` : ''}
docker pull ${repoName}:${tag.name}
-
- ${tag.images.map(img => ` -
- ${img.os}/${img.architecture}${img.variant ? '/' + img.variant : ''} - (${formatSize(img.size)}) -
- `).join('')} -
+ ${architectures ? `
${architectures}
` : ''}
`; }).join(''); diff --git a/ghproxy/search.go b/ghproxy/search.go index 12626f7..59b9216 100644 --- a/ghproxy/search.go +++ b/ghproxy/search.go @@ -6,7 +6,7 @@ import ( "fmt" "io" "net/http" - "os/exec" + "net/url" "strings" "sync" "time" @@ -28,6 +28,7 @@ type Repository struct { Namespace string `json:"namespace"` Description string `json:"description"` IsOfficial bool `json:"is_official"` + IsAutomated bool `json:"is_automated"` StarCount int `json:"star_count"` PullCount int `json:"pull_count"` LastUpdated time.Time `json:"last_updated"` @@ -95,96 +96,110 @@ func setCacheResult(key string, data interface{}) { } } -// searchWithSkopeo 使用skopeo搜索镜像 -func searchWithSkopeo(ctx context.Context, query string) (*SearchResult, error) { - // 执行skopeo search命令 - cmd := exec.CommandContext(ctx, "skopeo", "list-tags", fmt.Sprintf("docker://docker.io/%s", query)) - output, err := cmd.CombinedOutput() +// searchDockerHub 搜索镜像 +func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*SearchResult, error) { + cacheKey := fmt.Sprintf("search:%s:%d:%d", query, page, pageSize) + if cached, ok := getCachedResult(cacheKey); ok { + return cached.(*SearchResult), nil + } + + // 构建Docker Hub API请求 + baseURL := "https://hub.docker.com/v2/search/repositories/" + params := url.Values{} + params.Set("query", query) + params.Set("page", fmt.Sprintf("%d", page)) + params.Set("page_size", fmt.Sprintf("%d", pageSize)) + + req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"?"+params.Encode(), nil) if err != nil { - // 如果是因为找不到镜像,尝试搜索 - cmd = exec.CommandContext(ctx, "skopeo", "search", fmt.Sprintf("docker://%s", query)) - output, err = cmd.CombinedOutput() - if err != nil { - return nil, fmt.Errorf("搜索失败: %v, 输出: %s", err, string(output)) - } + return nil, fmt.Errorf("创建请求失败: %v", err) } - // 解析输出 + // 添加必要的请求头 + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") + + // 发送请求 + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("发送请求失败: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态码 + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body)) + } + + // 解析响应 var result SearchResult - result.Results = make([]Repository, 0) - - // 按行解析输出 - lines := strings.Split(string(output), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - // 解析仓库信息 - parts := strings.Fields(line) - if len(parts) < 1 { - continue - } - - fullName := parts[0] - nameParts := strings.Split(fullName, "/") - - repo := Repository{} - - if len(nameParts) == 1 { - repo.Name = nameParts[0] - repo.Namespace = "library" - repo.IsOfficial = true - } else { - repo.Name = nameParts[len(nameParts)-1] - repo.Namespace = strings.Join(nameParts[:len(nameParts)-1], "/") - } - - if len(parts) > 1 { - repo.Description = strings.Join(parts[1:], " ") - } - - result.Results = append(result.Results, repo) + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("解析响应失败: %v", err) } - result.Count = len(result.Results) + // 缓存结果 + setCacheResult(cacheKey, &result) return &result, nil } -// getTagsWithSkopeo 使用skopeo获取标签信息 -func getTagsWithSkopeo(ctx context.Context, namespace, name string) ([]TagInfo, error) { - repoName := name - if namespace != "library" { - repoName = namespace + "/" + name +// getRepositoryTags 获取仓库标签信息 +func getRepositoryTags(ctx context.Context, namespace, name string) ([]TagInfo, error) { + cacheKey := fmt.Sprintf("tags:%s:%s", namespace, name) + if cached, ok := getCachedResult(cacheKey); ok { + return cached.([]TagInfo), nil } - // 执行skopeo list-tags命令 - cmd := exec.CommandContext(ctx, "skopeo", "list-tags", fmt.Sprintf("docker://docker.io/%s", repoName)) - output, err := cmd.CombinedOutput() + // 构建API URL + var baseURL string + if namespace == "library" { + baseURL = fmt.Sprintf("https://hub.docker.com/v2/repositories/library/%s/tags", name) + } else { + baseURL = fmt.Sprintf("https://hub.docker.com/v2/repositories/%s/%s/tags", namespace, name) + } + + params := url.Values{} + params.Set("page_size", "100") + params.Set("ordering", "last_updated") + + req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"?"+params.Encode(), nil) if err != nil { - return nil, fmt.Errorf("获取标签失败: %v, 输出: %s", err, string(output)) + return nil, fmt.Errorf("创建请求失败: %v", err) } - var tags []TagInfo - if err := json.Unmarshal(output, &tags); err != nil { - // 如果解析JSON失败,尝试按行解析 - lines := strings.Split(string(output), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - tag := TagInfo{ - Name: line, - LastUpdated: time.Now(), - } - tags = append(tags, tag) - } + // 添加必要的请求头 + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") + + // 发送请求 + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("发送请求失败: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态码 + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body)) } - return tags, nil + // 解析响应 + var result struct { + Count int `json:"count"` + Next string `json:"next"` + Previous string `json:"previous"` + Results []TagInfo `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("解析响应失败: %v", err) + } + + // 缓存结果 + setCacheResult(cacheKey, result.Results) + return result.Results, nil } // RegisterSearchRoute 注册搜索相关路由 @@ -197,7 +212,23 @@ func RegisterSearchRoute(r *gin.Engine) { return } - result, err := searchWithSkopeo(c.Request.Context(), query) + page := 1 + pageSize := 25 + if p := c.Query("page"); p != "" { + fmt.Sscanf(p, "%d", &page) + } + if ps := c.Query("page_size"); ps != "" { + fmt.Sscanf(ps, "%d", &pageSize) + } + + // 如果是搜索官方镜像 + if strings.HasPrefix(query, "library/") || !strings.Contains(query, "/") { + if !strings.HasPrefix(query, "library/") { + query = "library/" + query + } + } + + result, err := searchDockerHub(c.Request.Context(), query, page, pageSize) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -210,10 +241,8 @@ func RegisterSearchRoute(r *gin.Engine) { r.GET("/tags/:namespace/:name", func(c *gin.Context) { namespace := c.Param("namespace") name := c.Param("name") - - fmt.Printf("获取标签请求: namespace=%s, name=%s\n", namespace, name) - - tags, err := getTagsWithSkopeo(c.Request.Context(), namespace, name) + + tags, err := getRepositoryTags(c.Request.Context(), namespace, name) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return