Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bf075fcaf | ||
|
|
208a239af3 | ||
|
|
1fb97b5347 | ||
|
|
95c2e4fd68 | ||
|
|
79fa21321f | ||
|
|
c4c5993bd1 | ||
|
|
d46fd3fec4 | ||
|
|
279b48d432 | ||
|
|
61f09192bb | ||
|
|
d876809086 | ||
|
|
fe9156f878 | ||
|
|
35651e214f | ||
|
|
d373e0104d | ||
|
|
207a03a511 | ||
|
|
5bd32cd6c1 | ||
|
|
8c127a795b |
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.DS_Store
|
||||||
|
hubproxy*
|
||||||
88
README.md
88
README.md
@@ -4,6 +4,10 @@
|
|||||||
|
|
||||||
一个轻量级、高性能的多功能代理服务,提供 Docker 镜像加速、GitHub 文件加速、下载离线镜像、在线搜索 Docker 镜像等功能。
|
一个轻量级、高性能的多功能代理服务,提供 Docker 镜像加速、GitHub 文件加速、下载离线镜像、在线搜索 Docker 镜像等功能。
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://count.getloli.com/get/@sky22333.hubproxy?theme=rule34" alt="Visitors">
|
||||||
|
</p>
|
||||||
|
|
||||||
## ✨ 特性
|
## ✨ 特性
|
||||||
|
|
||||||
- 🐳 **Docker 镜像加速** - 单域名实现 Docker Hub、GHCR、Quay 等多个镜像仓库加速,流式传输优化拉取速度。
|
- 🐳 **Docker 镜像加速** - 单域名实现 Docker Hub、GHCR、Quay 等多个镜像仓库加速,流式传输优化拉取速度。
|
||||||
@@ -63,6 +67,21 @@ docker pull yourdomain.com/ghcr.io/sky22333/hubproxy
|
|||||||
# 符合Docker Registry API v2标准的仓库都支持
|
# 符合Docker Registry API v2标准的仓库都支持
|
||||||
```
|
```
|
||||||
|
|
||||||
|
当然也支持配置为全局镜像加速,在主机上新建(或编辑)`/etc/docker/daemon.json`
|
||||||
|
|
||||||
|
在 `"registry-mirrors"` 中加入域名:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registry-mirrors": [
|
||||||
|
"https://yourdomain.com"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
若已设置其他加速地址,直接并列添加后保存,
|
||||||
|
再执行 `sudo systemctl restart docker` 重启docker服务让配置生效。
|
||||||
|
|
||||||
### GitHub 文件加速
|
### GitHub 文件加速
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -71,31 +90,18 @@ https://github.com/user/repo/releases/download/v1.0.0/file.tar.gz
|
|||||||
|
|
||||||
# 加速链接
|
# 加速链接
|
||||||
https://yourdomain.com/https://github.com/user/repo/releases/download/v1.0.0/file.tar.gz
|
https://yourdomain.com/https://github.com/user/repo/releases/download/v1.0.0/file.tar.gz
|
||||||
|
|
||||||
|
# 加速下载仓库
|
||||||
|
git clone https://yourdomain.com/https://github.com/sky22333/hubproxy.git
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## ⚙️ 配置
|
## ⚙️ 配置
|
||||||
|
|
||||||
容器内的配置文件位于 `/root/config.toml`
|
<details>
|
||||||
|
<summary>config.toml 配置说明</summary>
|
||||||
|
|
||||||
脚本部署配置文件位于 `/opt/hubproxy/config.toml`
|
*此配置是默认配置,已经内置在程序中了,可以不用添加。*
|
||||||
|
|
||||||
为了IP限流能够正常运行,反向代理需要传递IP头用来获取访客真实IP,以caddy为例:
|
|
||||||
```
|
|
||||||
example.com {
|
|
||||||
reverse_proxy 127.0.0.1:5000 {
|
|
||||||
header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
|
|
||||||
header_up X-Real-IP {http.request.header.CF-Connecting-IP}
|
|
||||||
header_up X-Forwarded-Proto https
|
|
||||||
header_up X-Forwarded-Host {host}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `config.toml`配置示例
|
|
||||||
|
|
||||||
不配置也可以,程序内置有默认值
|
|
||||||
```
|
```
|
||||||
[server]
|
[server]
|
||||||
host = "0.0.0.0"
|
host = "0.0.0.0"
|
||||||
@@ -115,6 +121,7 @@ periodHours = 1.0
|
|||||||
# 白名单中的IP不受限流限制
|
# 白名单中的IP不受限流限制
|
||||||
whiteList = [
|
whiteList = [
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
|
"172.17.0.0/16",
|
||||||
"192.168.1.0/24"
|
"192.168.1.0/24"
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -138,11 +145,17 @@ blackList = [
|
|||||||
"baduser/*"
|
"baduser/*"
|
||||||
]
|
]
|
||||||
|
|
||||||
# SOCKS5代理配置,支持有用户名/密码认证和无认证模式
|
# 代理配置,支持有用户名/密码认证和无认证模式
|
||||||
# 无认证: socks5://127.0.0.1:1080
|
# 无认证: socks5://127.0.0.1:1080
|
||||||
# 有认证: socks5://username:password@127.0.0.1:1080
|
# 有认证: socks5://username:password@127.0.0.1:1080
|
||||||
|
# HTTP 代理示例
|
||||||
|
# http://username:password@127.0.0.1:7890
|
||||||
|
# SOCKS5 代理示例
|
||||||
|
# socks5://username:password@127.0.0.1:1080
|
||||||
|
# SOCKS5H 代理示例
|
||||||
|
# socks5h://username:password@127.0.0.1:1080
|
||||||
# 留空不使用代理
|
# 留空不使用代理
|
||||||
socks5 = ""
|
proxy = ""
|
||||||
|
|
||||||
[download]
|
[download]
|
||||||
# 批量下载离线镜像数量限制
|
# 批量下载离线镜像数量限制
|
||||||
@@ -186,6 +199,35 @@ enabled = true
|
|||||||
defaultTTL = "20m"
|
defaultTTL = "20m"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
容器内的配置文件位于 `/root/config.toml`
|
||||||
|
|
||||||
|
脚本部署配置文件位于 `/opt/hubproxy/config.toml`
|
||||||
|
|
||||||
|
为了IP限流能够正常运行,反向代理需要传递IP头用来获取访客真实IP,以caddy为例:
|
||||||
|
```
|
||||||
|
example.com {
|
||||||
|
reverse_proxy {
|
||||||
|
to 127.0.0.1:5000
|
||||||
|
header_up X-Real-IP {remote}
|
||||||
|
header_up X-Forwarded-For {remote}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
cloudflare CDN:
|
||||||
|
```
|
||||||
|
example.com {
|
||||||
|
reverse_proxy 127.0.0.1:5000 {
|
||||||
|
header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
|
||||||
|
header_up X-Real-IP {http.request.header.CF-Connecting-IP}
|
||||||
|
header_up X-Forwarded-Proto https
|
||||||
|
header_up X-Forwarded-Host {host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## ⚠️ 免责声明
|
## ⚠️ 免责声明
|
||||||
@@ -201,3 +243,7 @@ defaultTTL = "20m"
|
|||||||
**⭐ 如果这个项目对你有帮助,请给个 Star!⭐**
|
**⭐ 如果这个项目对你有帮助,请给个 Star!⭐**
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[](https://www.star-history.com/#sky22333/hubproxy&Date)
|
||||||
|
|||||||
@@ -85,15 +85,15 @@ func (ac *AccessController) CheckDockerAccess(image string) (allowed bool, reaso
|
|||||||
imageInfo := ac.ParseDockerImage(image)
|
imageInfo := ac.ParseDockerImage(image)
|
||||||
|
|
||||||
// 检查白名单(如果配置了白名单,则只允许白名单中的镜像)
|
// 检查白名单(如果配置了白名单,则只允许白名单中的镜像)
|
||||||
if len(cfg.Proxy.WhiteList) > 0 {
|
if len(cfg.Access.WhiteList) > 0 {
|
||||||
if !ac.matchImageInList(imageInfo, cfg.Proxy.WhiteList) {
|
if !ac.matchImageInList(imageInfo, cfg.Access.WhiteList) {
|
||||||
return false, "不在Docker镜像白名单内"
|
return false, "不在Docker镜像白名单内"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查黑名单
|
// 检查黑名单
|
||||||
if len(cfg.Proxy.BlackList) > 0 {
|
if len(cfg.Access.BlackList) > 0 {
|
||||||
if ac.matchImageInList(imageInfo, cfg.Proxy.BlackList) {
|
if ac.matchImageInList(imageInfo, cfg.Access.BlackList) {
|
||||||
return false, "Docker镜像在黑名单内"
|
return false, "Docker镜像在黑名单内"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,12 +110,12 @@ func (ac *AccessController) CheckGitHubAccess(matches []string) (allowed bool, r
|
|||||||
cfg := GetConfig()
|
cfg := GetConfig()
|
||||||
|
|
||||||
// 检查白名单
|
// 检查白名单
|
||||||
if len(cfg.Proxy.WhiteList) > 0 && !ac.checkList(matches, cfg.Proxy.WhiteList) {
|
if len(cfg.Access.WhiteList) > 0 && !ac.checkList(matches, cfg.Access.WhiteList) {
|
||||||
return false, "不在GitHub仓库白名单内"
|
return false, "不在GitHub仓库白名单内"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查黑名单
|
// 检查黑名单
|
||||||
if len(cfg.Proxy.BlackList) > 0 && ac.checkList(matches, cfg.Proxy.BlackList) {
|
if len(cfg.Access.BlackList) > 0 && ac.checkList(matches, cfg.Access.BlackList) {
|
||||||
return false, "GitHub仓库在黑名单内"
|
return false, "GitHub仓库在黑名单内"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,5 +210,3 @@ func (ac *AccessController) checkList(matches, list []string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -37,11 +37,11 @@ type AppConfig struct {
|
|||||||
BlackList []string `toml:"blackList"` // 黑名单IP/CIDR列表
|
BlackList []string `toml:"blackList"` // 黑名单IP/CIDR列表
|
||||||
} `toml:"security"`
|
} `toml:"security"`
|
||||||
|
|
||||||
Proxy struct {
|
Access struct {
|
||||||
WhiteList []string `toml:"whiteList"` // 代理白名单(仓库级别)
|
WhiteList []string `toml:"whiteList"` // 代理白名单(仓库级别)
|
||||||
BlackList []string `toml:"blackList"` // 代理黑名单(仓库级别)
|
BlackList []string `toml:"blackList"` // 代理黑名单(仓库级别)
|
||||||
Socks5 string `toml:"socks5"` // SOCKS5代理地址: socks5://[user:pass@]host:port
|
Proxy string `toml:"proxy"` // 代理地址: 支持 http/https/socks5/socks5h
|
||||||
} `toml:"proxy"`
|
} `toml:"access"`
|
||||||
|
|
||||||
Download struct {
|
Download struct {
|
||||||
MaxImages int `toml:"maxImages"` // 单次下载最大镜像数量限制
|
MaxImages int `toml:"maxImages"` // 单次下载最大镜像数量限制
|
||||||
@@ -65,6 +65,7 @@ var (
|
|||||||
configCacheMutex sync.RWMutex
|
configCacheMutex sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// todo:Refactoring is needed
|
||||||
// DefaultConfig 返回默认配置
|
// DefaultConfig 返回默认配置
|
||||||
func DefaultConfig() *AppConfig {
|
func DefaultConfig() *AppConfig {
|
||||||
return &AppConfig{
|
return &AppConfig{
|
||||||
@@ -91,14 +92,14 @@ func DefaultConfig() *AppConfig {
|
|||||||
WhiteList: []string{},
|
WhiteList: []string{},
|
||||||
BlackList: []string{},
|
BlackList: []string{},
|
||||||
},
|
},
|
||||||
Proxy: struct {
|
Access: struct {
|
||||||
WhiteList []string `toml:"whiteList"`
|
WhiteList []string `toml:"whiteList"`
|
||||||
BlackList []string `toml:"blackList"`
|
BlackList []string `toml:"blackList"`
|
||||||
Socks5 string `toml:"socks5"`
|
Proxy string `toml:"proxy"`
|
||||||
}{
|
}{
|
||||||
WhiteList: []string{},
|
WhiteList: []string{},
|
||||||
BlackList: []string{},
|
BlackList: []string{},
|
||||||
Socks5: "", // 默认不使用代理
|
Proxy: "", // 默认不使用代理
|
||||||
},
|
},
|
||||||
Download: struct {
|
Download: struct {
|
||||||
MaxImages int `toml:"maxImages"`
|
MaxImages int `toml:"maxImages"`
|
||||||
@@ -173,8 +174,8 @@ func GetConfig() *AppConfig {
|
|||||||
configCopy := *appConfig
|
configCopy := *appConfig
|
||||||
configCopy.Security.WhiteList = append([]string(nil), appConfig.Security.WhiteList...)
|
configCopy.Security.WhiteList = append([]string(nil), appConfig.Security.WhiteList...)
|
||||||
configCopy.Security.BlackList = append([]string(nil), appConfig.Security.BlackList...)
|
configCopy.Security.BlackList = append([]string(nil), appConfig.Security.BlackList...)
|
||||||
configCopy.Proxy.WhiteList = append([]string(nil), appConfig.Proxy.WhiteList...)
|
configCopy.Access.WhiteList = append([]string(nil), appConfig.Access.WhiteList...)
|
||||||
configCopy.Proxy.BlackList = append([]string(nil), appConfig.Proxy.BlackList...)
|
configCopy.Access.BlackList = append([]string(nil), appConfig.Access.BlackList...)
|
||||||
appConfigLock.RUnlock()
|
appConfigLock.RUnlock()
|
||||||
|
|
||||||
cachedConfig = &configCopy
|
cachedConfig = &configCopy
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ periodHours = 1.0
|
|||||||
# 白名单中的IP不受限流限制
|
# 白名单中的IP不受限流限制
|
||||||
whiteList = [
|
whiteList = [
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
|
"172.17.0.0/16",
|
||||||
"192.168.1.0/24"
|
"192.168.1.0/24"
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ blackList = [
|
|||||||
"192.168.100.0/24"
|
"192.168.100.0/24"
|
||||||
]
|
]
|
||||||
|
|
||||||
[proxy]
|
[access]
|
||||||
# 代理服务白名单(支持GitHub仓库和Docker镜像,支持通配符)
|
# 代理服务白名单(支持GitHub仓库和Docker镜像,支持通配符)
|
||||||
# 只允许访问白名单中的仓库/镜像,为空时不限制
|
# 只允许访问白名单中的仓库/镜像,为空时不限制
|
||||||
whiteList = []
|
whiteList = []
|
||||||
@@ -39,11 +40,17 @@ blackList = [
|
|||||||
"baduser/*"
|
"baduser/*"
|
||||||
]
|
]
|
||||||
|
|
||||||
# SOCKS5代理配置,支持有用户名/密码认证和无认证模式
|
# 代理配置,支持有用户名/密码认证和无认证模式
|
||||||
# 无认证: socks5://127.0.0.1:1080
|
# 无认证: socks5://127.0.0.1:1080
|
||||||
# 有认证: socks5://username:password@127.0.0.1:1080
|
# 有认证: socks5://username:password@127.0.0.1:1080
|
||||||
|
# HTTP 代理示例
|
||||||
|
# http://username:password@127.0.0.1:7890
|
||||||
|
# SOCKS5 代理示例
|
||||||
|
# socks5://username:password@127.0.0.1:1080
|
||||||
|
# SOCKS5H 代理示例
|
||||||
|
# socks5h://username:password@127.0.0.1:1080
|
||||||
# 留空不使用代理
|
# 留空不使用代理
|
||||||
socks5 = ""
|
proxy = ""
|
||||||
|
|
||||||
[download]
|
[download]
|
||||||
# 批量下载离线镜像数量限制
|
# 批量下载离线镜像数量限制
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/google/go-containerregistry v0.20.5
|
github.com/google/go-containerregistry v0.20.5
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3
|
github.com/pelletier/go-toml/v2 v2.2.3
|
||||||
golang.org/x/net v0.33.0
|
|
||||||
golang.org/x/time v0.11.0
|
golang.org/x/time v0.11.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,6 +43,7 @@ require (
|
|||||||
github.com/vbatts/tar-split v0.12.1 // indirect
|
github.com/vbatts/tar-split v0.12.1 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/crypto v0.32.0 // indirect
|
golang.org/x/crypto v0.32.0 // indirect
|
||||||
|
golang.org/x/net v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.14.0 // indirect
|
golang.org/x/sync v0.14.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
golang.org/x/text v0.21.0 // indirect
|
golang.org/x/text v0.21.0 // indirect
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/net/proxy"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -22,63 +18,18 @@ var (
|
|||||||
func initHTTPClients() {
|
func initHTTPClients() {
|
||||||
cfg := GetConfig()
|
cfg := GetConfig()
|
||||||
|
|
||||||
// 创建DialContext函数,支持SOCKS5代理
|
if p := cfg.Access.Proxy; p != "" {
|
||||||
createDialContext := func(timeout time.Duration) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
os.Setenv("HTTP_PROXY", p)
|
||||||
if cfg.Proxy.Socks5 == "" {
|
os.Setenv("HTTPS_PROXY", p)
|
||||||
// 没有配置代理,使用直连
|
|
||||||
dialer := &net.Dialer{
|
|
||||||
Timeout: timeout,
|
|
||||||
KeepAlive: 30 * time.Second,
|
|
||||||
}
|
}
|
||||||
return dialer.DialContext
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析SOCKS5代理URL
|
|
||||||
proxyURL, err := url.Parse(cfg.Proxy.Socks5)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("SOCKS5代理配置错误,使用直连: %v", err)
|
|
||||||
dialer := &net.Dialer{
|
|
||||||
Timeout: timeout,
|
|
||||||
KeepAlive: 30 * time.Second,
|
|
||||||
}
|
|
||||||
return dialer.DialContext
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建基础dialer
|
|
||||||
baseDialer := &net.Dialer{
|
|
||||||
Timeout: timeout,
|
|
||||||
KeepAlive: 30 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建SOCKS5代理dialer
|
|
||||||
var auth *proxy.Auth
|
|
||||||
if proxyURL.User != nil {
|
|
||||||
if password, ok := proxyURL.User.Password(); ok {
|
|
||||||
auth = &proxy.Auth{
|
|
||||||
User: proxyURL.User.Username(),
|
|
||||||
Password: password,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socks5Dialer, err := proxy.SOCKS5("tcp", proxyURL.Host, auth, baseDialer)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("创建SOCKS5代理失败,使用直连: %v", err)
|
|
||||||
return baseDialer.DialContext
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("使用SOCKS5代理: %s", proxyURL.Host)
|
|
||||||
|
|
||||||
// 返回带上下文的dial函数
|
|
||||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
||||||
return socks5Dialer.Dial(network, addr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 代理客户端配置 - 适用于大文件传输
|
// 代理客户端配置 - 适用于大文件传输
|
||||||
globalHTTPClient = &http.Client{
|
globalHTTPClient = &http.Client{
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
DialContext: createDialContext(30 * time.Second),
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
MaxIdleConns: 1000,
|
MaxIdleConns: 1000,
|
||||||
MaxIdleConnsPerHost: 1000,
|
MaxIdleConnsPerHost: 1000,
|
||||||
IdleConnTimeout: 90 * time.Second,
|
IdleConnTimeout: 90 * time.Second,
|
||||||
@@ -92,7 +43,11 @@ func initHTTPClients() {
|
|||||||
searchHTTPClient = &http.Client{
|
searchHTTPClient = &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
DialContext: createDialContext(5 * time.Second),
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
MaxIdleConns: 100,
|
MaxIdleConns: 100,
|
||||||
MaxIdleConnsPerHost: 10,
|
MaxIdleConnsPerHost: 10,
|
||||||
IdleConnTimeout: 90 * time.Second,
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
|||||||
@@ -385,7 +385,6 @@ func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWr
|
|||||||
log.Printf("已处理层 %d/%d", i+1, len(layers))
|
log.Printf("已处理层 %d/%d", i+1, len(layers))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 构建单个镜像的manifest信息
|
// 构建单个镜像的manifest信息
|
||||||
singleManifest := map[string]interface{}{
|
singleManifest := map[string]interface{}{
|
||||||
"Config": configDigest.String() + ".json",
|
"Config": configDigest.String() + ".json",
|
||||||
|
|||||||
109
src/main.go
109
src/main.go
@@ -122,7 +122,6 @@ func main() {
|
|||||||
// 注册Docker Registry代理路由
|
// 注册Docker Registry代理路由
|
||||||
router.Any("/v2/*path", ProxyDockerRegistryGin)
|
router.Any("/v2/*path", ProxyDockerRegistryGin)
|
||||||
|
|
||||||
|
|
||||||
// 注册NoRoute处理器
|
// 注册NoRoute处理器
|
||||||
router.NoRoute(handler)
|
router.NoRoute(handler)
|
||||||
|
|
||||||
@@ -144,10 +143,16 @@ func handler(c *gin.Context) {
|
|||||||
for strings.HasPrefix(rawPath, "/") {
|
for strings.HasPrefix(rawPath, "/") {
|
||||||
rawPath = strings.TrimPrefix(rawPath, "/")
|
rawPath = strings.TrimPrefix(rawPath, "/")
|
||||||
}
|
}
|
||||||
|
// 自动补全协议头
|
||||||
if !strings.HasPrefix(rawPath, "http") {
|
if !strings.HasPrefix(rawPath, "https://") {
|
||||||
c.String(http.StatusForbidden, "无效输入")
|
// 修复 http:/ 和 https:/ 的情况
|
||||||
return
|
if strings.HasPrefix(rawPath, "http:/") || strings.HasPrefix(rawPath, "https:/") {
|
||||||
|
rawPath = strings.Replace(rawPath, "http:/", "", 1)
|
||||||
|
rawPath = strings.Replace(rawPath, "https:/", "", 1)
|
||||||
|
} else if strings.HasPrefix(rawPath, "http://") {
|
||||||
|
rawPath = strings.TrimPrefix(rawPath, "http://")
|
||||||
|
}
|
||||||
|
rawPath = "https://" + rawPath
|
||||||
}
|
}
|
||||||
|
|
||||||
matches := checkURL(rawPath)
|
matches := checkURL(rawPath)
|
||||||
@@ -177,12 +182,10 @@ func handler(c *gin.Context) {
|
|||||||
proxyRequest(c, rawPath)
|
proxyRequest(c, rawPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func proxyRequest(c *gin.Context, u string) {
|
func proxyRequest(c *gin.Context, u string) {
|
||||||
proxyWithRedirect(c, u, 0)
|
proxyWithRedirect(c, u, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func proxyWithRedirect(c *gin.Context, u string, redirectCount int) {
|
func proxyWithRedirect(c *gin.Context, u string, redirectCount int) {
|
||||||
// 限制最大重定向次数,防止无限递归
|
// 限制最大重定向次数,防止无限递归
|
||||||
const maxRedirects = 20
|
const maxRedirects = 20
|
||||||
@@ -311,72 +314,50 @@ func checkURL(u string) []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化健康监控路由
|
// 简单的健康检查
|
||||||
|
func formatBeijingTime(t time.Time) string {
|
||||||
|
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if err != nil {
|
||||||
|
loc = time.FixedZone("CST", 8*3600) // 兜底时区
|
||||||
|
}
|
||||||
|
return t.In(loc).Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为可读时间
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
if d < time.Minute {
|
||||||
|
return fmt.Sprintf("%d秒", int(d.Seconds()))
|
||||||
|
} else if d < time.Hour {
|
||||||
|
return fmt.Sprintf("%d分钟%d秒", int(d.Minutes()), int(d.Seconds())%60)
|
||||||
|
} else if d < 24*time.Hour {
|
||||||
|
return fmt.Sprintf("%d小时%d分钟", int(d.Hours()), int(d.Minutes())%60)
|
||||||
|
} else {
|
||||||
|
days := int(d.Hours()) / 24
|
||||||
|
hours := int(d.Hours()) % 24
|
||||||
|
return fmt.Sprintf("%d天%d小时", days, hours)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func initHealthRoutes(router *gin.Engine) {
|
func initHealthRoutes(router *gin.Engine) {
|
||||||
// 健康检查端点
|
|
||||||
router.GET("/health", func(c *gin.Context) {
|
router.GET("/health", func(c *gin.Context) {
|
||||||
|
uptime := time.Since(serviceStartTime)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"timestamp": time.Now().Unix(),
|
"timestamp_unix": serviceStartTime.Unix(),
|
||||||
"uptime": time.Since(serviceStartTime).Seconds(),
|
"uptime_sec": uptime.Seconds(),
|
||||||
"service": "hubproxy",
|
"service": "hubproxy",
|
||||||
|
"start_time_bj": formatBeijingTime(serviceStartTime),
|
||||||
|
"uptime_human": formatDuration(uptime),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 就绪检查端点
|
|
||||||
router.GET("/ready", func(c *gin.Context) {
|
router.GET("/ready", func(c *gin.Context) {
|
||||||
checks := make(map[string]string)
|
uptime := time.Since(serviceStartTime)
|
||||||
allReady := true
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ready": true,
|
||||||
if GetConfig() != nil {
|
"timestamp_unix": time.Now().Unix(),
|
||||||
checks["config"] = "ok"
|
"uptime_sec": uptime.Seconds(),
|
||||||
} else {
|
"uptime_human": formatDuration(uptime),
|
||||||
checks["config"] = "failed"
|
|
||||||
allReady = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查全局缓存状态
|
|
||||||
if globalCache != nil {
|
|
||||||
checks["cache"] = "ok"
|
|
||||||
} else {
|
|
||||||
checks["cache"] = "failed"
|
|
||||||
allReady = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查限流器状态
|
|
||||||
if globalLimiter != nil {
|
|
||||||
checks["ratelimiter"] = "ok"
|
|
||||||
} else {
|
|
||||||
checks["ratelimiter"] = "failed"
|
|
||||||
allReady = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查镜像下载器状态
|
|
||||||
if globalImageStreamer != nil {
|
|
||||||
checks["imagestreamer"] = "ok"
|
|
||||||
} else {
|
|
||||||
checks["imagestreamer"] = "failed"
|
|
||||||
allReady = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查HTTP客户端状态
|
|
||||||
if GetGlobalHTTPClient() != nil {
|
|
||||||
checks["httpclient"] = "ok"
|
|
||||||
} else {
|
|
||||||
checks["httpclient"] = "failed"
|
|
||||||
allReady = false
|
|
||||||
}
|
|
||||||
|
|
||||||
status := http.StatusOK
|
|
||||||
if !allReady {
|
|
||||||
status = http.StatusServiceUnavailable
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(status, gin.H{
|
|
||||||
"ready": allReady,
|
|
||||||
"checks": checks,
|
|
||||||
"timestamp": time.Now().Unix(),
|
|
||||||
"uptime": time.Since(serviceStartTime).Seconds(),
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -581,7 +581,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="feature">
|
<div class="feature">
|
||||||
<span class="feature-icon">💾</span>
|
<span class="feature-icon">💾</span>
|
||||||
<span>无需打包</span>
|
<span>无需等待</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature">
|
<div class="feature">
|
||||||
<span class="feature-icon">🏗️</span>
|
<span class="feature-icon">🏗️</span>
|
||||||
@@ -642,7 +642,7 @@
|
|||||||
|
|
||||||
<form id="batchForm">
|
<form id="batchForm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="imagesTextarea">镜像列表,每行一个,会将多个镜像自动合并,符合官方标准,完全兼容docker load</label>
|
<label class="form-label" for="imagesTextarea">镜像列表,每行一个,会将多个镜像自动合并,符合官方标准,兼容docker load</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="imagesTextarea"
|
id="imagesTextarea"
|
||||||
class="textarea"
|
class="textarea"
|
||||||
|
|||||||
@@ -609,10 +609,10 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2 class="card-title">
|
<h2 class="card-title">
|
||||||
⚡ 快速生成加速链接
|
⚡ 快速转换加速链接
|
||||||
</h2>
|
</h2>
|
||||||
<p class="card-description">
|
<p class="card-description">
|
||||||
输入GitHub文件或仓库链接,自动转换加速链接,可以直接在Github域名前面加上本站域名使用。
|
输入GitHub文件链接,自动转换加速链接,可以直接在Github文件链接前加上本站域名使用。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -622,7 +622,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="input"
|
||||||
id="githubLinkInput"
|
id="githubLinkInput"
|
||||||
placeholder="请输入GitHub链接,例如:https://github.com/user/repo/releases/download/..."
|
placeholder="请输入GitHub文件链接,例如:https://github.com/user/repo/releases/download/..."
|
||||||
>
|
>
|
||||||
<button class="button button-primary" id="formatButton">
|
<button class="button button-primary" id="formatButton">
|
||||||
获取加速链接
|
获取加速链接
|
||||||
@@ -653,12 +653,12 @@
|
|||||||
🐳 Docker 镜像加速
|
🐳 Docker 镜像加速
|
||||||
</h3>
|
</h3>
|
||||||
<p class="card-description">
|
<p class="card-description">
|
||||||
支持多种Registry,在镜像名前添加本站域名即可加速下载。
|
支持多种镜像仓库,在镜像名称前添加本站域名即可加速下载。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="docker-button" id="dockerButton">
|
<button class="docker-button" id="dockerButton">
|
||||||
查看 Docker 镜像加速配置
|
查看 Docker 镜像加速使用说明
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -669,23 +669,23 @@
|
|||||||
<button class="close-button" id="closeModal">×</button>
|
<button class="close-button" id="closeModal">×</button>
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 class="modal-title">Docker 镜像加速</h2>
|
<h2 class="modal-title">Docker 镜像加速</h2>
|
||||||
<p>支持多种Registry,在镜像名前添加本站域名即可加速下载。</p>
|
<p>支持多种镜像仓库,在镜像名称前添加本站域名即可加速下载。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="domain-examples">
|
<div class="domain-examples">
|
||||||
<strong>Docker Hub 官方镜像:</strong>
|
<strong>Docker 官方镜像:</strong>
|
||||||
docker pull <span class="domain-base"></span>/nginx
|
docker pull <span class="domain-base"></span>/nginx
|
||||||
|
|
||||||
<strong>Docker Hub 第三方镜像:</strong>
|
<strong>Docker 镜像:</strong>
|
||||||
docker pull <span class="domain-base"></span>/user/image
|
docker pull <span class="domain-base"></span>/user/image
|
||||||
|
|
||||||
<strong>GitHub Container Registry:</strong>
|
<strong>ghcr.io 镜像:</strong>
|
||||||
docker pull <span class="domain-base"></span>/ghcr.io/user/image
|
docker pull <span class="domain-base"></span>/ghcr.io/user/image
|
||||||
|
|
||||||
<strong>Quay.io Registry:</strong>
|
<strong>Quay.io 镜像:</strong>
|
||||||
docker pull <span class="domain-base"></span>/quay.io/org/image
|
docker pull <span class="domain-base"></span>/quay.io/org/image
|
||||||
|
|
||||||
<strong>Kubernetes Registry:</strong>
|
<strong>K8s 镜像:</strong>
|
||||||
docker pull <span class="domain-base"></span>/registry.k8s.io/pause:3.8
|
docker pull <span class="domain-base"></span>/registry.k8s.io/pause:3.8
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -778,7 +778,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tag-list" id="tagList"></div>
|
<div class="tag-list" id="tagList">
|
||||||
|
<div class="pagination" id="tagPagination" style="display: none;">
|
||||||
|
<button id="tagPrevPage" disabled>上一页</button>
|
||||||
|
<button id="tagNextPage" disabled>下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
@@ -854,6 +859,10 @@
|
|||||||
let currentQuery = '';
|
let currentQuery = '';
|
||||||
let currentRepo = null;
|
let currentRepo = null;
|
||||||
|
|
||||||
|
// 标签分页相关变量
|
||||||
|
let currentTagPage = 1;
|
||||||
|
let totalTagPages = 1;
|
||||||
|
|
||||||
document.getElementById('searchButton').addEventListener('click', () => {
|
document.getElementById('searchButton').addEventListener('click', () => {
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
performSearch();
|
performSearch();
|
||||||
@@ -884,6 +893,21 @@
|
|||||||
showSearchResults();
|
showSearchResults();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 使用事件委托处理分页按钮点击(避免DOM重建导致事件丢失)
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'tagPrevPage') {
|
||||||
|
if (currentTagPage > 1) {
|
||||||
|
currentTagPage--;
|
||||||
|
loadTagPage();
|
||||||
|
}
|
||||||
|
} else if (e.target.id === 'tagNextPage') {
|
||||||
|
if (currentTagPage < totalTagPages) {
|
||||||
|
currentTagPage++;
|
||||||
|
loadTagPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function showLoading() {
|
function showLoading() {
|
||||||
document.querySelector('.loading').style.display = 'block';
|
document.querySelector('.loading').style.display = 'block';
|
||||||
}
|
}
|
||||||
@@ -901,70 +925,134 @@
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePagination() {
|
// 统一分页更新函数(支持搜索和标签分页)
|
||||||
const prevButton = document.getElementById('prevPage');
|
function updatePagination(config = {}) {
|
||||||
const nextButton = document.getElementById('nextPage');
|
const {
|
||||||
|
currentPage: page = currentPage,
|
||||||
|
totalPages: total = totalPages,
|
||||||
|
prefix = ''
|
||||||
|
} = config;
|
||||||
|
|
||||||
prevButton.disabled = currentPage <= 1;
|
const prevButtonId = prefix ? `${prefix}PrevPage` : 'prevPage';
|
||||||
nextButton.disabled = currentPage >= totalPages;
|
const nextButtonId = prefix ? `${prefix}NextPage` : 'nextPage';
|
||||||
|
const paginationId = prefix ? `${prefix}Pagination` : '.pagination';
|
||||||
|
|
||||||
|
const prevButton = document.getElementById(prevButtonId);
|
||||||
|
const nextButton = document.getElementById(nextButtonId);
|
||||||
|
const paginationDiv = prefix ? document.getElementById(paginationId) : document.querySelector(paginationId);
|
||||||
|
|
||||||
|
if (!prevButton || !nextButton || !paginationDiv) {
|
||||||
|
return; // 静默处理,避免控制台警告
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新按钮状态
|
||||||
|
prevButton.disabled = page <= 1;
|
||||||
|
nextButton.disabled = page >= total;
|
||||||
|
|
||||||
|
// 更新或创建页面信息
|
||||||
|
const pageInfoId = prefix ? `${prefix}PageInfo` : 'pageInfo';
|
||||||
|
let pageInfo = document.getElementById(pageInfoId);
|
||||||
|
|
||||||
const paginationDiv = document.querySelector('.pagination');
|
|
||||||
let pageInfo = document.getElementById('pageInfo');
|
|
||||||
if (!pageInfo) {
|
if (!pageInfo) {
|
||||||
|
pageInfo = createPageInfo(pageInfoId, prefix, total);
|
||||||
|
paginationDiv.insertBefore(pageInfo, nextButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePageInfo(pageInfo, page, total, prefix);
|
||||||
|
paginationDiv.style.display = total > 1 ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建页面信息元素
|
||||||
|
function createPageInfo(pageInfoId, prefix, total) {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.id = 'pageInfo';
|
container.id = pageInfoId;
|
||||||
container.style.margin = '0 10px';
|
container.style.cssText = 'margin: 0 10px; display: flex; align-items: center; gap: 10px;';
|
||||||
container.style.display = 'flex';
|
|
||||||
container.style.alignItems = 'center';
|
|
||||||
container.style.gap = '10px';
|
|
||||||
|
|
||||||
const pageText = document.createElement('span');
|
const pageText = document.createElement('span');
|
||||||
pageText.id = 'pageText';
|
pageText.id = prefix ? `${prefix}PageText` : 'pageText';
|
||||||
|
|
||||||
const jumpInput = document.createElement('input');
|
const jumpInput = document.createElement('input');
|
||||||
jumpInput.type = 'number';
|
jumpInput.type = 'number';
|
||||||
jumpInput.min = '1';
|
jumpInput.min = '1';
|
||||||
jumpInput.id = 'jumpPage';
|
jumpInput.max = prefix === 'tag' ? total : Math.min(total, 100); // 搜索页面限制100页
|
||||||
jumpInput.style.width = '60px';
|
jumpInput.id = prefix ? `${prefix}JumpPage` : 'jumpPage';
|
||||||
jumpInput.style.padding = '4px';
|
jumpInput.style.cssText = 'width: 60px; padding: 4px; border-radius: 4px; border: 1px solid var(--border); background-color: var(--input); color: var(--foreground);';
|
||||||
jumpInput.style.borderRadius = '4px';
|
|
||||||
jumpInput.style.border = '1px solid var(--border)';
|
|
||||||
jumpInput.style.backgroundColor = 'var(--input)';
|
|
||||||
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';
|
||||||
jumpButton.style.padding = '4px 8px';
|
jumpButton.style.padding = '4px 8px';
|
||||||
jumpButton.onclick = () => {
|
jumpButton.onclick = () => handlePageJump(jumpInput, prefix, total);
|
||||||
const page = parseInt(jumpInput.value);
|
|
||||||
if (page && page >= 1 && page <= totalPages) {
|
container.append(pageText, jumpInput, jumpButton);
|
||||||
currentPage = page;
|
return container;
|
||||||
performSearch();
|
}
|
||||||
|
|
||||||
|
// 更新页面信息显示
|
||||||
|
function updatePageInfo(pageInfo, page, total, prefix) {
|
||||||
|
const pageText = pageInfo.querySelector('span');
|
||||||
|
const jumpInput = pageInfo.querySelector('input');
|
||||||
|
|
||||||
|
// 标签分页显示策略:根据是否确定总页数显示不同格式
|
||||||
|
const isTagPagination = prefix === 'tag';
|
||||||
|
const maxDisplayPages = isTagPagination ? total : Math.min(total, 100);
|
||||||
|
const pageTextContent = isTagPagination
|
||||||
|
? `第 ${page} 页` + (total > page ? ` (至少 ${total} 页)` : ` (共 ${total} 页)`)
|
||||||
|
: `第 ${page} / ${maxDisplayPages} 页 共 ${maxDisplayPages} 页` + (total > 100 ? ' (最多100页)' : '');
|
||||||
|
|
||||||
|
pageText.textContent = pageTextContent;
|
||||||
|
jumpInput.max = maxDisplayPages;
|
||||||
|
jumpInput.value = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理页面跳转
|
||||||
|
function handlePageJump(jumpInput, prefix, total) {
|
||||||
|
const inputPage = parseInt(jumpInput.value);
|
||||||
|
const maxPage = prefix === 'tag' ? total : Math.min(total, 100);
|
||||||
|
if (!inputPage || inputPage < 1 || inputPage > maxPage) {
|
||||||
|
const limitText = prefix === 'tag' ? '页码' : '页码 (最多100页)';
|
||||||
|
showToast(`请输入有效的${limitText}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefix === 'tag') {
|
||||||
|
currentTagPage = inputPage;
|
||||||
|
loadTagPage();
|
||||||
} else {
|
} else {
|
||||||
showToast('请输入有效的页码');
|
currentPage = inputPage;
|
||||||
|
performSearch();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一仓库信息处理
|
||||||
|
function parseRepositoryInfo(repo) {
|
||||||
|
const namespace = repo.namespace || (repo.is_official ? 'library' : '');
|
||||||
|
let name = repo.name || repo.repo_name || '';
|
||||||
|
|
||||||
|
// 清理名称,确保不包含命名空间前缀
|
||||||
|
if (name.includes('/')) {
|
||||||
|
const parts = name.split('/');
|
||||||
|
name = parts[parts.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanName = name.replace(/^library\//, '');
|
||||||
|
const fullRepoName = repo.is_official ? cleanName : `${namespace}/${cleanName}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
namespace,
|
||||||
|
name,
|
||||||
|
cleanName,
|
||||||
|
fullRepoName
|
||||||
};
|
};
|
||||||
|
|
||||||
container.appendChild(pageText);
|
|
||||||
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 updateSearchPagination = () => updatePagination();
|
||||||
|
const updateTagPagination = () => updatePagination({
|
||||||
const jumpInput = document.getElementById('jumpPage');
|
currentPage: currentTagPage,
|
||||||
if (jumpInput) {
|
totalPages: totalTagPages,
|
||||||
jumpInput.max = totalPages;
|
prefix: 'tag'
|
||||||
jumpInput.value = currentPage;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
paginationDiv.style.display = totalPages > 1 ? 'flex' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showSearchResults() {
|
function showSearchResults() {
|
||||||
document.querySelector('.search-results').style.display = 'block';
|
document.querySelector('.search-results').style.display = 'block';
|
||||||
@@ -1006,7 +1094,7 @@
|
|||||||
throw new Error(data.error || '搜索请求失败');
|
throw new Error(data.error || '搜索请求失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
totalPages = Math.ceil(data.count / 25);
|
totalPages = Math.min(Math.ceil(data.count / 25), 100);
|
||||||
updatePagination();
|
updatePagination();
|
||||||
|
|
||||||
displayResults(data.results, targetRepo);
|
displayResults(data.results, targetRepo);
|
||||||
@@ -1108,23 +1196,58 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 内存管理
|
||||||
|
|
||||||
async function loadTags(namespace, name) {
|
async function loadTags(namespace, name) {
|
||||||
|
currentTagPage = 1;
|
||||||
|
await loadTagPage(namespace, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTagPage(namespace = null, name = null) {
|
||||||
showLoading();
|
showLoading();
|
||||||
try {
|
try {
|
||||||
if (!namespace || !name) {
|
// 如果传入了新的namespace和name,更新currentRepo
|
||||||
|
if (namespace && name) {
|
||||||
|
// 清理旧数据,防止内存泄露
|
||||||
|
cleanupOldTagData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前仓库信息
|
||||||
|
const repoInfo = parseRepositoryInfo(currentRepo);
|
||||||
|
const currentNamespace = namespace || repoInfo.namespace;
|
||||||
|
const currentName = name || repoInfo.name;
|
||||||
|
|
||||||
|
// 调试日志
|
||||||
|
console.log(`loadTagPage: namespace=${currentNamespace}, name=${currentName}, page=${currentTagPage}`);
|
||||||
|
|
||||||
|
if (!currentNamespace || !currentName) {
|
||||||
showToast('命名空间和镜像名称不能为空');
|
showToast('命名空间和镜像名称不能为空');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`/tags/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`);
|
const response = await fetch(`/tags/${encodeURIComponent(currentNamespace)}/${encodeURIComponent(currentName)}?page=${currentTagPage}&page_size=100`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
throw new Error(errorText || '获取标签信息失败');
|
throw new Error(errorText || '获取标签信息失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
displayTags(data);
|
|
||||||
|
// 改进的总页数计算:使用更准确的分页策略
|
||||||
|
if (data.has_more) {
|
||||||
|
// 如果还有更多页面,至少有当前页+1页,但可能更多
|
||||||
|
totalTagPages = Math.max(currentTagPage + 1, totalTagPages);
|
||||||
|
} else {
|
||||||
|
// 如果没有更多页面,当前页就是最后一页
|
||||||
|
totalTagPages = currentTagPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayTags(data.tags, data.has_more);
|
||||||
|
updateTagPagination();
|
||||||
|
|
||||||
|
if (namespace && name) {
|
||||||
showTagList();
|
showTagList();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载标签错误:', error);
|
console.error('加载标签错误:', error);
|
||||||
showToast(error.message || '获取标签信息失败,请稍后重试');
|
showToast(error.message || '获取标签信息失败,请稍后重试');
|
||||||
@@ -1133,12 +1256,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayTags(tags) {
|
function cleanupOldTagData() {
|
||||||
|
// 清理全局变量,释放内存
|
||||||
|
if (window.currentPageTags) {
|
||||||
|
window.currentPageTags.length = 0;
|
||||||
|
window.currentPageTags = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理DOM缓存
|
||||||
|
const tagsContainer = document.getElementById('tagsContainer');
|
||||||
|
if (tagsContainer) {
|
||||||
|
tagsContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayTags(tags, hasMore = false) {
|
||||||
const tagList = document.getElementById('tagList');
|
const tagList = document.getElementById('tagList');
|
||||||
const namespace = currentRepo.namespace || (currentRepo.is_official ? 'library' : '');
|
const repoInfo = parseRepositoryInfo(currentRepo);
|
||||||
const name = currentRepo.name || currentRepo.repo_name || '';
|
const { fullRepoName } = repoInfo;
|
||||||
const cleanName = name.replace(/^library\//, '');
|
|
||||||
const fullRepoName = currentRepo.is_official ? cleanName : `${namespace}/${cleanName}`;
|
|
||||||
|
|
||||||
let header = `
|
let header = `
|
||||||
<div class="tag-header">
|
<div class="tag-header">
|
||||||
@@ -1165,22 +1300,60 @@
|
|||||||
<button class="tag-search-clear" onclick="clearTagSearch()">×</button>
|
<button class="tag-search-clear" onclick="clearTagSearch()">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="tagsContainer"></div>
|
<div id="tagsContainer"></div>
|
||||||
|
<div class="pagination" id="tagPagination" style="display: none;">
|
||||||
|
<button id="tagPrevPage" disabled>上一页</button>
|
||||||
|
<button id="tagNextPage" disabled>下一页</button>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
tagList.innerHTML = header;
|
tagList.innerHTML = header;
|
||||||
|
|
||||||
window.allTags = tags;
|
// 存储当前页标签数据
|
||||||
|
window.currentPageTags = tags;
|
||||||
renderFilteredTags(tags);
|
renderFilteredTags(tags);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFilteredTags(filteredTags) {
|
function renderFilteredTags(filteredTags) {
|
||||||
const tagsContainer = document.getElementById('tagsContainer');
|
const tagsContainer = document.getElementById('tagsContainer');
|
||||||
const namespace = currentRepo.namespace || (currentRepo.is_official ? 'library' : '');
|
const repoInfo = parseRepositoryInfo(currentRepo);
|
||||||
const name = currentRepo.name || currentRepo.repo_name || '';
|
const { fullRepoName } = repoInfo;
|
||||||
const cleanName = name.replace(/^library\//, '');
|
|
||||||
const fullRepoName = currentRepo.is_official ? cleanName : `${namespace}/${cleanName}`;
|
|
||||||
|
|
||||||
let tagsHtml = filteredTags.map(tag => {
|
if (filteredTags.length === 0) {
|
||||||
|
tagsContainer.innerHTML = '<div class="text-center" style="padding: 20px;">未找到匹配的标签</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渐进式渲染:分批处理大数据集
|
||||||
|
const BATCH_SIZE = 50;
|
||||||
|
|
||||||
|
if (filteredTags.length <= BATCH_SIZE) {
|
||||||
|
// 小数据集:直接渲染
|
||||||
|
renderTagsBatch(filteredTags, fullRepoName, tagsContainer, true);
|
||||||
|
} else {
|
||||||
|
// 大数据集:分批渲染
|
||||||
|
tagsContainer.innerHTML = ''; // 清空容器
|
||||||
|
let currentBatch = 0;
|
||||||
|
|
||||||
|
function renderNextBatch() {
|
||||||
|
const start = currentBatch * BATCH_SIZE;
|
||||||
|
const end = Math.min(start + BATCH_SIZE, filteredTags.length);
|
||||||
|
const batch = filteredTags.slice(start, end);
|
||||||
|
|
||||||
|
renderTagsBatch(batch, fullRepoName, tagsContainer, false);
|
||||||
|
|
||||||
|
currentBatch++;
|
||||||
|
if (end < filteredTags.length) {
|
||||||
|
// 使用requestAnimationFrame确保UI响应性
|
||||||
|
requestAnimationFrame(renderNextBatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNextBatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTagsBatch(tags, fullRepoName, container, replaceContent = false) {
|
||||||
|
const tagsHtml = tags.map(tag => {
|
||||||
const vulnIndicators = Object.entries(tag.vulnerabilities || {})
|
const vulnIndicators = Object.entries(tag.vulnerabilities || {})
|
||||||
.map(([level, count]) => count > 0 ? `<span class="vulnerability-dot vulnerability-${level.toLowerCase()}" title="${level}: ${count}"></span>` : '')
|
.map(([level, count]) => count > 0 ? `<span class="vulnerability-dot vulnerability-${level.toLowerCase()}" title="${level}: ${count}"></span>` : '')
|
||||||
.join('');
|
.join('');
|
||||||
@@ -1212,23 +1385,23 @@
|
|||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
if (filteredTags.length === 0) {
|
if (replaceContent) {
|
||||||
tagsHtml = '<div class="text-center" style="padding: 20px;">未找到匹配的标签</div>';
|
container.innerHTML = tagsHtml;
|
||||||
|
} else {
|
||||||
|
container.insertAdjacentHTML('beforeend', tagsHtml);
|
||||||
}
|
}
|
||||||
|
|
||||||
tagsContainer.innerHTML = tagsHtml;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterTags(searchText) {
|
function filterTags(searchText) {
|
||||||
if (!window.allTags) return;
|
if (!window.currentPageTags) return;
|
||||||
|
|
||||||
const searchLower = searchText.toLowerCase();
|
const searchLower = searchText.toLowerCase();
|
||||||
let filteredTags;
|
let filteredTags;
|
||||||
|
|
||||||
if (!searchText) {
|
if (!searchText) {
|
||||||
filteredTags = window.allTags;
|
filteredTags = window.currentPageTags;
|
||||||
} else {
|
} else {
|
||||||
const scoredTags = window.allTags.map(tag => {
|
const scoredTags = window.currentPageTags.map(tag => {
|
||||||
const name = tag.name.toLowerCase();
|
const name = tag.name.toLowerCase();
|
||||||
let score = 0;
|
let score = 0;
|
||||||
|
|
||||||
@@ -1263,6 +1436,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function copyToClipboard(text) {
|
function copyToClipboard(text) {
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
showToast('已复制到剪贴板');
|
showToast('已复制到剪贴板');
|
||||||
|
|||||||
@@ -299,5 +299,3 @@ func RateLimitMiddleware(limiter *IPRateLimiter) gin.HandlerFunc {
|
|||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
272
src/search.go
272
src/search.go
@@ -66,13 +66,20 @@ type Image struct {
|
|||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TagPageResult 分页标签结果
|
||||||
|
type TagPageResult struct {
|
||||||
|
Tags []TagInfo `json:"tags"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
}
|
||||||
|
|
||||||
type cacheEntry struct {
|
type cacheEntry struct {
|
||||||
data interface{}
|
data interface{}
|
||||||
timestamp time.Time
|
expiresAt time.Time // 存储过期时间
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxCacheSize = 1000 // 最大缓存条目数
|
maxCacheSize = 1000 // 最大缓存条目数
|
||||||
|
maxPaginationCache = 200 // 分页缓存最大条目数
|
||||||
cacheTTL = 30 * time.Minute
|
cacheTTL = 30 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -98,7 +105,8 @@ func (c *Cache) Get(key string) (interface{}, bool) {
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
if time.Since(entry.timestamp) > cacheTTL {
|
// 比较过期时间
|
||||||
|
if time.Now().After(entry.expiresAt) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
delete(c.data, key)
|
delete(c.data, key)
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
@@ -109,40 +117,36 @@ func (c *Cache) Get(key string) (interface{}, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) Set(key string, data interface{}) {
|
func (c *Cache) Set(key string, data interface{}) {
|
||||||
|
c.SetWithTTL(key, data, cacheTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) SetWithTTL(key string, data interface{}, ttl time.Duration) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
now := time.Now()
|
// 惰性清理:仅在容量超限时清理过期项
|
||||||
for k, v := range c.data {
|
|
||||||
if now.Sub(v.timestamp) > cacheTTL {
|
|
||||||
delete(c.data, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(c.data) >= c.maxSize {
|
if len(c.data) >= c.maxSize {
|
||||||
toDelete := len(c.data) / 4
|
c.cleanupExpiredLocked()
|
||||||
for k := range c.data {
|
|
||||||
if toDelete <= 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
delete(c.data, k)
|
|
||||||
toDelete--
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算过期时间
|
||||||
c.data[key] = cacheEntry{
|
c.data[key] = cacheEntry{
|
||||||
data: data,
|
data: data,
|
||||||
timestamp: now,
|
expiresAt: time.Now().Add(ttl),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) Cleanup() {
|
func (c *Cache) Cleanup() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
c.cleanupExpiredLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupExpiredLocked 清理过期缓存(需要已持有锁)
|
||||||
|
func (c *Cache) cleanupExpiredLocked() {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for key, entry := range c.data {
|
for key, entry := range c.data {
|
||||||
if now.Sub(entry.timestamp) > cacheTTL {
|
if now.After(entry.expiresAt) {
|
||||||
delete(c.data, key)
|
delete(c.data, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,6 +156,8 @@ func (c *Cache) Cleanup() {
|
|||||||
func init() {
|
func init() {
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(5 * time.Minute)
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop() // 确保ticker资源释放
|
||||||
|
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
searchCache.Cleanup()
|
searchCache.Cleanup()
|
||||||
}
|
}
|
||||||
@@ -214,8 +220,43 @@ func filterSearchResults(results []Repository, query string) []Repository {
|
|||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeRepository 统一规范化仓库信息(消除重复逻辑)
|
||||||
|
func normalizeRepository(repo *Repository) {
|
||||||
|
if repo.IsOfficial {
|
||||||
|
repo.Namespace = "library"
|
||||||
|
if !strings.Contains(repo.Name, "/") {
|
||||||
|
repo.Name = "library/" + repo.Name
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 处理用户仓库:设置命名空间但保持Name为纯仓库名
|
||||||
|
if repo.Namespace == "" && repo.RepoOwner != "" {
|
||||||
|
repo.Namespace = repo.RepoOwner
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果Name包含斜杠,提取纯仓库名
|
||||||
|
if strings.Contains(repo.Name, "/") {
|
||||||
|
parts := strings.Split(repo.Name, "/")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
if repo.Namespace == "" {
|
||||||
|
repo.Namespace = parts[0]
|
||||||
|
}
|
||||||
|
repo.Name = parts[len(parts)-1] // 取最后部分作为仓库名
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// searchDockerHub 搜索镜像
|
// searchDockerHub 搜索镜像
|
||||||
func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*SearchResult, error) {
|
func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*SearchResult, error) {
|
||||||
|
return searchDockerHubWithDepth(ctx, query, page, pageSize, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchDockerHubWithDepth 搜索镜像(带递归深度控制)
|
||||||
|
func searchDockerHubWithDepth(ctx context.Context, query string, page, pageSize int, depth int) (*SearchResult, error) {
|
||||||
|
// 防止无限递归:最多允许1次递归调用
|
||||||
|
if depth > 1 {
|
||||||
|
return nil, fmt.Errorf("搜索请求过于复杂,请尝试更具体的关键词")
|
||||||
|
}
|
||||||
cacheKey := fmt.Sprintf("search:%s:%d:%d", query, page, pageSize)
|
cacheKey := fmt.Sprintf("search:%s:%d:%d", query, page, pageSize)
|
||||||
|
|
||||||
// 尝试从缓存获取
|
// 尝试从缓存获取
|
||||||
@@ -264,11 +305,7 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("请求Docker Hub API失败: %v", err)
|
return nil, fmt.Errorf("请求Docker Hub API失败: %v", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer safeCloseResponseBody(resp.Body, "搜索响应体")
|
||||||
if err := resp.Body.Close(); err != nil {
|
|
||||||
fmt.Printf("关闭搜索响应体失败: %v\n", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -281,8 +318,8 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se
|
|||||||
return nil, fmt.Errorf("请求过于频繁,请稍后重试")
|
return nil, fmt.Errorf("请求过于频繁,请稍后重试")
|
||||||
case http.StatusNotFound:
|
case http.StatusNotFound:
|
||||||
if isUserRepo && namespace != "" {
|
if isUserRepo && namespace != "" {
|
||||||
// 如果用户仓库搜索失败,尝试普通搜索
|
// 如果用户仓库搜索失败,尝试普通搜索(递归调用)
|
||||||
return searchDockerHub(ctx, repoName, page, pageSize)
|
return searchDockerHubWithDepth(ctx, repoName, page, pageSize, depth+1)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("未找到相关镜像")
|
return nil, fmt.Errorf("未找到相关镜像")
|
||||||
case http.StatusBadGateway, http.StatusServiceUnavailable:
|
case http.StatusBadGateway, http.StatusServiceUnavailable:
|
||||||
@@ -318,18 +355,16 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se
|
|||||||
for _, repo := range userRepos.Results {
|
for _, repo := range userRepos.Results {
|
||||||
// 如果指定了仓库名,只保留匹配的结果
|
// 如果指定了仓库名,只保留匹配的结果
|
||||||
if repoName == "" || strings.Contains(strings.ToLower(repo.Name), strings.ToLower(repoName)) {
|
if repoName == "" || strings.Contains(strings.ToLower(repo.Name), strings.ToLower(repoName)) {
|
||||||
// 确保设置正确的命名空间和名称
|
// 设置命名空间并使用统一的规范化函数
|
||||||
repo.Namespace = namespace
|
repo.Namespace = namespace
|
||||||
if !strings.Contains(repo.Name, "/") {
|
normalizeRepository(&repo)
|
||||||
repo.Name = fmt.Sprintf("%s/%s", namespace, repo.Name)
|
|
||||||
}
|
|
||||||
result.Results = append(result.Results, repo)
|
result.Results = append(result.Results, repo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果没有找到结果,尝试普通搜索
|
// 如果没有找到结果,尝试普通搜索(递归调用)
|
||||||
if len(result.Results) == 0 {
|
if len(result.Results) == 0 {
|
||||||
return searchDockerHub(ctx, repoName, page, pageSize)
|
return searchDockerHubWithDepth(ctx, repoName, page, pageSize, depth+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Count = len(result.Results)
|
result.Count = len(result.Results)
|
||||||
@@ -340,23 +375,9 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se
|
|||||||
return nil, fmt.Errorf("解析响应失败: %v", err)
|
return nil, fmt.Errorf("解析响应失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理搜索结果
|
// 处理搜索结果:使用统一的规范化函数
|
||||||
for i := range result.Results {
|
for i := range result.Results {
|
||||||
if result.Results[i].IsOfficial {
|
normalizeRepository(&result.Results[i])
|
||||||
if !strings.Contains(result.Results[i].Name, "/") {
|
|
||||||
result.Results[i].Name = "library/" + result.Results[i].Name
|
|
||||||
}
|
|
||||||
result.Results[i].Namespace = "library"
|
|
||||||
} else {
|
|
||||||
parts := strings.Split(result.Results[i].Name, "/")
|
|
||||||
if len(parts) > 1 {
|
|
||||||
result.Results[i].Namespace = parts[0]
|
|
||||||
result.Results[i].Name = parts[1]
|
|
||||||
} else if result.Results[i].RepoOwner != "" {
|
|
||||||
result.Results[i].Namespace = result.Results[i].RepoOwner
|
|
||||||
result.Results[i].Name = fmt.Sprintf("%s/%s", result.Results[i].RepoOwner, result.Results[i].Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是用户/仓库搜索,过滤结果
|
// 如果是用户/仓库搜索,过滤结果
|
||||||
@@ -394,45 +415,100 @@ func isRetryableError(err error) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRepositoryTags 获取仓库标签信息
|
// getRepositoryTags 获取仓库标签信息(支持分页)
|
||||||
func getRepositoryTags(ctx context.Context, namespace, name string) ([]TagInfo, error) {
|
func getRepositoryTags(ctx context.Context, namespace, name string, page, pageSize int) ([]TagInfo, bool, error) {
|
||||||
if namespace == "" || name == "" {
|
if namespace == "" || name == "" {
|
||||||
return nil, fmt.Errorf("无效输入:命名空间和名称不能为空")
|
return nil, false, fmt.Errorf("无效输入:命名空间和名称不能为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheKey := fmt.Sprintf("tags:%s:%s", namespace, name)
|
// 默认参数
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize <= 0 || pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页缓存key
|
||||||
|
cacheKey := fmt.Sprintf("tags:%s:%s:page_%d", namespace, name, page)
|
||||||
if cached, ok := searchCache.Get(cacheKey); ok {
|
if cached, ok := searchCache.Get(cacheKey); ok {
|
||||||
return cached.([]TagInfo), nil
|
result := cached.(TagPageResult)
|
||||||
|
return result.Tags, result.HasMore, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建API URL
|
// 构建API URL
|
||||||
baseURL := fmt.Sprintf("https://registry.hub.docker.com/v2/repositories/%s/%s/tags", namespace, name)
|
baseURL := fmt.Sprintf("https://registry.hub.docker.com/v2/repositories/%s/%s/tags", namespace, name)
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("page_size", "100")
|
params.Set("page", fmt.Sprintf("%d", page))
|
||||||
|
params.Set("page_size", fmt.Sprintf("%d", pageSize))
|
||||||
params.Set("ordering", "last_updated")
|
params.Set("ordering", "last_updated")
|
||||||
|
|
||||||
fullURL := baseURL + "?" + params.Encode()
|
fullURL := baseURL + "?" + params.Encode()
|
||||||
|
|
||||||
// 使用统一的搜索HTTP客户端
|
// 获取当前页数据
|
||||||
resp, err := GetSearchHTTPClient().Get(fullURL)
|
pageResult, err := fetchTagPage(ctx, fullURL, 3)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("获取标签失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMore := pageResult.Next != ""
|
||||||
|
|
||||||
|
// 缓存结果(分页缓存时间较短)
|
||||||
|
result := TagPageResult{Tags: pageResult.Results, HasMore: hasMore}
|
||||||
|
searchCache.SetWithTTL(cacheKey, result, 30*time.Minute)
|
||||||
|
|
||||||
|
return pageResult.Results, hasMore, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchTagPage 获取单页标签数据,带重试机制
|
||||||
|
func fetchTagPage(ctx context.Context, url string, maxRetries int) (*struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
Previous string `json:"previous"`
|
||||||
|
Results []TagInfo `json:"results"`
|
||||||
|
}, error) {
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for retry := 0; retry < maxRetries; retry++ {
|
||||||
|
if retry > 0 {
|
||||||
|
// 重试前等待一段时间
|
||||||
|
time.Sleep(time.Duration(retry) * 500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := GetSearchHTTPClient().Get(url)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
if isRetryableError(err) && retry < maxRetries-1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("发送请求失败: %v", err)
|
return nil, fmt.Errorf("发送请求失败: %v", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
|
||||||
if err := resp.Body.Close(); err != nil {
|
// 读取响应体(立即关闭,避免defer在循环中累积)
|
||||||
fmt.Printf("关闭搜索响应体失败: %v\n", err)
|
body, err := func() ([]byte, error) {
|
||||||
}
|
defer safeCloseResponseBody(resp.Body, "标签响应体")
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 读取响应体
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
if retry < maxRetries-1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("读取响应失败: %v", err)
|
return nil, fmt.Errorf("读取响应失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查响应状态码
|
// 检查响应状态码
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body))
|
lastErr = fmt.Errorf("状态码=%d, 响应=%s", resp.StatusCode, string(body))
|
||||||
|
// 4xx错误通常不需要重试
|
||||||
|
if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != 429 {
|
||||||
|
return nil, fmt.Errorf("请求失败: %v", lastErr)
|
||||||
|
}
|
||||||
|
if retry < maxRetries-1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("请求失败: %v", lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析响应
|
// 解析响应
|
||||||
@@ -443,12 +519,46 @@ func getRepositoryTags(ctx context.Context, namespace, name string) ([]TagInfo,
|
|||||||
Results []TagInfo `json:"results"`
|
Results []TagInfo `json:"results"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
if retry < maxRetries-1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("解析响应失败: %v", err)
|
return nil, fmt.Errorf("解析响应失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 缓存结果
|
return &result, nil
|
||||||
searchCache.Set(cacheKey, result.Results)
|
}
|
||||||
return result.Results, nil
|
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePaginationParams 解析分页参数
|
||||||
|
func parsePaginationParams(c *gin.Context, defaultPageSize int) (page, pageSize int) {
|
||||||
|
page = 1
|
||||||
|
pageSize = defaultPageSize
|
||||||
|
|
||||||
|
if p := c.Query("page"); p != "" {
|
||||||
|
fmt.Sscanf(p, "%d", &page)
|
||||||
|
}
|
||||||
|
if ps := c.Query("page_size"); ps != "" {
|
||||||
|
fmt.Sscanf(ps, "%d", &pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
return page, pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeCloseResponseBody 安全关闭HTTP响应体(统一资源管理)
|
||||||
|
func safeCloseResponseBody(body io.ReadCloser, context string) {
|
||||||
|
if body != nil {
|
||||||
|
if err := body.Close(); err != nil {
|
||||||
|
fmt.Printf("关闭%s失败: %v\n", context, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendErrorResponse 统一错误响应处理
|
||||||
|
func sendErrorResponse(c *gin.Context, message string) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": message})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterSearchRoute 注册搜索相关路由
|
// RegisterSearchRoute 注册搜索相关路由
|
||||||
@@ -457,22 +567,15 @@ func RegisterSearchRoute(r *gin.Engine) {
|
|||||||
r.GET("/search", func(c *gin.Context) {
|
r.GET("/search", func(c *gin.Context) {
|
||||||
query := c.Query("q")
|
query := c.Query("q")
|
||||||
if query == "" {
|
if query == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "搜索关键词不能为空"})
|
sendErrorResponse(c, "搜索关键词不能为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
page := 1
|
page, pageSize := parsePaginationParams(c, 25)
|
||||||
pageSize := 25
|
|
||||||
if p := c.Query("page"); p != "" {
|
|
||||||
fmt.Sscanf(p, "%d", &page)
|
|
||||||
}
|
|
||||||
if ps := c.Query("page_size"); ps != "" {
|
|
||||||
fmt.Sscanf(ps, "%d", &pageSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := searchDockerHub(c.Request.Context(), query, page, pageSize)
|
result, err := searchDockerHub(c.Request.Context(), query, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
sendErrorResponse(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,16 +588,27 @@ func RegisterSearchRoute(r *gin.Engine) {
|
|||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
|
|
||||||
if namespace == "" || name == "" {
|
if namespace == "" || name == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "命名空间和名称不能为空"})
|
sendErrorResponse(c, "命名空间和名称不能为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tags, err := getRepositoryTags(c.Request.Context(), namespace, name)
|
page, pageSize := parsePaginationParams(c, 100)
|
||||||
|
|
||||||
|
tags, hasMore, err := getRepositoryTags(c.Request.Context(), namespace, name, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
sendErrorResponse(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"tags": tags,
|
||||||
|
"has_more": hasMore,
|
||||||
|
"page": page,
|
||||||
|
"page_size": pageSize,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
c.JSON(http.StatusOK, tags)
|
c.JSON(http.StatusOK, tags)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user