ZHIWEI666 commited on
Commit
5810932
·
verified ·
1 Parent(s): 47015d8

系统升级

Browse files
app.py CHANGED
@@ -1,5 +1,5 @@
1
  # ⚙️ 后端逻辑/核心服务端.py (Hugging Face Spaces app.py)
2
- from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException
3
  from fastapi.middleware.cors import CORSMiddleware
4
  from fastapi.responses import Response, JSONResponse, FileResponse
5
  from sqlalchemy.orm import Session
@@ -385,10 +385,11 @@ def sanitize_audio_content(content: bytes, ext: str) -> bytes:
385
 
386
 
387
  @app.post("/api/upload")
388
- def upload_file(file: UploadFile = File(...), file_type: str = Form(...), current_user: str = Depends(require_auth)):
389
- content = file.file.read()
390
-
391
  # 🟢 动态文件大小风控
 
 
392
  max_size = 10 * 1024 * 1024
393
  if file_type == "avatar":
394
  max_size = 2 * 1024 * 1024
@@ -408,9 +409,20 @@ def upload_file(file: UploadFile = File(...), file_type: str = Form(...), curren
408
  max_size = 100 * 1024 * 1024
409
  elif file_type == "post_audio":
410
  max_size = 30 * 1024 * 1024 # 音乐提示词音频:30MB
411
-
412
- if len(content) > max_size:
413
- raise HTTPException(status_code=400, detail=f"文件过大,{file_type} 类型请限制在 {max_size // (1024*1024)}MB 以内")
 
 
 
 
 
 
 
 
 
 
 
414
 
415
  if not file.filename:
416
  raise HTTPException(status_code=400, detail="文件名不能为空")
 
1
  # ⚙️ 后端逻辑/核心服务端.py (Hugging Face Spaces app.py)
2
+ from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException, Request
3
  from fastapi.middleware.cors import CORSMiddleware
4
  from fastapi.responses import Response, JSONResponse, FileResponse
5
  from sqlalchemy.orm import Session
 
385
 
386
 
387
  @app.post("/api/upload")
388
+ @limiter.limit("30/minute") # 🔒 安全限流:防止刷存储(单次发布最多 9 图 + 1 JSON,阈值留足余量)
389
+ def upload_file(request: Request, file: UploadFile = File(...), file_type: str = Form(...), current_user: str = Depends(require_auth)):
 
390
  # 🟢 动态文件大小风控
391
+ # 🔒 安全修复:上限必须先算出来。原实现是先把整个文件 read() 进内存再判大小,
392
+ # 超大文件会直接吃满容器内存(可被用于打挂服务),现改为分块读取、超限立即中止。
393
  max_size = 10 * 1024 * 1024
394
  if file_type == "avatar":
395
  max_size = 2 * 1024 * 1024
 
409
  max_size = 100 * 1024 * 1024
410
  elif file_type == "post_audio":
411
  max_size = 30 * 1024 * 1024 # 音乐提示词音频:30MB
412
+
413
+ # 分块读取(1MB / 块),累计超限则马上报错,不再继续消耗内存与带宽
414
+ _CHUNK_SIZE = 1024 * 1024
415
+ _chunks = []
416
+ _received = 0
417
+ while True:
418
+ _chunk = file.file.read(_CHUNK_SIZE)
419
+ if not _chunk:
420
+ break
421
+ _received += len(_chunk)
422
+ if _received > max_size:
423
+ raise HTTPException(status_code=400, detail=f"文件过大,{file_type} 类型请限制在 {max_size // (1024*1024)}MB 以内")
424
+ _chunks.append(_chunk)
425
+ content = b"".join(_chunks)
426
 
427
  if not file.filename:
428
  raise HTTPException(status_code=400, detail="文件名不能为空")
router_comments.py CHANGED
@@ -1,5 +1,5 @@
1
  # router_comments.py
2
- from fastapi import APIRouter, HTTPException, Depends
3
  import time
4
  import uuid
5
  from html import escape
@@ -8,6 +8,11 @@ from notifications import add_notification
8
  from models import InteractionToggle, CommentCreate
9
  from 安全认证 import require_auth, is_admin
10
 
 
 
 
 
 
11
  router = APIRouter()
12
 
13
  @router.post("/api/interactions/toggle")
@@ -57,7 +62,8 @@ async def toggle_interaction(interaction: InteractionToggle, current_user: str =
57
  return {"status": "success", "new_count": result_container["count"]}
58
 
59
  @router.post("/api/comments")
60
- async def post_comment(comment: CommentCreate, current_user: str = Depends(require_auth)):
 
61
  comment.author = current_user # 强制使用认证用户身份,防止伪造
62
  # 评论内容长度校验
63
  if not comment.content or len(comment.content) > 5000:
 
1
  # router_comments.py
2
+ from fastapi import APIRouter, HTTPException, Depends, Request
3
  import time
4
  import uuid
5
  from html import escape
 
8
  from models import InteractionToggle, CommentCreate
9
  from 安全认证 import require_auth, is_admin
10
 
11
+ # 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
12
+ from slowapi import Limiter
13
+ from slowapi.util import get_remote_address
14
+ limiter = Limiter(key_func=get_remote_address)
15
+
16
  router = APIRouter()
17
 
18
  @router.post("/api/interactions/toggle")
 
62
  return {"status": "success", "new_count": result_container["count"]}
63
 
64
  @router.post("/api/comments")
65
+ @limiter.limit("10/minute") # 🔒 安全限流:防止刷评论洗版(正常用户不会一分钟发 10 条以上)
66
+ async def post_comment(request: Request, comment: CommentCreate, current_user: str = Depends(require_auth)):
67
  comment.author = current_user # 强制使用认证用户身份,防止伪造
68
  # 评论内容长度校验
69
  if not comment.content or len(comment.content) > 5000:
router_items.py CHANGED
@@ -557,26 +557,33 @@ async def check_github_updates():
557
  headers["Authorization"] = f"Bearer {active_token}"
558
 
559
  req = urllib.request.Request(api_url, headers=headers)
560
- with urllib.request.urlopen(req, timeout=10) as response:
561
- commits = json.loads(response.read().decode("utf-8"))
562
- if commits and len(commits) > 0:
563
- latest_sha = commits[0].get("sha", "")[:7] # 取前7位作为版本标识
564
-
565
- # 如果版本有变化,更新 versions.json
566
- # 🧹 P2修复(格式统一):统一以新格式 dict 读写,保留 cached_at/zip_size 等缓存元字段;
567
- # 旧逻辑拿字符串与 dict 比较会把 dict 条目永远判为"有更新"并覆盖成字符串丢失元信息
568
- entry = versions_db.get(item_id)
569
- if isinstance(entry, str):
570
- entry = {"hash": entry}
571
- elif not isinstance(entry, dict):
572
- entry = {}
573
- old_version = entry.get("hash", "")
574
- if latest_sha and latest_sha != old_version:
575
- entry["hash"] = latest_sha
576
- versions_db[item_id] = entry
577
- updated_count += 1
578
- print(f"[版本更新] {item['title']}: {old_version} -> {latest_sha}")
579
-
 
 
 
 
 
 
 
580
  except urllib.error.HTTPError as e:
581
  print(f"[版本检查失败] {item.get('title', item_id)}: HTTP {e.code}")
582
  except Exception as e:
 
557
  headers["Authorization"] = f"Bearer {active_token}"
558
 
559
  req = urllib.request.Request(api_url, headers=headers)
560
+
561
+ # ⚡ 性能修复:urlopen 是同步阻塞调用(timeout=10),在 async 处理器里
562
+ # 逐个 item 串行等待会占死唯一的事件循环(N 个仓库最坏 N×10 秒全站无响应);
563
+ # 抽成同步小函数交给线程池执行,异常仍由下方 except 分支按原样捕获。
564
+ def _fetch_commits():
565
+ with urllib.request.urlopen(req, timeout=10) as response:
566
+ return json.loads(response.read().decode("utf-8"))
567
+
568
+ commits = await asyncio.to_thread(_fetch_commits)
569
+ if commits and len(commits) > 0:
570
+ latest_sha = commits[0].get("sha", "")[:7] # 取前7位作为版本标识
571
+
572
+ # 如果版本有变化,更新 versions.json
573
+ # 🧹 P2修复(格式统一):统一以新格式 dict 读写,保留 cached_at/zip_size 等缓存元字段;
574
+ # 旧逻辑拿字符串与 dict 比较会把 dict 条目永远判为"有更新"并覆盖成字符串丢失元信息
575
+ entry = versions_db.get(item_id)
576
+ if isinstance(entry, str):
577
+ entry = {"hash": entry}
578
+ elif not isinstance(entry, dict):
579
+ entry = {}
580
+ old_version = entry.get("hash", "")
581
+ if latest_sha and latest_sha != old_version:
582
+ entry["hash"] = latest_sha
583
+ versions_db[item_id] = entry
584
+ updated_count += 1
585
+ print(f"[版本更新] {item['title']}: {old_version} -> {latest_sha}")
586
+
587
  except urllib.error.HTTPError as e:
588
  print(f"[版本检查失败] {item.get('title', item_id)}: HTTP {e.code}")
589
  except Exception as e:
router_messages.py CHANGED
@@ -1,6 +1,7 @@
1
  # router_messages.py
2
- from fastapi import APIRouter, HTTPException, Depends
3
  from pydantic import BaseModel
 
4
  import time
5
  import uuid
6
  import subprocess
@@ -10,6 +11,11 @@ from notifications import add_notification
10
  from models import PrivateMessage
11
  from 安全认证 import require_auth, is_admin
12
 
 
 
 
 
 
13
  router = APIRouter()
14
 
15
  # ==========================================
@@ -107,7 +113,11 @@ async def run_admin_script(req: AdminScriptRequest, current_user: str = Depends(
107
  clean_env = {k: v for k, v in os.environ.items()
108
  if not any(k.startswith(prefix) for prefix in sensitive_prefixes)}
109
  # 执行脚本,超时缩短至30秒
110
- result = subprocess.run(
 
 
 
 
111
  ["python", script_path],
112
  capture_output=True,
113
  text=True,
@@ -150,7 +160,8 @@ async def run_admin_script(req: AdminScriptRequest, current_user: str = Depends(
150
  # 原有功能:私信与聊天
151
  # ==========================================
152
  @router.post("/api/messages/private")
153
- async def send_private_message(msg: PrivateMessage, current_user: str = Depends(require_auth)):
 
154
  msg.sender = current_user # 强制发送者身份,防止伪造
155
 
156
  # 验证接收者存在
 
1
  # router_messages.py
2
+ from fastapi import APIRouter, HTTPException, Depends, Request
3
  from pydantic import BaseModel
4
+ import asyncio
5
  import time
6
  import uuid
7
  import subprocess
 
11
  from models import PrivateMessage
12
  from 安全认证 import require_auth, is_admin
13
 
14
+ # 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
15
+ from slowapi import Limiter
16
+ from slowapi.util import get_remote_address
17
+ limiter = Limiter(key_func=get_remote_address)
18
+
19
  router = APIRouter()
20
 
21
  # ==========================================
 
113
  clean_env = {k: v for k, v in os.environ.items()
114
  if not any(k.startswith(prefix) for prefix in sensitive_prefixes)}
115
  # 执行脚本,超时缩短至30秒
116
+ # ⚡ 性能修复:subprocess.run 会同步等待子进程结束(最长 30 秒),
117
+ # 在 async 处理器里直接调用会占死唯一的事件循环,脚本跑完前整个服务无法响应任何请求;
118
+ # 交给线程池执行,TimeoutExpired 等异常仍由下方原有 except 分支按原样处理。
119
+ result = await asyncio.to_thread(
120
+ subprocess.run,
121
  ["python", script_path],
122
  capture_output=True,
123
  text=True,
 
160
  # 原有功能:私信与聊天
161
  # ==========================================
162
  @router.post("/api/messages/private")
163
+ @limiter.limit("30/minute") # 🔒 安全限流:防止私信骚扰/刷屏(聊天节奏快,阈值比发帖宽松)
164
+ async def send_private_message(request: Request, msg: PrivateMessage, current_user: str = Depends(require_auth)):
165
  msg.sender = current_user # 强制发送者身份,防止伪造
166
 
167
  # 验证接收者存在
router_posts.py CHANGED
@@ -5,7 +5,7 @@
5
  # 功能:帖子发布、列表、详情、互动(点赞/收藏/评论/打赏)
6
  # ==========================================
7
 
8
- from fastapi import APIRouter, HTTPException, Depends
9
  from sqlalchemy.orm import Session
10
  from models import PostCreate, PostUpdate, RatingRequest
11
  import 数据库连接 as db
@@ -20,6 +20,11 @@ import hashlib
20
  import datetime
21
  import logging
22
 
 
 
 
 
 
23
  router = APIRouter()
24
 
25
  # ==========================================
@@ -144,7 +149,8 @@ async def get_post_detail(post_id: str):
144
  raise HTTPException(status_code=404, detail="帖子不存在")
145
 
146
  @router.post("/api/posts")
147
- async def create_post(post: PostCreate, current_user: str = Depends(require_auth)):
 
148
  """
149
  发布帖子
150
  """
 
5
  # 功能:帖子发布、列表、详情、互动(点赞/收藏/评论/打赏)
6
  # ==========================================
7
 
8
+ from fastapi import APIRouter, HTTPException, Depends, Request
9
  from sqlalchemy.orm import Session
10
  from models import PostCreate, PostUpdate, RatingRequest
11
  import 数据库连接 as db
 
20
  import datetime
21
  import logging
22
 
23
+ # 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
24
+ from slowapi import Limiter
25
+ from slowapi.util import get_remote_address
26
+ limiter = Limiter(key_func=get_remote_address)
27
+
28
  router = APIRouter()
29
 
30
  # ==========================================
 
149
  raise HTTPException(status_code=404, detail="帖子不存在")
150
 
151
  @router.post("/api/posts")
152
+ @limiter.limit("5/minute") # 🔒 安全限流:防止刷帖洗版(一次发布含多图上传,但建帖只调一次)
153
+ async def create_post(request: Request, post: PostCreate, current_user: str = Depends(require_auth)):
154
  """
155
  发布帖子
156
  """
router_users_auth.py CHANGED
@@ -144,9 +144,12 @@ async def send_code_api(request: Request, req: SendCodeRequest):
144
  }
145
 
146
  # 同步发送(会阻塞等待结果)
 
 
 
147
  if req.contact_type == "email":
148
  try:
149
- send_email_code(req.contact, code, req.action_type)
150
  return {"status": "success", "message": "验证码已成功发送至邮箱"}
151
  except Exception as e:
152
  # 🔒 P0安全修复:仅服务端记录详细错误,不暴露给前端
 
144
  }
145
 
146
  # 同步发送(会阻塞等待结果)
147
+ # ⚡ 性能修复:send_email_code 内部走 SMTP 同步连接(最长可阻塞十秒级),
148
+ # 直接在 async 处理器里调用会占死唯一的事件循环,导致整个服务期间无法响应任何请求;
149
+ # 改由 asyncio.to_thread 丢到线程池执行,等待结果的同时不阻塞其他请求。
150
  if req.contact_type == "email":
151
  try:
152
+ await asyncio.to_thread(send_email_code, req.contact, code, req.action_type)
153
  return {"status": "success", "message": "验证码已成功发送至邮箱"}
154
  except Exception as e:
155
  # 🔒 P0安全修复:仅服务端记录详细错误,不暴露给前端
verify_code_engine.py CHANGED
@@ -34,6 +34,31 @@ _last_cleanup_time = 0
34
  _CLEANUP_INTERVAL = 300 # 5分钟清理一次
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  def check_send_cooldown(contact: str):
38
  """
39
  🔒 检查发送频率限制
@@ -148,7 +173,8 @@ def send_email_code(to_email: str, code: str, action: str):
148
  )
149
  try:
150
  with urllib.request.urlopen(req, timeout=10) as response:
151
- print(f"✅ 成功触发 Webhook 发送验证码 {code} 至 {to_email}")
 
152
  except Exception as e:
153
  print(f"❌ Webhook 触发失败: {e}")
154
 
@@ -198,7 +224,7 @@ def send_sms_code(phone: str, code: str, action: str):
198
  req.add_header("Authorization", f"Basic {auth}")
199
  try:
200
  with urllib.request.urlopen(req, timeout=10) as response:
201
- print(f"✅ Twilio 短信已成功下发至 {phone}")
202
  except Exception as e:
203
  print(f"❌ Twilio 发送失败: {e}")
204
  return
@@ -239,7 +265,7 @@ def send_sms_code(phone: str, code: str, action: str):
239
  try:
240
  response = client.send_sms(send_req)
241
  if response.body.code == "OK":
242
- print(f"✅ 阿里云短信已成功下发至 {phone}")
243
  else:
244
  print(f"❌ 阿里云下发失败: {response.body.message}")
245
  except Exception as e:
@@ -250,4 +276,9 @@ def send_sms_code(phone: str, code: str, action: str):
250
  # 降级模式:控制台打印模拟
251
  # ==========================================
252
  # 作用:当没有配置任何短信服务时,仅在控制台打印验证码(仅限开发测试)
253
- print(f"⚠️ 未配置短信秘钥,模拟下发 -> 手机号: {phone}, 验证码: {code}")
 
 
 
 
 
 
34
  _CLEANUP_INTERVAL = 300 # 5分钟清理一次
35
 
36
 
37
+ # ==========================================
38
+ # 🔒 日志脱敏工具
39
+ # ==========================================
40
+ # 作用:HF Spaces 的运行日志对外可见,验证码与联系方式明文入日志等同于泄露账号
41
+ # 关联:云端/本地判据与 数据库连接.py 保持一致(HF Spaces 会注入 SPACE_ID)
42
+ _IS_CLOUD = bool(os.environ.get("SPACE_ID"))
43
+
44
+
45
+ def _mask_contact(contact: str) -> str:
46
+ """
47
+ 🔒 联系方式脱敏(邮箱 / 手机号统一处理),仅用于日志输出
48
+ 参数:contact - 原始邮箱或手机号
49
+ 返回:脱敏后的字符串,保留足够辨识度以便排障,但不足以定位到具体用户
50
+ """
51
+ if not contact:
52
+ return ""
53
+ contact = str(contact)
54
+ if "@" in contact:
55
+ name, _, domain = contact.partition("@")
56
+ return f"{name[:1]}***@{domain}" if len(name) > 1 else f"***@{domain}"
57
+ if len(contact) >= 7:
58
+ return contact[:3] + "****" + contact[-2:]
59
+ return "***"
60
+
61
+
62
  def check_send_cooldown(contact: str):
63
  """
64
  🔒 检查发送频率限制
 
173
  )
174
  try:
175
  with urllib.request.urlopen(req, timeout=10) as response:
176
+ # 🔒 安全修复:不再打印验证码与邮箱明文(日志对外可见),仅留脱敏标识与动作类型
177
+ print(f"✅ 成功触发 Webhook 发送验证码邮件至 {_mask_contact(to_email)}(动作: {action})")
178
  except Exception as e:
179
  print(f"❌ Webhook 触发失败: {e}")
180
 
 
224
  req.add_header("Authorization", f"Basic {auth}")
225
  try:
226
  with urllib.request.urlopen(req, timeout=10) as response:
227
+ print(f"✅ Twilio 短信已成功下发至 {_mask_contact(phone)}")
228
  except Exception as e:
229
  print(f"❌ Twilio 发送失败: {e}")
230
  return
 
265
  try:
266
  response = client.send_sms(send_req)
267
  if response.body.code == "OK":
268
+ print(f"✅ 阿里云短信已成功下发至 {_mask_contact(phone)}")
269
  else:
270
  print(f"❌ 阿里云下发失败: {response.body.message}")
271
  except Exception as e:
 
276
  # 降级模式:控制台打印模拟
277
  # ==========================================
278
  # 作用:当没有配置任何短信服务时,仅在控制台打印验证码(仅限开发测试)
279
+ # 🔒 P0安全修复:云端日志对外可见,生产环境绝不能打印验证码明文;
280
+ # 本地开发(无 SPACE_ID)保留原行为,不影响调试便利性。
281
+ if _IS_CLOUD:
282
+ print(f"⚠️ 未配置短信密钥,验证码未实际下发(目标: {_mask_contact(phone)})")
283
+ else:
284
+ print(f"⚠️ 未配置短信密钥,模拟下发 -> 手机号: {phone}, 验证码: {code}")