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}
-
- {#if CFG.logoUrl}
-

- {:else}
-
{brandEmoji}
- {/if}
-
+
{brandTitle}
@@ -2371,15 +2643,30 @@
{t("wa_or")}
{/if}
-
- {#if authStatus}
- {authStatus}
+ {#if !telegramLoginChecking && (authStatus || telegramLoginUnavailableMessage)}
+
+ {authStatus || telegramLoginUnavailableMessage}
+
{/if}
{#if userAgreementUrl || privacyPolicyUrl}
@@ -2420,13 +2707,7 @@
{#if screen === "invite" || screen === "devices" || screen === "settings"}
@@ -2435,13 +2716,7 @@
{#if screen === "home"}
-
- {#if CFG.logoUrl}
-

- {:else}
-
{brandEmoji}
- {/if}
-
+
{brandTitle}
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,