diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index 8ba76fd..bb523f4 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -31,6 +31,7 @@ import { Select, Tooltip } from "bits-ui"; import Button from "./lib/components/ui/button.svelte"; + import BrandMark from "./BrandMark.svelte"; import Card from "./lib/components/ui/card.svelte"; import Dialog from "./lib/components/ui/dialog.svelte"; import Input from "./lib/components/ui/input.svelte"; @@ -62,6 +63,12 @@ devices: "/devices", settings: "/settings", }; + const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js"; + const TELEGRAM_OAUTH_AVAILABILITY_URL = "https://oauth.telegram.org/"; + const TELEGRAM_SDK_BOOT_TIMEOUT_MS = 900; + const TELEGRAM_SDK_ACTION_TIMEOUT_MS = 1800; + const TELEGRAM_OAUTH_AVAILABILITY_TIMEOUT_MS = 15000; + const TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS = 15000; const DEV_MOCK = { config: { @@ -204,7 +211,11 @@ ...(injectedConfig || {}), }; const I18N = injectedI18n || {}; - const tg = window.Telegram && window.Telegram.WebApp ? window.Telegram.WebApp : null; + let tg = resolveTelegramWebApp(); + let telegramSdkStatus = tg ? "ready" : "idle"; + let telegramSdkPromise = null; + let telegramLaunchParamsDetected = false; + let telegramOAuthUnavailable = false; let mode = isPreviewBoard ? "preview" : "loading"; let activeTab = "home"; @@ -256,6 +267,9 @@ let authStatus = ""; let authIsError = false; let authBusy = false; + let telegramLoginBusy = false; + let telegramLoginWatchdogTimer = null; + let telegramLoginAttemptId = 0; let loginEmailFieldError = ""; let loginEmailTooltipOpen = false; let authResendCooldown = 0; @@ -497,6 +511,27 @@ $: supportUrl = String(appSettings?.support_url || CFG.supportUrl || "").trim(); $: telegramLoginBotId = Number(CFG.telegramLoginBotId || 0); $: telegramOAuthClientId = Number(CFG.telegramOAuthClientId || telegramLoginBotId || 0); + $: telegramMiniAppAuthAvailable = Boolean(tg?.initData); + $: telegramMiniAppSdkUnavailable = + telegramLaunchParamsDetected && !telegramMiniAppAuthAvailable && telegramSdkStatus === "unavailable"; + $: telegramLoginUnavailable = + telegramOAuthUnavailable || + telegramMiniAppSdkUnavailable || + (!telegramMiniAppAuthAvailable && !telegramOAuthClientId && telegramSdkStatus !== "loading"); + $: telegramLoginChecking = telegramLoginBusy || (authBusy && authStatus === t("wa_auth_checking_telegram")); + $: telegramLoginLabel = telegramLoginUnavailable + ? t("wa_login_telegram_unavailable_button") + : telegramLoginChecking + ? t("wa_auth_checking_telegram") + : t("wa_login_telegram_button"); + $: telegramLoginUnavailableMessage = + telegramOAuthUnavailable + ? t("wa_auth_telegram_timeout") + : telegramLoginUnavailable && telegramSdkStatus === "unavailable" + ? t("wa_auth_telegram_unavailable") + : telegramLoginUnavailable + ? t("wa_auth_telegram_not_configured") + : ""; $: applyFavicon(CFG.logoUrl, brandEmoji); $: syncBodyScrollLock(paymentModalOpen || changeModalOpen || changeConfirmOpen || topupModalOpen || deviceTopupModalOpen || linkEmailOpen); $: if (!tariffMode && !selectedPlan && plans.length) selectedPlan = plans[Math.min(1, plans.length - 1)]; @@ -547,6 +582,7 @@ return () => { window.removeEventListener("popstate", onPopState); window.removeEventListener("pointerdown", onAnyPointerDown); + stopTelegramLoginWatchdog(); clearCooldownTimer("auth"); clearCooldownTimer("link_email"); clearLanguageClickGuard(); @@ -695,8 +731,156 @@ window.history[replace ? "replaceState" : "pushState"](null, "", nextUrl); } + function resolveTelegramWebApp() { + return window.Telegram?.WebApp || null; + } + + function refreshTelegramWebApp() { + tg = resolveTelegramWebApp(); + if (tg) telegramSdkStatus = "ready"; + if (tg?.initData) telegramLaunchParamsDetected = true; + return tg; + } + + function hasTelegramLaunchParams() { + refreshTelegramWebApp(); + if (telegramLaunchParamsDetected || tg?.initData) { + telegramLaunchParamsDetected = true; + return true; + } + const queryText = window.location.search.replace(/^\?/, ""); + const hashText = window.location.hash.replace(/^#/, ""); + const detected = [queryText, hashText].some((text) => { + if (!text) return false; + const params = new URLSearchParams(text); + return ["tgWebAppData", "tgWebAppVersion", "tgWebAppPlatform", "tgWebAppThemeParams"].some((key) => + params.has(key), + ); + }); + if (detected) telegramLaunchParamsDetected = true; + return detected; + } + + function loadTelegramSdk(timeoutMs = TELEGRAM_SDK_BOOT_TIMEOUT_MS) { + if (refreshTelegramWebApp()) return Promise.resolve(tg); + if (telegramSdkPromise) return telegramSdkPromise; + if (typeof document === "undefined") return Promise.resolve(null); + + telegramSdkStatus = "loading"; + telegramSdkPromise = new Promise((resolve) => { + const existingScript = document.querySelector("script[data-rw-telegram-web-app-sdk]"); + const script = existingScript || document.createElement("script"); + let resolved = false; + let timeoutId = null; + + const resolveOnce = (value) => { + if (resolved) return; + resolved = true; + if (timeoutId) window.clearTimeout(timeoutId); + resolve(value); + }; + + const refreshFromScript = () => { + tg = resolveTelegramWebApp(); + telegramSdkStatus = tg ? "ready" : "unavailable"; + return tg; + }; + + script.addEventListener("load", () => resolveOnce(refreshFromScript()), { once: true }); + script.addEventListener( + "error", + () => { + telegramSdkStatus = "unavailable"; + resolveOnce(null); + }, + { once: true }, + ); + + if (!existingScript) { + script.src = TELEGRAM_WEBAPP_SCRIPT_URL; + script.async = true; + script.defer = true; + script.dataset.rwTelegramWebAppSdk = "1"; + document.head.appendChild(script); + } + + timeoutId = window.setTimeout(() => { + if (!tg) telegramSdkStatus = "unavailable"; + resolveOnce(tg); + }, timeoutMs); + }).finally(() => { + telegramSdkPromise = null; + }); + return telegramSdkPromise; + } + + async function ensureTelegramSdkForAction() { + if (refreshTelegramWebApp()) return tg; + return await loadTelegramSdk(TELEGRAM_SDK_ACTION_TIMEOUT_MS); + } + + async function ensureTelegramOAuthAvailable() { + telegramOAuthUnavailable = false; + const controller = typeof AbortController === "undefined" ? null : new AbortController(); + const timeoutId = window.setTimeout(() => controller?.abort(), TELEGRAM_OAUTH_AVAILABILITY_TIMEOUT_MS); + try { + await fetch(`${TELEGRAM_OAUTH_AVAILABILITY_URL}?rw_check=${Date.now()}`, { + method: "GET", + mode: "no-cors", + cache: "no-store", + signal: controller?.signal, + }); + return true; + } catch { + telegramSdkStatus = "unavailable"; + telegramOAuthUnavailable = true; + return false; + } finally { + window.clearTimeout(timeoutId); + } + } + + function createTelegramMiniAppAuthTimeout() { + const controller = typeof AbortController === "undefined" ? null : new AbortController(); + let timedOut = false; + let timeoutId = null; + let timeoutPromise = new Promise(() => {}); + + if (typeof window !== "undefined") { + timeoutPromise = new Promise((_, reject) => { + timeoutId = window.setTimeout(() => { + timedOut = true; + controller?.abort(); + const error = new Error("telegram_mini_app_auth_timeout"); + error.name = "AbortError"; + reject(error); + }, TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS); + }); + } + + return { + promise: timeoutPromise, + get signal() { + return controller?.signal; + }, + get timedOut() { + return timedOut; + }, + clear() { + if (timeoutId) window.clearTimeout(timeoutId); + timeoutId = null; + }, + }; + } + + function shouldWaitForTelegramSdkBeforeOAuth() { + return hasTelegramLaunchParams() || !telegramOAuthClientId; + } + async function boot() { mode = "loading"; + if (hasTelegramLaunchParams()) await loadTelegramSdk(TELEGRAM_SDK_BOOT_TIMEOUT_MS); + if (tg) { try { tg.ready(); @@ -803,7 +987,7 @@ return payload; } - async function publicApi(path, payload = {}) { + async function publicApi(path, payload = {}, options = {}) { if (MOCK) { return mockApi(path, { method: "POST", body: JSON.stringify(payload) }); } @@ -811,6 +995,7 @@ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), + signal: options.signal, }); return response.json(); } @@ -1014,7 +1199,7 @@ return false; } - async function finalizeTelegramAuth(authData, source = "auth_data") { + async function finalizeTelegramAuth(authData, source = "auth_data", options = {}) { if (authBusy) return false; authBusy = true; setAuthStatus(t("wa_auth_checking_telegram")); @@ -1027,7 +1212,7 @@ : { auth_data: authData }; const referralParam = readReferralParam(); if (referralParam) payload.referral_code = referralParam; - const response = await publicApi("/auth/token", payload); + const response = await publicApi("/auth/token", payload, { signal: options.signal }); if (response.ok && response.token) { setToken(response.token, response.csrf_token); clearAuthQuery(); @@ -1036,8 +1221,11 @@ return true; } setAuthStatus(response.error === "banned" ? t("wa_auth_access_denied") : t("wa_auth_telegram_not_confirmed"), true); - } catch { - setAuthStatus(t("wa_auth_telegram_unavailable"), true); + } catch (error) { + setAuthStatus( + error?.name === "AbortError" ? t("wa_auth_telegram_timeout") : t("wa_auth_telegram_unavailable"), + true, + ); } finally { authBusy = false; } @@ -1166,21 +1354,114 @@ return url.toString(); } + function startTelegramLoginWatchdog() { + stopTelegramLoginWatchdog(); + telegramLoginAttemptId += 1; + const attemptId = telegramLoginAttemptId; + telegramLoginWatchdogTimer = window.setTimeout(() => { + if (attemptId !== telegramLoginAttemptId) return; + telegramLoginWatchdogTimer = null; + telegramSdkStatus = "unavailable"; + telegramOAuthUnavailable = true; + telegramLoginBusy = false; + authBusy = false; + setAuthStatus(t("wa_auth_telegram_timeout"), true); + }, TELEGRAM_OAUTH_AVAILABILITY_TIMEOUT_MS); + return attemptId; + } + + function stopTelegramLoginWatchdog(attemptId = null) { + if (attemptId !== null && attemptId !== telegramLoginAttemptId) return; + if (telegramLoginWatchdogTimer) { + window.clearTimeout(telegramLoginWatchdogTimer); + telegramLoginWatchdogTimer = null; + } + } + + function isActiveTelegramLoginAttempt(attemptId) { + return attemptId === telegramLoginAttemptId && telegramLoginBusy; + } + async function openTelegramLogin() { - if (authBusy) return; - if (tg?.initData) { - await finalizeTelegramAuth(tg.initData, "init_data"); + if (authBusy || telegramLoginBusy || telegramLoginUnavailable) return; + setAuthStatus(""); + + const isTelegramMiniAppAttempt = hasTelegramLaunchParams(); + if (!isTelegramMiniAppAttempt && telegramOAuthClientId) { + telegramLoginBusy = true; + const attemptId = startTelegramLoginWatchdog(); + try { + const available = await ensureTelegramOAuthAvailable(); + if (!isActiveTelegramLoginAttempt(attemptId)) return; + if (!available) { + setAuthStatus(t("wa_auth_telegram_timeout"), true); + return; + } + stopTelegramLoginWatchdog(attemptId); + telegramLoginBusy = false; + authBusy = false; + window.location.assign(buildTelegramOAuthStartUrl("login")); + } catch { + if (!isActiveTelegramLoginAttempt(attemptId)) return; + telegramSdkStatus = "unavailable"; + telegramOAuthUnavailable = true; + setAuthStatus(t("wa_auth_telegram_timeout"), true); + } finally { + if (isActiveTelegramLoginAttempt(attemptId)) { + stopTelegramLoginWatchdog(attemptId); + telegramLoginBusy = false; + } + } return; } - if (!telegramOAuthClientId) { - setAuthStatus(t("wa_auth_telegram_not_configured"), true); - return; - } + telegramLoginBusy = true; + const attemptId = startTelegramLoginWatchdog(); + const loginTimeout = createTelegramMiniAppAuthTimeout(); + try { + await Promise.race([ + (async () => { + await ensureTelegramSdkForAction(); + if (!isActiveTelegramLoginAttempt(attemptId)) return; + if (tg?.initData) { + await finalizeTelegramAuth(tg.initData, "init_data", { signal: loginTimeout.signal }); + return; + } - authBusy = true; - setAuthStatus(t("wa_auth_checking_telegram")); - window.location.assign(buildTelegramOAuthStartUrl("login")); + if (!telegramOAuthClientId || isTelegramMiniAppAttempt) { + setAuthStatus( + telegramSdkStatus === "unavailable" + ? t("wa_auth_telegram_unavailable") + : isTelegramMiniAppAttempt + ? t("wa_auth_telegram_not_confirmed") + : t("wa_auth_telegram_not_configured"), + true, + ); + return; + } + })(), + loginTimeout.promise, + ]); + } catch (error) { + if (!isActiveTelegramLoginAttempt(attemptId)) return; + if (error?.name === "AbortError") { + telegramSdkStatus = "unavailable"; + setAuthStatus(t("wa_auth_telegram_timeout"), true); + } else { + setAuthStatus(t("wa_auth_telegram_unavailable"), true); + } + } finally { + loginTimeout.clear(); + if (loginTimeout.timedOut) { + telegramSdkStatus = "unavailable"; + setAuthStatus(t("wa_auth_telegram_timeout"), true); + authBusy = false; + } + if (isActiveTelegramLoginAttempt(attemptId)) { + stopTelegramLoginWatchdog(attemptId); + telegramLoginBusy = false; + } + } } function setLinkEmailStatus(message, isError = false) { @@ -1289,12 +1570,15 @@ async function linkTelegramAccount() { if (linkTelegramBusy) return; + if (shouldWaitForTelegramSdkBeforeOAuth()) await ensureTelegramSdkForAction(); if (tg?.initData) { await linkTelegramAccountWithPayload({ init_data: tg.initData }); return; } if (!telegramOAuthClientId) { - showToast(t("wa_auth_telegram_not_configured")); + showToast( + telegramSdkStatus === "unavailable" ? t("wa_auth_telegram_unavailable") : t("wa_auth_telegram_not_configured"), + ); return; } linkTelegramBusy = true; @@ -2265,13 +2549,7 @@
{#if mode === "loading"}
-
- {#if CFG.logoUrl} - - {:else} - {brandEmoji} - {/if} -
+
{t("wa_loading")}
{:else if mode === "login"} @@ -2321,13 +2599,7 @@ {:else}
@@ -2371,15 +2643,30 @@
{t("wa_or")}
{/if}
-
- {#if authStatus} - + {#if !telegramLoginChecking && (authStatus || telegramLoginUnavailableMessage)} + {/if}
{#if userAgreementUrl || privacyPolicyUrl} @@ -2420,13 +2707,7 @@ {#if screen === "invite" || screen === "devices" || screen === "settings"}
-
- {#if CFG.logoUrl} - - {:else} - {brandEmoji} - {/if} -
+ {brandTitle}
@@ -2435,13 +2716,7 @@ {#if screen === "home"}
diff --git a/bot/app/web/frontend/src/BrandMark.svelte b/bot/app/web/frontend/src/BrandMark.svelte new file mode 100644 index 0000000..5ce08c7 --- /dev/null +++ b/bot/app/web/frontend/src/BrandMark.svelte @@ -0,0 +1,75 @@ + + +
+ {#if normalizedLogoUrl && !failed} + {#if !loaded} + + {/if} + (loaded = true)} + on:error={() => (failed = true)} + /> + {:else} + {normalizedEmoji} + {/if} +
diff --git a/bot/app/web/frontend/src/styles.css b/bot/app/web/frontend/src/styles.css index 31610f8..c71bf6f 100644 --- a/bot/app/web/frontend/src/styles.css +++ b/bot/app/web/frontend/src/styles.css @@ -17,7 +17,7 @@ } :root { - --font-sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; --font-mono: "JetBrains Mono", "Fira Code", monospace; color-scheme: dark; --accent: #00fe7a; @@ -190,10 +190,12 @@ a { } .brand-mark { + position: relative; display: grid; width: 38px; height: 38px; flex: 0 0 auto; + overflow: hidden; place-items: center; color: var(--accent); font-size: 26px; @@ -204,6 +206,28 @@ a { width: 100%; height: 100%; object-fit: contain; + opacity: 0; + transition: opacity 0.18s ease; +} + +.brand-mark img.loaded { + opacity: 1; +} + +.brand-mark-spinner { + position: absolute; + width: 42%; + height: 42%; + border: 2px solid color-mix(in srgb, var(--accent) 24%, transparent); + border-top-color: var(--accent); + border-radius: 999px; + animation: brand-mark-spin 0.72s linear infinite; +} + +@keyframes brand-mark-spin { + to { + transform: rotate(360deg); + } } .brand-mark-lg { @@ -1560,6 +1584,14 @@ a { padding-right: 14px; } +.telegram-login-button.unavailable:disabled { + cursor: not-allowed; +} + +.telegram-login-button.checking:disabled { + opacity: 0.82; +} + .telegram-login-text { display: inline-flex; align-items: center; @@ -1568,6 +1600,21 @@ a { width: 100%; } +.telegram-button-spinner { + width: 17px; + height: 17px; + border: 2px solid rgba(255, 255, 255, 0.35); + border-top-color: #fff; + border-radius: 999px; + animation: telegram-button-spin 0.72s linear infinite; +} + +@keyframes telegram-button-spin { + to { + transform: rotate(360deg); + } +} + .auth-legal { display: grid; justify-items: center; diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index ea4a58a..d350eb0 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -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_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" @@ -206,6 +210,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) @@ -237,7 +242,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 @@ -379,7 +386,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 @@ -387,7 +394,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( @@ -507,10 +514,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'" @@ -1577,6 +1584,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) @@ -2502,6 +2538,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], @@ -2799,6 +2935,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: @@ -2813,7 +2950,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, }, diff --git a/bot/app/web/templates/subscription_webapp.html b/bot/app/web/templates/subscription_webapp.html index cec2f84..7e56ab7 100644 --- a/bot/app/web/templates/subscription_webapp.html +++ b/bot/app/web/templates/subscription_webapp.html @@ -8,10 +8,6 @@ /minishop - - - - diff --git a/db/dal/user_dal.py b/db/dal/user_dal.py index 108a7cd..bf4da87 100644 --- a/db/dal/user_dal.py +++ b/db/dal/user_dal.py @@ -12,6 +12,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert from ..models import ( User, + UserTelegramAvatar, Subscription, Payment, PromoCodeActivation, @@ -112,6 +113,45 @@ async def get_user_by_telegram_id( return result.scalar_one_or_none() +async def get_user_telegram_avatar( + session: AsyncSession, + user_id: int, +) -> Optional[UserTelegramAvatar]: + stmt = select(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def upsert_user_telegram_avatar( + session: AsyncSession, + *, + user_id: int, + file_unique_id: Optional[str], + content_type: str, + image_bytes: bytes, +) -> UserTelegramAvatar: + avatar = await get_user_telegram_avatar(session, user_id) + if avatar is None: + avatar = UserTelegramAvatar( + user_id=user_id, + file_unique_id=file_unique_id, + content_type=content_type, + image_bytes=image_bytes, + size_bytes=len(image_bytes), + updated_at=datetime.now(timezone.utc), + ) + session.add(avatar) + else: + avatar.file_unique_id = file_unique_id + avatar.content_type = content_type + avatar.image_bytes = image_bytes + avatar.size_bytes = len(image_bytes) + avatar.updated_at = datetime.now(timezone.utc) + await session.flush() + await session.refresh(avatar) + return avatar + + async def get_user_by_panel_uuid( session: AsyncSession, panel_uuid: str ) -> Optional[User]: @@ -426,6 +466,22 @@ async def merge_users( .values(user_id=target_user_id) ) + target_has_avatar = ( + await session.execute( + select(UserTelegramAvatar.user_id).where(UserTelegramAvatar.user_id == target_user_id) + ) + ).scalar_one_or_none() + if target_has_avatar: + await session.execute( + delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == source_user_id) + ) + else: + await session.execute( + update(UserTelegramAvatar) + .where(UserTelegramAvatar.user_id == source_user_id) + .values(user_id=target_user_id) + ) + subscription_update_values: Dict[str, Any] = {"user_id": target_user_id} if panel_uuid_to_keep: subscription_update_values["panel_user_uuid"] = panel_uuid_to_keep @@ -684,6 +740,7 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool ) await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id)) await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id)) + await session.execute(delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id)) await session.delete(user) await session.flush() diff --git a/db/migrator.py b/db/migrator.py index ee5d52d..4ad3bfa 100644 --- a/db/migrator.py +++ b/db/migrator.py @@ -303,7 +303,32 @@ def _migration_0010_add_email_magic_token_hash(connection: Connection) -> None: ) -def _migration_0011_add_tariffs_schema(connection: Connection) -> None: +def _migration_0011_add_user_telegram_avatars(connection: Connection) -> None: + connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS user_telegram_avatars ( + user_id BIGINT PRIMARY KEY REFERENCES users(user_id), + file_unique_id VARCHAR, + content_type VARCHAR(64) NOT NULL DEFAULT 'image/jpeg', + image_bytes BYTEA NOT NULL, + size_bytes INTEGER NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """ + ) + ) + connection.execute( + text( + """ + CREATE INDEX IF NOT EXISTS ix_user_telegram_avatars_file_unique_id + ON user_telegram_avatars (file_unique_id) + """ + ) + ) + + +def _migration_0012_add_tariffs_schema(connection: Connection) -> None: inspector = inspect(connection) sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} @@ -497,9 +522,14 @@ MIGRATIONS: List[Migration] = [ upgrade=_migration_0010_add_email_magic_token_hash, ), Migration( - id="0011_add_tariffs_schema", + id="0011_add_user_telegram_avatars", + description="Cache compact Telegram profile avatars for WebApp profiles", + upgrade=_migration_0011_add_user_telegram_avatars, + ), + Migration( + id="0012_add_tariffs_schema", description="Add tariff catalog columns and traffic accounting tables", - upgrade=_migration_0011_add_tariffs_schema, + upgrade=_migration_0012_add_tariffs_schema, ), ] diff --git a/db/models.py b/db/models.py index 6d300d7..e406aca 100644 --- a/db/models.py +++ b/db/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Float, ForeignKey, UniqueConstraint, Text, BigInteger, Index, Numeric +from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Float, ForeignKey, UniqueConstraint, Text, BigInteger, Index, Numeric, LargeBinary from sqlalchemy.orm import relationship, DeclarativeBase from sqlalchemy.ext.asyncio import AsyncAttrs from sqlalchemy.sql import func @@ -59,6 +59,29 @@ class User(Base): return f"" +class UserTelegramAvatar(Base): + __tablename__ = "user_telegram_avatars" + + user_id = Column( + BigInteger, + ForeignKey("users.user_id"), + primary_key=True, + index=True, + ) + file_unique_id = Column(String, nullable=True, index=True) + content_type = Column(String(64), nullable=False, default="image/jpeg") + image_bytes = Column(LargeBinary, nullable=False) + size_bytes = Column(Integer, nullable=False) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + user = relationship("User") + + class Subscription(Base): __tablename__ = "subscriptions" __table_args__ = ( diff --git a/locales/en.json b/locales/en.json index b6d7cac..846824b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -591,6 +591,7 @@ "wa_auth_access_denied": "Access denied", "wa_auth_telegram_not_confirmed": "Telegram sign-in not confirmed", "wa_auth_telegram_unavailable": "Telegram sign-in is currently unavailable", + "wa_auth_telegram_timeout": "Could not check Telegram availability. Try again later or sign in with email", "wa_auth_telegram_not_configured": "Telegram sign-in is not configured", "wa_auth_telegram_cancelled": "Telegram sign-in was cancelled", "wa_auth_invalid_email": "Enter a valid email", @@ -610,6 +611,7 @@ "wa_send_code_email": "Send code to email", "wa_or": "or", "wa_login_telegram_button": "Sign in with Telegram", + "wa_login_telegram_unavailable_button": "Telegram unavailable", "wa_auth_legal_intro": "By creating an account, you agree to the", "wa_auth_legal_privacy": "privacy policy", "wa_auth_legal_and": "and", diff --git a/locales/ru.json b/locales/ru.json index 2eaec90..3b6c8df 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -591,6 +591,7 @@ "wa_auth_access_denied": "Доступ запрещен", "wa_auth_telegram_not_confirmed": "Telegram-вход не подтвержден", "wa_auth_telegram_unavailable": "Telegram-вход сейчас недоступен", + "wa_auth_telegram_timeout": "Не удалось проверить доступность Telegram. Попробуйте позже или войдите по email", "wa_auth_telegram_not_configured": "Telegram-вход не настроен", "wa_auth_telegram_cancelled": "Вход через Telegram отменён", "wa_auth_invalid_email": "Введите корректный email", @@ -610,6 +611,7 @@ "wa_send_code_email": "Отправить код на почту", "wa_or": "или", "wa_login_telegram_button": "Войти через телеграм", + "wa_login_telegram_unavailable_button": "Telegram недоступен", "wa_auth_legal_intro": "Создавая аккаунт, вы соглашаетесь с", "wa_auth_legal_privacy": "политикой конфиденциальности", "wa_auth_legal_and": "и", diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index 1af6f17..02ab3e4 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -2,6 +2,7 @@ import json import os import tempfile import unittest +from datetime import datetime, timezone from pathlib import Path from types import SimpleNamespace from unittest.mock import patch @@ -73,6 +74,40 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(plans[1]["traffic_gb"], 50.0) self.assertEqual(plans[1]["stars_price"], 2500) + def test_subscription_template_does_not_block_on_telegram_sdk(self): + html = subscription_webapp.TEMPLATE_PATH.read_text(encoding="utf-8") + + self.assertNotIn("https://telegram.org/js/telegram-web-app.js", html) + self.assertNotIn("https://fonts.googleapis.com", html) + self.assertLess(html.index("/subscription_webapp.css"), html.index("WEBAPP_JS_SCRIPT")) + + def test_https_webapp_logo_uses_same_origin_proxy(self): + settings = SimpleNamespace(WEBAPP_LOGO_URL="https://cdn.example.com/logo.png") + + self.assertEqual(subscription_webapp._resolve_webapp_logo_url(settings), "/webapp-logo") + + def test_telegram_avatar_url_uses_same_origin_account_route(self): + avatar = SimpleNamespace( + user_id=123, + image_bytes=b"avatar", + updated_at=datetime(2026, 5, 6, 12, 0, tzinfo=timezone.utc), + ) + + self.assertEqual( + subscription_webapp._telegram_avatar_url(avatar), + f"/api/account/avatar?v={int(avatar.updated_at.timestamp())}", + ) + + def test_select_compact_telegram_photo_size_prefers_small_suitable_photo(self): + small = SimpleNamespace(width=80, height=80, file_size=5000) + medium = SimpleNamespace(width=160, height=160, file_size=12000) + large = SimpleNamespace(width=640, height=640, file_size=90000) + + self.assertIs( + subscription_webapp._select_compact_telegram_photo_size([small, large, medium]), + medium, + ) + def test_serialize_plans_uses_traffic_packages_in_traffic_mode(self): settings = Settings( _env_file=None,