Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5bc86ef79 | ||
|
|
23dd077f5d | ||
|
|
3917b2503a | ||
|
|
bb61eb5025 | ||
|
|
11c34459ca | ||
|
|
6659e977ae |
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -62,12 +62,13 @@ jobs:
|
||||
- name: 编译二进制文件
|
||||
run: |
|
||||
cd src
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
|
||||
# Linux AMD64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ../build/hubproxy/hubproxy-linux-amd64 .
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.Version=${VERSION}" -o ../build/hubproxy/hubproxy-linux-amd64 .
|
||||
|
||||
# Linux ARM64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o ../build/hubproxy/hubproxy-linux-arm64 .
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.Version=${VERSION}" -o ../build/hubproxy/hubproxy-linux-arm64 .
|
||||
|
||||
# 压缩二进制文件
|
||||
upx -9 ../build/hubproxy/hubproxy-linux-amd64
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG VERSION=dev
|
||||
|
||||
WORKDIR /app
|
||||
COPY src/go.mod src/go.sum ./
|
||||
@@ -8,7 +9,7 @@ RUN go mod download && apk add upx
|
||||
|
||||
COPY src/ .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-s -w" -trimpath -o hubproxy . && upx -9 hubproxy
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-s -w -X main.Version=${VERSION}" -trimpath -o hubproxy . && upx -9 hubproxy
|
||||
|
||||
FROM alpine
|
||||
|
||||
@@ -17,4 +18,4 @@ WORKDIR /root/
|
||||
COPY --from=builder /app/hubproxy .
|
||||
COPY --from=builder /app/config.toml .
|
||||
|
||||
CMD ["./hubproxy"]
|
||||
CMD ["./hubproxy"]
|
||||
|
||||
19
README.md
19
README.md
@@ -114,6 +114,8 @@ port = 5000
|
||||
fileSize = 2147483648
|
||||
# HTTP/2 多路复用,提升下载速度
|
||||
enableH2C = false
|
||||
# 是否启用前端静态页面
|
||||
enableFrontend = true
|
||||
|
||||
[rateLimit]
|
||||
# 每个IP每周期允许的请求数(注意Docker镜像会有多个层,会消耗多个次数)
|
||||
@@ -204,6 +206,23 @@ defaultTTL = "20m"
|
||||
|
||||
脚本部署配置文件位于 `/opt/hubproxy/config.toml`
|
||||
|
||||
### 环境变量(可选)
|
||||
|
||||
支持通过环境变量覆盖部分配置,优先级高于`config.toml`,以下是默认值:
|
||||
|
||||
```
|
||||
SERVER_HOST=0.0.0.0 # 监听地址
|
||||
SERVER_PORT=5000 # 监听端口
|
||||
ENABLE_H2C=false # 是否启用 H2C
|
||||
ENABLE_FRONTEND=true # 是否启用前端静态页面
|
||||
MAX_FILE_SIZE=2147483648 # GitHub 文件大小限制(字节)
|
||||
RATE_LIMIT=500 # 每周期请求数
|
||||
RATE_PERIOD_HOURS=3 # 限流周期(小时)
|
||||
IP_WHITELIST=127.0.0.1,192.168.1.0/24 # IP 白名单(逗号分隔)
|
||||
IP_BLACKLIST=192.168.100.1,192.168.100.0/24 # IP 黑名单(逗号分隔)
|
||||
MAX_IMAGES=10 # 批量下载镜像数量限制
|
||||
```
|
||||
|
||||
为了IP限流能够正常运行,反向代理需要传递IP头用来获取访客真实IP,以caddy为例:
|
||||
```
|
||||
example.com {
|
||||
|
||||
@@ -6,6 +6,7 @@ port = 5000
|
||||
fileSize = 2147483648
|
||||
# HTTP/2 多路复用
|
||||
enableH2C = false
|
||||
enableFrontend = true
|
||||
|
||||
[rateLimit]
|
||||
# 每个IP每周期允许的请求数
|
||||
|
||||
@@ -22,10 +22,11 @@ type RegistryMapping struct {
|
||||
// AppConfig 应用配置结构体
|
||||
type AppConfig struct {
|
||||
Server struct {
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
FileSize int64 `toml:"fileSize"`
|
||||
EnableH2C bool `toml:"enableH2C"`
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
FileSize int64 `toml:"fileSize"`
|
||||
EnableH2C bool `toml:"enableH2C"`
|
||||
EnableFrontend bool `toml:"enableFrontend"`
|
||||
} `toml:"server"`
|
||||
|
||||
RateLimit struct {
|
||||
@@ -70,15 +71,17 @@ var (
|
||||
func DefaultConfig() *AppConfig {
|
||||
return &AppConfig{
|
||||
Server: struct {
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
FileSize int64 `toml:"fileSize"`
|
||||
EnableH2C bool `toml:"enableH2C"`
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
FileSize int64 `toml:"fileSize"`
|
||||
EnableH2C bool `toml:"enableH2C"`
|
||||
EnableFrontend bool `toml:"enableFrontend"`
|
||||
}{
|
||||
Host: "0.0.0.0",
|
||||
Port: 5000,
|
||||
FileSize: 2 * 1024 * 1024 * 1024, // 2GB
|
||||
EnableH2C: false, // 默认关闭H2C
|
||||
Host: "0.0.0.0",
|
||||
Port: 5000,
|
||||
FileSize: 2 * 1024 * 1024 * 1024,
|
||||
EnableH2C: false,
|
||||
EnableFrontend: true,
|
||||
},
|
||||
RateLimit: struct {
|
||||
RequestLimit int `toml:"requestLimit"`
|
||||
@@ -227,6 +230,11 @@ func overrideFromEnv(cfg *AppConfig) {
|
||||
cfg.Server.EnableH2C = enable
|
||||
}
|
||||
}
|
||||
if val := os.Getenv("ENABLE_FRONTEND"); val != "" {
|
||||
if enable, err := strconv.ParseBool(val); err == nil {
|
||||
cfg.Server.EnableFrontend = enable
|
||||
}
|
||||
}
|
||||
if val := os.Getenv("MAX_FILE_SIZE"); val != "" {
|
||||
if size, err := strconv.ParseInt(val, 10, 64); err == nil && size > 0 {
|
||||
cfg.Server.FileSize = size
|
||||
|
||||
@@ -274,7 +274,9 @@ func handleBlobRequest(c *gin.Context, imageRef, digest string) {
|
||||
c.Header("Docker-Content-Digest", digest)
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
io.Copy(c.Writer, reader)
|
||||
if _, err := io.Copy(c.Writer, reader); err != nil {
|
||||
fmt.Printf("复制layer内容失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTagsRequest 处理tags列表请求
|
||||
@@ -416,7 +418,9 @@ func proxyDockerAuthOriginal(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.Status(resp.StatusCode)
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
if _, err := io.Copy(c.Writer, resp.Body); err != nil {
|
||||
fmt.Printf("复制认证响应失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// rewriteAuthHeader 重写认证头
|
||||
@@ -569,7 +573,9 @@ func handleUpstreamBlobRequest(c *gin.Context, imageRef, digest string, mapping
|
||||
c.Header("Docker-Content-Digest", digest)
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
io.Copy(c.Writer, reader)
|
||||
if _, err := io.Copy(c.Writer, reader); err != nil {
|
||||
fmt.Printf("复制layer内容失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUpstreamTagsRequest 处理上游Registry的tags请求
|
||||
|
||||
@@ -129,7 +129,7 @@ func proxyGitHubWithRedirect(c *gin.Context, u string, redirectCount int) {
|
||||
fmt.Printf("关闭响应体失败: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
// 检查并处理被阻止的内容类型
|
||||
if c.Request.Method == "GET" {
|
||||
if contentType := resp.Header.Get("Content-Type"); blockedContentTypes[strings.ToLower(strings.Split(contentType, ";")[0])] {
|
||||
@@ -227,6 +227,8 @@ func proxyGitHubWithRedirect(c *gin.Context, u string, redirectCount int) {
|
||||
c.Status(resp.StatusCode)
|
||||
|
||||
// 直接流式转发
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
if _, err := io.Copy(c.Writer, resp.Body); err != nil {
|
||||
fmt.Printf("转发响应体失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@ import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -115,6 +118,15 @@ func getUserID(c *gin.Context) string {
|
||||
return "ip:" + hex.EncodeToString(hash[:8])
|
||||
}
|
||||
|
||||
func getClientIdentity(c *gin.Context) (string, string) {
|
||||
ip := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
if userAgent == "" {
|
||||
userAgent = "unknown"
|
||||
}
|
||||
return ip, userAgent
|
||||
}
|
||||
|
||||
var (
|
||||
singleImageDebouncer *DownloadDebouncer
|
||||
batchImageDebouncer *DownloadDebouncer
|
||||
@@ -126,6 +138,98 @@ func InitDebouncer() {
|
||||
batchImageDebouncer = NewDownloadDebouncer(60 * time.Second)
|
||||
}
|
||||
|
||||
type BatchDownloadRequest struct {
|
||||
Images []string
|
||||
Platform string
|
||||
UseCompressedLayers bool
|
||||
}
|
||||
|
||||
type SingleDownloadRequest struct {
|
||||
Image string
|
||||
Platform string
|
||||
UseCompressedLayers bool
|
||||
}
|
||||
|
||||
type tokenEntry[T any] struct {
|
||||
Request T
|
||||
ExpiresAt time.Time
|
||||
IP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
type tokenStore[T any] struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]tokenEntry[T]
|
||||
}
|
||||
|
||||
const downloadTokenTTL = 2 * time.Minute
|
||||
const downloadTokenMaxEntries = 2000
|
||||
|
||||
func newTokenStore[T any]() *tokenStore[T] {
|
||||
return &tokenStore[T]{
|
||||
entries: make(map[string]tokenEntry[T]),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *tokenStore[T]) create(req T, ip, userAgent string) (string, error) {
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
|
||||
now := time.Now()
|
||||
entry := tokenEntry[T]{
|
||||
Request: req,
|
||||
ExpiresAt: now.Add(downloadTokenTTL),
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.cleanup(now)
|
||||
if len(s.entries) >= downloadTokenMaxEntries {
|
||||
s.mu.Unlock()
|
||||
return "", fmt.Errorf("令牌过多,请稍后再试")
|
||||
}
|
||||
s.entries[token] = entry
|
||||
s.mu.Unlock()
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *tokenStore[T]) consume(token, ip, userAgent string) (T, bool) {
|
||||
var empty T
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entry, exists := s.entries[token]
|
||||
if !exists {
|
||||
return empty, false
|
||||
}
|
||||
if now.After(entry.ExpiresAt) {
|
||||
delete(s.entries, token)
|
||||
return empty, false
|
||||
}
|
||||
if entry.IP != ip || entry.UserAgent != userAgent {
|
||||
delete(s.entries, token)
|
||||
return empty, false
|
||||
}
|
||||
delete(s.entries, token)
|
||||
return entry.Request, true
|
||||
}
|
||||
|
||||
func (s *tokenStore[T]) cleanup(now time.Time) {
|
||||
for token, entry := range s.entries {
|
||||
if now.After(entry.ExpiresAt) {
|
||||
delete(s.entries, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var batchDownloadTokens = newTokenStore[BatchDownloadRequest]()
|
||||
var singleDownloadTokens = newTokenStore[SingleDownloadRequest]()
|
||||
|
||||
// ImageStreamer 镜像流式下载器
|
||||
type ImageStreamer struct {
|
||||
concurrency int
|
||||
@@ -209,6 +313,17 @@ func (is *ImageStreamer) getImageDescriptorWithPlatform(ref name.Reference, opti
|
||||
return remote.Get(ref, options...)
|
||||
}
|
||||
|
||||
func setDownloadHeaders(c *gin.Context, filename string, compressed bool) {
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Pragma", "no-cache")
|
||||
c.Header("Expires", "0")
|
||||
if compressed {
|
||||
c.Header("Content-Encoding", "gzip")
|
||||
}
|
||||
}
|
||||
|
||||
// StreamImageToGin 流式响应到Gin
|
||||
func (is *ImageStreamer) StreamImageToGin(ctx context.Context, imageRef string, c *gin.Context, options *StreamOptions) error {
|
||||
if options == nil {
|
||||
@@ -216,12 +331,7 @@ func (is *ImageStreamer) StreamImageToGin(ctx context.Context, imageRef string,
|
||||
}
|
||||
|
||||
filename := strings.ReplaceAll(imageRef, "/", "_") + ".tar"
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
if options.Compression {
|
||||
c.Header("Content-Encoding", "gzip")
|
||||
}
|
||||
setDownloadHeaders(c, filename, options.Compression)
|
||||
|
||||
return is.StreamImageToWriter(ctx, imageRef, c.Writer, options)
|
||||
}
|
||||
@@ -579,6 +689,7 @@ func InitImageTarRoutes(router *gin.Engine) {
|
||||
{
|
||||
imageAPI.GET("/download/:image", handleDirectImageDownload)
|
||||
imageAPI.GET("/info/:image", handleImageInfo)
|
||||
imageAPI.GET("/batch", handleSimpleBatchDownload)
|
||||
imageAPI.POST("/batch", handleSimpleBatchDownload)
|
||||
}
|
||||
}
|
||||
@@ -606,28 +717,73 @@ func handleDirectImageDownload(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": reason})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
contentKey := generateContentFingerprint([]string{imageRef}, platform)
|
||||
if c.Query("mode") == "prepare" {
|
||||
userID := getUserID(c)
|
||||
contentKey := generateContentFingerprint([]string{imageRef}, platform)
|
||||
|
||||
if !singleImageDebouncer.ShouldAllow(userID, contentKey) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "请求过于频繁,请稍后再试",
|
||||
"retry_after": 5,
|
||||
})
|
||||
if !singleImageDebouncer.ShouldAllow(userID, contentKey) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "请求过于频繁,请稍后再试",
|
||||
"retry_after": 5,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ip, userAgent := getClientIdentity(c)
|
||||
token, err := singleDownloadTokens.create(SingleDownloadRequest{
|
||||
Image: imageRef,
|
||||
Platform: platform,
|
||||
UseCompressedLayers: useCompressed,
|
||||
}, ip, userAgent)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
downloadURL := fmt.Sprintf("/api/image/download/%s?token=%s", imageParam, token)
|
||||
if tag != "" {
|
||||
downloadURL = downloadURL + "&tag=" + url.QueryEscape(tag)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"download_url": downloadURL})
|
||||
return
|
||||
}
|
||||
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少下载令牌"})
|
||||
return
|
||||
}
|
||||
|
||||
ip, userAgent := getClientIdentity(c)
|
||||
req, ok := singleDownloadTokens.consume(token, ip, userAgent)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效或过期的下载令牌"})
|
||||
return
|
||||
}
|
||||
if req.Image != imageRef {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "下载令牌与镜像不匹配"})
|
||||
return
|
||||
}
|
||||
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(req.Image); !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": reason})
|
||||
return
|
||||
}
|
||||
|
||||
options := &StreamOptions{
|
||||
Platform: platform,
|
||||
Platform: req.Platform,
|
||||
Compression: false,
|
||||
UseCompressedLayers: useCompressed,
|
||||
UseCompressedLayers: req.UseCompressedLayers,
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
log.Printf("下载镜像: %s (平台: %s)", imageRef, formatPlatformText(platform))
|
||||
log.Printf("下载镜像: %s (平台: %s)", req.Image, formatPlatformText(req.Platform))
|
||||
|
||||
if err := globalImageStreamer.StreamImageToGin(ctx, imageRef, c, options); err != nil {
|
||||
if err := globalImageStreamer.StreamImageToGin(ctx, req.Image, c, options); err != nil {
|
||||
log.Printf("镜像下载失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "镜像下载失败: " + err.Error()})
|
||||
return
|
||||
@@ -636,6 +792,51 @@ func handleDirectImageDownload(c *gin.Context) {
|
||||
|
||||
// handleSimpleBatchDownload 处理批量下载
|
||||
func handleSimpleBatchDownload(c *gin.Context) {
|
||||
if c.Request.Method == http.MethodGet {
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少下载令牌"})
|
||||
return
|
||||
}
|
||||
|
||||
ip, userAgent := getClientIdentity(c)
|
||||
req, ok := batchDownloadTokens.consume(token, ip, userAgent)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效或过期的下载令牌"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Images) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像列表不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
options := &StreamOptions{
|
||||
Platform: req.Platform,
|
||||
Compression: false,
|
||||
UseCompressedLayers: req.UseCompressedLayers,
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
log.Printf("批量下载 %d 个镜像 (平台: %s)", len(req.Images), formatPlatformText(req.Platform))
|
||||
|
||||
filename := fmt.Sprintf("batch_%d_images.tar", len(req.Images))
|
||||
|
||||
setDownloadHeaders(c, filename, options.Compression)
|
||||
|
||||
if err := globalImageStreamer.StreamMultipleImages(ctx, req.Images, c.Writer, options); err != nil {
|
||||
log.Printf("批量镜像下载失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "批量镜像下载失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("mode") != "prepare" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "只支持prepare模式"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Images []string `json:"images" binding:"required"`
|
||||
Platform string `json:"platform"`
|
||||
@@ -651,12 +852,24 @@ func handleSimpleBatchDownload(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像列表不能为空"})
|
||||
return
|
||||
}
|
||||
for _, imageRef := range req.Images {
|
||||
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": reason})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for i, imageRef := range req.Images {
|
||||
if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") {
|
||||
req.Images[i] = imageRef + ":latest"
|
||||
}
|
||||
}
|
||||
for _, imageRef := range req.Images {
|
||||
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": reason})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
cfg := config.GetConfig()
|
||||
if len(req.Images) > cfg.Download.MaxImages {
|
||||
@@ -682,25 +895,19 @@ func handleSimpleBatchDownload(c *gin.Context) {
|
||||
useCompressed = *req.UseCompressedLayers
|
||||
}
|
||||
|
||||
options := &StreamOptions{
|
||||
batchReq := BatchDownloadRequest{
|
||||
Images: req.Images,
|
||||
Platform: req.Platform,
|
||||
Compression: false,
|
||||
UseCompressedLayers: useCompressed,
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
log.Printf("批量下载 %d 个镜像 (平台: %s)", len(req.Images), formatPlatformText(req.Platform))
|
||||
|
||||
filename := fmt.Sprintf("batch_%d_images.tar", len(req.Images))
|
||||
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
if err := globalImageStreamer.StreamMultipleImages(ctx, req.Images, c.Writer, options); err != nil {
|
||||
log.Printf("批量镜像下载失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "批量镜像下载失败: " + err.Error()})
|
||||
ip, userAgent := getClientIdentity(c)
|
||||
token, err := batchDownloadTokens.create(batchReq, ip, userAgent)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"download_url": fmt.Sprintf("/api/image/batch?token=%s", token)})
|
||||
}
|
||||
|
||||
// handleImageInfo 处理镜像信息查询
|
||||
@@ -723,6 +930,10 @@ func handleImageInfo(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": reason})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
contextOptions := append(globalImageStreamer.remoteOptions, remote.WithContext(ctx))
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -160,51 +159,6 @@ func init() {
|
||||
}()
|
||||
}
|
||||
|
||||
func filterSearchResults(results []Repository, query string) []Repository {
|
||||
searchTerm := strings.ToLower(strings.TrimPrefix(query, "library/"))
|
||||
filtered := make([]Repository, 0)
|
||||
|
||||
for _, repo := range results {
|
||||
repoName := strings.ToLower(repo.Name)
|
||||
repoDesc := strings.ToLower(repo.Description)
|
||||
|
||||
score := 0
|
||||
|
||||
if repoName == searchTerm {
|
||||
score += 100
|
||||
}
|
||||
|
||||
if strings.HasPrefix(repoName, searchTerm) {
|
||||
score += 50
|
||||
}
|
||||
|
||||
if strings.Contains(repoName, searchTerm) {
|
||||
score += 30
|
||||
}
|
||||
|
||||
if strings.Contains(repoDesc, searchTerm) {
|
||||
score += 10
|
||||
}
|
||||
|
||||
if repo.IsOfficial {
|
||||
score += 20
|
||||
}
|
||||
|
||||
if score > 0 {
|
||||
filtered = append(filtered, repo)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
if filtered[i].IsOfficial != filtered[j].IsOfficial {
|
||||
return filtered[i].IsOfficial
|
||||
}
|
||||
return filtered[i].PullCount > filtered[j].PullCount
|
||||
})
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// normalizeRepository 统一规范化仓库信息
|
||||
func normalizeRepository(repo *Repository) {
|
||||
if repo.IsOfficial {
|
||||
@@ -297,7 +251,7 @@ func searchDockerHubWithDepth(ctx context.Context, query string, page, pageSize
|
||||
}
|
||||
return nil, fmt.Errorf("未找到相关镜像")
|
||||
case http.StatusBadGateway, http.StatusServiceUnavailable:
|
||||
return nil, fmt.Errorf("Docker Hub服务暂时不可用,请稍后重试")
|
||||
return nil, fmt.Errorf("docker hub 服务暂时不可用,请稍后重试")
|
||||
default:
|
||||
return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
@@ -487,10 +441,14 @@ func parsePaginationParams(c *gin.Context, defaultPageSize int) (page, pageSize
|
||||
pageSize = defaultPageSize
|
||||
|
||||
if p := c.Query("page"); p != "" {
|
||||
fmt.Sscanf(p, "%d", &page)
|
||||
if _, err := fmt.Sscanf(p, "%d", &page); err != nil {
|
||||
fmt.Printf("解析page参数失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
if ps := c.Query("page_size"); ps != "" {
|
||||
fmt.Sscanf(ps, "%d", &pageSize)
|
||||
if _, err := fmt.Sscanf(ps, "%d", &pageSize); err != nil {
|
||||
fmt.Printf("解析page_size参数失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return page, pageSize
|
||||
|
||||
134
src/main.go
134
src/main.go
@@ -40,6 +40,82 @@ var (
|
||||
serviceStartTime = time.Now()
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
func buildRouter(cfg *config.AppConfig) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
router := gin.Default()
|
||||
|
||||
// 全局Panic恢复保护
|
||||
router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
|
||||
log.Printf("🚨 Panic recovered: %v", recovered)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Internal server error",
|
||||
"code": "INTERNAL_ERROR",
|
||||
})
|
||||
}))
|
||||
|
||||
// 全局限流中间件
|
||||
router.Use(utils.RateLimitMiddleware(globalLimiter))
|
||||
|
||||
// 初始化监控端点
|
||||
initHealthRoutes(router)
|
||||
|
||||
// 初始化镜像tar下载路由
|
||||
handlers.InitImageTarRoutes(router)
|
||||
|
||||
if cfg.Server.EnableFrontend {
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/index.html")
|
||||
})
|
||||
router.GET("/public/*filepath", func(c *gin.Context) {
|
||||
filepath := strings.TrimPrefix(c.Param("filepath"), "/")
|
||||
serveEmbedFile(c, "public/"+filepath)
|
||||
})
|
||||
|
||||
router.GET("/images.html", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/images.html")
|
||||
})
|
||||
router.GET("/search.html", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/search.html")
|
||||
})
|
||||
router.GET("/favicon.ico", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/favicon.ico")
|
||||
})
|
||||
} else {
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
c.Status(http.StatusNotFound)
|
||||
})
|
||||
router.GET("/public/*filepath", func(c *gin.Context) {
|
||||
c.Status(http.StatusNotFound)
|
||||
})
|
||||
router.GET("/images.html", func(c *gin.Context) {
|
||||
c.Status(http.StatusNotFound)
|
||||
})
|
||||
router.GET("/search.html", func(c *gin.Context) {
|
||||
c.Status(http.StatusNotFound)
|
||||
})
|
||||
router.GET("/favicon.ico", func(c *gin.Context) {
|
||||
c.Status(http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
// 注册dockerhub搜索路由
|
||||
handlers.RegisterSearchRoute(router)
|
||||
|
||||
// 注册Docker认证路由
|
||||
router.Any("/token", handlers.ProxyDockerAuthGin)
|
||||
router.Any("/token/*path", handlers.ProxyDockerAuthGin)
|
||||
|
||||
// 注册Docker Registry代理路由
|
||||
router.Any("/v2/*path", handlers.ProxyDockerRegistryGin)
|
||||
|
||||
// 注册GitHub代理路由(NoRoute处理器)
|
||||
router.NoRoute(handlers.GitHubProxyHandler)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 加载配置
|
||||
if err := config.LoadConfig(); err != nil {
|
||||
@@ -62,60 +138,9 @@ func main() {
|
||||
// 初始化防抖器
|
||||
handlers.InitDebouncer()
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
router := gin.Default()
|
||||
|
||||
// 全局Panic恢复保护
|
||||
router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
|
||||
log.Printf("🚨 Panic recovered: %v", recovered)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Internal server error",
|
||||
"code": "INTERNAL_ERROR",
|
||||
})
|
||||
}))
|
||||
|
||||
// 全局限流中间件
|
||||
router.Use(utils.RateLimitMiddleware(globalLimiter))
|
||||
|
||||
// 初始化监控端点
|
||||
initHealthRoutes(router)
|
||||
|
||||
// 初始化镜像tar下载路由
|
||||
handlers.InitImageTarRoutes(router)
|
||||
|
||||
// 静态文件路由
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/index.html")
|
||||
})
|
||||
router.GET("/public/*filepath", func(c *gin.Context) {
|
||||
filepath := strings.TrimPrefix(c.Param("filepath"), "/")
|
||||
serveEmbedFile(c, "public/"+filepath)
|
||||
})
|
||||
|
||||
router.GET("/images.html", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/images.html")
|
||||
})
|
||||
router.GET("/search.html", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/search.html")
|
||||
})
|
||||
router.GET("/favicon.ico", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/favicon.ico")
|
||||
})
|
||||
|
||||
// 注册dockerhub搜索路由
|
||||
handlers.RegisterSearchRoute(router)
|
||||
|
||||
// 注册Docker认证路由
|
||||
router.Any("/token", handlers.ProxyDockerAuthGin)
|
||||
router.Any("/token/*path", handlers.ProxyDockerAuthGin)
|
||||
|
||||
// 注册Docker Registry代理路由
|
||||
router.Any("/v2/*path", handlers.ProxyDockerRegistryGin)
|
||||
|
||||
// 注册GitHub代理路由(NoRoute处理器)
|
||||
router.NoRoute(handlers.GitHubProxyHandler)
|
||||
|
||||
cfg := config.GetConfig()
|
||||
router := buildRouter(cfg)
|
||||
|
||||
fmt.Printf("HubProxy 启动成功\n")
|
||||
fmt.Printf("监听地址: %s:%d\n", cfg.Server.Host, cfg.Server.Port)
|
||||
fmt.Printf("限流配置: %d请求/%g小时\n", cfg.RateLimit.RequestLimit, cfg.RateLimit.PeriodHours)
|
||||
@@ -125,7 +150,7 @@ func main() {
|
||||
fmt.Printf("H2c: 已启用\n")
|
||||
}
|
||||
|
||||
fmt.Printf("版本号: v1.2.1\n")
|
||||
fmt.Printf("版本号: %s\n", Version)
|
||||
fmt.Printf("项目地址: https://github.com/sky22333/hubproxy\n")
|
||||
|
||||
// 创建HTTP2服务器
|
||||
@@ -182,6 +207,7 @@ func initHealthRoutes(router *gin.Engine) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ready": true,
|
||||
"service": "hubproxy",
|
||||
"version": Version,
|
||||
"start_time_unix": serviceStartTime.Unix(),
|
||||
"uptime_sec": uptimeSec,
|
||||
"uptime_human": uptimeHuman,
|
||||
|
||||
151
src/public/images.html
vendored
151
src/public/images.html
vendored
@@ -728,7 +728,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function buildDownloadUrl(imageName, platform = '', useCompressed = true) {
|
||||
function buildDownloadUrl(imageName, platform = '', useCompressed = true, mode = '') {
|
||||
const encodedImage = imageName.replace(/\//g, '_');
|
||||
let url = `/api/image/download/${encodedImage}`;
|
||||
|
||||
@@ -737,6 +737,9 @@
|
||||
params.append('platform', platform.trim());
|
||||
}
|
||||
params.append('compressed', useCompressed.toString());
|
||||
if (mode) {
|
||||
params.append('mode', mode);
|
||||
}
|
||||
|
||||
if (params.toString()) {
|
||||
url += '?' + params.toString();
|
||||
@@ -745,7 +748,65 @@
|
||||
return url;
|
||||
}
|
||||
|
||||
document.getElementById('singleForm').addEventListener('submit', function(e) {
|
||||
function buildInfoUrl(imageName) {
|
||||
const encodedImage = imageName.replace(/\//g, '_');
|
||||
return `/api/image/info/${encodedImage}`;
|
||||
}
|
||||
|
||||
async function preflightImageDownload(imageName) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
const response = await fetch(buildInfoUrl(imageName), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
cache: 'no-store',
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
let payload = null;
|
||||
if (contentType.includes('application/json')) {
|
||||
payload = await response.json();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { ok: false, error: (payload && payload.error) ? payload.error : '镜像预检失败' };
|
||||
}
|
||||
|
||||
if (payload && payload.success === false) {
|
||||
return { ok: false, error: payload.error || '镜像预检失败' };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return { ok: false, error: '预检超时,请稍后重试' };
|
||||
}
|
||||
return { ok: false, error: '网络错误: ' + error.message };
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function preflightImages(images) {
|
||||
const uniqueImages = Array.from(new Set(images));
|
||||
const results = await Promise.allSettled(uniqueImages.map((imageName) => preflightImageDownload(imageName)));
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i];
|
||||
if (result.status === 'rejected') {
|
||||
return { ok: false, error: '预检失败,请稍后重试' };
|
||||
}
|
||||
if (!result.value.ok) {
|
||||
return { ok: false, error: result.value.error || '预检失败' };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
document.getElementById('singleForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const imageName = document.getElementById('imageInput').value.trim();
|
||||
@@ -760,20 +821,50 @@
|
||||
hideStatus('singleStatus');
|
||||
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', true);
|
||||
|
||||
const downloadUrl = buildDownloadUrl(imageName, platform, useCompressed);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
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);
|
||||
showStatus('singleStatus', '正在准备下载...', 'success');
|
||||
const preflightResult = await preflightImages([imageName]);
|
||||
if (!preflightResult.ok) {
|
||||
showStatus('singleStatus', preflightResult.error, 'error');
|
||||
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
|
||||
return;
|
||||
}
|
||||
|
||||
const prepareUrl = buildDownloadUrl(imageName, platform, useCompressed, 'prepare');
|
||||
try {
|
||||
const response = await fetch(prepareUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (!data || !data.download_url) {
|
||||
showStatus('singleStatus', '下载地址生成失败', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = data.download_url;
|
||||
link.download = '';
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
const platformText = platform ? ` (${platform})` : '';
|
||||
showStatus('singleStatus', `开始下载 ${imageName}${platformText}`, 'success');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showStatus('singleStatus', error.error || '下载失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('singleStatus', '网络错误: ' + error.message, 'error');
|
||||
} finally {
|
||||
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('batchForm').addEventListener('submit', async function(e) {
|
||||
@@ -808,9 +899,16 @@
|
||||
|
||||
hideStatus('batchStatus');
|
||||
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', true);
|
||||
showStatus('batchStatus', '正在准备下载...', 'success');
|
||||
const preflightResult = await preflightImages(images);
|
||||
if (!preflightResult.ok) {
|
||||
showStatus('batchStatus', preflightResult.error, 'error');
|
||||
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/image/batch', {
|
||||
const response = await fetch('/api/image/batch?mode=prepare', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -819,24 +917,19 @@
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let filename = `batch_${images.length}_images.tar`;
|
||||
|
||||
if (contentDisposition) {
|
||||
const matches = contentDisposition.match(/filename="(.+)"/);
|
||||
if (matches) filename = matches[1];
|
||||
const data = await response.json();
|
||||
if (!data || !data.download_url) {
|
||||
showStatus('batchStatus', '下载地址生成失败', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
const url = data.download_url;
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
const platformText = platform ? ` (${platform})` : '';
|
||||
showStatus('batchStatus', `开始下载 ${images.length} 个镜像${platformText}`, 'success');
|
||||
@@ -872,4 +965,4 @@
|
||||
initMobileMenu();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
14
src/public/search.html
vendored
14
src/public/search.html
vendored
@@ -778,16 +778,10 @@
|
||||
</div>
|
||||
</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 class="tag-list" id="tagList"></div>
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script>
|
||||
const formatUtils = {
|
||||
formatNumber(num) {
|
||||
@@ -1047,7 +1041,6 @@
|
||||
}
|
||||
|
||||
// 分页更新函数
|
||||
const updateSearchPagination = () => updatePagination();
|
||||
const updateTagPagination = () => updatePagination({
|
||||
currentPage: currentTagPage,
|
||||
totalPages: totalTagPages,
|
||||
@@ -1217,9 +1210,6 @@
|
||||
const currentNamespace = namespace || repoInfo.namespace;
|
||||
const currentName = name || repoInfo.name;
|
||||
|
||||
// 调试日志
|
||||
console.log(`loadTagPage: namespace=${currentNamespace}, name=${currentName}, page=${currentTagPage}`);
|
||||
|
||||
if (!currentNamespace || !currentName) {
|
||||
showToast('命名空间和镜像名称不能为空');
|
||||
return;
|
||||
@@ -1488,4 +1478,4 @@
|
||||
</script>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -2,7 +2,6 @@ package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"hubproxy/config"
|
||||
)
|
||||
@@ -17,7 +16,6 @@ const (
|
||||
|
||||
// AccessController 统一访问控制器
|
||||
type AccessController struct {
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// DockerImageInfo Docker镜像信息
|
||||
|
||||
@@ -104,4 +104,4 @@ func transformURL(url, host string) string {
|
||||
host = strings.TrimSuffix(host, "/")
|
||||
|
||||
return host + "/" + url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,8 +176,9 @@ func (i *IPRateLimiter) GetLimiter(ip string) (*rate.Limiter, bool) {
|
||||
|
||||
now := time.Now()
|
||||
|
||||
var entry *rateLimiterEntry
|
||||
i.mu.RLock()
|
||||
entry, exists := i.ips[normalizedIP]
|
||||
_, exists := i.ips[normalizedIP]
|
||||
i.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
|
||||
Reference in New Issue
Block a user