diff --git a/ghproxy/public/search.html b/ghproxy/public/search.html index 2ab2d52..92ff52b 100644 --- a/ghproxy/public/search.html +++ b/ghproxy/public/search.html @@ -17,6 +17,7 @@ --inputcolor: #f5f5f5; --inputcolor-font: #333; --card-bg: #f8f9fa; + --border-color: #dee2e6; } @media (prefers-color-scheme: dark) { @@ -26,6 +27,7 @@ --inputcolor: #012333; --inputcolor-font: #969696d8; --card-bg: #012333; + --border-color: #2d3338; } } @@ -91,6 +93,8 @@ padding: 20px; margin-bottom: 20px; transition: all 0.3s ease; + cursor: pointer; + border: 1px solid var(--border-color); } .result-card:hover { @@ -103,11 +107,15 @@ font-weight: bold; margin-bottom: 10px; color: #39c5bc; + display: flex; + align-items: center; + gap: 10px; } .result-description { color: var(--fontcolor); margin-bottom: 15px; + font-size: 0.9rem; } .result-meta { @@ -202,6 +210,148 @@ transform: scale(1.05); background-color: #2ea8a0; } + + .badge { + padding: 4px 8px; + border-radius: 4px; + font-size: 0.8rem; + font-weight: normal; + } + + .badge-official { + background-color: #39c5bc; + color: white; + } + + .badge-organization { + background-color: #6c757d; + color: white; + } + + .tag-list { + margin-top: 20px; + display: none; + } + + .tag-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding: 20px; + background-color: var(--card-bg); + border-radius: 10px; + border: 1px solid var(--border-color); + } + + .tag-info { + flex: 1; + } + + .tag-title { + font-size: 1.5rem; + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 10px; + } + + .tag-description { + color: var(--fontcolor); + opacity: 0.8; + } + + .tag-pull-command { + background-color: var(--inputcolor); + padding: 10px 15px; + border-radius: 5px; + font-family: monospace; + margin-top: 10px; + position: relative; + } + + .copy-button { + position: absolute; + right: 5px; + top: 5px; + background: #39c5bc; + color: white; + border: none; + padding: 2px 8px; + border-radius: 3px; + cursor: pointer; + font-size: 0.8rem; + } + + .tag-item { + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 15px; + margin-bottom: 15px; + } + + .tag-name { + font-size: 1.1rem; + font-weight: bold; + margin-bottom: 10px; + color: #39c5bc; + } + + .tag-meta { + font-size: 0.9rem; + color: var(--fontcolor); + opacity: 0.8; + margin-bottom: 10px; + } + + .tag-architectures { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; + } + + .arch-item { + background-color: var(--inputcolor); + padding: 5px 10px; + border-radius: 5px; + font-size: 0.8rem; + } + + .vulnerability-indicator { + display: inline-flex; + gap: 5px; + margin-left: 10px; + } + + .vulnerability-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + } + + .vulnerability-critical { background-color: #dc3545; } + .vulnerability-high { background-color: #fd7e14; } + .vulnerability-medium { background-color: #ffc107; } + .vulnerability-low { background-color: #28a745; } + .vulnerability-unknown { background-color: #6c757d; } + + .search-results { + transition: all 0.3s ease; + } + + .back-to-search { + margin-bottom: 20px; + color: #39c5bc; + cursor: pointer; + display: none; + } + + .back-to-search:hover { + text-decoration: underline; + } @@ -220,15 +370,21 @@
-

正在搜索...

+

正在加载...

-
- -
@@ -237,6 +393,7 @@ let currentPage = 1; let totalPages = 1; let currentQuery = ''; + let currentRepo = null; document.getElementById('searchButton').addEventListener('click', () => { currentPage = 1; @@ -264,9 +421,12 @@ } }); + document.querySelector('.back-to-search').addEventListener('click', () => { + showSearchResults(); + }); + function showLoading() { document.querySelector('.loading').style.display = 'block'; - document.getElementById('searchResults').innerHTML = ''; } function hideLoading() { @@ -290,6 +450,20 @@ nextButton.disabled = currentPage >= totalPages; } + function showSearchResults() { + document.querySelector('.search-results').style.display = 'block'; + document.querySelector('.tag-list').style.display = 'none'; + document.querySelector('.back-to-search').style.display = 'none'; + document.querySelector('.search-container').style.display = 'block'; + } + + function showTagList() { + document.querySelector('.search-results').style.display = 'none'; + document.querySelector('.tag-list').style.display = 'block'; + document.querySelector('.back-to-search').style.display = 'block'; + document.querySelector('.search-container').style.display = 'none'; + } + async function performSearch() { const query = document.getElementById('searchInput').value.trim(); if (!query) { @@ -301,14 +475,14 @@ showLoading(); try { - const response = await fetch(`/search?q=${encodeURIComponent(query)}&page=${currentPage}&page_size=10`); + const response = await fetch(`/search?q=${encodeURIComponent(query)}&page=${currentPage}&page_size=25`); const data = await response.json(); if (!response.ok) { throw new Error(data.error || '搜索请求失败'); } - totalPages = Math.ceil(data.count / 10); + totalPages = Math.ceil(data.count / 25); displayResults(data.results); updatePagination(); } catch (error) { @@ -319,6 +493,32 @@ } } + function formatNumber(num) { + if (num >= 1000000000) { + return (num / 1000000000).toFixed(1) + 'B+'; + } + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + 'M+'; + } + if (num >= 1000) { + return (num / 1000).toFixed(1) + 'K+'; + } + return num.toString(); + } + + function formatTimeAgo(dateString) { + const date = new Date(dateString); + const now = new Date(); + const diffTime = Math.abs(now - date); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays === 1) return '昨天'; + if (diffDays < 7) return `${diffDays}天前`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)}周前`; + if (diffDays < 365) return `${Math.floor(diffDays / 30)}个月前`; + return `${Math.floor(diffDays / 365)}年前`; + } + function displayResults(results) { const resultsContainer = document.getElementById('searchResults'); resultsContainer.innerHTML = ''; @@ -332,46 +532,137 @@ const card = document.createElement('div'); card.className = 'result-card'; - const stars = result.star_count ? `⭐ ${result.star_count}` : ''; + const stars = result.star_count ? `⭐ ${formatNumber(result.star_count)}` : ''; const pulls = result.pull_count ? `⬇️ ${formatNumber(result.pull_count)}` : ''; const repoName = result.namespace ? `${result.namespace}/${result.name}` : result.name; - const isOfficial = result.is_official ? '官方' : ''; + const officialBadge = result.is_official ? '官方' : ''; + const orgBadge = result.organization ? `By ${result.organization}` : ''; card.innerHTML = `
- - ${repoName}${isOfficial} - + ${repoName} + ${officialBadge} + ${orgBadge}
${result.description || '暂无描述'}
${stars} ${pulls} - ${result.is_private ? '私有' : '公开'} + 更新于 ${formatTimeAgo(result.last_updated)}
`; + card.addEventListener('click', () => { + currentRepo = result; + loadTags(result.namespace || 'library', result.name); + }); + resultsContainer.appendChild(card); }); } - function formatNumber(num) { - if (num >= 1000000) { - return (num / 1000000).toFixed(1) + 'M'; + async function loadTags(namespace, name) { + showLoading(); + try { + const response = await fetch(`/tags/${namespace}/${name}`); + const tags = await response.json(); + + if (!response.ok) { + throw new Error(tags.error || '获取标签信息失败'); + } + + displayTags(tags); + showTagList(); + } catch (error) { + showToast('获取标签信息失败,请稍后重试'); + console.error('获取标签错误:', error); + } finally { + hideLoading(); } - if (num >= 1000) { - return (num / 1000).toFixed(1) + 'K'; - } - return num.toString(); } - function formatDate(dateString) { - const date = new Date(dateString); - return date.toLocaleDateString('zh-CN', { - year: 'numeric', - month: 'long', - day: 'numeric' + function displayTags(tags) { + const tagList = document.getElementById('tagList'); + const repoName = currentRepo.namespace ? `${currentRepo.namespace}/${currentRepo.name}` : currentRepo.name; + + let header = ` +
+
+
+ ${repoName} + ${currentRepo.is_official ? '官方' : ''} + ${currentRepo.organization ? `By ${currentRepo.organization}` : ''} +
+
${currentRepo.description || '暂无描述'}
+
+ docker pull ${repoName} + +
+
+
+ `; + + let tagsHtml = tags.map(tag => { + const vulnIndicators = Object.entries(tag.vulnerabilities) + .map(([level, count]) => count > 0 ? `` : '') + .join(''); + + return ` +
+
+ ${tag.name} +
${vulnIndicators}
+
+
+ 最后更新: ${formatTimeAgo(tag.last_updated)} + 由 ${tag.last_pusher} 推送 +
+
+ docker pull ${repoName}:${tag.name} + +
+
+ ${tag.images.map(img => ` +
+ ${img.os}/${img.architecture}${img.variant ? '/' + img.variant : ''} + (${formatSize(img.size)}) +
+ `).join('')} +
+
+ `; + }).join(''); + + tagList.innerHTML = header + tagsHtml; + } + + function formatSize(bytes) { + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(2)} ${units[unitIndex]}`; + } + + function copyToClipboard(text) { + navigator.clipboard.writeText(text).then(() => { + showToast('已复制到剪贴板'); + }).catch(() => { + showToast('复制失败'); }); } + + // 初始加载 + const urlParams = new URLSearchParams(window.location.search); + const initialQuery = urlParams.get('q'); + if (initialQuery) { + document.getElementById('searchInput').value = initialQuery; + performSearch(); + } \ No newline at end of file diff --git a/ghproxy/search.go b/ghproxy/search.go index 58a5a35..7dd8392 100644 --- a/ghproxy/search.go +++ b/ghproxy/search.go @@ -12,37 +12,70 @@ import ( "github.com/gin-gonic/gin" ) -type DockerHubSearchResult struct { +// SearchResult Docker Hub搜索结果 +type SearchResult struct { Count int `json:"count"` Next string `json:"next"` Previous string `json:"previous"` Results []Repository `json:"results"` } +// Repository 仓库信息 type Repository struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - RepositoryType string `json:"repository_type"` - Status int `json:"status"` - Description string `json:"description"` - IsOfficial bool `json:"is_official"` - IsPrivate bool `json:"is_private"` - StarCount int `json:"star_count"` - PullCount int `json:"pull_count"` + Name string `json:"name"` + 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"` + Status int `json:"status"` + Organization string `json:"organization,omitempty"` + IsTrusted bool `json:"is_trusted"` + IsPrivate bool `json:"is_private"` + PullsLastWeek int `json:"pulls_last_week"` +} + +// TagInfo 标签信息 +type TagInfo struct { + Name string `json:"name"` + FullSize int64 `json:"full_size"` + LastUpdated time.Time `json:"last_updated"` + LastPusher string `json:"last_pusher"` + Images []Image `json:"images"` + Vulnerabilities struct { + Critical int `json:"critical"` + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` + Unknown int `json:"unknown"` + } `json:"vulnerabilities"` +} + +// Image 镜像信息 +type Image struct { + Architecture string `json:"architecture"` + Features string `json:"features"` + Variant string `json:"variant,omitempty"` + Digest string `json:"digest"` + OS string `json:"os"` + OSFeatures string `json:"os_features"` + Size int64 `json:"size"` } type cacheEntry struct { - data *DockerHubSearchResult + data interface{} timestamp time.Time } var ( cache = make(map[string]cacheEntry) cacheLock sync.RWMutex - cacheTTL = 8 * time.Hour + cacheTTL = 30 * time.Minute ) -func getCachedResult(key string) (*DockerHubSearchResult, bool) { +func getCachedResult(key string) (interface{}, bool) { cacheLock.RLock() defer cacheLock.RUnlock() entry, exists := cache[key] @@ -55,7 +88,7 @@ func getCachedResult(key string) (*DockerHubSearchResult, bool) { return entry.data, true } -func setCacheResult(key string, data *DockerHubSearchResult) { +func setCacheResult(key string, data interface{}) { cacheLock.Lock() defer cacheLock.Unlock() cache[key] = cacheEntry{ @@ -64,14 +97,11 @@ func setCacheResult(key string, data *DockerHubSearchResult) { } } -// SearchDockerHub 独立函数 -func SearchDockerHub(ctx context.Context, query string, page, pageSize int, userAgent string) (*DockerHubSearchResult, error) { - if query == "" { - return nil, fmt.Errorf("query 不能为空") - } - cacheKey := fmt.Sprintf("q=%s&p=%d&ps=%d", query, page, pageSize) +// 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, nil + return cached.(*SearchResult), nil } baseURL := "https://hub.docker.com/v2/search/repositories/" @@ -80,30 +110,18 @@ func SearchDockerHub(ctx context.Context, query string, page, pageSize int, user params.Set("page", fmt.Sprintf("%d", page)) params.Set("page_size", fmt.Sprintf("%d", pageSize)) - reqURL := baseURL + "?" + params.Encode() - req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"?"+params.Encode(), nil) if err != nil { return nil, err } - if userAgent != "" { - req.Header.Set("User-Agent", userAgent) - } else { - req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; MyDockerHubClient/1.0)") - } - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) + resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("docker hub api 返回状态 %d", resp.StatusCode) - } - - var result DockerHubSearchResult + var result SearchResult if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } @@ -112,21 +130,64 @@ func SearchDockerHub(ctx context.Context, query string, page, pageSize int, user return &result, nil } -// RegisterSearchRoute 注册 /search 路由 +// getRepositoryTags 获取仓库标签信息 +func getRepositoryTags(ctx context.Context, namespace, name string, page, pageSize int) ([]TagInfo, error) { + cacheKey := fmt.Sprintf("tags:%s:%s:%d:%d", namespace, name, page, pageSize) + if cached, ok := getCachedResult(cacheKey); ok { + return cached.([]TagInfo), nil + } + + var baseURL string + if namespace == "library" { + baseURL = fmt.Sprintf("https://hub.docker.com/v2/repositories/%s/%s/tags", namespace, name) + } else { + baseURL = fmt.Sprintf("https://hub.docker.com/v2/repositories/%s/%s/tags", namespace, name) + } + + params := url.Values{} + 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 { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + 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, err + } + + setCacheResult(cacheKey, result.Results) + return result.Results, nil +} + +// RegisterSearchRoute 注册搜索相关路由 func RegisterSearchRoute(r *gin.Engine) { + // 搜索镜像 r.GET("/search", func(c *gin.Context) { query := c.Query("q") page := 1 - pageSize := 10 + 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) } - userAgent := c.GetHeader("User-Agent") - result, err := SearchDockerHub(c.Request.Context(), query, page, pageSize, userAgent) + result, err := searchDockerHub(c.Request.Context(), query, page, pageSize) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -134,4 +195,26 @@ func RegisterSearchRoute(r *gin.Engine) { c.JSON(http.StatusOK, result) }) + + // 获取标签信息 + r.GET("/tags/:namespace/:name", func(c *gin.Context) { + namespace := c.Param("namespace") + name := c.Param("name") + page := 1 + pageSize := 100 + if p := c.Query("page"); p != "" { + fmt.Sscanf(p, "%d", &page) + } + if ps := c.Query("page_size"); ps != "" { + fmt.Sscanf(ps, "%d", &pageSize) + } + + tags, err := getRepositoryTags(c.Request.Context(), namespace, name, page, pageSize) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, tags) + }) }