Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11bf9221c2 | |||
| b80f4844a4 | |||
| fc77ddb1ef | |||
| 85e47b7ce5 | |||
| 3e8ceb2b32 | |||
| 53cc1761ce |
|
Before Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -1,51 +0,0 @@
|
||||
name: Docs 文档发布
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/src/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: docs/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: docs
|
||||
run: npm ci
|
||||
|
||||
- name: Build docs
|
||||
working-directory: docs
|
||||
run: npm run build
|
||||
|
||||
- uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: docs/dist
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -25,18 +25,6 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -1,31 +1,5 @@
|
||||
# IDE / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
|
||||
# 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/
|
||||
|
||||
# Docs site (docs/)
|
||||
docs/node_modules/
|
||||
docs/dist/
|
||||
docs/.astro/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
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
|
||||
@@ -16,7 +8,6 @@ COPY src/go.mod src/go.sum ./
|
||||
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 -X main.Version=${VERSION}" -trimpath -o hubproxy . && upx -9 hubproxy
|
||||
|
||||
|
||||
@@ -9,28 +9,30 @@
|
||||
<img src="https://count.getloli.com/get/@sky22333.hubproxy?theme=rule34" alt="Visitors">
|
||||
</p>
|
||||
|
||||
|
||||
## 特性
|
||||
|
||||
- 🐳 **Docker 镜像加速** — 兼容 Registry API v2,支持 Docker Hub、GHCR、Quay、GCR、registry.k8s.io;流式传输,Manifest / Token 缓存
|
||||
- 📦 **离线镜像包** — 无需本地 Docker,在线打包单镜像或批量 tar;流式下载 + 防抖设计
|
||||
- 📁 **GitHub 文件加速** — Release、Raw、Git Clone、`api.github.com`;`.sh` / `.ps1` 脚本内 URL 自动改写
|
||||
- 🤖 **Hugging Face 加速** — 模型文件与 LFS 大文件下载
|
||||
- 🔍 **镜像搜索** — Web 界面与 API 搜索 Docker Hub 镜像、浏览标签
|
||||
- 🛡️ **智能限流** — 按真实客户端 IP 令牌桶限流(IPv6 按 `/64`);可配置周期与配额
|
||||
- 🚫 **仓库访问控制** — IP 黑白名单(限流豁免 / 封禁)+ 镜像 / GitHub 仓库黑白名单,支持通配符
|
||||
- 🌐 **上游 SOCKS5 代理** — 可选配置出站代理,适配特殊网络环境
|
||||
- 🖥️ **Web 界面** — 内置 Vue SPA,镜像搜索、离线包下载、标签浏览
|
||||
- ⚡ **轻量高效** — Go 单二进制,支持 `deb` / `rpm` / `apk` 与 Docker 多架构镜像
|
||||
- 🔧 **统一配置** — `config.toml` + 环境变量覆盖,开箱即用
|
||||
- 🚀 **多服务统一加速** — 单个程序覆盖 Docker、GitHub、Hugging Face,简化部署
|
||||
- ☁️ **完全自托管** — 不依赖第三方免费 CDN 代理,数据与带宽自主可控
|
||||
- 🐳 **Docker 镜像加速** - 支持 Docker Hub、GHCR、Quay 等多个镜像仓库加速,流式传输优化拉取速度。
|
||||
- 🐳 **离线镜像包** - 支持下载离线镜像包,流式传输加防抖设计。
|
||||
- 📁 **GitHub 文件加速** - 加速 GitHub Release、Raw 文件下载,支持`api.github.com`,脚本嵌套加速等等
|
||||
- 🤖 **AI 模型库支持** - 支持 Hugging Face 模型下载加速
|
||||
- 🛡️ **智能限流** - IP 限流保护,防止滥用
|
||||
- 🚫 **仓库审计** - 强大的自定义黑名单,白名单,同时审计镜像仓库,和GitHub仓库
|
||||
- 🔍 **镜像搜索** - 在线搜索 Docker 镜像
|
||||
- ⚡ **轻量高效** - 基于 Go 语言,单二进制文件运行,资源占用低。
|
||||
- 🔧 **统一配置** - 统一配置管理,便于维护。
|
||||
- 🛡️ **完全自托管** - 避免依赖免费第三方服务的不稳定性,例如`cloudflare`等等。
|
||||
- 🚀 **多服务统一加速** - 单个程序即可统一加速 Docker、GitHub、Hugging Face 等多种服务,简化部署与管理。
|
||||
|
||||
## 详细文档
|
||||
|
||||
[中文文档](https://zread.ai/sky22333/hubproxy)
|
||||
|
||||
[English](https://deepwiki.com/sky22333/hubproxy)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### Docker 部署(推荐)
|
||||
|
||||
```bash
|
||||
### Docker部署(推荐)
|
||||
```
|
||||
docker run -d \
|
||||
--name hubproxy \
|
||||
-p 5000:5000 \
|
||||
@@ -38,53 +40,265 @@ docker run -d \
|
||||
ghcr.io/sky22333/hubproxy
|
||||
```
|
||||
|
||||
验证服务:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:5000/ready
|
||||
```
|
||||
|
||||
或者网页访问
|
||||
|
||||
### 脚本安装
|
||||
|
||||
自动识别 `amd64` / `arm64` 与 `apt`、`dnf`、`apk` 等包管理器:
|
||||
自动识别系统与架构,从 GitHub Releases 下载对应的 `.deb`、`.rpm` 或 `.apk` 安装包:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh | sh
|
||||
```
|
||||
|
||||
安装后配置文件位于 `/etc/hubproxy/config.toml`,服务自动启动。
|
||||
安装包会自动安装并启动 `hubproxy` 服务。
|
||||
|
||||
### 快速上手
|
||||
<details>
|
||||
<summary>服务管理命令</summary>
|
||||
|
||||
#### systemd(Debian / Ubuntu / RHEL / CentOS / Fedora)
|
||||
|
||||
将 `yourdomain.com` 换成你的 `HubProxy` 地址
|
||||
```bash
|
||||
# Docker 镜像加速
|
||||
# 查看状态
|
||||
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`
|
||||
|
||||
## 使用方法
|
||||
|
||||
### Docker 镜像加速
|
||||
|
||||
```bash
|
||||
# 原命令
|
||||
docker pull nginx
|
||||
|
||||
# 使用加速
|
||||
docker pull yourdomain.com/nginx
|
||||
|
||||
# GitHub Release 加速
|
||||
wget "https://yourdomain.com/https://github.com/owner/repo/releases/download/v1.0.0/app.tar.gz"
|
||||
# ghcr加速
|
||||
docker pull yourdomain.com/ghcr.io/sky22333/hubproxy
|
||||
|
||||
# Git clone 加速
|
||||
# 符合Docker Registry API v2标准的仓库都支持
|
||||
```
|
||||
|
||||
当然也支持配置为全局镜像加速,在主机上新建(或编辑)`/etc/docker/daemon.json`
|
||||
|
||||
在 `"registry-mirrors"` 中加入域名:
|
||||
|
||||
```json
|
||||
{
|
||||
"registry-mirrors": [
|
||||
"https://yourdomain.com"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
若已设置其他加速地址,直接并列添加后保存,再执行 `sudo systemctl restart docker` 重启docker服务让配置生效。
|
||||
|
||||
### GitHub 文件加速
|
||||
|
||||
```bash
|
||||
# 原链接
|
||||
https://github.com/user/repo/releases/download/v1.0.0/file.tar.gz
|
||||
|
||||
# 加速链接
|
||||
https://yourdomain.com/https://github.com/user/repo/releases/download/v1.0.0/file.tar.gz
|
||||
|
||||
# 加速下载仓库
|
||||
git clone https://yourdomain.com/https://github.com/sky22333/hubproxy.git
|
||||
```
|
||||
|
||||
> **生产环境建议**:绑定自有域名,通过 Caddy / Nginx 反代并开启 HTTPS,不要长期暴露裸 `http://IP:5000`。详见 [文档](https://docs.52013120.xyz/getting-started/quick-start/)。
|
||||
## 配置
|
||||
|
||||
## 详细文档
|
||||
<details>
|
||||
<summary>config.toml 配置说明</summary>
|
||||
|
||||
部署架构、完整配置、K8s / NAS、传输特性与 FAQ 见官方文档站:
|
||||
*此配置是默认配置,已经内置在程序中了*
|
||||
|
||||
- [**中文文档**](https://docs.52013120.xyz/)
|
||||
- [**English**](https://docs.52013120.xyz/en/)
|
||||
```
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
# 监听端口
|
||||
port = 5000
|
||||
# Github文件大小限制(字节),默认2GB
|
||||
fileSize = 2147483648
|
||||
# HTTP/2 多路复用,提升下载速度
|
||||
enableH2C = false
|
||||
# 是否启用前端静态页面
|
||||
enableFrontend = true
|
||||
|
||||
[rateLimit]
|
||||
# 每个IP每周期允许的请求数(注意Docker镜像会有多个层,会消耗多个次数)
|
||||
requestLimit = 500
|
||||
# 限流周期(小时)
|
||||
periodHours = 3.0
|
||||
|
||||
[security]
|
||||
# IP白名单,支持单个IP或IP段
|
||||
# 白名单中的IP不受限流限制
|
||||
whiteList = [
|
||||
"127.0.0.1",
|
||||
"172.17.0.0/16",
|
||||
"192.168.1.0/24"
|
||||
]
|
||||
|
||||
# IP黑名单,支持单个IP或IP段
|
||||
# 黑名单中的IP将被直接拒绝访问
|
||||
blackList = [
|
||||
"192.168.100.1",
|
||||
"192.168.100.0/24"
|
||||
]
|
||||
|
||||
[access]
|
||||
# 代理服务白名单(支持GitHub仓库和Docker镜像,支持通配符)
|
||||
# 只允许访问白名单中的仓库/镜像,为空时不限制
|
||||
whiteList = []
|
||||
|
||||
# 代理服务黑名单(支持GitHub仓库和Docker镜像,支持通配符)
|
||||
# 禁止访问黑名单中的仓库/镜像
|
||||
blackList = [
|
||||
"baduser/malicious-repo",
|
||||
"*/malicious-repo",
|
||||
"baduser/*"
|
||||
]
|
||||
|
||||
# 代理配置,支持有用户名/密码认证和无认证模式
|
||||
# 无认证: socks5://127.0.0.1:1080
|
||||
# 有认证: socks5://username:password@127.0.0.1:1080
|
||||
# 留空不使用代理
|
||||
proxy = ""
|
||||
|
||||
[download]
|
||||
# 批量下载离线镜像数量限制
|
||||
maxImages = 10
|
||||
|
||||
# Registry映射配置,支持多种镜像仓库上游
|
||||
[registries]
|
||||
|
||||
# GitHub Container Registry
|
||||
[registries."ghcr.io"]
|
||||
upstream = "ghcr.io"
|
||||
authHost = "ghcr.io/token"
|
||||
authType = "github"
|
||||
enabled = true
|
||||
|
||||
# Google Container Registry
|
||||
[registries."gcr.io"]
|
||||
upstream = "gcr.io"
|
||||
authHost = "gcr.io/v2/token"
|
||||
authType = "google"
|
||||
enabled = true
|
||||
|
||||
# Quay.io Container Registry
|
||||
[registries."quay.io"]
|
||||
upstream = "quay.io"
|
||||
authHost = "quay.io/v2/auth"
|
||||
authType = "quay"
|
||||
enabled = true
|
||||
|
||||
# Kubernetes Container Registry
|
||||
[registries."registry.k8s.io"]
|
||||
upstream = "registry.k8s.io"
|
||||
authHost = "registry.k8s.io"
|
||||
authType = "anonymous"
|
||||
enabled = true
|
||||
|
||||
[tokenCache]
|
||||
# 是否启用缓存(同时控制Token和Manifest缓存)显著提升性能
|
||||
enabled = true
|
||||
# 默认缓存时间(分钟)
|
||||
defaultTTL = "20m"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 环境变量(可选)
|
||||
|
||||
支持通过环境变量覆盖部分配置,优先级高于`config.toml`,以下是默认值:
|
||||
|
||||
```
|
||||
CONFIG_PATH=config.toml # 配置文件路径
|
||||
SERVER_HOST=0.0.0.0 # 监听地址
|
||||
SERVER_PORT=5000 # 监听端口
|
||||
ENABLE_H2C=false # 是否启用 H2C
|
||||
ENABLE_FRONTEND=true # 是否启用前端静态页面
|
||||
MAX_FILE_SIZE=2147483648 # GitHub 文件大小限制(字节)
|
||||
RATE_LIMIT=500 # 每周期请求数
|
||||
RATE_PERIOD_HOURS=3 # 限流周期(小时)
|
||||
IP_WHITELIST=127.0.0.1,192.168.1.0/24 # IP 白名单(逗号分隔)
|
||||
IP_BLACKLIST=192.168.100.1,192.168.100.0/24 # IP 黑名单(逗号分隔)
|
||||
MAX_IMAGES=10 # 批量下载镜像数量限制
|
||||
ACCESS_PROXY= # 代理配置,例如 socks5://127.0.0.1:1080
|
||||
```
|
||||
|
||||
为了IP限流能够正常运行,反向代理需要传递IP头用来获取访客真实IP,以caddy为例:
|
||||
```
|
||||
example.com {
|
||||
reverse_proxy {
|
||||
to 127.0.0.1:5000
|
||||
header_up X-Real-IP {remote}
|
||||
header_up X-Forwarded-For {remote}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
}
|
||||
}
|
||||
```
|
||||
cloudflare CDN:
|
||||
```
|
||||
example.com {
|
||||
reverse_proxy 127.0.0.1:5000 {
|
||||
header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
|
||||
header_up X-Real-IP {http.request.header.CF-Connecting-IP}
|
||||
header_up X-Forwarded-Proto https
|
||||
header_up X-Forwarded-Host {host}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 对于使用nginx反代的用户,Github加速提示`无效输入`的问题可以参见[issues/62](https://github.com/sky22333/hubproxy/issues/62#issuecomment-3219572440)
|
||||
|
||||
|
||||
## 界面预览
|
||||
|
||||

|
||||
|
||||
## 免责声明
|
||||
## ⚠️ 免责声明
|
||||
|
||||
- 本程序仅供学习交流使用,请勿用于非法用途
|
||||
- 使用本程序需遵守当地法律法规
|
||||
@@ -92,4 +306,15 @@ git clone https://yourdomain.com/https://github.com/sky22333/hubproxy.git
|
||||
|
||||
---
|
||||
|
||||
**如果这个项目对你有帮助,请给个 Star ⭐**
|
||||
<div align="center">
|
||||
|
||||
**⭐ 如果这个项目对你有帮助,请给个 Star!⭐**
|
||||
|
||||
</div>
|
||||
|
||||
## 界面预览
|
||||
|
||||

|
||||
|
||||
## Star 趋势
|
||||
[](https://starchart.cc/sky22333/hubproxy)
|
||||
|
||||
@@ -10,5 +10,5 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "200m"
|
||||
max-file: "3"
|
||||
max-size: "1g"
|
||||
max-file: "2"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
@@ -1,36 +0,0 @@
|
||||
# HubProxy 文档站
|
||||
|
||||
基于 [Astro Starlight](https://starlight.astro.build/),默认中文,英文位于 `/en/`。
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
npm run build # 输出到 dist/
|
||||
npm run preview # 本地预览构建结果
|
||||
```
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
src/content/docs/ # 中文文档
|
||||
src/content/docs/en/ # 英文文档
|
||||
src/assets/ # logo、hero 等资源
|
||||
public/favicon.svg # 浏览器标签页图标
|
||||
src/styles/custom.css # 自定义样式
|
||||
astro.config.mjs # Starlight 配置(site: docs.52013120.xyz)
|
||||
```
|
||||
|
||||
## 贡献
|
||||
|
||||
1. 修改 `src/content/docs/` 中文页面,并同步 `en/` 镜像
|
||||
2. 侧边栏结构见 `astro.config.mjs`
|
||||
3. 向主仓库提交 PR
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { defineConfig } from 'astro/config'
|
||||
import starlight from '@astrojs/starlight'
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://docs.52013120.xyz',
|
||||
base: '/',
|
||||
integrations: [
|
||||
starlight({
|
||||
title: 'HubProxy',
|
||||
description: 'Docker 与 GitHub 加速代理服务文档',
|
||||
defaultLocale: 'root',
|
||||
locales: {
|
||||
root: {
|
||||
label: '简体中文',
|
||||
lang: 'zh-CN',
|
||||
},
|
||||
en: {
|
||||
label: 'English',
|
||||
lang: 'en',
|
||||
},
|
||||
},
|
||||
logo: {
|
||||
alt: 'HubProxy',
|
||||
src: './src/assets/logo.svg',
|
||||
},
|
||||
social: [
|
||||
{
|
||||
icon: 'github',
|
||||
label: 'GitHub',
|
||||
href: 'https://github.com/sky22333/hubproxy',
|
||||
},
|
||||
],
|
||||
editLink: {
|
||||
baseUrl: 'https://github.com/sky22333/hubproxy/edit/main/docs/',
|
||||
},
|
||||
sidebar: [
|
||||
{
|
||||
label: '简介',
|
||||
translations: { en: 'Introduction' },
|
||||
link: '/',
|
||||
},
|
||||
{
|
||||
label: '快速开始',
|
||||
translations: { en: 'Getting Started' },
|
||||
collapsed: true,
|
||||
items: [{ autogenerate: { directory: 'getting-started' } }],
|
||||
},
|
||||
{
|
||||
label: '部署',
|
||||
translations: { en: 'Deployment' },
|
||||
collapsed: true,
|
||||
items: [{ autogenerate: { directory: 'deployment' } }],
|
||||
},
|
||||
{
|
||||
label: '使用指南',
|
||||
translations: { en: 'Guides' },
|
||||
collapsed: true,
|
||||
items: [{ autogenerate: { directory: 'guides' } }],
|
||||
},
|
||||
{
|
||||
label: '配置',
|
||||
translations: { en: 'Configuration' },
|
||||
collapsed: true,
|
||||
items: [{ autogenerate: { directory: 'configuration' } }],
|
||||
},
|
||||
{
|
||||
label: '安全',
|
||||
translations: { en: 'Security' },
|
||||
collapsed: true,
|
||||
items: [{ autogenerate: { directory: 'security' } }],
|
||||
},
|
||||
{
|
||||
label: '常见问题',
|
||||
translations: { en: 'FAQ' },
|
||||
link: '/faq/',
|
||||
},
|
||||
],
|
||||
customCss: ['./src/styles/custom.css'],
|
||||
}),
|
||||
],
|
||||
})
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "hubproxy-docs",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "npm@11.12.1",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/starlight": "^0.41.3",
|
||||
"astro": "^7.0.7",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="f-bg" x1="10" y1="6" x2="54" y2="58" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#3b82f6"/>
|
||||
<stop stop-color="#1d4ed8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="4" y="4" width="56" height="56" rx="16" fill="url(#f-bg)"/>
|
||||
<rect x="28" y="18" width="8" height="28" rx="4" fill="#fff"/>
|
||||
<path d="M16 32h8M48 32H40" stroke="#fff" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<path d="M20 27.5 24 32l-4 4.5M44 27.5 40 32l4 4.5" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 642 B |
@@ -1,57 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 320" fill="none" role="img" aria-label="HubProxy">
|
||||
<defs>
|
||||
<linearGradient id="hero-bg" x1="0" y1="0" x2="480" y2="320" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#eff6ff"/>
|
||||
<stop stop-color="#f8fafc"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="hero-icon" x1="180" y1="72" x2="300" y2="192" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#3b82f6"/>
|
||||
<stop stop-color="#1d4ed8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="hero-shine" x1="200" y1="88" x2="280" y2="168" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#fff" stop-opacity="0.35"/>
|
||||
<stop stop-color="#fff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="hero-glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(240 130) rotate(90) scale(120 160)">
|
||||
<stop stop-color="#3b82f6" stop-opacity="0.18"/>
|
||||
<stop stop-color="#3b82f6" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="hero-shadow" x="160" y="70" width="160" height="160" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="14" flood-color="#1d4ed8" flood-opacity="0.18"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="480" height="320" rx="28" fill="url(#hero-bg)"/>
|
||||
<rect width="480" height="320" rx="28" fill="url(#hero-glow)"/>
|
||||
|
||||
<g opacity="0.35" stroke="#93c5fd" stroke-width="1.5">
|
||||
<path d="M72 96h64M72 224h64M344 96h64M344 224h64"/>
|
||||
<circle cx="72" cy="96" r="4" fill="#bfdbfe" stroke="none"/>
|
||||
<circle cx="136" cy="96" r="4" fill="#bfdbfe" stroke="none"/>
|
||||
<circle cx="344" cy="96" r="4" fill="#bfdbfe" stroke="none"/>
|
||||
<circle cx="408" cy="96" r="4" fill="#bfdbfe" stroke="none"/>
|
||||
<circle cx="72" cy="224" r="4" fill="#bfdbfe" stroke="none"/>
|
||||
<circle cx="136" cy="224" r="4" fill="#bfdbfe" stroke="none"/>
|
||||
<circle cx="344" cy="224" r="4" fill="#bfdbfe" stroke="none"/>
|
||||
<circle cx="408" cy="224" r="4" fill="#bfdbfe" stroke="none"/>
|
||||
</g>
|
||||
|
||||
<path d="M136 96C168 96 196 110 214 130M344 96C312 96 284 110 266 130" stroke="#93c5fd" stroke-width="2" stroke-linecap="round" stroke-dasharray="6 8" opacity="0.7"/>
|
||||
<path d="M136 224C168 224 196 210 214 190M344 224C312 224 284 210 266 190" stroke="#93c5fd" stroke-width="2" stroke-linecap="round" stroke-dasharray="6 8" opacity="0.7"/>
|
||||
|
||||
<g filter="url(#hero-shadow)">
|
||||
<rect x="192" y="82" width="96" height="96" rx="28" fill="url(#hero-icon)"/>
|
||||
<rect x="192" y="82" width="96" height="96" rx="28" fill="url(#hero-shine)"/>
|
||||
<rect x="234" y="104" width="12" height="52" rx="6" fill="#fff" fill-opacity="0.96"/>
|
||||
<path d="M208 130h16" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
|
||||
<path d="M216 121l8 9-8 9" stroke="#fff" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M272 130h-16" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
|
||||
<path d="M264 121l-8 9 8 9" stroke="#fff" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="240" cy="92" r="4" fill="#fff" fill-opacity="0.55"/>
|
||||
<circle cx="240" cy="168" r="4" fill="#fff" fill-opacity="0.55"/>
|
||||
<path d="M240 96v6M240 162v6" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-opacity="0.45"/>
|
||||
</g>
|
||||
|
||||
<text x="240" y="228" text-anchor="middle" fill="#0f172a" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif" font-size="34" font-weight="700" letter-spacing="-0.02em">HubProxy</text>
|
||||
<text x="240" y="258" text-anchor="middle" fill="#64748b" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif" font-size="15" font-weight="500">Docker · GitHub · Registry Proxy</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1,22 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" role="img" aria-label="HubProxy">
|
||||
<defs>
|
||||
<linearGradient id="hp-bg" x1="10" y1="6" x2="54" y2="58" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#3b82f6"/>
|
||||
<stop stop-color="#1d4ed8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="hp-shine" x1="20" y1="12" x2="44" y2="40" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#fff" stop-opacity="0.28"/>
|
||||
<stop stop-color="#fff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="4" y="4" width="56" height="56" rx="16" fill="url(#hp-bg)"/>
|
||||
<rect x="4" y="4" width="56" height="56" rx="16" fill="url(#hp-shine)"/>
|
||||
<rect x="28" y="18" width="8" height="28" rx="4" fill="#fff" fill-opacity="0.95"/>
|
||||
<path d="M16 32h8" stroke="#fff" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<path d="M20 27.5 24 32l-4 4.5" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M48 32H40" stroke="#fff" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<path d="M44 27.5 40 32l4 4.5" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="32" cy="14" r="2.5" fill="#fff" fill-opacity="0.55"/>
|
||||
<circle cx="32" cy="50" r="2.5" fill="#fff" fill-opacity="0.55"/>
|
||||
<path d="M32 16.5v4M32 43.5v4" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-opacity="0.45"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,7 +0,0 @@
|
||||
import { defineCollection } from 'astro:content'
|
||||
import { docsLoader } from '@astrojs/starlight/loaders'
|
||||
import { docsSchema } from '@astrojs/starlight/schema'
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
title: 环境变量
|
||||
description: HubProxy 支持的环境变量说明。
|
||||
---
|
||||
|
||||
环境变量在加载 `config.toml` 之后覆盖对应配置项。未列出的配置段(如 `[registries]`、`[tokenCache]`、`[access].whiteList`)只能通过配置文件修改。
|
||||
|
||||
## 全部变量
|
||||
|
||||
| 环境变量 | 对应配置 | 说明 |
|
||||
|---------|---------|------|
|
||||
| `CONFIG_PATH` | — | 配置文件路径,默认 `./config.toml` |
|
||||
| `SERVER_HOST` | `[server].host` | 监听地址 |
|
||||
| `SERVER_PORT` | `[server].port` | 监听端口 |
|
||||
| `ENABLE_H2C` | `[server].enableH2C` | 启用 HTTP/2 Cleartext(`true`/`false`) |
|
||||
| `ENABLE_FRONTEND` | `[server].enableFrontend` | 启用 Web 界面(`true`/`false`) |
|
||||
| `MAX_FILE_SIZE` | `[server].fileSize` | 单文件大小上限(字节) |
|
||||
| `RATE_LIMIT` | `[rateLimit].requestLimit` | 每 IP 每周期请求数 |
|
||||
| `RATE_PERIOD_HOURS` | `[rateLimit].periodHours` | 限流周期(小时) |
|
||||
| `IP_WHITELIST` | `[security].whiteList` | 追加限流豁免 IP,逗号分隔 |
|
||||
| `IP_BLACKLIST` | `[security].blackList` | 追加封禁 IP,逗号分隔 |
|
||||
| `ACCESS_PROXY` | `[access].proxy` | 上游 SOCKS5 代理地址 |
|
||||
| `MAX_IMAGES` | `[download].maxImages` | 批量离线镜像数量上限 |
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
# systemd / 本地运行
|
||||
CONFIG_PATH=/etc/hubproxy/config.toml ./hubproxy
|
||||
|
||||
# Docker 快速调参
|
||||
docker run -d \
|
||||
-e SERVER_PORT=5000 \
|
||||
-e RATE_LIMIT=1000 \
|
||||
-e IP_WHITELIST=10.0.0.0/8,192.168.1.100 \
|
||||
-v ./config.toml:/app/config.toml:ro \
|
||||
ghcr.io/sky22333/hubproxy
|
||||
```
|
||||
|
||||
## 使用场景
|
||||
|
||||
| 场景 | 建议 |
|
||||
|------|------|
|
||||
| systemd 服务 | `CONFIG_PATH=/etc/hubproxy/config.toml`(服务文件已内置) |
|
||||
| Docker | 挂载 `/app/config.toml`,临时参数用 `-e` |
|
||||
| 开发调试 | `CONFIG_PATH=./src/config.toml go run .` |
|
||||
@@ -1,106 +0,0 @@
|
||||
---
|
||||
title: config.toml 参考
|
||||
description: HubProxy 配置文件完整参考。
|
||||
---
|
||||
|
||||
配置文件默认路径为工作目录下的 `config.toml`,可通过 `CONFIG_PATH` 环境变量覆盖。完整环境变量列表见 [环境变量](/configuration/environment/)。
|
||||
|
||||
## [server]
|
||||
|
||||
| 键 | 类型 | 默认值 | 说明 |
|
||||
|----|------|--------|------|
|
||||
| `host` | string | `0.0.0.0` | 监听地址 |
|
||||
| `port` | int | `5000` | 监听端口 |
|
||||
| `fileSize` | int | `2147483648` | 单文件大小上限(字节),仅影响 GitHub / Hugging Face URL 代理 |
|
||||
| `enableH2C` | bool | `false` | 启用 HTTP/2 Cleartext |
|
||||
| `enableFrontend` | bool | `true` | 启用 Web 界面(Vue SPA) |
|
||||
|
||||
## [rateLimit]
|
||||
|
||||
| 键 | 类型 | 默认值 | 说明 |
|
||||
|----|------|--------|------|
|
||||
| `requestLimit` | int | `500` | 每 IP 每周期允许请求数 |
|
||||
| `periodHours` | float | `3.0` | 限流周期(小时) |
|
||||
|
||||
IPv4 按完整 IP 计数,IPv6 按 `/64` 网段计数。仅前端静态路由(`/`、`/images`、`/search`、`/favicon.ico`、`/assets/*`)不计入限流;`/ready`、API 与代理请求均会计入。
|
||||
|
||||
:::note
|
||||
Docker 拉取一个镜像会请求多个 layer,每个 HTTP 请求均消耗限流配额。
|
||||
:::
|
||||
|
||||
## [security]
|
||||
|
||||
| 键 | 说明 |
|
||||
|----|------|
|
||||
| `whiteList` | 限流豁免 IP 列表(支持 CIDR,单 IP 自动补 `/32`) |
|
||||
| `blackList` | 直接拒绝访问的 IP 列表(403) |
|
||||
|
||||
:::note
|
||||
`[security]` 黑白名单仅影响限流与 IP 封禁,**不**控制可代理的仓库。
|
||||
:::
|
||||
|
||||
## [access]
|
||||
|
||||
| 键 | 说明 |
|
||||
|----|------|
|
||||
| `whiteList` | 允许代理的仓库/镜像(空=不限制) |
|
||||
| `blackList` | 禁止代理的仓库/镜像 |
|
||||
| `proxy` | 上游 SOCKS5 代理,如 `socks5://127.0.0.1:1080` 或 `socks5://user:pass@host:1080` |
|
||||
|
||||
仓库规则支持通配符,如 `owner/*`、`*/repo-name`。
|
||||
|
||||
## [download]
|
||||
|
||||
| 键 | 类型 | 默认值 | 说明 |
|
||||
|----|------|--------|------|
|
||||
| `maxImages` | int | `10` | 批量离线镜像数量上限 |
|
||||
|
||||
## [registries]
|
||||
|
||||
每个 Registry 条目:
|
||||
|
||||
| 键 | 说明 |
|
||||
|----|------|
|
||||
| `upstream` | 上游 Registry 地址 |
|
||||
| `authHost` | 认证端点(用于匹配 token 请求) |
|
||||
| `authType` | 认证类型标识(`anonymous`/`github`/`google`/`quay`) |
|
||||
| `enabled` | 是否启用 |
|
||||
|
||||
默认预置 `ghcr.io`、`gcr.io`、`quay.io`、`registry.k8s.io`。Docker Hub 固定走 `registry-1.docker.io`,不在此段配置。
|
||||
|
||||
:::note
|
||||
当前版本对所有 Registry 均使用匿名拉取(`authn.Anonymous`),`authType` 仅用于标识认证端点类型,**尚未**实现 GitHub Token 或 Google 服务账号等私有仓库凭据,且不转发客户端 `Authorization` 头。**无法**通过 HubProxy 拉取需要认证的私有镜像。
|
||||
:::
|
||||
|
||||
## [tokenCache]
|
||||
|
||||
| 键 | 类型 | 默认值 | 说明 |
|
||||
|----|------|--------|------|
|
||||
| `enabled` | bool | `true` | 启用 Token/Manifest 缓存 |
|
||||
| `defaultTTL` | string | `"20m"` | 普通 tag 的 Manifest 默认缓存时间 |
|
||||
|
||||
Manifest 缓存 TTL 规则:
|
||||
|
||||
| 条件 | TTL |
|
||||
|------|-----|
|
||||
| digest(`sha256:...`) | 24 小时 |
|
||||
| `latest` / `main` / `master` / `dev` / `develop` | 10 分钟 |
|
||||
| 其他 tag | `[tokenCache].defaultTTL` |
|
||||
|
||||
上游 token 响应中的 `expires_in` 会用于 token 缓存(预留 5 分钟安全余量,最短 5 分钟)。
|
||||
|
||||
## HTTP 端点
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `GET /ready` | 健康检查,返回 `ready`、`version`、`uptime_sec` 等(**计入** IP 限流) |
|
||||
| `GET /api/search?q=...` | Docker Hub 镜像搜索 |
|
||||
| `GET /api/tags/:namespace/:name` | 镜像标签列表 |
|
||||
| `GET /api/image/info?image=...` | 镜像元信息 |
|
||||
| `GET /api/image/download?mode=prepare` | 申请单镜像离线包 token |
|
||||
| `GET /api/image/download?token=...` | 下载单镜像 tar |
|
||||
| `POST /api/image/batch?mode=prepare` | 申请批量离线包 token |
|
||||
| `GET /api/image/batch?token=...` | 下载批量 tar |
|
||||
| `ANY /v2/*` | Docker Registry API v2 代理 |
|
||||
| `ANY /token*` | Docker 认证代理 |
|
||||
| 其他路径 | GitHub / Hugging Face 等 URL 代理 |
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
title: 推荐部署架构
|
||||
description: 公网部署 HubProxy 的推荐拓扑与安全要求。
|
||||
---
|
||||
|
||||
公网服务建议通过反向代理暴露 HubProxy,保障服务器安全。
|
||||
|
||||
## 推荐拓扑
|
||||
|
||||
```
|
||||
用户 → CDN(可选)→ Caddy/Nginx(私网/本机)→ HubProxy:5000
|
||||
```
|
||||
|
||||
| 组件 | 作用 |
|
||||
|------|------|
|
||||
| CDN | 可选,提供 TLS 与边缘加速 |
|
||||
| 反向代理 | 终止 TLS、写入真实客户端 IP、隐藏后端端口 |
|
||||
| HubProxy | 处理 Docker/GitHub 代理逻辑 |
|
||||
|
||||
## 反代部署时的要求
|
||||
|
||||
若采用上文推荐拓扑(用户 → 反代 → HubProxy),需满足:
|
||||
|
||||
1. **反代覆盖写** `X-Forwarded-For` / `X-Real-IP`(防止用户伪造 IP,勿 append 客户端自带头)
|
||||
2. **建议 5000 仅对反代可达**(绑定 `127.0.0.1` 或防火墙限制)
|
||||
3. 反代覆盖写 `X-Forwarded-Host`(防止 `.sh` 脚本 Host 注入)
|
||||
|
||||
## 各场景对照
|
||||
|
||||
| 部署方式 | 每用户独立限流 | IP 防伪造 | 推荐度 |
|
||||
|---------|---------------|----------|--------|
|
||||
| CDN → 私网反代 → HubProxy | ✅ | ✅ | ⭐⭐⭐ |
|
||||
| Docker 内 Caddy + HubProxy | ✅ | ✅ | ⭐⭐⭐ |
|
||||
| 本机 Nginx → HubProxy | ✅ | ✅ | ⭐⭐⭐ |
|
||||
| Cloudflare 直连 HubProxy | ❌ 共用 CDN IP | ✅ | ⚠️ |
|
||||
| 公网直连 `0.0.0.0:5000` | ✅ | ✅ | ⚠️ 容易被滥用 |
|
||||
|
||||
## 下一步
|
||||
|
||||
- [反向代理配置](/deployment/reverse-proxy/)
|
||||
- [Docker 部署](/deployment/docker/)
|
||||
- [安全概述](/security/overview/)
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
title: Docker 部署
|
||||
description: 使用 Docker 与 Docker Compose 部署 HubProxy。
|
||||
---
|
||||
|
||||
## docker run
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name hubproxy \
|
||||
-p 5000:5000 \
|
||||
--restart always \
|
||||
-v ./src/config.toml:/app/config.toml:ro \
|
||||
ghcr.io/sky22333/hubproxy
|
||||
```
|
||||
|
||||
## Docker Compose
|
||||
|
||||
项目根目录提供 `docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
默认挂载 `./src/config.toml` 到容器内 `/app/config.toml`。Compose 文件内已配置日志轮转(`200m × 3`),无需在 `docker run` 中重复设置。
|
||||
|
||||
:::caution
|
||||
生产环境建议不要将 `5000` 直接映射到公网。更安全的做法是让 Caddy/Nginx 容器访问 HubProxy 内网地址。
|
||||
:::
|
||||
|
||||
## 与反代配合
|
||||
|
||||
典型 Compose 结构:
|
||||
|
||||
```
|
||||
caddy: 对外 443,反代 hubproxy:5000
|
||||
hubproxy: 仅内网暴露 5000,不映射到宿主机公网
|
||||
```
|
||||
|
||||
## 健康检查
|
||||
|
||||
容器镜像未内置 `HEALTHCHECK`,可手动验证:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:5000/ready
|
||||
```
|
||||
|
||||
## Compose 日志(可选)
|
||||
|
||||
若使用 Compose,可在 `docker-compose.yml` 中配置:
|
||||
|
||||
| 参数 | 含义 |
|
||||
|------|------|
|
||||
| `max-size=200m` | 单个日志文件上限 |
|
||||
| `max-file=3` | 保留 3 个文件,总量约 600MB |
|
||||
|
||||
`docker run` 未指定时 Docker 默认约 20MB 日志上限。
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
title: 反向代理
|
||||
description: 配置 Caddy 与 Nginx 反向代理,正确传递客户端真实 IP。
|
||||
---
|
||||
|
||||
反向代理必须**覆盖写入**真实客户端 IP,HubProxy 才能按用户独立限流并防止 IP 伪造。
|
||||
|
||||
## Caddy
|
||||
|
||||
### 基础反代
|
||||
|
||||
```txt
|
||||
example.com {
|
||||
reverse_proxy 127.0.0.1:5000 {
|
||||
header_up X-Real-IP {remote}
|
||||
header_up X-Forwarded-For {remote}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up X-Forwarded-Host {host}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cloudflare CDN
|
||||
|
||||
```txt
|
||||
example.com {
|
||||
reverse_proxy 127.0.0.1:5000 {
|
||||
header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
|
||||
header_up X-Real-IP {http.request.header.CF-Connecting-IP}
|
||||
header_up X-Forwarded-Proto https
|
||||
header_up X-Forwarded-Host {host}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Nginx
|
||||
|
||||
:::caution
|
||||
避免使用 `proxy_add_x_forwarded_for`,它会 append 客户端自带的 `X-Forwarded-For`,可能导致 IP 解析错误。
|
||||
:::
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
### GitHub 加速提示「无效输入」
|
||||
|
||||
若使用 Nginx 反代后 GitHub 加速异常,请参考 [issue #62](https://github.com/sky22333/hubproxy/issues/62#issuecomment-3219572440) 检查 `proxy_set_header Host` 等配置。
|
||||
|
||||
## 工作原理
|
||||
|
||||
HubProxy 仅当 TCP 连接来自可信私网/本机网段时,才信任转发头:
|
||||
|
||||
- `127.0.0.0/8`
|
||||
- `10.0.0.0/8`
|
||||
- `172.16.0.0/12`
|
||||
- `192.168.0.0/16`
|
||||
|
||||
详见 [IP 信任机制](/security/ip-trust/)。
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
title: Environment Variables
|
||||
description: Environment variables supported by HubProxy.
|
||||
---
|
||||
|
||||
Environment variables override config after `config.toml` is loaded. Sections not listed here (e.g. `[registries]`, `[tokenCache]`, `[access].whiteList`) can only be changed in the config file.
|
||||
|
||||
## All Variables
|
||||
|
||||
| Variable | Config key | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `CONFIG_PATH` | — | Config file path, default `./config.toml` |
|
||||
| `SERVER_HOST` | `[server].host` | Listen address |
|
||||
| `SERVER_PORT` | `[server].port` | Listen port |
|
||||
| `ENABLE_H2C` | `[server].enableH2C` | Enable HTTP/2 Cleartext (`true`/`false`) |
|
||||
| `ENABLE_FRONTEND` | `[server].enableFrontend` | Enable web UI (`true`/`false`) |
|
||||
| `MAX_FILE_SIZE` | `[server].fileSize` | Max single-file size (bytes) |
|
||||
| `RATE_LIMIT` | `[rateLimit].requestLimit` | Requests per IP per period |
|
||||
| `RATE_PERIOD_HOURS` | `[rateLimit].periodHours` | Rate limit period (hours) |
|
||||
| `IP_WHITELIST` | `[security].whiteList` | Append rate-limit exempt IPs, comma-separated |
|
||||
| `IP_BLACKLIST` | `[security].blackList` | Append blocked IPs, comma-separated |
|
||||
| `ACCESS_PROXY` | `[access].proxy` | Upstream SOCKS5 proxy URL |
|
||||
| `MAX_IMAGES` | `[download].maxImages` | Max images per batch offline download |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# systemd / local
|
||||
CONFIG_PATH=/etc/hubproxy/config.toml ./hubproxy
|
||||
|
||||
# Docker quick overrides
|
||||
docker run -d \
|
||||
-e SERVER_PORT=5000 \
|
||||
-e RATE_LIMIT=1000 \
|
||||
-e IP_WHITELIST=10.0.0.0/8,192.168.1.100 \
|
||||
-v ./config.toml:/app/config.toml:ro \
|
||||
ghcr.io/sky22333/hubproxy
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
| Scenario | Recommendation |
|
||||
|----------|----------------|
|
||||
| systemd service | `CONFIG_PATH=/etc/hubproxy/config.toml` (built into service file) |
|
||||
| Docker | Mount `/app/config.toml`, use `-e` for quick overrides |
|
||||
| Local development | `CONFIG_PATH=./src/config.toml go run .` |
|
||||
@@ -1,106 +0,0 @@
|
||||
---
|
||||
title: config.toml Reference
|
||||
description: Complete reference for the HubProxy configuration file.
|
||||
---
|
||||
|
||||
Default config path is `./config.toml` in the working directory. Override with `CONFIG_PATH`. See [Environment Variables](/en/configuration/environment/) for the full env var list.
|
||||
|
||||
## [server]
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `host` | string | `0.0.0.0` | Listen address |
|
||||
| `port` | int | `5000` | Listen port |
|
||||
| `fileSize` | int | `2147483648` | Max single-file size (bytes), GitHub / Hugging Face URL proxy only |
|
||||
| `enableH2C` | bool | `false` | Enable HTTP/2 Cleartext |
|
||||
| `enableFrontend` | bool | `true` | Enable web UI (Vue SPA) |
|
||||
|
||||
## [rateLimit]
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `requestLimit` | int | `500` | Requests per IP per period |
|
||||
| `periodHours` | float | `3.0` | Rate limit period (hours) |
|
||||
|
||||
IPv4 uses full addresses; IPv6 uses `/64` prefixes. Only frontend static routes (`/`, `/images`, `/search`, `/favicon.ico`, `/assets/*`) are exempt; `/ready`, API, and proxy requests all count.
|
||||
|
||||
:::note
|
||||
Pulling one Docker image triggers multiple layer requests — each HTTP request counts against the limit.
|
||||
:::
|
||||
|
||||
## [security]
|
||||
|
||||
| Key | Description |
|
||||
|-----|-------------|
|
||||
| `whiteList` | Rate-limit exempt IPs (CIDR supported, single IPs auto-get `/32`) |
|
||||
| `blackList` | Blocked IPs (403) |
|
||||
|
||||
:::note
|
||||
`[security]` lists affect rate limiting only — they do **not** control which registries can be proxied.
|
||||
:::
|
||||
|
||||
## [access]
|
||||
|
||||
| Key | Description |
|
||||
|-----|-------------|
|
||||
| `whiteList` | Allowed repos/images (empty = no restriction) |
|
||||
| `blackList` | Blocked repos/images |
|
||||
| `proxy` | Upstream SOCKS5 proxy, e.g. `socks5://127.0.0.1:1080` or `socks5://user:pass@host:1080` |
|
||||
|
||||
Wildcards supported: `owner/*`, `*/repo-name`.
|
||||
|
||||
## [download]
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `maxImages` | int | `10` | Max images per batch offline download |
|
||||
|
||||
## [registries]
|
||||
|
||||
Per-registry keys:
|
||||
|
||||
| Key | Description |
|
||||
|-----|-------------|
|
||||
| `upstream` | Upstream registry host |
|
||||
| `authHost` | Auth endpoint (for token request matching) |
|
||||
| `authType` | Auth type label (`anonymous` / `github` / `google` / `quay`) |
|
||||
| `enabled` | Enable or disable |
|
||||
|
||||
Defaults include `ghcr.io`, `gcr.io`, `quay.io`, `registry.k8s.io`. Docker Hub always proxies to `registry-1.docker.io` and is not configured here.
|
||||
|
||||
:::note
|
||||
All registries currently use anonymous pulls (`authn.Anonymous`). `authType` labels the auth endpoint only — **GitHub tokens and Google service accounts are not yet implemented**, and client `Authorization` headers are not forwarded. **Private authenticated images cannot be pulled through HubProxy.**
|
||||
:::
|
||||
|
||||
## [tokenCache]
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `enabled` | bool | `true` | Enable token/manifest cache |
|
||||
| `defaultTTL` | string | `"20m"` | Default manifest cache TTL for ordinary tags |
|
||||
|
||||
Manifest cache TTL rules:
|
||||
|
||||
| Condition | TTL |
|
||||
|-----------|-----|
|
||||
| Digest (`sha256:...`) | 24 hours |
|
||||
| `latest` / `main` / `master` / `dev` / `develop` | 10 minutes |
|
||||
| Other tags | `[tokenCache].defaultTTL` |
|
||||
|
||||
Upstream token `expires_in` is used for token cache (5-minute safety margin, minimum 5 minutes).
|
||||
|
||||
## HTTP Endpoints
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `GET /ready` | Health check — returns `ready`, `version`, `uptime_sec`, etc. (**counts toward** rate limit) |
|
||||
| `GET /api/search?q=...` | Docker Hub image search |
|
||||
| `GET /api/tags/:namespace/:name` | Image tag list |
|
||||
| `GET /api/image/info?image=...` | Image metadata |
|
||||
| `GET /api/image/download?mode=prepare` | Request single-image offline token |
|
||||
| `GET /api/image/download?token=...` | Download single-image tar |
|
||||
| `POST /api/image/batch?mode=prepare` | Request batch offline token |
|
||||
| `GET /api/image/batch?token=...` | Download batch tar |
|
||||
| `ANY /v2/*` | Docker Registry API v2 proxy |
|
||||
| `ANY /token*` | Docker auth proxy |
|
||||
| Other paths | GitHub / Hugging Face URL proxy |
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
title: Recommended Architecture
|
||||
description: Recommended topology and security requirements for public HubProxy deployments.
|
||||
---
|
||||
|
||||
For public deployments, a reverse proxy in front of HubProxy is recommended to keep the server secure.
|
||||
|
||||
## Recommended Topology
|
||||
|
||||
```
|
||||
Users → CDN (optional) → Caddy/Nginx (private/local) → HubProxy:5000
|
||||
```
|
||||
|
||||
| Component | Role |
|
||||
|-----------|------|
|
||||
| CDN | Optional TLS termination and edge caching |
|
||||
| Reverse proxy | TLS, real client IP injection, hide backend port |
|
||||
| HubProxy | Docker/GitHub proxy logic |
|
||||
|
||||
## Requirements When Using a Reverse Proxy
|
||||
|
||||
If you follow the recommended topology above (users → proxy → HubProxy):
|
||||
|
||||
1. Reverse proxy must **overwrite** `X-Forwarded-For` / `X-Real-IP` (prevent client IP spoofing — never append client-supplied headers)
|
||||
2. **Preferably make port 5000 reachable only by the proxy** (bind `127.0.0.1` or firewall)
|
||||
3. Overwrite `X-Forwarded-Host` to prevent Host injection in `.sh` scripts
|
||||
|
||||
## Scenario Comparison
|
||||
|
||||
| Deployment | Per-user rate limit | IP spoof protection | Rating |
|
||||
|------------|--------------------|--------------------|--------|
|
||||
| CDN → private proxy → HubProxy | ✅ | ✅ | ⭐⭐⭐ |
|
||||
| Docker Caddy + HubProxy | ✅ | ✅ | ⭐⭐⭐ |
|
||||
| Local Nginx → HubProxy | ✅ | ✅ | ⭐⭐⭐ |
|
||||
| Cloudflare direct to HubProxy | ❌ shared CDN IP | ✅ | ⚠️ |
|
||||
| Public `0.0.0.0:5000` | ✅ | ✅ | ⚠️ easily abused |
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Reverse Proxy](/en/deployment/reverse-proxy/)
|
||||
- [Docker Deployment](/en/deployment/docker/)
|
||||
- [Security Overview](/en/security/overview/)
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
title: Docker Deployment
|
||||
description: Deploy HubProxy with Docker and Docker Compose.
|
||||
---
|
||||
|
||||
## docker run
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name hubproxy \
|
||||
-p 5000:5000 \
|
||||
--restart always \
|
||||
-v ./src/config.toml:/app/config.toml:ro \
|
||||
ghcr.io/sky22333/hubproxy
|
||||
```
|
||||
|
||||
## Docker Compose
|
||||
|
||||
The repository includes `docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Mounts `./src/config.toml` to `/app/config.toml`. Log rotation (`200m × 3`) is preconfigured in Compose — no need to duplicate in `docker run`.
|
||||
|
||||
:::caution
|
||||
In production, avoid mapping port 5000 directly to the public internet. Let a Caddy/Nginx container reach HubProxy on the internal network instead.
|
||||
:::
|
||||
|
||||
## With Reverse Proxy
|
||||
|
||||
Typical Compose layout:
|
||||
|
||||
```
|
||||
caddy: exposes 443, proxies to hubproxy:5000
|
||||
hubproxy: internal 5000 only, not mapped to host public interface
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
The image has no built-in `HEALTHCHECK`. Verify manually:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:5000/ready
|
||||
```
|
||||
|
||||
## Compose Logging (Optional)
|
||||
|
||||
When using Compose, `docker-compose.yml` configures:
|
||||
|
||||
| Option | Meaning |
|
||||
|--------|---------|
|
||||
| `max-size=200m` | Max size per log file |
|
||||
| `max-file=3` | Keep 3 files (~600MB total) |
|
||||
|
||||
Without options, `docker run` defaults to ~20MB per log file.
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
title: Reverse Proxy
|
||||
description: Configure Caddy and Nginx to pass the real client IP correctly.
|
||||
---
|
||||
|
||||
The reverse proxy must **overwrite** the real client IP so HubProxy can rate-limit per user and resist IP spoofing.
|
||||
|
||||
## Caddy
|
||||
|
||||
### Basic
|
||||
|
||||
```txt
|
||||
example.com {
|
||||
reverse_proxy 127.0.0.1:5000 {
|
||||
header_up X-Real-IP {remote}
|
||||
header_up X-Forwarded-For {remote}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up X-Forwarded-Host {host}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Behind Cloudflare
|
||||
|
||||
```txt
|
||||
example.com {
|
||||
reverse_proxy 127.0.0.1:5000 {
|
||||
header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
|
||||
header_up X-Real-IP {http.request.header.CF-Connecting-IP}
|
||||
header_up X-Forwarded-Proto https
|
||||
header_up X-Forwarded-Host {host}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Nginx
|
||||
|
||||
:::caution
|
||||
Avoid `proxy_add_x_forwarded_for` — it appends client-supplied `X-Forwarded-For` and can break IP resolution.
|
||||
:::
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
### GitHub acceleration shows "invalid input"
|
||||
|
||||
If GitHub acceleration fails behind Nginx, check `proxy_set_header Host` per [issue #62](https://github.com/sky22333/hubproxy/issues/62#issuecomment-3219572440).
|
||||
|
||||
## How It Works
|
||||
|
||||
HubProxy only trusts forwarding headers when the TCP connection comes from trusted private/local ranges:
|
||||
|
||||
- `127.0.0.0/8`
|
||||
- `10.0.0.0/8`
|
||||
- `172.16.0.0/12`
|
||||
- `192.168.0.0/16`
|
||||
|
||||
See [IP Trust](/en/security/ip-trust/) for details.
|
||||
@@ -1,92 +0,0 @@
|
||||
---
|
||||
title: FAQ
|
||||
description: Frequently asked questions about deploying and using HubProxy.
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>How is HubProxy different from free CDN proxies?</summary>
|
||||
|
||||
HubProxy is fully self-hosted — you control data and bandwidth without relying on unstable third-party CDNs. Customize rate limits, registry allowlists, and upstream SOCKS5 proxies.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Is a reverse proxy required for public deployment?</summary>
|
||||
|
||||
Strongly recommended. It provides TLS, correct client IP passing, and avoids exposing port 5000. See [Recommended Architecture](/en/deployment/architecture/).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>What's the difference between rate-limit and registry whitelists?</summary>
|
||||
|
||||
`[security].whiteList` exempts IPs from rate limiting. `[access].whiteList` controls which Docker images, GitHub repos, and Hugging Face resources can be proxied. They are independent.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>How do I format GitHub acceleration URLs?</summary>
|
||||
|
||||
Prefix the **full original URL** with your HubProxy host, e.g. `https://example.com/https://github.com/owner/repo/releases/download/...`. Path-prefix rewrites like `/github/owner/...` are not supported. See [GitHub Acceleration](/en/guides/github-proxy/).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>GitHub acceleration shows 'invalid input'?</summary>
|
||||
|
||||
Common causes: wrong URL format (using `/github/` prefix instead of full URL), or incorrect Nginx `Host` header. See [GitHub Acceleration](/en/guides/github-proxy/) and [issue #62](https://github.com/sky22333/hubproxy/issues/62#issuecomment-3219572440).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>How does the offline image API work?</summary>
|
||||
|
||||
Two steps: `GET /api/image/download?image=...&mode=prepare` for a token, then download with that token. There is no `/v2/offline/...` path. See [Offline Images](/en/guides/offline-images/).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>All users share one IP behind Cloudflare?</summary>
|
||||
|
||||
If HubProxy connects directly to Cloudflare edges, rate limits apply per Cloudflare IP, not per user. Add a private reverse proxy that writes `CF-Connecting-IP` to forwarding headers.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>How do I verify the service is running?</summary>
|
||||
|
||||
Visit `https://example.com/ready` — a healthy response includes `ready`, `service`, `version`, and `uptime_sec`. This path **counts toward** the IP rate limit; avoid high-frequency polling.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Docker logs consuming too much disk?</summary>
|
||||
|
||||
`docker run` defaults to ~20MB logs. [Docker Compose](/en/deployment/docker/) preconfigures `200m × 3` rotation; adjust `logging.options` in compose as needed.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Does HubProxy support resume or multi-part downloads?</summary>
|
||||
|
||||
**GitHub / Hugging Face file downloads: yes.** The proxy forwards `Range` headers and passes through upstream 206 responses — `wget -c` and multi-threaded Range downloads work (except `.sh` / `.ps1` script rewriting).
|
||||
|
||||
**Docker pulls: no in-layer Range.** Blobs are streamed whole; clients retry failed layers or pull layers in parallel.
|
||||
|
||||
**Offline tar API: no** resume. See [Transfer Capabilities](/en/guides/capabilities/).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>How do I add a new acceleration domain?</summary>
|
||||
|
||||
Add a regex to `githubExps` in `src/handlers/github.go`. See [Development & Build](/en/guides/development/).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Why can't I pull private images with authType github?</summary>
|
||||
|
||||
`authType` labels auth endpoints only — credential injection is not yet implemented. All registry pulls use anonymous auth and client `Authorization` headers are not forwarded. **Private authenticated images cannot be pulled through HubProxy.**
|
||||
|
||||
</details>
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
title: System Installation
|
||||
description: Deploy HubProxy with deb, rpm, or apk packages and manage the service.
|
||||
---
|
||||
|
||||
## Install Script
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh | sh
|
||||
```
|
||||
|
||||
Supports `amd64` / `arm64` with `apt`, `dnf`, `apk`, and other package managers.
|
||||
|
||||
## File Paths
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/etc/hubproxy/config.toml` | Configuration file |
|
||||
| `/usr/bin/hubproxy` | Binary |
|
||||
| `/lib/systemd/system/hubproxy.service` | systemd unit (Debian/Ubuntu/RHEL) |
|
||||
| `/etc/init.d/hubproxy` | OpenRC service (Alpine) |
|
||||
| `/var/log/hubproxy.log` | Alpine log file |
|
||||
|
||||
## systemd
|
||||
|
||||
```bash
|
||||
sudo systemctl status hubproxy # Check service status
|
||||
sudo systemctl restart hubproxy # Restart after config changes
|
||||
sudo journalctl -u hubproxy -f # Follow logs
|
||||
sudo nano /etc/hubproxy/config.toml # Edit config
|
||||
```
|
||||
|
||||
Uninstall:
|
||||
|
||||
```bash
|
||||
sudo apt purge hubproxy # Debian/Ubuntu — remove package and config
|
||||
```
|
||||
|
||||
## OpenRC (Alpine)
|
||||
|
||||
```bash
|
||||
sudo rc-service hubproxy status # Check status
|
||||
sudo rc-service hubproxy restart # Restart service
|
||||
sudo tail -f /var/log/hubproxy.log # Follow logs
|
||||
sudo vi /etc/hubproxy/config.toml # Edit config
|
||||
```
|
||||
|
||||
Uninstall:
|
||||
|
||||
```bash
|
||||
sudo apk del hubproxy # Remove package
|
||||
```
|
||||
|
||||
## Production recommendation
|
||||
|
||||
For public or team use, point a **custom domain** at HubProxy through Caddy / Nginx with **HTTPS enabled**, instead of exposing `http://IP:5000` long term. This provides TLS, correct client IP forwarding, and safer access. See [Reverse Proxy](/en/deployment/reverse-proxy/) and [Recommended Architecture](/en/deployment/architecture/).
|
||||
@@ -1,60 +0,0 @@
|
||||
---
|
||||
title: Quick Start
|
||||
description: Run HubProxy in minutes with Docker or the install script.
|
||||
---
|
||||
|
||||
HubProxy supports Docker images and native package installation.
|
||||
|
||||
## Docker (Recommended)
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name hubproxy \
|
||||
-p 5000:5000 \
|
||||
--restart always \
|
||||
-v /path/to/config.toml:/app/config.toml:ro \
|
||||
ghcr.io/sky22333/hubproxy
|
||||
```
|
||||
|
||||
:::tip
|
||||
[Docker Compose](/en/deployment/docker/) makes config mounting easier; log rotation is preconfigured in Compose.
|
||||
:::
|
||||
|
||||
## Install Script
|
||||
|
||||
Auto-detects OS and architecture, downloads `.deb`, `.rpm`, or `.apk` from GitHub Releases:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh | sh
|
||||
```
|
||||
|
||||
The service starts automatically. Config is at `/etc/hubproxy/config.toml`.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:5000/ready # Local health check
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ready": true,
|
||||
"service": "hubproxy",
|
||||
"version": "v1.x.x",
|
||||
"start_time_unix": 1710000000,
|
||||
"uptime_sec": 42.5,
|
||||
"uptime_human": "42s"
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [System Installation](/en/getting-started/install/)
|
||||
- [Docker Compose](/en/deployment/docker/)
|
||||
- [Docker Mirror Guide](/en/guides/docker-mirror/)
|
||||
|
||||
## Production recommendation
|
||||
|
||||
After verification, set up a **custom domain + reverse proxy + HTTPS** (Caddy / Nginx) so clients use `https://example.com` instead of bare `http://IP:5000`. See [Recommended Architecture](/en/deployment/architecture/) and [Reverse Proxy](/en/deployment/reverse-proxy/).
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
title: Transfer Capabilities
|
||||
description: Transfer behavior for Docker pulls and GitHub downloads in HubProxy.
|
||||
---
|
||||
|
||||
Based on current code in `src/handlers/docker.go` and `src/handlers/github.go`.
|
||||
|
||||
## Docker Image Pulls
|
||||
|
||||
HubProxy fetches manifests and blobs via go-containerregistry, then returns them over HTTP.
|
||||
|
||||
| Capability | Supported | Notes |
|
||||
|------------|-----------|-------|
|
||||
| Manifest cache | ✅ | GET cached; digest 24h, `latest` etc. 10m |
|
||||
| Token cache | ✅ | Upstream `expires_in` driven |
|
||||
| Multi-registry paths | ✅ | e.g. `example.com/ghcr.io/owner/image:tag` |
|
||||
| containerd `ns` param | ✅ | Matches `[registries]` entries |
|
||||
| Auth realm rewrite | ✅ | Upstream token → HubProxy `/token` |
|
||||
| HTTP Range / in-layer resume | ❌ | Blobs always fetched fully; client `Range` ignored |
|
||||
| Layer-level retry | ✅ | Docker/containerd retries failed layers |
|
||||
|
||||
:::note
|
||||
Each layer costs at least one blob request against rate limits. No Range inside a layer, but clients may pull **different layers** in parallel.
|
||||
:::
|
||||
|
||||
## GitHub / Hugging Face Downloads
|
||||
|
||||
The GitHub proxy (`proxyGitHubWithRedirect`) forwards **all client request headers** (including `Range`) upstream and passes through **status codes and response headers** (`Content-Range`, `Accept-Ranges`, etc.) before streaming the body.
|
||||
|
||||
| Capability | Supported | Notes |
|
||||
|------------|-----------|-------|
|
||||
| Streaming | ✅ | Direct `io.Copy` for normal files |
|
||||
| HTTP Range / resume | ✅ | `wget -c`, `curl -C -`; 206 responses passed through |
|
||||
| Multi-connection Range | ✅ | aria2-style parallel chunks (if upstream supports Range) |
|
||||
| Redirects | ✅ | Max 20; GitHub Location URLs rewritten |
|
||||
| HTML block | ✅ | `text/html` etc. → 403 |
|
||||
| File size limit | ✅ | `[server].fileSize`, default 2GB |
|
||||
| `.sh` / `.ps1` rewrite | ⚠️ | Full download + rewrite + chunked output; **no** Range resume |
|
||||
|
||||
### wget resume example
|
||||
|
||||
```bash
|
||||
wget -c "https://example.com/https://github.com/owner/repo/releases/download/v1.0.0/large.bin"
|
||||
```
|
||||
|
||||
See `src/handlers/github.go` lines 113–118 (header forward) and 209–231 (response pass-through).
|
||||
|
||||
## Offline image tar
|
||||
|
||||
| Capability | Supported |
|
||||
|------------|-----------|
|
||||
| Streaming tar | ✅ |
|
||||
| One-time token (2 min) | ✅ |
|
||||
| Range / resume | ❌ |
|
||||
| `Cache-Control: no-store` | ✅ |
|
||||
|
||||
## Related
|
||||
|
||||
- [Docker Mirror](/en/guides/docker-mirror/)
|
||||
- [GitHub Acceleration](/en/guides/github-proxy/)
|
||||
- [Offline Images](/en/guides/offline-images/)
|
||||
@@ -1,80 +0,0 @@
|
||||
---
|
||||
title: Development & Build
|
||||
description: Extend acceleration URLs, registry mappings, and build from source.
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
hubproxy/
|
||||
├── src/ # Go backend
|
||||
├── web/ # Vue SPA
|
||||
├── Dockerfile
|
||||
└── docs/
|
||||
```
|
||||
|
||||
## Add acceleration URLs
|
||||
|
||||
Edit **`githubExps`** in `src/handlers/github.go`:
|
||||
|
||||
```go
|
||||
var githubExps = []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:releases|archive)/.*`),
|
||||
// add new patterns here
|
||||
}
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
1. Include `([^/]+)/([^/]+)` capture groups for `[access]` owner/repo matching
|
||||
2. Run `go test ./handlers/...`
|
||||
3. Routes use `NoRoute(GitHubProxyHandler)` in `main.go` — no new route needed
|
||||
|
||||
Access control: `src/utils/access_control.go` → `CheckGitHubAccess`.
|
||||
|
||||
## Add Docker Registry
|
||||
|
||||
`config.toml` → `[registries]`. Auth hook: `createUpstreamOptions()` in `src/handlers/docker.go`.
|
||||
|
||||
## Add HTTP routes
|
||||
|
||||
Register in `buildRouter()` in `src/main.go`.
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
cd src && CONFIG_PATH=./config.toml go run .
|
||||
cd web && npm ci && npm run dev
|
||||
```
|
||||
|
||||
Production embed:
|
||||
|
||||
```bash
|
||||
cd web && npm ci && npm run build
|
||||
# copy dist to src/dist
|
||||
cd ../src && go build -o hubproxy .
|
||||
```
|
||||
|
||||
## Docker build
|
||||
|
||||
```bash
|
||||
docker build -t hubproxy:local --build-arg VERSION=1.0.0 .
|
||||
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t ghcr.io/your-org/hubproxy:latest \
|
||||
--build-arg VERSION=1.0.0 --push .
|
||||
```
|
||||
|
||||
| Stage | Image | Role |
|
||||
|-------|-------|------|
|
||||
| frontend | node:24-alpine | Vue build |
|
||||
| builder | golang:1.26-alpine | Go + UPX |
|
||||
| final | alpine | Runtime |
|
||||
|
||||
## Test & release
|
||||
|
||||
```bash
|
||||
cd src && go test ./...
|
||||
```
|
||||
|
||||
CI: `.github/workflows/docker-ghcr.yml`, `release.yml`.
|
||||
@@ -1,80 +0,0 @@
|
||||
---
|
||||
title: Registry Sources
|
||||
description: Configure upstream registry mappings in HubProxy.
|
||||
---
|
||||
|
||||
HubProxy uses the `[registries]` section for third-party registry upstreams and auth endpoints. Docker Hub always proxies to `registry-1.docker.io` and is not configured here.
|
||||
|
||||
## Default Mappings
|
||||
|
||||
`config.toml` includes common registries:
|
||||
|
||||
```toml
|
||||
[registries."ghcr.io"]
|
||||
upstream = "ghcr.io"
|
||||
authHost = "ghcr.io/token"
|
||||
authType = "github"
|
||||
enabled = true
|
||||
|
||||
[registries."gcr.io"]
|
||||
upstream = "gcr.io"
|
||||
authHost = "gcr.io/v2/token"
|
||||
authType = "google"
|
||||
enabled = true
|
||||
|
||||
[registries."quay.io"]
|
||||
upstream = "quay.io"
|
||||
authHost = "quay.io/v2/auth"
|
||||
authType = "quay"
|
||||
enabled = true
|
||||
|
||||
[registries."registry.k8s.io"]
|
||||
upstream = "registry.k8s.io"
|
||||
authHost = "registry.k8s.io"
|
||||
authType = "anonymous"
|
||||
enabled = true
|
||||
```
|
||||
|
||||
## authType Values
|
||||
|
||||
| authType | Meaning |
|
||||
|----------|---------|
|
||||
| `anonymous` | Anonymous registry (e.g. registry.k8s.io) |
|
||||
| `github` | GHCR auth endpoint |
|
||||
| `google` | GCR auth endpoint |
|
||||
| `quay` | Quay.io auth endpoint |
|
||||
|
||||
:::note
|
||||
`authType` matches upstream token auth endpoints only. The current version does **not** inject GitHub tokens or Google service accounts — all pulls use anonymous auth and client credentials are not forwarded. **Private authenticated images cannot be pulled.**
|
||||
:::
|
||||
|
||||
## Pull Path Format
|
||||
|
||||
```bash
|
||||
# Docker Hub (no registry prefix; library and latest added automatically for official images)
|
||||
docker pull example.com/nginx
|
||||
|
||||
# Third-party registry
|
||||
docker pull example.com/ghcr.io/owner/image:tag
|
||||
```
|
||||
|
||||
Also supports containerd `ns` query parameter for configured registries.
|
||||
|
||||
## Enable/Disable
|
||||
|
||||
```toml
|
||||
[registries."quay.io"]
|
||||
enabled = false
|
||||
```
|
||||
|
||||
## Token and Manifest Cache
|
||||
|
||||
`[tokenCache]` controls upstream auth token and manifest caching:
|
||||
|
||||
```toml
|
||||
[tokenCache]
|
||||
enabled = true
|
||||
defaultTTL = "20m"
|
||||
```
|
||||
|
||||
Digest refs cache for 24 hours; common tags like `latest` cache for 10 minutes. See [config.toml Reference](/en/configuration/reference/#tokencache).
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
title: Docker Mirror
|
||||
description: Configure Docker clients to pull images through HubProxy.
|
||||
---
|
||||
|
||||
HubProxy implements Docker Registry API v2 and can accelerate Docker Hub and multiple third-party registries.
|
||||
|
||||
## Configure Docker Client
|
||||
|
||||
Edit `/etc/docker/daemon.json` (or Docker Desktop settings on Windows):
|
||||
|
||||
```json
|
||||
{
|
||||
"registry-mirrors": ["https://example.com"]
|
||||
}
|
||||
```
|
||||
|
||||
Restart Docker:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
## Supported Registries
|
||||
|
||||
| Registry | Notes |
|
||||
|----------|-------|
|
||||
| Docker Hub | Default `registry-1.docker.io`, no path prefix needed |
|
||||
| ghcr.io | GitHub Container Registry |
|
||||
| gcr.io | Google Container Registry |
|
||||
| quay.io | Red Hat Quay |
|
||||
| registry.k8s.io | Kubernetes official images |
|
||||
|
||||
Extend or disable in `[registries]` — see [Registry Sources](/en/guides/docker-mirror-sources/).
|
||||
|
||||
## Pull Examples
|
||||
|
||||
```bash
|
||||
# Docker Hub official image (no library prefix needed)
|
||||
docker pull example.com/nginx
|
||||
|
||||
# Docker Hub user image
|
||||
docker pull example.com/user/app:tag
|
||||
|
||||
# Third-party registry (path includes registry domain)
|
||||
docker pull example.com/ghcr.io/owner/image:tag
|
||||
```
|
||||
|
||||
The `library/` namespace is added automatically for official images, and `latest` is used when no tag is given — `example.com/nginx` is equivalent to `example.com/library/nginx:latest`.
|
||||
|
||||
## Image Search
|
||||
|
||||
The web UI (`enableFrontend = true`) provides Docker Hub search and tag browsing. API access:
|
||||
|
||||
```bash
|
||||
curl "https://example.com/api/search?q=nginx"
|
||||
curl "https://example.com/api/tags/library/nginx"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Each layer counts against rate limits
|
||||
- Configure `[access].whiteList` for public deployments
|
||||
- Ensure correct client IP behind reverse proxy — see [Reverse Proxy](/en/deployment/reverse-proxy/)
|
||||
- Anonymous upstream pulls — **private authenticated images cannot be pulled through HubProxy**
|
||||
|
||||
## Other environments
|
||||
|
||||
- [Kubernetes & containerd](/en/guides/kubernetes-containerd/)
|
||||
- [Synology / Feiniu NAS](/en/guides/nas/)
|
||||
- [Transfer capabilities](/en/guides/capabilities/)
|
||||
@@ -1,98 +0,0 @@
|
||||
---
|
||||
title: GitHub Acceleration
|
||||
description: Accelerate GitHub Release, Raw, Clone, API, and Hugging Face downloads with HubProxy.
|
||||
---
|
||||
|
||||
Requests that do not match `/v2`, `/token`, `/api`, or other registered routes are handled by the GitHub proxy. Prefix the **full original URL** with your HubProxy host; if the URL matches a supported pattern, the handler reconstructs it and proxies to GitHub, Hugging Face, or other upstreams — otherwise it returns "invalid input".
|
||||
|
||||
## How to Accelerate
|
||||
|
||||
Prefix your original URL with the HubProxy host. Two formats are supported.
|
||||
|
||||
### Full URL (Recommended)
|
||||
|
||||
```bash
|
||||
# Release download
|
||||
curl -L -O "https://example.com/https://github.com/owner/repo/releases/download/v1.0.0/app.tar.gz"
|
||||
|
||||
# Git clone
|
||||
git clone https://example.com/https://github.com/owner/repo.git
|
||||
|
||||
# API
|
||||
curl "https://example.com/https://api.github.com/repos/owner/repo/releases/latest"
|
||||
```
|
||||
|
||||
### Global Git acceleration
|
||||
|
||||
Use `url.<base>.insteadOf` so every `https://github.com/` clone/fetch goes through HubProxy automatically:
|
||||
|
||||
```bash
|
||||
git config --global url."https://example.com/https://github.com/".insteadOf "https://github.com/"
|
||||
```
|
||||
|
||||
Then clone as usual:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/owner/repo.git
|
||||
# actually requests https://example.com/https://github.com/owner/repo.git
|
||||
```
|
||||
|
||||
To remove:
|
||||
|
||||
```bash
|
||||
git config --global --unset url.https://example.com/https://github.com/.insteadOf
|
||||
```
|
||||
|
||||
### Omit `https://`
|
||||
|
||||
HubProxy auto-prepends the scheme:
|
||||
|
||||
```bash
|
||||
curl -L -O "https://example.com/github.com/owner/repo/releases/download/v1.0.0/app.tar.gz"
|
||||
```
|
||||
|
||||
:::caution
|
||||
**Not supported:** path-prefix rewrites like `https://example.com/gh/owner/repo/...` or `/raw/owner/...` — these return "invalid input".
|
||||
:::
|
||||
|
||||
## Supported URL Types
|
||||
|
||||
| Type | Path pattern |
|
||||
|------|-------------|
|
||||
| Release / Archive | `github.com/{owner}/{repo}/releases/...`, `/archive/...` |
|
||||
| Raw / Blob | `github.com/{owner}/{repo}/raw/...` (`/blob/` auto-converted to `/raw/`) |
|
||||
| Git protocol | `github.com/{owner}/{repo}/info/...`, `/git-...` |
|
||||
| Raw domains | `raw.githubusercontent.com/...`, `raw.github.com/...` |
|
||||
| API | `api.github.com/repos/{owner}/{repo}/...` |
|
||||
| Gist | `gist.github.com/...`, `gist.githubusercontent.com/...` |
|
||||
| Hugging Face | `huggingface.co/...`, `cdn-lfs.hf.co/...` |
|
||||
| GitHub Assets | `github.githubassets.com/...`, `opengraph.githubassets.com/...` |
|
||||
|
||||
## Script Nesting
|
||||
|
||||
HubProxy can rewrite GitHub URLs inside `.sh` / `.ps1` install scripts (max 10MB). Behind a reverse proxy, set `X-Forwarded-Host` correctly — see [Reverse Proxy](/en/deployment/reverse-proxy/).
|
||||
|
||||
## Limits and Blocks
|
||||
|
||||
| Limit | Description |
|
||||
|-------|-------------|
|
||||
| Web content | `text/html`, `application/xml`, etc. are blocked (403) — not for browsing GitHub pages |
|
||||
| File size | `[server].fileSize` caps single-file downloads (default 2GB) |
|
||||
| Repo access | `[access].whiteList` / `blackList` restrict proxied GitHub repos and Hugging Face resources |
|
||||
|
||||
Large Release files support `wget -c` resume — see [Transfer Capabilities](/en/guides/capabilities/).
|
||||
|
||||
```toml
|
||||
[server]
|
||||
fileSize = 2147483648
|
||||
|
||||
[access]
|
||||
whiteList = ["trusted-org/*"]
|
||||
blackList = ["*/malicious-repo"]
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [Hugging Face Acceleration](/en/guides/huggingface/)
|
||||
- [Transfer capabilities](/en/guides/capabilities/)
|
||||
- [Development: add new URLs](/en/guides/development/)
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
title: Hugging Face Acceleration
|
||||
description: Accelerate Hugging Face model and LFS downloads through HubProxy.
|
||||
---
|
||||
|
||||
Hugging Face uses the same URL-prefix proxy as GitHub — prepend your HubProxy host to the **full original URL**.
|
||||
|
||||
## Supported URLs
|
||||
|
||||
| Type | Example |
|
||||
|------|---------|
|
||||
| Model / dataset files | `huggingface.co/{user}/{repo}/resolve/...` |
|
||||
| Spaces | `huggingface.co/spaces/{user}/{repo}/...` |
|
||||
| LFS CDN | `cdn-lfs.hf.co/{user}/{repo}/...` |
|
||||
|
||||
Regex patterns are in `githubExps` in `src/handlers/github.go`.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
curl -L -O "https://example.com/https://huggingface.co/bert-base-uncased/resolve/main/config.json"
|
||||
|
||||
curl -L -O "https://example.com/huggingface.co/bert-base-uncased/resolve/main/pytorch_model.bin"
|
||||
|
||||
curl -L -O "https://example.com/https://cdn-lfs.hf.co/user/model-repo/abc123..."
|
||||
```
|
||||
|
||||
## Limits
|
||||
|
||||
Same as [GitHub Acceleration](/en/guides/github-proxy/):
|
||||
|
||||
- No HTML browsing
|
||||
- `[server].fileSize` cap
|
||||
- Same as GitHub: **supports** `Range` resume and multi-connection downloads — see [Transfer Capabilities](/en/guides/capabilities/)
|
||||
- `[access]` allow/deny lists apply
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
title: Kubernetes & containerd
|
||||
description: Configure HubProxy as a registry mirror for K3s, RKE2, and containerd.
|
||||
---
|
||||
|
||||
Kubernetes nodes typically pull images via **containerd**, not Docker `daemon.json`.
|
||||
|
||||
## K3s
|
||||
|
||||
Create `/etc/rancher/k3s/registries.yaml` on each node:
|
||||
|
||||
```yaml
|
||||
mirrors:
|
||||
docker.io:
|
||||
endpoint:
|
||||
- "https://example.com"
|
||||
"ghcr.io":
|
||||
endpoint:
|
||||
- "https://example.com"
|
||||
"quay.io":
|
||||
endpoint:
|
||||
- "https://example.com"
|
||||
"registry.k8s.io":
|
||||
endpoint:
|
||||
- "https://example.com"
|
||||
```
|
||||
|
||||
Restart:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart k3s # server
|
||||
sudo systemctl restart k3s-agent # agent
|
||||
```
|
||||
|
||||
Verify with **crictl** (mirrors don't apply to `ctr`):
|
||||
|
||||
```bash
|
||||
sudo crictl pull docker.io/library/nginx:latest
|
||||
```
|
||||
|
||||
## RKE2
|
||||
|
||||
Use `/etc/rancher/rke2/registries.yaml`, same format. Restart `rke2-server` or `rke2-agent`.
|
||||
|
||||
## Native containerd (1.5+)
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/containerd/certs.d/docker.io
|
||||
```
|
||||
|
||||
`/etc/containerd/certs.d/docker.io/hosts.toml`:
|
||||
|
||||
```toml
|
||||
server = "https://registry-1.docker.io"
|
||||
|
||||
[host."https://example.com"]
|
||||
capabilities = ["pull", "resolve"]
|
||||
```
|
||||
|
||||
GHCR: `/etc/containerd/certs.d/ghcr.io/hosts.toml` with `server = "https://ghcr.io"`.
|
||||
|
||||
Enable in `/etc/containerd/config.toml`:
|
||||
|
||||
```toml
|
||||
[plugins."io.containerd.grpc.v1.cri".registry]
|
||||
config_path = "/etc/containerd/certs.d"
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl restart containerd
|
||||
```
|
||||
|
||||
## Path notes
|
||||
|
||||
- docker.io → `/v2/library/nginx/...`
|
||||
- ghcr.io → `/v2/ghcr.io/owner/image/...`
|
||||
- containerd `ns` query param supported
|
||||
|
||||
## Notes
|
||||
|
||||
- Configure every node; use HTTPS in production
|
||||
- HubProxy pulls upstream anonymously — **private authenticated images cannot be proxied**
|
||||
- See [Transfer Capabilities](/en/guides/capabilities/)
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
title: NAS Configuration
|
||||
description: Configure HubProxy on Synology DSM and Feiniu fnOS.
|
||||
---
|
||||
|
||||
Do **not** overwrite entire `daemon.json` with scripts — you may lose `data-root` and break existing containers.
|
||||
|
||||
Assumes HubProxy is exposed at `https://example.com` via reverse proxy.
|
||||
|
||||
## Synology DSM (Container Manager)
|
||||
|
||||
DSM 7.2+ renamed Docker to **Container Manager**.
|
||||
|
||||
### UI (Recommended)
|
||||
|
||||
1. **Container Manager** → **Registry** → **Settings**
|
||||
2. Select **Docker Hub** → **Edit**
|
||||
3. Enable **Registry mirror**, enter `https://example.com`
|
||||
4. Save and restart Docker engine
|
||||
|
||||
### SSH (dockerd.json)
|
||||
|
||||
:::caution
|
||||
Back up first. **Keep** `data-root`, `storage-driver`, etc. — only change `registry-mirrors`.
|
||||
:::
|
||||
|
||||
DSM ≤7.2: `/var/packages/Docker/etc/dockerd.json`
|
||||
|
||||
DSM 7.3+: `/var/packages/ContainerManager/etc/dockerd.json`
|
||||
|
||||
```json
|
||||
"registry-mirrors": ["https://example.com"]
|
||||
```
|
||||
|
||||
Restart:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart pkgctl-Docker # DSM ≤7.2
|
||||
sudo systemctl restart pkg-ContainerManager-dockerd # DSM 7.3+
|
||||
```
|
||||
|
||||
Verify: `sudo docker info | grep -A3 "Registry Mirrors"`
|
||||
|
||||
---
|
||||
|
||||
## Feiniu fnOS
|
||||
|
||||
Official guidance: **do not** edit `/etc/docker/daemon.json` directly with third-party scripts — it contains `data-root`; clearing it can make images/containers disappear.
|
||||
|
||||
### Web UI (Recommended)
|
||||
|
||||
1. **Docker** → **Image Registry** → **Registry Settings** / **Mirror Settings**
|
||||
2. **Add URL**: `https://example.com`
|
||||
3. Move to **top** of list, save, **restart Docker**
|
||||
|
||||
### Manual edit (Advanced)
|
||||
|
||||
Back up first; only append to `registry-mirrors`, keep `data-root`, restart Docker.
|
||||
|
||||
### Notes
|
||||
|
||||
- App Center apps may use fixed registries (e.g. Aliyun) unrelated to HubProxy
|
||||
- Restart Docker after changes
|
||||
|
||||
## Related
|
||||
|
||||
- [Docker Mirror](/en/guides/docker-mirror/)
|
||||
- [Reverse Proxy](/en/deployment/reverse-proxy/)
|
||||
@@ -1,108 +0,0 @@
|
||||
---
|
||||
title: Offline Images
|
||||
description: Package Docker images as tar files online, with single and batch download.
|
||||
---
|
||||
|
||||
HubProxy's web UI (`enableFrontend = true`) and API package images as tar archives without a local Docker daemon.
|
||||
|
||||
Downloads use a **two-step flow**: `prepare` for a one-time token, then download with that token. Tokens expire in **2 minutes** and are bound to client IP and User-Agent.
|
||||
|
||||
## Web UI
|
||||
|
||||
Visit the HubProxy homepage and use the offline image feature. Leave architecture empty to prefer `linux/amd64`; if a specified architecture is unmatched, the first available platform in the multi-arch index is used.
|
||||
|
||||
## Image Reference Format
|
||||
|
||||
Besides Docker Hub official images, enter a full registry-prefixed reference to pull from other platforms:
|
||||
|
||||
| Source | Example input |
|
||||
|--------|---------------|
|
||||
| Docker Hub | `nginx`, `redis:7` |
|
||||
| GHCR | `ghcr.io/sky22333/hubproxy`, `ghcr.io/owner/app:v1.0` |
|
||||
| Quay | `quay.io/coreos/etcd:latest` |
|
||||
| GCR / K8s | `gcr.io/distroless/base`, `registry.k8s.io/pause:3.9` |
|
||||
|
||||
Missing tags default to `:latest`. Single-segment names (no `/`) get the `library/` namespace. The registry must be enabled in `[registries]` and the image must be anonymously pullable.
|
||||
|
||||
## Compressed Layers
|
||||
|
||||
The web UI and API expose a compressed-layers toggle (`compressed` / `useCompressedLayers`, **on by default**). **Keep it enabled** in most cases.
|
||||
|
||||
| Setting | Contents of each `layer.tar` in the archive | Size | Use case |
|
||||
|---------|-----------------------------------------------|------|----------|
|
||||
| On (default) | **Compressed blob** from the registry (usually gzip) | Smaller, faster download | Modern Docker Engine, `docker load` |
|
||||
| Off | **Uncompressed** filesystem layer tar (classic `docker save` format) | Larger, slower to build | Older Docker Engine versions or environments that only accept uncompressed layers |
|
||||
|
||||
HubProxy outputs a `docker load`-compatible tar. With compression enabled, each layer keeps the upstream compressed blob instead of decompressing and re-packing on the server, which saves bandwidth and CPU. The off switch remains for **legacy Docker** (image format v1 era and early `docker load` implementations): those expect uncompressed filesystem tars in `layer.tar`, matching `docker save` output — disabling compression produces the same layer format.
|
||||
|
||||
## Single Image API
|
||||
|
||||
**Step 1: Prepare**
|
||||
|
||||
```bash
|
||||
curl "https://example.com/api/image/download?image=library/nginx:latest&mode=prepare"
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"download_url": "/api/image/download?image=library/nginx%3Alatest&token=..."
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Download tar**
|
||||
|
||||
```bash
|
||||
curl -L -o nginx.tar "https://example.com/api/image/download?image=library/nginx:latest&token=YOUR_TOKEN"
|
||||
```
|
||||
|
||||
Optional parameters:
|
||||
|
||||
| Param | Description |
|
||||
|-------|-------------|
|
||||
| `platform` | Target platform, e.g. `linux/arm64`; empty prefers `linux/amd64`; if specified but unmatched, uses the first available platform in the index |
|
||||
| `tag` | Used when image has no tag, default `latest` |
|
||||
| `compressed` | Keep registry-compressed layers in tar, default `true` (recommended — see **Compressed Layers** above) |
|
||||
|
||||
## Batch API
|
||||
|
||||
**Step 1: Prepare**
|
||||
|
||||
```bash
|
||||
curl -X POST "https://example.com/api/image/batch?mode=prepare" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"images":["nginx:latest","ghcr.io/sky22333/hubproxy:latest"],"useCompressedLayers":true}'
|
||||
```
|
||||
|
||||
**Step 2: Download combined tar**
|
||||
|
||||
```bash
|
||||
curl -L -o batch.tar "https://example.com/api/image/batch?token=YOUR_TOKEN"
|
||||
```
|
||||
|
||||
## Image Info
|
||||
|
||||
```bash
|
||||
curl "https://example.com/api/image/info?image=library/nginx:latest"
|
||||
```
|
||||
|
||||
## Limits
|
||||
|
||||
| Config / Rule | Default | Description |
|
||||
|--------------|---------|-------------|
|
||||
| `[download].maxImages` | `10` | Max images per batch |
|
||||
| Prepare debounce (single) | 5s | Repeated prepare returns 429 |
|
||||
| Prepare debounce (batch) | 60s | Same for batch |
|
||||
| Token TTL | 2 min | Invalid if expired or IP/UA mismatch |
|
||||
|
||||
```toml
|
||||
[download]
|
||||
maxImages = 10
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Large images take longer; streamed downloads must restart if interrupted
|
||||
- Subject to `[access]` lists and IP rate limiting
|
||||
- Frontend static routes (`/`, `/images`, `/search`, `/assets/*`) are not rate-limited; `/ready`, API, and proxy requests all count
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
title: HubProxy Docs
|
||||
description: Official documentation for the Docker and GitHub acceleration proxy.
|
||||
template: splash
|
||||
hero:
|
||||
tagline: Lightweight, self-hosted multi-service acceleration proxy
|
||||
image:
|
||||
file: ../../../assets/hero.svg
|
||||
actions:
|
||||
- text: Quick Start
|
||||
link: /en/getting-started/quick-start/
|
||||
icon: right-arrow
|
||||
- text: GitHub
|
||||
link: https://github.com/sky22333/hubproxy
|
||||
icon: external
|
||||
variant: minimal
|
||||
---
|
||||
|
||||
import { Card, CardGrid } from '@astrojs/starlight/components'
|
||||
|
||||
## Core Features
|
||||
|
||||
<CardGrid stagger>
|
||||
<Card title="Docker Mirror" icon="rocket">
|
||||
Accelerate Docker Hub, GHCR, Quay, GCR, registry.k8s.io and other Registry API v2 registries.
|
||||
</Card>
|
||||
<Card title="GitHub Acceleration" icon="document">
|
||||
Prefix the full original URL with your HubProxy host to accelerate Release, Raw, Clone, API, and Hugging Face downloads.
|
||||
</Card>
|
||||
<Card title="Offline Images" icon="seti:docker">
|
||||
Package images as tar archives online, with single and batch download support.
|
||||
</Card>
|
||||
<Card title="Image Search" icon="magnifier">
|
||||
Built-in Docker Hub image search and tag browsing.
|
||||
</Card>
|
||||
<Card title="Smart Rate Limiting" icon="warning">
|
||||
Per-client IP rate limiting with allow/deny lists and registry access control.
|
||||
</Card>
|
||||
<Card title="Fully Self-Hosted" icon="approve-check">
|
||||
Single-binary deployment with no dependency on third-party free CDN proxies.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## Recommended Reading
|
||||
|
||||
- For public deployments: [Recommended Architecture](/en/deployment/architecture/) and [Reverse Proxy](/en/deployment/reverse-proxy/)
|
||||
- K8s / NAS: [Kubernetes & containerd](/en/guides/kubernetes-containerd/), [NAS Configuration](/en/guides/nas/)
|
||||
- Capabilities: [Transfer Capabilities](/en/guides/capabilities/); extending: [Development & Build](/en/guides/development/)
|
||||
- [config.toml Reference](/en/configuration/reference/)
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
title: IP Trust
|
||||
description: How HubProxy identifies real client IPs and prevents spoofing.
|
||||
---
|
||||
|
||||
HubProxy uses Gin's `ClientIP()` with trusted proxy CIDRs to decide when to trust forwarding headers.
|
||||
|
||||
## Trusted Proxy CIDRs
|
||||
|
||||
Forwarding headers are only read when the TCP connection originates from:
|
||||
|
||||
- `127.0.0.0/8` (loopback)
|
||||
- `10.0.0.0/8` (private Class A)
|
||||
- `172.16.0.0/12` (private Class B)
|
||||
- `192.168.0.0/16` (private Class C)
|
||||
|
||||
Direct public connections **never** trust forwarding headers.
|
||||
|
||||
## Rate Limit Keys
|
||||
|
||||
| Protocol | Key |
|
||||
|----------|-----|
|
||||
| IPv4 | Full IP address |
|
||||
| IPv6 | `/64` prefix |
|
||||
|
||||
At most 10,000 IP entries are cached in memory.
|
||||
|
||||
## `[security].whiteList` vs IP Protection
|
||||
|
||||
`[security].whiteList` is a **rate-limit exemption** list, not an access control list:
|
||||
|
||||
- Whitelisted IPs skip `[rateLimit]`
|
||||
- Independent from IP spoof protection and `[access]` registry control
|
||||
|
||||
## Correct Proxy Headers
|
||||
|
||||
The proxy must **overwrite** (not append) client IP:
|
||||
|
||||
```nginx
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
```
|
||||
|
||||
See [Reverse Proxy](/en/deployment/reverse-proxy/).
|
||||
|
||||
## Cloudflare
|
||||
|
||||
When users reach your proxy via Cloudflare, the reverse proxy should read `CF-Connecting-IP` and write it to forwarding headers.
|
||||
|
||||
HubProxy does **not** ship a Cloudflare IP trust list — it relies on the private-side proxy writing the correct IP.
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
title: Security Overview
|
||||
description: HubProxy security model, risks, and best practices.
|
||||
---
|
||||
|
||||
HubProxy is an open proxy service — improper deployment creates abuse risk. Follow these practices to reduce exposure.
|
||||
|
||||
## Core Mechanisms
|
||||
|
||||
| Mechanism | Description |
|
||||
|-----------|-------------|
|
||||
| IP rate limiting | Per real client IP (IPv6 uses `/64`) |
|
||||
| IP allow/deny | `[security]` controls rate-limit exemption and blocking |
|
||||
| Repo access control | `[access]` restricts proxied images, GitHub repos, and Hugging Face resources |
|
||||
| Trusted proxies | Forward headers trusted only from private/local networks |
|
||||
| File size limit | `[server].fileSize` prevents oversized file abuse |
|
||||
| Offline download tokens | One-time tokens bound to IP and User-Agent, 2-minute TTL |
|
||||
|
||||
## Not Built In
|
||||
|
||||
HubProxy has **no** admin login, Basic Auth, API keys, or Prometheus `/metrics`. The web UI is a public SPA — security relies on network placement and configuration.
|
||||
|
||||
## Main Risks
|
||||
|
||||
1. **Open proxy**: Public `0.0.0.0:5000` without `[access].whiteList` lets anyone use your bandwidth
|
||||
2. **IP spoofing**: Reverse proxy that appends instead of overwriting `X-Forwarded-For` bypasses rate limits
|
||||
3. **Host injection**: Missing `X-Forwarded-Host` causes incorrect URLs in rewritten `.sh` scripts
|
||||
|
||||
## Recommendations
|
||||
|
||||
- Use [Recommended Architecture](/en/deployment/architecture/): CDN (optional) → reverse proxy → HubProxy
|
||||
- Configure `[access].whiteList` for public services
|
||||
- Overwrite `X-Forwarded-For`, `X-Real-IP`, and `X-Forwarded-Host` at the proxy
|
||||
- Expose public services through a reverse proxy instead of port 5000 directly
|
||||
- Review `[access].blackList` and access logs regularly
|
||||
|
||||
## Related Docs
|
||||
|
||||
- [IP Trust](/en/security/ip-trust/)
|
||||
- [Reverse Proxy](/en/deployment/reverse-proxy/)
|
||||
- [config.toml Reference](/en/configuration/reference/)
|
||||
@@ -1,92 +0,0 @@
|
||||
---
|
||||
title: 常见问题
|
||||
description: HubProxy 部署与使用中的常见问题解答。
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>HubProxy 和免费 CDN 代理有什么区别?</summary>
|
||||
|
||||
HubProxy 是完全自托管的代理服务,数据与带宽由你自己控制,不依赖第三方免费 CDN 的不稳定性。你可以自定义限流、仓库白名单和上游 SOCKS5 代理。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>公网部署必须配置反向代理吗?</summary>
|
||||
|
||||
强烈建议。反代提供 TLS 终止、正确传递客户端 IP,并避免将 5000 端口直接暴露。详见 [推荐部署架构](/deployment/architecture/)。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>限流白名单和仓库白名单有什么区别?</summary>
|
||||
|
||||
`[security].whiteList` 是限流豁免 IP,不影响可代理的仓库。`[access].whiteList` 控制允许代理哪些 Docker 镜像、GitHub 仓库与 Hugging Face 资源。两者独立配置。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>GitHub 加速链接怎么写?</summary>
|
||||
|
||||
在**完整原始 URL** 前加上 HubProxy 域名,例如 `https://example.com/https://github.com/owner/repo/releases/download/...`。不支持 `/github/owner/...` 路径替换写法。详见 [GitHub 加速](/guides/github-proxy/)。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>GitHub 加速提示「无效输入」?</summary>
|
||||
|
||||
常见原因:URL 格式错误(使用了 `/github/` 前缀而非完整 URL),或 Nginx 反代未正确设置 `Host` 头。参考 [GitHub 加速](/guides/github-proxy/) 与 [issue #62](https://github.com/sky22333/hubproxy/issues/62#issuecomment-3219572440)。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>离线镜像 API 怎么用?</summary>
|
||||
|
||||
需两步:先 `GET /api/image/download?image=...&mode=prepare` 获取 token,再携带 token 下载。不存在 `/v2/offline/...` 路径。详见 [离线镜像包](/guides/offline-images/)。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>使用 Cloudflare 后所有用户共用一个 IP 怎么办?</summary>
|
||||
|
||||
若 HubProxy 直连 Cloudflare 边缘节点,限流会按 Cloudflare IP 计数,无法区分用户。正确做法是在 Cloudflare 后面加一层私网反代,由反代写入 `CF-Connecting-IP`。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>如何验证服务是否正常?</summary>
|
||||
|
||||
访问 `https://example.com/ready`,正常时返回 `{"ready":true,"service":"hubproxy",...}`,包含 `version` 与 `uptime_sec` 字段。该路径**会计入** IP 限流,避免高频轮询。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Docker 日志占用磁盘过大?</summary>
|
||||
|
||||
`docker run` 默认日志约 20MB。若使用 [Docker Compose](/deployment/docker/),已在 compose 文件中配置 `200m × 3` 日志轮转;也可自行在 compose 中调整 `logging.options`。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>支持断点续传或多线程分片下载吗?</summary>
|
||||
|
||||
**GitHub / Hugging Face 文件下载:支持。** 代理会透传 `Range` 请求头并将上游 206 响应原样返回,`wget -c`、多线程 Range 分片均可使用(`.sh` / `.ps1` 脚本改写除外)。
|
||||
|
||||
**Docker 镜像拉取:不支持 layer 内 Range。** blob 整层转发,但客户端可重试失败 layer 或并行拉取不同 layer。
|
||||
|
||||
**离线 tar API:不支持**断点续传。详见 [传输特性](/guides/capabilities/)。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>如何添加新的加速域名?</summary>
|
||||
|
||||
在 `src/handlers/github.go` 的 `githubExps` 数组中添加正则表达式,详见 [二次开发与构建](/guides/development/)。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>authType 配置了 GitHub Token 为什么私有镜像仍拉不下来?</summary>
|
||||
|
||||
当前版本 `authType` 仅标识认证端点类型,尚未实现凭据注入,所有 Registry 均使用匿名拉取,且不转发客户端 `Authorization` 头。**无法**通过 HubProxy 拉取需要认证的私有镜像。
|
||||
|
||||
</details>
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
title: 系统安装
|
||||
description: 使用 deb、rpm、apk 安装包部署 HubProxy,以及服务管理命令。
|
||||
---
|
||||
|
||||
## 安装脚本
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh | sh
|
||||
```
|
||||
|
||||
脚本会自动识别 `amd64` / `arm64` 与 `apt`、`dnf`、`apk` 等包管理器。
|
||||
|
||||
## 文件路径
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `/etc/hubproxy/config.toml` | 配置文件 |
|
||||
| `/usr/bin/hubproxy` | 二进制文件 |
|
||||
| `/lib/systemd/system/hubproxy.service` | systemd 服务(Debian/Ubuntu/RHEL 等) |
|
||||
| `/etc/init.d/hubproxy` | OpenRC 服务(Alpine) |
|
||||
| `/var/log/hubproxy.log` | Alpine 日志文件 |
|
||||
|
||||
## systemd 管理
|
||||
|
||||
```bash
|
||||
sudo systemctl status hubproxy # 查看运行状态
|
||||
sudo systemctl restart hubproxy # 修改配置后重启
|
||||
sudo journalctl -u hubproxy -f # 实时查看日志
|
||||
sudo nano /etc/hubproxy/config.toml # 编辑配置
|
||||
```
|
||||
|
||||
卸载:
|
||||
|
||||
```bash
|
||||
sudo apt purge hubproxy # Debian/Ubuntu 卸载并清除配置
|
||||
```
|
||||
|
||||
## OpenRC 管理(Alpine)
|
||||
|
||||
```bash
|
||||
sudo rc-service hubproxy status # 查看状态
|
||||
sudo rc-service hubproxy restart # 重启服务
|
||||
sudo tail -f /var/log/hubproxy.log # 查看日志
|
||||
sudo vi /etc/hubproxy/config.toml # 编辑配置
|
||||
```
|
||||
|
||||
卸载:
|
||||
|
||||
```bash
|
||||
sudo apk del hubproxy # 删除安装包
|
||||
```
|
||||
|
||||
## 生产环境建议
|
||||
|
||||
公网或团队使用时,建议为 HubProxy 绑定**自有域名**,通过 Caddy / Nginx 等反向代理并**开启 HTTPS**,而不是长期直接暴露 `http://IP:5000`。这样可同时获得 TLS、正确的客户端 IP 传递与更安全的访问控制。配置示例见 [反向代理](/deployment/reverse-proxy/) 与 [推荐部署架构](/deployment/architecture/)。
|
||||
@@ -1,60 +0,0 @@
|
||||
---
|
||||
title: 快速开始
|
||||
description: 使用 Docker 或安装脚本在数分钟内运行 HubProxy。
|
||||
---
|
||||
|
||||
HubProxy 提供 Docker 镜像与系统安装包两种主流部署方式。
|
||||
|
||||
## Docker 部署(推荐)
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name hubproxy \
|
||||
-p 5000:5000 \
|
||||
--restart always \
|
||||
-v /path/to/config.toml:/app/config.toml:ro \
|
||||
ghcr.io/sky22333/hubproxy
|
||||
```
|
||||
|
||||
:::tip
|
||||
使用 [Docker Compose](/deployment/docker/) 可更方便地挂载配置文件;日志轮转已在 Compose 文件中预置。
|
||||
:::
|
||||
|
||||
## 脚本安装
|
||||
|
||||
自动识别系统与架构,从 GitHub Releases 下载 `.deb`、`.rpm` 或 `.apk`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh | sh
|
||||
```
|
||||
|
||||
安装完成后服务会自动启动,配置文件位于 `/etc/hubproxy/config.toml`。
|
||||
|
||||
## 验证服务
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:5000/ready # 本地健康检查
|
||||
```
|
||||
|
||||
正常响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"ready": true,
|
||||
"service": "hubproxy",
|
||||
"version": "v1.x.x",
|
||||
"start_time_unix": 1710000000,
|
||||
"uptime_sec": 42.5,
|
||||
"uptime_human": "42秒"
|
||||
}
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
- [系统安装与服务管理](/getting-started/install/)
|
||||
- [Docker Compose 部署](/deployment/docker/)
|
||||
- [Docker 镜像加速用法](/guides/docker-mirror/)
|
||||
|
||||
## 生产环境建议
|
||||
|
||||
验证通过后,建议尽快为 HubProxy 配置**自有域名 + 反向代理 + HTTPS**(如 Caddy / Nginx),客户端通过 `https://example.com` 访问,而不是长期使用裸 `http://IP:5000`。详见 [推荐部署架构](/deployment/architecture/) 与 [反向代理配置](/deployment/reverse-proxy/)。
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
title: 传输特性
|
||||
description: HubProxy 在 Docker 拉取与 GitHub 下载场景下的能力边界说明。
|
||||
---
|
||||
|
||||
本文基于当前代码实现(`src/handlers/docker.go`、`src/handlers/github.go`),说明各场景的传输行为。
|
||||
|
||||
## Docker 镜像拉取
|
||||
|
||||
HubProxy 通过 go-containerregistry 向上游拉取 manifest 与 blob,再以 HTTP 响应形式返回客户端。
|
||||
|
||||
| 能力 | 支持 | 说明 |
|
||||
|------|------|------|
|
||||
| Manifest 缓存 | ✅ | GET manifest 可缓存;digest 24h,`latest` 等 10 分钟 |
|
||||
| Token 缓存 | ✅ | 上游 `expires_in` 驱动 |
|
||||
| 多 Registry 路径 | ✅ | 如 `example.com/ghcr.io/owner/image:tag` |
|
||||
| containerd `ns` 参数 | ✅ | 识别 `[registries]` 中的 registry |
|
||||
| 认证 realm 改写 | ✅ | 上游 token 地址改写到 HubProxy `/token` |
|
||||
| HTTP Range / layer 内续传 | ❌ | blob 始终整层读取后转发,不读取客户端 `Range` |
|
||||
| layer 级重试 | ✅ | Docker/containerd 拉取失败会重试整个 layer |
|
||||
|
||||
:::note
|
||||
每个 layer 对应至少一次 blob 请求,均计入 IP 限流。HubProxy 对 blob **不做** Range 分片,但客户端可并行拉取**不同 layer**(多连接)。
|
||||
:::
|
||||
|
||||
### 与标准 Registry 的差异
|
||||
|
||||
- blob 非透明透传,由 HubProxy 从上游完整拉取后再 `io.Copy` 转发
|
||||
- 不支持 PATCH/PUT 上传(仅拉取)
|
||||
- HEAD / GET manifest 支持
|
||||
|
||||
## GitHub / Hugging Face 下载
|
||||
|
||||
GitHub 代理(`proxyGitHubWithRedirect`)会将客户端**全部请求头**(含 `Range`)转发上游,并将上游**状态码与响应头**(含 `Content-Range`、`Accept-Ranges`)原样返回,再流式转发 body。
|
||||
|
||||
| 能力 | 支持 | 说明 |
|
||||
|------|------|------|
|
||||
| 流式转发 | ✅ | 普通文件直接 `io.Copy` |
|
||||
| HTTP Range / 断点续传 | ✅ | `wget -c`、`curl -C -` 可用;上游返回 206 时原样透传 |
|
||||
| 多连接 Range 分片 | ✅ | aria2 等多线程分片下载可用(依赖上游支持 Range) |
|
||||
| 重定向跟随 | ✅ | 最多 20 次;GitHub URL 的 Location 自动改写 |
|
||||
| 网页类型拦截 | ✅ | `text/html` 等返回 403 |
|
||||
| 文件大小限制 | ✅ | `[server].fileSize`,默认 2GB(按响应头 `Content-Length` 校验) |
|
||||
| `.sh` / `.ps1` 脚本改写 | ⚠️ | 脚本会完整下载并改写内容后 chunked 输出,**不支持** Range 续传 |
|
||||
|
||||
### wget 断点续传示例
|
||||
|
||||
```bash
|
||||
# 首次下载(中断后可续传)
|
||||
wget -c "https://example.com/https://github.com/owner/repo/releases/download/v1.0.0/large.bin"
|
||||
```
|
||||
|
||||
实现位置:`src/handlers/github.go` 第 113–118 行(转发请求头)、第 209–231 行(透传响应)。
|
||||
|
||||
## 离线镜像 tar
|
||||
|
||||
| 能力 | 支持 |
|
||||
|------|------|
|
||||
| 流式打包 tar | ✅ |
|
||||
| 一次性 token(2 分钟) | ✅ |
|
||||
| Range / 断点续传 | ❌ |
|
||||
| `Cache-Control: no-store` | ✅ |
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [Docker 镜像加速](/guides/docker-mirror/)
|
||||
- [GitHub 加速](/guides/github-proxy/)
|
||||
- [离线镜像包](/guides/offline-images/)
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
title: 二次开发与构建
|
||||
description: 扩展加速域名、Registry 映射,以及从源码构建 HubProxy。
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
hubproxy/
|
||||
├── src/ # Go 后端(main.go、handlers、config、utils)
|
||||
├── web/ # Vue 前端 SPA
|
||||
├── Dockerfile # 多阶段构建
|
||||
└── docs/ # 文档站
|
||||
```
|
||||
|
||||
## 增加加速 URL
|
||||
|
||||
GitHub / Hugging Face 等 URL 前缀代理由 `src/handlers/github.go` 的 **`githubExps`** 控制:
|
||||
|
||||
```go
|
||||
var githubExps = []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:releases|archive)/.*`),
|
||||
// 在此追加新正则
|
||||
}
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
1. 正则需含 `([^/]+)/([^/]+)` 捕获组,供 `[access]` 匹配 owner/repo
|
||||
2. 运行 `go test ./handlers/...`
|
||||
3. 路由已在 `main.go` 的 `NoRoute(GitHubProxyHandler)` 注册,新域名无需加路由
|
||||
|
||||
访问控制逻辑:`src/utils/access_control.go` → `CheckGitHubAccess`。
|
||||
|
||||
## 增加 Docker Registry
|
||||
|
||||
`config.toml` → `[registries]`,认证扩展点在 `src/handlers/docker.go` → `createUpstreamOptions()`。
|
||||
|
||||
## 增加 HTTP 路由
|
||||
|
||||
在 `src/main.go` → `buildRouter()` 注册。
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
cd src && CONFIG_PATH=./config.toml go run .
|
||||
|
||||
cd web && npm ci && npm run dev
|
||||
```
|
||||
|
||||
生产嵌入前端:
|
||||
|
||||
```bash
|
||||
cd web && npm ci && npm run build
|
||||
# 将 dist 复制到 src/dist
|
||||
cd ../src && go build -o hubproxy .
|
||||
```
|
||||
|
||||
## Docker 构建
|
||||
|
||||
```bash
|
||||
docker build -t hubproxy:local --build-arg VERSION=1.0.0 .
|
||||
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t ghcr.io/your-org/hubproxy:latest \
|
||||
--build-arg VERSION=1.0.0 --push .
|
||||
```
|
||||
|
||||
| 阶段 | 镜像 | 作用 |
|
||||
|------|------|------|
|
||||
| frontend | node:24-alpine | 构建 Vue |
|
||||
| builder | golang:1.26-alpine | 编译 + UPX |
|
||||
| final | alpine | 运行时 |
|
||||
|
||||
## 测试与发布
|
||||
|
||||
```bash
|
||||
cd src && go test ./...
|
||||
```
|
||||
|
||||
CI:`.github/workflows/docker-ghcr.yml`(镜像)、`release.yml`(安装包)。
|
||||
@@ -1,80 +0,0 @@
|
||||
---
|
||||
title: Docker 镜像源
|
||||
description: 在 HubProxy 中配置上游 Registry 映射。
|
||||
---
|
||||
|
||||
HubProxy 通过 `[registries]` 段配置第三方 Registry 的上游地址与认证端点。Docker Hub 固定代理到 `registry-1.docker.io`,无需在此段配置。
|
||||
|
||||
## 默认 Registry 映射
|
||||
|
||||
`config.toml` 中预置了常用 Registry:
|
||||
|
||||
```toml
|
||||
[registries."ghcr.io"]
|
||||
upstream = "ghcr.io"
|
||||
authHost = "ghcr.io/token"
|
||||
authType = "github"
|
||||
enabled = true
|
||||
|
||||
[registries."gcr.io"]
|
||||
upstream = "gcr.io"
|
||||
authHost = "gcr.io/v2/token"
|
||||
authType = "google"
|
||||
enabled = true
|
||||
|
||||
[registries."quay.io"]
|
||||
upstream = "quay.io"
|
||||
authHost = "quay.io/v2/auth"
|
||||
authType = "quay"
|
||||
enabled = true
|
||||
|
||||
[registries."registry.k8s.io"]
|
||||
upstream = "registry.k8s.io"
|
||||
authHost = "registry.k8s.io"
|
||||
authType = "anonymous"
|
||||
enabled = true
|
||||
```
|
||||
|
||||
## authType 说明
|
||||
|
||||
| authType | 含义 |
|
||||
|----------|------|
|
||||
| `anonymous` | 匿名 Registry(如 registry.k8s.io) |
|
||||
| `github` | GHCR 认证端点 |
|
||||
| `google` | GCR 认证端点 |
|
||||
| `quay` | Quay.io 认证端点 |
|
||||
|
||||
:::note
|
||||
`authType` 用于匹配上游 token 认证地址,当前版本**不会**据此注入 GitHub Token 或 Google 服务账号。所有 Registry 拉取均使用匿名认证,且不转发客户端凭据,**无法**拉取需要认证的私有镜像。
|
||||
:::
|
||||
|
||||
## 拉取路径格式
|
||||
|
||||
```bash
|
||||
# Docker Hub(无需前缀 registry 域名,官方镜像自动补齐 library 与 latest)
|
||||
docker pull example.com/nginx
|
||||
|
||||
# 第三方 Registry
|
||||
docker pull example.com/ghcr.io/owner/image:tag
|
||||
```
|
||||
|
||||
也支持 containerd 的 `ns` 查询参数识别已配置的 Registry。
|
||||
|
||||
## 启用/禁用 Registry
|
||||
|
||||
```toml
|
||||
[registries."quay.io"]
|
||||
enabled = false
|
||||
```
|
||||
|
||||
## Token 与 Manifest 缓存
|
||||
|
||||
`[tokenCache]` 段控制上游认证 Token 与 Manifest 缓存:
|
||||
|
||||
```toml
|
||||
[tokenCache]
|
||||
enabled = true
|
||||
defaultTTL = "20m"
|
||||
```
|
||||
|
||||
digest 引用缓存 24 小时,`latest` 等常用 tag 缓存 10 分钟,详见 [config.toml 参考](/configuration/reference/#tokencache)。
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
title: Docker 镜像加速
|
||||
description: 配置 Docker 客户端使用 HubProxy 加速镜像拉取。
|
||||
---
|
||||
|
||||
HubProxy 兼容 Docker Registry API v2,可作为 Docker Hub 及多种第三方 Registry 的镜像加速入口。
|
||||
|
||||
## 配置 Docker 客户端
|
||||
|
||||
编辑 `/etc/docker/daemon.json`(Windows 为 Docker Desktop 设置):
|
||||
|
||||
```json
|
||||
{
|
||||
"registry-mirrors": ["https://example.com"]
|
||||
}
|
||||
```
|
||||
|
||||
重启 Docker:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
## 支持的 Registry
|
||||
|
||||
| Registry | 说明 |
|
||||
|----------|------|
|
||||
| Docker Hub | 默认 `registry-1.docker.io`,无需路径前缀 |
|
||||
| ghcr.io | GitHub Container Registry |
|
||||
| gcr.io | Google Container Registry |
|
||||
| quay.io | Red Hat Quay |
|
||||
| registry.k8s.io | Kubernetes 官方镜像 |
|
||||
|
||||
可在 `config.toml` 的 `[registries]` 中扩展或禁用,详见 [Docker 镜像源配置](/guides/docker-mirror-sources/)。
|
||||
|
||||
## 拉取示例
|
||||
|
||||
```bash
|
||||
# Docker Hub 官方镜像(无需写 library 前缀)
|
||||
docker pull example.com/nginx
|
||||
|
||||
# Docker Hub 用户镜像
|
||||
docker pull example.com/user/app:tag
|
||||
|
||||
# 第三方 Registry(路径含 registry 域名)
|
||||
docker pull example.com/ghcr.io/owner/image:tag
|
||||
```
|
||||
|
||||
官方镜像会自动补齐 `library/` 命名空间,未指定标签时自动使用 `latest`,即 `example.com/nginx` 等价于 `example.com/library/nginx:latest`。
|
||||
|
||||
## 镜像搜索
|
||||
|
||||
Web 界面(`enableFrontend = true`)提供 Docker Hub 搜索与标签浏览,也可直接调用 API:
|
||||
|
||||
```bash
|
||||
curl "https://example.com/api/search?q=nginx"
|
||||
curl "https://example.com/api/tags/library/nginx"
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 拉取一个镜像会请求多个 layer,每个 HTTP 请求均计入 IP 限流配额
|
||||
- 公网服务建议配置 `[access].whiteList` 限制可代理的镜像
|
||||
- 配合反向代理时确保正确传递客户端 IP,详见 [反向代理配置](/deployment/reverse-proxy/)
|
||||
- 当前版本对 Registry 使用匿名拉取,**无法**通过 HubProxy 拉取需要认证的私有镜像
|
||||
|
||||
## 其他环境
|
||||
|
||||
- [Kubernetes 与 containerd](/guides/kubernetes-containerd/)
|
||||
- [群晖 / 飞牛 NAS](/guides/nas/)
|
||||
- [传输特性(Range 续传、layer 拉取)](/guides/capabilities/)
|
||||
@@ -1,98 +0,0 @@
|
||||
---
|
||||
title: GitHub 加速
|
||||
description: 使用 HubProxy 加速 GitHub Release、Raw、Clone、API 与 Hugging Face 下载。
|
||||
---
|
||||
|
||||
未匹配 `/v2`、`/token`、`/api` 等路由的请求,由 GitHub 代理处理器接管。使用时在**完整原始 URL** 前加上 HubProxy 域名;匹配支持的 URL 类型后,处理器还原地址并代理到 GitHub、Hugging Face 等上游,不匹配则返回「无效输入」。
|
||||
|
||||
## 加速方式
|
||||
|
||||
在原始 URL 前加上 HubProxy 域名,支持两种写法。
|
||||
|
||||
### 完整 URL(推荐)
|
||||
|
||||
```bash
|
||||
# Release 下载
|
||||
curl -L -O "https://example.com/https://github.com/owner/repo/releases/download/v1.0.0/app.tar.gz"
|
||||
|
||||
# Git Clone
|
||||
git clone https://example.com/https://github.com/owner/repo.git
|
||||
|
||||
# API
|
||||
curl "https://example.com/https://api.github.com/repos/owner/repo/releases/latest"
|
||||
```
|
||||
|
||||
### Git 全局加速
|
||||
|
||||
配置 `url.<base>.insteadOf` 后,所有 `https://github.com/` 开头的 clone/fetch 会自动走 HubProxy,无需每次手动改 URL:
|
||||
|
||||
```bash
|
||||
git config --global url."https://example.com/https://github.com/".insteadOf "https://github.com/"
|
||||
```
|
||||
|
||||
之后可直接:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/owner/repo.git
|
||||
# 实际请求 https://example.com/https://github.com/owner/repo.git
|
||||
```
|
||||
|
||||
取消配置:
|
||||
|
||||
```bash
|
||||
git config --global --unset url.https://example.com/https://github.com/.insteadOf
|
||||
```
|
||||
|
||||
### 省略 `https://`
|
||||
|
||||
HubProxy 会自动补全协议头:
|
||||
|
||||
```bash
|
||||
curl -L -O "https://example.com/github.com/owner/repo/releases/download/v1.0.0/app.tar.gz"
|
||||
```
|
||||
|
||||
:::caution
|
||||
**不支持**将域名替换为路径前缀的写法,例如 `https://example.com/gh/owner/repo/...` 或 `/raw/owner/...` 会返回「无效输入」。
|
||||
:::
|
||||
|
||||
## 支持的 URL 类型
|
||||
|
||||
| 类型 | 匹配路径示例 |
|
||||
|------|-------------|
|
||||
| Release / Archive | `github.com/{owner}/{repo}/releases/...`、`/archive/...` |
|
||||
| Raw / Blob | `github.com/{owner}/{repo}/raw/...`(`/blob/` 自动转为 `/raw/`) |
|
||||
| Git 协议 | `github.com/{owner}/{repo}/info/...`、`/git-...` |
|
||||
| Raw 域名 | `raw.githubusercontent.com/...`、`raw.github.com/...` |
|
||||
| API | `api.github.com/repos/{owner}/{repo}/...` |
|
||||
| Gist | `gist.github.com/...`、`gist.githubusercontent.com/...` |
|
||||
| Hugging Face | `huggingface.co/...`、`cdn-lfs.hf.co/...` |
|
||||
| GitHub Assets | `github.githubassets.com/...`、`opengraph.githubassets.com/...` |
|
||||
|
||||
## 脚本嵌套加速
|
||||
|
||||
HubProxy 可自动改写 `.sh` / `.ps1` 安装脚本中的 GitHub URL(脚本最大 10MB)。反代部署时需正确设置 `X-Forwarded-Host`,详见 [反向代理配置](/deployment/reverse-proxy/)。
|
||||
|
||||
## 限制与拒绝
|
||||
|
||||
| 限制 | 说明 |
|
||||
|------|------|
|
||||
| 网页内容 | `text/html`、`application/xml` 等网页类型会被拒绝(403),不能用来加速 GitHub 网页浏览 |
|
||||
| 文件大小 | `[server].fileSize` 控制单文件上限,默认 2GB |
|
||||
| 仓库访问 | `[access].whiteList` / `blackList` 限制可代理的 GitHub 仓库与 Hugging Face 资源 |
|
||||
|
||||
Release 等大文件支持 `wget -c` 断点续传,见 [传输特性](/guides/capabilities/)。
|
||||
|
||||
```toml
|
||||
[server]
|
||||
fileSize = 2147483648
|
||||
|
||||
[access]
|
||||
whiteList = ["trusted-org/*"]
|
||||
blackList = ["*/malicious-repo"]
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [Hugging Face 加速](/guides/huggingface/)
|
||||
- [传输特性(断点续传、分片)](/guides/capabilities/)
|
||||
- [二次开发:添加新加速 URL](/guides/development/)
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
title: Hugging Face 加速
|
||||
description: 通过 HubProxy 加速 Hugging Face 模型与 LFS 文件下载。
|
||||
---
|
||||
|
||||
Hugging Face 下载与 GitHub 共用同一套 URL 代理机制,在**完整原始 URL** 前加上 HubProxy 域名即可。
|
||||
|
||||
## 支持的 URL
|
||||
|
||||
| 类型 | 示例 |
|
||||
|------|------|
|
||||
| 模型 / 数据集文件 | `huggingface.co/{user}/{repo}/resolve/...` |
|
||||
| Spaces | `huggingface.co/spaces/{user}/{repo}/...` |
|
||||
| LFS CDN | `cdn-lfs.hf.co/{user}/{repo}/...` |
|
||||
|
||||
正则定义位于 `src/handlers/github.go` 的 `githubExps` 数组。
|
||||
|
||||
## 下载示例
|
||||
|
||||
```bash
|
||||
# 模型文件
|
||||
curl -L -O "https://example.com/https://huggingface.co/bert-base-uncased/resolve/main/config.json"
|
||||
|
||||
# 省略 https://
|
||||
curl -L -O "https://example.com/huggingface.co/bert-base-uncased/resolve/main/pytorch_model.bin"
|
||||
|
||||
# LFS 大文件
|
||||
curl -L -O "https://example.com/https://cdn-lfs.hf.co/user/model-repo/abc123..."
|
||||
```
|
||||
|
||||
## 限制
|
||||
|
||||
与 [GitHub 加速](/guides/github-proxy/) 相同:
|
||||
|
||||
- 不支持网页浏览(HTML 会被拒绝)
|
||||
- 单文件受 `[server].fileSize` 限制
|
||||
- 与 GitHub 相同,**支持** `Range` 断点续传与多连接分片(见 [传输特性](/guides/capabilities/))
|
||||
- 受 `[access]` 仓库黑白名单约束
|
||||
|
||||
```toml
|
||||
[access]
|
||||
whiteList = ["trusted-user/*"]
|
||||
```
|
||||
@@ -1,90 +0,0 @@
|
||||
---
|
||||
title: Kubernetes 与 containerd
|
||||
description: 在 K3s、RKE2 及原生 containerd 中配置 HubProxy 镜像加速。
|
||||
---
|
||||
|
||||
HubProxy 兼容 OCI/Distribution Registry API v2。Kubernetes 节点通常由 **containerd** 拉取镜像,配置方式与 Docker `daemon.json` 不同。
|
||||
|
||||
## K3s
|
||||
|
||||
在每个节点创建 `/etc/rancher/k3s/registries.yaml`:
|
||||
|
||||
```yaml
|
||||
mirrors:
|
||||
docker.io:
|
||||
endpoint:
|
||||
- "https://example.com"
|
||||
"ghcr.io":
|
||||
endpoint:
|
||||
- "https://example.com"
|
||||
"quay.io":
|
||||
endpoint:
|
||||
- "https://example.com"
|
||||
"registry.k8s.io":
|
||||
endpoint:
|
||||
- "https://example.com"
|
||||
```
|
||||
|
||||
重启服务:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart k3s # server
|
||||
sudo systemctl restart k3s-agent # agent
|
||||
```
|
||||
|
||||
验证(使用 **crictl**,mirror 不对 `ctr` 生效):
|
||||
|
||||
```bash
|
||||
sudo crictl pull docker.io/library/nginx:latest
|
||||
```
|
||||
|
||||
## RKE2
|
||||
|
||||
配置 `/etc/rancher/rke2/registries.yaml`,格式同上,重启 `rke2-server` 或 `rke2-agent`。
|
||||
|
||||
## 原生 containerd(1.5+)
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/containerd/certs.d/docker.io
|
||||
```
|
||||
|
||||
`/etc/containerd/certs.d/docker.io/hosts.toml`:
|
||||
|
||||
```toml
|
||||
server = "https://registry-1.docker.io"
|
||||
|
||||
[host."https://example.com"]
|
||||
capabilities = ["pull", "resolve"]
|
||||
```
|
||||
|
||||
GHCR 示例:`/etc/containerd/certs.d/ghcr.io/hosts.toml`:
|
||||
|
||||
```toml
|
||||
server = "https://ghcr.io"
|
||||
|
||||
[host."https://example.com"]
|
||||
capabilities = ["pull", "resolve"]
|
||||
```
|
||||
|
||||
确保 `/etc/containerd/config.toml` 启用:
|
||||
|
||||
```toml
|
||||
[plugins."io.containerd.grpc.v1.cri".registry]
|
||||
config_path = "/etc/containerd/certs.d"
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl restart containerd
|
||||
```
|
||||
|
||||
## 路径说明
|
||||
|
||||
- docker.io:`/v2/library/nginx/...`
|
||||
- ghcr.io 等:`/v2/ghcr.io/owner/image/...`
|
||||
- 支持 containerd `ns` 查询参数识别 `[registries]`
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 每节点独立配置;生产环境 HubProxy 建议 HTTPS 反代
|
||||
- HubProxy 对上游匿名拉取,**无法**代理需要认证的私有镜像
|
||||
- 见 [传输特性](/guides/capabilities/)
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
title: NAS 配置
|
||||
description: 在群晖 DSM 与飞牛 fnOS 上配置 HubProxy 镜像加速。
|
||||
---
|
||||
|
||||
NAS 上的 Docker 通过图形界面管理,**不要**随意用脚本覆盖整个 `daemon.json`,以免丢失 `data-root` 导致容器丢失。
|
||||
|
||||
以下假设 HubProxy 已通过反代暴露在 `https://example.com`。
|
||||
|
||||
## 群晖 DSM(Container Manager)
|
||||
|
||||
DSM 7.2+ 将「Docker」更名为 **Container Manager**。
|
||||
|
||||
### 图形界面(推荐)
|
||||
|
||||
1. **Container Manager** → **注册表** → **设置**
|
||||
2. 选择 **Docker Hub** → **编辑**
|
||||
3. 勾选 **启用 Registry 镜像**,填入 `https://example.com`
|
||||
4. 保存并重启 Docker 引擎
|
||||
|
||||
### SSH 修改 dockerd.json
|
||||
|
||||
:::caution
|
||||
备份原文件,**保留** `data-root`、`storage-driver` 等字段,仅改 `registry-mirrors`。
|
||||
:::
|
||||
|
||||
DSM 7.2 及更早:`/var/packages/Docker/etc/dockerd.json`
|
||||
|
||||
DSM 7.3+:`/var/packages/ContainerManager/etc/dockerd.json`
|
||||
|
||||
```json
|
||||
"registry-mirrors": ["https://example.com"]
|
||||
```
|
||||
|
||||
重启:
|
||||
|
||||
```bash
|
||||
# DSM 7.2 及更早
|
||||
sudo systemctl restart pkgctl-Docker
|
||||
|
||||
# DSM 7.3+
|
||||
sudo systemctl restart pkg-ContainerManager-dockerd
|
||||
```
|
||||
|
||||
验证:`sudo docker info | grep -A3 "Registry Mirrors"`
|
||||
|
||||
---
|
||||
|
||||
## 飞牛 fnOS
|
||||
|
||||
官方建议:**不要**用脚本直接改写 `/etc/docker/daemon.json`(含 `data-root`,误删会导致镜像/容器丢失)。
|
||||
|
||||
### Web 界面(推荐)
|
||||
|
||||
1. **Docker** → **镜像仓库** → **仓库设置** / **加速源设置**
|
||||
2. **添加 URL**:`https://example.com`
|
||||
3. 拖到列表**顶部**,保存并**重启 Docker**
|
||||
|
||||
### 手动编辑(高级)
|
||||
|
||||
先备份,仅向 `registry-mirrors` 追加地址,保留 `data-root` 等字段,然后重启 Docker。
|
||||
|
||||
### 注意
|
||||
|
||||
- 应用中心部分应用走阿里云等固定 Registry,与 HubProxy 无关
|
||||
- 修改后必须重启 Docker 才生效
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [Docker 镜像加速](/guides/docker-mirror/)
|
||||
- [反向代理](/deployment/reverse-proxy/)
|
||||
@@ -1,108 +0,0 @@
|
||||
---
|
||||
title: 离线镜像包
|
||||
description: 在线打包 Docker 镜像为 tar 文件,支持单镜像与批量下载。
|
||||
---
|
||||
|
||||
HubProxy Web 界面(`enableFrontend = true`)与 API 支持将镜像在线打包为 tar 离线包,无需本地 Docker 环境。
|
||||
|
||||
下载采用**两步流程**:先 `prepare` 获取一次性 token,再携带 token 下载。Token 有效期 **2 分钟**,绑定客户端 IP 与 User-Agent。
|
||||
|
||||
## Web 界面
|
||||
|
||||
访问 HubProxy 首页,在「离线镜像」功能中输入镜像名与标签即可下载。架构选择留空时优先使用 `linux/amd64`;指定架构但匹配不到时,使用多架构索引中的第一个可用平台。
|
||||
|
||||
## 镜像名称格式
|
||||
|
||||
除 Docker Hub 官方镜像外,可直接输入带 Registry 域名的完整引用,从对应平台拉取并打包:
|
||||
|
||||
| 来源 | 输入示例 |
|
||||
|------|---------|
|
||||
| Docker Hub | `nginx`、`redis:7` |
|
||||
| GHCR | `ghcr.io/sky22333/hubproxy`、`ghcr.io/owner/app:v1.0` |
|
||||
| Quay | `quay.io/coreos/etcd:latest` |
|
||||
| GCR / K8s | `gcr.io/distroless/base`、`registry.k8s.io/pause:3.9` |
|
||||
|
||||
未写 tag 时自动补 `:latest`;官方镜像(不含 `/` 的单段名称)会自动补齐 `library/` 命名空间。Registry 需在 `[registries]` 中启用,且镜像可匿名拉取。
|
||||
|
||||
## 压缩层
|
||||
|
||||
Web 界面与 API 均提供「压缩层」开关(`compressed` / `useCompressedLayers`,**默认开启**)。**建议保持开启**。
|
||||
|
||||
| 开关 | tar 内 `layer.tar` 内容 | 体积 | 适用场景 |
|
||||
|------|--------------------------|------|---------|
|
||||
| 开启(默认) | Registry 中的**压缩 blob**(通常为 gzip) | 更小,下载更快 | 现代 Docker Engine,`docker load` 导入 |
|
||||
| 关闭 | **解压后**的文件系统层 tar(与 `docker save` 经典格式一致) | 更大,打包更慢 | 较旧版本 Docker Engine 或仅支持未压缩 layer 的环境 |
|
||||
|
||||
HubProxy 输出的 tar 为 `docker load` 兼容格式。开启压缩层时,每层保留上游 Registry 原样压缩数据,避免 HubProxy 在服务端解压再重打包,显著减少传输体积与 CPU 开销。保留关闭选项,是为了兼容**旧版 Docker**(镜像 v1 时代及更早的 `docker load` 实现):彼时 `layer.tar` 通常为未压缩的文件系统 tar,与 `docker save` 导出结果一致;关闭后输出的 layer 格式与之相同。
|
||||
|
||||
## 单镜像 API
|
||||
|
||||
**第一步:申请下载**
|
||||
|
||||
```bash
|
||||
curl "https://example.com/api/image/download?image=library/nginx:latest&mode=prepare"
|
||||
```
|
||||
|
||||
响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"download_url": "/api/image/download?image=library/nginx%3Alatest&token=..."
|
||||
}
|
||||
```
|
||||
|
||||
**第二步:下载 tar**
|
||||
|
||||
```bash
|
||||
curl -L -o nginx.tar "https://example.com/api/image/download?image=library/nginx:latest&token=YOUR_TOKEN"
|
||||
```
|
||||
|
||||
可选参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `platform` | 指定平台,如 `linux/arm64`;留空时优先 `linux/amd64`;指定但匹配不到时使用索引中第一个可用平台 |
|
||||
| `tag` | 镜像未含 tag 时使用,默认 `latest` |
|
||||
| `compressed` | 是否保留 Registry 压缩层写入 tar,默认 `true`(建议开启,见上文「压缩层」) |
|
||||
|
||||
## 批量 API
|
||||
|
||||
**第一步:申请批量下载**
|
||||
|
||||
```bash
|
||||
curl -X POST "https://example.com/api/image/batch?mode=prepare" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"images":["nginx:latest","ghcr.io/sky22333/hubproxy:latest"],"useCompressedLayers":true}'
|
||||
```
|
||||
|
||||
**第二步:下载合并 tar**
|
||||
|
||||
```bash
|
||||
curl -L -o batch.tar "https://example.com/api/image/batch?token=YOUR_TOKEN"
|
||||
```
|
||||
|
||||
## 镜像信息
|
||||
|
||||
```bash
|
||||
curl "https://example.com/api/image/info?image=library/nginx:latest"
|
||||
```
|
||||
|
||||
## 限制
|
||||
|
||||
| 配置 / 规则 | 默认值 | 说明 |
|
||||
|------------|--------|------|
|
||||
| `[download].maxImages` | `10` | 单次批量镜像数量上限 |
|
||||
| prepare 防抖(单镜像) | 5 秒 | 同一用户重复 prepare 会返回 429 |
|
||||
| prepare 防抖(批量) | 60 秒 | 同上 |
|
||||
| Token TTL | 2 分钟 | 过期或 IP/UA 不匹配则无效 |
|
||||
|
||||
```toml
|
||||
[download]
|
||||
maxImages = 10
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 大镜像打包耗时较长,流式传输中断后需重新请求
|
||||
- 受 `[access]` 黑白名单与 IP 限流约束
|
||||
- 前端静态页面(`/`、`/images`、`/search`、`/assets/*`)不计入限流;`/ready`、API 与代理请求均会计入
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
title: HubProxy 文档
|
||||
description: Docker 与 GitHub 加速代理服务的官方文档,涵盖部署、配置与安全实践。
|
||||
template: splash
|
||||
hero:
|
||||
tagline: 轻量级、自托管的多功能加速代理
|
||||
image:
|
||||
file: ../../assets/hero.svg
|
||||
actions:
|
||||
- text: 快速开始
|
||||
link: /getting-started/quick-start/
|
||||
icon: right-arrow
|
||||
- text: GitHub
|
||||
link: https://github.com/sky22333/hubproxy
|
||||
icon: external
|
||||
variant: minimal
|
||||
---
|
||||
|
||||
import { Card, CardGrid } from '@astrojs/starlight/components'
|
||||
|
||||
## 核心能力
|
||||
|
||||
<CardGrid stagger>
|
||||
<Card title="Docker 镜像加速" icon="rocket">
|
||||
支持 Docker Hub、GHCR、Quay、GCR、registry.k8s.io 等 Registry API v2 标准仓库。
|
||||
</Card>
|
||||
<Card title="GitHub 文件加速" icon="document">
|
||||
在完整 URL 前加域名即可加速 Release、Raw、Clone、API 与 Hugging Face 下载。
|
||||
</Card>
|
||||
<Card title="离线镜像包" icon="seti:docker">
|
||||
在线打包镜像为 tar,支持单镜像与批量下载。
|
||||
</Card>
|
||||
<Card title="镜像搜索" icon="magnifier">
|
||||
内置 Docker Hub 镜像搜索与标签浏览。
|
||||
</Card>
|
||||
<Card title="智能限流" icon="warning">
|
||||
按真实客户端 IP 限流,支持黑白名单与仓库访问控制。
|
||||
</Card>
|
||||
<Card title="完全自托管" icon="approve-check">
|
||||
单二进制部署,不依赖第三方免费 CDN 代理服务。
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## 推荐阅读
|
||||
|
||||
- 公网部署请优先阅读 [推荐部署架构](/deployment/architecture/) 与 [反向代理配置](/deployment/reverse-proxy/)
|
||||
- K8s / NAS 用户见 [Kubernetes 与 containerd](/guides/kubernetes-containerd/)、[NAS 配置](/guides/nas/)
|
||||
- 能力边界见 [传输特性](/guides/capabilities/),扩展开发见 [二次开发与构建](/guides/development/)
|
||||
- 配置项说明见 [config.toml 参考](/configuration/reference/)
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
title: IP 信任机制
|
||||
description: HubProxy 如何识别真实客户端 IP 并防止伪造。
|
||||
---
|
||||
|
||||
HubProxy 使用 Gin 的 `ClientIP()` 获取客户端地址,并配合可信代理网段判断何时信任转发头。
|
||||
|
||||
## 可信代理网段
|
||||
|
||||
仅当 TCP 连接来自以下网段时,才读取 `X-Forwarded-For` / `X-Real-IP`:
|
||||
|
||||
- `127.0.0.0/8`(本机)
|
||||
- `10.0.0.0/8`(私网 A 类)
|
||||
- `172.16.0.0/12`(私网 B 类)
|
||||
- `192.168.0.0/16`(私网 C 类)
|
||||
|
||||
直连公网 IP 的请求**不会**信任任何转发头,直接使用 TCP 远端地址。
|
||||
|
||||
## 限流 IP 计算
|
||||
|
||||
| 协议 | 限流键 |
|
||||
|------|--------|
|
||||
| IPv4 | 完整 IP 地址 |
|
||||
| IPv6 | `/64` 网段 |
|
||||
|
||||
内存中最多缓存 10000 条 IP 记录,不会无限增长。
|
||||
|
||||
## `[security].whiteList` 与 IP 防护
|
||||
|
||||
`[security].whiteList` 是**限流豁免名单**,不是「允许访问的 IP 列表」:
|
||||
|
||||
- 白名单 IP 不受 `[rateLimit]` 限制
|
||||
- 与 IP 伪造防护、仓库访问控制(`[access]`)是独立机制
|
||||
|
||||
## 正确反代示例
|
||||
|
||||
反代必须**覆盖写**(非 append)客户端 IP:
|
||||
|
||||
```nginx
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
```
|
||||
|
||||
详见 [反向代理配置](/deployment/reverse-proxy/)。
|
||||
|
||||
## Cloudflare 场景
|
||||
|
||||
若用户经 Cloudflare 到达反代,反代应从 `CF-Connecting-IP` 读取真实 IP 并写入转发头,而不是让 HubProxy 直连 Cloudflare。
|
||||
|
||||
HubProxy **不**内置 Cloudflare IP 信任列表,依赖「反代在私网侧正确写入 IP」这一架构。
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
title: 安全概述
|
||||
description: HubProxy 安全模型、风险点与最佳实践。
|
||||
---
|
||||
|
||||
HubProxy 作为开放代理服务,部署不当可能带来滥用风险。遵循以下实践可显著降低暴露面。
|
||||
|
||||
## 核心安全机制
|
||||
|
||||
| 机制 | 说明 |
|
||||
|------|------|
|
||||
| IP 限流 | 按真实客户端 IP 限制请求频率(IPv6 按 `/64`) |
|
||||
| IP 黑白名单 | `[security]` 控制限流豁免与封禁 |
|
||||
| 仓库访问控制 | `[access]` 限制可代理的镜像、GitHub 仓库与 Hugging Face 资源 |
|
||||
| 可信代理 | 仅信任来自私网/本机的转发头,防止 IP 伪造 |
|
||||
| 文件大小限制 | `[server].fileSize` 防止超大文件滥用 |
|
||||
| 离线下载 Token | 一次性 token,绑定 IP 与 User-Agent,2 分钟过期 |
|
||||
|
||||
## 未内置的能力
|
||||
|
||||
HubProxy **没有**管理后台登录、Basic Auth、API Key 或 Prometheus 指标端点。Web 界面为公开 SPA,安全依赖网络层与配置策略。
|
||||
|
||||
## 主要风险
|
||||
|
||||
1. **开放代理**:公网直连 `0.0.0.0:5000` 且无 `[access].whiteList` 时,任何人可使用你的带宽代理任意仓库
|
||||
2. **IP 伪造**:反代未覆盖写 `X-Forwarded-For` 时,攻击者可绕过限流
|
||||
3. **Host 注入**:反代未设置 `X-Forwarded-Host` 时,`.sh` 脚本可能生成错误加速 URL
|
||||
|
||||
## 推荐实践
|
||||
|
||||
- 使用 [推荐部署架构](/deployment/architecture/):CDN(可选)→ 反代 → HubProxy
|
||||
- 公网服务配置 `[access].whiteList`
|
||||
- 反代覆盖写 `X-Forwarded-For`、`X-Real-IP`、`X-Forwarded-Host`
|
||||
- 公网服务建议通过反代暴露,不直接开放 `5000` 端口
|
||||
- 定期审查 `[access].blackList` 与访问日志
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [IP 信任机制](/security/ip-trust/)
|
||||
- [反向代理配置](/deployment/reverse-proxy/)
|
||||
- [config.toml 参考](/configuration/reference/)
|
||||
@@ -1,352 +0,0 @@
|
||||
/* HubProxy docs */
|
||||
|
||||
:root {
|
||||
--sl-color-accent: #2563eb;
|
||||
--sl-color-accent-high: #1d4ed8;
|
||||
--sl-sidebar-width: 15rem;
|
||||
--sl-toc-width: 18rem;
|
||||
--hub-ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--hub-duration-fast: 0.15s;
|
||||
--hub-duration: 0.22s;
|
||||
--hub-duration-slow: 0.32s;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
scroll-padding-top: calc(1.5rem + var(--sl-nav-height) + var(--sl-mobile-toc-height));
|
||||
}
|
||||
|
||||
@media (min-width: 72rem) {
|
||||
html {
|
||||
scroll-padding-top: calc(1.5rem + var(--sl-nav-height));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hub-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hub-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hub-scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.97) translateY(-6px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
#starlight__sidebar {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
|
||||
#starlight__sidebar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 72rem) {
|
||||
:root[data-has-toc] {
|
||||
--__toc-width: var(--sl-toc-width);
|
||||
}
|
||||
|
||||
.right-sidebar-container {
|
||||
width: var(--sl-toc-width);
|
||||
}
|
||||
|
||||
.right-sidebar-panel .sl-container {
|
||||
width: calc(var(--sl-toc-width) - 2 * var(--sl-sidebar-pad-x));
|
||||
max-width: calc(var(--sl-toc-width) - 2 * var(--sl-sidebar-pad-x));
|
||||
}
|
||||
|
||||
[data-has-sidebar][data-has-toc] .main-pane {
|
||||
--sl-content-margin-inline: 0;
|
||||
width: min(
|
||||
calc(100% - var(--sl-toc-width)),
|
||||
calc(
|
||||
var(--sl-content-width) +
|
||||
(100% - var(--sl-content-width) - var(--sl-toc-width)) / 2
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 49.999rem) {
|
||||
.sidebar-pane {
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--hub-duration) var(--hub-ease);
|
||||
}
|
||||
|
||||
[aria-expanded='true'] ~ .sidebar-pane {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-content a,
|
||||
.right-sidebar-panel a,
|
||||
#starlight__mobile-toc a,
|
||||
.social-icons a,
|
||||
.header a,
|
||||
#starlight__mobile-toc,
|
||||
#starlight__mobile-toc summary,
|
||||
.sl-markdown-content a:not(:where(.not-content *)),
|
||||
footer a {
|
||||
transition:
|
||||
color var(--hub-duration-fast) var(--hub-ease),
|
||||
background-color var(--hub-duration-fast) var(--hub-ease),
|
||||
border-color var(--hub-duration-fast) var(--hub-ease),
|
||||
text-decoration-color var(--hub-duration-fast) var(--hub-ease),
|
||||
opacity var(--hub-duration-fast) var(--hub-ease);
|
||||
}
|
||||
|
||||
.sidebar-content summary,
|
||||
.sidebar-content summary .caret {
|
||||
transition:
|
||||
color var(--hub-duration-fast) var(--hub-ease),
|
||||
transform var(--hub-duration) var(--hub-ease),
|
||||
opacity var(--hub-duration-fast) var(--hub-ease);
|
||||
}
|
||||
|
||||
.site-title {
|
||||
transition: opacity var(--hub-duration-fast) var(--hub-ease);
|
||||
}
|
||||
|
||||
.site-title:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
site-search button,
|
||||
starlight-menu-button button {
|
||||
transition:
|
||||
color var(--hub-duration-fast) var(--hub-ease),
|
||||
background-color var(--hub-duration-fast) var(--hub-ease),
|
||||
transform var(--hub-duration-fast) var(--hub-ease),
|
||||
box-shadow var(--hub-duration-fast) var(--hub-ease);
|
||||
}
|
||||
|
||||
site-search button:hover,
|
||||
starlight-menu-button button:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
site-search button:active,
|
||||
starlight-menu-button button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
starlight-theme-select label,
|
||||
starlight-lang-select label {
|
||||
transition:
|
||||
border-color var(--hub-duration-fast) var(--hub-ease),
|
||||
background-color var(--hub-duration-fast) var(--hub-ease),
|
||||
color var(--hub-duration-fast) var(--hub-ease);
|
||||
}
|
||||
|
||||
.sl-link-button,
|
||||
.pagination-links a,
|
||||
.card {
|
||||
transition:
|
||||
color var(--hub-duration-fast) var(--hub-ease),
|
||||
background-color var(--hub-duration-fast) var(--hub-ease),
|
||||
border-color var(--hub-duration) var(--hub-ease),
|
||||
transform var(--hub-duration) var(--hub-ease),
|
||||
box-shadow var(--hub-duration) var(--hub-ease);
|
||||
}
|
||||
|
||||
/* minimal 为文本链式按钮,勿叠加卡片式 hover(首页 GitHub 按钮) */
|
||||
.sl-link-button.primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--sl-shadow-sm);
|
||||
}
|
||||
|
||||
.sl-link-button.minimal:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.pagination-links a:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--sl-shadow-md);
|
||||
}
|
||||
|
||||
.sl-link-button:active,
|
||||
.pagination-links a:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.pagination-links svg {
|
||||
transition: transform var(--hub-duration) var(--hub-ease);
|
||||
}
|
||||
|
||||
.pagination-links a[rel='prev']:hover svg {
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
|
||||
.pagination-links a[rel='next']:hover svg {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.sl-markdown-content .starlight-aside {
|
||||
transition:
|
||||
border-color var(--hub-duration-fast) var(--hub-ease),
|
||||
box-shadow var(--hub-duration) var(--hub-ease);
|
||||
}
|
||||
|
||||
.expressive-code .copy button {
|
||||
transition:
|
||||
opacity var(--hub-duration-fast) var(--hub-ease),
|
||||
background-color var(--hub-duration-fast) var(--hub-ease),
|
||||
transform var(--hub-duration-fast) var(--hub-ease);
|
||||
}
|
||||
|
||||
.expressive-code .copy button:hover {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--sl-shadow-md);
|
||||
}
|
||||
|
||||
.card-grid.stagger .card {
|
||||
animation: hub-fade-up var(--hub-duration-slow) var(--hub-ease) both;
|
||||
}
|
||||
|
||||
.card-grid.stagger .card:nth-child(1) {
|
||||
animation-delay: 0.04s;
|
||||
}
|
||||
|
||||
.card-grid.stagger .card:nth-child(2) {
|
||||
animation-delay: 0.08s;
|
||||
}
|
||||
|
||||
.card-grid.stagger .card:nth-child(3) {
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
|
||||
.card-grid.stagger .card:nth-child(4) {
|
||||
animation-delay: 0.16s;
|
||||
}
|
||||
|
||||
.card-grid.stagger .card:nth-child(5) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.card-grid.stagger .card:nth-child(6) {
|
||||
animation-delay: 0.24s;
|
||||
}
|
||||
|
||||
.hero img {
|
||||
width: min(100%, 22rem);
|
||||
height: auto;
|
||||
border-radius: 1rem;
|
||||
box-shadow: var(--sl-shadow-md);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.hero img {
|
||||
box-shadow: 0 12px 40px rgb(0 0 0 / 35%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 仅正文 FAQ,勿用全局 details 以免破坏移动端 TOC / 侧栏 */
|
||||
.sl-markdown-content details {
|
||||
margin-block: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 0.5rem;
|
||||
transition:
|
||||
background-color var(--hub-duration-fast) var(--hub-ease),
|
||||
border-color var(--hub-duration-fast) var(--hub-ease);
|
||||
}
|
||||
|
||||
.sl-markdown-content details[open] {
|
||||
background: var(--sl-color-gray-6);
|
||||
}
|
||||
|
||||
.sl-markdown-content summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: color var(--hub-duration-fast) var(--hub-ease);
|
||||
}
|
||||
|
||||
.sl-markdown-content details > :not(summary) {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@view-transition {
|
||||
navigation: auto;
|
||||
}
|
||||
|
||||
.main-pane {
|
||||
view-transition-name: main-content;
|
||||
}
|
||||
|
||||
::view-transition-old(main-content),
|
||||
::view-transition-new(main-content) {
|
||||
animation-duration: var(--hub-duration);
|
||||
animation-timing-function: var(--hub-ease);
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
site-search dialog[open] {
|
||||
animation: hub-scale-in var(--hub-duration) var(--hub-ease) both;
|
||||
}
|
||||
|
||||
site-search dialog[open]::backdrop {
|
||||
animation: hub-fade-in var(--hub-duration) var(--hub-ease) both;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
site-search button:hover,
|
||||
starlight-menu-button button:hover,
|
||||
.sl-link-button.primary:hover,
|
||||
.pagination-links a:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.card-grid.stagger .card {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
@@ -20,7 +20,8 @@ periodHours = 3.0
|
||||
# 白名单中的IP不受限流限制
|
||||
whiteList = [
|
||||
"127.0.0.1",
|
||||
"127.0.0.2"
|
||||
"172.17.0.0/16",
|
||||
"192.168.1.0/24"
|
||||
]
|
||||
|
||||
# IP黑名单,支持单个IP或IP段
|
||||
|
||||
@@ -276,3 +276,15 @@ func overrideFromEnv(cfg *AppConfig) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ 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/([^/]+)/.+?`),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -29,7 +29,4 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,22 +289,27 @@ func (is *ImageStreamer) StreamImageToWriter(ctx context.Context, imageRef strin
|
||||
|
||||
contextOptions := append(is.remoteOptions, remote.WithContext(ctx))
|
||||
|
||||
desc, err := is.getImageDescriptor(ref, contextOptions)
|
||||
desc, err := is.getImageDescriptorWithPlatform(ref, contextOptions, options.Platform)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取镜像描述失败: %w", err)
|
||||
}
|
||||
switch desc.MediaType {
|
||||
case types.OCIImageIndex, types.DockerManifestList:
|
||||
return is.streamMultiArchImage(ctx, desc, writer, options, imageRef)
|
||||
return is.streamMultiArchImage(ctx, desc, writer, options, contextOptions, imageRef)
|
||||
case types.OCIManifestSchema1, types.DockerManifestSchema2:
|
||||
return is.streamSingleImage(ctx, desc, writer, options, imageRef)
|
||||
return is.streamSingleImage(ctx, desc, writer, options, contextOptions, imageRef)
|
||||
default:
|
||||
return is.streamSingleImage(ctx, desc, writer, options, imageRef)
|
||||
return is.streamSingleImage(ctx, desc, writer, options, contextOptions, imageRef)
|
||||
}
|
||||
}
|
||||
|
||||
// getImageDescriptor 获取镜像描述符
|
||||
func (is *ImageStreamer) getImageDescriptor(ref name.Reference, options []remote.Option) (*remote.Descriptor, error) {
|
||||
return is.getImageDescriptorWithPlatform(ref, options, "")
|
||||
}
|
||||
|
||||
// getImageDescriptorWithPlatform 获取指定平台的镜像描述符
|
||||
func (is *ImageStreamer) getImageDescriptorWithPlatform(ref name.Reference, options []remote.Option, platform string) (*remote.Descriptor, error) {
|
||||
return remote.Get(ref, options...)
|
||||
}
|
||||
|
||||
@@ -319,47 +324,20 @@ func setDownloadHeaders(c *gin.Context, filename string, compressed bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
func (is *ImageStreamer) StreamImageToGin(ctx context.Context, imageRef string, c *gin.Context, options *StreamOptions) error {
|
||||
if options == nil {
|
||||
options = &StreamOptions{UseCompressedLayers: true}
|
||||
}
|
||||
|
||||
ref, err := name.ParseReference(imageRef)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析镜像引用失败: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return is.StreamImageToWriter(ctx, imageRef, c.Writer, options)
|
||||
}
|
||||
|
||||
// streamMultiArchImage 处理多架构镜像
|
||||
func (is *ImageStreamer) streamMultiArchImage(ctx context.Context, desc *remote.Descriptor, writer io.Writer, options *StreamOptions, imageRef string) error {
|
||||
func (is *ImageStreamer) streamMultiArchImage(ctx context.Context, desc *remote.Descriptor, writer io.Writer, options *StreamOptions, remoteOptions []remote.Option, imageRef string) error {
|
||||
img, err := is.selectPlatformImage(desc, options)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -369,7 +347,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, imageRef string) error {
|
||||
func (is *ImageStreamer) streamSingleImage(ctx context.Context, desc *remote.Descriptor, writer io.Writer, options *StreamOptions, remoteOptions []remote.Option, imageRef string) error {
|
||||
img, err := desc.Image()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取镜像失败: %w", err)
|
||||
@@ -605,7 +583,7 @@ func (is *ImageStreamer) streamSingleImageForBatch(ctx context.Context, tarWrite
|
||||
|
||||
contextOptions := append(is.remoteOptions, remote.WithContext(ctx))
|
||||
|
||||
desc, err := is.getImageDescriptor(ref, contextOptions)
|
||||
desc, err := is.getImageDescriptorWithPlatform(ref, contextOptions, options.Platform)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("获取镜像描述失败: %w", err)
|
||||
}
|
||||
@@ -709,26 +687,22 @@ func formatPlatformText(platform string) string {
|
||||
func InitImageTarRoutes(router *gin.Engine) {
|
||||
imageAPI := router.Group("/api/image")
|
||||
{
|
||||
imageAPI.GET("/download", handleDirectImageDownload)
|
||||
imageAPI.GET("/info", handleImageInfo)
|
||||
imageAPI.GET("/download/:image", handleDirectImageDownload)
|
||||
imageAPI.GET("/info/:image", 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) {
|
||||
imageRef := resolveImageRef(c)
|
||||
if imageRef == "" {
|
||||
imageParam := c.Param("image")
|
||||
if imageParam == "" {
|
||||
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"
|
||||
@@ -771,10 +745,11 @@ func handleDirectImageDownload(c *gin.Context) {
|
||||
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()})
|
||||
downloadURL := fmt.Sprintf("/api/image/download/%s?token=%s", imageParam, token)
|
||||
if tag != "" {
|
||||
downloadURL = downloadURL + "&tag=" + url.QueryEscape(tag)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"download_url": downloadURL})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -809,7 +784,8 @@ func handleDirectImageDownload(c *gin.Context) {
|
||||
log.Printf("下载镜像: %s (平台: %s)", req.Image, formatPlatformText(req.Platform))
|
||||
|
||||
if err := globalImageStreamer.StreamImageToGin(ctx, req.Image, c, options); err != nil {
|
||||
writeDownloadError(c, err, "镜像下载失败")
|
||||
log.Printf("镜像下载失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "镜像下载失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -845,10 +821,12 @@ func handleSimpleBatchDownload(c *gin.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, "批量镜像下载失败")
|
||||
log.Printf("批量镜像下载失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "批量镜像下载失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
return
|
||||
@@ -934,12 +912,13 @@ func handleSimpleBatchDownload(c *gin.Context) {
|
||||
|
||||
// handleImageInfo 处理镜像信息查询
|
||||
func handleImageInfo(c *gin.Context) {
|
||||
imageRef := resolveImageRef(c)
|
||||
if imageRef == "" {
|
||||
imageParam := c.Param("image")
|
||||
if imageParam == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少镜像参数"})
|
||||
return
|
||||
}
|
||||
|
||||
imageRef := strings.ReplaceAll(imageParam, "_", "/")
|
||||
tag := c.DefaultQuery("tag", "latest")
|
||||
|
||||
if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") {
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestDownloadDebouncer(t *testing.T) {
|
||||
@@ -64,52 +58,3 @@ func TestGenerateContentFingerprintStable(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -466,9 +466,9 @@ func sendErrorResponse(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": message})
|
||||
}
|
||||
|
||||
// RegisterSearchRoute 注册搜索与标签相关 API 路由。
|
||||
// RegisterSearchRoute 注册搜索相关路由
|
||||
func RegisterSearchRoute(r *gin.Engine) {
|
||||
r.GET("/api/search", func(c *gin.Context) {
|
||||
r.GET("/search", func(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
sendErrorResponse(c, "搜索关键词不能为空")
|
||||
@@ -486,7 +486,7 @@ func RegisterSearchRoute(r *gin.Engine) {
|
||||
c.JSON(http.StatusOK, result)
|
||||
})
|
||||
|
||||
r.GET("/api/tags/:namespace/:name", func(c *gin.Context) {
|
||||
r.GET("/tags/:namespace/:name", func(c *gin.Context) {
|
||||
namespace := c.Param("namespace")
|
||||
name := c.Param("name")
|
||||
|
||||
@@ -503,9 +503,15 @@ func RegisterSearchRoute(r *gin.Engine) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, TagPageResult{
|
||||
Tags: tags,
|
||||
HasMore: hasMore,
|
||||
})
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,7 +16,7 @@ import (
|
||||
"hubproxy/utils"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
//go:embed public/*
|
||||
var staticFiles embed.FS
|
||||
|
||||
var (
|
||||
@@ -28,69 +26,23 @@ var (
|
||||
|
||||
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
|
||||
contentType := "text/html; charset=utf-8"
|
||||
if strings.HasSuffix(filename, ".ico") {
|
||||
contentType = "image/x-icon"
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
func buildRouter(cfg *config.AppConfig) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
router := gin.Default()
|
||||
utils.ConfigureTrustedProxies(router)
|
||||
|
||||
router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
|
||||
log.Printf("Panic 已恢复: %v", recovered)
|
||||
@@ -104,7 +56,32 @@ func buildRouter(cfg *config.AppConfig) *gin.Engine {
|
||||
|
||||
initHealthRoutes(router)
|
||||
handlers.InitImageTarRoutes(router)
|
||||
registerFrontendRoutes(router, cfg.Server.EnableFrontend)
|
||||
|
||||
if cfg.Server.EnableFrontend {
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/index.html")
|
||||
})
|
||||
router.GET("/public/*filepath", func(c *gin.Context) {
|
||||
filepath := strings.TrimPrefix(c.Param("filepath"), "/")
|
||||
serveEmbedFile(c, "public/"+filepath)
|
||||
})
|
||||
router.GET("/images.html", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/images.html")
|
||||
})
|
||||
router.GET("/search.html", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/search.html")
|
||||
})
|
||||
router.GET("/favicon.ico", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/favicon.ico")
|
||||
})
|
||||
} else {
|
||||
router.GET("/", func(c *gin.Context) { c.Status(http.StatusNotFound) })
|
||||
router.GET("/public/*filepath", func(c *gin.Context) { c.Status(http.StatusNotFound) })
|
||||
router.GET("/images.html", func(c *gin.Context) { c.Status(http.StatusNotFound) })
|
||||
router.GET("/search.html", func(c *gin.Context) { c.Status(http.StatusNotFound) })
|
||||
router.GET("/favicon.ico", func(c *gin.Context) { c.Status(http.StatusNotFound) })
|
||||
}
|
||||
|
||||
handlers.RegisterSearchRoute(router)
|
||||
|
||||
router.Any("/token", handlers.ProxyDockerAuthGin)
|
||||
|
||||
@@ -71,7 +71,7 @@ func TestFrontendDisabledRoutesReturnNotFound(t *testing.T) {
|
||||
enableFrontend = false
|
||||
`)
|
||||
|
||||
for _, path := range []string{"/", "/images", "/search", "/favicon.ico"} {
|
||||
for _, path := range []string{"/", "/images.html", "/search.html", "/favicon.ico"} {
|
||||
w := performRequest(router, http.MethodGet, path, "")
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("%s status = %d, want 404", path, w.Code)
|
||||
@@ -82,7 +82,7 @@ enableFrontend = false
|
||||
func TestSingleImageDownloadPrepareReturnsURL(t *testing.T) {
|
||||
router := newTestRouter(t, "")
|
||||
|
||||
w := performRequest(router, http.MethodGet, "/api/image/download?image=nginx&mode=prepare", "")
|
||||
w := performRequest(router, http.MethodGet, "/api/image/download/nginx?mode=prepare", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -93,10 +93,7 @@ func TestSingleImageDownloadPrepareReturnsURL(t *testing.T) {
|
||||
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?") {
|
||||
if !strings.HasPrefix(got.DownloadURL, "/api/image/download/nginx?token=") {
|
||||
t.Fatalf("download_url = %q", got.DownloadURL)
|
||||
}
|
||||
}
|
||||
@@ -143,27 +140,19 @@ func TestGitHubNoRouteRejectsUnsupportedHost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerV2PingAndInvalidPath(t *testing.T) {
|
||||
func TestDockerV2InvalidPath(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", "")
|
||||
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
|
||||
`)
|
||||
func TestSearchRouteRejectsMissingQuery(t *testing.T) {
|
||||
router := newTestRouter(t, "")
|
||||
|
||||
w := performRequest(router, http.MethodGet, "/api/search", "")
|
||||
w := performRequest(router, http.MethodGet, "/search", "")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -176,21 +165,3 @@ enableFrontend = false
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,968 @@
|
||||
<!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, mode = '') {
|
||||
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 (mode) {
|
||||
params.append('mode', mode);
|
||||
}
|
||||
|
||||
if (params.toString()) {
|
||||
url += '?' + params.toString();
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
function buildInfoUrl(imageName) {
|
||||
const encodedImage = imageName.replace(/\//g, '_');
|
||||
return `/api/image/info/${encodedImage}`;
|
||||
}
|
||||
|
||||
async function preflightImageDownload(imageName) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
const response = await fetch(buildInfoUrl(imageName), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
cache: 'no-store',
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
let payload = null;
|
||||
if (contentType.includes('application/json')) {
|
||||
payload = await response.json();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { ok: false, error: (payload && payload.error) ? payload.error : '镜像预检失败' };
|
||||
}
|
||||
|
||||
if (payload && payload.success === false) {
|
||||
return { ok: false, error: payload.error || '镜像预检失败' };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return { ok: false, error: '预检超时,请稍后重试' };
|
||||
}
|
||||
return { ok: false, error: '网络错误: ' + error.message };
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function preflightImages(images) {
|
||||
const uniqueImages = Array.from(new Set(images));
|
||||
const results = await Promise.allSettled(uniqueImages.map((imageName) => preflightImageDownload(imageName)));
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i];
|
||||
if (result.status === 'rejected') {
|
||||
return { ok: false, error: '预检失败,请稍后重试' };
|
||||
}
|
||||
if (!result.value.ok) {
|
||||
return { ok: false, error: result.value.error || '预检失败' };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
document.getElementById('singleForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const imageName = document.getElementById('imageInput').value.trim();
|
||||
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);
|
||||
|
||||
showStatus('singleStatus', '正在准备下载...', 'success');
|
||||
const preflightResult = await preflightImages([imageName]);
|
||||
if (!preflightResult.ok) {
|
||||
showStatus('singleStatus', preflightResult.error, 'error');
|
||||
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
|
||||
return;
|
||||
}
|
||||
|
||||
const prepareUrl = buildDownloadUrl(imageName, platform, useCompressed, 'prepare');
|
||||
try {
|
||||
const response = await fetch(prepareUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (!data || !data.download_url) {
|
||||
showStatus('singleStatus', '下载地址生成失败', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = data.download_url;
|
||||
link.download = '';
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
const platformText = platform ? ` (${platform})` : '';
|
||||
showStatus('singleStatus', `开始下载 ${imageName}${platformText}`, 'success');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showStatus('singleStatus', error.error || '下载失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('singleStatus', '网络错误: ' + error.message, 'error');
|
||||
} finally {
|
||||
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('batchForm').addEventListener('submit', async function(e) {
|
||||
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);
|
||||
showStatus('batchStatus', '正在准备下载...', 'success');
|
||||
const preflightResult = await preflightImages(images);
|
||||
if (!preflightResult.ok) {
|
||||
showStatus('batchStatus', preflightResult.error, 'error');
|
||||
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/image/batch?mode=prepare', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(options)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (!data || !data.download_url) {
|
||||
showStatus('batchStatus', '下载地址生成失败', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = data.download_url;
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,832 @@
|
||||
<!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>
|
||||
@@ -102,10 +102,18 @@ func ExtractTTLFromResponse(responseBody []byte) time.Duration {
|
||||
defaultTTL := 30 * time.Minute
|
||||
|
||||
if json.Unmarshal(responseBody, &tokenResp) == nil && tokenResp.ExpiresIn > 0 {
|
||||
safeTTL := time.Duration(tokenResp.ExpiresIn-300) * time.Second
|
||||
if safeTTL > 5*time.Minute {
|
||||
return safeTTL
|
||||
expires := time.Duration(tokenResp.ExpiresIn) * time.Second
|
||||
skew := expires / 10
|
||||
if skew > 5*time.Minute {
|
||||
skew = 5 * time.Minute
|
||||
}
|
||||
if skew < 10*time.Second {
|
||||
skew = 10 * time.Second
|
||||
}
|
||||
if expires > skew {
|
||||
return expires - skew
|
||||
}
|
||||
return expires / 2
|
||||
}
|
||||
|
||||
return defaultTTL
|
||||
|
||||
@@ -34,6 +34,10 @@ func TestExtractTTLFromResponse(t *testing.T) {
|
||||
t.Fatalf("TTL = %s, want 55m", ttl)
|
||||
}
|
||||
|
||||
if ttl := ExtractTTLFromResponse([]byte(`{"expires_in":300}`)); ttl != 270*time.Second {
|
||||
t.Fatalf("short TTL = %s, want 270s", ttl)
|
||||
}
|
||||
|
||||
if ttl := ExtractTTLFromResponse([]byte(`{}`)); ttl != 30*time.Minute {
|
||||
t.Fatalf("default TTL = %s", ttl)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package utils
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -17,18 +18,7 @@ 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)
|
||||
}
|
||||
var debugRateLimitLog = strings.EqualFold(os.Getenv("DEBUG_RATE_LIMIT_LOG"), "true")
|
||||
|
||||
// IPRateLimiter IP限流器结构体
|
||||
type IPRateLimiter struct {
|
||||
@@ -225,14 +215,43 @@ 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 == "/images" || path == "/search" ||
|
||||
path == "/favicon.ico" ||
|
||||
strings.HasPrefix(path, "/assets/") {
|
||||
if path == "/" || path == "/favicon.ico" || path == "/images.html" || path == "/search.html" ||
|
||||
strings.HasPrefix(path, "/public/") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
cleanIP := extractIPFromAddress(c.ClientIP())
|
||||
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)
|
||||
|
||||
if debugRateLimitLog {
|
||||
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"))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ipLimiter, allowed := limiter.GetLimiter(cleanIP)
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestExtractIPFromAddress(t *testing.T) {
|
||||
if got := extractIPFromAddress("127.0.0.1:5000"); got != "127.0.0.1" {
|
||||
@@ -24,51 +19,3 @@ func TestNormalizeIPv6ForRateLimit(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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?
|
||||
@@ -1,9 +0,0 @@
|
||||
# HubProxy Web
|
||||
|
||||
Vue 3 + Vite + Tailwind 前端。
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # 代理到 :5000
|
||||
npm run build # 输出到 ../src/dist
|
||||
```
|
||||
@@ -1,14 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<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>
|
||||
@@ -1,137 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
<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>
|
||||
@@ -1,19 +0,0 @@
|
||||
<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>
|
||||
@@ -1,28 +0,0 @@
|
||||
<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>
|
||||
@@ -1,45 +0,0 @@
|
||||
<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>
|
||||
@@ -1,28 +0,0 @@
|
||||
<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>
|
||||
@@ -1,40 +0,0 @@
|
||||
<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>
|
||||
@@ -1,26 +0,0 @@
|
||||
<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>
|
||||
@@ -1,29 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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')
|
||||
@@ -1,178 +0,0 @@
|
||||
<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>
|
||||
@@ -1,176 +0,0 @@
|
||||
<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>
|
||||
@@ -1,400 +0,0 @@
|
||||
<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>
|
||||
@@ -1,37 +0,0 @@
|
||||
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
|
||||
@@ -1,233 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||