🎉 v1.1.0 #13
@@ -7,13 +7,13 @@
|
||||
## ✨ 特性
|
||||
|
||||
- 🐳 **Docker 镜像加速** - 单域名实现 Docker Hub、GHCR、Quay 等多个镜像仓库加速,以及优化拉取速度。
|
||||
- 🐳 **离线镜像包** - 支持批量下载离线镜像包。
|
||||
- 🐳 **离线镜像包** - 支持下载离线镜像包,流式传输加防抖设计。
|
||||
- 📁 **GitHub 文件加速** - 加速 GitHub Release、Raw 文件下载,支持`api.github.com`,脚本嵌套加速等等
|
||||
- 🤖 **AI 模型库支持** - 支持 Hugging Face 模型下载加速
|
||||
- 🛡️ **智能限流** - IP 限流保护,防止滥用
|
||||
- 🚫 **仓库审计** - 强大的自定义黑名单,白名单,同时审计镜像仓库,和GitHub仓库
|
||||
- 🔍 **镜像搜索** - 在线搜索 Docker 镜像
|
||||
- ⚡ **轻量高效** - 基于 Go 语言,单二进制文件运行,资源占用低
|
||||
- ⚡ **轻量高效** - 基于 Go 语言,单二进制文件运行,资源占用低,优雅的内存清理机制。
|
||||
- 🔧 **配置热重载** - 统一配置管理,部分配置项支持热重载,无需重启服务
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
@@ -31,13 +31,10 @@ var GlobalAccessController = &AccessController{}
|
||||
|
||||
// ParseDockerImage 解析Docker镜像名称
|
||||
func (ac *AccessController) ParseDockerImage(image string) DockerImageInfo {
|
||||
// 移除可能的协议前缀
|
||||
image = strings.TrimPrefix(image, "docker://")
|
||||
|
||||
// 分离标签
|
||||
var tag string
|
||||
if idx := strings.LastIndex(image, ":"); idx != -1 {
|
||||
// 检查是否是端口号而不是标签(包含斜杠)
|
||||
part := image[idx+1:]
|
||||
if !strings.Contains(part, "/") {
|
||||
tag = part
|
||||
@@ -48,15 +45,11 @@ func (ac *AccessController) ParseDockerImage(image string) DockerImageInfo {
|
||||
tag = "latest"
|
||||
}
|
||||
|
||||
// 分离命名空间和仓库名
|
||||
var namespace, repository string
|
||||
if strings.Contains(image, "/") {
|
||||
// 处理自定义registry的情况,如 registry.com/user/repo
|
||||
parts := strings.Split(image, "/")
|
||||
if len(parts) >= 2 {
|
||||
// 检查第一部分是否是域名(包含.)
|
||||
if strings.Contains(parts[0], ".") {
|
||||
// 跳过registry域名,取用户名和仓库名
|
||||
if len(parts) >= 3 {
|
||||
namespace = parts[1]
|
||||
repository = parts[2]
|
||||
@@ -65,13 +58,11 @@ func (ac *AccessController) ParseDockerImage(image string) DockerImageInfo {
|
||||
repository = parts[1]
|
||||
}
|
||||
} else {
|
||||
// 标准格式:user/repo
|
||||
namespace = parts[0]
|
||||
repository = parts[1]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 官方镜像,如 nginx
|
||||
namespace = "library"
|
||||
repository = image
|
||||
}
|
||||
@@ -171,7 +162,6 @@ func (ac *AccessController) matchImageInList(imageInfo DockerImageInfo, list []s
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 子仓库匹配(防止 user/repo 匹配到 user/repo-fork)
|
||||
if strings.HasPrefix(fullName, item+"/") {
|
||||
return true
|
||||
}
|
||||
@@ -185,7 +175,6 @@ func (ac *AccessController) checkList(matches, list []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// 组合用户名和仓库名,处理.git后缀
|
||||
username := strings.ToLower(strings.TrimSpace(matches[0]))
|
||||
repoName := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(matches[1], ".git")))
|
||||
fullRepo := username + "/" + repoName
|
||||
@@ -196,10 +185,7 @@ func (ac *AccessController) checkList(matches, list []string) bool {
|
||||
continue
|
||||
}
|
||||
|
||||
// 支持多种匹配模式:
|
||||
// 1. 精确匹配: "vaxilu/x-ui"
|
||||
// 2. 用户级匹配: "vaxilu/*" 或 "vaxilu"
|
||||
// 3. 前缀匹配: "vaxilu/x-ui-*"
|
||||
// 支持多种匹配模式
|
||||
if fullRepo == item {
|
||||
return true
|
||||
}
|
||||
@@ -225,15 +211,10 @@ func (ac *AccessController) checkList(matches, list []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// 🔥 Reload 热重载访问控制规则
|
||||
// Reload 热重载访问控制规则
|
||||
func (ac *AccessController) Reload() {
|
||||
ac.mu.Lock()
|
||||
defer ac.mu.Unlock()
|
||||
|
||||
// 访问控制器本身不缓存配置,每次检查时都会调用GetConfig()
|
||||
// 所以这里只需要确保锁的原子性,实际的重载在GetConfig()中完成
|
||||
// 可以在这里添加一些初始化逻辑,比如预编译正则表达式等
|
||||
|
||||
// 目前访问控制器设计为无状态的,每次检查都读取最新配置
|
||||
// 这样设计的好处是配置更新后无需额外处理,自动生效
|
||||
// 访问控制器本身不缓存配置
|
||||
}
|
||||
@@ -48,10 +48,8 @@ type AppConfig struct {
|
||||
MaxImages int `toml:"maxImages"` // 单次下载最大镜像数量限制
|
||||
} `toml:"download"`
|
||||
|
||||
// 新增:Registry映射配置
|
||||
Registries map[string]RegistryMapping `toml:"registries"`
|
||||
|
||||
// Token缓存配置
|
||||
TokenCache struct {
|
||||
Enabled bool `toml:"enabled"` // 是否启用token缓存
|
||||
DefaultTTL string `toml:"defaultTTL"` // 默认缓存时间
|
||||
@@ -64,7 +62,6 @@ var (
|
||||
isViperEnabled bool
|
||||
viperInstance *viper.Viper
|
||||
|
||||
// ✅ 配置缓存变量
|
||||
cachedConfig *AppConfig
|
||||
configCacheTime time.Time
|
||||
configCacheTTL = 5 * time.Second
|
||||
@@ -147,7 +144,6 @@ func DefaultConfig() *AppConfig {
|
||||
|
||||
// GetConfig 安全地获取配置副本
|
||||
func GetConfig() *AppConfig {
|
||||
// ✅ 快速缓存检查,减少深拷贝开销
|
||||
configCacheMutex.RLock()
|
||||
if cachedConfig != nil && time.Since(configCacheTime) < configCacheTTL {
|
||||
config := cachedConfig
|
||||
@@ -194,7 +190,6 @@ func setConfig(cfg *AppConfig) {
|
||||
defer appConfigLock.Unlock()
|
||||
appConfig = cfg
|
||||
|
||||
// ✅ 配置更新时清除缓存
|
||||
configCacheMutex.Lock()
|
||||
cachedConfig = nil
|
||||
configCacheMutex.Unlock()
|
||||
@@ -220,17 +215,13 @@ func LoadConfig() error {
|
||||
// 设置配置
|
||||
setConfig(cfg)
|
||||
|
||||
// 🔥 首次加载后启用Viper热重载
|
||||
if !isViperEnabled {
|
||||
go enableViperHotReload()
|
||||
}
|
||||
|
||||
// 配置加载成功,详细信息在启动时统一显示
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 🔥 启用Viper自动热重载
|
||||
func enableViperHotReload() {
|
||||
if isViperEnabled {
|
||||
return
|
||||
@@ -251,9 +242,7 @@ func enableViperHotReload() {
|
||||
}
|
||||
|
||||
isViperEnabled = true
|
||||
// 热重载已启用,不显示额外信息
|
||||
|
||||
// 🚀 启用文件监听
|
||||
viperInstance.WatchConfig()
|
||||
viperInstance.OnConfigChange(func(e fsnotify.Event) {
|
||||
fmt.Printf("检测到配置文件变化: %s\n", e.Name)
|
||||
@@ -261,7 +250,6 @@ func enableViperHotReload() {
|
||||
})
|
||||
}
|
||||
|
||||
// 🔥 使用Viper进行热重载
|
||||
func hotReloadWithViper() {
|
||||
start := time.Now()
|
||||
fmt.Println("🔄 自动热重载...")
|
||||
@@ -275,10 +263,8 @@ func hotReloadWithViper() {
|
||||
return
|
||||
}
|
||||
|
||||
// 从环境变量覆盖(保持原有功能)
|
||||
overrideFromEnv(cfg)
|
||||
|
||||
// 原子性更新配置
|
||||
setConfig(cfg)
|
||||
|
||||
// 异步更新受影响的组件
|
||||
@@ -288,7 +274,6 @@ func hotReloadWithViper() {
|
||||
}()
|
||||
}
|
||||
|
||||
// 🔧 更新受配置影响的组件
|
||||
func updateAffectedComponents() {
|
||||
// 重新初始化限流器
|
||||
if globalLimiter != nil {
|
||||
@@ -302,7 +287,6 @@ func updateAffectedComponents() {
|
||||
GlobalAccessController.Reload()
|
||||
}
|
||||
|
||||
// 🔥 刷新Registry配置映射
|
||||
fmt.Println("🌐 更新Registry配置映射...")
|
||||
reloadRegistryConfig()
|
||||
|
||||
@@ -310,7 +294,6 @@ func updateAffectedComponents() {
|
||||
fmt.Println("🔧 组件更新完成")
|
||||
}
|
||||
|
||||
// 🔥 重新加载Registry配置
|
||||
func reloadRegistryConfig() {
|
||||
cfg := GetConfig()
|
||||
enabledCount := 0
|
||||
@@ -324,8 +307,6 @@ func reloadRegistryConfig() {
|
||||
|
||||
fmt.Printf("🌐 Registry配置已更新: %d个启用\n", enabledCount)
|
||||
|
||||
// Registry配置是动态读取的,每次请求都会调用GetConfig()
|
||||
// 所以这里只需要简单通知,实际生效是自动的
|
||||
}
|
||||
|
||||
// overrideFromEnv 从环境变量覆盖配置
|
||||
|
||||
@@ -78,8 +78,6 @@ func initDockerProxy() {
|
||||
registry: registry,
|
||||
options: options,
|
||||
}
|
||||
|
||||
// Docker代理初始化完成
|
||||
}
|
||||
|
||||
// ProxyDockerRegistryGin 标准Docker Registry API v2代理
|
||||
@@ -105,7 +103,6 @@ func handleRegistryRequest(c *gin.Context, path string) {
|
||||
// 移除 /v2/ 前缀
|
||||
pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
|
||||
|
||||
// 🔍 新增:Registry域名检测和路由
|
||||
if registryDomain, remainingPath := registryDetector.detectRegistryDomain(pathWithoutV2); registryDomain != "" {
|
||||
if registryDetector.isRegistryEnabled(registryDomain) {
|
||||
// 设置目标Registry信息到Context
|
||||
@@ -118,7 +115,6 @@ func handleRegistryRequest(c *gin.Context, path string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 原有逻辑完全保持(零改动)
|
||||
imageName, apiType, reference := parseRegistryPath(pathWithoutV2)
|
||||
if imageName == "" || apiType == "" {
|
||||
c.String(http.StatusBadRequest, "Invalid path format")
|
||||
@@ -392,9 +388,7 @@ func (r *ResponseRecorder) Write(data []byte) (int, error) {
|
||||
return r.ResponseWriter.Write(data)
|
||||
}
|
||||
|
||||
// proxyDockerAuthOriginal Docker认证代理(原始逻辑,保持不变)
|
||||
func proxyDockerAuthOriginal(c *gin.Context) {
|
||||
// 检查是否有目标Registry域名(来自Context)
|
||||
var authURL string
|
||||
if targetDomain, exists := c.Get("target_registry_domain"); exists {
|
||||
if mapping, found := registryDetector.getRegistryMapping(targetDomain.(string)); found {
|
||||
@@ -672,17 +666,11 @@ func createUpstreamOptions(mapping RegistryMapping) []remote.Option {
|
||||
remote.WithUserAgent("hubproxy/go-containerregistry"),
|
||||
}
|
||||
|
||||
// 根据Registry类型添加特定的认证选项
|
||||
// 根据Registry类型添加特定的认证选项(方便后续扩展)
|
||||
switch mapping.AuthType {
|
||||
case "github":
|
||||
// GitHub Container Registry 通常使用匿名访问
|
||||
// 如需要认证,可在此处添加
|
||||
case "google":
|
||||
// Google Container Registry 配置
|
||||
// 如需要认证,可在此处添加
|
||||
case "quay":
|
||||
// Quay.io 配置
|
||||
// 如需要认证,可在此处添加
|
||||
}
|
||||
|
||||
return options
|
||||
|
||||
@@ -361,7 +361,7 @@ func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWr
|
||||
default:
|
||||
}
|
||||
|
||||
if err := func() error { // ✅ 匿名函数确保资源立即释放
|
||||
if err := func() error {
|
||||
digest, err := layer.Digest()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -859,7 +859,7 @@ func (is *ImageStreamer) StreamMultipleImages(ctx context.Context, imageRefs []s
|
||||
|
||||
log.Printf("处理镜像 %d/%d: %s", i+1, len(imageRefs), imageRef)
|
||||
|
||||
// ✅ 添加超时保护,防止单个镜像处理时间过长
|
||||
// 防止单个镜像处理时间过长
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
|
||||
manifest, repositories, err := is.streamSingleImageForBatch(timeoutCtx, tarWriter, imageRef, options)
|
||||
cancel()
|
||||
|
||||
27
src/main.go
27
src/main.go
@@ -46,7 +46,7 @@ var (
|
||||
}
|
||||
globalLimiter *IPRateLimiter
|
||||
|
||||
// ✅ 服务启动时间追踪
|
||||
// 服务启动时间
|
||||
serviceStartTime = time.Now()
|
||||
)
|
||||
|
||||
@@ -75,7 +75,7 @@ func main() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
router := gin.Default()
|
||||
|
||||
// ✅ 添加全局Panic恢复保护
|
||||
// 全局Panic恢复保护
|
||||
router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
|
||||
log.Printf("🚨 Panic recovered: %v", recovered)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -84,13 +84,13 @@ func main() {
|
||||
})
|
||||
}))
|
||||
|
||||
// ✅ 初始化监控端点 (优先级最高,避免中间件影响)
|
||||
// 初始化监控端点
|
||||
initHealthRoutes(router)
|
||||
|
||||
// 初始化镜像tar下载路由
|
||||
initImageTarRoutes(router)
|
||||
|
||||
// 静态文件路由(使用嵌入文件)
|
||||
// 静态文件路由
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/index.html")
|
||||
})
|
||||
@@ -120,7 +120,7 @@ func main() {
|
||||
router.Any("/v2/*path", RateLimitMiddleware(globalLimiter), ProxyDockerRegistryGin)
|
||||
|
||||
|
||||
// 注册NoRoute处理器,应用限流中间件
|
||||
// 注册NoRoute处理器
|
||||
router.NoRoute(RateLimitMiddleware(globalLimiter), handler)
|
||||
|
||||
cfg := GetConfig()
|
||||
@@ -226,7 +226,6 @@ func proxyWithRedirect(c *gin.Context, u string, redirectCount int) {
|
||||
resp.Header.Del("Referrer-Policy")
|
||||
resp.Header.Del("Strict-Transport-Security")
|
||||
|
||||
// 智能处理系统 - 自动识别需要加速的内容
|
||||
// 获取真实域名
|
||||
realHost := c.Request.Header.Get("X-Forwarded-Host")
|
||||
if realHost == "" {
|
||||
@@ -237,15 +236,11 @@ func proxyWithRedirect(c *gin.Context, u string, redirectCount int) {
|
||||
realHost = "https://" + realHost
|
||||
}
|
||||
|
||||
// 🚀 高性能预筛选:仅对.sh文件进行智能处理
|
||||
if strings.HasSuffix(strings.ToLower(u), ".sh") {
|
||||
// 检查是否为gzip压缩内容
|
||||
isGzipCompressed := resp.Header.Get("Content-Encoding") == "gzip"
|
||||
|
||||
// 仅对shell脚本使用智能处理器
|
||||
processedBody, processedSize, err := ProcessSmart(resp.Body, isGzipCompressed, realHost)
|
||||
if err != nil {
|
||||
// 优雅降级 - 处理失败时使用直接代理模式
|
||||
fmt.Printf("智能处理失败,回退到直接代理: %v\n", err)
|
||||
processedBody = resp.Body
|
||||
processedSize = 0
|
||||
@@ -253,7 +248,6 @@ func proxyWithRedirect(c *gin.Context, u string, redirectCount int) {
|
||||
|
||||
// 智能设置响应头
|
||||
if processedSize > 0 {
|
||||
// 内容被处理过,清理压缩相关头,使用chunked传输
|
||||
resp.Header.Del("Content-Length")
|
||||
resp.Header.Del("Content-Encoding")
|
||||
resp.Header.Set("Transfer-Encoding", "chunked")
|
||||
@@ -282,8 +276,6 @@ func proxyWithRedirect(c *gin.Context, u string, redirectCount int) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 🔥 非.sh文件:直接高性能流式代理,零内存消耗
|
||||
// 复制所有响应头
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
c.Header(key, value)
|
||||
@@ -302,7 +294,7 @@ func proxyWithRedirect(c *gin.Context, u string, redirectCount int) {
|
||||
|
||||
c.Status(resp.StatusCode)
|
||||
|
||||
// 直接流式转发,零内存拷贝
|
||||
// 直接流式转发
|
||||
if _, err := io.Copy(c.Writer, resp.Body); err != nil {
|
||||
fmt.Printf("直接代理失败: %v\n", err)
|
||||
}
|
||||
@@ -318,9 +310,9 @@ func checkURL(u string) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ✅ 初始化健康监控路由
|
||||
// 初始化健康监控路由
|
||||
func initHealthRoutes(router *gin.Engine) {
|
||||
// 健康检查端点 - 最轻量级,无依赖检查
|
||||
// 健康检查端点
|
||||
router.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "healthy",
|
||||
@@ -330,12 +322,11 @@ func initHealthRoutes(router *gin.Engine) {
|
||||
})
|
||||
})
|
||||
|
||||
// 就绪检查端点 - 检查关键组件状态
|
||||
// 就绪检查端点
|
||||
router.GET("/ready", func(c *gin.Context) {
|
||||
checks := make(map[string]string)
|
||||
allReady := true
|
||||
|
||||
// 检查配置状态
|
||||
if GetConfig() != nil {
|
||||
checks["config"] = "ok"
|
||||
} else {
|
||||
|
||||
@@ -16,7 +16,6 @@ var githubRegex = regexp.MustCompile(`https?://(?:github\.com|raw\.githubusercon
|
||||
func ProcessSmart(input io.ReadCloser, isCompressed bool, host string) (io.Reader, int64, error) {
|
||||
defer input.Close()
|
||||
|
||||
// 读取Shell脚本内容
|
||||
content, err := readShellContent(input, isCompressed)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("内容读取失败: %v", err)
|
||||
@@ -26,38 +25,31 @@ func ProcessSmart(input io.ReadCloser, isCompressed bool, host string) (io.Reade
|
||||
return strings.NewReader(""), 0, nil
|
||||
}
|
||||
|
||||
// Shell脚本大小检查 (10MB限制)
|
||||
if len(content) > 10*1024*1024 {
|
||||
return strings.NewReader(content), int64(len(content)), nil
|
||||
}
|
||||
|
||||
// 快速检查是否包含GitHub URL
|
||||
if !strings.Contains(content, "github.com") && !strings.Contains(content, "githubusercontent.com") {
|
||||
return strings.NewReader(content), int64(len(content)), nil
|
||||
}
|
||||
|
||||
// 执行GitHub URL替换
|
||||
processed := processGitHubURLs(content, host)
|
||||
|
||||
return strings.NewReader(processed), int64(len(processed)), nil
|
||||
}
|
||||
|
||||
// readShellContent 读取Shell脚本内容
|
||||
func readShellContent(input io.ReadCloser, isCompressed bool) (string, error) {
|
||||
var reader io.Reader = input
|
||||
|
||||
// 处理gzip压缩
|
||||
if isCompressed {
|
||||
// 读取前2字节检查gzip魔数
|
||||
peek := make([]byte, 2)
|
||||
n, err := input.Read(peek)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", fmt.Errorf("读取数据失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查gzip魔数 (0x1f, 0x8b)
|
||||
if n >= 2 && peek[0] == 0x1f && peek[1] == 0x8b {
|
||||
// 合并peek数据和剩余流
|
||||
combinedReader := io.MultiReader(bytes.NewReader(peek[:n]), input)
|
||||
gzReader, err := gzip.NewReader(combinedReader)
|
||||
if err != nil {
|
||||
@@ -66,12 +58,10 @@ func readShellContent(input io.ReadCloser, isCompressed bool) (string, error) {
|
||||
defer gzReader.Close()
|
||||
reader = gzReader
|
||||
} else {
|
||||
// 不是gzip格式,合并peek数据
|
||||
reader = io.MultiReader(bytes.NewReader(peek[:n]), input)
|
||||
}
|
||||
}
|
||||
|
||||
// 读取全部内容
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取内容失败: %v", err)
|
||||
@@ -80,7 +70,6 @@ func readShellContent(input io.ReadCloser, isCompressed bool) (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// processGitHubURLs 处理GitHub URL替换
|
||||
func processGitHubURLs(content, host string) string {
|
||||
return githubRegex.ReplaceAllStringFunc(content, func(url string) string {
|
||||
return transformURL(url, host)
|
||||
@@ -89,19 +78,15 @@ func processGitHubURLs(content, host string) string {
|
||||
|
||||
// transformURL URL转换函数
|
||||
func transformURL(url, host string) string {
|
||||
// 避免重复处理
|
||||
if strings.Contains(url, host) {
|
||||
return url
|
||||
}
|
||||
|
||||
// 协议标准化为https
|
||||
if strings.HasPrefix(url, "http://") {
|
||||
url = "https" + url[4:]
|
||||
} else if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "//") {
|
||||
url = "https://" + url
|
||||
}
|
||||
|
||||
// 清理host格式
|
||||
cleanHost := strings.TrimPrefix(host, "https://")
|
||||
cleanHost = strings.TrimPrefix(cleanHost, "http://")
|
||||
cleanHost = strings.TrimSuffix(cleanHost, "/")
|
||||
|
||||
@@ -348,7 +348,6 @@
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.navbar-container {
|
||||
padding: 0 0.5rem;
|
||||
@@ -385,7 +384,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
@@ -405,7 +403,6 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 移动端菜单样式 - 与其他页面完全一致 */
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
}
|
||||
@@ -480,7 +477,6 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 现代化导航栏 -->
|
||||
<nav class="navbar">
|
||||
<div class="navbar-container">
|
||||
<a href="/" class="logo">
|
||||
@@ -507,10 +503,8 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<main class="main">
|
||||
<div class="container">
|
||||
<!-- 页面头部 -->
|
||||
<div class="header">
|
||||
<h1 class="title">Docker离线镜像下载</h1>
|
||||
<p class="subtitle">即点即下,无需等待打包,完全符合docker load加载标准</p>
|
||||
@@ -535,7 +529,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 单镜像下载 -->
|
||||
<div class="download-section">
|
||||
<h2 class="section-title">单镜像下载</h2>
|
||||
|
||||
@@ -548,13 +541,12 @@
|
||||
type="text"
|
||||
id="imageInput"
|
||||
class="form-input"
|
||||
placeholder="例如: nginx:latest, ubuntu:20.04, redis:alpine"
|
||||
value="nginx:latest"
|
||||
placeholder="例如: nginx:alpine"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="platformInput">目标平台(可选)</label>
|
||||
<label class="form-label" for="platformInput">目标架构(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="platformInput"
|
||||
@@ -574,7 +566,6 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 批量下载 -->
|
||||
<div class="batch-section">
|
||||
<h2 class="section-title">多个镜像批量下载</h2>
|
||||
|
||||
@@ -591,7 +582,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="batchPlatformInput">目标平台(可选)</label>
|
||||
<label class="form-label" for="batchPlatformInput">目标架构(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="batchPlatformInput"
|
||||
@@ -600,7 +591,7 @@
|
||||
value="linux/amd64"
|
||||
>
|
||||
<div class="help-text">
|
||||
所有镜像将使用相同的目标平台
|
||||
所有镜像将使用相同的目标架构
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -634,7 +625,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 显示状态消息
|
||||
function showStatus(elementId, message, type = 'success') {
|
||||
const element = document.getElementById(elementId);
|
||||
element.className = `status status-${type}`;
|
||||
@@ -642,12 +632,10 @@
|
||||
element.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 隐藏状态消息
|
||||
function hideStatus(elementId) {
|
||||
document.getElementById(elementId).classList.add('hidden');
|
||||
}
|
||||
|
||||
// 设置按钮加载状态
|
||||
function setButtonLoading(btnId, textId, loadingId, loading) {
|
||||
const btn = document.getElementById(btnId);
|
||||
const text = document.getElementById(textId);
|
||||
@@ -663,13 +651,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 构建下载URL
|
||||
function buildDownloadUrl(imageName, platform = '') {
|
||||
// 将斜杠替换为下划线以适应URL路径
|
||||
const encodedImage = imageName.replace(/\//g, '_');
|
||||
let url = `/api/image/download/${encodedImage}`;
|
||||
|
||||
// 只有指定平台时才添加查询参数
|
||||
if (platform && platform.trim()) {
|
||||
url += `?platform=${encodeURIComponent(platform.trim())}`;
|
||||
}
|
||||
@@ -677,7 +662,6 @@
|
||||
return url;
|
||||
}
|
||||
|
||||
// 单镜像下载
|
||||
document.getElementById('singleForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -692,26 +676,22 @@
|
||||
hideStatus('singleStatus');
|
||||
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', true);
|
||||
|
||||
// 创建下载链接并触发下载
|
||||
const downloadUrl = buildDownloadUrl(imageName, platform);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = ''; // 让浏览器决定文件名
|
||||
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');
|
||||
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
|
||||
});
|
||||
|
||||
// 批量下载
|
||||
document.getElementById('batchForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -736,7 +716,6 @@
|
||||
images: images
|
||||
};
|
||||
|
||||
// 如果指定了平台,添加到选项中
|
||||
if (platform) {
|
||||
options.platform = platform;
|
||||
}
|
||||
@@ -754,7 +733,6 @@
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// 获取文件名
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let filename = `batch_${images.length}_images.tar`;
|
||||
|
||||
@@ -763,7 +741,6 @@
|
||||
if (matches) filename = matches[1];
|
||||
}
|
||||
|
||||
// 创建blob并下载
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
@@ -805,7 +782,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
initTheme();
|
||||
initMobileMenu();
|
||||
</script>
|
||||
|
||||
@@ -543,14 +543,13 @@
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* 移动端快速生成加速链接区域优化 */
|
||||
.input-container {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
font-size: 16px; /* 防止iOS缩放 */
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.button {
|
||||
@@ -572,7 +571,6 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- 现代化导航栏 -->
|
||||
<nav class="navbar">
|
||||
<div class="navbar-container">
|
||||
<a href="/" class="logo">
|
||||
@@ -599,10 +597,8 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<main class="main">
|
||||
<div class="container">
|
||||
<!-- Hero 区域 -->
|
||||
<div class="hero">
|
||||
<h1 class="hero-title">GitHub 文件加速</h1>
|
||||
<p class="hero-subtitle">
|
||||
@@ -610,7 +606,6 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 主功能卡片 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">
|
||||
@@ -635,7 +630,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输出区域 -->
|
||||
<div class="output-container" id="outputBlock">
|
||||
<div class="success-header">
|
||||
<span>✅</span>
|
||||
@@ -653,7 +647,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Docker 信息卡片 -->
|
||||
<div class="card docker-info">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">
|
||||
@@ -671,7 +664,6 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Docker 模态框 -->
|
||||
<div id="dockerModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<button class="close-button" id="closeModal">×</button>
|
||||
@@ -699,12 +691,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast 通知 -->
|
||||
<div id="toast" class="toast">
|
||||
链接已复制到剪贴板
|
||||
</div>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<footer class="footer">
|
||||
<a href="https://github.com/sky22333/hubproxy" target="_blank" class="github-link">
|
||||
<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor">
|
||||
@@ -823,7 +813,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
// 移动端菜单切换
|
||||
const mobileMenuToggle = document.getElementById('mobileMenuToggle');
|
||||
const navLinks = document.getElementById('navLinks');
|
||||
|
||||
@@ -832,7 +821,6 @@
|
||||
mobileMenuToggle.textContent = navLinks.classList.contains('active') ? '✕' : '☰';
|
||||
});
|
||||
|
||||
// 点击页面其他地方关闭菜单
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.navbar') && navLinks.classList.contains('active')) {
|
||||
navLinks.classList.remove('active');
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
<title>Docker镜像搜索</title>
|
||||
<link rel="icon" href="./favicon.ico">
|
||||
<style>
|
||||
/* 使用首页完全相同的颜色系统 */
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #0f172a;
|
||||
@@ -84,7 +83,6 @@
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
/* 导航栏样式 - 与首页完全一致,使用!important确保优先级 */
|
||||
.navbar {
|
||||
position: sticky !important;
|
||||
top: 0 !important;
|
||||
@@ -169,7 +167,6 @@
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
/* 主要内容区域 */
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 2rem 1rem;
|
||||
@@ -191,7 +188,6 @@
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* 重新定义基础按钮和输入框样式 */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.375rem 0.75rem;
|
||||
@@ -727,7 +723,6 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 现代化导航栏 -->
|
||||
<nav class="navbar">
|
||||
<div class="navbar-container">
|
||||
<a href="/" class="logo">
|
||||
@@ -789,7 +784,6 @@
|
||||
<div id="toast"></div>
|
||||
|
||||
<script>
|
||||
// 添加统一的格式化工具对象
|
||||
const formatUtils = {
|
||||
formatNumber(num) {
|
||||
if (num >= 1000000000) return (num / 1000000000).toFixed(1) + 'B+';
|
||||
@@ -914,7 +908,6 @@
|
||||
prevButton.disabled = currentPage <= 1;
|
||||
nextButton.disabled = currentPage >= totalPages;
|
||||
|
||||
// 添加页码显示和快速跳转
|
||||
const paginationDiv = document.querySelector('.pagination');
|
||||
let pageInfo = document.getElementById('pageInfo');
|
||||
if (!pageInfo) {
|
||||
@@ -925,11 +918,9 @@
|
||||
container.style.alignItems = 'center';
|
||||
container.style.gap = '10px';
|
||||
|
||||
// 页码显示
|
||||
const pageText = document.createElement('span');
|
||||
pageText.id = 'pageText';
|
||||
|
||||
// 跳转输入框
|
||||
const jumpInput = document.createElement('input');
|
||||
jumpInput.type = 'number';
|
||||
jumpInput.min = '1';
|
||||
@@ -941,7 +932,6 @@
|
||||
jumpInput.style.backgroundColor = 'var(--input)';
|
||||
jumpInput.style.color = 'var(--foreground)';
|
||||
|
||||
// 跳转按钮
|
||||
const jumpButton = document.createElement('button');
|
||||
jumpButton.textContent = '跳转';
|
||||
jumpButton.className = 'btn search-button';
|
||||
@@ -960,23 +950,19 @@
|
||||
container.appendChild(jumpInput);
|
||||
container.appendChild(jumpButton);
|
||||
|
||||
// 插入到分页区域
|
||||
paginationDiv.insertBefore(container, nextButton);
|
||||
pageInfo = container;
|
||||
}
|
||||
|
||||
// 更新页码显示
|
||||
const pageText = document.getElementById('pageText');
|
||||
pageText.textContent = `第 ${currentPage} / ${totalPages || 1} 页 共 ${totalPages || 1} 页`;
|
||||
|
||||
// 更新跳转输入框
|
||||
const jumpInput = document.getElementById('jumpPage');
|
||||
if (jumpInput) {
|
||||
jumpInput.max = totalPages;
|
||||
jumpInput.value = currentPage;
|
||||
}
|
||||
|
||||
// 显示或隐藏分页区域
|
||||
paginationDiv.style.display = totalPages > 1 ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
@@ -1005,13 +991,12 @@
|
||||
showLoading();
|
||||
|
||||
try {
|
||||
// 处理搜索查询
|
||||
let searchQuery = query;
|
||||
let targetRepo = '';
|
||||
if (query.includes('/')) {
|
||||
const [namespace, repo] = query.split('/');
|
||||
searchQuery = namespace; // 只使用斜杠前面的用户空间
|
||||
targetRepo = repo.toLowerCase(); // 保存目标仓库名用于排序
|
||||
searchQuery = namespace;
|
||||
targetRepo = repo.toLowerCase();
|
||||
}
|
||||
|
||||
const response = await fetch(`/search?q=${encodeURIComponent(searchQuery)}&page=${currentPage}&page_size=25`);
|
||||
@@ -1021,11 +1006,9 @@
|
||||
throw new Error(data.error || '搜索请求失败');
|
||||
}
|
||||
|
||||
// 更新总页数和分页状态
|
||||
totalPages = Math.ceil(data.count / 25);
|
||||
updatePagination();
|
||||
|
||||
// 传入目标仓库名进行排序
|
||||
displayResults(data.results, targetRepo);
|
||||
} catch (error) {
|
||||
console.error('搜索错误:', error);
|
||||
@@ -1044,9 +1027,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// 对结果进行排序
|
||||
results.sort((a, b) => {
|
||||
// 如果有目标仓库名,将匹配的排在最前面
|
||||
if (targetRepo) {
|
||||
const aName = (a.name || a.repo_name || '').toLowerCase();
|
||||
const bName = (b.name || b.repo_name || '').toLowerCase();
|
||||
@@ -1057,12 +1038,10 @@
|
||||
if (!aMatch && bMatch) return 1;
|
||||
}
|
||||
|
||||
// 其次按照官方镜像排序
|
||||
if (a.is_official !== b.is_official) {
|
||||
return b.is_official - a.is_official;
|
||||
}
|
||||
|
||||
// 最后按照拉取次数排序
|
||||
return b.pull_count - a.pull_count;
|
||||
});
|
||||
|
||||
@@ -1070,13 +1049,10 @@
|
||||
const card = document.createElement('div');
|
||||
card.className = 'result-card';
|
||||
|
||||
// 构建显示名称
|
||||
let displayName = '';
|
||||
if (result.is_official) {
|
||||
// 对于官方镜像,去掉 library/ 前缀
|
||||
displayName = (result.name || result.repo_name || '').replace('library/', '');
|
||||
} else {
|
||||
// 对于非官方镜像,显示完整路径
|
||||
const name = result.name || result.repo_name || '';
|
||||
displayName = result.namespace ? `${result.namespace}/${name}` : name;
|
||||
}
|
||||
@@ -1161,7 +1137,6 @@
|
||||
const tagList = document.getElementById('tagList');
|
||||
const namespace = currentRepo.namespace || (currentRepo.is_official ? 'library' : '');
|
||||
const name = currentRepo.name || currentRepo.repo_name || '';
|
||||
// 移除可能重复的 library/ 前缀
|
||||
const cleanName = name.replace(/^library\//, '');
|
||||
const fullRepoName = currentRepo.is_official ? cleanName : `${namespace}/${cleanName}`;
|
||||
|
||||
@@ -1194,9 +1169,7 @@
|
||||
|
||||
tagList.innerHTML = header;
|
||||
|
||||
// 存储所有标签数据供搜索使用
|
||||
window.allTags = tags;
|
||||
// 初始显示所有标签
|
||||
renderFilteredTags(tags);
|
||||
}
|
||||
|
||||
@@ -1255,24 +1228,19 @@
|
||||
if (!searchText) {
|
||||
filteredTags = window.allTags;
|
||||
} else {
|
||||
// 对标签进行评分和排序
|
||||
const scoredTags = window.allTags.map(tag => {
|
||||
const name = tag.name.toLowerCase();
|
||||
let score = 0;
|
||||
|
||||
// 完全匹配
|
||||
if (name === searchLower) {
|
||||
score += 100;
|
||||
}
|
||||
// 前缀匹配
|
||||
else if (name.startsWith(searchLower)) {
|
||||
score += 50;
|
||||
}
|
||||
// 包含匹配
|
||||
else if (name.includes(searchLower)) {
|
||||
score += 30;
|
||||
}
|
||||
// 部分匹配(按单词)
|
||||
else if (searchLower.split(/\s+/).some(word => name.includes(word))) {
|
||||
score += 10;
|
||||
}
|
||||
@@ -1280,7 +1248,6 @@
|
||||
return { tag, score };
|
||||
}).filter(item => item.score > 0);
|
||||
|
||||
// 按分数排序
|
||||
scoredTags.sort((a, b) => b.score - a.score);
|
||||
filteredTags = scoredTags.map(item => item.tag);
|
||||
}
|
||||
@@ -1304,7 +1271,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 初始加载
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const initialQuery = urlParams.get('q');
|
||||
if (initialQuery) {
|
||||
@@ -1312,7 +1278,6 @@
|
||||
performSearch();
|
||||
}
|
||||
|
||||
// 主题切换功能
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const html = document.documentElement;
|
||||
|
||||
@@ -1331,7 +1296,6 @@
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
});
|
||||
|
||||
// 移动端菜单切换
|
||||
const mobileMenuToggle = document.getElementById('mobileMenuToggle');
|
||||
const navLinks = document.getElementById('navLinks');
|
||||
|
||||
@@ -1340,7 +1304,6 @@
|
||||
mobileMenuToggle.textContent = navLinks.classList.contains('active') ? '✕' : '☰';
|
||||
});
|
||||
|
||||
// 点击页面其他地方关闭菜单
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.navbar') && navLinks.classList.contains('active')) {
|
||||
navLinks.classList.remove('active');
|
||||
@@ -1348,6 +1311,6 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</main> <!-- 关闭 main -->
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
const (
|
||||
// 清理间隔
|
||||
CleanupInterval = 10 * time.Minute
|
||||
// 最大IP缓存数量,防止内存过度占用
|
||||
MaxIPCacheSize = 10000
|
||||
)
|
||||
|
||||
@@ -30,16 +29,14 @@ type IPRateLimiter struct {
|
||||
|
||||
// rateLimiterEntry 限流器条目
|
||||
type rateLimiterEntry struct {
|
||||
limiter *rate.Limiter // 限流器
|
||||
lastAccess time.Time // 最后访问时间
|
||||
limiter *rate.Limiter
|
||||
lastAccess time.Time
|
||||
}
|
||||
|
||||
// initGlobalLimiter 初始化全局限流器
|
||||
func initGlobalLimiter() *IPRateLimiter {
|
||||
// 获取配置
|
||||
cfg := GetConfig()
|
||||
|
||||
// 解析白名单IP段
|
||||
whitelist := make([]*net.IPNet, 0, len(cfg.Security.WhiteList))
|
||||
for _, item := range cfg.Security.WhiteList {
|
||||
if item = strings.TrimSpace(item); item != "" {
|
||||
@@ -74,10 +71,9 @@ func initGlobalLimiter() *IPRateLimiter {
|
||||
// 计算速率:将 "每N小时X个请求" 转换为 "每秒Y个请求"
|
||||
ratePerSecond := rate.Limit(float64(cfg.RateLimit.RequestLimit) / (cfg.RateLimit.PeriodHours * 3600))
|
||||
|
||||
// 令牌桶容量设置为最大突发请求数,建议设为限制值的一半以允许合理突发
|
||||
burstSize := cfg.RateLimit.RequestLimit
|
||||
if burstSize < 1 {
|
||||
burstSize = 1 // 至少允许1个请求
|
||||
burstSize = 1
|
||||
}
|
||||
|
||||
limiter := &IPRateLimiter{
|
||||
@@ -92,12 +88,10 @@ func initGlobalLimiter() *IPRateLimiter {
|
||||
// 启动定期清理goroutine
|
||||
go limiter.cleanupRoutine()
|
||||
|
||||
// 限流器初始化完成,详细信息在启动时统一显示
|
||||
|
||||
return limiter
|
||||
}
|
||||
|
||||
// initLimiter 初始化限流器(保持向后兼容)
|
||||
// initLimiter 初始化限流器
|
||||
func initLimiter() {
|
||||
globalLimiter = initGlobalLimiter()
|
||||
}
|
||||
@@ -152,7 +146,6 @@ func extractIPFromAddress(address string) string {
|
||||
return address[:lastColon]
|
||||
}
|
||||
|
||||
// 如果没有端口号,直接返回
|
||||
return address
|
||||
}
|
||||
|
||||
@@ -180,23 +173,21 @@ func (i *IPRateLimiter) GetLimiter(ip string) (*rate.Limiter, bool) {
|
||||
|
||||
// 检查是否在黑名单中
|
||||
if isIPInCIDRList(cleanIP, i.blacklist) {
|
||||
return nil, false // 黑名单中的IP不允许访问
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// 检查是否在白名单中
|
||||
if isIPInCIDRList(cleanIP, i.whitelist) {
|
||||
return rate.NewLimiter(rate.Inf, i.b), true // 白名单中的IP不受限制
|
||||
return rate.NewLimiter(rate.Inf, i.b), true
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// ✅ 双重检查锁定,解决竞态条件
|
||||
i.mu.RLock()
|
||||
entry, exists := i.ips[cleanIP]
|
||||
i.mu.RUnlock()
|
||||
|
|
||||
|
||||
if exists {
|
||||
// 安全更新访问时间
|
||||
i.mu.Lock()
|
||||
if entry, stillExists := i.ips[cleanIP]; stillExists {
|
||||
entry.lastAccess = now
|
||||
@@ -206,7 +197,6 @@ func (i *IPRateLimiter) GetLimiter(ip string) (*rate.Limiter, bool) {
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
// 创建新条目时的双重检查
|
||||
i.mu.Lock()
|
||||
if entry, exists := i.ips[cleanIP]; exists {
|
||||
entry.lastAccess = now
|
||||
@@ -214,7 +204,6 @@ func (i *IPRateLimiter) GetLimiter(ip string) (*rate.Limiter, bool) {
|
||||
return entry.limiter, true
|
||||
}
|
||||
|
||||
// 创建新条目
|
||||
entry = &rateLimiterEntry{
|
||||
limiter: rate.NewLimiter(i.r, i.b),
|
||||
lastAccess: now,
|
||||
@@ -282,7 +271,6 @@ func RateLimitMiddleware(limiter *IPRateLimiter) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 允许请求继续处理
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,8 +89,6 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// HTTP客户端配置在 http_client.go 中统一管理
|
||||
|
||||
func (c *Cache) Get(key string) (interface{}, bool) {
|
||||
c.mu.RLock()
|
||||
entry, exists := c.data[key]
|
||||
@@ -114,7 +112,6 @@ func (c *Cache) Set(key string, data interface{}) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// ✅ 先清理过期项,防止内存泄漏
|
||||
now := time.Now()
|
||||
for k, v := range c.data {
|
||||
if now.Sub(v.timestamp) > cacheTTL {
|
||||
@@ -122,9 +119,8 @@ func (c *Cache) Set(key string, data interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果清理后仍然超限,批量删除最旧的条目
|
||||
if len(c.data) >= c.maxSize {
|
||||
toDelete := len(c.data) / 4 // 删除25%最旧的
|
||||
toDelete := len(c.data) / 4
|
||||
for k := range c.data {
|
||||
if toDelete <= 0 {
|
||||
break
|
||||
@@ -162,7 +158,6 @@ func init() {
|
||||
}()
|
||||
}
|
||||
|
||||
// 改进的搜索结果过滤函数
|
||||
func filterSearchResults(results []Repository, query string) []Repository {
|
||||
searchTerm := strings.ToLower(strings.TrimPrefix(query, "library/"))
|
||||
filtered := make([]Repository, 0)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
// SmartRateLimit 智能限流会话管理
|
||||
type SmartRateLimit struct {
|
||||
sessions sync.Map // IP -> *PullSession
|
||||
sessions sync.Map
|
||||
}
|
||||
|
||||
// PullSession Docker拉取会话
|
||||
@@ -20,35 +20,27 @@ type PullSession struct {
|
||||
// 全局智能限流实例
|
||||
var smartLimiter = &SmartRateLimit{}
|
||||
|
||||
// 硬编码的智能限流参数 - 无需配置管理
|
||||
const (
|
||||
// manifest请求后的活跃窗口时间
|
||||
activeWindowDuration = 3 * time.Minute
|
||||
// 活跃窗口内最大免费blob请求数(防止滥用)
|
||||
maxFreeBlobRequests = 100
|
||||
// 会话清理间隔
|
||||
sessionCleanupInterval = 10 * time.Minute
|
||||
// 会话过期时间
|
||||
sessionExpireTime = 30 * time.Minute
|
||||
)
|
||||
|
||||
func init() {
|
||||
// 启动会话清理协程
|
||||
go smartLimiter.cleanupSessions()
|
||||
}
|
||||
|
||||
// ShouldSkipRateLimit 判断是否应该跳过限流计数
|
||||
// 返回true表示跳过限流,false表示正常计入限流
|
||||
func (s *SmartRateLimit) ShouldSkipRateLimit(ip, path string) bool {
|
||||
// 提取请求类型
|
||||
requestType, _ := parseRequestInfo(path)
|
||||
|
||||
// 只对manifest和blob请求做智能处理
|
||||
if requestType != "manifests" && requestType != "blobs" {
|
||||
return false // 其他请求正常计入限流
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取或创建会话
|
||||
sessionKey := ip
|
||||
sessionInterface, _ := s.sessions.LoadOrStore(sessionKey, &PullSession{})
|
||||
session := sessionInterface.(*PullSession)
|
||||
@@ -56,35 +48,28 @@ func (s *SmartRateLimit) ShouldSkipRateLimit(ip, path string) bool {
|
||||
now := time.Now()
|
||||
|
||||
if requestType == "manifests" {
|
||||
// manifest请求:始终计入限流,但更新会话状态
|
||||
session.LastManifestTime = now
|
||||
session.RequestCount = 0 // 重置计数
|
||||
return false // manifest请求正常计入限流
|
||||
session.RequestCount = 0
|
||||
return false
|
||||
}
|
||||
|
||||
// blob请求:检查是否在活跃窗口内
|
||||
if requestType == "blobs" {
|
||||
// 检查是否在活跃拉取窗口内
|
||||
if !session.LastManifestTime.IsZero() &&
|
||||
now.Sub(session.LastManifestTime) <= activeWindowDuration {
|
||||
|
||||
// 在活跃窗口内,检查是否超过最大免费请求数
|
||||
session.RequestCount++
|
||||
if session.RequestCount <= maxFreeBlobRequests {
|
||||
return true // 跳过限流计数
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false // 正常计入限流
|
||||
return false
|
||||
}
|
||||
|
||||
// parseRequestInfo 解析请求路径,提取请求类型和镜像引用
|
||||
func parseRequestInfo(path string) (requestType, imageRef string) {
|
||||
// 清理路径前缀
|
||||
path = strings.TrimPrefix(path, "/v2/")
|
||||
|
||||
// 查找manifest或blob路径
|
||||
if idx := strings.Index(path, "/manifests/"); idx != -1 {
|
||||
return "manifests", path[:idx]
|
||||
}
|
||||
@@ -107,7 +92,6 @@ func (s *SmartRateLimit) cleanupSessions() {
|
||||
now := time.Now()
|
||||
expiredKeys := make([]string, 0)
|
||||
|
||||
// 找出过期的会话
|
||||
s.sessions.Range(func(key, value interface{}) bool {
|
||||
session := value.(*PullSession)
|
||||
if !session.LastManifestTime.IsZero() &&
|
||||
@@ -117,7 +101,6 @@ func (s *SmartRateLimit) cleanupSessions() {
|
||||
return true
|
||||
})
|
||||
|
||||
// 删除过期会话
|
||||
for _, key := range expiredKeys {
|
||||
s.sessions.Delete(key)
|
||||
}
|
||||
|
||||
@@ -21,24 +21,22 @@ type CachedItem struct {
|
||||
|
||||
// UniversalCache 通用缓存,支持Token和Manifest
|
||||
type UniversalCache struct {
|
||||
cache sync.Map // 线程安全的并发映射
|
||||
cache sync.Map
|
||||
}
|
||||
|
||||
var globalCache = &UniversalCache{}
|
||||
|
||||
// Get 获取缓存项,如果不存在或过期返回nil
|
||||
// Get 获取缓存项
|
||||
func (c *UniversalCache) Get(key string) *CachedItem {
|
||||
if v, ok := c.cache.Load(key); ok {
|
||||
if cached := v.(*CachedItem); time.Now().Before(cached.ExpiresAt) {
|
||||
return cached
|
||||
}
|
||||
// 自动清理过期项,保持内存整洁
|
||||
c.cache.Delete(key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set 设置缓存项
|
||||
func (c *UniversalCache) Set(key string, data []byte, contentType string, headers map[string]string, ttl time.Duration) {
|
||||
c.cache.Store(key, &CachedItem{
|
||||
Data: data,
|
||||
@@ -48,7 +46,6 @@ func (c *UniversalCache) Set(key string, data []byte, contentType string, header
|
||||
})
|
||||
}
|
||||
|
||||
// GetToken 获取缓存的token(向后兼容)
|
||||
func (c *UniversalCache) GetToken(key string) string {
|
||||
if item := c.Get(key); item != nil {
|
||||
return string(item.Data)
|
||||
@@ -56,29 +53,24 @@ func (c *UniversalCache) GetToken(key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetToken 设置token缓存(向后兼容)
|
||||
func (c *UniversalCache) SetToken(key, token string, ttl time.Duration) {
|
||||
c.Set(key, []byte(token), "application/json", nil, ttl)
|
||||
}
|
||||
|
||||
// buildCacheKey 构建稳定的缓存key
|
||||
func buildCacheKey(prefix, query string) string {
|
||||
// 使用MD5确保key的一致性和简洁性
|
||||
return fmt.Sprintf("%s:%x", prefix, md5.Sum([]byte(query)))
|
||||
}
|
||||
|
||||
// buildTokenCacheKey 构建token缓存key(向后兼容)
|
||||
func buildTokenCacheKey(query string) string {
|
||||
return buildCacheKey("token", query)
|
||||
}
|
||||
|
||||
// buildManifestCacheKey 构建manifest缓存key
|
||||
func buildManifestCacheKey(imageRef, reference string) string {
|
||||
key := fmt.Sprintf("%s:%s", imageRef, reference)
|
||||
return buildCacheKey("manifest", key)
|
||||
}
|
||||
|
||||
// buildManifestCacheKeyWithPlatform 构建包含平台信息的manifest缓存key
|
||||
func buildManifestCacheKeyWithPlatform(imageRef, reference, platform string) string {
|
||||
if platform == "" {
|
||||
platform = "default"
|
||||
@@ -87,7 +79,6 @@ func buildManifestCacheKeyWithPlatform(imageRef, reference, platform string) str
|
||||
return buildCacheKey("manifest", key)
|
||||
}
|
||||
|
||||
// getManifestTTL 根据引用类型智能确定TTL
|
||||
func getManifestTTL(reference string) time.Duration {
|
||||
cfg := GetConfig()
|
||||
defaultTTL := 30 * time.Minute
|
||||
@@ -97,9 +88,7 @@ func getManifestTTL(reference string) time.Duration {
|
||||
}
|
||||
}
|
||||
|
||||
// 智能TTL策略
|
||||
if strings.HasPrefix(reference, "sha256:") {
|
||||
// immutable digest: 长期缓存
|
||||
return 24 * time.Hour
|
||||
}
|
||||
|
||||
@@ -124,9 +113,8 @@ func extractTTLFromResponse(responseBody []byte) time.Duration {
|
||||
defaultTTL := 30 * time.Minute
|
||||
|
||||
if json.Unmarshal(responseBody, &tokenResp) == nil && tokenResp.ExpiresIn > 0 {
|
||||
// 使用响应中的过期时间,但提前5分钟过期确保安全边际
|
||||
safeTTL := time.Duration(tokenResp.ExpiresIn-300) * time.Second
|
||||
if safeTTL > 5*time.Minute { // 确保至少有5分钟的缓存时间
|
||||
if safeTTL > 5*time.Minute {
|
||||
return safeTTL
|
||||
}
|
||||
}
|
||||
@@ -134,16 +122,12 @@ func extractTTLFromResponse(responseBody []byte) time.Duration {
|
||||
return defaultTTL
|
||||
}
|
||||
|
||||
// writeTokenResponse 写入token响应(向后兼容)
|
||||
func writeTokenResponse(c *gin.Context, cachedBody string) {
|
||||
// 直接返回缓存的完整响应体,保持格式一致性
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.String(200, cachedBody)
|
||||
}
|
||||
|
||||
// writeCachedResponse 写入缓存响应
|
||||
func writeCachedResponse(c *gin.Context, item *CachedItem) {
|
||||
// 设置内容类型
|
||||
if item.ContentType != "" {
|
||||
c.Header("Content-Type", item.ContentType)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user
⚠️ Potential issue
Remove unused variable assignment to avoid confusion.
The static analysis correctly identifies that the
entryvariable assigned at line 195 is never used because it's shadowed by the redeclaration at line 201. While this doesn't cause a functional issue, it could confuse future maintainers.📝 Committable suggestion
🧰 Tools
🪛 golangci-lint (1.64.8)
195-195: SA4006: this value of
entryis never used(staticcheck)
🤖 Prompt for AI Agents