🎉 v1.1.0 #13

Merged
sky22333 merged 37 commits from test into main 2025-06-13 14:06:26 +08:00
14 changed files with 42 additions and 239 deletions
Showing only changes of commit 8ffceb7f2b - Show all commits

View File

@@ -7,13 +7,13 @@
## ✨ 特性
- 🐳 **Docker 镜像加速** - 单域名实现 Docker Hub、GHCR、Quay 等多个镜像仓库加速,以及优化拉取速度。
- 🐳 **离线镜像包** - 支持批量下载离线镜像包。
- 🐳 **离线镜像包** - 支持下载离线镜像包,流式传输加防抖设计
- 📁 **GitHub 文件加速** - 加速 GitHub Release、Raw 文件下载,支持`api.github.com`,脚本嵌套加速等等
- 🤖 **AI 模型库支持** - 支持 Hugging Face 模型下载加速
- 🛡️ **智能限流** - IP 限流保护,防止滥用
- 🚫 **仓库审计** - 强大的自定义黑名单白名单同时审计镜像仓库和GitHub仓库
- 🔍 **镜像搜索** - 在线搜索 Docker 镜像
-**轻量高效** - 基于 Go 语言,单二进制文件运行,资源占用低
-**轻量高效** - 基于 Go 语言,单二进制文件运行,资源占用低,优雅的内存清理机制。
- 🔧 **配置热重载** - 统一配置管理,部分配置项支持热重载,无需重启服务
## 🚀 快速开始

View File

@@ -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()中完成
// 可以在这里添加一些初始化逻辑,比如预编译正则表达式等
// 目前访问控制器设计为无状态的,每次检查都读取最新配置
// 这样设计的好处是配置更新后无需额外处理,自动生效
// 访问控制器本身不缓存配置
}

View File

@@ -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 从环境变量覆盖配置

View File

@@ -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

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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, "/")

View File

@@ -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>

View File

@@ -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">&times;</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');

View File

@@ -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>

View File

@@ -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()
coderabbitai[bot] commented 2025-06-13 12:00:36 +08:00 (Migrated from github.com)
Review

⚠️ Potential issue

Remove unused variable assignment to avoid confusion.

The static analysis correctly identifies that the entry variable 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.

 // ✅ 双重检查锁定,解决竞态条件
 i.mu.RLock()
-entry, exists := i.ips[cleanIP]
+_, exists := i.ips[cleanIP]
 i.mu.RUnlock()
 
 if exists {
     // 安全更新访问时间
     i.mu.Lock()
     if entry, stillExists := i.ips[cleanIP]; stillExists {
         entry.lastAccess = now
         i.mu.Unlock()
         return entry.limiter, true
     }
     i.mu.Unlock()
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    now := time.Now()
    
    // ✅ 双重检查锁定,解决竞态条件
    i.mu.RLock()
    _, exists := i.ips[cleanIP]
    i.mu.RUnlock()
    
    if exists {
        // 安全更新访问时间
        i.mu.Lock()
        if entry, stillExists := i.ips[cleanIP]; stillExists {
            entry.lastAccess = now
            i.mu.Unlock()
            return entry.limiter, true
        }
        i.mu.Unlock()
    }
🧰 Tools
🪛 golangci-lint (1.64.8)

195-195: SA4006: this value of entry is never used

(staticcheck)

🤖 Prompt for AI Agents
In src/ratelimiter.go around lines 191 to 207, the variable 'entry' is assigned
at line 195 but then shadowed by a new declaration at line 201, making the first
assignment unused and potentially confusing. To fix this, remove the initial
assignment of 'entry' at line 195 and only declare it once inside the locked
section where it is actually used, ensuring no variable shadowing occurs.
_⚠️ Potential issue_ **Remove unused variable assignment to avoid confusion.** The static analysis correctly identifies that the `entry` variable 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. ```diff // ✅ 双重检查锁定,解决竞态条件 i.mu.RLock() -entry, exists := i.ips[cleanIP] +_, exists := i.ips[cleanIP] i.mu.RUnlock() if exists { // 安全更新访问时间 i.mu.Lock() if entry, stillExists := i.ips[cleanIP]; stillExists { entry.lastAccess = now i.mu.Unlock() return entry.limiter, true } i.mu.Unlock() } ``` <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion now := time.Now() // ✅ 双重检查锁定,解决竞态条件 i.mu.RLock() _, exists := i.ips[cleanIP] i.mu.RUnlock() if exists { // 安全更新访问时间 i.mu.Lock() if entry, stillExists := i.ips[cleanIP]; stillExists { entry.lastAccess = now i.mu.Unlock() return entry.limiter, true } i.mu.Unlock() } ``` </details> <!-- suggestion_end --> <details> <summary>🧰 Tools</summary> <details> <summary>🪛 golangci-lint (1.64.8)</summary> 195-195: SA4006: this value of `entry` is never used (staticcheck) </details> </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` In src/ratelimiter.go around lines 191 to 207, the variable 'entry' is assigned at line 195 but then shadowed by a new declaration at line 201, making the first assignment unused and potentially confusing. To fix this, remove the initial assignment of 'entry' at line 195 and only declare it once inside the locked section where it is actually used, ensuring no variable shadowing occurs. ``` </details> <!-- This is an auto-generated comment by CodeRabbit -->
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()
}
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)
}