ZHIWEI666 commited on
Commit
4bcc697
·
verified ·
1 Parent(s): cd53308

Upload 8 files

Browse files
Files changed (4) hide show
  1. app.py +6 -3
  2. proxy_access.py +82 -62
  3. router_proxy.py +205 -206
  4. router_tasks.py +0 -0
app.py CHANGED
@@ -71,10 +71,11 @@ from router_items import (router as items_router, start_resource_reads, stop_res
71
  from router_comments import (router as comments_router, start_comment_writes, stop_comment_writes, close_comment_writes) # 💬 评论系统
72
  from router_messages import (router as messages_router, start_message_io, stop_message_io, close_message_io) # ✉️ 私信系统
73
  from router_wallet import (router as wallet_router, start_wallet_sql, stop_wallet_sql, close_wallet_sql) # 💰 钱包/提现
 
74
  from router_proxy import router as proxy_router # 🔗 代理下载
75
  from router_posts import (router as posts_router, start_posts_io, stop_posts_io, close_posts_io) # 💬 讨论区
76
  from router_prompts import (router as prompts_router, start_prompts_io, stop_prompts_io, close_prompts_io) # ✨ 提示词市场
77
- from router_tasks import router as tasks_router # 📝 任务榜
78
  from router_open_api import start_open_sql, stop_open_sql, close_open_sql
79
  from router_open_api import router as open_api_router # 🌐 通用开放API(第三方插件)
80
  from router_admin_plugins import router as admin_plugins_router # 🔒 管理后台 - 第三方插件管理API
@@ -301,6 +302,8 @@ async def on_startup():
301
  start_message_io()
302
  start_posts_io()
303
  start_prompts_io()
 
 
304
  start_comment_reads()
305
  start_admin_scripts()
306
 
@@ -361,7 +364,7 @@ async def _shutdown_resources():
361
  for stage, stop in (
362
  ('stop_wallet_sql', stop_wallet_sql),
363
  ('stop_resource_reads', stop_resource_reads), ('stop_comment_writes', stop_comment_writes),
364
- ('stop_posts', stop_posts_io), ('stop_prompts', stop_prompts_io),
365
  ('stop_messages', stop_message_io), ('stop_resources', stop_resource_work), ('stop_open_sql', stop_open_sql), ('stop_shared', stop_shared_work),
366
  ('stop_identity', stop_identity_io), ('stop_auth', stop_auth_io),
367
  ('stop_verification', stop_verification_io), ('stop_scripts', stop_admin_scripts),
@@ -378,7 +381,7 @@ async def _shutdown_resources():
378
  ('wait_warmup', wait_startup_warmup, (app.state,)),
379
  ('close_wallet_sql', close_wallet_sql, ()),
380
  ('close_resource_reads', close_resource_reads, ()), ('close_comment_writes', close_comment_writes, ()),
381
- ('close_posts', close_posts_io, ()), ('close_prompts', close_prompts_io, ()),
382
  ('close_messages', close_message_io, ()), ('close_resources', close_resource_work, ()), ('close_open_sql', close_open_sql, ()), ('close_shared', close_shared_work, ()),
383
  ('close_identity', close_identity_io, ()), ('close_auth', close_auth_io, ()),
384
  ('close_verification', close_verification_io, ()), ('close_profile', close_profile_io, ()),
 
71
  from router_comments import (router as comments_router, start_comment_writes, stop_comment_writes, close_comment_writes) # 💬 评论系统
72
  from router_messages import (router as messages_router, start_message_io, stop_message_io, close_message_io) # ✉️ 私信系统
73
  from router_wallet import (router as wallet_router, start_wallet_sql, stop_wallet_sql, close_wallet_sql) # 💰 钱包/提现
74
+ from proxy_access import start_proxy_access, stop_proxy_access, close_proxy_access
75
  from router_proxy import router as proxy_router # 🔗 代理下载
76
  from router_posts import (router as posts_router, start_posts_io, stop_posts_io, close_posts_io) # 💬 讨论区
77
  from router_prompts import (router as prompts_router, start_prompts_io, stop_prompts_io, close_prompts_io) # ✨ 提示词市场
78
+ from router_tasks import (router as tasks_router, start_tasks_io, stop_tasks_io, close_tasks_io) # 📝 任务榜
79
  from router_open_api import start_open_sql, stop_open_sql, close_open_sql
80
  from router_open_api import router as open_api_router # 🌐 通用开放API(第三方插件)
81
  from router_admin_plugins import router as admin_plugins_router # 🔒 管理后台 - 第三方插件管理API
 
302
  start_message_io()
303
  start_posts_io()
304
  start_prompts_io()
305
+ start_tasks_io()
306
+ start_proxy_access()
307
  start_comment_reads()
308
  start_admin_scripts()
309
 
 
364
  for stage, stop in (
365
  ('stop_wallet_sql', stop_wallet_sql),
366
  ('stop_resource_reads', stop_resource_reads), ('stop_comment_writes', stop_comment_writes),
367
+ ('stop_posts', stop_posts_io), ('stop_prompts', stop_prompts_io), ('stop_tasks', stop_tasks_io), ('stop_proxy_access', stop_proxy_access),
368
  ('stop_messages', stop_message_io), ('stop_resources', stop_resource_work), ('stop_open_sql', stop_open_sql), ('stop_shared', stop_shared_work),
369
  ('stop_identity', stop_identity_io), ('stop_auth', stop_auth_io),
370
  ('stop_verification', stop_verification_io), ('stop_scripts', stop_admin_scripts),
 
381
  ('wait_warmup', wait_startup_warmup, (app.state,)),
382
  ('close_wallet_sql', close_wallet_sql, ()),
383
  ('close_resource_reads', close_resource_reads, ()), ('close_comment_writes', close_comment_writes, ()),
384
+ ('close_posts', close_posts_io, ()), ('close_prompts', close_prompts_io, ()), ('close_tasks', close_tasks_io, ()), ('close_proxy_access', close_proxy_access, ()),
385
  ('close_messages', close_message_io, ()), ('close_resources', close_resource_work, ()), ('close_open_sql', close_open_sql, ()), ('close_shared', close_shared_work, ()),
386
  ('close_identity', close_identity_io, ()), ('close_auth', close_auth_io, ()),
387
  ('close_verification', close_verification_io, ()), ('close_profile', close_profile_io, ()),
proxy_access.py CHANGED
@@ -1,62 +1,82 @@
1
- """下载代理共用资源/有效价格/当前所有权核查,不把存储故障视为免费。"""
2
- import asyncio
3
- from models_sql import Ownership
4
- from purchase_transaction import effective_resource_price
5
-
6
-
7
- class ProxyAccessError(Exception):
8
- def __init__(self, status_code, message):
9
- self.status_code = status_code
10
- self.message = message
11
- super().__init__(message)
12
-
13
-
14
- def read_proxy_item(store, item_id):
15
- try:
16
- source = store.load_data('items.json', default_data=[], strict=True, skip_cache=True)
17
- if not isinstance(source, list):
18
- raise ValueError('invalid resource source')
19
- if any(not isinstance(item, dict) or not isinstance(item.get('id'), str) or not item['id'] for item in source):
20
- raise ValueError('invalid resource record')
21
- matches = [item for item in source if item.get('id') == item_id]
22
- if not matches:
23
- raise ProxyAccessError(404, '资源不存在')
24
- if len(matches) != 1:
25
- raise ValueError('ambiguous resource')
26
- item = matches[0]
27
- author = item.get('author')
28
- if not isinstance(author, str) or not author.strip():
29
- raise ValueError('invalid resource author')
30
- effective_resource_price(item)
31
- return item
32
- except ProxyAccessError:
33
- raise
34
- except Exception:
35
- raise ProxyAccessError(503, '资源信息暂时无法核实,请稍后核对') from None
36
-
37
-
38
- async def authorized_proxy_item(store, session, item_id, account):
39
- try:
40
- item = await asyncio.to_thread(read_proxy_item, store, item_id)
41
- author = item['author']
42
- price = effective_resource_price(item)
43
- if price > 0 and account != author:
44
- # SQL会话仍留在调用线程;不用陈旧的身份/拥有缓存,也不授予已退款记录。
45
- ownerships = session.query(Ownership).filter(Ownership.account == account, Ownership.item_id == item_id).populate_existing().all()
46
- if any(type(row.is_refunded) is not bool for row in ownerships):
47
- raise ValueError('invalid ownership refund state')
48
- rows = [row for row in ownerships if not row.is_refunded]
49
- if len(rows) > 1:
50
- raise ValueError('ambiguous ownership')
51
- if not rows:
52
- raise ProxyAccessError(403, '尚未获取该资源')
53
- return item
54
- except ProxyAccessError:
55
- raise
56
- except Exception:
57
- raise ProxyAccessError(503, '下载授权暂时无法核实,请稍后核对') from None
58
- finally:
59
- try:
60
- session.rollback() # 不让下载流长期持有所有权读取事务。
61
- except Exception:
62
- raise ProxyAccessError(503, '下载授权暂时无法核实,请稍后核对') from None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """下载代理共用资源/有效价格/当前所有权核查,不把存储故障视为免费。"""
2
+ from owned_thread import OwnedThreadGroup
3
+ from models_sql import Ownership
4
+ from purchase_transaction import effective_resource_price
5
+
6
+
7
+ class ProxyAccessError(Exception):
8
+ def __init__(self, status_code, message):
9
+ self.status_code = status_code
10
+ self.message = message
11
+ super().__init__(message)
12
+
13
+
14
+ def read_proxy_item(store, item_id):
15
+ try:
16
+ source = store.load_data('items.json', default_data=[], strict=True, skip_cache=True)
17
+ if not isinstance(source, list):
18
+ raise ValueError('invalid resource source')
19
+ if any(not isinstance(item, dict) or not isinstance(item.get('id'), str) or not item['id'] for item in source):
20
+ raise ValueError('invalid resource record')
21
+ matches = [item for item in source if item.get('id') == item_id]
22
+ if not matches:
23
+ raise ProxyAccessError(404, '资源不存在')
24
+ if len(matches) != 1:
25
+ raise ValueError('ambiguous resource')
26
+ item = matches[0]
27
+ author = item.get('author')
28
+ if not isinstance(author, str) or not author.strip():
29
+ raise ValueError('invalid resource author')
30
+ effective_resource_price(item)
31
+ return item
32
+ except ProxyAccessError:
33
+ raise
34
+ except Exception:
35
+ raise ProxyAccessError(503, '资源信息暂时无法核实,请稍后核对') from None
36
+
37
+
38
+ def _authorized_proxy_item(store, session, item_id, account):
39
+ try:
40
+ item = read_proxy_item(store, item_id)
41
+ author = item['author']
42
+ price = effective_resource_price(item)
43
+ if price > 0 and account != author:
44
+ # SQL会话仍留在调用线程;不用陈旧的身份/拥有缓存,也不授予已退款记录。
45
+ ownerships = session.query(Ownership).filter(Ownership.account == account, Ownership.item_id == item_id).populate_existing().all()
46
+ if any(type(row.is_refunded) is not bool for row in ownerships):
47
+ raise ValueError('invalid ownership refund state')
48
+ rows = [row for row in ownerships if not row.is_refunded]
49
+ if len(rows) > 1:
50
+ raise ValueError('ambiguous ownership')
51
+ if not rows:
52
+ raise ProxyAccessError(403, '尚未获取该资源')
53
+ return item
54
+ except ProxyAccessError:
55
+ raise
56
+ except Exception:
57
+ raise ProxyAccessError(503, '下载授权暂时无法核实,请稍后核对') from None
58
+ finally:
59
+ try:
60
+ session.rollback() # 不让下载流长期持有所有权读取事务。
61
+ except Exception:
62
+ raise ProxyAccessError(503, '下载授权暂时无法核实,请稍后核对') from None
63
+
64
+
65
+ MAX_PROXY_ACCESS = 8
66
+ _proxy_access_io = OwnedThreadGroup(MAX_PROXY_ACCESS,
67
+ lambda: ProxyAccessError(503, '下载授权暂时无法核实,请稍后核对'))
68
+ start_proxy_access = _proxy_access_io.start
69
+ stop_proxy_access = _proxy_access_io.stop
70
+ close_proxy_access = _proxy_access_io.close
71
+
72
+
73
+ async def authorized_proxy_item(store, session_factory, item_id, account):
74
+ def execute():
75
+ try:
76
+ with session_factory() as session:
77
+ return _authorized_proxy_item(store, session, item_id, account)
78
+ except ProxyAccessError:
79
+ raise
80
+ except Exception:
81
+ raise ProxyAccessError(503, '下载授权暂时无法核实,请稍后核对') from None
82
+ return await _proxy_access_io.run(execute)
router_proxy.py CHANGED
@@ -1,206 +1,205 @@
1
- # router_proxy.py
2
- from fastapi import APIRouter, Depends, HTTPException, Request
3
- from rate_limit import shared_limit
4
- from fastapi.responses import StreamingResponse, JSONResponse, Response
5
- from sqlalchemy.orm import Session
6
- from pydantic import BaseModel, Field, field_validator, HttpUrl
7
- from typing import Optional
8
- from urllib.parse import urlparse, urljoin
9
- import ipaddress
10
- import httpx
11
- import os
12
- import re
13
- import urllib.request
14
- import urllib.error
15
- import 数据库连接 as json_db
16
- from database_sql import get_db
17
- from models_sql import Ownership
18
- from workflow_source import authorized_workflow_path, workflow_credential_target, WorkflowSourceError
19
- from proxy_access import authorized_proxy_item, ProxyAccessError
20
- from github_source import repository_parts, select_github_token
21
- from github_archive import ARCHIVE_MAX_BYTES
22
- from 安全认证 import require_auth, JWT_SECRET
23
- from safe_http_transport import ValidatedAsyncHTTPTransport, _resolve_public_ips, PublicResolutionError
24
- from workflow_download_service import prepare_workflow_download
25
- from zip_stream import ZIP_DOWNLOAD_MAX_BYTES
26
- from github_zip_service import prepare_github_zip
27
- from proxy_resources import close_redirect_response
28
- from skill_archive_source import install_source, SkillSourceError
29
-
30
- router = APIRouter()
31
- MAX_PROXY_DOWNLOAD_BYTES = int(os.environ.get("MAX_PROXY_DOWNLOAD_BYTES", str(256 * 1024 * 1024)))
32
-
33
-
34
- async def _validate_public_http_target(url: str) -> str:
35
- """Validate every proxy hop and reject local/private destinations."""
36
- parsed = urlparse(url)
37
- if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password:
38
- raise HTTPException(status_code=400, detail="下载地址无效")
39
- try:
40
- port = parsed.port
41
- except ValueError:
42
- raise HTTPException(status_code=400, detail="下载地址端口无效")
43
- if port not in (None, 80, 443):
44
- raise HTTPException(status_code=400, detail="不允许访问自定义端口")
45
-
46
- host = parsed.hostname.rstrip(".").lower()
47
- try:
48
- await _resolve_public_ips(host, port or (443 if parsed.scheme == "https" else 80))
49
- except PublicResolutionError as error:
50
- detail = "下载地址无法解析" if error.reason == "dns_unavailable" else "下载地址解析到非公网地址"
51
- raise HTTPException(status_code=400, detail=detail) from None
52
- return host
53
-
54
-
55
- async def _send_with_validated_redirects(client, method: str, url: str, headers=None, timeout=None,
56
- max_redirects: int = 5):
57
- """逐跳校验 URL/DNS 后请求;不让 HTTPX 自动跟随未经检查的重定向。"""
58
- current_url = url
59
- original_host = await _validate_public_http_target(current_url)
60
- original_parsed = urlparse(current_url)
61
- original_origin = (
62
- original_parsed.scheme.lower(),
63
- original_host,
64
- original_parsed.port or (443 if original_parsed.scheme.lower() == "https" else 80),
65
- )
66
- request_headers = dict(headers or {})
67
- for redirect_count in range(max_redirects + 1):
68
- current_host = await _validate_public_http_target(current_url)
69
- current_parsed = urlparse(current_url)
70
- current_origin = (
71
- current_parsed.scheme.lower(),
72
- current_host,
73
- current_parsed.port or (443 if current_parsed.scheme.lower() == "https" else 80),
74
- )
75
- hop_headers = dict(request_headers)
76
- if current_origin != original_origin:
77
- # 凭据只发给初始 HTTPS origin,绝不随跨 origin 重定向传递。
78
- hop_headers.pop("Authorization", None)
79
- response = await client.send(
80
- client.build_request(method, current_url, headers=hop_headers, timeout=timeout),
81
- stream=True,
82
- follow_redirects=False,
83
- )
84
- if response.status_code not in (301, 302, 303, 307, 308):
85
- return response
86
- location = response.headers.get("location")
87
- await close_redirect_response(response)
88
- if not location or redirect_count >= max_redirects:
89
- raise HTTPException(status_code=502, detail="下载源重定向无效或次数过多")
90
- current_url = urljoin(current_url, location)
91
- raise HTTPException(status_code=502, detail="下载源重定向失败")
92
-
93
-
94
- class ProxyGithubZipRequest(BaseModel):
95
- """GitHub ZIP 代理下载请求模型"""
96
- url: str = Field(..., min_length=1, description="资源URL")
97
- item_id: str = Field(..., min_length=1, max_length=100, description="资源ID")
98
- # 🔒 P0安全加固:身份以 Authorization Token 为准,account 仅作向后兼容字段,不再作为鉴权依据
99
- account: Optional[str] = Field(None, max_length=50, description="用户账号(已废弃,以Token为准)")
100
-
101
- @field_validator('item_id')
102
- @classmethod
103
- def validate_item_id(cls, v: str) -> str:
104
- """验证item_id只允许[a-zA-Z0-9_-]字符"""
105
- if not re.match(r'^[a-zA-Z0-9_-]+$', v):
106
- raise ValueError('item_id只能包含字母、数字、下划线和连字符')
107
- return v
108
-
109
-
110
- class SkillInstallSourceRequest(BaseModel):
111
- model_config = {'extra': 'forbid'}
112
- item_id: str = Field(..., min_length=1, max_length=100, pattern=r'^[A-Za-z0-9_-]+$')
113
-
114
-
115
- @router.post('/api/skill_install_source')
116
- @shared_limit(30)
117
- async def skill_install_source(request: Request, req_data: SkillInstallSourceRequest,
118
- account: str = Depends(require_auth), db: Session = Depends(get_db)):
119
- try:
120
- item = await authorized_proxy_item(json_db, db, req_data.item_id, account)
121
- result = install_source(item, account, token_selector=select_github_token, signing_key=JWT_SECRET)
122
- except ProxyAccessError as error:
123
- return JSONResponse(content={'error': error.message}, status_code=error.status_code,
124
- headers={'Cache-Control': 'no-store'})
125
- except SkillSourceError:
126
- return JSONResponse(content={'error': '该资源不是可安装的Skill来源', 'error_code': 'invalid_repository'}, status_code=400,
127
- headers={'Cache-Control': 'no-store'})
128
- return JSONResponse(content=result, headers={'Cache-Control': 'no-store', 'Vary': 'Authorization'})
129
-
130
- @router.post("/api/proxy_github_zip")
131
- @shared_limit(5)
132
- async def proxy_github_zip(request: Request, req_data: ProxyGithubZipRequest, account: str = Depends(require_auth), db: Session = Depends(get_db)):
133
- """云端代理:校验所有权后,拉取 GitHub 私有库 ZIP 流透传给本地"""
134
- try:
135
- item = await authorized_proxy_item(json_db, db, req_data.item_id, account)
136
- except ProxyAccessError as error:
137
- return JSONResponse(content={"error": error.message}, status_code=error.status_code)
138
-
139
- async def resolve_commit(repo_url, token):
140
- # 缓存命中不加载版本引擎;保留原按需导入时序。
141
- from 云端_定时版本检测引擎 import fetch_github_commit
142
- return await fetch_github_commit(repo_url, token)
143
-
144
- return await prepare_github_zip(item, req_data.item_id, store=json_db,
145
- signing_key=JWT_SECRET,
146
- token_selector=select_github_token, resolve_commit=resolve_commit,
147
- send=_send_with_validated_redirects, client_factory=httpx.AsyncClient,
148
- transport_factory=ValidatedAsyncHTTPTransport, error_response=JSONResponse,
149
- cache_max_bytes=ARCHIVE_MAX_BYTES, direct_max_bytes=ZIP_DOWNLOAD_MAX_BYTES)
150
-
151
-
152
-
153
- class ProxyDownloadRequest(BaseModel):
154
- """工作流/应用 JSON 代理下载请求模型"""
155
- url: str = Field(..., min_length=1, description="下载URL")
156
- item_id: str = Field(..., min_length=1, max_length=100, description="资源ID")
157
- # 🔒 P0安全加固:身份以 Authorization Token 为准,account 仅作向后兼容字段
158
- account: Optional[str] = Field(None, max_length=50, description="用户账号(已废弃,以Token为准)")
159
-
160
- @field_validator('item_id')
161
- @classmethod
162
- def validate_item_id(cls, v: str) -> str:
163
- """验证item_id只允许[a-zA-Z0-9_-]字符"""
164
- if not re.match(r'^[a-zA-Z0-9_-]+$', v):
165
- raise ValueError('item_id只能包含字母、数字、下划线和连字符')
166
- return v
167
-
168
- @field_validator('url')
169
- @classmethod
170
- def validate_url(cls, v: str) -> str:
171
- """验证URL格式并阻止内网地址"""
172
- if not v.startswith(('http://', 'https://')):
173
- raise ValueError('url必须是有效的HTTP或HTTPS地址')
174
-
175
- parsed = urlparse(v)
176
- # 阻止内网地址
177
- dangerous_hosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1']
178
- if parsed.hostname in dangerous_hosts:
179
- raise ValueError('不允许访问本地地址')
180
-
181
- # 检查是否是私有IP
182
- try:
183
- ip = ipaddress.ip_address(parsed.hostname)
184
- except ValueError:
185
- return v # 域名仍由实际请求的逐跳DNS与传输层校验。
186
- # 拒绝异常必须在解析异常处理之外,否则会被当作域名吞掉。
187
- if ip.is_private or ip.is_loopback or ip.is_reserved:
188
- raise ValueError('不允许访问内网地址')
189
-
190
- return v
191
-
192
- @router.post("/api/proxy_download")
193
- @shared_limit(10)
194
- async def proxy_download(request: Request, req_data: ProxyDownloadRequest, account: str = Depends(require_auth), db: Session = Depends(get_db)):
195
- """云端代理:校验所有权后,拉取真实的工作流 JSON 文件并透传给本地"""
196
- try:
197
- item = await authorized_proxy_item(json_db, db, req_data.item_id, account)
198
- except ProxyAccessError as error:
199
- return JSONResponse(content={"error": error.message}, status_code=error.status_code)
200
-
201
- return await prepare_workflow_download(item, req_data.item_id, store=json_db,
202
- source_authorizer=authorized_workflow_path, source_error=WorkflowSourceError,
203
- credential_target=workflow_credential_target, validate_target=_validate_public_http_target,
204
- client_factory=httpx.AsyncClient, transport_factory=ValidatedAsyncHTTPTransport,
205
- timeout_error=httpx.TimeoutException, http_error=HTTPException,
206
- error_response=JSONResponse, max_bytes=MAX_PROXY_DOWNLOAD_BYTES)
 
1
+ # router_proxy.py
2
+ from fastapi import APIRouter, Depends, HTTPException, Request
3
+ from rate_limit import shared_limit
4
+ from fastapi.responses import StreamingResponse, JSONResponse, Response
5
+ from pydantic import BaseModel, Field, field_validator, HttpUrl
6
+ from typing import Optional
7
+ from urllib.parse import urlparse, urljoin
8
+ import ipaddress
9
+ import httpx
10
+ import os
11
+ import re
12
+ import urllib.request
13
+ import urllib.error
14
+ import 数据库连接 as json_db
15
+ from database_sql import SessionLocal
16
+ from models_sql import Ownership
17
+ from workflow_source import authorized_workflow_path, workflow_credential_target, WorkflowSourceError
18
+ from proxy_access import authorized_proxy_item, ProxyAccessError
19
+ from github_source import repository_parts, select_github_token
20
+ from github_archive import ARCHIVE_MAX_BYTES
21
+ from 安全认证 import require_auth, JWT_SECRET
22
+ from safe_http_transport import ValidatedAsyncHTTPTransport, _resolve_public_ips, PublicResolutionError
23
+ from workflow_download_service import prepare_workflow_download
24
+ from zip_stream import ZIP_DOWNLOAD_MAX_BYTES
25
+ from github_zip_service import prepare_github_zip
26
+ from proxy_resources import close_redirect_response
27
+ from skill_archive_source import install_source, SkillSourceError
28
+
29
+ router = APIRouter()
30
+ MAX_PROXY_DOWNLOAD_BYTES = int(os.environ.get("MAX_PROXY_DOWNLOAD_BYTES", str(256 * 1024 * 1024)))
31
+
32
+
33
+ async def _validate_public_http_target(url: str) -> str:
34
+ """Validate every proxy hop and reject local/private destinations."""
35
+ parsed = urlparse(url)
36
+ if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password:
37
+ raise HTTPException(status_code=400, detail="下载地址无效")
38
+ try:
39
+ port = parsed.port
40
+ except ValueError:
41
+ raise HTTPException(status_code=400, detail="下载地址端口无效")
42
+ if port not in (None, 80, 443):
43
+ raise HTTPException(status_code=400, detail="不允许访问自定义端口")
44
+
45
+ host = parsed.hostname.rstrip(".").lower()
46
+ try:
47
+ await _resolve_public_ips(host, port or (443 if parsed.scheme == "https" else 80))
48
+ except PublicResolutionError as error:
49
+ detail = "下载地址无法解析" if error.reason == "dns_unavailable" else "下载地址解析到非公网地址"
50
+ raise HTTPException(status_code=400, detail=detail) from None
51
+ return host
52
+
53
+
54
+ async def _send_with_validated_redirects(client, method: str, url: str, headers=None, timeout=None,
55
+ max_redirects: int = 5):
56
+ """逐跳校验 URL/DNS 后请求;不让 HTTPX 自动跟随未经检查的重定向。"""
57
+ current_url = url
58
+ original_host = await _validate_public_http_target(current_url)
59
+ original_parsed = urlparse(current_url)
60
+ original_origin = (
61
+ original_parsed.scheme.lower(),
62
+ original_host,
63
+ original_parsed.port or (443 if original_parsed.scheme.lower() == "https" else 80),
64
+ )
65
+ request_headers = dict(headers or {})
66
+ for redirect_count in range(max_redirects + 1):
67
+ current_host = await _validate_public_http_target(current_url)
68
+ current_parsed = urlparse(current_url)
69
+ current_origin = (
70
+ current_parsed.scheme.lower(),
71
+ current_host,
72
+ current_parsed.port or (443 if current_parsed.scheme.lower() == "https" else 80),
73
+ )
74
+ hop_headers = dict(request_headers)
75
+ if current_origin != original_origin:
76
+ # 凭据只发给初始 HTTPS origin,绝不随跨 origin 重定向传递。
77
+ hop_headers.pop("Authorization", None)
78
+ response = await client.send(
79
+ client.build_request(method, current_url, headers=hop_headers, timeout=timeout),
80
+ stream=True,
81
+ follow_redirects=False,
82
+ )
83
+ if response.status_code not in (301, 302, 303, 307, 308):
84
+ return response
85
+ location = response.headers.get("location")
86
+ await close_redirect_response(response)
87
+ if not location or redirect_count >= max_redirects:
88
+ raise HTTPException(status_code=502, detail="下载源重定向无效或次数过多")
89
+ current_url = urljoin(current_url, location)
90
+ raise HTTPException(status_code=502, detail="下载源重定向失败")
91
+
92
+
93
+ class ProxyGithubZipRequest(BaseModel):
94
+ """GitHub ZIP 代理下载请求模型"""
95
+ url: str = Field(..., min_length=1, description="资源URL")
96
+ item_id: str = Field(..., min_length=1, max_length=100, description="资源ID")
97
+ # 🔒 P0安全加固:身份以 Authorization Token 为准,account 仅作向后兼容字���,不再作为鉴权依据
98
+ account: Optional[str] = Field(None, max_length=50, description="用户账号(已废弃,以Token为准)")
99
+
100
+ @field_validator('item_id')
101
+ @classmethod
102
+ def validate_item_id(cls, v: str) -> str:
103
+ """验证item_id只允许[a-zA-Z0-9_-]字符"""
104
+ if not re.match(r'^[a-zA-Z0-9_-]+$', v):
105
+ raise ValueError('item_id只能包含字母、数字、下划线和连字符')
106
+ return v
107
+
108
+
109
+ class SkillInstallSourceRequest(BaseModel):
110
+ model_config = {'extra': 'forbid'}
111
+ item_id: str = Field(..., min_length=1, max_length=100, pattern=r'^[A-Za-z0-9_-]+$')
112
+
113
+
114
+ @router.post('/api/skill_install_source')
115
+ @shared_limit(30)
116
+ async def skill_install_source(request: Request, req_data: SkillInstallSourceRequest,
117
+ account: str = Depends(require_auth)):
118
+ try:
119
+ item = await authorized_proxy_item(json_db, SessionLocal, req_data.item_id, account)
120
+ result = install_source(item, account, token_selector=select_github_token, signing_key=JWT_SECRET)
121
+ except ProxyAccessError as error:
122
+ return JSONResponse(content={'error': error.message}, status_code=error.status_code,
123
+ headers={'Cache-Control': 'no-store'})
124
+ except SkillSourceError:
125
+ return JSONResponse(content={'error': '该资源不是可安装的Skill来源', 'error_code': 'invalid_repository'}, status_code=400,
126
+ headers={'Cache-Control': 'no-store'})
127
+ return JSONResponse(content=result, headers={'Cache-Control': 'no-store', 'Vary': 'Authorization'})
128
+
129
+ @router.post("/api/proxy_github_zip")
130
+ @shared_limit(5)
131
+ async def proxy_github_zip(request: Request, req_data: ProxyGithubZipRequest, account: str = Depends(require_auth)):
132
+ """云端代理:校验所有权后,拉取 GitHub 私有库 ZIP 流透传给本地"""
133
+ try:
134
+ item = await authorized_proxy_item(json_db, SessionLocal, req_data.item_id, account)
135
+ except ProxyAccessError as error:
136
+ return JSONResponse(content={"error": error.message}, status_code=error.status_code)
137
+
138
+ async def resolve_commit(repo_url, token):
139
+ # 缓存命中不加载版本引擎;保留原按需导入时序。
140
+ from 云端_定时版本检测引擎 import fetch_github_commit
141
+ return await fetch_github_commit(repo_url, token)
142
+
143
+ return await prepare_github_zip(item, req_data.item_id, store=json_db,
144
+ signing_key=JWT_SECRET,
145
+ token_selector=select_github_token, resolve_commit=resolve_commit,
146
+ send=_send_with_validated_redirects, client_factory=httpx.AsyncClient,
147
+ transport_factory=ValidatedAsyncHTTPTransport, error_response=JSONResponse,
148
+ cache_max_bytes=ARCHIVE_MAX_BYTES, direct_max_bytes=ZIP_DOWNLOAD_MAX_BYTES)
149
+
150
+
151
+
152
+ class ProxyDownloadRequest(BaseModel):
153
+ """工作流/应用 JSON 代理下载请求模型"""
154
+ url: str = Field(..., min_length=1, description="下载URL")
155
+ item_id: str = Field(..., min_length=1, max_length=100, description="资源ID")
156
+ # 🔒 P0安全加固:身份以 Authorization Token 为准,account 仅作向后兼容字段
157
+ account: Optional[str] = Field(None, max_length=50, description="用户账号(已废弃,以Token为准)")
158
+
159
+ @field_validator('item_id')
160
+ @classmethod
161
+ def validate_item_id(cls, v: str) -> str:
162
+ """验证item_id只允许[a-zA-Z0-9_-]字符"""
163
+ if not re.match(r'^[a-zA-Z0-9_-]+$', v):
164
+ raise ValueError('item_id只能包含字母、数字、下划线和连字符')
165
+ return v
166
+
167
+ @field_validator('url')
168
+ @classmethod
169
+ def validate_url(cls, v: str) -> str:
170
+ """验证URL格式并阻止内网地址"""
171
+ if not v.startswith(('http://', 'https://')):
172
+ raise ValueError('url必须是有效的HTTP或HTTPS地址')
173
+
174
+ parsed = urlparse(v)
175
+ # 阻止内网地址
176
+ dangerous_hosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1']
177
+ if parsed.hostname in dangerous_hosts:
178
+ raise ValueError('不允许访问本地地址')
179
+
180
+ # 检查是否是私有IP
181
+ try:
182
+ ip = ipaddress.ip_address(parsed.hostname)
183
+ except ValueError:
184
+ return v # 域名仍由实际请求的逐跳DNS与传输层校验。
185
+ # 拒绝异常必须在解析异常处理之外,否则会被当作域名吞掉。
186
+ if ip.is_private or ip.is_loopback or ip.is_reserved:
187
+ raise ValueError('不允许访问内网地址')
188
+
189
+ return v
190
+
191
+ @router.post("/api/proxy_download")
192
+ @shared_limit(10)
193
+ async def proxy_download(request: Request, req_data: ProxyDownloadRequest, account: str = Depends(require_auth)):
194
+ """云端代理:校验所有权后,拉取真实的工作流 JSON 文件并透传给本地"""
195
+ try:
196
+ item = await authorized_proxy_item(json_db, SessionLocal, req_data.item_id, account)
197
+ except ProxyAccessError as error:
198
+ return JSONResponse(content={"error": error.message}, status_code=error.status_code)
199
+
200
+ return await prepare_workflow_download(item, req_data.item_id, store=json_db,
201
+ source_authorizer=authorized_workflow_path, source_error=WorkflowSourceError,
202
+ credential_target=workflow_credential_target, validate_target=_validate_public_http_target,
203
+ client_factory=httpx.AsyncClient, transport_factory=ValidatedAsyncHTTPTransport,
204
+ timeout_error=httpx.TimeoutException, http_error=HTTPException,
205
+ error_response=JSONResponse, max_bytes=MAX_PROXY_DOWNLOAD_BYTES)
 
router_tasks.py CHANGED
The diff for this file is too large to render. See raw diff