ZHIWEI666 commited on
Commit
be93389
·
verified ·
1 Parent(s): 11350d0

Upload 15 files

Browse files
Files changed (4) hide show
  1. models.py +11 -0
  2. profile_media.py +85 -0
  3. rate_limit.py +5 -1
  4. user_store.py +32 -9
models.py CHANGED
@@ -2,6 +2,7 @@
2
  import re
3
  from pydantic import BaseModel, Field, field_validator # 🧹 P2修复:pydantic v2 已废弃 v1 风格 validator
4
  from typing import Optional, List, Literal
 
5
 
6
  def _validate_email_format(email: str) -> str:
7
  """邮箱格式校验"""
@@ -48,6 +49,11 @@ class UserRegister(BaseModel):
48
  def _check_email(cls, v):
49
  return _validate_email_format(v)
50
 
 
 
 
 
 
51
  class UserLogin(BaseModel):
52
  account: str
53
  password: str
@@ -69,6 +75,11 @@ class UserUpdate(BaseModel):
69
  avatarDataUrl: Optional[str] = None
70
  bannerUrl: Optional[str] = None # 🚀 个人资料卡背景图云端URL
71
 
 
 
 
 
 
72
  class SettingsPrivacy(BaseModel):
73
  model_config = {"extra": "forbid"}
74
  follows: bool = Field(strict=True)
 
2
  import re
3
  from pydantic import BaseModel, Field, field_validator # 🧹 P2修复:pydantic v2 已废弃 v1 风格 validator
4
  from typing import Optional, List, Literal
5
+ from profile_media import normalize_profile_media
6
 
7
  def _validate_email_format(email: str) -> str:
8
  """邮箱格式校验"""
 
49
  def _check_email(cls, v):
50
  return _validate_email_format(v)
51
 
52
+ @field_validator('avatarDataUrl')
53
+ @classmethod
54
+ def _check_avatar_url(cls, value):
55
+ return normalize_profile_media(value)
56
+
57
  class UserLogin(BaseModel):
58
  account: str
59
  password: str
 
75
  avatarDataUrl: Optional[str] = None
76
  bannerUrl: Optional[str] = None # 🚀 个人资料卡背景图云端URL
77
 
78
+ @field_validator('avatarDataUrl', 'bannerUrl')
79
+ @classmethod
80
+ def _check_media_url(cls, value):
81
+ return normalize_profile_media(value)
82
+
83
  class SettingsPrivacy(BaseModel):
84
  model_config = {"extra": "forbid"}
85
  follows: bool = Field(strict=True)
profile_media.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """资料持久媒体地址准入;不解析DNS、不下载内容。"""
2
+ import ipaddress
3
+ import re
4
+ from urllib.parse import unquote, urlsplit
5
+ from deployment_config import PUBLIC_API_BASE_URL
6
+
7
+ MAX_PROFILE_MEDIA_URL_BYTES = 16384
8
+ LOCAL_IMAGE_PREFIX = "/community_hub/image?url="
9
+ COMPLETE_IMAGE_SUFFIX = "&require_complete=1"
10
+ INVALID_MEDIA = "invalid_profile_media_url"
11
+
12
+
13
+ def _host(parts):
14
+ host = parts.hostname
15
+ if not host or "%" in host:
16
+ raise ValueError(INVALID_MEDIA)
17
+ host = host.removesuffix(".").lower()
18
+ if not host:
19
+ raise ValueError(INVALID_MEDIA)
20
+ try:
21
+ address = ipaddress.ip_address(host)
22
+ except ValueError:
23
+ ascii_host = host.encode("idna").decode("ascii")
24
+ labels = ascii_host.split(".")
25
+ if (len(ascii_host) > 253 or not all(re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) for label in labels)):
26
+ raise ValueError(INVALID_MEDIA)
27
+ return ascii_host, None
28
+ return address.compressed, address
29
+
30
+
31
+ def normalize_profile_media(value):
32
+ if value is None:
33
+ return None
34
+ if not isinstance(value, str):
35
+ raise ValueError(INVALID_MEDIA)
36
+ if value == "":
37
+ return ""
38
+ if value.strip() != value or any(ord(char) < 32 or ord(char) == 127 for char in value):
39
+ raise ValueError(INVALID_MEDIA)
40
+ if value[:5].lower() in {"data:", "blob:"}:
41
+ return None
42
+ if len(value.encode("utf-8")) > MAX_PROFILE_MEDIA_URL_BYTES or "\\" in value:
43
+ raise ValueError(INVALID_MEDIA)
44
+ source = value
45
+ layers = 0
46
+ while source.startswith(LOCAL_IMAGE_PREFIX):
47
+ layers += 1
48
+ if layers > 10:
49
+ raise ValueError(INVALID_MEDIA)
50
+ encoded = source[len(LOCAL_IMAGE_PREFIX):]
51
+ if encoded.endswith(COMPLETE_IMAGE_SUFFIX):
52
+ encoded = encoded[:-len(COMPLETE_IMAGE_SUFFIX)]
53
+ if re.search(r"%(?![a-fA-F0-9]{2})", encoded):
54
+ raise ValueError(INVALID_MEDIA)
55
+ source = unquote(encoded, errors="strict")
56
+ if source.strip() != source or "\\" in source or any(ord(char) < 32 or ord(char) == 127 for char in source):
57
+ raise ValueError(INVALID_MEDIA)
58
+ if not re.match(r"^https?://", source, re.I):
59
+ raise ValueError(INVALID_MEDIA)
60
+ try:
61
+ parsed = urlsplit(source)
62
+ if parsed.username is not None or parsed.password is not None or parsed.netloc.endswith(":"):
63
+ raise ValueError(INVALID_MEDIA)
64
+ if parsed.port is not None and not 1 <= parsed.port <= 65535:
65
+ raise ValueError(INVALID_MEDIA)
66
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
67
+ host, address = _host(parsed)
68
+ base = urlsplit(PUBLIC_API_BASE_URL)
69
+ base_host, _ = _host(base)
70
+ if host == base_host:
71
+ base_port = base.port or (443 if base.scheme == "https" else 80)
72
+ if parsed.scheme != base.scheme or port != base_port or parsed.path != base.path.rstrip("/") + "/api/image_proxy":
73
+ raise ValueError(INVALID_MEDIA)
74
+ return source
75
+ if address:
76
+ if (not address.is_global or address.is_multicast or (address.version == 6
77
+ and (address.ipv4_mapped is not None or int(address) >> 125 != 1))):
78
+ raise ValueError(INVALID_MEDIA)
79
+ elif (host == "localhost" or host.endswith((".localhost", ".local")) or "." not in host
80
+ or re.fullmatch(r"(?:0x[0-9a-f]+|[0-9]+)(?:\.(?:0x[0-9a-f]+|[0-9]+))*", host, re.I)
81
+ or re.fullmatch(r"(?:0x[0-9a-f]+|[0-9]+)", host.rsplit(".", 1)[-1], re.I)):
82
+ raise ValueError(INVALID_MEDIA)
83
+ return source
84
+ except (ValueError, UnicodeError):
85
+ raise ValueError(INVALID_MEDIA) from None
rate_limit.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  from datetime import datetime
4
  from functools import wraps
 
5
  import hashlib
6
  import hmac
7
  import logging
@@ -171,6 +172,7 @@ def shared_limit(limit: int):
171
 
172
  def decorate(endpoint):
173
  scope = f"{endpoint.__module__}.{endpoint.__name__}"
 
174
 
175
  @wraps(endpoint)
176
  async def wrapped(*args, **kwargs):
@@ -181,7 +183,9 @@ def shared_limit(limit: int):
181
  raise RuntimeError(f"{scope} requires a FastAPI Request parameter")
182
  identity = await run_in_threadpool(get_rate_limit_key, request)
183
  await run_in_threadpool(_consume_shared_limit, identity, scope, limit)
184
- return await endpoint(*args, **kwargs)
 
 
185
 
186
  return wrapped
187
 
 
2
 
3
  from datetime import datetime
4
  from functools import wraps
5
+ from inspect import iscoroutinefunction
6
  import hashlib
7
  import hmac
8
  import logging
 
172
 
173
  def decorate(endpoint):
174
  scope = f"{endpoint.__module__}.{endpoint.__name__}"
175
+ is_async = iscoroutinefunction(endpoint)
176
 
177
  @wraps(endpoint)
178
  async def wrapped(*args, **kwargs):
 
183
  raise RuntimeError(f"{scope} requires a FastAPI Request parameter")
184
  identity = await run_in_threadpool(get_rate_limit_key, request)
185
  await run_in_threadpool(_consume_shared_limit, identity, scope, limit)
186
+ if is_async:
187
+ return await endpoint(*args, **kwargs)
188
+ return await run_in_threadpool(endpoint, *args, **kwargs)
189
 
190
  return wrapped
191
 
user_store.py CHANGED
@@ -5,6 +5,7 @@ import time
5
 
6
  from fastapi import HTTPException
7
  import 数据库连接 as db
 
8
 
9
  logger = logging.getLogger(__name__)
10
  PROFILE_FIELDS = frozenset({"account", "name", "avatar", "avatarDataUrl", "bannerUrl", "intro", "gender", "age",
@@ -17,7 +18,14 @@ PRIVACY_FIELDS = frozenset({"follows", "likes", "favorites", "downloads"})
17
 
18
  def project_profile(record, owner=False):
19
  fields = PROFILE_FIELDS | OWNER_FIELDS if owner else PROFILE_FIELDS
20
- return copy.deepcopy({key: value for key, value in record.items() if key in fields})
 
 
 
 
 
 
 
21
 
22
 
23
  def _user(data, account):
@@ -63,12 +71,27 @@ def attach_profile_counts(record, items):
63
  return result
64
 
65
 
66
- def update_profile(account, updates):
67
- # 路由已经过 UserUpdate 校验;这里再限定字段,避免未来调用方扩散写入范围。
68
  patch = copy.deepcopy({key: value for key, value in updates.items()
69
  if key in EDITABLE_FIELDS and value is not None})
70
- if isinstance(patch.get("avatarDataUrl"), str) and patch["avatarDataUrl"].startswith("data:"):
71
- del patch["avatarDataUrl"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  def updater(data):
74
  record = _user(data, account)
@@ -82,10 +105,7 @@ def update_settings(account, updates, privacy):
82
  if not isinstance(privacy, dict) or set(privacy) != PRIVACY_FIELDS or any(type(value) is not bool for value in privacy.values()):
83
  raise HTTPException(status_code=400, detail="隐私设置不正确")
84
  settings = copy.deepcopy(privacy)
85
- patch = copy.deepcopy({key: value for key, value in updates.items()
86
- if key in EDITABLE_FIELDS and value is not None})
87
- if isinstance(patch.get("avatarDataUrl"), str) and patch["avatarDataUrl"].startswith("data:"):
88
- del patch["avatarDataUrl"]
89
 
90
  def updater(data):
91
  record = _user(data, account)
@@ -169,6 +189,9 @@ def check_registration(account, email, phone):
169
 
170
  def create_user(record):
171
  incoming = copy.deepcopy(record)
 
 
 
172
  def updater(data):
173
  _registration_available(data, incoming["account"], incoming.get("email"), incoming.get("phone"))
174
  data[incoming["account"]] = incoming
 
5
 
6
  from fastapi import HTTPException
7
  import 数据库连接 as db
8
+ from profile_media import normalize_profile_media
9
 
10
  logger = logging.getLogger(__name__)
11
  PROFILE_FIELDS = frozenset({"account", "name", "avatar", "avatarDataUrl", "bannerUrl", "intro", "gender", "age",
 
18
 
19
  def project_profile(record, owner=False):
20
  fields = PROFILE_FIELDS | OWNER_FIELDS if owner else PROFILE_FIELDS
21
+ result = copy.deepcopy({key: value for key, value in record.items() if key in fields})
22
+ for field in ('avatar', 'avatarDataUrl', 'bannerUrl'):
23
+ if field in result:
24
+ try:
25
+ result[field] = normalize_profile_media(result[field]) or ''
26
+ except (ValueError, TypeError):
27
+ result[field] = ''
28
+ return result
29
 
30
 
31
  def _user(data, account):
 
71
  return result
72
 
73
 
74
+ def _profile_patch(updates):
 
75
  patch = copy.deepcopy({key: value for key, value in updates.items()
76
  if key in EDITABLE_FIELDS and value is not None})
77
+ for field in ('avatarDataUrl', 'bannerUrl'):
78
+ if field not in patch:
79
+ continue
80
+ try:
81
+ value = normalize_profile_media(patch[field])
82
+ except (ValueError, TypeError):
83
+ raise HTTPException(status_code=422, detail=[{'loc': ['body', field],
84
+ 'type': 'value_error', 'msg': 'invalid_profile_media_url'}]) from None
85
+ if value is None:
86
+ del patch[field]
87
+ else:
88
+ patch[field] = value
89
+ return patch
90
+
91
+
92
+ def update_profile(account, updates):
93
+ # 路由已经过 UserUpdate 校验;这里再限定字段,避免未来调用方扩散写入范围。
94
+ patch = _profile_patch(updates)
95
 
96
  def updater(data):
97
  record = _user(data, account)
 
105
  if not isinstance(privacy, dict) or set(privacy) != PRIVACY_FIELDS or any(type(value) is not bool for value in privacy.values()):
106
  raise HTTPException(status_code=400, detail="隐私设置不正确")
107
  settings = copy.deepcopy(privacy)
108
+ patch = _profile_patch(updates)
 
 
 
109
 
110
  def updater(data):
111
  record = _user(data, account)
 
189
 
190
  def create_user(record):
191
  incoming = copy.deepcopy(record)
192
+ for field in ('avatarDataUrl', 'bannerUrl'):
193
+ if field in incoming:
194
+ incoming[field] = _profile_patch({field: incoming[field]}).get(field, '')
195
  def updater(data):
196
  _registration_available(data, incoming["account"], incoming.get("email"), incoming.get("phone"))
197
  data[incoming["account"]] = incoming