Spaces:
Running
Running
Upload 15 files
Browse files- models.py +11 -0
- profile_media.py +85 -0
- rate_limit.py +5 -1
- 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
def _user(data, account):
|
|
@@ -63,12 +71,27 @@ def attach_profile_counts(record, items):
|
|
| 63 |
return result
|
| 64 |
|
| 65 |
|
| 66 |
-
def
|
| 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 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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
|