mirror of
https://github.com/sky22333/hubproxy.git
synced 2026-08-27 11:10:41 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
320df5def5 | ||
|
|
7e0b82f8f0 | ||
|
|
79f23d13ad | ||
|
|
26b45c98bf | ||
|
|
587c1f2144 | ||
|
|
8100bcea0b | ||
|
|
ba83a44492 | ||
|
|
c7a7f3d146 | ||
|
|
e4d4f33ea1 | ||
|
|
6e91fe9925 | ||
|
|
d0b3c657cc | ||
|
|
f5bc86ef79 | ||
|
|
23dd077f5d | ||
|
|
3917b2503a | ||
|
|
bb61eb5025 | ||
|
|
11c34459ca | ||
|
|
6659e977ae |
BIN
.github/demo/demo.png
vendored
Normal file
BIN
.github/demo/demo.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
.github/demo/demo1.jpg
vendored
BIN
.github/demo/demo1.jpg
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 79 KiB |
10
.github/workflows/docker-ghcr.yml
vendored
10
.github/workflows/docker-ghcr.yml
vendored
@@ -15,13 +15,13 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Log in to GitHub Docker Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -58,4 +58,4 @@ jobs:
|
||||
--build-arg VERSION=${{ env.VERSION }} \
|
||||
-f Dockerfile .
|
||||
env:
|
||||
GHCR_PUBLIC: true
|
||||
GHCR_PUBLIC: true
|
||||
|
||||
109
.github/workflows/release.yml
vendored
109
.github/workflows/release.yml
vendored
@@ -16,15 +16,27 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 设置Go环境
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: "src/go.mod"
|
||||
cache-dependency-path: "src/go.sum"
|
||||
|
||||
- name: 设置 Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: 构建前端
|
||||
run: |
|
||||
cd web
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: 获取版本号
|
||||
id: version
|
||||
@@ -55,42 +67,33 @@ jobs:
|
||||
mkdir -p build/hubproxy
|
||||
|
||||
- name: 安装 UPX
|
||||
uses: crazy-max/ghaction-upx@v3
|
||||
uses: crazy-max/ghaction-upx@v4
|
||||
with:
|
||||
install-only: true
|
||||
|
||||
- name: 安装 nFPM
|
||||
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.46.3
|
||||
|
||||
- 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
|
||||
upx -9 ../build/hubproxy/hubproxy-linux-arm64
|
||||
|
||||
- name: 复制配置文件
|
||||
- name: 准备压缩包文件
|
||||
run: |
|
||||
# 复制配置文件
|
||||
cp src/config.toml build/hubproxy/
|
||||
|
||||
# 复制systemd服务文件
|
||||
cp hubproxy.service build/hubproxy/
|
||||
|
||||
# 复制安装脚本
|
||||
cp install.sh build/hubproxy/
|
||||
|
||||
# 创建README文件
|
||||
cat > build/hubproxy/README.md << 'EOF'
|
||||
# HubProxy
|
||||
|
||||
项目地址:https://github.com/sky22333/hubproxy
|
||||
EOF
|
||||
|
||||
- name: 创建压缩包
|
||||
run: |
|
||||
cd build
|
||||
@@ -98,26 +101,64 @@ jobs:
|
||||
# Linux AMD64 包
|
||||
mkdir -p linux-amd64/hubproxy
|
||||
cp hubproxy/hubproxy-linux-amd64 linux-amd64/hubproxy/hubproxy
|
||||
cp hubproxy/config.toml hubproxy/hubproxy.service hubproxy/install.sh hubproxy/README.md linux-amd64/hubproxy/
|
||||
tar -czf hubproxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz -C linux-amd64 hubproxy
|
||||
cp hubproxy/config.toml linux-amd64/hubproxy/
|
||||
tar -czf hubproxy-linux-amd64.tar.gz -C linux-amd64 hubproxy
|
||||
|
||||
# Linux ARM64 包
|
||||
mkdir -p linux-arm64/hubproxy
|
||||
cp hubproxy/hubproxy-linux-arm64 linux-arm64/hubproxy/hubproxy
|
||||
cp hubproxy/config.toml hubproxy/hubproxy.service hubproxy/install.sh hubproxy/README.md linux-arm64/hubproxy/
|
||||
tar -czf hubproxy-${{ steps.version.outputs.version }}-linux-arm64.tar.gz -C linux-arm64 hubproxy
|
||||
cp hubproxy/config.toml linux-arm64/hubproxy/
|
||||
tar -czf hubproxy-linux-arm64.tar.gz -C linux-arm64 hubproxy
|
||||
|
||||
# 列出生成的文件
|
||||
ls -la *.tar.gz
|
||||
|
||||
- name: 计算文件校验和
|
||||
|
||||
- name: 创建Linux发行版安装包
|
||||
run: |
|
||||
cd build
|
||||
sha256sum *.tar.gz > checksums.txt
|
||||
cat checksums.txt
|
||||
mkdir -p build/packages
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
NFPM_VERSION="${VERSION#v}"
|
||||
|
||||
package() {
|
||||
hubproxy_arch="$1"
|
||||
nfpm_arch="$2"
|
||||
packager="$3"
|
||||
config="$4"
|
||||
target="build/packages/hubproxy-linux-${hubproxy_arch}.${packager}"
|
||||
temp_dir="build/packages/${hubproxy_arch}-${packager}"
|
||||
binary="./build/hubproxy/hubproxy-linux-${hubproxy_arch}"
|
||||
|
||||
rm -rf "${temp_dir}"
|
||||
mkdir -p "${temp_dir}"
|
||||
rm -rf build/package-root
|
||||
mkdir -p build/package-root
|
||||
cp "${binary}" build/package-root/hubproxy
|
||||
NFPM_ARCH="${nfpm_arch}" NFPM_VERSION="${NFPM_VERSION}" nfpm package --config "${config}" --packager "${packager}" --target "${temp_dir}/"
|
||||
mv "${temp_dir}"/*.${packager} "${target}"
|
||||
rm -rf "${temp_dir}"
|
||||
rm -rf build/package-root
|
||||
}
|
||||
|
||||
# AMD64 包
|
||||
package amd64 amd64 deb packaging/nfpm.deb-rpm.yaml
|
||||
package amd64 amd64 rpm packaging/nfpm.deb-rpm.yaml
|
||||
package amd64 amd64 apk packaging/nfpm.apk.yaml
|
||||
|
||||
# ARM64 包
|
||||
package arm64 arm64 deb packaging/nfpm.deb-rpm.yaml
|
||||
package arm64 arm64 rpm packaging/nfpm.deb-rpm.yaml
|
||||
package arm64 arm64 apk packaging/nfpm.apk.yaml
|
||||
|
||||
ls -la build/packages
|
||||
|
||||
- name: 检查安装包内容
|
||||
run: |
|
||||
dpkg-deb -c build/packages/hubproxy-linux-amd64.deb
|
||||
rpm -qpl build/packages/hubproxy-linux-amd64.rpm
|
||||
tar -tf build/packages/hubproxy-linux-amd64.apk
|
||||
|
||||
- name: 创建或更新Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.version }}
|
||||
name: "HubProxy ${{ steps.version.outputs.version }}"
|
||||
@@ -127,12 +168,16 @@ jobs:
|
||||
|
||||
## 下载文件
|
||||
|
||||
- **Linux AMD64**: `hubproxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz`
|
||||
- **Linux ARM64**: `hubproxy-${{ steps.version.outputs.version }}-linux-arm64.tar.gz`
|
||||
- **Linux AMD64**: `hubproxy-linux-amd64.tar.gz`
|
||||
- **Linux ARM64**: `hubproxy-linux-arm64.tar.gz`
|
||||
- **Debian/Ubuntu**: `.deb`
|
||||
- **RHEL/CentOS/Fedora**: `.rpm`
|
||||
- **Alpine Linux**: `.apk`
|
||||
|
||||
files: |
|
||||
build/*.tar.gz
|
||||
build/checksums.txt
|
||||
build/packages/*
|
||||
overwrite_files: true
|
||||
draft: false
|
||||
prerelease: false
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
29
.gitignore
vendored
29
.gitignore
vendored
@@ -1,5 +1,26 @@
|
||||
.idea
|
||||
.vscode
|
||||
# IDE / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
hubproxy*
|
||||
!hubproxy.service
|
||||
|
||||
# Go build artifacts
|
||||
/hubproxy*
|
||||
*.exe
|
||||
build/
|
||||
|
||||
# Frontend (web/)
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
web/dist-ssr/
|
||||
web/.tmp/
|
||||
web/*.local
|
||||
web/npm-debug.log*
|
||||
web/yarn-debug.log*
|
||||
web/yarn-error.log*
|
||||
web/pnpm-debug.log*
|
||||
|
||||
# Frontend build output embedded by Go
|
||||
src/dist/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
20
Dockerfile
20
Dockerfile
@@ -1,20 +1,30 @@
|
||||
FROM golang:1.25-alpine AS builder
|
||||
FROM node:24-alpine AS frontend
|
||||
|
||||
WORKDIR /web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/ .
|
||||
RUN npm run build
|
||||
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG VERSION=dev
|
||||
|
||||
WORKDIR /app
|
||||
COPY src/go.mod src/go.sum ./
|
||||
RUN go mod download && apk add upx
|
||||
RUN apk add --no-cache upx && go mod download
|
||||
|
||||
COPY src/ .
|
||||
COPY --from=frontend /src/dist ./dist
|
||||
|
||||
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
|
||||
|
||||
WORKDIR /root/
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/hubproxy .
|
||||
COPY --from=builder /app/config.toml .
|
||||
|
||||
CMD ["./hubproxy"]
|
||||
CMD ["./hubproxy"]
|
||||
|
||||
93
README.md
93
README.md
@@ -40,19 +40,70 @@ docker run -d \
|
||||
ghcr.io/sky22333/hubproxy
|
||||
```
|
||||
|
||||
### 一键脚本安装
|
||||
### 脚本安装
|
||||
|
||||
自动识别系统与架构,从 GitHub Releases 下载对应的 `.deb`、`.rpm` 或 `.apk` 安装包:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh | sudo bash
|
||||
curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh | sh
|
||||
```
|
||||
|
||||
支持单个二进制文件直接启动,无需其他配置,内置默认配置,支持所有功能。
|
||||
安装包会自动安装并启动 `hubproxy` 服务。
|
||||
|
||||
这个脚本会:
|
||||
- 自动检测系统架构(AMD64/ARM64)
|
||||
- 从 GitHub Releases 下载最新版本
|
||||
- 自动配置系统服务
|
||||
- 保留现有配置(升级时)
|
||||
<details>
|
||||
<summary>服务管理命令</summary>
|
||||
|
||||
#### systemd(Debian / Ubuntu / RHEL / CentOS / Fedora)
|
||||
|
||||
```bash
|
||||
# 查看状态
|
||||
sudo systemctl status hubproxy
|
||||
|
||||
# 重启服务
|
||||
sudo systemctl restart hubproxy
|
||||
|
||||
# 查看实时日志
|
||||
sudo journalctl -u hubproxy -f
|
||||
|
||||
# 编辑配置文件
|
||||
sudo nano /etc/hubproxy/config.toml
|
||||
|
||||
# 卸载服务
|
||||
sudo apt remove hubproxy
|
||||
|
||||
# 连配置一起清理
|
||||
sudo apt purge hubproxy
|
||||
```
|
||||
|
||||
#### OpenRC(Alpine Linux)
|
||||
|
||||
```bash
|
||||
# 查看状态
|
||||
sudo rc-service hubproxy status
|
||||
|
||||
# 重启服务
|
||||
sudo rc-service hubproxy restart
|
||||
|
||||
# 查看实时日志
|
||||
sudo tail -f /var/log/hubproxy.log
|
||||
|
||||
# 编辑配置文件
|
||||
sudo vi /etc/hubproxy/config.toml
|
||||
|
||||
# 卸载
|
||||
sudo apk del hubproxy
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 文件路径
|
||||
|
||||
- Linux 安装包配置文件:`/etc/hubproxy/config.toml`
|
||||
- Linux 安装包二进制文件:`/usr/bin/hubproxy`
|
||||
- systemd 服务文件:`/lib/systemd/system/hubproxy.service`
|
||||
- Alpine OpenRC 服务文件:`/etc/init.d/hubproxy`
|
||||
- Alpine 日志文件:`/var/log/hubproxy.log`
|
||||
- Alpine 日志轮转配置:`/etc/logrotate.d/hubproxy`
|
||||
|
||||
## 使用方法
|
||||
|
||||
@@ -114,6 +165,8 @@ port = 5000
|
||||
fileSize = 2147483648
|
||||
# HTTP/2 多路复用,提升下载速度
|
||||
enableH2C = false
|
||||
# 是否启用前端页面(Vue SPA)
|
||||
enableFrontend = true
|
||||
|
||||
[rateLimit]
|
||||
# 每个IP每周期允许的请求数(注意Docker镜像会有多个层,会消耗多个次数)
|
||||
@@ -200,9 +253,24 @@ defaultTTL = "20m"
|
||||
|
||||
</details>
|
||||
|
||||
容器内的配置文件位于 `/root/config.toml`
|
||||
### 环境变量(可选)
|
||||
|
||||
脚本部署配置文件位于 `/opt/hubproxy/config.toml`
|
||||
支持通过环境变量覆盖部分配置,优先级高于`config.toml`,以下是默认值:
|
||||
|
||||
```
|
||||
CONFIG_PATH=config.toml # 配置文件路径
|
||||
SERVER_HOST=0.0.0.0 # 监听地址
|
||||
SERVER_PORT=5000 # 监听端口
|
||||
ENABLE_H2C=false # 是否启用 H2C
|
||||
ENABLE_FRONTEND=true # 是否启用前端页面(Vue SPA)
|
||||
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 # 批量下载镜像数量限制
|
||||
ACCESS_PROXY= # 代理配置,例如 socks5://127.0.0.1:1080
|
||||
```
|
||||
|
||||
为了IP限流能够正常运行,反向代理需要传递IP头用来获取访客真实IP,以caddy为例:
|
||||
```
|
||||
@@ -246,7 +314,4 @@ example.com {
|
||||
|
||||
## 界面预览
|
||||
|
||||

|
||||
|
||||
## Star 趋势
|
||||
[](https://starchart.cc/sky22333/hubproxy)
|
||||

|
||||
|
||||
@@ -6,9 +6,9 @@ services:
|
||||
ports:
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
- ./src/config.toml:/root/config.toml
|
||||
- ./src/config.toml:/app/config.toml:ro
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "1g"
|
||||
max-file: "2"
|
||||
max-size: "200m"
|
||||
max-file: "3"
|
||||
286
install.sh
286
install.sh
@@ -1,213 +1,121 @@
|
||||
#!/bin/bash
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# HubProxy 一键安装脚本
|
||||
# 支持自动下载最新版本或使用本地文件安装
|
||||
set -e
|
||||
REPO="${REPO:-sky22333/hubproxy}"
|
||||
VERSION="${VERSION:-latest}"
|
||||
TMP_DIR="${TMP_DIR:-/tmp/hubproxy-install}"
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
log() {
|
||||
printf '%s\n' "$*"
|
||||
}
|
||||
|
||||
# 配置
|
||||
REPO="sky22333/hubproxy"
|
||||
GITHUB_API="https://api.github.com/repos/${REPO}"
|
||||
GITHUB_RELEASES="${GITHUB_API}/releases"
|
||||
SERVICE_NAME="hubproxy"
|
||||
INSTALL_DIR="/opt/hubproxy"
|
||||
CONFIG_FILE="config.toml"
|
||||
BINARY_NAME="hubproxy"
|
||||
LOG_DIR="/var/log/hubproxy"
|
||||
TEMP_DIR="/tmp/hubproxy-install"
|
||||
fail() {
|
||||
printf 'HubProxy 安装失败:%s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo -e "${BLUE}HubProxy 一键安装脚本${NC}"
|
||||
echo "================================================="
|
||||
need_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || fail "缺少必要命令:$1"
|
||||
}
|
||||
|
||||
# 检查是否以root权限运行
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo -e "${RED}此脚本需要root权限运行${NC}"
|
||||
echo "请使用: sudo $0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检测系统架构
|
||||
detect_arch() {
|
||||
local arch=$(uname -m)
|
||||
case $arch in
|
||||
x86_64)
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64)
|
||||
echo "amd64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
echo "arm64"
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}不支持的架构: $arch${NC}"
|
||||
exit 1
|
||||
fail "不支持的系统架构:$(uname -m)"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
ARCH=$(detect_arch)
|
||||
echo -e "${BLUE}检测到架构: linux-${ARCH}${NC}"
|
||||
|
||||
# 检查是否为本地安装模式
|
||||
if [ -f "${BINARY_NAME}" ]; then
|
||||
echo -e "${BLUE}发现本地文件,使用本地安装模式${NC}"
|
||||
LOCAL_INSTALL=true
|
||||
else
|
||||
echo -e "${BLUE}本地无文件,使用自动下载模式${NC}"
|
||||
LOCAL_INSTALL=false
|
||||
|
||||
# 检查依赖
|
||||
missing_deps=()
|
||||
for cmd in curl jq tar; do
|
||||
if ! command -v $cmd &> /dev/null; then
|
||||
missing_deps+=($cmd)
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#missing_deps[@]} -gt 0 ]; then
|
||||
echo -e "${YELLOW}检测到缺少依赖: ${missing_deps[*]}${NC}"
|
||||
echo -e "${BLUE}正在自动安装依赖...${NC}"
|
||||
|
||||
apt update && apt install -y curl jq
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}依赖安装失败${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 重新检查依赖
|
||||
for cmd in curl jq tar; do
|
||||
if ! command -v $cmd &> /dev/null; then
|
||||
echo -e "${RED}依赖安装后仍缺少: $cmd${NC}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "${GREEN}依赖安装成功${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 自动下载功能
|
||||
if [ "$LOCAL_INSTALL" = false ]; then
|
||||
echo -e "${BLUE}获取最新版本信息...${NC}"
|
||||
LATEST_RELEASE=$(curl -s "${GITHUB_RELEASES}/latest")
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}无法获取版本信息${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$(echo "$LATEST_RELEASE" | jq -r '.tag_name')
|
||||
if [ "$VERSION" = "null" ]; then
|
||||
echo -e "${RED}无法解析版本信息${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}最新版本: ${VERSION}${NC}"
|
||||
|
||||
# 构造下载URL
|
||||
ASSET_NAME="hubproxy-${VERSION}-linux-${ARCH}.tar.gz"
|
||||
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${ASSET_NAME}"
|
||||
|
||||
echo -e "${BLUE}下载: ${ASSET_NAME}${NC}"
|
||||
|
||||
# 创建临时目录并下载
|
||||
rm -rf "${TEMP_DIR}"
|
||||
mkdir -p "${TEMP_DIR}"
|
||||
cd "${TEMP_DIR}"
|
||||
|
||||
curl -L -o "${ASSET_NAME}" "${DOWNLOAD_URL}"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}下载失败${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 解压
|
||||
tar -xzf "${ASSET_NAME}"
|
||||
if [ $? -ne 0 ] || [ ! -d "hubproxy" ]; then
|
||||
echo -e "${RED}解压失败${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd hubproxy
|
||||
echo -e "${GREEN}下载完成${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}开始安装 HubProxy...${NC}"
|
||||
|
||||
# 停止现有服务(如果存在)
|
||||
if systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then
|
||||
echo -e "${YELLOW}停止现有服务...${NC}"
|
||||
systemctl stop ${SERVICE_NAME}
|
||||
fi
|
||||
|
||||
# 备份现有配置(如果存在)
|
||||
CONFIG_BACKUP_EXISTS=false
|
||||
if [ -f "${INSTALL_DIR}/${CONFIG_FILE}" ]; then
|
||||
echo -e "${BLUE}备份现有配置...${NC}"
|
||||
cp "${INSTALL_DIR}/${CONFIG_FILE}" "${TEMP_DIR}/config.toml.backup"
|
||||
CONFIG_BACKUP_EXISTS=true
|
||||
fi
|
||||
|
||||
# 1. 创建目录结构
|
||||
echo -e "${BLUE}创建目录结构${NC}"
|
||||
mkdir -p ${INSTALL_DIR}
|
||||
mkdir -p ${LOG_DIR}
|
||||
chmod 755 ${INSTALL_DIR}
|
||||
chmod 755 ${LOG_DIR}
|
||||
|
||||
# 2. 复制二进制文件
|
||||
echo -e "${BLUE}复制二进制文件${NC}"
|
||||
cp "${BINARY_NAME}" "${INSTALL_DIR}/"
|
||||
chmod +x "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
|
||||
# 3. 复制配置文件
|
||||
echo -e "${BLUE}复制配置文件${NC}"
|
||||
if [ -f "${CONFIG_FILE}" ]; then
|
||||
if [ "$CONFIG_BACKUP_EXISTS" = false ]; then
|
||||
cp "${CONFIG_FILE}" "${INSTALL_DIR}/"
|
||||
echo -e "${GREEN}配置文件复制成功${NC}"
|
||||
detect_packager() {
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
echo "apk"
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
echo "deb"
|
||||
elif command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1 || command -v rpm >/dev/null 2>&1; then
|
||||
echo "rpm"
|
||||
else
|
||||
echo -e "${YELLOW}保留现有配置文件${NC}"
|
||||
fail "不支持的系统:需要 apt、dnf、yum、rpm 或 apk"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}配置文件不存在,将使用默认配置${NC}"
|
||||
}
|
||||
|
||||
asset_name() {
|
||||
packager="$1"
|
||||
arch="$2"
|
||||
|
||||
case "$packager:$arch" in
|
||||
deb:amd64|rpm:amd64|apk:amd64) echo "hubproxy-linux-amd64.${packager}" ;;
|
||||
deb:arm64|rpm:arm64|apk:arm64) echo "hubproxy-linux-arm64.${packager}" ;;
|
||||
*) fail "不支持的安装包目标:${packager}/${arch}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
asset_url() {
|
||||
asset="$1"
|
||||
|
||||
if [ "$VERSION" = "latest" ]; then
|
||||
echo "https://github.com/${REPO}/releases/latest/download/${asset}"
|
||||
else
|
||||
echo "https://github.com/${REPO}/releases/download/${VERSION}/${asset}"
|
||||
fi
|
||||
}
|
||||
|
||||
install_package() {
|
||||
package_file="$1"
|
||||
packager="$2"
|
||||
|
||||
case "$packager" in
|
||||
deb)
|
||||
apt-get install -y "$package_file"
|
||||
;;
|
||||
rpm)
|
||||
if command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y "$package_file"
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y "$package_file"
|
||||
else
|
||||
rpm -Uvh "$package_file"
|
||||
fi
|
||||
;;
|
||||
apk)
|
||||
apk add --allow-untrusted "$package_file"
|
||||
;;
|
||||
*)
|
||||
fail "不支持的包管理器:$packager"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
fail "请使用 root 权限运行"
|
||||
fi
|
||||
|
||||
# 5. 安装systemd服务文件
|
||||
echo -e "${BLUE}安装systemd服务文件${NC}"
|
||||
cp "${SERVICE_NAME}.service" "/etc/systemd/system/"
|
||||
systemctl daemon-reload
|
||||
need_cmd curl
|
||||
|
||||
# 6. 恢复配置文件(如果有备份)
|
||||
if [ "$CONFIG_BACKUP_EXISTS" = true ]; then
|
||||
echo -e "${BLUE}恢复配置文件...${NC}"
|
||||
cp "${TEMP_DIR}/config.toml.backup" "${INSTALL_DIR}/${CONFIG_FILE}"
|
||||
fi
|
||||
ARCH="$(detect_arch)"
|
||||
PACKAGER="$(detect_packager)"
|
||||
|
||||
# 7. 启用并启动服务
|
||||
echo -e "${BLUE}启用并启动服务${NC}"
|
||||
systemctl enable ${SERVICE_NAME}
|
||||
systemctl start ${SERVICE_NAME}
|
||||
rm -rf "$TMP_DIR"
|
||||
mkdir -p "$TMP_DIR"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT INT TERM
|
||||
|
||||
# 8. 清理临时文件
|
||||
if [ "$LOCAL_INSTALL" = false ]; then
|
||||
echo -e "${BLUE}清理临时文件...${NC}"
|
||||
cd /
|
||||
rm -rf "${TEMP_DIR}"
|
||||
fi
|
||||
log "安装 HubProxy:linux/${ARCH}(${PACKAGER})"
|
||||
|
||||
# 9. 检查服务状态
|
||||
sleep 2
|
||||
if systemctl is-active --quiet ${SERVICE_NAME}; then
|
||||
echo ""
|
||||
echo -e "${GREEN}HubProxy 安装成功!${NC}"
|
||||
echo -e "${GREEN}默认运行端口: 5000${NC}"
|
||||
echo -e "${GREEN}配置文件路径: ${INSTALL_DIR}/${CONFIG_FILE}${NC}"
|
||||
else
|
||||
echo -e "${RED}服务启动失败${NC}"
|
||||
echo "查看错误日志: sudo journalctl -u ${SERVICE_NAME} -f"
|
||||
exit 1
|
||||
fi
|
||||
ASSET="$(asset_name "$PACKAGER" "$ARCH")"
|
||||
ASSET_URL="$(asset_url "$ASSET")"
|
||||
|
||||
PACKAGE_FILE="${TMP_DIR}/$(basename "$ASSET_URL")"
|
||||
log "下载安装包..."
|
||||
curl -fL -o "$PACKAGE_FILE" "$ASSET_URL" || fail "下载安装包失败"
|
||||
|
||||
log "安装软件包..."
|
||||
install_package "$PACKAGE_FILE" "$PACKAGER"
|
||||
|
||||
log "安装完成"
|
||||
log "默认端口:5000"
|
||||
log "配置文件:/etc/hubproxy/config.toml"
|
||||
|
||||
9
packaging/hubproxy.logrotate
Normal file
9
packaging/hubproxy.logrotate
Normal file
@@ -0,0 +1,9 @@
|
||||
/var/log/hubproxy.log {
|
||||
weekly
|
||||
maxsize 50M
|
||||
rotate 4
|
||||
compress
|
||||
missingok
|
||||
notifempty
|
||||
copytruncate
|
||||
}
|
||||
18
packaging/hubproxy.openrc
Normal file
18
packaging/hubproxy.openrc
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/sbin/openrc-run
|
||||
|
||||
name="hubproxy"
|
||||
description="Docker and GitHub acceleration proxy server"
|
||||
command="/usr/bin/hubproxy"
|
||||
pidfile="/run/${RC_SVCNAME}.pid"
|
||||
output_log="/var/log/hubproxy.log"
|
||||
error_log="/var/log/hubproxy.log"
|
||||
supervisor="supervise-daemon"
|
||||
respawn_delay=5
|
||||
respawn_max=0
|
||||
|
||||
export CONFIG_PATH="/etc/hubproxy/config.toml"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
after firewall
|
||||
}
|
||||
@@ -7,11 +7,10 @@ Wants=network-online.target
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/opt/hubproxy
|
||||
ExecStart=/opt/hubproxy/hubproxy
|
||||
Environment=CONFIG_PATH=/etc/hubproxy/config.toml
|
||||
ExecStart=/usr/bin/hubproxy
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=hubproxy
|
||||
45
packaging/nfpm.apk.yaml
Normal file
45
packaging/nfpm.apk.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
name: hubproxy
|
||||
arch: ${NFPM_ARCH}
|
||||
platform: linux
|
||||
version: ${NFPM_VERSION}
|
||||
release: "1"
|
||||
section: net
|
||||
priority: optional
|
||||
maintainer: sky22333
|
||||
description: Docker and GitHub acceleration proxy server
|
||||
vendor: sky22333
|
||||
homepage: https://github.com/sky22333/hubproxy
|
||||
license: MIT
|
||||
depends:
|
||||
- logrotate
|
||||
|
||||
contents:
|
||||
- src: ./build/package-root/hubproxy
|
||||
dst: /usr/bin/hubproxy
|
||||
file_info:
|
||||
mode: 0755
|
||||
|
||||
- src: ./src/config.toml
|
||||
dst: /etc/hubproxy/config.toml
|
||||
type: config|noreplace
|
||||
file_info:
|
||||
mode: 0644
|
||||
|
||||
- src: ./packaging/hubproxy.openrc
|
||||
dst: /etc/init.d/hubproxy
|
||||
file_info:
|
||||
mode: 0755
|
||||
|
||||
- src: ./packaging/hubproxy.logrotate
|
||||
dst: /etc/logrotate.d/hubproxy
|
||||
file_info:
|
||||
mode: 0644
|
||||
|
||||
scripts:
|
||||
postinstall: ./packaging/postinstall.sh
|
||||
preremove: ./packaging/preremove.sh
|
||||
postremove: ./packaging/postremove.sh
|
||||
|
||||
apk:
|
||||
scripts:
|
||||
postupgrade: ./packaging/postinstall.sh
|
||||
34
packaging/nfpm.deb-rpm.yaml
Normal file
34
packaging/nfpm.deb-rpm.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
name: hubproxy
|
||||
arch: ${NFPM_ARCH}
|
||||
platform: linux
|
||||
version: ${NFPM_VERSION}
|
||||
release: "1"
|
||||
section: net
|
||||
priority: optional
|
||||
maintainer: sky22333
|
||||
description: Docker and GitHub acceleration proxy server
|
||||
vendor: sky22333
|
||||
homepage: https://github.com/sky22333/hubproxy
|
||||
license: MIT
|
||||
|
||||
contents:
|
||||
- src: ./build/package-root/hubproxy
|
||||
dst: /usr/bin/hubproxy
|
||||
file_info:
|
||||
mode: 0755
|
||||
|
||||
- src: ./src/config.toml
|
||||
dst: /etc/hubproxy/config.toml
|
||||
type: config|noreplace
|
||||
file_info:
|
||||
mode: 0644
|
||||
|
||||
- src: ./packaging/hubproxy.service
|
||||
dst: /lib/systemd/system/hubproxy.service
|
||||
file_info:
|
||||
mode: 0644
|
||||
|
||||
scripts:
|
||||
postinstall: ./packaging/postinstall.sh
|
||||
preremove: ./packaging/preremove.sh
|
||||
postremove: ./packaging/postremove.sh
|
||||
27
packaging/postinstall.sh
Normal file
27
packaging/postinstall.sh
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
warn() {
|
||||
echo "hubproxy: $1"
|
||||
}
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl daemon-reload || warn "systemd reload failed"
|
||||
systemctl enable hubproxy >/dev/null 2>&1 || warn "systemd enable failed"
|
||||
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl restart hubproxy || systemctl start hubproxy || {
|
||||
warn "service start failed, check: journalctl -u hubproxy"
|
||||
}
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v rc-update >/dev/null 2>&1; then
|
||||
rc-update add hubproxy default >/dev/null 2>&1 || warn "OpenRC enable failed"
|
||||
fi
|
||||
|
||||
if command -v rc-service >/dev/null 2>&1; then
|
||||
rc-service hubproxy restart || rc-service hubproxy start || {
|
||||
warn "service start failed, check: rc-service hubproxy status"
|
||||
}
|
||||
fi
|
||||
6
packaging/postremove.sh
Normal file
6
packaging/postremove.sh
Normal file
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
21
packaging/preremove.sh
Normal file
21
packaging/preremove.sh
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
case "${1:-}" in
|
||||
1|upgrade)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl stop hubproxy >/dev/null 2>&1 || true
|
||||
systemctl disable hubproxy >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if command -v rc-service >/dev/null 2>&1; then
|
||||
rc-service hubproxy stop >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if command -v rc-update >/dev/null 2>&1; then
|
||||
rc-update del hubproxy default >/dev/null 2>&1 || true
|
||||
fi
|
||||
@@ -1,4 +1,5 @@
|
||||
[server]
|
||||
# 可通过 CONFIG_PATH 环境变量指定配置文件路径,默认读取当前工作目录下的 config.toml
|
||||
host = "0.0.0.0"
|
||||
# 监听端口
|
||||
port = 5000
|
||||
@@ -6,6 +7,7 @@ port = 5000
|
||||
fileSize = 2147483648
|
||||
# HTTP/2 多路复用
|
||||
enableH2C = false
|
||||
enableFrontend = true
|
||||
|
||||
[rateLimit]
|
||||
# 每个IP每周期允许的请求数
|
||||
@@ -18,8 +20,7 @@ periodHours = 3.0
|
||||
# 白名单中的IP不受限流限制
|
||||
whiteList = [
|
||||
"127.0.0.1",
|
||||
"172.17.0.0/16",
|
||||
"192.168.1.0/24"
|
||||
"127.0.0.2"
|
||||
]
|
||||
|
||||
# IP黑名单,支持单个IP或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"`
|
||||
@@ -194,16 +197,23 @@ func setConfig(cfg *AppConfig) {
|
||||
configCacheMutex.Unlock()
|
||||
}
|
||||
|
||||
// LoadConfig 加载配置文件
|
||||
func configFilePath() string {
|
||||
if path := strings.TrimSpace(os.Getenv("CONFIG_PATH")); path != "" {
|
||||
return path
|
||||
}
|
||||
return "config.toml"
|
||||
}
|
||||
|
||||
func LoadConfig() error {
|
||||
cfg := DefaultConfig()
|
||||
path := configFilePath()
|
||||
|
||||
if data, err := os.ReadFile("config.toml"); err == nil {
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := toml.Unmarshal(data, cfg); err != nil {
|
||||
return fmt.Errorf("解析配置文件失败: %v", err)
|
||||
return fmt.Errorf("解析配置文件 %s 失败: %v", path, err)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("未找到config.toml,使用默认配置")
|
||||
fmt.Printf("未找到配置文件 %s,使用默认配置\n", path)
|
||||
}
|
||||
|
||||
overrideFromEnv(cfg)
|
||||
@@ -227,6 +237,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
|
||||
@@ -251,21 +266,13 @@ func overrideFromEnv(cfg *AppConfig) {
|
||||
cfg.Security.BlackList = append(cfg.Security.BlackList, strings.Split(val, ",")...)
|
||||
}
|
||||
|
||||
if val, ok := os.LookupEnv("ACCESS_PROXY"); ok {
|
||||
cfg.Access.Proxy = strings.TrimSpace(val)
|
||||
}
|
||||
|
||||
if val := os.Getenv("MAX_IMAGES"); val != "" {
|
||||
if maxImages, err := strconv.Atoi(val); err == nil && maxImages > 0 {
|
||||
cfg.Download.MaxImages = maxImages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CreateDefaultConfigFile 创建默认配置文件
|
||||
func CreateDefaultConfigFile() error {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
data, err := toml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化默认配置失败: %v", err)
|
||||
}
|
||||
|
||||
return os.WriteFile("config.toml", data, 0644)
|
||||
}
|
||||
|
||||
41
src/config/config_test.go
Normal file
41
src/config/config_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfigUsesConfigPathAndEnvOverrides(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "custom.toml")
|
||||
data := []byte(`
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 5999
|
||||
|
||||
[access]
|
||||
proxy = "socks5://127.0.0.1:1080"
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("CONFIG_PATH", path)
|
||||
t.Setenv("SERVER_PORT", "6001")
|
||||
t.Setenv("ACCESS_PROXY", "")
|
||||
|
||||
if err := LoadConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := GetConfig()
|
||||
if cfg.Server.Host != "127.0.0.1" {
|
||||
t.Fatalf("Server.Host = %q", cfg.Server.Host)
|
||||
}
|
||||
if cfg.Server.Port != 6001 {
|
||||
t.Fatalf("Server.Port = %d, want 6001", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Access.Proxy != "" {
|
||||
t.Fatalf("Access.Proxy = %q, want empty override", cfg.Access.Proxy)
|
||||
}
|
||||
}
|
||||
62
src/go.mod
62
src/go.mod
@@ -1,33 +1,33 @@
|
||||
module hubproxy
|
||||
|
||||
go 1.25
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/google/go-containerregistry v0.20.6
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/time v0.12.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/google/go-containerregistry v0.21.5
|
||||
github.com/pelletier/go-toml/v2 v2.3.1
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/time v0.15.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
|
||||
github.com/docker/cli v28.2.2+incompatible // indirect
|
||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect
|
||||
github.com/docker/cli v29.4.0+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
@@ -35,16 +35,18 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/vbatts/tar-split v0.12.1 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
google.golang.org/protobuf v1.36.3 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/vbatts/tar-split v0.12.2 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
)
|
||||
|
||||
139
src/go.sum
139
src/go.sum
@@ -1,51 +1,49 @@
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8=
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw=
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A=
|
||||
github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
|
||||
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM=
|
||||
github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
|
||||
github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU=
|
||||
github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y=
|
||||
github.com/google/go-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM=
|
||||
github.com/google/go-containerregistry v0.21.5/go.mod h1:ySvMuiWg+dOsRW0Hw8GYwfMwBlNRTmpYBFJPlkco5zU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -61,56 +59,57 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
|
||||
github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4=
|
||||
github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
|
||||
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
|
||||
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
|
||||
@@ -108,9 +108,6 @@ func handleRegistryRequest(c *gin.Context, path string) {
|
||||
|
||||
if registryDomain, remainingPath := registryDetector.detectRegistryDomain(c, pathWithoutV2); registryDomain != "" {
|
||||
if registryDetector.isRegistryEnabled(registryDomain) {
|
||||
c.Set("target_registry_domain", registryDomain)
|
||||
c.Set("target_path", remainingPath)
|
||||
|
||||
handleMultiRegistryRequest(c, registryDomain, remainingPath)
|
||||
return
|
||||
}
|
||||
@@ -274,7 +271,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列表请求
|
||||
@@ -353,20 +352,7 @@ func (r *ResponseRecorder) Write(data []byte) (int, error) {
|
||||
}
|
||||
|
||||
func proxyDockerAuthOriginal(c *gin.Context) {
|
||||
var authURL string
|
||||
if targetDomain, exists := c.Get("target_registry_domain"); exists {
|
||||
if mapping, found := registryDetector.getRegistryMapping(targetDomain.(string)); found {
|
||||
authURL = "https://" + mapping.AuthHost + c.Request.URL.Path
|
||||
} else {
|
||||
authURL = "https://auth.docker.io" + c.Request.URL.Path
|
||||
}
|
||||
} else {
|
||||
authURL = "https://auth.docker.io" + c.Request.URL.Path
|
||||
}
|
||||
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
authURL += "?" + c.Request.URL.RawQuery
|
||||
}
|
||||
authURL := buildDockerAuthURL(c)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
@@ -416,15 +402,58 @@ 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 重写认证头
|
||||
// buildDockerAuthURL 根据 token 请求的 service 参数选择上游认证地址。
|
||||
// AuthHost 已包含路径(如 ghcr.io/token、quay.io/v2/auth),不再拼接本机 Path,避免 /token/token。
|
||||
func buildDockerAuthURL(c *gin.Context) string {
|
||||
authHost := resolveAuthHost(c.Query("service"))
|
||||
var authURL string
|
||||
if authHost != "" {
|
||||
authURL = "https://" + authHost
|
||||
} else {
|
||||
authURL = "https://auth.docker.io" + c.Request.URL.Path
|
||||
}
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
authURL += "?" + c.Request.URL.RawQuery
|
||||
}
|
||||
return authURL
|
||||
}
|
||||
|
||||
// resolveAuthHost 用 service 匹配已启用 Registry 的 AuthHost;Docker Hub 返回空串走默认路径。
|
||||
func resolveAuthHost(service string) string {
|
||||
if service == "" || service == "registry.docker.io" || service == "docker.io" {
|
||||
return ""
|
||||
}
|
||||
|
||||
cfg := config.GetConfig()
|
||||
for domain, mapping := range cfg.Registries {
|
||||
if !mapping.Enabled || mapping.AuthHost == "" {
|
||||
continue
|
||||
}
|
||||
if service == domain || service == mapping.Upstream {
|
||||
return mapping.AuthHost
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// rewriteAuthHeader 将上游认证 realm 统一改写到本机 /token,避免 quay 等变成 /v2/auth 误入 Registry 路由。
|
||||
func rewriteAuthHeader(authHeader, proxyHost string) string {
|
||||
proxyToken := "http://" + proxyHost + "/token"
|
||||
|
||||
cfg := config.GetConfig()
|
||||
for _, mapping := range cfg.Registries {
|
||||
if mapping.AuthHost == "" {
|
||||
continue
|
||||
}
|
||||
authHeader = strings.ReplaceAll(authHeader, "https://"+mapping.AuthHost, proxyToken)
|
||||
}
|
||||
authHeader = strings.ReplaceAll(authHeader, "https://auth.docker.io/token", proxyToken)
|
||||
authHeader = strings.ReplaceAll(authHeader, "https://auth.docker.io", "http://"+proxyHost)
|
||||
authHeader = strings.ReplaceAll(authHeader, "https://ghcr.io", "http://"+proxyHost)
|
||||
authHeader = strings.ReplaceAll(authHeader, "https://gcr.io", "http://"+proxyHost)
|
||||
authHeader = strings.ReplaceAll(authHeader, "https://quay.io", "http://"+proxyHost)
|
||||
|
||||
return authHeader
|
||||
}
|
||||
@@ -569,7 +598,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请求
|
||||
|
||||
155
src/handlers/docker_test.go
Normal file
155
src/handlers/docker_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hubproxy/config"
|
||||
)
|
||||
|
||||
func TestParseRegistryPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
image string
|
||||
apiType string
|
||||
reference string
|
||||
}{
|
||||
{"library/nginx/manifests/latest", "library/nginx", "manifests", "latest"},
|
||||
{"library/nginx/blobs/sha256:abc", "library/nginx", "blobs", "sha256:abc"},
|
||||
{"library/nginx/tags/list", "library/nginx", "tags", "list"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
image, apiType, reference := parseRegistryPath(tt.path)
|
||||
if image != tt.image || apiType != tt.apiType || reference != tt.reference {
|
||||
t.Fatalf("parseRegistryPath(%q) = %q %q %q", tt.path, image, apiType, reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRegistryPathInvalid(t *testing.T) {
|
||||
image, apiType, reference := parseRegistryPath("library/nginx/unknown/latest")
|
||||
if image != "" || apiType != "" || reference != "" {
|
||||
t.Fatalf("invalid path parsed as %q %q %q", image, apiType, reference)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAuthHost(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
data := []byte(`
|
||||
[registries."ghcr.io"]
|
||||
upstream = "ghcr.io"
|
||||
authHost = "ghcr.io/token"
|
||||
authType = "github"
|
||||
enabled = true
|
||||
|
||||
[registries."quay.io"]
|
||||
upstream = "quay.io"
|
||||
authHost = "quay.io/v2/auth"
|
||||
authType = "quay"
|
||||
enabled = true
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CONFIG_PATH", path)
|
||||
if err := config.LoadConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := resolveAuthHost(""); got != "" {
|
||||
t.Fatalf("empty service = %q", got)
|
||||
}
|
||||
if got := resolveAuthHost("registry.docker.io"); got != "" {
|
||||
t.Fatalf("docker hub service = %q", got)
|
||||
}
|
||||
if got := resolveAuthHost("ghcr.io"); got != "ghcr.io/token" {
|
||||
t.Fatalf("ghcr.io = %q", got)
|
||||
}
|
||||
if got := resolveAuthHost("quay.io"); got != "quay.io/v2/auth" {
|
||||
t.Fatalf("quay.io = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDockerAuthURL(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
data := []byte(`
|
||||
[registries."ghcr.io"]
|
||||
upstream = "ghcr.io"
|
||||
authHost = "ghcr.io/token"
|
||||
enabled = true
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CONFIG_PATH", path)
|
||||
if err := config.LoadConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("docker hub keeps path", func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/token?service=registry.docker.io&scope=repository:library/nginx:pull", nil)
|
||||
got := buildDockerAuthURL(c)
|
||||
want := "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/nginx:pull"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ghcr uses AuthHost without duplicating path", func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/token?service=ghcr.io&scope=repository:foo/bar:pull", nil)
|
||||
got := buildDockerAuthURL(c)
|
||||
want := "https://ghcr.io/token?service=ghcr.io&scope=repository:foo/bar:pull"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRewriteAuthHeader(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
data := []byte(`
|
||||
[registries."quay.io"]
|
||||
upstream = "quay.io"
|
||||
authHost = "quay.io/v2/auth"
|
||||
enabled = true
|
||||
|
||||
[registries."ghcr.io"]
|
||||
upstream = "ghcr.io"
|
||||
authHost = "ghcr.io/token"
|
||||
enabled = true
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CONFIG_PATH", path)
|
||||
if err := config.LoadConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := rewriteAuthHeader(`Bearer realm="https://quay.io/v2/auth",service="quay.io"`, "proxy.example.com")
|
||||
want := `Bearer realm="http://proxy.example.com/token",service="quay.io"`
|
||||
if got != want {
|
||||
t.Fatalf("quay rewrite: got %q want %q", got, want)
|
||||
}
|
||||
|
||||
got = rewriteAuthHeader(`Bearer realm="https://ghcr.io/token",service="ghcr.io"`, "proxy.example.com")
|
||||
want = `Bearer realm="http://proxy.example.com/token",service="ghcr.io"`
|
||||
if got != want {
|
||||
t.Fatalf("ghcr rewrite: got %q want %q", got, want)
|
||||
}
|
||||
|
||||
got = rewriteAuthHeader(`Bearer realm="https://auth.docker.io/token",service="registry.docker.io"`, "proxy.example.com")
|
||||
want = `Bearer realm="http://proxy.example.com/token",service="registry.docker.io"`
|
||||
if got != want {
|
||||
t.Fatalf("docker hub rewrite: got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ var (
|
||||
regexp.MustCompile(`^(?:https?://)?api\.github\.com/repos/([^/]+)/([^/]+)/.*`),
|
||||
regexp.MustCompile(`^(?:https?://)?huggingface\.co(?:/spaces)?/([^/]+)/(.+)`),
|
||||
regexp.MustCompile(`^(?:https?://)?cdn-lfs\.hf\.co(?:/spaces)?/([^/]+)/([^/]+)(?:/(.*))?`),
|
||||
regexp.MustCompile(`^(?:https?://)?download\.docker\.com/([^/]+)/.*\.(tgz|zip)`),
|
||||
regexp.MustCompile(`^(?:https?://)?(github|opengraph)\.githubassets\.com/([^/]+)/.+?`),
|
||||
}
|
||||
)
|
||||
@@ -129,7 +128,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 +226,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
35
src/handlers/github_test.go
Normal file
35
src/handlers/github_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package handlers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCheckGitHubURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
user string
|
||||
repo string
|
||||
}{
|
||||
{"release", "https://github.com/user/repo/releases/download/v1/file.tar.gz", "user", "repo"},
|
||||
{"raw", "https://raw.githubusercontent.com/user/repo/main/file.sh", "user", "repo"},
|
||||
{"api", "https://api.github.com/repos/user/repo/releases/latest", "user", "repo"},
|
||||
{"huggingface", "https://huggingface.co/user/model/resolve/main/file", "user", "model/resolve/main/file"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := CheckGitHubURL(tt.url)
|
||||
if len(got) < 2 || got[0] != tt.user || got[1] != tt.repo {
|
||||
t.Fatalf("CheckGitHubURL(%q) = %#v", tt.url, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckGitHubURLRejectsOtherHosts(t *testing.T) {
|
||||
if got := CheckGitHubURL("https://example.com/user/repo/file"); got != nil {
|
||||
t.Fatalf("unexpected match: %#v", got)
|
||||
}
|
||||
if got := CheckGitHubURL("https://download.docker.com/linux/static/stable/x86_64/docker.tgz"); got != nil {
|
||||
t.Fatalf("download.docker.com should be rejected: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -185,28 +289,43 @@ func (is *ImageStreamer) StreamImageToWriter(ctx context.Context, imageRef strin
|
||||
|
||||
contextOptions := append(is.remoteOptions, remote.WithContext(ctx))
|
||||
|
||||
desc, err := is.getImageDescriptorWithPlatform(ref, contextOptions, options.Platform)
|
||||
desc, err := is.getImageDescriptor(ref, contextOptions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取镜像描述失败: %w", err)
|
||||
}
|
||||
switch desc.MediaType {
|
||||
case types.OCIImageIndex, types.DockerManifestList:
|
||||
return is.streamMultiArchImage(ctx, desc, writer, options, contextOptions, imageRef)
|
||||
return is.streamMultiArchImage(ctx, desc, writer, options, imageRef)
|
||||
case types.OCIManifestSchema1, types.DockerManifestSchema2:
|
||||
return is.streamSingleImage(ctx, desc, writer, options, contextOptions, imageRef)
|
||||
return is.streamSingleImage(ctx, desc, writer, options, imageRef)
|
||||
default:
|
||||
return is.streamSingleImage(ctx, desc, writer, options, contextOptions, imageRef)
|
||||
return is.streamSingleImage(ctx, desc, writer, options, imageRef)
|
||||
}
|
||||
}
|
||||
|
||||
// getImageDescriptor 获取镜像描述符
|
||||
func (is *ImageStreamer) getImageDescriptor(ref name.Reference, options []remote.Option) (*remote.Descriptor, error) {
|
||||
return is.getImageDescriptorWithPlatform(ref, options, "")
|
||||
return remote.Get(ref, options...)
|
||||
}
|
||||
|
||||
// getImageDescriptorWithPlatform 获取指定平台的镜像描述符
|
||||
func (is *ImageStreamer) getImageDescriptorWithPlatform(ref name.Reference, options []remote.Option, platform string) (*remote.Descriptor, error) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// writeDownloadError 仅在尚未写出响应体时返回 JSON;流已开始则只记日志,避免损坏 tar。
|
||||
func writeDownloadError(c *gin.Context, err error, message string) {
|
||||
if c.Writer.Written() {
|
||||
log.Printf("%s: %v", message, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": message + ": " + err.Error()})
|
||||
}
|
||||
|
||||
// StreamImageToGin 流式响应到Gin
|
||||
@@ -215,19 +334,32 @@ func (is *ImageStreamer) StreamImageToGin(ctx context.Context, imageRef string,
|
||||
options = &StreamOptions{UseCompressedLayers: true}
|
||||
}
|
||||
|
||||
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")
|
||||
ref, err := name.ParseReference(imageRef)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析镜像引用失败: %w", err)
|
||||
}
|
||||
|
||||
return is.StreamImageToWriter(ctx, imageRef, c.Writer, options)
|
||||
contextOptions := append(is.remoteOptions, remote.WithContext(ctx))
|
||||
desc, err := is.getImageDescriptor(ref, contextOptions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取镜像描述失败: %w", err)
|
||||
}
|
||||
|
||||
filename := strings.ReplaceAll(imageRef, "/", "_") + ".tar"
|
||||
setDownloadHeaders(c, filename, options.Compression)
|
||||
|
||||
switch desc.MediaType {
|
||||
case types.OCIImageIndex, types.DockerManifestList:
|
||||
return is.streamMultiArchImage(ctx, desc, c.Writer, options, imageRef)
|
||||
case types.OCIManifestSchema1, types.DockerManifestSchema2:
|
||||
return is.streamSingleImage(ctx, desc, c.Writer, options, imageRef)
|
||||
default:
|
||||
return is.streamSingleImage(ctx, desc, c.Writer, options, imageRef)
|
||||
}
|
||||
}
|
||||
|
||||
// streamMultiArchImage 处理多架构镜像
|
||||
func (is *ImageStreamer) streamMultiArchImage(ctx context.Context, desc *remote.Descriptor, writer io.Writer, options *StreamOptions, remoteOptions []remote.Option, imageRef string) error {
|
||||
func (is *ImageStreamer) streamMultiArchImage(ctx context.Context, desc *remote.Descriptor, writer io.Writer, options *StreamOptions, imageRef string) error {
|
||||
img, err := is.selectPlatformImage(desc, options)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -237,7 +369,7 @@ func (is *ImageStreamer) streamMultiArchImage(ctx context.Context, desc *remote.
|
||||
}
|
||||
|
||||
// streamSingleImage 处理单架构镜像
|
||||
func (is *ImageStreamer) streamSingleImage(ctx context.Context, desc *remote.Descriptor, writer io.Writer, options *StreamOptions, remoteOptions []remote.Option, imageRef string) error {
|
||||
func (is *ImageStreamer) streamSingleImage(ctx context.Context, desc *remote.Descriptor, writer io.Writer, options *StreamOptions, imageRef string) error {
|
||||
img, err := desc.Image()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取镜像失败: %w", err)
|
||||
@@ -473,7 +605,7 @@ func (is *ImageStreamer) streamSingleImageForBatch(ctx context.Context, tarWrite
|
||||
|
||||
contextOptions := append(is.remoteOptions, remote.WithContext(ctx))
|
||||
|
||||
desc, err := is.getImageDescriptorWithPlatform(ref, contextOptions, options.Platform)
|
||||
desc, err := is.getImageDescriptor(ref, contextOptions)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("获取镜像描述失败: %w", err)
|
||||
}
|
||||
@@ -577,21 +709,26 @@ func formatPlatformText(platform string) string {
|
||||
func InitImageTarRoutes(router *gin.Engine) {
|
||||
imageAPI := router.Group("/api/image")
|
||||
{
|
||||
imageAPI.GET("/download/:image", handleDirectImageDownload)
|
||||
imageAPI.GET("/info/:image", handleImageInfo)
|
||||
imageAPI.GET("/download", handleDirectImageDownload)
|
||||
imageAPI.GET("/info", handleImageInfo)
|
||||
imageAPI.GET("/batch", handleSimpleBatchDownload)
|
||||
imageAPI.POST("/batch", handleSimpleBatchDownload)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveImageRef 从 query image 读取镜像引用,避免 path 段用 _ 代替 / 导致下划线歧义。
|
||||
func resolveImageRef(c *gin.Context) string {
|
||||
return strings.TrimSpace(c.Query("image"))
|
||||
}
|
||||
|
||||
// handleDirectImageDownload 处理单镜像下载
|
||||
func handleDirectImageDownload(c *gin.Context) {
|
||||
imageParam := c.Param("image")
|
||||
if imageParam == "" {
|
||||
imageRef := resolveImageRef(c)
|
||||
if imageRef == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少镜像参数"})
|
||||
return
|
||||
}
|
||||
|
||||
imageRef := strings.ReplaceAll(imageParam, "_", "/")
|
||||
platform := c.Query("platform")
|
||||
tag := c.DefaultQuery("tag", "")
|
||||
useCompressed := c.DefaultQuery("compressed", "true") == "true"
|
||||
@@ -606,36 +743,122 @@ 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
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("image", imageRef)
|
||||
q.Set("token", token)
|
||||
c.JSON(http.StatusOK, gin.H{"download_url": "/api/image/download?" + q.Encode()})
|
||||
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 {
|
||||
log.Printf("镜像下载失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "镜像下载失败: " + err.Error()})
|
||||
if err := globalImageStreamer.StreamImageToGin(ctx, req.Image, c, options); err != nil {
|
||||
writeDownloadError(c, err, "镜像下载失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
writeDownloadError(c, err, "批量镜像下载失败")
|
||||
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 +874,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,36 +917,29 @@ 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 处理镜像信息查询
|
||||
func handleImageInfo(c *gin.Context) {
|
||||
imageParam := c.Param("image")
|
||||
if imageParam == "" {
|
||||
imageRef := resolveImageRef(c)
|
||||
if imageRef == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少镜像参数"})
|
||||
return
|
||||
}
|
||||
|
||||
imageRef := strings.ReplaceAll(imageParam, "_", "/")
|
||||
tag := c.DefaultQuery("tag", "latest")
|
||||
|
||||
if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") {
|
||||
@@ -723,6 +951,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))
|
||||
|
||||
115
src/handlers/imagetar_test.go
Normal file
115
src/handlers/imagetar_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestDownloadDebouncer(t *testing.T) {
|
||||
d := NewDownloadDebouncer(time.Minute)
|
||||
if !d.ShouldAllow("user", "content") {
|
||||
t.Fatal("first request denied")
|
||||
}
|
||||
if d.ShouldAllow("user", "content") {
|
||||
t.Fatal("duplicate request allowed")
|
||||
}
|
||||
if !d.ShouldAllow("other", "content") {
|
||||
t.Fatal("different user denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenStoreCreateConsume(t *testing.T) {
|
||||
store := newTokenStore[SingleDownloadRequest]()
|
||||
req := SingleDownloadRequest{Image: "nginx:latest", Platform: "linux/amd64", UseCompressedLayers: true}
|
||||
|
||||
token, err := store.create(req, "127.0.0.1", "ua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, ok := store.consume(token, "127.0.0.1", "ua")
|
||||
if !ok {
|
||||
t.Fatal("token not consumed")
|
||||
}
|
||||
if got != req {
|
||||
t.Fatalf("request = %#v, want %#v", got, req)
|
||||
}
|
||||
if _, ok := store.consume(token, "127.0.0.1", "ua"); ok {
|
||||
t.Fatal("token consumed twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenStoreRejectsDifferentClient(t *testing.T) {
|
||||
store := newTokenStore[SingleDownloadRequest]()
|
||||
token, err := store.create(SingleDownloadRequest{Image: "nginx:latest"}, "127.0.0.1", "ua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := store.consume(token, "127.0.0.2", "ua"); ok {
|
||||
t.Fatal("token accepted for different IP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateContentFingerprintStable(t *testing.T) {
|
||||
a := generateContentFingerprint([]string{"b:1", "a:1"}, "linux/amd64")
|
||||
b := generateContentFingerprint([]string{"a:1", "b:1"}, "linux/amd64")
|
||||
c := generateContentFingerprint([]string{"a:1", "b:1"}, "linux/arm64")
|
||||
if a != b || a == c {
|
||||
t.Fatalf("unexpected fingerprints: %q %q %q", a, b, c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveImageRef(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("query preserves underscores", func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/image/download?image=user/my_app:v1", nil)
|
||||
if got := resolveImageRef(c); got != "user/my_app:v1" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing image is empty", func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/image/download", nil)
|
||||
if got := resolveImageRef(c); got != "" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteDownloadErrorSkipsJSONAfterBodyStarted(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("before write returns json", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
writeDownloadError(c, errors.New("boom"), "镜像下载失败")
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "镜像下载失败") || !strings.Contains(body, "boom") {
|
||||
t.Fatalf("body = %q", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("after write skips json", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
if _, err := c.Writer.Write([]byte("tar-bytes")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeDownloadError(c, errors.New("boom"), "镜像下载失败")
|
||||
if got := w.Body.String(); got != "tar-bytes" {
|
||||
t.Fatalf("body corrupted: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -508,9 +466,9 @@ func sendErrorResponse(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": message})
|
||||
}
|
||||
|
||||
// RegisterSearchRoute 注册搜索相关路由
|
||||
// RegisterSearchRoute 注册搜索与标签相关 API 路由。
|
||||
func RegisterSearchRoute(r *gin.Engine) {
|
||||
r.GET("/search", func(c *gin.Context) {
|
||||
r.GET("/api/search", func(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
sendErrorResponse(c, "搜索关键词不能为空")
|
||||
@@ -528,7 +486,7 @@ func RegisterSearchRoute(r *gin.Engine) {
|
||||
c.JSON(http.StatusOK, result)
|
||||
})
|
||||
|
||||
r.GET("/tags/:namespace/:name", func(c *gin.Context) {
|
||||
r.GET("/api/tags/:namespace/:name", func(c *gin.Context) {
|
||||
namespace := c.Param("namespace")
|
||||
name := c.Param("name")
|
||||
|
||||
@@ -545,15 +503,9 @@ func RegisterSearchRoute(r *gin.Engine) {
|
||||
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, TagPageResult{
|
||||
Tags: tags,
|
||||
HasMore: hasMore,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
45
src/handlers/search_test.go
Normal file
45
src/handlers/search_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestNormalizeRepository(t *testing.T) {
|
||||
official := &Repository{Name: "nginx", IsOfficial: true}
|
||||
normalizeRepository(official)
|
||||
if official.Namespace != "library" || official.Name != "library/nginx" {
|
||||
t.Fatalf("official normalized to %#v", official)
|
||||
}
|
||||
|
||||
userRepo := &Repository{Name: "owner/app", RepoOwner: "owner"}
|
||||
normalizeRepository(userRepo)
|
||||
if userRepo.Namespace != "owner" || userRepo.Name != "app" {
|
||||
t.Fatalf("user repo normalized to %#v", userRepo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePaginationParams(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
req := httptest.NewRequest(http.MethodGet, "/?page=3&page_size=50", nil)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = req
|
||||
|
||||
page, pageSize := parsePaginationParams(c, 25)
|
||||
if page != 3 || pageSize != 50 {
|
||||
t.Fatalf("pagination = %d %d", page, pageSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchCacheExpires(t *testing.T) {
|
||||
cache := &Cache{data: make(map[string]cacheEntry), maxSize: 10}
|
||||
cache.SetWithTTL("k", "v", -time.Second)
|
||||
|
||||
if got, ok := cache.Get("k"); ok || got != nil {
|
||||
t.Fatalf("expired cache returned: %#v", got)
|
||||
}
|
||||
}
|
||||
183
src/main.go
183
src/main.go
@@ -4,7 +4,9 @@ import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,119 +18,127 @@ import (
|
||||
"hubproxy/utils"
|
||||
)
|
||||
|
||||
//go:embed public/*
|
||||
//go:embed all:dist
|
||||
var staticFiles embed.FS
|
||||
|
||||
// 服务嵌入的静态文件
|
||||
func serveEmbedFile(c *gin.Context, filename string) {
|
||||
data, err := staticFiles.ReadFile(filename)
|
||||
if err != nil {
|
||||
c.Status(404)
|
||||
return
|
||||
}
|
||||
contentType := "text/html; charset=utf-8"
|
||||
if strings.HasSuffix(filename, ".ico") {
|
||||
contentType = "image/x-icon"
|
||||
}
|
||||
c.Data(200, contentType, data)
|
||||
}
|
||||
|
||||
var (
|
||||
globalLimiter *utils.IPRateLimiter
|
||||
|
||||
// 服务启动时间
|
||||
globalLimiter *utils.IPRateLimiter
|
||||
serviceStartTime = time.Now()
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 加载配置
|
||||
if err := config.LoadConfig(); err != nil {
|
||||
fmt.Printf("配置加载失败: %v\n", err)
|
||||
var Version = "dev"
|
||||
|
||||
func init() {
|
||||
for ext, typ := range map[string]string{
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".map": "application/json",
|
||||
} {
|
||||
_ = mime.AddExtensionType(ext, typ)
|
||||
}
|
||||
}
|
||||
|
||||
func contentTypeFor(filename string) string {
|
||||
if ct := mime.TypeByExtension(path.Ext(filename)); ct != "" {
|
||||
return ct
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func serveEmbedFile(c *gin.Context, filename string) {
|
||||
data, err := staticFiles.ReadFile(filename)
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, contentTypeFor(filename), data)
|
||||
}
|
||||
|
||||
func serveSPA(c *gin.Context) {
|
||||
serveEmbedFile(c, "dist/index.html")
|
||||
}
|
||||
|
||||
func registerFrontendRoutes(router *gin.Engine, enabled bool) {
|
||||
if !enabled {
|
||||
notFound := func(c *gin.Context) { c.Status(http.StatusNotFound) }
|
||||
router.GET("/", notFound)
|
||||
router.GET("/images", notFound)
|
||||
router.GET("/search", notFound)
|
||||
router.GET("/assets/*filepath", notFound)
|
||||
router.GET("/favicon.ico", notFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化HTTP客户端
|
||||
utils.InitHTTPClients()
|
||||
|
||||
// 初始化限流器
|
||||
globalLimiter = utils.InitGlobalLimiter()
|
||||
|
||||
// 初始化Docker流式代理
|
||||
handlers.InitDockerProxy()
|
||||
|
||||
// 初始化镜像流式下载器
|
||||
handlers.InitImageStreamer()
|
||||
|
||||
// 初始化防抖器
|
||||
handlers.InitDebouncer()
|
||||
router.GET("/", serveSPA)
|
||||
router.GET("/images", serveSPA)
|
||||
router.GET("/search", serveSPA)
|
||||
router.GET("/favicon.ico", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "dist/favicon.ico")
|
||||
})
|
||||
router.GET("/assets/*filepath", func(c *gin.Context) {
|
||||
filepath := strings.TrimPrefix(c.Param("filepath"), "/")
|
||||
if filepath == "" || strings.Contains(filepath, "..") {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
serveEmbedFile(c, path.Join("dist/assets", filepath))
|
||||
})
|
||||
}
|
||||
|
||||
func buildRouter(cfg *config.AppConfig) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
router := gin.Default()
|
||||
utils.ConfigureTrustedProxies(router)
|
||||
|
||||
// 全局Panic恢复保护
|
||||
router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
|
||||
log.Printf("🚨 Panic recovered: %v", recovered)
|
||||
log.Printf("Panic 已恢复: %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搜索路由
|
||||
registerFrontendRoutes(router, cfg.Server.EnableFrontend)
|
||||
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 {
|
||||
fmt.Printf("配置加载失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.InitHTTPClients()
|
||||
globalLimiter = utils.InitGlobalLimiter()
|
||||
handlers.InitDockerProxy()
|
||||
handlers.InitImageStreamer()
|
||||
handlers.InitDebouncer()
|
||||
|
||||
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)
|
||||
|
||||
// 显示HTTP/2支持状态
|
||||
if cfg.Server.EnableH2C {
|
||||
fmt.Printf("H2c: 已启用\n")
|
||||
}
|
||||
|
||||
fmt.Printf("版本号: v1.2.1\n")
|
||||
fmt.Printf("版本号: %s\n", Version)
|
||||
fmt.Printf("项目地址: https://github.com/sky22333/hubproxy\n")
|
||||
|
||||
// 创建HTTP2服务器
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port),
|
||||
ReadTimeout: 60 * time.Second,
|
||||
@@ -136,39 +146,37 @@ func main() {
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// 根据配置决定是否启用H2C
|
||||
if cfg.Server.EnableH2C {
|
||||
h2cHandler := h2c.NewHandler(router, &http2.Server{
|
||||
server.Handler = h2c.NewHandler(router, &http2.Server{
|
||||
MaxConcurrentStreams: 250,
|
||||
IdleTimeout: 300 * time.Second,
|
||||
MaxReadFrameSize: 4 << 20,
|
||||
MaxUploadBufferPerConnection: 8 << 20,
|
||||
MaxUploadBufferPerStream: 2 << 20,
|
||||
})
|
||||
server.Handler = h2cHandler
|
||||
} else {
|
||||
server.Handler = router
|
||||
}
|
||||
|
||||
err := server.ListenAndServe()
|
||||
if err != nil {
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
fmt.Printf("启动服务失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 简单的健康检查
|
||||
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)
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%d分钟%d秒", int(d.Minutes()), int(d.Seconds())%60)
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%d小时%d分钟", int(d.Hours()), int(d.Minutes())%60)
|
||||
}
|
||||
|
||||
days := int(d.Hours()) / 24
|
||||
hours := int(d.Hours()) % 24
|
||||
return fmt.Sprintf("%d天%d小时", days, hours)
|
||||
}
|
||||
|
||||
func getUptimeInfo() (time.Duration, float64, string) {
|
||||
@@ -182,6 +190,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,
|
||||
|
||||
196
src/main_test.go
Normal file
196
src/main_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hubproxy/config"
|
||||
"hubproxy/handlers"
|
||||
"hubproxy/utils"
|
||||
)
|
||||
|
||||
func newTestRouter(t *testing.T, configBody string) *gin.Engine {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte(configBody), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("CONFIG_PATH", path)
|
||||
if err := config.LoadConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
utils.InitHTTPClients()
|
||||
globalLimiter = utils.InitGlobalLimiter()
|
||||
handlers.InitDockerProxy()
|
||||
handlers.InitImageStreamer()
|
||||
handlers.InitDebouncer()
|
||||
|
||||
return buildRouter(config.GetConfig())
|
||||
}
|
||||
|
||||
func performRequest(router http.Handler, method, path, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("User-Agent", "hubproxy-test")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestReadyRoute(t *testing.T) {
|
||||
router := newTestRouter(t, "")
|
||||
|
||||
w := performRequest(router, http.MethodGet, "/ready", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["ready"] != true || got["service"] != "hubproxy" {
|
||||
t.Fatalf("unexpected ready response: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrontendDisabledRoutesReturnNotFound(t *testing.T) {
|
||||
router := newTestRouter(t, `
|
||||
[server]
|
||||
enableFrontend = false
|
||||
`)
|
||||
|
||||
for _, path := range []string{"/", "/images", "/search", "/favicon.ico"} {
|
||||
w := performRequest(router, http.MethodGet, path, "")
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("%s status = %d, want 404", path, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleImageDownloadPrepareReturnsURL(t *testing.T) {
|
||||
router := newTestRouter(t, "")
|
||||
|
||||
w := performRequest(router, http.MethodGet, "/api/image/download?image=nginx&mode=prepare", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var got struct {
|
||||
DownloadURL string `json:"download_url"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(got.DownloadURL, "image=nginx") || !strings.Contains(got.DownloadURL, "token=") {
|
||||
t.Fatalf("download_url = %q", got.DownloadURL)
|
||||
}
|
||||
if !strings.HasPrefix(got.DownloadURL, "/api/image/download?") {
|
||||
t.Fatalf("download_url = %q", got.DownloadURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchImageDownloadPrepareReturnsURL(t *testing.T) {
|
||||
router := newTestRouter(t, "")
|
||||
|
||||
body := `{"images":["nginx"],"useCompressedLayers":true}`
|
||||
w := performRequest(router, http.MethodPost, "/api/image/batch?mode=prepare", body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var got struct {
|
||||
DownloadURL string `json:"download_url"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(got.DownloadURL, "/api/image/batch?token=") {
|
||||
t.Fatalf("download_url = %q", got.DownloadURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchImageDownloadRejectsTooManyImages(t *testing.T) {
|
||||
router := newTestRouter(t, `
|
||||
[download]
|
||||
maxImages = 1
|
||||
`)
|
||||
|
||||
body := `{"images":["nginx","redis"],"useCompressedLayers":true}`
|
||||
w := performRequest(router, http.MethodPost, "/api/image/batch?mode=prepare", body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubNoRouteRejectsUnsupportedHost(t *testing.T) {
|
||||
router := newTestRouter(t, "")
|
||||
|
||||
w := performRequest(router, http.MethodGet, "/https://example.com/file.zip", "")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerV2PingAndInvalidPath(t *testing.T) {
|
||||
router := newTestRouter(t, "")
|
||||
|
||||
w := performRequest(router, http.MethodGet, "/v2/", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("/v2/ status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
w = performRequest(router, http.MethodGet, "/v2/library/nginx/unknown/latest", "")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid v2 status = %d, want 400; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchAPIRejectsMissingQuery(t *testing.T) {
|
||||
router := newTestRouter(t, `
|
||||
[server]
|
||||
enableFrontend = false
|
||||
`)
|
||||
|
||||
w := performRequest(router, http.MethodGet, "/api/search", "")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var got map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["error"] == "" {
|
||||
t.Fatalf("missing error response: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchServesSPAWhenFrontendEnabled(t *testing.T) {
|
||||
router := newTestRouter(t, `
|
||||
[server]
|
||||
enableFrontend = true
|
||||
`)
|
||||
|
||||
w := performRequest(router, http.MethodGet, "/search?q=nginx", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Content-Type"), "text/html") {
|
||||
t.Fatalf("content-type = %q, want text/html", w.Header().Get("Content-Type"))
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `<div id="app">`) {
|
||||
t.Fatalf("SPA shell missing: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
875
src/public/images.html
vendored
875
src/public/images.html
vendored
@@ -1,875 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Docker镜像流式下载工具、即点即下无需等待">
|
||||
<meta name="keywords" content="Docker镜像下载、流式下载、即时下载">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>Docker离线镜像下载</title>
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<style>
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #0f172a;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #0f172a;
|
||||
--primary: #2563eb;
|
||||
--primary-foreground: #f8fafc;
|
||||
--secondary: #f1f5f9;
|
||||
--secondary-foreground: #0f172a;
|
||||
--muted: #f1f5f9;
|
||||
--muted-foreground: #64748b;
|
||||
--accent: #f1f5f9;
|
||||
--accent-foreground: #0f172a;
|
||||
--border: #e2e8f0;
|
||||
--input: #ffffff;
|
||||
--ring: #2563eb;
|
||||
--radius: 0.5rem;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0f172a;
|
||||
--foreground: #f8fafc;
|
||||
--card: #1e293b;
|
||||
--card-foreground: #f8fafc;
|
||||
--primary: #3b82f6;
|
||||
--primary-foreground: #f8fafc;
|
||||
--secondary: #1e293b;
|
||||
--secondary-foreground: #f8fafc;
|
||||
--muted: #1e293b;
|
||||
--muted-foreground: #94a3b8;
|
||||
--accent: #1e293b;
|
||||
--accent-foreground: #f8fafc;
|
||||
--border: #334155;
|
||||
--input: #1e293b;
|
||||
--ring: #3b82f6;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0f172a;
|
||||
--foreground: #f8fafc;
|
||||
--card: #1e293b;
|
||||
--card-foreground: #f8fafc;
|
||||
--primary: #3b82f6;
|
||||
--primary-foreground: #f8fafc;
|
||||
--secondary: #1e293b;
|
||||
--secondary-foreground: #f8fafc;
|
||||
--muted: #1e293b;
|
||||
--muted-foreground: #94a3b8;
|
||||
--accent: #1e293b;
|
||||
--accent-foreground: #f8fafc;
|
||||
--border: #334155;
|
||||
--input: #1e293b;
|
||||
--ring: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
/* 导航栏 */
|
||||
.navbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dark .navbar {
|
||||
background-color: rgba(15, 23, 42, 0.95);
|
||||
}
|
||||
|
||||
.navbar-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(135deg, var(--primary), #3b82f6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
color: var(--muted-foreground);
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link.active {
|
||||
color: var(--foreground);
|
||||
background-color: var(--muted);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background-color: transparent;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background-color: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* 主要内容 */
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
background: linear-gradient(135deg, var(--primary), #3b82f6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.125rem;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* 下载区域 */
|
||||
.download-section,
|
||||
.batch-section {
|
||||
background-color: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--input);
|
||||
color: var(--foreground);
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-select:focus,
|
||||
.textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--ring);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: var(--success);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: var(--error);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
background-color: rgba(245, 158, 11, 0.1);
|
||||
color: var(--warning);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted-foreground);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar-container {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.download-section,
|
||||
.batch-section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.features {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid transparent;
|
||||
border-top: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 切换开关样式 */
|
||||
.switch-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--muted);
|
||||
transition: 0.2s;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: white;
|
||||
transition: 0.2s;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar-container {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 12px 12px;
|
||||
padding: 1rem;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
z-index: 1000;
|
||||
transform: translateY(-100vh);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-links.active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: block !important;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--foreground);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle:hover {
|
||||
background-color: var(--muted);
|
||||
}
|
||||
|
||||
.navbar-container {
|
||||
justify-content: space-between !important;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.download-section,
|
||||
.batch-section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.features {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="navbar-container">
|
||||
<a href="/" class="logo">
|
||||
<div class="logo-icon">
|
||||
⚡
|
||||
</div>
|
||||
加速服务
|
||||
</a>
|
||||
|
||||
<button class="mobile-menu-toggle" id="mobileMenuToggle">
|
||||
☰
|
||||
</button>
|
||||
|
||||
<div class="nav-links" id="navLinks">
|
||||
<a href="/" class="nav-link">🚀 GitHub加速</a>
|
||||
<a href="/images.html" class="nav-link active">🐳 离线镜像下载</a>
|
||||
<a href="/search.html" class="nav-link">🔍 镜像搜索</a>
|
||||
<a href="https://gitee.com/if-the-wind/github-hosts/raw/main/hosts" target="_blank" class="nav-link">📄 Hosts</a>
|
||||
|
||||
<button class="theme-toggle" id="themeToggle">
|
||||
🌙
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main">
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1 class="title">Docker离线镜像下载</h1>
|
||||
<p class="subtitle">即点即下,无需等待打包,完全符合docker load加载标准</p>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature">
|
||||
<span class="feature-icon">⚡</span>
|
||||
<span>即时下载</span>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="feature-icon">🔄</span>
|
||||
<span>流式传输</span>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="feature-icon">💾</span>
|
||||
<span>无需等待</span>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="feature-icon">🏗️</span>
|
||||
<span>多架构支持</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="download-section">
|
||||
<h2 class="section-title">单镜像下载</h2>
|
||||
|
||||
<div id="singleStatus"></div>
|
||||
|
||||
<form id="singleForm">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="imageInput">镜像名称</label>
|
||||
<input
|
||||
type="text"
|
||||
id="imageInput"
|
||||
class="form-input"
|
||||
placeholder="例如: nginx:alpine"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="platformInput">目标架构(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="platformInput"
|
||||
class="form-input"
|
||||
placeholder="linux/amd64"
|
||||
value="linux/amd64"
|
||||
>
|
||||
<div class="help-text">
|
||||
常用平台: linux/amd64, linux/arm64, linux/arm/v7
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="switch-container">
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="compressedToggle" checked>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<label for="compressedToggle" class="switch-label">使用压缩层(减小包体积)</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-full" id="downloadBtn">
|
||||
<span id="downloadText">立即下载</span>
|
||||
<span id="downloadLoading" class="loading hidden"></span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="batch-section">
|
||||
<h2 class="section-title">多个镜像批量下载</h2>
|
||||
|
||||
<div id="batchStatus"></div>
|
||||
|
||||
<form id="batchForm">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="imagesTextarea">镜像列表,每行一个,会将多个镜像自动合并,符合官方标准,兼容docker load</label>
|
||||
<textarea
|
||||
id="imagesTextarea"
|
||||
class="textarea"
|
||||
placeholder="alpine redis:alpine stilleshan/frpc:0.62.1"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="batchPlatformInput">目标架构(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="batchPlatformInput"
|
||||
class="form-input"
|
||||
placeholder="linux/amd64"
|
||||
value="linux/amd64"
|
||||
>
|
||||
<div class="help-text">
|
||||
所有镜像将使用相同的目标架构
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="switch-container">
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="batchCompressedToggle" checked>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<label for="batchCompressedToggle" class="switch-label">使用压缩层(减小包体积)</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-full" id="batchDownloadBtn">
|
||||
<span id="batchDownloadText">开始下载</span>
|
||||
<span id="batchDownloadLoading" class="loading hidden"></span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function initTheme() {
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const html = document.documentElement;
|
||||
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
html.classList.add('dark');
|
||||
themeToggle.textContent = '☀️';
|
||||
}
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
html.classList.toggle('dark');
|
||||
const isDark = html.classList.contains('dark');
|
||||
themeToggle.textContent = isDark ? '☀️' : '🌙';
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
});
|
||||
}
|
||||
|
||||
function showStatus(elementId, message, type = 'success') {
|
||||
const element = document.getElementById(elementId);
|
||||
element.className = `status status-${type}`;
|
||||
element.textContent = message;
|
||||
element.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideStatus(elementId) {
|
||||
document.getElementById(elementId).classList.add('hidden');
|
||||
}
|
||||
|
||||
function setButtonLoading(btnId, textId, loadingId, loading) {
|
||||
const btn = document.getElementById(btnId);
|
||||
const text = document.getElementById(textId);
|
||||
const loadingSpinner = document.getElementById(loadingId);
|
||||
|
||||
btn.disabled = loading;
|
||||
if (loading) {
|
||||
text.classList.add('hidden');
|
||||
loadingSpinner.classList.remove('hidden');
|
||||
} else {
|
||||
text.classList.remove('hidden');
|
||||
loadingSpinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function buildDownloadUrl(imageName, platform = '', useCompressed = true) {
|
||||
const encodedImage = imageName.replace(/\//g, '_');
|
||||
let url = `/api/image/download/${encodedImage}`;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (platform && platform.trim()) {
|
||||
params.append('platform', platform.trim());
|
||||
}
|
||||
params.append('compressed', useCompressed.toString());
|
||||
|
||||
if (params.toString()) {
|
||||
url += '?' + params.toString();
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
document.getElementById('singleForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const imageName = document.getElementById('imageInput').value.trim();
|
||||
if (!imageName) {
|
||||
showStatus('singleStatus', '请输入镜像名称', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const platform = document.getElementById('platformInput').value.trim();
|
||||
const useCompressed = document.getElementById('compressedToggle').checked;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
document.getElementById('batchForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const imagesText = document.getElementById('imagesTextarea').value.trim();
|
||||
if (!imagesText) {
|
||||
showStatus('batchStatus', '请输入镜像列表', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const images = imagesText.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#'));
|
||||
|
||||
if (images.length === 0) {
|
||||
showStatus('batchStatus', '镜像列表为空', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const platform = document.getElementById('batchPlatformInput').value.trim();
|
||||
const useCompressed = document.getElementById('batchCompressedToggle').checked;
|
||||
|
||||
const options = {
|
||||
images: images,
|
||||
useCompressedLayers: useCompressed
|
||||
};
|
||||
|
||||
if (platform) {
|
||||
options.platform = platform;
|
||||
}
|
||||
|
||||
hideStatus('batchStatus');
|
||||
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/image/batch', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(options)
|
||||
});
|
||||
|
||||
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 blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
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');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showStatus('batchStatus', error.error || '下载失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('batchStatus', '网络错误: ' + error.message, 'error');
|
||||
} finally {
|
||||
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', false);
|
||||
}
|
||||
});
|
||||
|
||||
function initMobileMenu() {
|
||||
const mobileMenuToggle = document.getElementById('mobileMenuToggle');
|
||||
const navLinks = document.getElementById('navLinks');
|
||||
|
||||
if (mobileMenuToggle && navLinks) {
|
||||
mobileMenuToggle.addEventListener('click', () => {
|
||||
navLinks.classList.toggle('active');
|
||||
});
|
||||
|
||||
navLinks.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('nav-link')) {
|
||||
navLinks.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
initTheme();
|
||||
initMobileMenu();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
832
src/public/index.html
vendored
832
src/public/index.html
vendored
@@ -1,832 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Github文件加速、docker镜像加速">
|
||||
<meta name="keywords" content="Github、文件加速、ghproxy、docker镜像加速">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>Github、Docker加速</title>
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<style>
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #0f172a;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #0f172a;
|
||||
--primary: #2563eb;
|
||||
--primary-foreground: #f8fafc;
|
||||
--secondary: #f1f5f9;
|
||||
--secondary-foreground: #0f172a;
|
||||
--muted: #f1f5f9;
|
||||
--muted-foreground: #64748b;
|
||||
--accent: #f1f5f9;
|
||||
--accent-foreground: #0f172a;
|
||||
--border: #e2e8f0;
|
||||
--input: #ffffff;
|
||||
--ring: #2563eb;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0f172a;
|
||||
--foreground: #f8fafc;
|
||||
--card: #1e293b;
|
||||
--card-foreground: #f8fafc;
|
||||
--primary: #3b82f6;
|
||||
--primary-foreground: #f8fafc;
|
||||
--secondary: #1e293b;
|
||||
--secondary-foreground: #f8fafc;
|
||||
--muted: #1e293b;
|
||||
--muted-foreground: #94a3b8;
|
||||
--accent: #1e293b;
|
||||
--accent-foreground: #f8fafc;
|
||||
--border: #334155;
|
||||
--input: #1e293b;
|
||||
--ring: #3b82f6;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0f172a;
|
||||
--foreground: #f8fafc;
|
||||
--card: #1e293b;
|
||||
--card-foreground: #f8fafc;
|
||||
--primary: #3b82f6;
|
||||
--primary-foreground: #f8fafc;
|
||||
--secondary: #1e293b;
|
||||
--secondary-foreground: #f8fafc;
|
||||
--muted: #1e293b;
|
||||
--muted-foreground: #94a3b8;
|
||||
--accent: #1e293b;
|
||||
--accent-foreground: #f8fafc;
|
||||
--border: #334155;
|
||||
--input: #1e293b;
|
||||
--ring: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background-color: var(--background);
|
||||
backdrop-filter: blur(8px);
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.dark .navbar {
|
||||
background-color: rgba(15, 23, 42, 0.95);
|
||||
}
|
||||
|
||||
.navbar-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(135deg, var(--primary), #3b82f6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
color: var(--muted-foreground);
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link.active {
|
||||
color: var(--foreground);
|
||||
background-color: var(--muted);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background-color: transparent;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background-color: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
animation: fadeInUp 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
background: linear-gradient(135deg, var(--primary), #3b82f6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.125rem;
|
||||
color: var(--muted-foreground);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
animation: fadeInUp 0.6s ease-out 0.2s forwards;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--input);
|
||||
color: var(--foreground);
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--ring);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
background-color: #1d4ed8;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
background-color: var(--secondary);
|
||||
color: var(--secondary-foreground);
|
||||
}
|
||||
|
||||
.button-secondary:hover {
|
||||
background-color: var(--muted);
|
||||
}
|
||||
|
||||
.output-container {
|
||||
margin-top: 1.5rem;
|
||||
display: none;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.output-container.show {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.success-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.output-box {
|
||||
background-color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.875rem;
|
||||
word-break: break-all;
|
||||
position: relative;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.output-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.docker-info {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
animation: fadeInUp 0.6s ease-out 0.4s forwards;
|
||||
}
|
||||
|
||||
.docker-button {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.docker-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark .docker-button {
|
||||
background: linear-gradient(135deg, #374151, #4b5563);
|
||||
}
|
||||
|
||||
.dark .docker-button:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--card);
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--muted-foreground);
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background-color: var(--muted);
|
||||
}
|
||||
|
||||
.domain-examples {
|
||||
background-color: var(--muted);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.domain-examples strong {
|
||||
color: var(--foreground);
|
||||
display: block;
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.domain-examples strong:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1001;
|
||||
display: none;
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.github-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.github-link:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 12px 12px;
|
||||
padding: 1rem;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
z-index: 1000;
|
||||
transform: translateY(-100vh);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-links.active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: block !important;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--foreground);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle:hover {
|
||||
background-color: var(--muted);
|
||||
}
|
||||
|
||||
.navbar-container {
|
||||
justify-content: space-between !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 0.875rem 1.5rem;
|
||||
}
|
||||
|
||||
.output-actions {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="navbar-container">
|
||||
<a href="/" class="logo">
|
||||
<div class="logo-icon">
|
||||
⚡
|
||||
</div>
|
||||
加速服务
|
||||
</a>
|
||||
|
||||
<button class="mobile-menu-toggle" id="mobileMenuToggle">
|
||||
☰
|
||||
</button>
|
||||
|
||||
<div class="nav-links" id="navLinks">
|
||||
<a href="/" class="nav-link active">🚀 GitHub加速</a>
|
||||
<a href="/images.html" class="nav-link">🐳 离线镜像下载</a>
|
||||
<a href="/search.html" class="nav-link">🔍 镜像搜索</a>
|
||||
<a href="https://gitee.com/if-the-wind/github-hosts/raw/main/hosts" target="_blank" class="nav-link">📄 Hosts</a>
|
||||
|
||||
<button class="theme-toggle" id="themeToggle">
|
||||
🌙
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main">
|
||||
<div class="container">
|
||||
<div class="hero">
|
||||
<h1 class="hero-title">GitHub 文件加速</h1>
|
||||
<p class="hero-subtitle">
|
||||
快速下载GitHub上的文件和仓库,解决国内访问GitHub速度慢的问题,支持Docker镜像加速和Hugging Face仓库。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">
|
||||
⚡ 快速转换加速链接
|
||||
</h2>
|
||||
<p class="card-description">
|
||||
输入GitHub文件链接,自动转换加速链接,可以直接在Github文件链接前加上本站域名使用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="input-container">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
id="githubLinkInput"
|
||||
placeholder="请输入GitHub文件链接,例如:https://github.com/user/repo/releases/download/..."
|
||||
>
|
||||
<button class="button button-primary" id="formatButton">
|
||||
获取加速链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="output-container" id="outputBlock">
|
||||
<div class="success-header">
|
||||
<span>✅</span>
|
||||
<strong>加速链接已生成</strong>
|
||||
</div>
|
||||
<div class="output-box" id="formattedLinkOutput"></div>
|
||||
<div class="output-actions">
|
||||
<button class="button button-secondary" id="copyButton">
|
||||
📋 复制链接
|
||||
</button>
|
||||
<button class="button button-secondary" id="redirButton">
|
||||
🔗 打开链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card docker-info">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">
|
||||
🐳 Docker 镜像加速
|
||||
</h3>
|
||||
<p class="card-description">
|
||||
支持多种镜像仓库,在镜像名称前添加本站域名即可加速下载。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="docker-button" id="dockerButton">
|
||||
查看 Docker 镜像加速使用说明
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="dockerModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<button class="close-button" id="closeModal">×</button>
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Docker 镜像加速</h2>
|
||||
<p>支持多种镜像仓库,在镜像名称前添加本站域名即可加速下载。</p>
|
||||
</div>
|
||||
|
||||
<div class="domain-examples">
|
||||
<strong>Docker 官方镜像:</strong>
|
||||
docker pull <span class="domain-base"></span>/nginx
|
||||
|
||||
<strong>Docker 镜像:</strong>
|
||||
docker pull <span class="domain-base"></span>/user/image
|
||||
|
||||
<strong>ghcr.io 镜像:</strong>
|
||||
docker pull <span class="domain-base"></span>/ghcr.io/user/image
|
||||
|
||||
<strong>Quay.io 镜像:</strong>
|
||||
docker pull <span class="domain-base"></span>/quay.io/org/image
|
||||
|
||||
<strong>Kubernetes 镜像:</strong>
|
||||
docker pull <span class="domain-base"></span>/registry.k8s.io/pause:3.8
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast">
|
||||
链接已复制到剪贴板
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<a href="https://github.com/sky22333/hubproxy" target="_blank" class="github-link">
|
||||
<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const html = document.documentElement;
|
||||
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
html.classList.add('dark');
|
||||
themeToggle.textContent = '☀️';
|
||||
}
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
html.classList.toggle('dark');
|
||||
const isDark = html.classList.contains('dark');
|
||||
themeToggle.textContent = isDark ? '☀️' : '🌙';
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const fullDomain = window.location.host;
|
||||
document.querySelectorAll('.domain-base').forEach(span => {
|
||||
span.textContent = fullDomain;
|
||||
});
|
||||
|
||||
const modal = document.getElementById('dockerModal');
|
||||
const dockerButton = document.getElementById('dockerButton');
|
||||
const closeButton = document.getElementById('closeModal');
|
||||
|
||||
dockerButton.onclick = () => modal.style.display = "flex";
|
||||
closeButton.onclick = () => modal.style.display = "none";
|
||||
window.onclick = (event) => {
|
||||
if (event.target == modal) modal.style.display = "none";
|
||||
};
|
||||
});
|
||||
|
||||
function formatGithubLink() {
|
||||
const githubLinkInput = document.getElementById('githubLinkInput');
|
||||
const currentHost = window.location.host;
|
||||
let formattedLink = "";
|
||||
const link = githubLinkInput.value.trim();
|
||||
|
||||
if (link.startsWith("https://") || link.startsWith("http://")) {
|
||||
formattedLink = "https://" + currentHost + "/" + link;
|
||||
} else if (
|
||||
link.startsWith("github.com/") ||
|
||||
link.startsWith("raw.githubusercontent.com/") ||
|
||||
link.startsWith("gist.githubusercontent.com/") ||
|
||||
link.startsWith("huggingface.co/") ||
|
||||
link.startsWith("cdn-lfs.hf.co/") ||
|
||||
link.startsWith("download.docker.com/")
|
||||
) {
|
||||
formattedLink = "https://" + currentHost + "/https://" + link;
|
||||
} else {
|
||||
showToast('请输入有效的链接');
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedLinkOutput = document.getElementById('formattedLinkOutput');
|
||||
formattedLinkOutput.textContent = formattedLink;
|
||||
|
||||
const outputBlock = document.getElementById('outputBlock');
|
||||
outputBlock.classList.add('show');
|
||||
}
|
||||
|
||||
function copyToClipboard() {
|
||||
const output = document.getElementById('formattedLinkOutput');
|
||||
const text = output.textContent;
|
||||
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast('链接已复制到剪贴板');
|
||||
});
|
||||
} else {
|
||||
const range = document.createRange();
|
||||
range.selectNode(output);
|
||||
window.getSelection().removeAllRanges();
|
||||
window.getSelection().addRange(range);
|
||||
document.execCommand('copy');
|
||||
window.getSelection().removeAllRanges();
|
||||
showToast('链接已复制到剪贴板');
|
||||
}
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
const formattedLinkOutput = document.getElementById('formattedLinkOutput');
|
||||
window.open(formattedLinkOutput.textContent);
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = message;
|
||||
toast.classList.add('show');
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
document.getElementById('formatButton').addEventListener('click', formatGithubLink);
|
||||
document.getElementById('copyButton').addEventListener('click', copyToClipboard);
|
||||
document.getElementById('redirButton').addEventListener('click', openLink);
|
||||
|
||||
document.getElementById('githubLinkInput').addEventListener('keyup', function(event) {
|
||||
if (event.key === 'Enter') {
|
||||
formatGithubLink();
|
||||
}
|
||||
});
|
||||
|
||||
const mobileMenuToggle = document.getElementById('mobileMenuToggle');
|
||||
const navLinks = document.getElementById('navLinks');
|
||||
|
||||
mobileMenuToggle.addEventListener('click', () => {
|
||||
navLinks.classList.toggle('active');
|
||||
mobileMenuToggle.textContent = navLinks.classList.contains('active') ? '✕' : '☰';
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.navbar') && navLinks.classList.contains('active')) {
|
||||
navLinks.classList.remove('active');
|
||||
mobileMenuToggle.textContent = '☰';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
1491
src/public/search.html
vendored
1491
src/public/search.html
vendored
File diff suppressed because it is too large
Load Diff
@@ -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镜像信息
|
||||
|
||||
86
src/utils/access_control_test.go
Normal file
86
src/utils/access_control_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"hubproxy/config"
|
||||
)
|
||||
|
||||
func TestParseDockerImage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
image string
|
||||
namespace string
|
||||
repository string
|
||||
tag string
|
||||
fullName string
|
||||
}{
|
||||
{"official", "nginx", "library", "nginx", "latest", "library/nginx"},
|
||||
{"tagged", "redis:7", "library", "redis", "7", "library/redis"},
|
||||
{"namespaced", "user/app:v1", "user", "app", "v1", "user/app"},
|
||||
{"registry", "ghcr.io/user/app:v2", "user", "app", "v2", "user/app"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := GlobalAccessController.ParseDockerImage(tt.image)
|
||||
if got.Namespace != tt.namespace || got.Repository != tt.repository || got.Tag != tt.tag || got.FullName != tt.fullName {
|
||||
t.Fatalf("ParseDockerImage(%q) = %#v", tt.image, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerAccessLists(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
data := []byte(`
|
||||
[access]
|
||||
whiteList = ["library/*", "good/*"]
|
||||
blackList = ["good/bad"]
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CONFIG_PATH", path)
|
||||
if err := config.LoadConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if allowed, reason := GlobalAccessController.CheckDockerAccess("nginx"); !allowed {
|
||||
t.Fatalf("nginx denied: %s", reason)
|
||||
}
|
||||
if allowed, _ := GlobalAccessController.CheckDockerAccess("good/bad:latest"); allowed {
|
||||
t.Fatal("blacklisted image allowed")
|
||||
}
|
||||
if allowed, _ := GlobalAccessController.CheckDockerAccess("other/app"); allowed {
|
||||
t.Fatal("image outside whitelist allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubAccessLists(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
data := []byte(`
|
||||
[access]
|
||||
whiteList = ["allowed/*"]
|
||||
blackList = ["allowed/blocked"]
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CONFIG_PATH", path)
|
||||
if err := config.LoadConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if allowed, reason := GlobalAccessController.CheckGitHubAccess([]string{"allowed", "repo"}); !allowed {
|
||||
t.Fatalf("allowed/repo denied: %s", reason)
|
||||
}
|
||||
if allowed, _ := GlobalAccessController.CheckGitHubAccess([]string{"allowed", "blocked"}); allowed {
|
||||
t.Fatal("blacklisted repo allowed")
|
||||
}
|
||||
if allowed, _ := GlobalAccessController.CheckGitHubAccess([]string{"other", "repo"}); allowed {
|
||||
t.Fatal("repo outside whitelist allowed")
|
||||
}
|
||||
}
|
||||
49
src/utils/cache_test.go
Normal file
49
src/utils/cache_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUniversalCacheSetGetAndExpire(t *testing.T) {
|
||||
cache := &UniversalCache{}
|
||||
|
||||
cache.Set("k", []byte("v"), "text/plain", map[string]string{"X-Test": "1"}, time.Minute)
|
||||
if got := cache.Get("k"); got == nil || string(got.Data) != "v" || got.Headers["X-Test"] != "1" {
|
||||
t.Fatalf("cache hit mismatch: %#v", got)
|
||||
}
|
||||
|
||||
cache.Set("expired", []byte("v"), "", nil, -time.Second)
|
||||
if got := cache.Get("expired"); got != nil {
|
||||
t.Fatalf("expired item returned: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCacheHelpers(t *testing.T) {
|
||||
cache := &UniversalCache{}
|
||||
cache.SetToken("token", `{"token":"abc"}`, time.Minute)
|
||||
|
||||
if got := cache.GetToken("token"); got != `{"token":"abc"}` {
|
||||
t.Fatalf("GetToken = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTTLFromResponse(t *testing.T) {
|
||||
ttl := ExtractTTLFromResponse([]byte(`{"expires_in":3600}`))
|
||||
if ttl != 55*time.Minute {
|
||||
t.Fatalf("TTL = %s, want 55m", ttl)
|
||||
}
|
||||
|
||||
if ttl := ExtractTTLFromResponse([]byte(`{}`)); ttl != 30*time.Minute {
|
||||
t.Fatalf("default TTL = %s", ttl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCacheKeyStable(t *testing.T) {
|
||||
a := BuildCacheKey("p", "query")
|
||||
b := BuildCacheKey("p", "query")
|
||||
c := BuildCacheKey("p", "other")
|
||||
if a != b || a == c {
|
||||
t.Fatalf("unexpected keys: %q %q %q", a, b, c)
|
||||
}
|
||||
}
|
||||
@@ -104,4 +104,4 @@ func transformURL(url, host string) string {
|
||||
host = strings.TrimSuffix(host, "/")
|
||||
|
||||
return host + "/" + url
|
||||
}
|
||||
}
|
||||
|
||||
69
src/utils/proxy_shell_test.go
Normal file
69
src/utils/proxy_shell_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProcessSmartRewritesGitHubURLs(t *testing.T) {
|
||||
input := `curl -L https://github.com/user/repo/releases/download/v1/file.sh`
|
||||
reader, size, err := ProcessSmart(strings.NewReader(input), false, "proxy.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := new(strings.Builder)
|
||||
if _, err := io.Copy(buf, reader); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := "https://proxy.example.com/https://github.com/user/repo/releases/download/v1/file.sh"
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Fatalf("processed script = %q, want contains %q", buf.String(), want)
|
||||
}
|
||||
if size != int64(len(buf.String())) {
|
||||
t.Fatalf("size = %d, want %d", size, len(buf.String()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSmartKeepsNonGitHubContent(t *testing.T) {
|
||||
input := "echo hello"
|
||||
reader, _, err := ProcessSmart(strings.NewReader(input), false, "proxy.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := new(strings.Builder)
|
||||
if _, err := io.Copy(buf, reader); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if buf.String() != input {
|
||||
t.Fatalf("content changed: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadShellContentGzip(t *testing.T) {
|
||||
var compressed strings.Builder
|
||||
gz := gzip.NewWriter(&compressed)
|
||||
if _, err := gz.Write([]byte("echo https://github.com/u/r")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reader, _, err := ProcessSmart(strings.NewReader(compressed.String()), true, "proxy.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := new(strings.Builder)
|
||||
if _, err := io.Copy(buf, reader); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "https://proxy.example.com/https://github.com/u/r") {
|
||||
t.Fatalf("gzip content not rewritten: %q", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,19 @@ const (
|
||||
MaxIPCacheSize = 10000
|
||||
)
|
||||
|
||||
// 可信反代
|
||||
var trustedProxyCIDRs = []string{
|
||||
"127.0.0.0/8",
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
}
|
||||
|
||||
// ConfigureTrustedProxies 可信反代
|
||||
func ConfigureTrustedProxies(router *gin.Engine) {
|
||||
_ = router.SetTrustedProxies(trustedProxyCIDRs)
|
||||
}
|
||||
|
||||
// IPRateLimiter IP限流器结构体
|
||||
type IPRateLimiter struct {
|
||||
ips map[string]*rateLimiterEntry
|
||||
@@ -176,8 +189,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 {
|
||||
@@ -211,40 +225,14 @@ func (i *IPRateLimiter) GetLimiter(ip string) (*rate.Limiter, bool) {
|
||||
func RateLimitMiddleware(limiter *IPRateLimiter) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
if path == "/" || path == "/favicon.ico" || path == "/images.html" || path == "/search.html" ||
|
||||
strings.HasPrefix(path, "/public/") {
|
||||
if path == "/" || path == "/images" || path == "/search" ||
|
||||
path == "/favicon.ico" ||
|
||||
strings.HasPrefix(path, "/assets/") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
var ip string
|
||||
|
||||
if forwarded := c.GetHeader("X-Forwarded-For"); forwarded != "" {
|
||||
ips := strings.Split(forwarded, ",")
|
||||
ip = strings.TrimSpace(ips[0])
|
||||
} else if realIP := c.GetHeader("X-Real-IP"); realIP != "" {
|
||||
ip = realIP
|
||||
} else if remoteIP := c.GetHeader("X-Original-Forwarded-For"); remoteIP != "" {
|
||||
ips := strings.Split(remoteIP, ",")
|
||||
ip = strings.TrimSpace(ips[0])
|
||||
} else {
|
||||
ip = c.ClientIP()
|
||||
}
|
||||
|
||||
cleanIP := extractIPFromAddress(ip)
|
||||
|
||||
normalizedIP := normalizeIPForRateLimit(cleanIP)
|
||||
if cleanIP != normalizedIP {
|
||||
fmt.Printf("请求IP: %s (提纯后: %s, 限流段: %s), X-Forwarded-For: %s, X-Real-IP: %s\n",
|
||||
ip, cleanIP, normalizedIP,
|
||||
c.GetHeader("X-Forwarded-For"),
|
||||
c.GetHeader("X-Real-IP"))
|
||||
} else {
|
||||
fmt.Printf("请求IP: %s (提纯后: %s), X-Forwarded-For: %s, X-Real-IP: %s\n",
|
||||
ip, cleanIP,
|
||||
c.GetHeader("X-Forwarded-For"),
|
||||
c.GetHeader("X-Real-IP"))
|
||||
}
|
||||
cleanIP := extractIPFromAddress(c.ClientIP())
|
||||
|
||||
ipLimiter, allowed := limiter.GetLimiter(cleanIP)
|
||||
|
||||
|
||||
74
src/utils/ratelimiter_test.go
Normal file
74
src/utils/ratelimiter_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestExtractIPFromAddress(t *testing.T) {
|
||||
if got := extractIPFromAddress("127.0.0.1:5000"); got != "127.0.0.1" {
|
||||
t.Fatalf("extract IPv4 = %q", got)
|
||||
}
|
||||
if got := extractIPFromAddress("[2001:db8::1]:5000"); got != "2001:db8::1" {
|
||||
t.Fatalf("extract IPv6 = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIPv6ForRateLimit(t *testing.T) {
|
||||
if got := normalizeIPForRateLimit("192.168.1.2"); got != "192.168.1.2" {
|
||||
t.Fatalf("IPv4 normalized = %q", got)
|
||||
}
|
||||
if got := normalizeIPForRateLimit("2001:db8::1"); got != "2001:db8::/64" {
|
||||
t.Fatalf("IPv6 normalized = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPIgnoresSpoofedXFFWithoutTrustedProxy(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
ConfigureTrustedProxies(router)
|
||||
|
||||
var got string
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
got = c.ClientIP()
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = "203.0.113.50:12345"
|
||||
req.Header.Set("X-Forwarded-For", "127.0.0.1")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if got != "203.0.113.50" {
|
||||
t.Fatalf("ClientIP() = %q, want 203.0.113.50", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPTrustsXFFFromTrustedProxy(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
if err := router.SetTrustedProxies([]string{"127.0.0.1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got string
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
got = c.ClientIP()
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = "127.0.0.1:54321"
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.50")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if got != "203.0.113.50" {
|
||||
t.Fatalf("ClientIP() = %q, want 203.0.113.50", got)
|
||||
}
|
||||
}
|
||||
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
9
web/README.md
Normal file
9
web/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# HubProxy Web
|
||||
|
||||
Vue 3 + Vite + Tailwind 前端。
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # 代理到 :5000
|
||||
npm run build # 输出到 ../src/dist
|
||||
```
|
||||
14
web/index.html
vendored
Normal file
14
web/index.html
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="HubProxy - GitHub 加速、Docker 镜像加速与离线下载" />
|
||||
<title>HubProxy</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1723
web/package-lock.json
generated
Normal file
1723
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
web/package.json
Normal file
34
web/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"packageManager": "npm@11.12.1",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"@fontsource/syne": "^5.2.7",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.577.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^24.13.2",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
14
web/src/App.vue
Normal file
14
web/src/App.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import AppShell from '@/components/AppShell.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition name="page">
|
||||
<component :is="Component" :key="route.path" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</AppShell>
|
||||
</template>
|
||||
137
web/src/api.ts
Normal file
137
web/src/api.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
class ApiError extends Error {
|
||||
status: number
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function parseError(res: Response): Promise<string> {
|
||||
const contentType = res.headers.get('Content-Type') || ''
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
const data = (await res.json()) as { error?: string; message?: string }
|
||||
return data.error || data.message || `请求失败 (${res.status})`
|
||||
} catch {
|
||||
return `请求失败 (${res.status})`
|
||||
}
|
||||
}
|
||||
try {
|
||||
const text = await res.text()
|
||||
return text || `请求失败 (${res.status})`
|
||||
} catch {
|
||||
return `请求失败 (${res.status})`
|
||||
}
|
||||
}
|
||||
|
||||
async function getJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!res.ok) throw new ApiError(await parseError(res), res.status)
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export interface PrepareDownloadResponse {
|
||||
download_url: string
|
||||
}
|
||||
|
||||
export interface ImageInfoResponse {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export interface Repository {
|
||||
repo_name?: string
|
||||
short_description?: string
|
||||
is_official?: boolean
|
||||
star_count?: number
|
||||
pull_count?: number
|
||||
namespace?: string
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
count: number
|
||||
results: Repository[]
|
||||
}
|
||||
|
||||
export interface TagInfo {
|
||||
name: string
|
||||
last_updated?: string
|
||||
full_size?: number
|
||||
images?: Array<{
|
||||
architecture?: string
|
||||
os?: string
|
||||
variant?: string
|
||||
size?: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface TagPageResult {
|
||||
tags: TagInfo[]
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
export function prepareSingleDownload(params: {
|
||||
image: string
|
||||
platform?: string
|
||||
compressed: boolean
|
||||
}) {
|
||||
const q = new URLSearchParams()
|
||||
q.set('image', params.image)
|
||||
q.set('mode', 'prepare')
|
||||
q.set('compressed', String(params.compressed))
|
||||
if (params.platform?.trim()) q.set('platform', params.platform.trim())
|
||||
return getJSON<PrepareDownloadResponse>(`/api/image/download?${q}`)
|
||||
}
|
||||
|
||||
export function fetchImageInfo(image: string) {
|
||||
const q = new URLSearchParams({ image })
|
||||
return getJSON<ImageInfoResponse>(`/api/image/info?${q}`)
|
||||
}
|
||||
|
||||
export function prepareBatchDownload(body: {
|
||||
images: string[]
|
||||
platform?: string
|
||||
useCompressedLayers: boolean
|
||||
}) {
|
||||
return getJSON<PrepareDownloadResponse>('/api/image/batch?mode=prepare', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function searchImages(q: string, page: number, pageSize = 25) {
|
||||
const params = new URLSearchParams({
|
||||
q,
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
})
|
||||
return getJSON<SearchResponse>(`/api/search?${params}`)
|
||||
}
|
||||
|
||||
export function fetchTags(namespace: string, name: string, page: number, pageSize = 100) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
})
|
||||
return getJSON<TagPageResult>(
|
||||
`/api/tags/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}?${params}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function triggerDownload(url: string) {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
120
web/src/components/AppShell.vue
Normal file
120
web/src/components/AppShell.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { Container, Github, Menu, Rocket, Search, X, Zap } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import ThemeToggle from '@/components/ThemeToggle.vue'
|
||||
|
||||
const STORAGE_KEY = 'theme'
|
||||
const route = useRoute()
|
||||
const isDark = ref(false)
|
||||
const menuOpen = ref(false)
|
||||
|
||||
const links = [
|
||||
{ to: '/', label: 'GitHub 加速', icon: Rocket },
|
||||
{ to: '/images', label: '离线镜像', icon: Container },
|
||||
{ to: '/search', label: '镜像搜索', icon: Search },
|
||||
] as const
|
||||
|
||||
const currentPath = computed(() => route.path)
|
||||
|
||||
function applyTheme(dark: boolean) {
|
||||
isDark.value = dark
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
localStorage.setItem(STORAGE_KEY, dark ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
applyTheme(!isDark.value)
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved === 'dark' || saved === 'light') {
|
||||
applyTheme(saved === 'dark')
|
||||
} else {
|
||||
applyTheme(window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell-atmosphere flex min-h-screen flex-col text-foreground">
|
||||
<header class="sticky top-0 z-50 border-b border-border/50 bg-background/70 backdrop-blur-xl">
|
||||
<div class="mx-auto flex h-[4.25rem] max-w-6xl items-center justify-between gap-3 px-5 sm:px-8">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="flex items-center gap-3 font-display text-lg font-semibold tracking-tight transition-opacity hover:opacity-80"
|
||||
@click="closeMenu"
|
||||
>
|
||||
<span class="brand-mark flex size-9 items-center justify-center rounded-lg">
|
||||
<Zap class="size-[18px]" />
|
||||
</span>
|
||||
<span>HubProxy</span>
|
||||
</RouterLink>
|
||||
|
||||
<nav class="hidden items-center gap-1.5 md:flex">
|
||||
<RouterLink
|
||||
v-for="link in links"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-[15px] transition-colors duration-150"
|
||||
:class="currentPath === link.to ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'"
|
||||
>
|
||||
<component :is="link.icon" class="size-4" />
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
<ThemeToggle :is-dark="isDark" button-class="ml-1" @toggle="toggleTheme" />
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-0.5 md:hidden">
|
||||
<ThemeToggle :is-dark="isDark" @toggle="toggleTheme" />
|
||||
<Button variant="ghost" size="icon" aria-label="菜单" @click="menuOpen = !menuOpen">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<X v-if="menuOpen" key="x" class="size-4" />
|
||||
<Menu v-else key="menu" class="size-4" />
|
||||
</Transition>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="menu">
|
||||
<div v-if="menuOpen" class="border-t border-border px-5 py-2 md:hidden">
|
||||
<div class="flex flex-col gap-1">
|
||||
<RouterLink
|
||||
v-for="link in links"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
class="inline-flex items-center gap-2 rounded-full px-3.5 py-2 text-[15px] transition-colors"
|
||||
:class="currentPath === link.to ? 'bg-primary text-primary-foreground' : 'text-muted-foreground'"
|
||||
@click="closeMenu"
|
||||
>
|
||||
<component :is="link.icon" class="size-4" />
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto w-full max-w-6xl flex-1 px-5 py-10 text-base sm:px-8 sm:py-16">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="flex justify-center pb-10 pt-2">
|
||||
<a
|
||||
href="https://github.com/sky22333/hubproxy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub"
|
||||
class="text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||
>
|
||||
<Github class="size-5" />
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
19
web/src/components/PageHero.vue
Normal file
19
web/src/components/PageHero.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
eyebrow: string
|
||||
title: string
|
||||
subtitle: string
|
||||
gradient?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-hero">
|
||||
<p class="eyebrow">{{ eyebrow }}</p>
|
||||
<h1 class="display-title" :class="{ 'gradient-text': gradient }">{{ title }}</h1>
|
||||
<p class="mx-auto max-w-xl text-lg text-muted-foreground sm:text-xl">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
<slot />
|
||||
</header>
|
||||
</template>
|
||||
28
web/src/components/ThemeToggle.vue
Normal file
28
web/src/components/ThemeToggle.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { Moon, Sun } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineProps<{
|
||||
isDark: boolean
|
||||
buttonClass?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ toggle: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="切换主题"
|
||||
:class="cn(buttonClass)"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<Sun v-if="isDark" key="sun" class="size-4" />
|
||||
<Moon v-else key="moon" class="size-4" />
|
||||
</Transition>
|
||||
</Button>
|
||||
</template>
|
||||
45
web/src/components/ui/Button.vue
Normal file
45
web/src/components/ui/Button.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'default' | 'secondary' | 'outline' | 'ghost'
|
||||
size?: 'default' | 'sm' | 'icon'
|
||||
disabled?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>(),
|
||||
{
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
)
|
||||
|
||||
const variants: Record<NonNullable<typeof props.variant>, string> = {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
outline: 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground text-muted-foreground',
|
||||
}
|
||||
|
||||
const sizes: Record<NonNullable<typeof props.size>, string> = {
|
||||
default: 'h-11 px-5 text-base',
|
||||
sm: 'h-9 px-3 text-sm',
|
||||
icon: 'size-11',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:class="cn(
|
||||
'inline-flex items-center justify-center gap-1.5 rounded-lg font-medium outline-none transition-[opacity,transform,background-color,color] duration-150 ease-out active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50',
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
28
web/src/components/ui/Input.vue
Normal file
28
web/src/components/ui/Input.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
type?: string
|
||||
id?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:id="id"
|
||||
v-model="model"
|
||||
:type="type || 'text'"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:class="cn(
|
||||
'h-11 w-full rounded-lg border border-input bg-transparent px-3.5 text-base outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-50',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
</template>
|
||||
40
web/src/components/ui/Switch.vue
Normal file
40
web/src/components/ui/Switch.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const checked = defineModel<boolean>('checked', { default: false })
|
||||
|
||||
const props = defineProps<{
|
||||
id?: string
|
||||
class?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
function toggle() {
|
||||
if (props.disabled) return
|
||||
checked.value = !checked.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:id="id"
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="checked"
|
||||
:disabled="disabled"
|
||||
:class="cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:opacity-50',
|
||||
checked ? 'bg-primary' : 'bg-muted',
|
||||
props.class,
|
||||
)"
|
||||
@click="toggle"
|
||||
>
|
||||
<span
|
||||
:class="cn(
|
||||
'pointer-events-none block size-4 rounded-full bg-background shadow-sm transition-transform duration-150 ease-out',
|
||||
checked ? 'translate-x-[16px]' : 'translate-x-0.5',
|
||||
)"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
26
web/src/components/ui/Textarea.vue
Normal file
26
web/src/components/ui/Textarea.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
id?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
:id="id"
|
||||
v-model="model"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:class="cn(
|
||||
'min-h-36 w-full rounded-lg border border-input bg-transparent px-3.5 py-3 text-base outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-50',
|
||||
props.class,
|
||||
)"
|
||||
/>
|
||||
</template>
|
||||
29
web/src/fonts/syne-600.woff2.css
Normal file
29
web/src/fonts/syne-600.woff2.css
Normal file
@@ -0,0 +1,29 @@
|
||||
@font-face {
|
||||
font-family: 'Syne';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 600;
|
||||
src: url('@fontsource/syne/files/syne-greek-600-normal.woff2') format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Syne';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 600;
|
||||
src: url('@fontsource/syne/files/syne-latin-ext-600-normal.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
|
||||
U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
|
||||
U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Syne';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 600;
|
||||
src: url('@fontsource/syne/files/syne-latin-600-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
|
||||
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
80
web/src/lib/utils.ts
Normal file
80
web/src/lib/utils.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { ClassValue } from 'clsx'
|
||||
import { clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatNumber(num: number): string {
|
||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B+`
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M+`
|
||||
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K+`
|
||||
return String(num)
|
||||
}
|
||||
|
||||
export function formatSize(bytes?: number): string {
|
||||
if (bytes == null || bytes <= 0) return ''
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let size = bytes
|
||||
let i = 0
|
||||
while (size >= 1024 && i < units.length - 1) {
|
||||
size /= 1024
|
||||
i++
|
||||
}
|
||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
|
||||
}
|
||||
|
||||
export function formatArchs(
|
||||
images?: Array<{ architecture?: string; os?: string; variant?: string }>,
|
||||
): string[] {
|
||||
if (!images?.length) return []
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const img of images) {
|
||||
const arch = img.architecture?.trim()
|
||||
if (!arch || arch === 'unknown') continue
|
||||
const os = img.os?.trim()
|
||||
let label = os && os !== 'unknown' ? `${os}/${arch}` : arch
|
||||
if (img.variant) label += `/${img.variant}`
|
||||
if (seen.has(label)) continue
|
||||
seen.add(label)
|
||||
out.push(label)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function formatTimeAgo(dateString?: string): string {
|
||||
if (!dateString) return '未知时间'
|
||||
const date = new Date(dateString)
|
||||
if (Number.isNaN(date.getTime())) return '未知时间'
|
||||
|
||||
const diffMs = Math.abs(Date.now() - date.getTime())
|
||||
const minutes = Math.floor(diffMs / 60_000)
|
||||
const hours = Math.floor(diffMs / 3_600_000)
|
||||
const days = Math.floor(diffMs / 86_400_000)
|
||||
const months = Math.floor(days / 30)
|
||||
const years = Math.floor(days / 365)
|
||||
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
if (days < 7) return `${days}天前`
|
||||
if (days < 30) return `${Math.floor(days / 7)}周前`
|
||||
if (months < 12) return `${months}个月前`
|
||||
if (years < 1) return '近1年'
|
||||
return `${years}年前`
|
||||
}
|
||||
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error ? error.message : fallback
|
||||
}
|
||||
10
web/src/main.ts
Normal file
10
web/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
if ('scrollRestoration' in history) {
|
||||
history.scrollRestoration = 'manual'
|
||||
}
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
178
web/src/pages/HomePage.vue
Normal file
178
web/src/pages/HomePage.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Check, Clipboard, Container, Link2, Rocket, Sparkles } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import PageHero from '@/components/PageHero.vue'
|
||||
import { copyText } from '@/lib/utils'
|
||||
|
||||
const input = ref('')
|
||||
const output = ref('')
|
||||
const error = ref('')
|
||||
const copied = ref(false)
|
||||
|
||||
const host = computed(() => window.location.host)
|
||||
|
||||
const features = [
|
||||
{ icon: Rocket, label: 'GitHub 加速' },
|
||||
{ icon: Container, label: 'Docker 镜像' },
|
||||
{ icon: Sparkles, label: 'Hugging Face' },
|
||||
] as const
|
||||
|
||||
const dockerExamples = computed(() => [
|
||||
{
|
||||
id: 'official',
|
||||
label: '官方镜像',
|
||||
original: 'docker pull nginx',
|
||||
accelerated: `docker pull ${host.value}/nginx`,
|
||||
},
|
||||
{
|
||||
id: 'user',
|
||||
label: '用户镜像',
|
||||
original: 'docker pull user/app:tag',
|
||||
accelerated: `docker pull ${host.value}/user/app:tag`,
|
||||
},
|
||||
{
|
||||
id: 'ghcr',
|
||||
label: 'GHCR',
|
||||
original: 'docker pull ghcr.io/org/app',
|
||||
accelerated: `docker pull ${host.value}/ghcr.io/org/app`,
|
||||
},
|
||||
])
|
||||
|
||||
const allowedHosts = [
|
||||
'github.com/',
|
||||
'raw.githubusercontent.com/',
|
||||
'gist.githubusercontent.com/',
|
||||
'huggingface.co/',
|
||||
'cdn-lfs.hf.co/',
|
||||
]
|
||||
|
||||
function formatLink() {
|
||||
error.value = ''
|
||||
copied.value = false
|
||||
const link = input.value.trim()
|
||||
if (!link) {
|
||||
error.value = '请输入有效的链接'
|
||||
output.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (link.startsWith('https://') || link.startsWith('http://')) {
|
||||
output.value = `https://${host.value}/${link}`
|
||||
return
|
||||
}
|
||||
|
||||
if (allowedHosts.some((prefix) => link.startsWith(prefix))) {
|
||||
output.value = `https://${host.value}/https://${link}`
|
||||
return
|
||||
}
|
||||
|
||||
error.value = '请输入有效的 GitHub / Hugging Face 链接'
|
||||
output.value = ''
|
||||
}
|
||||
|
||||
async function onCopy() {
|
||||
if (!output.value) return
|
||||
copied.value = await copyText(output.value)
|
||||
}
|
||||
|
||||
function onOpen() {
|
||||
if (!output.value) return
|
||||
window.open(output.value, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<PageHero
|
||||
eyebrow="面向开发者和运维人员的加速服务"
|
||||
title="HubProxy"
|
||||
subtitle="GitHub 文件加速 · Docker 镜像加速 · Hugging Face 资源"
|
||||
gradient
|
||||
>
|
||||
<div class="flex flex-wrap justify-center gap-2 pt-2">
|
||||
<span
|
||||
v-for="item in features"
|
||||
:key="item.label"
|
||||
class="feature-pill"
|
||||
>
|
||||
<component :is="item.icon" class="size-4" />
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</PageHero>
|
||||
|
||||
<section class="surface-panel field-block">
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
v-model="input"
|
||||
class="sm:flex-1"
|
||||
placeholder="粘贴 GitHub / Hugging Face 原始链接"
|
||||
@keyup.enter="formatLink"
|
||||
/>
|
||||
<Button @click="formatLink">获取加速链接</Button>
|
||||
</div>
|
||||
|
||||
<Transition name="fade" mode="out-in">
|
||||
<p v-if="error" key="error" class="text-center text-destructive">{{ error }}</p>
|
||||
<div v-else-if="output" key="output" class="space-y-4 pt-2">
|
||||
<div class="flex items-center justify-center gap-2 font-medium text-primary">
|
||||
<Check class="size-4" />
|
||||
加速链接已生成
|
||||
</div>
|
||||
<p class="break-all rounded-lg border border-border bg-muted/40 px-4 py-3.5 font-mono">
|
||||
{{ output }}
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<Button variant="secondary" size="sm" @click="onCopy">
|
||||
<Clipboard class="size-4" />
|
||||
{{ copied ? '已复制' : '复制链接' }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" @click="onOpen">
|
||||
<Link2 class="size-4" />
|
||||
打开链接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</section>
|
||||
|
||||
<section class="space-y-6 pt-12">
|
||||
<div class="space-y-1 text-center">
|
||||
<h2 class="text-sm font-semibold tracking-[0.16em] text-muted-foreground uppercase">
|
||||
Docker 镜像加速
|
||||
</h2>
|
||||
<p class="text-muted-foreground">
|
||||
在镜像名前加上本站域名,一行命令即可加速拉取
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="terminal-block">
|
||||
<div class="terminal-header">
|
||||
<span class="terminal-dot" />
|
||||
<span class="terminal-dot" />
|
||||
<span class="terminal-dot" />
|
||||
<span class="ml-2 text-xs text-muted-foreground">shell</span>
|
||||
</div>
|
||||
<div class="terminal-body">
|
||||
<div
|
||||
v-for="item in dockerExamples"
|
||||
:key="item.id"
|
||||
class="terminal-example"
|
||||
>
|
||||
<span class="example-tag">{{ item.label }}</span>
|
||||
<p class="font-mono leading-relaxed">
|
||||
<span class="text-muted-foreground">$ </span>
|
||||
<span class="text-muted-foreground/70 line-through decoration-muted-foreground/40">{{ item.original }}</span>
|
||||
</p>
|
||||
<p class="font-mono leading-relaxed">
|
||||
<span class="text-muted-foreground">$ </span>
|
||||
<span class="text-primary">{{ item.accelerated }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
176
web/src/pages/ImagesPage.vue
Normal file
176
web/src/pages/ImagesPage.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import {
|
||||
fetchImageInfo,
|
||||
prepareBatchDownload,
|
||||
prepareSingleDownload,
|
||||
triggerDownload,
|
||||
} from '@/api'
|
||||
import { errorMessage } from '@/lib/utils'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import PageHero from '@/components/PageHero.vue'
|
||||
import Switch from '@/components/ui/Switch.vue'
|
||||
import Textarea from '@/components/ui/Textarea.vue'
|
||||
|
||||
const singleImage = ref('')
|
||||
const singlePlatform = ref('linux/amd64')
|
||||
const singleCompressed = ref(true)
|
||||
const singleStatus = ref('')
|
||||
const singleError = ref('')
|
||||
const singleLoading = ref(false)
|
||||
|
||||
const batchText = ref('')
|
||||
const batchPlatform = ref('linux/amd64')
|
||||
const batchCompressed = ref(true)
|
||||
const batchStatus = ref('')
|
||||
const batchError = ref('')
|
||||
const batchLoading = ref(false)
|
||||
|
||||
async function preflight(images: string[]) {
|
||||
for (const image of [...new Set(images)]) {
|
||||
await fetchImageInfo(image)
|
||||
}
|
||||
}
|
||||
|
||||
async function onSingleSubmit() {
|
||||
singleError.value = ''
|
||||
singleStatus.value = ''
|
||||
const image = singleImage.value.trim()
|
||||
if (!image) {
|
||||
singleError.value = '请输入镜像名称'
|
||||
return
|
||||
}
|
||||
|
||||
singleLoading.value = true
|
||||
singleStatus.value = '正在准备下载...'
|
||||
try {
|
||||
await preflight([image])
|
||||
const data = await prepareSingleDownload({
|
||||
image,
|
||||
platform: singlePlatform.value,
|
||||
compressed: singleCompressed.value,
|
||||
})
|
||||
if (!data.download_url) throw new Error('下载地址生成失败')
|
||||
triggerDownload(data.download_url)
|
||||
const platformText = singlePlatform.value.trim()
|
||||
? ` (${singlePlatform.value.trim()})`
|
||||
: ''
|
||||
singleStatus.value = `开始下载 ${image}${platformText}`
|
||||
} catch (e) {
|
||||
singleStatus.value = ''
|
||||
singleError.value = errorMessage(e, '下载失败')
|
||||
} finally {
|
||||
singleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onBatchSubmit() {
|
||||
batchError.value = ''
|
||||
batchStatus.value = ''
|
||||
const images = batchText.value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('#'))
|
||||
|
||||
if (images.length === 0) {
|
||||
batchError.value = '请输入镜像列表'
|
||||
return
|
||||
}
|
||||
|
||||
batchLoading.value = true
|
||||
batchStatus.value = '正在准备批量下载...'
|
||||
try {
|
||||
await preflight(images)
|
||||
const data = await prepareBatchDownload({
|
||||
images,
|
||||
platform: batchPlatform.value,
|
||||
useCompressedLayers: batchCompressed.value,
|
||||
})
|
||||
if (!data.download_url) throw new Error('下载地址生成失败')
|
||||
triggerDownload(data.download_url)
|
||||
batchStatus.value = `开始下载 ${images.length} 个镜像`
|
||||
} catch (e) {
|
||||
batchStatus.value = ''
|
||||
batchError.value = errorMessage(e, '下载失败')
|
||||
} finally {
|
||||
batchLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<PageHero
|
||||
eyebrow="Offline Image"
|
||||
title="离线镜像"
|
||||
subtitle="流式下载,兼容 docker load,支持多架构。"
|
||||
/>
|
||||
|
||||
<section class="field-block">
|
||||
<h2 class="text-center text-sm font-semibold tracking-[0.16em] text-muted-foreground uppercase">
|
||||
单镜像
|
||||
</h2>
|
||||
|
||||
<Transition name="fade" mode="out-in">
|
||||
<p v-if="singleError" key="error" class="text-center text-destructive">{{ singleError }}</p>
|
||||
<p v-else-if="singleStatus" key="status" class="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 v-if="singleLoading" class="size-4 animate-spin" />
|
||||
{{ singleStatus }}
|
||||
</p>
|
||||
</Transition>
|
||||
|
||||
<label class="block space-y-1.5">
|
||||
<span>镜像名称</span>
|
||||
<Input v-model="singleImage" placeholder="nginx 或 user/app:tag" />
|
||||
</label>
|
||||
<label class="block space-y-1.5">
|
||||
<span>目标架构(可选)</span>
|
||||
<Input v-model="singlePlatform" placeholder="linux/amd64" />
|
||||
</label>
|
||||
<div class="flex items-center justify-between py-1">
|
||||
<span>压缩层</span>
|
||||
<Switch v-model:checked="singleCompressed" />
|
||||
</div>
|
||||
<Button class="w-full" :disabled="singleLoading" @click="onSingleSubmit">
|
||||
<Loader2 v-if="singleLoading" class="size-4 animate-spin" />
|
||||
{{ singleLoading ? '准备中...' : '立即下载' }}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section class="section-gap field-block">
|
||||
<h2 class="text-center text-sm font-semibold tracking-[0.16em] text-muted-foreground uppercase">
|
||||
批量下载
|
||||
</h2>
|
||||
|
||||
<Transition name="fade" mode="out-in">
|
||||
<p v-if="batchError" key="error" class="text-center text-destructive">{{ batchError }}</p>
|
||||
<p v-else-if="batchStatus" key="status" class="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 v-if="batchLoading" class="size-4 animate-spin" />
|
||||
{{ batchStatus }}
|
||||
</p>
|
||||
</Transition>
|
||||
|
||||
<label class="block space-y-1.5">
|
||||
<span>镜像列表</span>
|
||||
<Textarea
|
||||
v-model="batchText"
|
||||
placeholder="alpine redis:alpine user/app:1.0"
|
||||
/>
|
||||
</label>
|
||||
<label class="block space-y-1.5">
|
||||
<span>目标架构(可选)</span>
|
||||
<Input v-model="batchPlatform" placeholder="linux/amd64" />
|
||||
</label>
|
||||
<div class="flex items-center justify-between py-1">
|
||||
<span>压缩层</span>
|
||||
<Switch v-model:checked="batchCompressed" />
|
||||
</div>
|
||||
<Button class="w-full" :disabled="batchLoading" @click="onBatchSubmit">
|
||||
<Loader2 v-if="batchLoading" class="size-4 animate-spin" />
|
||||
{{ batchLoading ? '准备中...' : '批量下载' }}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
400
web/src/pages/SearchPage.vue
Normal file
400
web/src/pages/SearchPage.vue
Normal file
@@ -0,0 +1,400 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ChevronLeft, ChevronRight, Copy, Loader2, Search } from 'lucide-vue-next'
|
||||
import {
|
||||
fetchTags,
|
||||
searchImages,
|
||||
type Repository,
|
||||
type TagInfo,
|
||||
} from '@/api'
|
||||
import { copyText, errorMessage, formatArchs, formatNumber, formatSize, formatTimeAgo } from '@/lib/utils'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import PageHero from '@/components/PageHero.vue'
|
||||
|
||||
interface RepoView {
|
||||
raw: Repository
|
||||
displayName: string
|
||||
namespace: string
|
||||
name: string
|
||||
fullRepoName: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const query = ref('')
|
||||
const searching = ref(false)
|
||||
const searchError = ref('')
|
||||
const results = ref<RepoView[]>([])
|
||||
const resultCount = ref(0)
|
||||
const resultsPage = ref(1)
|
||||
const pageSize = 25
|
||||
|
||||
const selected = ref<RepoView | null>(null)
|
||||
const tagsLoading = ref(false)
|
||||
const tagsError = ref('')
|
||||
const tags = ref<TagInfo[]>([])
|
||||
const tagFilter = ref('')
|
||||
const tagsPage = ref(1)
|
||||
const tagsHasMore = ref(false)
|
||||
const copyHint = ref('')
|
||||
|
||||
const host = computed(() => window.location.host)
|
||||
|
||||
const hasResults = computed(() => results.value.length > 0)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(resultCount.value / pageSize)))
|
||||
const hasMoreResults = computed(() => resultsPage.value < totalPages.value)
|
||||
|
||||
const filteredTags = computed(() => {
|
||||
const q = tagFilter.value.trim().toLowerCase()
|
||||
if (!q) return tags.value
|
||||
const exact: TagInfo[] = []
|
||||
const starts: TagInfo[] = []
|
||||
const includes: TagInfo[] = []
|
||||
for (const tag of tags.value) {
|
||||
const name = tag.name.toLowerCase()
|
||||
if (name === q) exact.push(tag)
|
||||
else if (name.startsWith(q)) starts.push(tag)
|
||||
else if (name.includes(q)) includes.push(tag)
|
||||
}
|
||||
return [...exact, ...starts, ...includes]
|
||||
})
|
||||
|
||||
const displayTags = computed(() =>
|
||||
filteredTags.value.map((tag) => ({
|
||||
tag,
|
||||
archs: formatArchs(tag.images),
|
||||
size: formatSize(tag.full_size),
|
||||
})),
|
||||
)
|
||||
|
||||
function toRepoView(item: Repository): RepoView | null {
|
||||
const rawName = item.repo_name || ''
|
||||
const namespace =
|
||||
item.namespace ||
|
||||
(item.is_official ? 'library' : rawName.includes('/') ? rawName.split('/')[0] : '')
|
||||
const name = rawName.replace(/^library\//, '').includes('/')
|
||||
? rawName.split('/').pop() || ''
|
||||
: rawName.replace(/^library\//, '')
|
||||
|
||||
if (!namespace || !name) return null
|
||||
|
||||
const displayName = item.is_official
|
||||
? name
|
||||
: item.namespace
|
||||
? `${item.namespace}/${name}`
|
||||
: rawName.includes('/')
|
||||
? rawName
|
||||
: `${namespace}/${name}`
|
||||
|
||||
return {
|
||||
raw: item,
|
||||
displayName,
|
||||
namespace,
|
||||
name,
|
||||
fullRepoName: item.is_official ? name : `${namespace}/${name}`,
|
||||
}
|
||||
}
|
||||
|
||||
async function runSearch(q: string, page = 1) {
|
||||
const trimmed = q.trim()
|
||||
if (!trimmed) {
|
||||
searchError.value = '请输入搜索关键词'
|
||||
return
|
||||
}
|
||||
|
||||
searching.value = true
|
||||
searchError.value = ''
|
||||
results.value = []
|
||||
selected.value = null
|
||||
tags.value = []
|
||||
tagsError.value = ''
|
||||
tagFilter.value = ''
|
||||
|
||||
try {
|
||||
let searchQuery = trimmed
|
||||
let targetRepo = ''
|
||||
if (trimmed.includes('/')) {
|
||||
const [ns] = trimmed.split('/')
|
||||
searchQuery = ns
|
||||
targetRepo = trimmed.toLowerCase()
|
||||
}
|
||||
|
||||
const data = await searchImages(searchQuery, page, pageSize)
|
||||
const views = (data.results || [])
|
||||
.map(toRepoView)
|
||||
.filter((v): v is RepoView => v !== null)
|
||||
|
||||
views.sort((a, b) => {
|
||||
if (targetRepo) {
|
||||
const aMatch =
|
||||
a.displayName.toLowerCase() === targetRepo ||
|
||||
a.fullRepoName.toLowerCase() === targetRepo
|
||||
const bMatch =
|
||||
b.displayName.toLowerCase() === targetRepo ||
|
||||
b.fullRepoName.toLowerCase() === targetRepo
|
||||
if (aMatch && !bMatch) return -1
|
||||
if (!aMatch && bMatch) return 1
|
||||
}
|
||||
if (!!a.raw.is_official !== !!b.raw.is_official) {
|
||||
return a.raw.is_official ? -1 : 1
|
||||
}
|
||||
return (b.raw.pull_count || 0) - (a.raw.pull_count || 0)
|
||||
})
|
||||
|
||||
results.value = views
|
||||
resultCount.value = data.count ?? views.length
|
||||
resultsPage.value = page
|
||||
if (views.length === 0) searchError.value = '未找到相关镜像'
|
||||
} catch (e) {
|
||||
searchError.value = errorMessage(e, '搜索失败')
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSearch() {
|
||||
const q = query.value.trim()
|
||||
await router.replace({ path: '/search', query: q ? { q } : {} })
|
||||
await runSearch(q, 1)
|
||||
}
|
||||
|
||||
async function loadResultsPage(page: number) {
|
||||
if (page < 1 || page > totalPages.value || searching.value) return
|
||||
await runSearch(query.value, page)
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
|
||||
async function loadTagPage(repo: RepoView, page: number) {
|
||||
selected.value = repo
|
||||
tagsLoading.value = true
|
||||
tagsError.value = ''
|
||||
tagsPage.value = page
|
||||
if (page === 1) tagFilter.value = ''
|
||||
|
||||
try {
|
||||
const data = await fetchTags(repo.namespace, repo.name, page, 100)
|
||||
tags.value = data.tags || []
|
||||
tagsHasMore.value = !!data.has_more
|
||||
if (tags.value.length === 0) tagsError.value = '该镜像暂无可用标签'
|
||||
} catch (e) {
|
||||
tags.value = []
|
||||
tagsError.value = errorMessage(e, '加载标签失败')
|
||||
} finally {
|
||||
tagsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToResults() {
|
||||
selected.value = null
|
||||
tags.value = []
|
||||
tagsError.value = ''
|
||||
tagFilter.value = ''
|
||||
}
|
||||
|
||||
async function copyPull(tagName?: string) {
|
||||
if (!selected.value) return
|
||||
const image = tagName
|
||||
? `${host.value}/${selected.value.fullRepoName}:${tagName}`
|
||||
: `${host.value}/${selected.value.fullRepoName}`
|
||||
const refName = `docker pull ${image}`
|
||||
const ok = await copyText(refName)
|
||||
copyHint.value = ok ? `已复制 ${refName}` : '复制失败'
|
||||
setTimeout(() => {
|
||||
if (copyHint.value.includes(refName) || copyHint.value === '复制失败') copyHint.value = ''
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.q,
|
||||
async (q) => {
|
||||
const next = typeof q === 'string' ? q : ''
|
||||
if (next === query.value) return
|
||||
query.value = next
|
||||
if (next) await runSearch(next, 1)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHero
|
||||
eyebrow="Docker Hub"
|
||||
title="镜像搜索"
|
||||
subtitle="检索官方与社区镜像,查看标签与架构,一键复制拉取命令。"
|
||||
/>
|
||||
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="!selected" key="search" class="mx-auto max-w-3xl space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
v-model="query"
|
||||
class="sm:flex-1"
|
||||
placeholder="例如 nginx、redis、library/ubuntu"
|
||||
@keydown.enter="onSearch"
|
||||
/>
|
||||
<Button :disabled="searching" @click="onSearch">
|
||||
<Loader2 v-if="searching" class="size-4 animate-spin" />
|
||||
<Search v-else class="size-4" />
|
||||
{{ searching ? '搜索中...' : '搜索' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="searchError"
|
||||
class="text-center text-destructive"
|
||||
>
|
||||
{{ searchError }}
|
||||
</p>
|
||||
|
||||
<div v-if="searching" class="space-y-3">
|
||||
<div v-for="i in 3" :key="i" class="h-16 animate-pulse rounded-xl bg-muted" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="hasResults" class="space-y-2">
|
||||
<p class="text-center text-muted-foreground">
|
||||
共 {{ resultCount }} 条结果
|
||||
<template v-if="totalPages > 1"> · 第 {{ resultsPage }} / {{ totalPages }} 页</template>
|
||||
</p>
|
||||
<div class="divide-y divide-border border-y border-border">
|
||||
<button
|
||||
v-for="item in results"
|
||||
:key="`${item.namespace}/${item.name}`"
|
||||
type="button"
|
||||
class="w-full py-4 text-left transition-colors duration-150 hover:text-primary"
|
||||
@click="loadTagPage(item, 1)"
|
||||
>
|
||||
<div class="mb-1 flex flex-wrap items-center gap-2">
|
||||
<span class="text-base font-medium">{{ item.displayName }}</span>
|
||||
<span
|
||||
v-if="item.raw.is_official"
|
||||
class="rounded-full bg-primary/12 px-2 py-0.5 text-[11px] text-primary"
|
||||
>官方</span>
|
||||
<span
|
||||
v-if="item.raw.star_count"
|
||||
class="text-xs text-muted-foreground"
|
||||
>★ {{ formatNumber(item.raw.star_count) }}</span>
|
||||
<span
|
||||
v-if="item.raw.pull_count"
|
||||
class="text-xs text-muted-foreground"
|
||||
>⬇ {{ formatNumber(item.raw.pull_count) }}</span>
|
||||
</div>
|
||||
<p class="line-clamp-2 text-muted-foreground">
|
||||
{{ item.raw.short_description || '暂无描述' }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-center gap-1.5 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="searching || resultsPage <= 1"
|
||||
@click="loadResultsPage(resultsPage - 1)"
|
||||
>
|
||||
<ChevronLeft class="size-4" />
|
||||
</Button>
|
||||
<span class="min-w-14 text-center text-muted-foreground">第 {{ resultsPage }} 页</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="searching || !hasMoreResults"
|
||||
@click="loadResultsPage(resultsPage + 1)"
|
||||
>
|
||||
<ChevronRight class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else key="tags" class="mx-auto max-w-3xl space-y-6">
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground transition-colors hover:text-primary"
|
||||
@click="backToResults"
|
||||
>
|
||||
← 返回搜索结果
|
||||
</button>
|
||||
|
||||
<div class="space-y-2 text-center">
|
||||
<div class="flex flex-wrap items-center justify-center gap-2">
|
||||
<h2 class="text-2xl font-semibold tracking-tight sm:text-3xl">{{ selected.fullRepoName }}</h2>
|
||||
<span
|
||||
v-if="selected.raw.is_official"
|
||||
class="rounded-full bg-primary/12 px-2 py-0.5 text-[11px] text-primary"
|
||||
>官方</span>
|
||||
</div>
|
||||
<p class="text-base text-muted-foreground">
|
||||
{{ selected.raw.short_description || '暂无描述' }}
|
||||
</p>
|
||||
<Transition name="fade">
|
||||
<p v-if="copyHint" class="text-muted-foreground">{{ copyHint }}</p>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input v-model="tagFilter" class="sm:flex-1" placeholder="筛选当前页标签..." />
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="tagsLoading || tagsPage <= 1"
|
||||
@click="loadTagPage(selected, tagsPage - 1)"
|
||||
>
|
||||
<ChevronLeft class="size-4" />
|
||||
</Button>
|
||||
<span class="min-w-14 text-center text-muted-foreground">第 {{ tagsPage }} 页</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="tagsLoading || !tagsHasMore"
|
||||
@click="loadTagPage(selected, tagsPage + 1)"
|
||||
>
|
||||
<ChevronRight class="size-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="copyPull()">
|
||||
<Copy class="size-4" />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="tagsError" class="text-center text-destructive">{{ tagsError }}</p>
|
||||
<div v-else-if="tagsLoading" class="space-y-3">
|
||||
<div v-for="i in 5" :key="i" class="h-14 animate-pulse rounded-xl bg-muted" />
|
||||
</div>
|
||||
<p v-else-if="displayTags.length === 0" class="text-center text-muted-foreground">
|
||||
没有匹配的标签
|
||||
</p>
|
||||
<div v-else class="divide-y divide-border border-y border-border">
|
||||
<div
|
||||
v-for="{ tag, archs, size } in displayTags"
|
||||
:key="tag.name"
|
||||
class="flex items-start justify-between gap-3 py-4"
|
||||
>
|
||||
<div class="min-w-0 space-y-1.5">
|
||||
<p class="truncate text-base font-medium">{{ tag.name }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<template v-if="size">{{ size }} · </template>
|
||||
{{ formatTimeAgo(tag.last_updated) }}
|
||||
</p>
|
||||
<div v-if="archs.length" class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="arch in archs"
|
||||
:key="arch"
|
||||
class="rounded-full bg-primary/10 px-2 py-0.5 font-mono text-[11px] text-primary"
|
||||
>{{ arch }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" class="shrink-0" @click="copyPull(tag.name)">
|
||||
<Copy class="size-4" />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
37
web/src/router/index.ts
Normal file
37
web/src/router/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomePage from '@/pages/HomePage.vue'
|
||||
import ImagesPage from '@/pages/ImagesPage.vue'
|
||||
import SearchPage from '@/pages/SearchPage.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: HomePage,
|
||||
meta: { title: 'GitHub 加速' },
|
||||
},
|
||||
{
|
||||
path: '/images',
|
||||
component: ImagesPage,
|
||||
meta: { title: '离线镜像下载' },
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
component: SearchPage,
|
||||
meta: { title: '镜像搜索' },
|
||||
},
|
||||
],
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) return savedPosition
|
||||
if (to.path !== from.path) return { top: 0, left: 0 }
|
||||
return false
|
||||
},
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const title = (to.meta.title as string) || 'HubProxy'
|
||||
document.title = `${title} · HubProxy`
|
||||
})
|
||||
|
||||
export default router
|
||||
233
web/src/style.css
Normal file
233
web/src/style.css
Normal file
@@ -0,0 +1,233 @@
|
||||
@import "tailwindcss";
|
||||
@import "@fontsource-variable/manrope";
|
||||
@import "./fonts/syne-600.woff2.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Manrope Variable", "Manrope", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: "Syne", "Manrope Variable", ui-sans-serif, system-ui, sans-serif;
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--radius-lg: var(--radius);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.75rem;
|
||||
--background: oklch(0.985 0.004 260);
|
||||
--foreground: oklch(0.2 0.02 260);
|
||||
--primary: oklch(0.46 0.14 264);
|
||||
--primary-foreground: oklch(0.99 0.005 264);
|
||||
--secondary: oklch(0.945 0.012 260);
|
||||
--secondary-foreground: oklch(0.28 0.03 260);
|
||||
--muted: oklch(0.955 0.008 260);
|
||||
--muted-foreground: oklch(0.48 0.025 260);
|
||||
--accent: oklch(0.94 0.015 260);
|
||||
--accent-foreground: oklch(0.26 0.03 260);
|
||||
--destructive: oklch(0.55 0.2 25);
|
||||
--border: oklch(0.9 0.01 260);
|
||||
--input: oklch(0.9 0.01 260);
|
||||
--ring: oklch(0.46 0.14 264);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.16 0.015 260);
|
||||
--foreground: oklch(0.96 0.008 260);
|
||||
--primary: oklch(0.72 0.11 264);
|
||||
--primary-foreground: oklch(0.16 0.02 260);
|
||||
--secondary: oklch(0.24 0.02 260);
|
||||
--secondary-foreground: oklch(0.94 0.008 260);
|
||||
--muted: oklch(0.24 0.02 260);
|
||||
--muted-foreground: oklch(0.68 0.025 260);
|
||||
--accent: oklch(0.26 0.025 260);
|
||||
--accent-foreground: oklch(0.94 0.008 260);
|
||||
--destructive: oklch(0.65 0.17 22);
|
||||
--border: oklch(1 0 0 / 11%);
|
||||
--input: oklch(1 0 0 / 14%);
|
||||
--ring: oklch(0.72 0.11 264);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
html {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
html::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.shell-atmosphere {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.shell-atmosphere::before {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 45% at 50% -15%, oklch(0.72 0.06 264 / 0.12), transparent 60%),
|
||||
linear-gradient(180deg, var(--background), oklch(0.975 0.006 260));
|
||||
}
|
||||
|
||||
.dark .shell-atmosphere::before {
|
||||
background:
|
||||
radial-gradient(ellipse 65% 40% at 50% -10%, oklch(0.45 0.08 264 / 0.18), transparent 55%),
|
||||
linear-gradient(180deg, var(--background), oklch(0.14 0.015 260));
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
@apply bg-primary/10 text-primary dark:bg-primary/15;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply mb-3 text-[11px] font-semibold tracking-[0.22em] text-primary uppercase;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
@apply relative mb-14 space-y-4 pb-12 text-center sm:mb-16 sm:pb-14;
|
||||
}
|
||||
|
||||
.page-hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
width: min(8rem, 32%);
|
||||
height: 2px;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.display-title {
|
||||
font-family: var(--font-display);
|
||||
@apply text-5xl font-semibold tracking-tight sm:text-6xl;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, var(--foreground) 30%, var(--primary) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.feature-pill {
|
||||
@apply inline-flex items-center gap-2 rounded-full border border-border bg-background/60 px-3.5 py-1.5 text-sm text-muted-foreground backdrop-blur-sm;
|
||||
}
|
||||
|
||||
.surface-panel {
|
||||
@apply rounded-xl border border-border/80 bg-background/50 p-5 backdrop-blur-sm;
|
||||
}
|
||||
|
||||
.terminal-block {
|
||||
@apply overflow-hidden rounded-xl border border-border/80 bg-background/50 backdrop-blur-sm;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
@apply flex items-center gap-1.5 border-b border-border/70 px-4 py-2.5;
|
||||
}
|
||||
|
||||
.terminal-dot {
|
||||
@apply size-2 rounded-full bg-border;
|
||||
}
|
||||
|
||||
.terminal-body {
|
||||
@apply space-y-0 px-4 py-3;
|
||||
}
|
||||
|
||||
.terminal-example {
|
||||
@apply space-y-1 py-3;
|
||||
}
|
||||
|
||||
.terminal-example + .terminal-example {
|
||||
@apply border-t border-border/60;
|
||||
}
|
||||
|
||||
.example-tag {
|
||||
@apply mb-1 inline-block rounded-md bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary;
|
||||
}
|
||||
|
||||
.section-gap {
|
||||
@apply space-y-8 border-t border-border pt-12;
|
||||
}
|
||||
|
||||
.field-block {
|
||||
@apply space-y-4;
|
||||
}
|
||||
}
|
||||
|
||||
.page-enter-active {
|
||||
transition: opacity 150ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.page-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition:
|
||||
opacity 180ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
.menu-enter-active,
|
||||
.menu-leave-active {
|
||||
transition:
|
||||
opacity 160ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 160ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.menu-enter-from,
|
||||
.menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-enter-active,
|
||||
.fade-enter-active,
|
||||
.fade-leave-active,
|
||||
.menu-enter-active,
|
||||
.menu-leave-active {
|
||||
transition: none !important;
|
||||
}
|
||||
.page-enter-from,
|
||||
.fade-enter-from,
|
||||
.fade-leave-to,
|
||||
.menu-enter-from,
|
||||
.menu-leave-to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
18
web/tsconfig.app.json
Normal file
18
web/tsconfig.app.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
13
web/tsconfig.json
Normal file
13
web/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
23
web/tsconfig.node.json
Normal file
23
web/tsconfig.node.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
23
web/vite.config.ts
Normal file
23
web/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'node:path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: '../src/dist',
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:5000',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user