fix: webapp accesability when tg is unreachable

This commit is contained in:
3252a8
2026-05-06 21:39:11 +03:00
parent ca8df10eab
commit 8783129c16
11 changed files with 743 additions and 64 deletions
+145 -8
View File
@@ -2,6 +2,7 @@ import asyncio
import base64
import hashlib
import hmac
import io
import ipaddress
import json
import logging
@@ -49,7 +50,7 @@ from bot.utils.request_security import request_client_ip
from config.settings import Settings
from db.dal import payment_dal, subscription_dal, user_dal
from db.dal.user_dal import UserMergeConflictError
from db.models import Payment, User
from db.models import Payment, User, UserTelegramAvatar
logger = logging.getLogger(__name__)
@@ -64,6 +65,9 @@ DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
WEBAPP_TELEGRAM_AVATAR_MAX_BYTES = 128 * 1024
WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60
WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4
WEBAPP_SESSION_COOKIE_NAME = "rw_webapp_session"
WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf"
WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token"
@@ -196,6 +200,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_post("/api/auth/email/magic", email_auth_magic_route)
app.router.add_post("/api/auth/logout", logout_route)
app.router.add_get("/api/me", me_route)
app.router.add_get("/api/account/avatar", account_avatar_route)
app.router.add_post("/api/account/language", account_language_route)
app.router.add_post("/api/account/email/request", account_email_request_route)
app.router.add_post("/api/account/email/verify", account_email_verify_route)
@@ -222,7 +227,9 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
return ""
parsed_logo_url = urlsplit(raw_logo_url)
if parsed_logo_url.scheme in {"https", "http", "data"}:
if parsed_logo_url.scheme == "https":
return WEBAPP_LOGO_PROXY_PATH
if parsed_logo_url.scheme in {"http", "data"}:
return raw_logo_url
if raw_logo_url.startswith("/"):
return raw_logo_url
@@ -364,7 +371,7 @@ async def webapp_logo_route(request: web.Request) -> web.Response:
body, content_type = logo_cache
response = web.Response(body=body, content_type=content_type)
response.headers["Cache-Control"] = "no-cache"
response.headers["Cache-Control"] = "public, max-age=3600"
return response
@@ -372,7 +379,7 @@ async def _fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]:
"""Fetch and cache the configured logo on the server side."""
try:
session = await _get_shared_http_session()
timeout = ClientTimeout(total=5)
timeout = ClientTimeout(total=3)
async with session.get(logo_url, allow_redirects=False, timeout=timeout) as response:
if response.status != 200:
logger.warning(
@@ -492,10 +499,10 @@ async def _security_headers_middleware(request: web.Request, handler):
f"script-src 'self' 'nonce-{nonce}' 'unsafe-eval' https://telegram.org; "
"frame-src https://oauth.telegram.org; "
"frame-ancestors https://web.telegram.org https://t.me; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; "
"style-src 'self' 'unsafe-inline'; "
"font-src 'self' https://cdn.jsdelivr.net data:; "
"img-src 'self' data: https: http:; "
"connect-src 'self'; "
"connect-src 'self' https://oauth.telegram.org; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'"
@@ -1562,6 +1569,35 @@ async def me_route(request: web.Request) -> web.Response:
return web.json_response({"ok": True, **data})
async def account_avatar_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
await session.commit()
if not avatar:
raise web.HTTPNotFound(text="avatar_not_cached")
etag = _telegram_avatar_etag(avatar)
if etag and request.headers.get("If-None-Match") == etag:
return web.Response(status=304, headers={"ETag": etag})
response = web.Response(
body=bytes(avatar.image_bytes),
content_type=avatar.content_type or "image/jpeg",
)
response.headers["Cache-Control"] = "private, max-age=3600"
if etag:
response.headers["ETag"] = etag
return response
async def account_language_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
payload = await _read_json(request)
@@ -2184,6 +2220,106 @@ def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
return value or None
def _telegram_avatar_is_stale(avatar: Optional[UserTelegramAvatar]) -> bool:
if not avatar or not avatar.updated_at:
return True
updated_at = avatar.updated_at
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - updated_at).total_seconds() >= WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS
def _telegram_avatar_etag(avatar: UserTelegramAvatar) -> str:
digest = hashlib.sha256(bytes(avatar.image_bytes)).hexdigest()[:16]
return f'"tg-avatar-{int(avatar.user_id)}-{digest}"'
def _telegram_avatar_url(avatar: Optional[UserTelegramAvatar]) -> str:
if not avatar:
return ""
updated_at = avatar.updated_at
if updated_at and updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=timezone.utc)
version = int(updated_at.timestamp()) if updated_at else hashlib.sha256(bytes(avatar.image_bytes)).hexdigest()[:8]
return f"/api/account/avatar?v={version}"
def _select_compact_telegram_photo_size(sizes: List[Any]) -> Optional[Any]:
if not sizes:
return None
suitable = [size for size in sizes if int(getattr(size, "width", 0) or 0) >= 160]
candidates = suitable or sizes
return min(
candidates,
key=lambda size: (
int(getattr(size, "file_size", 0) or 0) or int(getattr(size, "width", 0) or 0) * int(getattr(size, "height", 0) or 0),
int(getattr(size, "width", 0) or 0),
),
)
def _telegram_file_content_type(file_path: Optional[str]) -> str:
path = str(file_path or "").lower()
if path.endswith(".png"):
return "image/png"
if path.endswith(".webp"):
return "image/webp"
return "image/jpeg"
async def _fetch_compact_telegram_avatar(bot: Bot, telegram_id: int) -> Optional[Tuple[bytes, str, Optional[str]]]:
photos = await bot.get_user_profile_photos(user_id=telegram_id, limit=1)
if not photos or not photos.photos:
return None
photo_size = _select_compact_telegram_photo_size(list(photos.photos[0] or []))
if not photo_size:
return None
file_info = await bot.get_file(photo_size.file_id)
destination = io.BytesIO()
await bot.download_file(file_info.file_path, destination=destination)
body = destination.getvalue()
if not body or len(body) > WEBAPP_TELEGRAM_AVATAR_MAX_BYTES:
return None
return body, _telegram_file_content_type(file_info.file_path), getattr(photo_size, "file_unique_id", None)
async def _ensure_cached_telegram_avatar(
request: web.Request,
session: AsyncSession,
user: User,
) -> Optional[UserTelegramAvatar]:
avatar = await user_dal.get_user_telegram_avatar(session, int(user.user_id))
telegram_id = _telegram_id_for_user(user)
if not telegram_id:
return avatar
if avatar and not _telegram_avatar_is_stale(avatar):
return avatar
bot: Bot = request.app["bot"]
try:
fetched = await asyncio.wait_for(
_fetch_compact_telegram_avatar(bot, int(telegram_id)),
timeout=WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS,
)
except Exception as exc:
logger.info("Failed to refresh Telegram avatar for user %s: %s", user.user_id, exc)
return avatar
if not fetched:
return avatar
body, content_type, file_unique_id = fetched
return await user_dal.upsert_user_telegram_avatar(
session,
user_id=int(user.user_id),
file_unique_id=file_unique_id,
content_type=content_type,
image_bytes=body,
)
def _apply_telegram_profile_to_user(
user: User,
telegram_user: Dict[str, Any],
@@ -2481,6 +2617,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
and settings.TRIAL_DURATION_DAYS > 0
and not await subscription_service.has_had_any_subscription(session, user_id)
)
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
try:
await session.commit()
except Exception:
@@ -2495,7 +2632,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"email_verified": bool(db_user.email_verified_at),
"telegram_id": db_user.telegram_id,
"telegram_linked": bool(_telegram_id_for_user(db_user)),
"telegram_photo_url": db_user.telegram_photo_url,
"telegram_photo_url": _telegram_avatar_url(avatar),
"first_name": db_user.first_name,
"language_code": lang,
},