Spaces:
Running
Running
Upload 11 files
Browse files- app.py +11 -14
- media_file_response.py +115 -0
- media_proxy_service.py +7 -0
- proxy_download_work.py +2 -2
- response_privacy.py +24 -0
- router_proxy.py +2 -0
- safe_http_transport.py +32 -7
- workflow_download_service.py +1 -0
- workflow_stream_response.py +11 -4
app.py
CHANGED
|
@@ -4,7 +4,7 @@ install_uvicorn_privacy_filters()
|
|
| 4 |
|
| 5 |
from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException, Request
|
| 6 |
from rate_limit import shared_limit
|
| 7 |
-
from response_privacy import
|
| 8 |
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
from fastapi.responses import Response, JSONResponse, FileResponse
|
| 10 |
from sqlalchemy.orm import Session
|
|
@@ -74,6 +74,7 @@ from router_messages import (router as messages_router, start_message_io, stop_m
|
|
| 74 |
from router_wallet import (router as wallet_router, start_wallet_sql, stop_wallet_sql, close_wallet_sql) # 💰 钱包/提现
|
| 75 |
from proxy_access import start_proxy_access, stop_proxy_access, close_proxy_access
|
| 76 |
from proxy_download_work import start_proxy_downloads, stop_proxy_downloads, close_proxy_downloads
|
|
|
|
| 77 |
from router_proxy import router as proxy_router # 🔗 代理下载
|
| 78 |
from router_posts import (router as posts_router, start_posts_io, stop_posts_io, close_posts_io) # 💬 讨论区
|
| 79 |
from router_prompts import (router as prompts_router, start_prompts_io, stop_prompts_io, close_prompts_io) # ✨ 提示词市场
|
|
@@ -170,16 +171,7 @@ app.add_middleware(UploadBodyGuard, capacity=_UPLOAD_CONCURRENCY, error_response
|
|
| 170 |
from public_list_etag import PublicListETag
|
| 171 |
app.add_middleware(PublicListETag)
|
| 172 |
|
| 173 |
-
|
| 174 |
-
async def private_response_cache_control(request: Request, call_next):
|
| 175 |
-
response = await call_next(request)
|
| 176 |
-
if requires_no_store(request.url.path, request.headers.get("Authorization")):
|
| 177 |
-
response.headers["Cache-Control"] = "no-store"
|
| 178 |
-
vary = [name.strip() for name in response.headers.get("Vary", "").split(",") if name.strip()]
|
| 179 |
-
if not any(name.lower() in {"authorization", "*"} for name in vary):
|
| 180 |
-
vary.append("Authorization")
|
| 181 |
-
response.headers["Vary"] = ", ".join(vary)
|
| 182 |
-
return response
|
| 183 |
|
| 184 |
|
| 185 |
@app.exception_handler(RequestValidationError)
|
|
@@ -327,6 +319,7 @@ async def on_startup():
|
|
| 327 |
start_tasks_io()
|
| 328 |
start_proxy_access()
|
| 329 |
start_proxy_downloads()
|
|
|
|
| 330 |
start_comment_reads()
|
| 331 |
start_admin_scripts()
|
| 332 |
|
|
@@ -387,6 +380,7 @@ async def _shutdown_resources():
|
|
| 387 |
for stage, stop in (
|
| 388 |
('stop_wallet_sql', stop_wallet_sql),
|
| 389 |
('stop_proxy_downloads', stop_proxy_downloads),
|
|
|
|
| 390 |
('stop_resource_reads', stop_resource_reads), ('stop_comment_writes', stop_comment_writes),
|
| 391 |
('stop_posts', stop_posts_io), ('stop_prompts', stop_prompts_io), ('stop_tasks', stop_tasks_io), ('stop_proxy_access', stop_proxy_access),
|
| 392 |
('stop_messages', stop_message_io), ('stop_resources', stop_resource_work), ('stop_open_sql', stop_open_sql), ('stop_config_sql', stop_config_sql), ('stop_shared', stop_shared_work),
|
|
@@ -404,6 +398,7 @@ async def _shutdown_resources():
|
|
| 404 |
for stage, close, args in (
|
| 405 |
('wait_warmup', wait_startup_warmup, (app.state,)),
|
| 406 |
('close_proxy_downloads', close_proxy_downloads, ()),
|
|
|
|
| 407 |
('close_wallet_sql', close_wallet_sql, ()),
|
| 408 |
('close_resource_reads', close_resource_reads, ()), ('close_comment_writes', close_comment_writes, ()),
|
| 409 |
('close_posts', close_posts_io, ()), ('close_prompts', close_prompts_io, ()), ('close_tasks', close_tasks_io, ()), ('close_proxy_access', close_proxy_access, ()),
|
|
@@ -465,11 +460,13 @@ app.include_router(admin_plugins_router) # 🔒 管理后台 - 第三方插件
|
|
| 465 |
# 解决 Private 仓库下,本地客户端报 401 Unauthorized 的终极方案
|
| 466 |
# ==========================================
|
| 467 |
from media_proxy_service import read_public_media
|
|
|
|
| 468 |
|
| 469 |
@app.get("/api/image_proxy")
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
|
|
|
| 473 |
|
| 474 |
# ==========================================
|
| 475 |
# 上传接口 (将返回的 URL 替换为 Proxy 代理链接)
|
|
|
|
| 4 |
|
| 5 |
from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException, Request
|
| 6 |
from rate_limit import shared_limit
|
| 7 |
+
from response_privacy import PrivateResponseCacheControl
|
| 8 |
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
from fastapi.responses import Response, JSONResponse, FileResponse
|
| 10 |
from sqlalchemy.orm import Session
|
|
|
|
| 74 |
from router_wallet import (router as wallet_router, start_wallet_sql, stop_wallet_sql, close_wallet_sql) # 💰 钱包/提现
|
| 75 |
from proxy_access import start_proxy_access, stop_proxy_access, close_proxy_access
|
| 76 |
from proxy_download_work import start_proxy_downloads, stop_proxy_downloads, close_proxy_downloads
|
| 77 |
+
from media_proxy_service import start_public_media, stop_public_media, close_public_media, public_media_endpoint
|
| 78 |
from router_proxy import router as proxy_router # 🔗 代理下载
|
| 79 |
from router_posts import (router as posts_router, start_posts_io, stop_posts_io, close_posts_io) # 💬 讨论区
|
| 80 |
from router_prompts import (router as prompts_router, start_prompts_io, stop_prompts_io, close_prompts_io) # ✨ 提示词市场
|
|
|
|
| 171 |
from public_list_etag import PublicListETag
|
| 172 |
app.add_middleware(PublicListETag)
|
| 173 |
|
| 174 |
+
app.add_middleware(PrivateResponseCacheControl)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
|
| 177 |
@app.exception_handler(RequestValidationError)
|
|
|
|
| 319 |
start_tasks_io()
|
| 320 |
start_proxy_access()
|
| 321 |
start_proxy_downloads()
|
| 322 |
+
start_public_media()
|
| 323 |
start_comment_reads()
|
| 324 |
start_admin_scripts()
|
| 325 |
|
|
|
|
| 380 |
for stage, stop in (
|
| 381 |
('stop_wallet_sql', stop_wallet_sql),
|
| 382 |
('stop_proxy_downloads', stop_proxy_downloads),
|
| 383 |
+
('stop_public_media', stop_public_media),
|
| 384 |
('stop_resource_reads', stop_resource_reads), ('stop_comment_writes', stop_comment_writes),
|
| 385 |
('stop_posts', stop_posts_io), ('stop_prompts', stop_prompts_io), ('stop_tasks', stop_tasks_io), ('stop_proxy_access', stop_proxy_access),
|
| 386 |
('stop_messages', stop_message_io), ('stop_resources', stop_resource_work), ('stop_open_sql', stop_open_sql), ('stop_config_sql', stop_config_sql), ('stop_shared', stop_shared_work),
|
|
|
|
| 398 |
for stage, close, args in (
|
| 399 |
('wait_warmup', wait_startup_warmup, (app.state,)),
|
| 400 |
('close_proxy_downloads', close_proxy_downloads, ()),
|
| 401 |
+
('close_public_media', close_public_media, ()),
|
| 402 |
('close_wallet_sql', close_wallet_sql, ()),
|
| 403 |
('close_resource_reads', close_resource_reads, ()), ('close_comment_writes', close_comment_writes, ()),
|
| 404 |
('close_posts', close_posts_io, ()), ('close_prompts', close_prompts_io, ()), ('close_tasks', close_tasks_io, ()), ('close_proxy_access', close_proxy_access, ()),
|
|
|
|
| 460 |
# 解决 Private 仓库下,本地客户端报 401 Unauthorized 的终极方案
|
| 461 |
# ==========================================
|
| 462 |
from media_proxy_service import read_public_media
|
| 463 |
+
from media_file_response import OwnedMediaFileResponse
|
| 464 |
|
| 465 |
@app.get("/api/image_proxy")
|
| 466 |
+
@public_media_endpoint
|
| 467 |
+
async def proxy_hf_image(url: str = None, path: str = None):
|
| 468 |
+
return await run_owned_thread(read_public_media, url, path, store=db, download=hf_hub_download,
|
| 469 |
+
file_response=OwnedMediaFileResponse, error_response=create_error_response)
|
| 470 |
|
| 471 |
# ==========================================
|
| 472 |
# 上传接口 (将返回的 URL 替换为 Proxy 代理链接)
|
media_file_response.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""媒体缓存文件的有界读取、Range交付与真实线程关闭。"""
|
| 2 |
+
import asyncio
|
| 3 |
+
import os
|
| 4 |
+
from secrets import token_hex
|
| 5 |
+
import stat
|
| 6 |
+
|
| 7 |
+
from fastapi.responses import FileResponse
|
| 8 |
+
from starlette.datastructures import MutableHeaders
|
| 9 |
+
from owned_thread import run_owned_thread
|
| 10 |
+
from workflow_stream_response import OwnedDelivery
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class OwnedMediaFileResponse(OwnedDelivery, FileResponse):
|
| 14 |
+
total_timeout = 300
|
| 15 |
+
operation_timeout = 15
|
| 16 |
+
eof_timeout = 5
|
| 17 |
+
|
| 18 |
+
def __init__(self, path, **kwargs):
|
| 19 |
+
snapshot = os.stat(path)
|
| 20 |
+
if not stat.S_ISREG(snapshot.st_mode):
|
| 21 |
+
raise ValueError('invalid media cache file')
|
| 22 |
+
super().__init__(path, stat_result=snapshot, **kwargs)
|
| 23 |
+
self._file = None
|
| 24 |
+
self._init_delivery(self._close_file)
|
| 25 |
+
|
| 26 |
+
async def send_owned(self, scope, receive, send):
|
| 27 |
+
async def bounded_send(message):
|
| 28 |
+
final = message['type'] == 'http.response.body' and not message.get('more_body', False)
|
| 29 |
+
async with asyncio.timeout(self.eof_timeout if final else self.operation_timeout):
|
| 30 |
+
await send(message)
|
| 31 |
+
async with asyncio.timeout(self.total_timeout):
|
| 32 |
+
return await super().send_owned(scope, receive, bounded_send)
|
| 33 |
+
|
| 34 |
+
async def _file_work(self, function, *args):
|
| 35 |
+
async with asyncio.timeout(self.operation_timeout):
|
| 36 |
+
return await run_owned_thread(function, *args)
|
| 37 |
+
|
| 38 |
+
async def _open_file(self):
|
| 39 |
+
def open_file():
|
| 40 |
+
self._file = open(self.path, 'rb')
|
| 41 |
+
self._check_snapshot(os.fstat(self._file.fileno()))
|
| 42 |
+
await self._file_work(open_file)
|
| 43 |
+
|
| 44 |
+
def _check_snapshot(self, current):
|
| 45 |
+
fields = ('st_dev', 'st_ino', 'st_size', 'st_mtime_ns')
|
| 46 |
+
if os.name != 'nt':
|
| 47 |
+
fields += ('st_ctime_ns',)
|
| 48 |
+
if (not stat.S_ISREG(current.st_mode) or not current.st_ino or not self.stat_result.st_ino
|
| 49 |
+
or any(getattr(current, key) != getattr(self.stat_result, key) for key in fields)):
|
| 50 |
+
raise RuntimeError('media cache file changed')
|
| 51 |
+
|
| 52 |
+
async def _prepare_file(self, header_only):
|
| 53 |
+
if header_only:
|
| 54 |
+
current = await self._file_work(os.stat, self.path)
|
| 55 |
+
self._check_snapshot(current)
|
| 56 |
+
else:
|
| 57 |
+
await self._open_file()
|
| 58 |
+
|
| 59 |
+
async def _close_file(self):
|
| 60 |
+
if self._file is None:
|
| 61 |
+
return
|
| 62 |
+
def close_file():
|
| 63 |
+
try:
|
| 64 |
+
self._file.close()
|
| 65 |
+
finally:
|
| 66 |
+
self._file = None
|
| 67 |
+
await run_owned_thread(close_file)
|
| 68 |
+
|
| 69 |
+
async def _send_part(self, send, start, end, *, final):
|
| 70 |
+
await self._file_work(self._file.seek, start)
|
| 71 |
+
remaining = end - start
|
| 72 |
+
if remaining == 0:
|
| 73 |
+
await send({'type': 'http.response.body', 'body': b'', 'more_body': not final})
|
| 74 |
+
while remaining:
|
| 75 |
+
chunk = await self._file_work(self._file.read, min(self.chunk_size, remaining))
|
| 76 |
+
if not chunk:
|
| 77 |
+
raise RuntimeError('media cache file truncated')
|
| 78 |
+
remaining -= len(chunk)
|
| 79 |
+
await send({'type': 'http.response.body', 'body': chunk, 'more_body': remaining > 0 or not final})
|
| 80 |
+
|
| 81 |
+
async def _handle_simple(self, send, send_header_only, send_pathsend):
|
| 82 |
+
await self._prepare_file(send_header_only)
|
| 83 |
+
await send({'type': 'http.response.start', 'status': self.status_code, 'headers': self.raw_headers})
|
| 84 |
+
if send_header_only:
|
| 85 |
+
await send({'type': 'http.response.body', 'body': b'', 'more_body': False})
|
| 86 |
+
return
|
| 87 |
+
await self._send_part(send, 0, self.stat_result.st_size, final=True)
|
| 88 |
+
|
| 89 |
+
async def _handle_single_range(self, send, start, end, file_size, send_header_only):
|
| 90 |
+
await self._prepare_file(send_header_only)
|
| 91 |
+
headers = MutableHeaders(raw=list(self.raw_headers))
|
| 92 |
+
headers['content-range'] = f'bytes {start}-{end - 1}/{file_size}'
|
| 93 |
+
headers['content-length'] = str(end - start)
|
| 94 |
+
await send({'type': 'http.response.start', 'status': 206, 'headers': headers.raw})
|
| 95 |
+
if send_header_only:
|
| 96 |
+
await send({'type': 'http.response.body', 'body': b'', 'more_body': False})
|
| 97 |
+
return
|
| 98 |
+
await self._send_part(send, start, end, final=True)
|
| 99 |
+
|
| 100 |
+
async def _handle_multiple_ranges(self, send, ranges, file_size, send_header_only):
|
| 101 |
+
await self._prepare_file(send_header_only)
|
| 102 |
+
boundary = token_hex(13)
|
| 103 |
+
length, header = self.generate_multipart(ranges, boundary, file_size, self.headers['content-type'])
|
| 104 |
+
headers = MutableHeaders(raw=list(self.raw_headers))
|
| 105 |
+
headers['content-type'] = f'multipart/byteranges; boundary={boundary}'
|
| 106 |
+
headers['content-length'] = str(length)
|
| 107 |
+
await send({'type': 'http.response.start', 'status': 206, 'headers': headers.raw})
|
| 108 |
+
if send_header_only:
|
| 109 |
+
await send({'type': 'http.response.body', 'body': b'', 'more_body': False})
|
| 110 |
+
return
|
| 111 |
+
for start, end in ranges:
|
| 112 |
+
await send({'type': 'http.response.body', 'body': header(start, end), 'more_body': True})
|
| 113 |
+
await self._send_part(send, start, end, final=False)
|
| 114 |
+
await send({'type': 'http.response.body', 'body': b'\r\n', 'more_body': True})
|
| 115 |
+
await send({'type': 'http.response.body', 'body': f'--{boundary}--'.encode('latin-1'), 'more_body': False})
|
media_proxy_service.py
CHANGED
|
@@ -7,6 +7,13 @@ import urllib.parse
|
|
| 7 |
import uuid
|
| 8 |
from fastapi import HTTPException
|
| 9 |
from fastapi.responses import JSONResponse
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
logger = logging.getLogger("ComfyUI-Ranking")
|
| 12 |
|
|
|
|
| 7 |
import uuid
|
| 8 |
from fastapi import HTTPException
|
| 9 |
from fastapi.responses import JSONResponse
|
| 10 |
+
from proxy_download_work import ProxyDownloadWork
|
| 11 |
+
|
| 12 |
+
_public_media_work = ProxyDownloadWork(16)
|
| 13 |
+
public_media_endpoint = _public_media_work.endpoint
|
| 14 |
+
start_public_media = _public_media_work.start
|
| 15 |
+
stop_public_media = _public_media_work.stop
|
| 16 |
+
close_public_media = _public_media_work.close
|
| 17 |
|
| 18 |
logger = logging.getLogger("ComfyUI-Ranking")
|
| 19 |
|
proxy_download_work.py
CHANGED
|
@@ -3,7 +3,7 @@ import asyncio
|
|
| 3 |
from functools import wraps
|
| 4 |
|
| 5 |
from fastapi import HTTPException
|
| 6 |
-
from workflow_stream_response import
|
| 7 |
|
| 8 |
|
| 9 |
def _spawn(function):
|
|
@@ -58,7 +58,7 @@ class _Download:
|
|
| 58 |
|
| 59 |
async def prepare(self, function, args, kwargs):
|
| 60 |
result = await function(*args, **kwargs)
|
| 61 |
-
if isinstance(result,
|
| 62 |
result.bind_download(self)
|
| 63 |
self.response = result
|
| 64 |
return result
|
|
|
|
| 3 |
from functools import wraps
|
| 4 |
|
| 5 |
from fastapi import HTTPException
|
| 6 |
+
from workflow_stream_response import OwnedDelivery
|
| 7 |
|
| 8 |
|
| 9 |
def _spawn(function):
|
|
|
|
| 58 |
|
| 59 |
async def prepare(self, function, args, kwargs):
|
| 60 |
result = await function(*args, **kwargs)
|
| 61 |
+
if isinstance(result, OwnedDelivery):
|
| 62 |
result.bind_download(self)
|
| 63 |
self.response = result
|
| 64 |
return result
|
response_privacy.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""浏览器/共享 HTTP 缓存的私有响应边界,无数据库或应用依赖。"""
|
| 2 |
import re
|
|
|
|
| 3 |
|
| 4 |
_PRIVATE_PATH = re.compile(
|
| 5 |
r"^/api/(wallet|messages|chats|admin|disputes|users)(?:/|$)"
|
|
@@ -12,3 +13,26 @@ def requires_no_store(path, authorization=None):
|
|
| 12 |
# 含 Authorization 的响应即使路径公开也不交给共享 HTTP 缓存。
|
| 13 |
# 匿名访问私有路径的错误响应及登录返回的 Token 同样不能缓存。
|
| 14 |
return bool(authorization or _PRIVATE_PATH.search(path))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""浏览器/共享 HTTP 缓存的私有响应边界,无数据库或应用依赖。"""
|
| 2 |
import re
|
| 3 |
+
from starlette.datastructures import Headers, MutableHeaders
|
| 4 |
|
| 5 |
_PRIVATE_PATH = re.compile(
|
| 6 |
r"^/api/(wallet|messages|chats|admin|disputes|users)(?:/|$)"
|
|
|
|
| 13 |
# 含 Authorization 的响应即使路径公开也不交给共享 HTTP 缓存。
|
| 14 |
# 匿名访问私有路径的错误响应及登录返回的 Token 同样不能缓存。
|
| 15 |
return bool(authorization or _PRIVATE_PATH.search(path))
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class PrivateResponseCacheControl:
|
| 19 |
+
def __init__(self, app):
|
| 20 |
+
self.app = app
|
| 21 |
+
|
| 22 |
+
async def __call__(self, scope, receive, send):
|
| 23 |
+
if (scope['type'] != 'http'
|
| 24 |
+
or not requires_no_store(scope.get('path', ''), Headers(scope=scope).get('authorization'))):
|
| 25 |
+
return await self.app(scope, receive, send)
|
| 26 |
+
|
| 27 |
+
async def private_send(message):
|
| 28 |
+
if message['type'] == 'http.response.start':
|
| 29 |
+
headers = MutableHeaders(raw=list(message.get('headers', ())))
|
| 30 |
+
headers['Cache-Control'] = 'no-store'
|
| 31 |
+
vary = [name.strip() for name in headers.get('Vary', '').split(',') if name.strip()]
|
| 32 |
+
if not any(name.lower() in {'authorization', '*'} for name in vary):
|
| 33 |
+
vary.append('Authorization')
|
| 34 |
+
headers['Vary'] = ', '.join(vary)
|
| 35 |
+
message = {**message, 'headers': headers.raw}
|
| 36 |
+
await send(message)
|
| 37 |
+
|
| 38 |
+
return await self.app(scope, receive, private_send)
|
router_proxy.py
CHANGED
|
@@ -48,6 +48,8 @@ async def _validate_public_http_target(url: str) -> str:
|
|
| 48 |
try:
|
| 49 |
await _resolve_public_ips(host, port or (443 if parsed.scheme == "https" else 80))
|
| 50 |
except PublicResolutionError as error:
|
|
|
|
|
|
|
| 51 |
detail = "下载地址无法解析" if error.reason == "dns_unavailable" else "下载地址解析到非公网地址"
|
| 52 |
raise HTTPException(status_code=400, detail=detail) from None
|
| 53 |
return host
|
|
|
|
| 48 |
try:
|
| 49 |
await _resolve_public_ips(host, port or (443 if parsed.scheme == "https" else 80))
|
| 50 |
except PublicResolutionError as error:
|
| 51 |
+
if error.reason == 'dns_busy':
|
| 52 |
+
raise HTTPException(status_code=503, detail='service_unavailable') from None
|
| 53 |
detail = "下载地址无法解析" if error.reason == "dns_unavailable" else "下载地址解析到非公网地址"
|
| 54 |
raise HTTPException(status_code=400, detail=detail) from None
|
| 55 |
return host
|
safe_http_transport.py
CHANGED
|
@@ -8,6 +8,7 @@ import socket
|
|
| 8 |
import httpcore
|
| 9 |
import httpx
|
| 10 |
from proxy_resources import ProxyResources
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
_validated_ip = ContextVar("ranking_validated_ip", default=None)
|
|
@@ -18,7 +19,12 @@ _DNS_TIMEOUT = 8
|
|
| 18 |
class PublicResolutionError(httpx.ConnectError):
|
| 19 |
def __init__(self, reason):
|
| 20 |
self.reason = reason
|
| 21 |
-
super().__init__("目标域名无法解析" if reason
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
def _public(address):
|
|
@@ -113,23 +119,42 @@ async def _resolve_hf_public_ips(host):
|
|
| 113 |
|
| 114 |
|
| 115 |
async def _resolve_public_ips(host: str, port: int) -> tuple[str, ...]:
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
try:
|
| 118 |
address = ipaddress.ip_address(normalized_host)
|
| 119 |
addresses = [address]
|
| 120 |
except ValueError:
|
| 121 |
try:
|
| 122 |
-
records = await
|
| 123 |
socket.getaddrinfo, normalized_host, port, type=socket.SOCK_STREAM
|
| 124 |
)
|
| 125 |
except OSError as exc:
|
| 126 |
if normalized_host == "huggingface.co":
|
| 127 |
return await _resolve_hf_public_ips(normalized_host)
|
| 128 |
raise PublicResolutionError("dns_unavailable") from exc
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
if not addresses or any(not _public(address) for address in addresses):
|
| 135 |
if normalized_host == "huggingface.co":
|
|
|
|
| 8 |
import httpcore
|
| 9 |
import httpx
|
| 10 |
from proxy_resources import ProxyResources
|
| 11 |
+
from owned_thread import OwnedThreadGroup
|
| 12 |
|
| 13 |
|
| 14 |
_validated_ip = ContextVar("ranking_validated_ip", default=None)
|
|
|
|
| 19 |
class PublicResolutionError(httpx.ConnectError):
|
| 20 |
def __init__(self, reason):
|
| 21 |
self.reason = reason
|
| 22 |
+
super().__init__("目标域名无法解析" if reason in ('dns_unavailable', 'dns_busy') else "目标域名解析到非公网地址")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
_DNS_SYSTEM_CAPACITY = 8
|
| 26 |
+
_DNS_MAX_RECORDS = 32
|
| 27 |
+
_system_dns = OwnedThreadGroup(_DNS_SYSTEM_CAPACITY, lambda: PublicResolutionError('dns_busy'))
|
| 28 |
|
| 29 |
|
| 30 |
def _public(address):
|
|
|
|
| 119 |
|
| 120 |
|
| 121 |
async def _resolve_public_ips(host: str, port: int) -> tuple[str, ...]:
|
| 122 |
+
try:
|
| 123 |
+
normalized_host = host.rstrip('.').lower()
|
| 124 |
+
if (not normalized_host or any(ord(char) <= 32 or ord(char) == 127 for char in normalized_host)
|
| 125 |
+
or len(normalized_host.encode('idna')) > 253):
|
| 126 |
+
raise ValueError()
|
| 127 |
+
except (AttributeError, TypeError, ValueError):
|
| 128 |
+
raise PublicResolutionError('dns_unavailable') from None
|
| 129 |
+
try:
|
| 130 |
+
async with asyncio.timeout(_DNS_TIMEOUT):
|
| 131 |
+
return await _resolve_public_ips_once(normalized_host, port)
|
| 132 |
+
except TimeoutError:
|
| 133 |
+
raise PublicResolutionError('dns_unavailable') from None
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
async def _resolve_public_ips_once(normalized_host: str, port: int) -> tuple[str, ...]:
|
| 137 |
try:
|
| 138 |
address = ipaddress.ip_address(normalized_host)
|
| 139 |
addresses = [address]
|
| 140 |
except ValueError:
|
| 141 |
try:
|
| 142 |
+
records = await _system_dns.run(
|
| 143 |
socket.getaddrinfo, normalized_host, port, type=socket.SOCK_STREAM
|
| 144 |
)
|
| 145 |
except OSError as exc:
|
| 146 |
if normalized_host == "huggingface.co":
|
| 147 |
return await _resolve_hf_public_ips(normalized_host)
|
| 148 |
raise PublicResolutionError("dns_unavailable") from exc
|
| 149 |
+
if not isinstance(records, list) or len(records) > _DNS_MAX_RECORDS:
|
| 150 |
+
raise PublicResolutionError('dns_unavailable')
|
| 151 |
+
try:
|
| 152 |
+
addresses = [
|
| 153 |
+
ipaddress.ip_address(record[4][0].split("%", 1)[0])
|
| 154 |
+
for record in records
|
| 155 |
+
]
|
| 156 |
+
except (IndexError, AttributeError, TypeError, ValueError):
|
| 157 |
+
raise PublicResolutionError('dns_unavailable') from None
|
| 158 |
|
| 159 |
if not addresses or any(not _public(address) for address in addresses):
|
| 160 |
if normalized_host == "huggingface.co":
|
workflow_download_service.py
CHANGED
|
@@ -119,6 +119,7 @@ async def prepare_workflow_download(item, item_id, *, store, source_authorizer,
|
|
| 119 |
"下载源重定向无效或次数过多": "invalid_redirect",
|
| 120 |
"下载源重定向失败": "redirect_failed",
|
| 121 |
"下载文件超过大小限制": "size_exceeded",
|
|
|
|
| 122 |
}
|
| 123 |
reason = reasons.get(error.detail, "http_rejected") if type(error.detail) is str else "http_rejected"
|
| 124 |
status = error.status_code if type(error.status_code) is int else 0
|
|
|
|
| 119 |
"下载源重定向无效或次数过多": "invalid_redirect",
|
| 120 |
"下载源重定向失败": "redirect_failed",
|
| 121 |
"下载文件超过大小限制": "size_exceeded",
|
| 122 |
+
"service_unavailable": "dns_busy",
|
| 123 |
}
|
| 124 |
reason = reasons.get(error.detail, "http_rejected") if type(error.detail) is str else "http_rejected"
|
| 125 |
status = error.status_code if type(error.status_code) is int else 0
|
workflow_stream_response.py
CHANGED
|
@@ -3,9 +3,8 @@ import asyncio
|
|
| 3 |
from fastapi.responses import StreamingResponse
|
| 4 |
|
| 5 |
|
| 6 |
-
class
|
| 7 |
-
def
|
| 8 |
-
super().__init__(content, **kwargs)
|
| 9 |
self._close_callback = close_callback
|
| 10 |
self._download = None
|
| 11 |
self._close_task = None
|
|
@@ -19,7 +18,7 @@ class OwnedProxyResponse(StreamingResponse):
|
|
| 19 |
if self._close_task is None:
|
| 20 |
async def close():
|
| 21 |
try:
|
| 22 |
-
close_iterator = getattr(self
|
| 23 |
if close_iterator is not None:
|
| 24 |
await close_iterator()
|
| 25 |
finally:
|
|
@@ -36,6 +35,8 @@ class OwnedProxyResponse(StreamingResponse):
|
|
| 36 |
raise cancelled
|
| 37 |
|
| 38 |
async def __call__(self, scope, receive, send):
|
|
|
|
|
|
|
| 39 |
if self._download is not None:
|
| 40 |
return await self._download.deliver(self, scope, receive, send)
|
| 41 |
return await self.send_owned(scope, receive, send)
|
|
@@ -46,5 +47,11 @@ class OwnedProxyResponse(StreamingResponse):
|
|
| 46 |
finally:
|
| 47 |
await self.aclose()
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
# 保留既有工作流调用方的导出名称。
|
| 50 |
OwnedWorkflowResponse = OwnedProxyResponse
|
|
|
|
| 3 |
from fastapi.responses import StreamingResponse
|
| 4 |
|
| 5 |
|
| 6 |
+
class OwnedDelivery:
|
| 7 |
+
def _init_delivery(self, close_callback):
|
|
|
|
| 8 |
self._close_callback = close_callback
|
| 9 |
self._download = None
|
| 10 |
self._close_task = None
|
|
|
|
| 18 |
if self._close_task is None:
|
| 19 |
async def close():
|
| 20 |
try:
|
| 21 |
+
close_iterator = getattr(getattr(self, 'body_iterator', None), "aclose", None)
|
| 22 |
if close_iterator is not None:
|
| 23 |
await close_iterator()
|
| 24 |
finally:
|
|
|
|
| 35 |
raise cancelled
|
| 36 |
|
| 37 |
async def __call__(self, scope, receive, send):
|
| 38 |
+
if self._close_task is not None:
|
| 39 |
+
raise asyncio.CancelledError()
|
| 40 |
if self._download is not None:
|
| 41 |
return await self._download.deliver(self, scope, receive, send)
|
| 42 |
return await self.send_owned(scope, receive, send)
|
|
|
|
| 47 |
finally:
|
| 48 |
await self.aclose()
|
| 49 |
|
| 50 |
+
|
| 51 |
+
class OwnedProxyResponse(OwnedDelivery, StreamingResponse):
|
| 52 |
+
def __init__(self, content, *, close_callback, **kwargs):
|
| 53 |
+
super().__init__(content, **kwargs)
|
| 54 |
+
self._init_delivery(close_callback)
|
| 55 |
+
|
| 56 |
# 保留既有工作流调用方的导出名称。
|
| 57 |
OwnedWorkflowResponse = OwnedProxyResponse
|