Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5bc86ef79 | ||
|
|
23dd077f5d |
@@ -5,12 +5,15 @@ import (
|
|||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -115,6 +118,15 @@ func getUserID(c *gin.Context) string {
|
|||||||
return "ip:" + hex.EncodeToString(hash[:8])
|
return "ip:" + hex.EncodeToString(hash[:8])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getClientIdentity(c *gin.Context) (string, string) {
|
||||||
|
ip := c.ClientIP()
|
||||||
|
userAgent := c.GetHeader("User-Agent")
|
||||||
|
if userAgent == "" {
|
||||||
|
userAgent = "unknown"
|
||||||
|
}
|
||||||
|
return ip, userAgent
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
singleImageDebouncer *DownloadDebouncer
|
singleImageDebouncer *DownloadDebouncer
|
||||||
batchImageDebouncer *DownloadDebouncer
|
batchImageDebouncer *DownloadDebouncer
|
||||||
@@ -126,6 +138,98 @@ func InitDebouncer() {
|
|||||||
batchImageDebouncer = NewDownloadDebouncer(60 * time.Second)
|
batchImageDebouncer = NewDownloadDebouncer(60 * time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BatchDownloadRequest struct {
|
||||||
|
Images []string
|
||||||
|
Platform string
|
||||||
|
UseCompressedLayers bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type SingleDownloadRequest struct {
|
||||||
|
Image string
|
||||||
|
Platform string
|
||||||
|
UseCompressedLayers bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenEntry[T any] struct {
|
||||||
|
Request T
|
||||||
|
ExpiresAt time.Time
|
||||||
|
IP string
|
||||||
|
UserAgent string
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenStore[T any] struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
entries map[string]tokenEntry[T]
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadTokenTTL = 2 * time.Minute
|
||||||
|
const downloadTokenMaxEntries = 2000
|
||||||
|
|
||||||
|
func newTokenStore[T any]() *tokenStore[T] {
|
||||||
|
return &tokenStore[T]{
|
||||||
|
entries: make(map[string]tokenEntry[T]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tokenStore[T]) create(req T, ip, userAgent string) (string, error) {
|
||||||
|
tokenBytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(tokenBytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
|
||||||
|
now := time.Now()
|
||||||
|
entry := tokenEntry[T]{
|
||||||
|
Request: req,
|
||||||
|
ExpiresAt: now.Add(downloadTokenTTL),
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cleanup(now)
|
||||||
|
if len(s.entries) >= downloadTokenMaxEntries {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return "", fmt.Errorf("令牌过多,请稍后再试")
|
||||||
|
}
|
||||||
|
s.entries[token] = entry
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tokenStore[T]) consume(token, ip, userAgent string) (T, bool) {
|
||||||
|
var empty T
|
||||||
|
now := time.Now()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
entry, exists := s.entries[token]
|
||||||
|
if !exists {
|
||||||
|
return empty, false
|
||||||
|
}
|
||||||
|
if now.After(entry.ExpiresAt) {
|
||||||
|
delete(s.entries, token)
|
||||||
|
return empty, false
|
||||||
|
}
|
||||||
|
if entry.IP != ip || entry.UserAgent != userAgent {
|
||||||
|
delete(s.entries, token)
|
||||||
|
return empty, false
|
||||||
|
}
|
||||||
|
delete(s.entries, token)
|
||||||
|
return entry.Request, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tokenStore[T]) cleanup(now time.Time) {
|
||||||
|
for token, entry := range s.entries {
|
||||||
|
if now.After(entry.ExpiresAt) {
|
||||||
|
delete(s.entries, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var batchDownloadTokens = newTokenStore[BatchDownloadRequest]()
|
||||||
|
var singleDownloadTokens = newTokenStore[SingleDownloadRequest]()
|
||||||
|
|
||||||
// ImageStreamer 镜像流式下载器
|
// ImageStreamer 镜像流式下载器
|
||||||
type ImageStreamer struct {
|
type ImageStreamer struct {
|
||||||
concurrency int
|
concurrency int
|
||||||
@@ -209,6 +313,17 @@ func (is *ImageStreamer) getImageDescriptorWithPlatform(ref name.Reference, opti
|
|||||||
return remote.Get(ref, options...)
|
return remote.Get(ref, options...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setDownloadHeaders(c *gin.Context, filename string, compressed bool) {
|
||||||
|
c.Header("Content-Type", "application/octet-stream")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
|
c.Header("Pragma", "no-cache")
|
||||||
|
c.Header("Expires", "0")
|
||||||
|
if compressed {
|
||||||
|
c.Header("Content-Encoding", "gzip")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// StreamImageToGin 流式响应到Gin
|
// StreamImageToGin 流式响应到Gin
|
||||||
func (is *ImageStreamer) StreamImageToGin(ctx context.Context, imageRef string, c *gin.Context, options *StreamOptions) error {
|
func (is *ImageStreamer) StreamImageToGin(ctx context.Context, imageRef string, c *gin.Context, options *StreamOptions) error {
|
||||||
if options == nil {
|
if options == nil {
|
||||||
@@ -216,12 +331,7 @@ func (is *ImageStreamer) StreamImageToGin(ctx context.Context, imageRef string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
filename := strings.ReplaceAll(imageRef, "/", "_") + ".tar"
|
filename := strings.ReplaceAll(imageRef, "/", "_") + ".tar"
|
||||||
c.Header("Content-Type", "application/octet-stream")
|
setDownloadHeaders(c, filename, options.Compression)
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
|
||||||
|
|
||||||
if options.Compression {
|
|
||||||
c.Header("Content-Encoding", "gzip")
|
|
||||||
}
|
|
||||||
|
|
||||||
return is.StreamImageToWriter(ctx, imageRef, c.Writer, options)
|
return is.StreamImageToWriter(ctx, imageRef, c.Writer, options)
|
||||||
}
|
}
|
||||||
@@ -579,6 +689,7 @@ func InitImageTarRoutes(router *gin.Engine) {
|
|||||||
{
|
{
|
||||||
imageAPI.GET("/download/:image", handleDirectImageDownload)
|
imageAPI.GET("/download/:image", handleDirectImageDownload)
|
||||||
imageAPI.GET("/info/:image", handleImageInfo)
|
imageAPI.GET("/info/:image", handleImageInfo)
|
||||||
|
imageAPI.GET("/batch", handleSimpleBatchDownload)
|
||||||
imageAPI.POST("/batch", handleSimpleBatchDownload)
|
imageAPI.POST("/batch", handleSimpleBatchDownload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -606,28 +717,73 @@ func handleDirectImageDownload(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": reason})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
userID := getUserID(c)
|
if c.Query("mode") == "prepare" {
|
||||||
contentKey := generateContentFingerprint([]string{imageRef}, platform)
|
userID := getUserID(c)
|
||||||
|
contentKey := generateContentFingerprint([]string{imageRef}, platform)
|
||||||
|
|
||||||
if !singleImageDebouncer.ShouldAllow(userID, contentKey) {
|
if !singleImageDebouncer.ShouldAllow(userID, contentKey) {
|
||||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||||
"error": "请求过于频繁,请稍后再试",
|
"error": "请求过于频繁,请稍后再试",
|
||||||
"retry_after": 5,
|
"retry_after": 5,
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ip, userAgent := getClientIdentity(c)
|
||||||
|
token, err := singleDownloadTokens.create(SingleDownloadRequest{
|
||||||
|
Image: imageRef,
|
||||||
|
Platform: platform,
|
||||||
|
UseCompressedLayers: useCompressed,
|
||||||
|
}, ip, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
|
||||||
|
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})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := c.Query("token")
|
||||||
|
if token == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少下载令牌"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ip, userAgent := getClientIdentity(c)
|
||||||
|
req, ok := singleDownloadTokens.consume(token, ip, userAgent)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效或过期的下载令牌"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Image != imageRef {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "下载令牌与镜像不匹配"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(req.Image); !allowed {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": reason})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
options := &StreamOptions{
|
options := &StreamOptions{
|
||||||
Platform: platform,
|
Platform: req.Platform,
|
||||||
Compression: false,
|
Compression: false,
|
||||||
UseCompressedLayers: useCompressed,
|
UseCompressedLayers: req.UseCompressedLayers,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
log.Printf("下载镜像: %s (平台: %s)", imageRef, formatPlatformText(platform))
|
log.Printf("下载镜像: %s (平台: %s)", req.Image, formatPlatformText(req.Platform))
|
||||||
|
|
||||||
if err := globalImageStreamer.StreamImageToGin(ctx, imageRef, c, options); err != nil {
|
if err := globalImageStreamer.StreamImageToGin(ctx, req.Image, c, options); err != nil {
|
||||||
log.Printf("镜像下载失败: %v", err)
|
log.Printf("镜像下载失败: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "镜像下载失败: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "镜像下载失败: " + err.Error()})
|
||||||
return
|
return
|
||||||
@@ -636,6 +792,51 @@ func handleDirectImageDownload(c *gin.Context) {
|
|||||||
|
|
||||||
// handleSimpleBatchDownload 处理批量下载
|
// handleSimpleBatchDownload 处理批量下载
|
||||||
func handleSimpleBatchDownload(c *gin.Context) {
|
func handleSimpleBatchDownload(c *gin.Context) {
|
||||||
|
if c.Request.Method == http.MethodGet {
|
||||||
|
token := c.Query("token")
|
||||||
|
if token == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少下载令牌"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ip, userAgent := getClientIdentity(c)
|
||||||
|
req, ok := batchDownloadTokens.consume(token, ip, userAgent)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效或过期的下载令牌"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Images) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像列表不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options := &StreamOptions{
|
||||||
|
Platform: req.Platform,
|
||||||
|
Compression: false,
|
||||||
|
UseCompressedLayers: req.UseCompressedLayers,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
log.Printf("批量下载 %d 个镜像 (平台: %s)", len(req.Images), formatPlatformText(req.Platform))
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("batch_%d_images.tar", len(req.Images))
|
||||||
|
|
||||||
|
setDownloadHeaders(c, filename, options.Compression)
|
||||||
|
|
||||||
|
if err := globalImageStreamer.StreamMultipleImages(ctx, req.Images, c.Writer, options); err != nil {
|
||||||
|
log.Printf("批量镜像下载失败: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "批量镜像下载失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Query("mode") != "prepare" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "只支持prepare模式"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Images []string `json:"images" binding:"required"`
|
Images []string `json:"images" binding:"required"`
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
@@ -651,12 +852,24 @@ func handleSimpleBatchDownload(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像列表不能为空"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像列表不能为空"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
for _, imageRef := range req.Images {
|
||||||
|
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": reason})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for i, imageRef := range req.Images {
|
for i, imageRef := range req.Images {
|
||||||
if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") {
|
if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") {
|
||||||
req.Images[i] = imageRef + ":latest"
|
req.Images[i] = imageRef + ":latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, imageRef := range req.Images {
|
||||||
|
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": reason})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cfg := config.GetConfig()
|
cfg := config.GetConfig()
|
||||||
if len(req.Images) > cfg.Download.MaxImages {
|
if len(req.Images) > cfg.Download.MaxImages {
|
||||||
@@ -682,25 +895,19 @@ func handleSimpleBatchDownload(c *gin.Context) {
|
|||||||
useCompressed = *req.UseCompressedLayers
|
useCompressed = *req.UseCompressedLayers
|
||||||
}
|
}
|
||||||
|
|
||||||
options := &StreamOptions{
|
batchReq := BatchDownloadRequest{
|
||||||
|
Images: req.Images,
|
||||||
Platform: req.Platform,
|
Platform: req.Platform,
|
||||||
Compression: false,
|
|
||||||
UseCompressedLayers: useCompressed,
|
UseCompressedLayers: useCompressed,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
ip, userAgent := getClientIdentity(c)
|
||||||
log.Printf("批量下载 %d 个镜像 (平台: %s)", len(req.Images), formatPlatformText(req.Platform))
|
token, err := batchDownloadTokens.create(batchReq, ip, userAgent)
|
||||||
|
if err != nil {
|
||||||
filename := fmt.Sprintf("batch_%d_images.tar", len(req.Images))
|
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
|
||||||
|
|
||||||
c.Header("Content-Type", "application/octet-stream")
|
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
|
||||||
|
|
||||||
if err := globalImageStreamer.StreamMultipleImages(ctx, req.Images, c.Writer, options); err != nil {
|
|
||||||
log.Printf("批量镜像下载失败: %v", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "批量镜像下载失败: " + err.Error()})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"download_url": fmt.Sprintf("/api/image/batch?token=%s", token)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleImageInfo 处理镜像信息查询
|
// handleImageInfo 处理镜像信息查询
|
||||||
@@ -723,6 +930,10 @@ func handleImageInfo(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": reason})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
contextOptions := append(globalImageStreamer.remoteOptions, remote.WithContext(ctx))
|
contextOptions := append(globalImageStreamer.remoteOptions, remote.WithContext(ctx))
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ func searchDockerHubWithDepth(ctx context.Context, query string, page, pageSize
|
|||||||
}
|
}
|
||||||
return nil, fmt.Errorf("未找到相关镜像")
|
return nil, fmt.Errorf("未找到相关镜像")
|
||||||
case http.StatusBadGateway, http.StatusServiceUnavailable:
|
case http.StatusBadGateway, http.StatusServiceUnavailable:
|
||||||
return nil, fmt.Errorf("Docker Hub服务暂时不可用,请稍后重试")
|
return nil, fmt.Errorf("docker hub 服务暂时不可用,请稍后重试")
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body))
|
return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|||||||
151
src/public/images.html
vendored
151
src/public/images.html
vendored
@@ -728,7 +728,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDownloadUrl(imageName, platform = '', useCompressed = true) {
|
function buildDownloadUrl(imageName, platform = '', useCompressed = true, mode = '') {
|
||||||
const encodedImage = imageName.replace(/\//g, '_');
|
const encodedImage = imageName.replace(/\//g, '_');
|
||||||
let url = `/api/image/download/${encodedImage}`;
|
let url = `/api/image/download/${encodedImage}`;
|
||||||
|
|
||||||
@@ -737,6 +737,9 @@
|
|||||||
params.append('platform', platform.trim());
|
params.append('platform', platform.trim());
|
||||||
}
|
}
|
||||||
params.append('compressed', useCompressed.toString());
|
params.append('compressed', useCompressed.toString());
|
||||||
|
if (mode) {
|
||||||
|
params.append('mode', mode);
|
||||||
|
}
|
||||||
|
|
||||||
if (params.toString()) {
|
if (params.toString()) {
|
||||||
url += '?' + params.toString();
|
url += '?' + params.toString();
|
||||||
@@ -745,7 +748,65 @@
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('singleForm').addEventListener('submit', function(e) {
|
function buildInfoUrl(imageName) {
|
||||||
|
const encodedImage = imageName.replace(/\//g, '_');
|
||||||
|
return `/api/image/info/${encodedImage}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preflightImageDownload(imageName) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||||
|
try {
|
||||||
|
const response = await fetch(buildInfoUrl(imageName), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = response.headers.get('Content-Type') || '';
|
||||||
|
let payload = null;
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
payload = await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { ok: false, error: (payload && payload.error) ? payload.error : '镜像预检失败' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload && payload.success === false) {
|
||||||
|
return { ok: false, error: payload.error || '镜像预检失败' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
return { ok: false, error: '预检超时,请稍后重试' };
|
||||||
|
}
|
||||||
|
return { ok: false, error: '网络错误: ' + error.message };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preflightImages(images) {
|
||||||
|
const uniqueImages = Array.from(new Set(images));
|
||||||
|
const results = await Promise.allSettled(uniqueImages.map((imageName) => preflightImageDownload(imageName)));
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
const result = results[i];
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
return { ok: false, error: '预检失败,请稍后重试' };
|
||||||
|
}
|
||||||
|
if (!result.value.ok) {
|
||||||
|
return { ok: false, error: result.value.error || '预检失败' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('singleForm').addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const imageName = document.getElementById('imageInput').value.trim();
|
const imageName = document.getElementById('imageInput').value.trim();
|
||||||
@@ -760,20 +821,50 @@
|
|||||||
hideStatus('singleStatus');
|
hideStatus('singleStatus');
|
||||||
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', true);
|
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', true);
|
||||||
|
|
||||||
const downloadUrl = buildDownloadUrl(imageName, platform, useCompressed);
|
showStatus('singleStatus', '正在准备下载...', 'success');
|
||||||
|
const preflightResult = await preflightImages([imageName]);
|
||||||
const link = document.createElement('a');
|
if (!preflightResult.ok) {
|
||||||
link.href = downloadUrl;
|
showStatus('singleStatus', preflightResult.error, 'error');
|
||||||
link.download = '';
|
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
|
||||||
link.style.display = 'none';
|
return;
|
||||||
document.body.appendChild(link);
|
}
|
||||||
|
|
||||||
link.click();
|
const prepareUrl = buildDownloadUrl(imageName, platform, useCompressed, 'prepare');
|
||||||
document.body.removeChild(link);
|
try {
|
||||||
|
const response = await fetch(prepareUrl, {
|
||||||
const platformText = platform ? ` (${platform})` : '';
|
method: 'GET',
|
||||||
showStatus('singleStatus', `开始下载 ${imageName}${platformText}`, 'success');
|
headers: {
|
||||||
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data || !data.download_url) {
|
||||||
|
showStatus('singleStatus', '下载地址生成失败', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = data.download_url;
|
||||||
|
link.download = '';
|
||||||
|
link.style.display = 'none';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
const platformText = platform ? ` (${platform})` : '';
|
||||||
|
showStatus('singleStatus', `开始下载 ${imageName}${platformText}`, 'success');
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
showStatus('singleStatus', error.error || '下载失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showStatus('singleStatus', '网络错误: ' + error.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('batchForm').addEventListener('submit', async function(e) {
|
document.getElementById('batchForm').addEventListener('submit', async function(e) {
|
||||||
@@ -808,9 +899,16 @@
|
|||||||
|
|
||||||
hideStatus('batchStatus');
|
hideStatus('batchStatus');
|
||||||
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', true);
|
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', true);
|
||||||
|
showStatus('batchStatus', '正在准备下载...', 'success');
|
||||||
|
const preflightResult = await preflightImages(images);
|
||||||
|
if (!preflightResult.ok) {
|
||||||
|
showStatus('batchStatus', preflightResult.error, 'error');
|
||||||
|
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/image/batch', {
|
const response = await fetch('/api/image/batch?mode=prepare', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -819,24 +917,19 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const contentDisposition = response.headers.get('Content-Disposition');
|
const data = await response.json();
|
||||||
let filename = `batch_${images.length}_images.tar`;
|
if (!data || !data.download_url) {
|
||||||
|
showStatus('batchStatus', '下载地址生成失败', 'error');
|
||||||
if (contentDisposition) {
|
return;
|
||||||
const matches = contentDisposition.match(/filename="(.+)"/);
|
|
||||||
if (matches) filename = matches[1];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await response.blob();
|
const url = data.download_url;
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = filename;
|
|
||||||
link.style.display = 'none';
|
link.style.display = 'none';
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
const platformText = platform ? ` (${platform})` : '';
|
const platformText = platform ? ` (${platform})` : '';
|
||||||
showStatus('batchStatus', `开始下载 ${images.length} 个镜像${platformText}`, 'success');
|
showStatus('batchStatus', `开始下载 ${images.length} 个镜像${platformText}`, 'success');
|
||||||
@@ -872,4 +965,4 @@
|
|||||||
initMobileMenu();
|
initMobileMenu();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
14
src/public/search.html
vendored
14
src/public/search.html
vendored
@@ -778,16 +778,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tag-list" id="tagList">
|
<div class="tag-list" id="tagList"></div>
|
||||||
<div class="pagination" id="tagPagination" style="display: none;">
|
|
||||||
<button id="tagPrevPage" disabled>上一页</button>
|
|
||||||
<button id="tagNextPage" disabled>下一页</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const formatUtils = {
|
const formatUtils = {
|
||||||
formatNumber(num) {
|
formatNumber(num) {
|
||||||
@@ -1047,7 +1041,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 分页更新函数
|
// 分页更新函数
|
||||||
const updateSearchPagination = () => updatePagination();
|
|
||||||
const updateTagPagination = () => updatePagination({
|
const updateTagPagination = () => updatePagination({
|
||||||
currentPage: currentTagPage,
|
currentPage: currentTagPage,
|
||||||
totalPages: totalTagPages,
|
totalPages: totalTagPages,
|
||||||
@@ -1217,9 +1210,6 @@
|
|||||||
const currentNamespace = namespace || repoInfo.namespace;
|
const currentNamespace = namespace || repoInfo.namespace;
|
||||||
const currentName = name || repoInfo.name;
|
const currentName = name || repoInfo.name;
|
||||||
|
|
||||||
// 调试日志
|
|
||||||
console.log(`loadTagPage: namespace=${currentNamespace}, name=${currentName}, page=${currentTagPage}`);
|
|
||||||
|
|
||||||
if (!currentNamespace || !currentName) {
|
if (!currentNamespace || !currentName) {
|
||||||
showToast('命名空间和镜像名称不能为空');
|
showToast('命名空间和镜像名称不能为空');
|
||||||
return;
|
return;
|
||||||
@@ -1488,4 +1478,4 @@
|
|||||||
</script>
|
</script>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user