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