commit 79c1cdde2f7c5cbea978a18c660caeba1aaee428
Author: Anonymous <>
Date: Sat Feb 24 10:09:14 2024 +0800
initial commit
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..5825b57
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,13 @@
+
+# for caddy
+CLOUDFLARE_DNS_API_TOKEN=xxxx
+
+# for caddy and mirrors
+BASE_DOMAIN=local.homeinfra.org
+
+# for aria2 and mirrors
+RPC_SECRET=changeit
+
+# for all, if you want set proxy for all services
+# aria2 need the lowercase one, FYI https://aria2.github.io/manual/en/html/aria2c.html#environment
+# all_proxy=
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bb1c13f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+
+data/*
+!data/cache/.gitkeep
+caddy/caddy_data
+.idea
+.env
+node_modules
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..53b9629
--- /dev/null
+++ b/README.md
@@ -0,0 +1,63 @@
+
+
+# LightMirrors
+
+LightMirrors是一个开源的缓存镜像站服务,用于加速软件包下载和镜像拉取。
+目前支持DockerHub、PyPI、PyTorch、NPM等镜像缓存服务。
+
+
+

+

+[](https://github.com/NoCLin/LightMirrors)
+[](https://github.com/NoCLin/LightMirrors)
+
+
+
+
+---
+
+## Quick Start
+
+### Prerequisites
+
+- docker + docker-compose.
+- 一个域名,设置 `*.local.homeinfra.org` 的A记录指向您的服务器.
+- 代理服务器(如有必要).
+- 一个Cloudflare账户(非强制,也可以使用其他DNS服务,请自行修改Caddy)
+
+### Deployment
+
+修改 `.env` 文件,设置下列参数:
+
+- `BASE_DOMAIN`: 基础域名,如 `local.homeinfra.org`,镜像站将会使用 `*.local.homeinfra.org` 的子域名。
+- `CLOUDFLARE_DNS_API_TOKEN`,Cloudflare的API Token,用于管理DNS申请HTTPS证书。
+- `RPC_SECRET`:Aria2的RPC密钥。
+- `all_proxy`:代理服务器地址,如有必要。
+
+```bash
+docker-compose up
+```
+
+## Design
+
+LightMirrors依赖于三个组件:
+
+- aria2 + Aria2Ng : 下载器与管理UI。
+- mirrors: 镜像HTTP服务器。
+- caddy: HTTP网关。
+
+## Test
+
+> 假设我们的域名为 local.homeinfra.org
+
+| subdomain | source | test command |
+|-----------|---------------------------------|-----------------------------------------------------------------|
+| pypi | https://pypi.org | pip3 download -i https://pypi.local.homeinfra.org/simple jinja2 |
+| torch | https://download.pytorch.org | pip3 download -i https://pypi.local.homeinfra.org/whl/ torch |
+| dockerhub | https://registry-1.docker.io/v2 | docker pull docker.local.homeinfra.org/alpine |
+
+
+## Star History
+
+[](https://star-history.com/#NoCLin/LightMirrors&Date)
+
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000..0808b6a
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,3 @@
+- Test
+- Publish to ghcr and dockerhub
+- Configurable upstreams
\ No newline at end of file
diff --git a/aria2/Dockerfile b/aria2/Dockerfile
new file mode 100644
index 0000000..cf72fe1
--- /dev/null
+++ b/aria2/Dockerfile
@@ -0,0 +1,13 @@
+FROM alpine:3.19.1
+
+# Optimization for China
+RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories
+
+RUN apk update && apk add --no-cache aria2
+
+ADD entrypoint.sh /
+ADD aria2.conf /
+
+EXPOSE 6800
+
+CMD ["/entrypoint.sh"]
\ No newline at end of file
diff --git a/aria2/aria2.conf b/aria2/aria2.conf
new file mode 100644
index 0000000..04415b0
--- /dev/null
+++ b/aria2/aria2.conf
@@ -0,0 +1,54 @@
+dir=/app/cache
+disk-cache=64M
+file-allocation=none
+no-file-allocation-limit=64M
+continue=true
+always-resume=false
+max-resume-failure-tries=0
+remote-time=true
+input-file=/data/aria2.session
+save-session=/data/aria2.session
+save-session-interval=1
+auto-save-interval=20
+
+# remove .aria2 when download is done
+# we leverage this to detech if download is done
+force-save=false
+
+max-file-not-found=3
+max-tries=0
+retry-wait=10
+connect-timeout=10
+timeout=10
+max-concurrent-downloads=5
+max-connection-per-server=16
+split=64
+min-split-size=4M
+piece-length=1M
+allow-piece-length-change=true
+lowest-speed-limit=0
+max-overall-download-limit=0
+max-download-limit=0
+disable-ipv6=true
+http-accept-gzip=true
+reuse-uri=false
+no-netrc=true
+allow-overwrite=true
+auto-file-renaming=false
+content-disposition-default-utf8=true
+
+enable-rpc=true
+rpc-allow-origin-all=false
+rpc-listen-all=true
+rpc-listen-port=6800
+rpc-max-request-size=10M
+
+log=-
+log-level=warn
+
+console-log-level=notice
+
+quiet=false
+
+summary-interval=0
+
diff --git a/aria2/entrypoint.sh b/aria2/entrypoint.sh
new file mode 100755
index 0000000..1528191
--- /dev/null
+++ b/aria2/entrypoint.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env sh
+touch /data/aria2.session
+
+aria2c --conf-path=/aria2.conf --rpc-secret $RPC_SECRET
\ No newline at end of file
diff --git a/caddy/Caddyfile b/caddy/Caddyfile
new file mode 100644
index 0000000..e4111c2
--- /dev/null
+++ b/caddy/Caddyfile
@@ -0,0 +1,15 @@
+*.{$BASE_DOMAIN} {
+
+ tls {
+ dns cloudflare {env.CLOUDFLARE_DNS_API_TOKEN}
+ }
+
+ reverse_proxy http://lightmirrors:8080
+
+ @aria2 host aria2.{$BASE_DOMAIN}
+ handle @aria2 {
+ root * /wwwroot
+ file_server
+ reverse_proxy /jsonrpc http://aria2:6800
+ }
+}
\ No newline at end of file
diff --git a/caddy/Dockerfile b/caddy/Dockerfile
new file mode 100644
index 0000000..bf50fe3
--- /dev/null
+++ b/caddy/Dockerfile
@@ -0,0 +1,23 @@
+FROM caddy:2.7.6-builder-alpine AS builder
+
+# Optimization for China
+RUN go env -w GOPROXY=https://goproxy.cn,direct
+
+RUN xcaddy build \
+ --with github.com/caddy-dns/cloudflare
+
+FROM caddy:2.7.6-alpine
+
+COPY --from=builder /usr/bin/caddy /usr/bin/caddy
+
+RUN mkdir -p /wwwroot
+WORKDIR /wwwroot
+
+# Optimization for China as the project is aimed at Chinese users
+ADD https://hub.gitmirror.com/https://github.com/mayswind/AriaNg/releases/download/1.3.7/AriaNg-1.3.7.zip /wwwroot/
+
+RUN unzip AriaNg-1.3.7.zip && rm AriaNg-1.3.7.zip
+
+ENTRYPOINT ["caddy"]
+
+CMD ["run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..f63372f
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,44 @@
+services:
+ lightmirrors:
+ image: lightmirrors/mirrors
+ build: ./mirrors
+ volumes:
+ - ./mirrors:/app
+ - ./data/cache:/app/cache
+ env_file:
+ - .env
+ networks:
+ - app
+ restart: unless-stopped
+ # for linux
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ caddy:
+ image: lightmirrors/caddy
+ build: ./caddy
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - ./caddy/Caddyfile:/etc/caddy/Caddyfile
+ - ./caddy/caddy_data:/data/caddy
+ env_file:
+ - .env
+ networks:
+ - app
+ restart: unless-stopped
+ aria2:
+ image: lightmirrors/aria2
+ build: ./aria2
+ volumes:
+ - ./aria2/aria2.conf:/aria2.conf
+ - ./data/cache:/app/cache
+ - ./data/aria2:/data/
+ networks:
+ - app
+ env_file:
+ - .env
+ restart: unless-stopped
+networks:
+ app:
+ driver: bridge
\ No newline at end of file
diff --git a/mirrors/Dockerfile b/mirrors/Dockerfile
new file mode 100644
index 0000000..aec3db5
--- /dev/null
+++ b/mirrors/Dockerfile
@@ -0,0 +1,13 @@
+FROM python:3.11-alpine
+
+ADD requirements.txt /app/requirements.txt
+
+RUN pip install -r /app/requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple/
+
+WORKDIR /app
+
+EXPOSE 8080
+
+CMD ["python", "server.py"]
+
+
diff --git a/mirrors/aria2_api.py b/mirrors/aria2_api.py
new file mode 100644
index 0000000..9b6ede4
--- /dev/null
+++ b/mirrors/aria2_api.py
@@ -0,0 +1,67 @@
+import json
+import logging
+import uuid
+
+import httpx
+
+from config import RPC_SECRET, ARIA2_RPC_URL
+
+logger = logging.getLogger(__name__)
+
+
+async def send_request(method, params=None):
+ payload = {
+ 'jsonrpc': '2.0',
+ 'id': uuid.uuid4().hex,
+ 'method': method,
+ 'params': [f'token:{RPC_SECRET}'] + (params or [])
+ }
+
+ # specify the internal API call don't use proxy
+ async with httpx.AsyncClient(mounts={
+ "all://": httpx.AsyncHTTPTransport()
+ }) as client:
+ response = await client.post(ARIA2_RPC_URL, json=payload)
+ logger.info(f"aria2 request: {method} {params} -> {response.status_code} {response.text}")
+ try:
+ return response.json()
+ except json.JSONDecodeError as e:
+ logger.warning(f"aria2 response: {response.status_code} {response.text}")
+ raise e
+
+
+async def add_download(url, save_dir='/app/cache'):
+ method = 'aria2.addUri'
+ params = [[url],
+ {'dir': save_dir,
+ 'header': []
+ }]
+ response = await send_request(method, params)
+ return response['result']
+
+
+async def pause_download(gid):
+ method = 'aria2.pause'
+ params = [gid]
+ response = await send_request(method, params)
+ return response['result']
+
+
+async def resume_download(gid):
+ method = 'aria2.unpause'
+ params = [gid]
+ response = await send_request(method, params)
+ return response['result']
+
+
+async def get_status(gid):
+ method = 'aria2.tellStatus'
+ params = [gid]
+ response = await send_request(method, params)
+ return response['result']
+
+
+async def list_downloads():
+ method = 'aria2.tellActive'
+ response = await send_request(method)
+ return response['result']
diff --git a/mirrors/config.py b/mirrors/config.py
new file mode 100644
index 0000000..ef42740
--- /dev/null
+++ b/mirrors/config.py
@@ -0,0 +1,11 @@
+import os
+
+ARIA2_RPC_URL = os.environ.get("ARIA2_RPC_URL", 'http://aria2:6800/jsonrpc')
+RPC_SECRET = os.environ.get("RPC_SECRET", '')
+BASE_DOMAIN = os.environ.get("BASE_DOMAIN", '127.0.0.1.nip.io')
+
+PROXY = os.environ.get("PROXY", None)
+CACHE_DIR = os.environ.get("CACHE_DIR", "/app/cache/")
+
+EXTERNAL_HOST_ARIA2 = f"aria2." + BASE_DOMAIN
+EXTERNAL_URL_ARIA2 = f"https://" + EXTERNAL_HOST_ARIA2
diff --git a/mirrors/poetry.lock b/mirrors/poetry.lock
new file mode 100644
index 0000000..c3dd002
--- /dev/null
+++ b/mirrors/poetry.lock
@@ -0,0 +1,344 @@
+# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
+
+[[package]]
+name = "annotated-types"
+version = "0.6.0"
+description = "Reusable constraint types to use with typing.Annotated"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"},
+ {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"},
+]
+
+[[package]]
+name = "anyio"
+version = "4.2.0"
+description = "High level compatibility layer for multiple asynchronous event loop implementations"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "anyio-4.2.0-py3-none-any.whl", hash = "sha256:745843b39e829e108e518c489b31dc757de7d2131d53fac32bd8df268227bfee"},
+ {file = "anyio-4.2.0.tar.gz", hash = "sha256:e1875bb4b4e2de1669f4bc7869b6d3f54231cdced71605e6e64c9be77e3be50f"},
+]
+
+[package.dependencies]
+exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""}
+idna = ">=2.8"
+sniffio = ">=1.1"
+typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""}
+
+[package.extras]
+doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
+test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"]
+trio = ["trio (>=0.23)"]
+
+[[package]]
+name = "certifi"
+version = "2024.2.2"
+description = "Python package for providing Mozilla's CA Bundle."
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"},
+ {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"},
+]
+
+[[package]]
+name = "click"
+version = "8.1.7"
+description = "Composable command line interface toolkit"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
+ {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.2.0"
+description = "Backport of PEP 654 (exception groups)"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"},
+ {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"},
+]
+
+[package.extras]
+test = ["pytest (>=6)"]
+
+[[package]]
+name = "fastapi"
+version = "0.109.0"
+description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "fastapi-0.109.0-py3-none-any.whl", hash = "sha256:8c77515984cd8e8cfeb58364f8cc7a28f0692088475e2614f7bf03275eba9093"},
+ {file = "fastapi-0.109.0.tar.gz", hash = "sha256:b978095b9ee01a5cf49b19f4bc1ac9b8ca83aa076e770ef8fd9af09a2b88d191"},
+]
+
+[package.dependencies]
+pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
+starlette = ">=0.35.0,<0.36.0"
+typing-extensions = ">=4.8.0"
+
+[package.extras]
+all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
+
+[[package]]
+name = "h11"
+version = "0.14.0"
+description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
+ {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.2"
+description = "A minimal low-level HTTP client."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7"},
+ {file = "httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535"},
+]
+
+[package.dependencies]
+certifi = "*"
+h11 = ">=0.13,<0.15"
+
+[package.extras]
+asyncio = ["anyio (>=4.0,<5.0)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (==1.*)"]
+trio = ["trio (>=0.22.0,<0.23.0)"]
+
+[[package]]
+name = "httpx"
+version = "0.26.0"
+description = "The next generation HTTP client."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd"},
+ {file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"},
+]
+
+[package.dependencies]
+anyio = "*"
+certifi = "*"
+httpcore = "==1.*"
+idna = "*"
+sniffio = "*"
+
+[package.extras]
+brotli = ["brotli", "brotlicffi"]
+cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (==1.*)"]
+
+[[package]]
+name = "idna"
+version = "3.6"
+description = "Internationalized Domain Names in Applications (IDNA)"
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"},
+ {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"},
+]
+
+[[package]]
+name = "pydantic"
+version = "2.6.0"
+description = "Data validation using Python type hints"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pydantic-2.6.0-py3-none-any.whl", hash = "sha256:1440966574e1b5b99cf75a13bec7b20e3512e8a61b894ae252f56275e2c465ae"},
+ {file = "pydantic-2.6.0.tar.gz", hash = "sha256:ae887bd94eb404b09d86e4d12f93893bdca79d766e738528c6fa1c849f3c6bcf"},
+]
+
+[package.dependencies]
+annotated-types = ">=0.4.0"
+pydantic-core = "2.16.1"
+typing-extensions = ">=4.6.1"
+
+[package.extras]
+email = ["email-validator (>=2.0.0)"]
+
+[[package]]
+name = "pydantic-core"
+version = "2.16.1"
+description = ""
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pydantic_core-2.16.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:300616102fb71241ff477a2cbbc847321dbec49428434a2f17f37528721c4948"},
+ {file = "pydantic_core-2.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5511f962dd1b9b553e9534c3b9c6a4b0c9ded3d8c2be96e61d56f933feef9e1f"},
+ {file = "pydantic_core-2.16.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98f0edee7ee9cc7f9221af2e1b95bd02810e1c7a6d115cfd82698803d385b28f"},
+ {file = "pydantic_core-2.16.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9795f56aa6b2296f05ac79d8a424e94056730c0b860a62b0fdcfe6340b658cc8"},
+ {file = "pydantic_core-2.16.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c45f62e4107ebd05166717ac58f6feb44471ed450d07fecd90e5f69d9bf03c48"},
+ {file = "pydantic_core-2.16.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:462d599299c5971f03c676e2b63aa80fec5ebc572d89ce766cd11ca8bcb56f3f"},
+ {file = "pydantic_core-2.16.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21ebaa4bf6386a3b22eec518da7d679c8363fb7fb70cf6972161e5542f470798"},
+ {file = "pydantic_core-2.16.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:99f9a50b56713a598d33bc23a9912224fc5d7f9f292444e6664236ae471ddf17"},
+ {file = "pydantic_core-2.16.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8ec364e280db4235389b5e1e6ee924723c693cbc98e9d28dc1767041ff9bc388"},
+ {file = "pydantic_core-2.16.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:653a5dfd00f601a0ed6654a8b877b18d65ac32c9d9997456e0ab240807be6cf7"},
+ {file = "pydantic_core-2.16.1-cp310-none-win32.whl", hash = "sha256:1661c668c1bb67b7cec96914329d9ab66755911d093bb9063c4c8914188af6d4"},
+ {file = "pydantic_core-2.16.1-cp310-none-win_amd64.whl", hash = "sha256:561be4e3e952c2f9056fba5267b99be4ec2afadc27261505d4992c50b33c513c"},
+ {file = "pydantic_core-2.16.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:102569d371fadc40d8f8598a59379c37ec60164315884467052830b28cc4e9da"},
+ {file = "pydantic_core-2.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:735dceec50fa907a3c314b84ed609dec54b76a814aa14eb90da31d1d36873a5e"},
+ {file = "pydantic_core-2.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e83ebbf020be727d6e0991c1b192a5c2e7113eb66e3def0cd0c62f9f266247e4"},
+ {file = "pydantic_core-2.16.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:30a8259569fbeec49cfac7fda3ec8123486ef1b729225222f0d41d5f840b476f"},
+ {file = "pydantic_core-2.16.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:920c4897e55e2881db6a6da151198e5001552c3777cd42b8a4c2f72eedc2ee91"},
+ {file = "pydantic_core-2.16.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5247a3d74355f8b1d780d0f3b32a23dd9f6d3ff43ef2037c6dcd249f35ecf4c"},
+ {file = "pydantic_core-2.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d5bea8012df5bb6dda1e67d0563ac50b7f64a5d5858348b5c8cb5043811c19d"},
+ {file = "pydantic_core-2.16.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ed3025a8a7e5a59817b7494686d449ebfbe301f3e757b852c8d0d1961d6be864"},
+ {file = "pydantic_core-2.16.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:06f0d5a1d9e1b7932477c172cc720b3b23c18762ed7a8efa8398298a59d177c7"},
+ {file = "pydantic_core-2.16.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:150ba5c86f502c040b822777e2e519b5625b47813bd05f9273a8ed169c97d9ae"},
+ {file = "pydantic_core-2.16.1-cp311-none-win32.whl", hash = "sha256:d6cbdf12ef967a6aa401cf5cdf47850559e59eedad10e781471c960583f25aa1"},
+ {file = "pydantic_core-2.16.1-cp311-none-win_amd64.whl", hash = "sha256:afa01d25769af33a8dac0d905d5c7bb2d73c7c3d5161b2dd6f8b5b5eea6a3c4c"},
+ {file = "pydantic_core-2.16.1-cp311-none-win_arm64.whl", hash = "sha256:1a2fe7b00a49b51047334d84aafd7e39f80b7675cad0083678c58983662da89b"},
+ {file = "pydantic_core-2.16.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f478ec204772a5c8218e30eb813ca43e34005dff2eafa03931b3d8caef87d51"},
+ {file = "pydantic_core-2.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1936ef138bed2165dd8573aa65e3095ef7c2b6247faccd0e15186aabdda7f66"},
+ {file = "pydantic_core-2.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99d3a433ef5dc3021c9534a58a3686c88363c591974c16c54a01af7efd741f13"},
+ {file = "pydantic_core-2.16.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd88f40f2294440d3f3c6308e50d96a0d3d0973d6f1a5732875d10f569acef49"},
+ {file = "pydantic_core-2.16.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fac641bbfa43d5a1bed99d28aa1fded1984d31c670a95aac1bf1d36ac6ce137"},
+ {file = "pydantic_core-2.16.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72bf9308a82b75039b8c8edd2be2924c352eda5da14a920551a8b65d5ee89253"},
+ {file = "pydantic_core-2.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb4363e6c9fc87365c2bc777a1f585a22f2f56642501885ffc7942138499bf54"},
+ {file = "pydantic_core-2.16.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:20f724a023042588d0f4396bbbcf4cffd0ddd0ad3ed4f0d8e6d4ac4264bae81e"},
+ {file = "pydantic_core-2.16.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fb4370b15111905bf8b5ba2129b926af9470f014cb0493a67d23e9d7a48348e8"},
+ {file = "pydantic_core-2.16.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23632132f1fd608034f1a56cc3e484be00854db845b3a4a508834be5a6435a6f"},
+ {file = "pydantic_core-2.16.1-cp312-none-win32.whl", hash = "sha256:b9f3e0bffad6e238f7acc20c393c1ed8fab4371e3b3bc311020dfa6020d99212"},
+ {file = "pydantic_core-2.16.1-cp312-none-win_amd64.whl", hash = "sha256:a0b4cfe408cd84c53bab7d83e4209458de676a6ec5e9c623ae914ce1cb79b96f"},
+ {file = "pydantic_core-2.16.1-cp312-none-win_arm64.whl", hash = "sha256:d195add190abccefc70ad0f9a0141ad7da53e16183048380e688b466702195dd"},
+ {file = "pydantic_core-2.16.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:502c062a18d84452858f8aea1e520e12a4d5228fc3621ea5061409d666ea1706"},
+ {file = "pydantic_core-2.16.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d8c032ccee90b37b44e05948b449a2d6baed7e614df3d3f47fe432c952c21b60"},
+ {file = "pydantic_core-2.16.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:920f4633bee43d7a2818e1a1a788906df5a17b7ab6fe411220ed92b42940f818"},
+ {file = "pydantic_core-2.16.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f5d37ff01edcbace53a402e80793640c25798fb7208f105d87a25e6fcc9ea06"},
+ {file = "pydantic_core-2.16.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:399166f24c33a0c5759ecc4801f040dbc87d412c1a6d6292b2349b4c505effc9"},
+ {file = "pydantic_core-2.16.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ac89ccc39cd1d556cc72d6752f252dc869dde41c7c936e86beac5eb555041b66"},
+ {file = "pydantic_core-2.16.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73802194f10c394c2bedce7a135ba1d8ba6cff23adf4217612bfc5cf060de34c"},
+ {file = "pydantic_core-2.16.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8fa00fa24ffd8c31fac081bf7be7eb495be6d248db127f8776575a746fa55c95"},
+ {file = "pydantic_core-2.16.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:601d3e42452cd4f2891c13fa8c70366d71851c1593ed42f57bf37f40f7dca3c8"},
+ {file = "pydantic_core-2.16.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07982b82d121ed3fc1c51faf6e8f57ff09b1325d2efccaa257dd8c0dd937acca"},
+ {file = "pydantic_core-2.16.1-cp38-none-win32.whl", hash = "sha256:d0bf6f93a55d3fa7a079d811b29100b019784e2ee6bc06b0bb839538272a5610"},
+ {file = "pydantic_core-2.16.1-cp38-none-win_amd64.whl", hash = "sha256:fbec2af0ebafa57eb82c18c304b37c86a8abddf7022955d1742b3d5471a6339e"},
+ {file = "pydantic_core-2.16.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a497be217818c318d93f07e14502ef93d44e6a20c72b04c530611e45e54c2196"},
+ {file = "pydantic_core-2.16.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:694a5e9f1f2c124a17ff2d0be613fd53ba0c26de588eb4bdab8bca855e550d95"},
+ {file = "pydantic_core-2.16.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d4dfc66abea3ec6d9f83e837a8f8a7d9d3a76d25c9911735c76d6745950e62c"},
+ {file = "pydantic_core-2.16.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8655f55fe68c4685673265a650ef71beb2d31871c049c8b80262026f23605ee3"},
+ {file = "pydantic_core-2.16.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21e3298486c4ea4e4d5cc6fb69e06fb02a4e22089304308817035ac006a7f506"},
+ {file = "pydantic_core-2.16.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71b4a48a7427f14679f0015b13c712863d28bb1ab700bd11776a5368135c7d60"},
+ {file = "pydantic_core-2.16.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dca874e35bb60ce4f9f6665bfbfad050dd7573596608aeb9e098621ac331dc"},
+ {file = "pydantic_core-2.16.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fa496cd45cda0165d597e9d6f01e36c33c9508f75cf03c0a650018c5048f578e"},
+ {file = "pydantic_core-2.16.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5317c04349472e683803da262c781c42c5628a9be73f4750ac7d13040efb5d2d"},
+ {file = "pydantic_core-2.16.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:42c29d54ed4501a30cd71015bf982fa95e4a60117b44e1a200290ce687d3e640"},
+ {file = "pydantic_core-2.16.1-cp39-none-win32.whl", hash = "sha256:ba07646f35e4e49376c9831130039d1b478fbfa1215ae62ad62d2ee63cf9c18f"},
+ {file = "pydantic_core-2.16.1-cp39-none-win_amd64.whl", hash = "sha256:2133b0e412a47868a358713287ff9f9a328879da547dc88be67481cdac529118"},
+ {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d25ef0c33f22649b7a088035fd65ac1ce6464fa2876578df1adad9472f918a76"},
+ {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:99c095457eea8550c9fa9a7a992e842aeae1429dab6b6b378710f62bfb70b394"},
+ {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b49c604ace7a7aa8af31196abbf8f2193be605db6739ed905ecaf62af31ccae0"},
+ {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c56da23034fe66221f2208c813d8aa509eea34d97328ce2add56e219c3a9f41c"},
+ {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cebf8d56fee3b08ad40d332a807ecccd4153d3f1ba8231e111d9759f02edfd05"},
+ {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:1ae8048cba95f382dba56766525abca438328455e35c283bb202964f41a780b0"},
+ {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:780daad9e35b18d10d7219d24bfb30148ca2afc309928e1d4d53de86822593dc"},
+ {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c94b5537bf6ce66e4d7830c6993152940a188600f6ae044435287753044a8fe2"},
+ {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:adf28099d061a25fbcc6531febb7a091e027605385de9fe14dd6a97319d614cf"},
+ {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:644904600c15816a1f9a1bafa6aab0d21db2788abcdf4e2a77951280473f33e1"},
+ {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87bce04f09f0552b66fca0c4e10da78d17cb0e71c205864bab4e9595122cb9d9"},
+ {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:877045a7969ace04d59516d5d6a7dee13106822f99a5d8df5e6822941f7bedc8"},
+ {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9c46e556ee266ed3fb7b7a882b53df3c76b45e872fdab8d9cf49ae5e91147fd7"},
+ {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4eebbd049008eb800f519578e944b8dc8e0f7d59a5abb5924cc2d4ed3a1834ff"},
+ {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:c0be58529d43d38ae849a91932391eb93275a06b93b79a8ab828b012e916a206"},
+ {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:b1fc07896fc1851558f532dffc8987e526b682ec73140886c831d773cef44b76"},
+ {file = "pydantic_core-2.16.1.tar.gz", hash = "sha256:daff04257b49ab7f4b3f73f98283d3dbb1a65bf3500d55c7beac3c66c310fe34"},
+]
+
+[package.dependencies]
+typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
+
+[[package]]
+name = "sniffio"
+version = "1.3.0"
+description = "Sniff out which async library your code is running under"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"},
+ {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"},
+]
+
+[[package]]
+name = "starlette"
+version = "0.35.1"
+description = "The little ASGI library that shines."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "starlette-0.35.1-py3-none-any.whl", hash = "sha256:50bbbda9baa098e361f398fda0928062abbaf1f54f4fadcbe17c092a01eb9a25"},
+ {file = "starlette-0.35.1.tar.gz", hash = "sha256:3e2639dac3520e4f58734ed22553f950d3f3cb1001cd2eaac4d57e8cdc5f66bc"},
+]
+
+[package.dependencies]
+anyio = ">=3.4.0,<5"
+typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""}
+
+[package.extras]
+full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"]
+
+[[package]]
+name = "typing-extensions"
+version = "4.9.0"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"},
+ {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"},
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.27.0.post1"
+description = "The lightning-fast ASGI server."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "uvicorn-0.27.0.post1-py3-none-any.whl", hash = "sha256:4b85ba02b8a20429b9b205d015cbeb788a12da527f731811b643fd739ef90d5f"},
+ {file = "uvicorn-0.27.0.post1.tar.gz", hash = "sha256:54898fcd80c13ff1cd28bf77b04ec9dbd8ff60c5259b499b4b12bb0917f22907"},
+]
+
+[package.dependencies]
+click = ">=7.0"
+h11 = ">=0.8"
+typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""}
+
+[package.extras]
+standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"]
+
+[metadata]
+lock-version = "2.0"
+python-versions = "^3.9"
+content-hash = "3f85c224110f9432dab85aaffc90d271b06475dfb7ab97e89d8817d2afeab223"
diff --git a/mirrors/proxy/cached.py b/mirrors/proxy/cached.py
new file mode 100644
index 0000000..b527ba4
--- /dev/null
+++ b/mirrors/proxy/cached.py
@@ -0,0 +1,107 @@
+import logging
+import os
+import pathlib
+import typing
+from asyncio import sleep
+from enum import Enum
+from urllib.parse import urlparse, quote
+
+import httpx
+from starlette.requests import Request
+from starlette.responses import Response
+from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR, HTTP_504_GATEWAY_TIMEOUT
+
+from aria2_api import add_download
+
+from config import CACHE_DIR, EXTERNAL_URL_ARIA2, PROXY
+
+logger = logging.getLogger(__name__)
+
+
+def get_cache_file_and_folder(url: str) -> typing.Tuple[str, str]:
+ parsed_url = urlparse(url)
+ base_dir = pathlib.Path(CACHE_DIR)
+ assert parsed_url.path[0] == "/"
+ assert parsed_url.path[-1] != "/"
+ cache_file = (base_dir / parsed_url.hostname / parsed_url.path[1:]).resolve()
+ assert cache_file.is_relative_to(base_dir)
+
+ return str(cache_file), os.path.dirname(cache_file)
+
+
+class DownloadingStatus(Enum):
+ DOWNLOADING = 1
+ DOWNLOADED = 2
+ NOT_FOUND = 3
+
+
+def lookup_cache(url: str) -> DownloadingStatus:
+ cache_file, _ = get_cache_file_and_folder(url)
+
+ cache_file_aria2 = f"{cache_file}.aria2"
+ if os.path.exists(cache_file_aria2):
+ return DownloadingStatus.DOWNLOADING
+
+ if os.path.exists(cache_file):
+ assert not os.path.isdir(cache_file)
+ return DownloadingStatus.DOWNLOADED
+ return DownloadingStatus.NOT_FOUND
+
+
+def make_cached_response(url):
+ cache_file, _ = get_cache_file_and_folder(url)
+
+ assert os.path.exists(cache_file)
+ assert not os.path.isdir(cache_file)
+ with open(cache_file, "rb") as f:
+ content = f.read()
+ return Response(content=content, status_code=200)
+
+
+async def get_url_content_length(url):
+ async with httpx.AsyncClient(proxy=PROXY, verify=False) as client:
+ head_response = await client.head(url)
+ content_len = (head_response.headers.get("content-length", None))
+ return content_len
+
+
+async def try_get_cache(
+ request: Request,
+ target_url: str,
+ download_wait_time: int = 60,
+ post_process: typing.Callable[[Request, Response], Response] = None,
+) -> typing.Optional[Response]:
+ cache_status = lookup_cache(target_url)
+ if cache_status == DownloadingStatus.DOWNLOADED:
+ resp = make_cached_response(target_url)
+ if post_process:
+ resp = post_process(request, resp)
+ return resp
+
+ if cache_status == DownloadingStatus.DOWNLOADING:
+ logger.info(f"Download is not finished, return 503 for {target_url}")
+ return Response(content=f"This file is downloading, view it at {EXTERNAL_URL_ARIA2}",
+ status_code=HTTP_504_GATEWAY_TIMEOUT)
+
+ assert cache_status == DownloadingStatus.NOT_FOUND
+
+ cache_file, cache_file_dir = get_cache_file_and_folder(target_url)
+ print("prepare to download", target_url, cache_file, cache_file_dir)
+
+ processed_url = quote(target_url, safe='/:?=&')
+
+ try:
+ await add_download(processed_url, save_dir=cache_file_dir)
+ except Exception as e:
+ logger.error(f"Download error, return 503500 for {target_url}", exc_info=e)
+ return Response(content=f"Failed to add download: {e}", status_code=HTTP_500_INTERNAL_SERVER_ERROR)
+
+ # wait for download finished
+ for _ in range(download_wait_time):
+ await sleep(1)
+ cache_status = lookup_cache(target_url)
+ if cache_status == DownloadingStatus.DOWNLOADED:
+ return make_cached_response(target_url)
+ logger.info(f"Download is not finished, return 503 for {target_url}")
+ return Response(content=f"This file is downloading, view it at {EXTERNAL_URL_ARIA2}",
+ status_code=HTTP_504_GATEWAY_TIMEOUT)
diff --git a/mirrors/proxy/direct.py b/mirrors/proxy/direct.py
new file mode 100644
index 0000000..aa42145
--- /dev/null
+++ b/mirrors/proxy/direct.py
@@ -0,0 +1,47 @@
+import typing
+
+import httpx
+import starlette.requests
+import starlette.responses
+
+from config import PROXY
+
+
+async def direct_proxy(
+ request: starlette.requests.Request,
+ target_url: str,
+ pre_process: typing.Callable[[starlette.requests.Request, httpx.Request], httpx.Request] = None,
+ post_process: typing.Callable[[starlette.requests.Request, httpx.Response], httpx.Response] = None,
+) -> typing.Optional[starlette.responses.Response]:
+ async with httpx.AsyncClient(proxy=PROXY, verify=False) as client:
+
+ headers = request.headers.mutablecopy()
+ for key in headers.keys():
+ if key not in ["user-agent", "accept"]:
+ del headers[key]
+
+ httpx_req = client.build_request(request.method, target_url, headers=headers, )
+
+ if pre_process:
+ httpx_req = pre_process(request, httpx_req)
+ upstream_response = await client.send(httpx_req)
+
+ # TODO: move to post_process
+ if upstream_response.status_code == 307:
+ location = upstream_response.headers["location"]
+ print("catch redirect", location)
+
+ headers = upstream_response.headers
+ cl = headers.pop("content-length", None)
+ ce = headers.pop("content-encoding", None)
+ # print(target_url, cl, ce)
+ content = upstream_response.content
+ response = starlette.responses.Response(
+ headers=headers,
+ content=content,
+ status_code=upstream_response.status_code)
+
+ if post_process:
+ response = post_process(request, response)
+
+ return response
diff --git a/mirrors/pyproject.toml b/mirrors/pyproject.toml
new file mode 100644
index 0000000..9c57e77
--- /dev/null
+++ b/mirrors/pyproject.toml
@@ -0,0 +1,17 @@
+[tool.poetry]
+name = "light-mirrors"
+version = "0.1.0"
+description = ""
+authors = ["'Anonymous' <'<>'>"]
+readme = "README.md"
+
+[tool.poetry.dependencies]
+python = "^3.9"
+fastapi = "^0.109.0"
+uvicorn = "^0.27.0.post1"
+httpx = "^0.26.0"
+
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
diff --git a/mirrors/requirements.txt b/mirrors/requirements.txt
new file mode 100644
index 0000000..9649bbb
--- /dev/null
+++ b/mirrors/requirements.txt
@@ -0,0 +1,128 @@
+annotated-types==0.6.0 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43 \
+ --hash=sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d
+anyio==4.2.0 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:745843b39e829e108e518c489b31dc757de7d2131d53fac32bd8df268227bfee \
+ --hash=sha256:e1875bb4b4e2de1669f4bc7869b6d3f54231cdced71605e6e64c9be77e3be50f
+certifi==2024.2.2 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f \
+ --hash=sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1
+click==8.1.7 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 \
+ --hash=sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de
+colorama==0.4.6 ; python_version >= "3.9" and python_version < "4.0" and platform_system == "Windows" \
+ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
+ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
+exceptiongroup==1.2.0 ; python_version >= "3.9" and python_version < "3.11" \
+ --hash=sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14 \
+ --hash=sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68
+fastapi==0.109.0 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:8c77515984cd8e8cfeb58364f8cc7a28f0692088475e2614f7bf03275eba9093 \
+ --hash=sha256:b978095b9ee01a5cf49b19f4bc1ac9b8ca83aa076e770ef8fd9af09a2b88d191
+h11==0.14.0 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d \
+ --hash=sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761
+httpcore==1.0.2 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7 \
+ --hash=sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535
+httpx==0.26.0 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf \
+ --hash=sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd
+idna==3.6 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \
+ --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f
+pydantic-core==2.16.1 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:06f0d5a1d9e1b7932477c172cc720b3b23c18762ed7a8efa8398298a59d177c7 \
+ --hash=sha256:07982b82d121ed3fc1c51faf6e8f57ff09b1325d2efccaa257dd8c0dd937acca \
+ --hash=sha256:0f478ec204772a5c8218e30eb813ca43e34005dff2eafa03931b3d8caef87d51 \
+ --hash=sha256:102569d371fadc40d8f8598a59379c37ec60164315884467052830b28cc4e9da \
+ --hash=sha256:10dca874e35bb60ce4f9f6665bfbfad050dd7573596608aeb9e098621ac331dc \
+ --hash=sha256:150ba5c86f502c040b822777e2e519b5625b47813bd05f9273a8ed169c97d9ae \
+ --hash=sha256:1661c668c1bb67b7cec96914329d9ab66755911d093bb9063c4c8914188af6d4 \
+ --hash=sha256:1a2fe7b00a49b51047334d84aafd7e39f80b7675cad0083678c58983662da89b \
+ --hash=sha256:1ae8048cba95f382dba56766525abca438328455e35c283bb202964f41a780b0 \
+ --hash=sha256:20f724a023042588d0f4396bbbcf4cffd0ddd0ad3ed4f0d8e6d4ac4264bae81e \
+ --hash=sha256:2133b0e412a47868a358713287ff9f9a328879da547dc88be67481cdac529118 \
+ --hash=sha256:21e3298486c4ea4e4d5cc6fb69e06fb02a4e22089304308817035ac006a7f506 \
+ --hash=sha256:21ebaa4bf6386a3b22eec518da7d679c8363fb7fb70cf6972161e5542f470798 \
+ --hash=sha256:23632132f1fd608034f1a56cc3e484be00854db845b3a4a508834be5a6435a6f \
+ --hash=sha256:2d5bea8012df5bb6dda1e67d0563ac50b7f64a5d5858348b5c8cb5043811c19d \
+ --hash=sha256:300616102fb71241ff477a2cbbc847321dbec49428434a2f17f37528721c4948 \
+ --hash=sha256:30a8259569fbeec49cfac7fda3ec8123486ef1b729225222f0d41d5f840b476f \
+ --hash=sha256:399166f24c33a0c5759ecc4801f040dbc87d412c1a6d6292b2349b4c505effc9 \
+ --hash=sha256:3fac641bbfa43d5a1bed99d28aa1fded1984d31c670a95aac1bf1d36ac6ce137 \
+ --hash=sha256:42c29d54ed4501a30cd71015bf982fa95e4a60117b44e1a200290ce687d3e640 \
+ --hash=sha256:462d599299c5971f03c676e2b63aa80fec5ebc572d89ce766cd11ca8bcb56f3f \
+ --hash=sha256:4eebbd049008eb800f519578e944b8dc8e0f7d59a5abb5924cc2d4ed3a1834ff \
+ --hash=sha256:502c062a18d84452858f8aea1e520e12a4d5228fc3621ea5061409d666ea1706 \
+ --hash=sha256:5317c04349472e683803da262c781c42c5628a9be73f4750ac7d13040efb5d2d \
+ --hash=sha256:5511f962dd1b9b553e9534c3b9c6a4b0c9ded3d8c2be96e61d56f933feef9e1f \
+ --hash=sha256:561be4e3e952c2f9056fba5267b99be4ec2afadc27261505d4992c50b33c513c \
+ --hash=sha256:601d3e42452cd4f2891c13fa8c70366d71851c1593ed42f57bf37f40f7dca3c8 \
+ --hash=sha256:644904600c15816a1f9a1bafa6aab0d21db2788abcdf4e2a77951280473f33e1 \
+ --hash=sha256:653a5dfd00f601a0ed6654a8b877b18d65ac32c9d9997456e0ab240807be6cf7 \
+ --hash=sha256:694a5e9f1f2c124a17ff2d0be613fd53ba0c26de588eb4bdab8bca855e550d95 \
+ --hash=sha256:71b4a48a7427f14679f0015b13c712863d28bb1ab700bd11776a5368135c7d60 \
+ --hash=sha256:72bf9308a82b75039b8c8edd2be2924c352eda5da14a920551a8b65d5ee89253 \
+ --hash=sha256:735dceec50fa907a3c314b84ed609dec54b76a814aa14eb90da31d1d36873a5e \
+ --hash=sha256:73802194f10c394c2bedce7a135ba1d8ba6cff23adf4217612bfc5cf060de34c \
+ --hash=sha256:780daad9e35b18d10d7219d24bfb30148ca2afc309928e1d4d53de86822593dc \
+ --hash=sha256:8655f55fe68c4685673265a650ef71beb2d31871c049c8b80262026f23605ee3 \
+ --hash=sha256:877045a7969ace04d59516d5d6a7dee13106822f99a5d8df5e6822941f7bedc8 \
+ --hash=sha256:87bce04f09f0552b66fca0c4e10da78d17cb0e71c205864bab4e9595122cb9d9 \
+ --hash=sha256:8d4dfc66abea3ec6d9f83e837a8f8a7d9d3a76d25c9911735c76d6745950e62c \
+ --hash=sha256:8ec364e280db4235389b5e1e6ee924723c693cbc98e9d28dc1767041ff9bc388 \
+ --hash=sha256:8fa00fa24ffd8c31fac081bf7be7eb495be6d248db127f8776575a746fa55c95 \
+ --hash=sha256:920c4897e55e2881db6a6da151198e5001552c3777cd42b8a4c2f72eedc2ee91 \
+ --hash=sha256:920f4633bee43d7a2818e1a1a788906df5a17b7ab6fe411220ed92b42940f818 \
+ --hash=sha256:9795f56aa6b2296f05ac79d8a424e94056730c0b860a62b0fdcfe6340b658cc8 \
+ --hash=sha256:98f0edee7ee9cc7f9221af2e1b95bd02810e1c7a6d115cfd82698803d385b28f \
+ --hash=sha256:99c095457eea8550c9fa9a7a992e842aeae1429dab6b6b378710f62bfb70b394 \
+ --hash=sha256:99d3a433ef5dc3021c9534a58a3686c88363c591974c16c54a01af7efd741f13 \
+ --hash=sha256:99f9a50b56713a598d33bc23a9912224fc5d7f9f292444e6664236ae471ddf17 \
+ --hash=sha256:9c46e556ee266ed3fb7b7a882b53df3c76b45e872fdab8d9cf49ae5e91147fd7 \
+ --hash=sha256:9f5d37ff01edcbace53a402e80793640c25798fb7208f105d87a25e6fcc9ea06 \
+ --hash=sha256:a0b4cfe408cd84c53bab7d83e4209458de676a6ec5e9c623ae914ce1cb79b96f \
+ --hash=sha256:a497be217818c318d93f07e14502ef93d44e6a20c72b04c530611e45e54c2196 \
+ --hash=sha256:ac89ccc39cd1d556cc72d6752f252dc869dde41c7c936e86beac5eb555041b66 \
+ --hash=sha256:adf28099d061a25fbcc6531febb7a091e027605385de9fe14dd6a97319d614cf \
+ --hash=sha256:afa01d25769af33a8dac0d905d5c7bb2d73c7c3d5161b2dd6f8b5b5eea6a3c4c \
+ --hash=sha256:b1fc07896fc1851558f532dffc8987e526b682ec73140886c831d773cef44b76 \
+ --hash=sha256:b49c604ace7a7aa8af31196abbf8f2193be605db6739ed905ecaf62af31ccae0 \
+ --hash=sha256:b9f3e0bffad6e238f7acc20c393c1ed8fab4371e3b3bc311020dfa6020d99212 \
+ --hash=sha256:ba07646f35e4e49376c9831130039d1b478fbfa1215ae62ad62d2ee63cf9c18f \
+ --hash=sha256:bd88f40f2294440d3f3c6308e50d96a0d3d0973d6f1a5732875d10f569acef49 \
+ --hash=sha256:c0be58529d43d38ae849a91932391eb93275a06b93b79a8ab828b012e916a206 \
+ --hash=sha256:c45f62e4107ebd05166717ac58f6feb44471ed450d07fecd90e5f69d9bf03c48 \
+ --hash=sha256:c56da23034fe66221f2208c813d8aa509eea34d97328ce2add56e219c3a9f41c \
+ --hash=sha256:c94b5537bf6ce66e4d7830c6993152940a188600f6ae044435287753044a8fe2 \
+ --hash=sha256:cebf8d56fee3b08ad40d332a807ecccd4153d3f1ba8231e111d9759f02edfd05 \
+ --hash=sha256:d0bf6f93a55d3fa7a079d811b29100b019784e2ee6bc06b0bb839538272a5610 \
+ --hash=sha256:d195add190abccefc70ad0f9a0141ad7da53e16183048380e688b466702195dd \
+ --hash=sha256:d25ef0c33f22649b7a088035fd65ac1ce6464fa2876578df1adad9472f918a76 \
+ --hash=sha256:d6cbdf12ef967a6aa401cf5cdf47850559e59eedad10e781471c960583f25aa1 \
+ --hash=sha256:d8c032ccee90b37b44e05948b449a2d6baed7e614df3d3f47fe432c952c21b60 \
+ --hash=sha256:daff04257b49ab7f4b3f73f98283d3dbb1a65bf3500d55c7beac3c66c310fe34 \
+ --hash=sha256:e83ebbf020be727d6e0991c1b192a5c2e7113eb66e3def0cd0c62f9f266247e4 \
+ --hash=sha256:ed3025a8a7e5a59817b7494686d449ebfbe301f3e757b852c8d0d1961d6be864 \
+ --hash=sha256:f1936ef138bed2165dd8573aa65e3095ef7c2b6247faccd0e15186aabdda7f66 \
+ --hash=sha256:f5247a3d74355f8b1d780d0f3b32a23dd9f6d3ff43ef2037c6dcd249f35ecf4c \
+ --hash=sha256:fa496cd45cda0165d597e9d6f01e36c33c9508f75cf03c0a650018c5048f578e \
+ --hash=sha256:fb4363e6c9fc87365c2bc777a1f585a22f2f56642501885ffc7942138499bf54 \
+ --hash=sha256:fb4370b15111905bf8b5ba2129b926af9470f014cb0493a67d23e9d7a48348e8 \
+ --hash=sha256:fbec2af0ebafa57eb82c18c304b37c86a8abddf7022955d1742b3d5471a6339e
+pydantic==2.6.0 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:1440966574e1b5b99cf75a13bec7b20e3512e8a61b894ae252f56275e2c465ae \
+ --hash=sha256:ae887bd94eb404b09d86e4d12f93893bdca79d766e738528c6fa1c849f3c6bcf
+sniffio==1.3.0 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101 \
+ --hash=sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384
+starlette==0.35.1 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:3e2639dac3520e4f58734ed22553f950d3f3cb1001cd2eaac4d57e8cdc5f66bc \
+ --hash=sha256:50bbbda9baa098e361f398fda0928062abbaf1f54f4fadcbe17c092a01eb9a25
+typing-extensions==4.9.0 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783 \
+ --hash=sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd
+uvicorn==0.27.0.post1 ; python_version >= "3.9" and python_version < "4.0" \
+ --hash=sha256:4b85ba02b8a20429b9b205d015cbeb788a12da527f731811b643fd739ef90d5f \
+ --hash=sha256:54898fcd80c13ff1cd28bf77b04ec9dbd8ff60c5259b499b4b12bb0917f22907
diff --git a/mirrors/server.py b/mirrors/server.py
new file mode 100644
index 0000000..5c4a636
--- /dev/null
+++ b/mirrors/server.py
@@ -0,0 +1,58 @@
+import base64
+import signal
+import urllib.parse
+
+import uvicorn
+from fastapi import FastAPI
+from starlette.requests import Request
+
+from config import BASE_DOMAIN, RPC_SECRET, EXTERNAL_URL_ARIA2, EXTERNAL_HOST_ARIA2
+from sites.docker import docker
+from sites.npm import npm
+from sites.pypi import pypi
+from sites.torch import torch
+
+app = FastAPI()
+
+
+@app.middleware("http")
+async def capture_request(request: Request, call_next: callable):
+ hostname = request.url.hostname
+ if not hostname.endswith(f".{BASE_DOMAIN}"):
+ return await call_next(request)
+
+ if hostname.startswith("pypi."):
+ return await pypi(request)
+ if hostname.startswith("torch."):
+ return await torch(request)
+ if hostname.startswith("docker."):
+ return await docker(request)
+ if hostname.startswith("npm."):
+ return await npm(request)
+
+ return await call_next(request)
+
+
+if __name__ == '__main__':
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
+ port = 8080
+ print(f"Server started at https://*.{BASE_DOMAIN})")
+
+ for dn in ["pypi", "torch", "docker", "npm"]:
+ print(f" - https://{dn}.{BASE_DOMAIN}")
+
+ aria2_secret = base64.b64encode(RPC_SECRET.encode()).decode()
+
+ params = {
+ 'protocol': 'https',
+ 'host': EXTERNAL_HOST_ARIA2,
+ 'port': '443',
+ 'interface': 'jsonrpc',
+ 'secret': aria2_secret
+ }
+
+ query_string = urllib.parse.urlencode(params)
+ aria2_url_with_auth = EXTERNAL_URL_ARIA2 + "/#!/settings/rpc/set?" + query_string
+
+ print(f"Download manager (Aria2) at {aria2_url_with_auth}")
+ uvicorn.run(app="server:app", host="0.0.0.0", port=port, reload=True, proxy_headers=True, forwarded_allow_ips="*")
diff --git a/mirrors/sites/docker.py b/mirrors/sites/docker.py
new file mode 100644
index 0000000..4ab4c2c
--- /dev/null
+++ b/mirrors/sites/docker.py
@@ -0,0 +1,100 @@
+import base64
+import json
+import re
+import time
+
+import httpx
+from starlette.requests import Request
+from starlette.responses import Response
+
+from proxy.direct import direct_proxy
+
+BASE_URL = "https://registry-1.docker.io"
+
+cached_token = {
+
+}
+
+# https://github.com/opencontainers/distribution-spec/blob/main/spec.md
+name_regex = "[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*(\/[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*)*"
+reference_regex = "[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}"
+
+
+def try_extract_image_name(path):
+ pattern = rf"^/v2/(.*)/([a-zA-Z]+)/(.*)$"
+ match = re.search(pattern, path)
+
+ if match:
+ assert len(match.groups()) == 3
+ name, operation, reference = match.groups()
+ assert re.match(name_regex, name)
+ assert re.match(reference_regex, reference)
+ assert operation in ["manifests", "blobs"]
+ return name, operation, reference
+
+ return None, None, None
+
+
+def get_docker_token(name):
+ cached = cached_token.get(name, {})
+ exp = cached.get("exp", 0)
+
+ if exp > time.time():
+ return cached.get("token", 0)
+
+ url = "https://auth.docker.io/token"
+ params = {
+ "scope": f"repository:{name}:pull",
+ "service": "registry.docker.io",
+ }
+
+ response = httpx.get(url, params=params, verify=False)
+ response.raise_for_status()
+
+ token_data = response.json()
+ token = token_data["token"]
+ payload = (token.split(".")[1])
+ padding = len(payload) % 4
+ payload += "=" * padding
+
+ payload = json.loads(base64.b64decode(payload))
+ assert payload["iss"] == "auth.docker.io"
+ assert len(payload["access"]) > 0
+
+ cached_token[name] = {
+ "exp": payload["exp"],
+ "token": token
+ }
+
+ return token
+
+
+async def docker(request: Request):
+ path = request.url.path
+ print("[request]", request.method, request.url)
+ if not path.startswith("/v2/"):
+ return Response(content="Not Found", status_code=404)
+
+ if path == "/v2/":
+ return Response(content="OK")
+ # return await direct_proxy(request, BASE_URL + '/v2/')
+
+ name, operation, reference = try_extract_image_name(path)
+
+ if not name:
+ return Response(content='404 Not Found', status_code=404)
+
+ # support docker pull xxx which name without library
+ if '/' not in name:
+ name = f"library/{name}"
+
+ target_url = BASE_URL + f"/v2/{name}/{operation}/{reference}"
+
+ print('[PARSED]', path, name, operation, reference, target_url)
+
+ def inject_token(req, httpx_req):
+ docker_token = get_docker_token(f"{name}")
+ httpx_req.headers["Authorization"] = f"Bearer {docker_token}"
+ return httpx_req
+
+ return await direct_proxy(request, target_url, pre_process=inject_token)
diff --git a/mirrors/sites/npm.py b/mirrors/sites/npm.py
new file mode 100644
index 0000000..8912619
--- /dev/null
+++ b/mirrors/sites/npm.py
@@ -0,0 +1,11 @@
+from starlette.requests import Request
+
+from proxy.direct import direct_proxy
+
+BASE_URL = "https://registry.npmjs.org/"
+
+
+async def npm(request: Request):
+ path = request.url.path
+
+ return await direct_proxy(request, BASE_URL + path)
diff --git a/mirrors/sites/pypi.py b/mirrors/sites/pypi.py
new file mode 100644
index 0000000..a85f3e5
--- /dev/null
+++ b/mirrors/sites/pypi.py
@@ -0,0 +1,44 @@
+import re
+
+from starlette.requests import Request
+from starlette.responses import Response
+
+from proxy.direct import direct_proxy
+from proxy.cached import try_get_cache
+
+pypi_file_base_url = "https://files.pythonhosted.org"
+pypi_base_url = "https://pypi.org"
+
+
+def pypi_replace(request: Request, response: Response) -> Response:
+ is_detail_page = re.search(r"/simple/([^/]+)/", request.url.path) is not None
+ if not is_detail_page:
+ return response
+
+ if is_detail_page:
+ mirror_url = f"{request.url.scheme}://{request.url.netloc}"
+ content = response.body
+ content = content.replace(pypi_file_base_url.encode(), mirror_url.encode())
+ response.body = content
+ del response.headers["content-length"]
+ del response.headers["content-encoding"]
+ return response
+
+
+async def pypi(request: Request) -> Response:
+ # TODO: a debug flag to show origin url
+ path = request.url.path
+ if path == "/simple":
+ path = "/simple/"
+
+ if path.startswith("/simple/"):
+ target_url = pypi_base_url + path
+ elif path.startswith("/packages/"):
+ target_url = pypi_file_base_url + path
+ else:
+ return Response(content="Not Found", status_code=404)
+
+ if path.endswith(".whl") or path.endswith(".tar.gz"):
+ return await try_get_cache(request, target_url)
+
+ return await direct_proxy(request, target_url, post_process=pypi_replace)
diff --git a/mirrors/sites/torch.py b/mirrors/sites/torch.py
new file mode 100644
index 0000000..17a2c04
--- /dev/null
+++ b/mirrors/sites/torch.py
@@ -0,0 +1,24 @@
+from starlette.requests import Request
+from starlette.responses import Response
+
+from proxy.cached import try_get_cache
+from proxy.direct import direct_proxy
+
+BASE_URL = "https://download.pytorch.org"
+
+
+async def torch(request: Request):
+ path = request.url.path
+
+ if not path.startswith("/whl/"):
+ return Response(content="Not Found", status_code=404)
+
+ if path == "/whl":
+ path = "/whl/"
+
+ target_url = BASE_URL + path
+
+ if path.endswith(".whl") or path.endswith(".tar.gz"):
+ return await try_get_cache(request, target_url)
+
+ return await direct_proxy(request, target_url, )