mirror of
https://github.com/sky22333/hubproxy.git
synced 2026-08-05 03:24:57 +08:00
修复前端的某些小bug
This commit is contained in:
@@ -709,22 +709,26 @@ func formatPlatformText(platform string) string {
|
||||
func InitImageTarRoutes(router *gin.Engine) {
|
||||
imageAPI := router.Group("/api/image")
|
||||
{
|
||||
imageAPI.GET("/download/:image", handleDirectImageDownload)
|
||||
imageAPI.GET("/info/:image", handleImageInfo)
|
||||
imageAPI.GET("/download", handleDirectImageDownload)
|
||||
imageAPI.GET("/info", handleImageInfo)
|
||||
imageAPI.GET("/batch", handleSimpleBatchDownload)
|
||||
imageAPI.POST("/batch", handleSimpleBatchDownload)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveImageRef 从 query image 读取镜像引用,避免 path 段用 _ 代替 / 导致下划线歧义。
|
||||
func resolveImageRef(c *gin.Context) string {
|
||||
return strings.TrimSpace(c.Query("image"))
|
||||
}
|
||||
|
||||
// handleDirectImageDownload 处理单镜像下载
|
||||
func handleDirectImageDownload(c *gin.Context) {
|
||||
imageParam := c.Param("image")
|
||||
if imageParam == "" {
|
||||
imageRef := resolveImageRef(c)
|
||||
if imageRef == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少镜像参数"})
|
||||
return
|
||||
}
|
||||
|
||||
imageRef := strings.ReplaceAll(imageParam, "_", "/")
|
||||
platform := c.Query("platform")
|
||||
tag := c.DefaultQuery("tag", "")
|
||||
useCompressed := c.DefaultQuery("compressed", "true") == "true"
|
||||
@@ -767,11 +771,10 @@ func handleDirectImageDownload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
downloadURL := fmt.Sprintf("/api/image/download/%s?token=%s", imageParam, token)
|
||||
if tag != "" {
|
||||
downloadURL = downloadURL + "&tag=" + url.QueryEscape(tag)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"download_url": downloadURL})
|
||||
q := url.Values{}
|
||||
q.Set("image", imageRef)
|
||||
q.Set("token", token)
|
||||
c.JSON(http.StatusOK, gin.H{"download_url": "/api/image/download?" + q.Encode()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -931,13 +934,12 @@ func handleSimpleBatchDownload(c *gin.Context) {
|
||||
|
||||
// handleImageInfo 处理镜像信息查询
|
||||
func handleImageInfo(c *gin.Context) {
|
||||
imageParam := c.Param("image")
|
||||
if imageParam == "" {
|
||||
imageRef := resolveImageRef(c)
|
||||
if imageRef == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少镜像参数"})
|
||||
return
|
||||
}
|
||||
|
||||
imageRef := strings.ReplaceAll(imageParam, "_", "/")
|
||||
tag := c.DefaultQuery("tag", "latest")
|
||||
|
||||
if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") {
|
||||
|
||||
@@ -65,6 +65,26 @@ func TestGenerateContentFingerprintStable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveImageRef(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("query preserves underscores", func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/image/download?image=user/my_app:v1", nil)
|
||||
if got := resolveImageRef(c); got != "user/my_app:v1" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing image is empty", func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/image/download", nil)
|
||||
if got := resolveImageRef(c); got != "" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteDownloadErrorSkipsJSONAfterBodyStarted(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ enableFrontend = false
|
||||
func TestSingleImageDownloadPrepareReturnsURL(t *testing.T) {
|
||||
router := newTestRouter(t, "")
|
||||
|
||||
w := performRequest(router, http.MethodGet, "/api/image/download/nginx?mode=prepare", "")
|
||||
w := performRequest(router, http.MethodGet, "/api/image/download?image=nginx&mode=prepare", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -93,7 +93,10 @@ func TestSingleImageDownloadPrepareReturnsURL(t *testing.T) {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(got.DownloadURL, "/api/image/download/nginx?token=") {
|
||||
if !strings.Contains(got.DownloadURL, "image=nginx") || !strings.Contains(got.DownloadURL, "token=") {
|
||||
t.Fatalf("download_url = %q", got.DownloadURL)
|
||||
}
|
||||
if !strings.HasPrefix(got.DownloadURL, "/api/image/download?") {
|
||||
t.Fatalf("download_url = %q", got.DownloadURL)
|
||||
}
|
||||
}
|
||||
|
||||
23
src/public/images.html
vendored
23
src/public/images.html
vendored
@@ -555,7 +555,6 @@
|
||||
<a href="/" class="nav-link">🚀 GitHub加速</a>
|
||||
<a href="/images.html" class="nav-link active">🐳 离线镜像下载</a>
|
||||
<a href="/search.html" class="nav-link">🔍 镜像搜索</a>
|
||||
<a href="https://gitee.com/if-the-wind/github-hosts/raw/main/hosts" target="_blank" class="nav-link">📄 Hosts</a>
|
||||
|
||||
<button class="theme-toggle" id="themeToggle">
|
||||
🌙
|
||||
@@ -729,28 +728,22 @@
|
||||
}
|
||||
|
||||
function buildDownloadUrl(imageName, platform = '', useCompressed = true, mode = '') {
|
||||
const encodedImage = imageName.replace(/\//g, '_');
|
||||
let url = `/api/image/download/${encodedImage}`;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('image', imageName);
|
||||
if (platform && platform.trim()) {
|
||||
params.append('platform', platform.trim());
|
||||
params.set('platform', platform.trim());
|
||||
}
|
||||
params.append('compressed', useCompressed.toString());
|
||||
params.set('compressed', useCompressed.toString());
|
||||
if (mode) {
|
||||
params.append('mode', mode);
|
||||
params.set('mode', mode);
|
||||
}
|
||||
|
||||
if (params.toString()) {
|
||||
url += '?' + params.toString();
|
||||
}
|
||||
|
||||
return url;
|
||||
return '/api/image/download?' + params.toString();
|
||||
}
|
||||
|
||||
function buildInfoUrl(imageName) {
|
||||
const encodedImage = imageName.replace(/\//g, '_');
|
||||
return `/api/image/info/${encodedImage}`;
|
||||
const params = new URLSearchParams();
|
||||
params.set('image', imageName);
|
||||
return '/api/image/info?' + params.toString();
|
||||
}
|
||||
|
||||
async function preflightImageDownload(imageName) {
|
||||
|
||||
3
src/public/index.html
vendored
3
src/public/index.html
vendored
@@ -587,7 +587,6 @@
|
||||
<a href="/" class="nav-link active">🚀 GitHub加速</a>
|
||||
<a href="/images.html" class="nav-link">🐳 离线镜像下载</a>
|
||||
<a href="/search.html" class="nav-link">🔍 镜像搜索</a>
|
||||
<a href="https://gitee.com/if-the-wind/github-hosts/raw/main/hosts" target="_blank" class="nav-link">📄 Hosts</a>
|
||||
|
||||
<button class="theme-toggle" id="themeToggle">
|
||||
🌙
|
||||
@@ -774,6 +773,8 @@
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast('链接已复制到剪贴板');
|
||||
}).catch(() => {
|
||||
showToast('复制失败');
|
||||
});
|
||||
} else {
|
||||
const range = document.createRange();
|
||||
|
||||
86
src/public/search.html
vendored
86
src/public/search.html
vendored
@@ -740,7 +740,6 @@
|
||||
<a href="/" class="nav-link">🚀 GitHub加速</a>
|
||||
<a href="/images.html" class="nav-link">🐳 离线镜像下载</a>
|
||||
<a href="/search.html" class="nav-link active">🔍 镜像搜索</a>
|
||||
<a href="https://gitee.com/if-the-wind/github-hosts/raw/main/hosts" target="_blank" class="nav-link">📄 Hosts</a>
|
||||
|
||||
<button class="theme-toggle" id="themeToggle">
|
||||
🌙
|
||||
@@ -850,13 +849,22 @@
|
||||
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
let currentQuery = '';
|
||||
let currentRepo = null;
|
||||
|
||||
// 标签分页相关变量
|
||||
let currentTagPage = 1;
|
||||
let totalTagPages = 1;
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (text == null || text === undefined) return '';
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
document.getElementById('searchButton').addEventListener('click', () => {
|
||||
currentPage = 1;
|
||||
performSearch();
|
||||
@@ -887,8 +895,14 @@
|
||||
showSearchResults();
|
||||
});
|
||||
|
||||
// 使用事件委托处理分页按钮点击(避免DOM重建导致事件丢失)
|
||||
// 使用事件委托处理分页按钮与复制按钮(避免 DOM 重建丢事件 / 避免 inline onclick XSS)
|
||||
document.addEventListener('click', (e) => {
|
||||
const copyBtn = e.target.closest('button.copy-button[data-copy]');
|
||||
if (copyBtn) {
|
||||
e.preventDefault();
|
||||
copyToClipboard(copyBtn.getAttribute('data-copy') || '');
|
||||
return;
|
||||
}
|
||||
if (e.target.id === 'tagPrevPage') {
|
||||
if (currentTagPage > 1) {
|
||||
currentTagPage--;
|
||||
@@ -1068,7 +1082,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
currentQuery = query;
|
||||
showLoading();
|
||||
|
||||
try {
|
||||
@@ -1147,27 +1160,27 @@
|
||||
|
||||
const badges = [];
|
||||
if (result.is_official) badges.push('<span class="badge badge-official">官方</span>');
|
||||
if (organization) badges.push(`<span class="badge badge-organization">By ${organization}</span>`);
|
||||
if (organization) badges.push(`<span class="badge badge-organization">By ${escapeHtml(organization)}</span>`);
|
||||
|
||||
const stats = [];
|
||||
if (starCount > 0) stats.push(`<span class="meta-item">⭐ ${formatUtils.formatNumber(starCount)}</span>`);
|
||||
if (pullCount > 0) stats.push(`<span class="meta-item">⬇️ ${formatUtils.formatNumber(pullCount)}+</span>`);
|
||||
if (starCount > 0) stats.push(`<span class="meta-item">⭐ ${escapeHtml(formatUtils.formatNumber(starCount))}</span>`);
|
||||
if (pullCount > 0) stats.push(`<span class="meta-item">⬇️ ${escapeHtml(formatUtils.formatNumber(pullCount))}+</span>`);
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="result-title">
|
||||
${displayName}
|
||||
${escapeHtml(displayName)}
|
||||
${badges.join(' ')}
|
||||
</div>
|
||||
<div class="result-description">${description}</div>
|
||||
<div class="result-description">${escapeHtml(description)}</div>
|
||||
<div class="result-meta">
|
||||
<div class="meta-stats">
|
||||
${stats.join(' ')}
|
||||
${lastUpdated ? `<span class="meta-item">更新于 ${formatUtils.formatTimeAgo(lastUpdated)}</span>` : ''}
|
||||
${lastUpdated ? `<span class="meta-item">更新于 ${escapeHtml(formatUtils.formatTimeAgo(lastUpdated))}</span>` : ''}
|
||||
</div>
|
||||
${pullsLastWeek > 0 ? `
|
||||
<div class="meta-pulls">
|
||||
<div>本周拉取次数</div>
|
||||
<div class="pulls-count">${formatUtils.formatNumber(pullsLastWeek)}</div>
|
||||
<div class="pulls-count">${escapeHtml(formatUtils.formatNumber(pullsLastWeek))}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -1232,7 +1245,7 @@
|
||||
totalTagPages = currentTagPage;
|
||||
}
|
||||
|
||||
displayTags(data.tags, data.has_more);
|
||||
displayTags(data.tags);
|
||||
updateTagPagination();
|
||||
|
||||
if (namespace && name) {
|
||||
@@ -1260,39 +1273,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
function displayTags(tags, hasMore = false) {
|
||||
function displayTags(tags) {
|
||||
const tagList = document.getElementById('tagList');
|
||||
const repoInfo = parseRepositoryInfo(currentRepo);
|
||||
const { fullRepoName } = repoInfo;
|
||||
const pullCommand = `docker pull ${fullRepoName}`;
|
||||
|
||||
let header = `
|
||||
<div class="tag-header">
|
||||
<div class="tag-info">
|
||||
<div class="tag-title">
|
||||
${fullRepoName}
|
||||
${escapeHtml(fullRepoName)}
|
||||
${currentRepo.is_official ? '<span class="badge badge-official">官方</span>' : ''}
|
||||
${currentRepo.affiliation ? `<span class="badge badge-organization">By ${currentRepo.affiliation}</span>` : ''}
|
||||
${currentRepo.affiliation ? `<span class="badge badge-organization">By ${escapeHtml(currentRepo.affiliation)}</span>` : ''}
|
||||
</div>
|
||||
<div class="tag-description">${currentRepo.short_description || '暂无描述'}</div>
|
||||
<div class="tag-description">${escapeHtml(currentRepo.short_description || '暂无描述')}</div>
|
||||
<div class="tag-meta">
|
||||
${currentRepo.star_count > 0 ? `<span class="meta-item">⭐ ${formatUtils.formatNumber(currentRepo.star_count)}</span>` : ''}
|
||||
${currentRepo.pull_count > 0 ? `<span class="meta-item">⬇️ ${formatUtils.formatNumber(currentRepo.pull_count)}+</span>` : ''}
|
||||
${currentRepo.last_updated ? `<span class="meta-item">更新于 ${formatUtils.formatTimeAgo(currentRepo.last_updated)}</span>` : ''}
|
||||
${currentRepo.star_count > 0 ? `<span class="meta-item">⭐ ${escapeHtml(formatUtils.formatNumber(currentRepo.star_count))}</span>` : ''}
|
||||
${currentRepo.pull_count > 0 ? `<span class="meta-item">⬇️ ${escapeHtml(formatUtils.formatNumber(currentRepo.pull_count))}+</span>` : ''}
|
||||
${currentRepo.last_updated ? `<span class="meta-item">更新于 ${escapeHtml(formatUtils.formatTimeAgo(currentRepo.last_updated))}</span>` : ''}
|
||||
</div>
|
||||
<div class="tag-pull-command">
|
||||
docker pull ${fullRepoName}
|
||||
<button class="copy-button" onclick="copyToClipboard('docker pull ${fullRepoName}')">复制</button>
|
||||
${escapeHtml(pullCommand)}
|
||||
<button type="button" class="copy-button" data-copy="${escapeHtml(pullCommand)}">复制</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tag-search-container">
|
||||
<input type="text" class="tag-search-input" placeholder="输入关键词搜索标签,支持模糊匹配..." oninput="filterTags(this.value)">
|
||||
<button class="tag-search-clear" onclick="clearTagSearch()">×</button>
|
||||
<button type="button" class="tag-search-clear" onclick="clearTagSearch()">×</button>
|
||||
</div>
|
||||
<div id="tagsContainer"></div>
|
||||
<div class="pagination" id="tagPagination" style="display: none;">
|
||||
<button id="tagPrevPage" disabled>上一页</button>
|
||||
<button id="tagNextPage" disabled>下一页</button>
|
||||
<button type="button" id="tagPrevPage" disabled>上一页</button>
|
||||
<button type="button" id="tagNextPage" disabled>下一页</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1345,30 +1359,36 @@
|
||||
function renderTagsBatch(tags, fullRepoName, container, replaceContent = false) {
|
||||
const tagsHtml = tags.map(tag => {
|
||||
const vulnIndicators = Object.entries(tag.vulnerabilities || {})
|
||||
.map(([level, count]) => count > 0 ? `<span class="vulnerability-dot vulnerability-${level.toLowerCase()}" title="${level}: ${count}"></span>` : '')
|
||||
.map(([level, count]) => {
|
||||
if (!(count > 0)) return '';
|
||||
const safeLevel = String(level).toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
||||
if (!safeLevel) return '';
|
||||
return `<span class="vulnerability-dot vulnerability-${safeLevel}" title="${escapeHtml(level)}: ${escapeHtml(count)}"></span>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const images = tag.images || [];
|
||||
const architectures = images.map(img => {
|
||||
const arch = `${img.os}/${img.architecture}${img.variant ? '/' + img.variant : ''}`;
|
||||
const arch = `${img.os || ''}/${img.architecture || ''}${img.variant ? '/' + img.variant : ''}`;
|
||||
const size = formatUtils.formatSize(img.size);
|
||||
return `<div class="arch-item" title="大小: ${size}">${arch}</div>`;
|
||||
return `<div class="arch-item" title="大小: ${escapeHtml(size)}">${escapeHtml(arch)}</div>`;
|
||||
}).join('');
|
||||
|
||||
const pullCommand = `docker pull ${fullRepoName}:${tag.name || ''}`;
|
||||
return `
|
||||
<div class="tag-item">
|
||||
<div class="tag-name">
|
||||
${tag.name}
|
||||
${escapeHtml(tag.name)}
|
||||
${vulnIndicators ? `<div class="vulnerability-indicator">${vulnIndicators}</div>` : ''}
|
||||
</div>
|
||||
<div class="tag-meta">
|
||||
<span>最后更新: ${formatUtils.formatTimeAgo(tag.last_updated)}</span>
|
||||
${tag.last_pusher ? `<span>由 ${tag.last_pusher} 推送</span>` : ''}
|
||||
${tag.full_size ? `<span>大小: ${formatUtils.formatSize(tag.full_size)}</span>` : ''}
|
||||
<span>最后更新: ${escapeHtml(formatUtils.formatTimeAgo(tag.last_updated))}</span>
|
||||
${tag.last_pusher ? `<span>由 ${escapeHtml(tag.last_pusher)} 推送</span>` : ''}
|
||||
${tag.full_size ? `<span>大小: ${escapeHtml(formatUtils.formatSize(tag.full_size))}</span>` : ''}
|
||||
</div>
|
||||
<div class="tag-pull-command">
|
||||
docker pull ${fullRepoName}:${tag.name}
|
||||
<button class="copy-button" onclick="copyToClipboard('docker pull ${fullRepoName}:${tag.name}')">复制</button>
|
||||
${escapeHtml(pullCommand)}
|
||||
<button type="button" class="copy-button" data-copy="${escapeHtml(pullCommand)}">复制</button>
|
||||
</div>
|
||||
${architectures ? `<div class="tag-architectures">${architectures}</div>` : ''}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user