# ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 import gzip from config.webapp_themes_config import ( default_webapp_theme_asset_file, default_webapp_theme_css_files, ensure_default_webapp_theme_descriptor_files, public_theme_payload, public_themes_catalog_payload, ) from bot.middlewares.i18n import locale_language_options _TEXT_FILE_CACHE: Dict[tuple[str, bool], tuple[int, int, str]] = {} _BINARY_FILE_CACHE: Dict[str, tuple[int, int, bytes]] = {} _GZIP_BODY_CACHE: Dict[str, bytes] = {} _ASSET_NAME_CACHE: Dict[tuple[str, str], tuple[float, str]] = {} _I18N_PAYLOAD_CACHE: Dict[tuple[int, str, tuple[tuple[str, int, int], ...]], Dict[str, Any]] = {} _ASSET_NAME_CACHE_TTL_SECONDS = 30.0 async def health_route(request: web.Request) -> web.Response: return web.json_response({"ok": True}) async def robots_txt_route(request: web.Request) -> web.Response: response = web.Response(text=ROBOTS_TX, content_type="text/plain") response.headers["Cache-Control"] = "public, max-age=3600" return response async def css_asset_route(request: web.Request) -> web.Response: return await _css_asset_route(request, base_name="subscription_webapp") async def admin_css_asset_route(request: web.Request) -> web.Response: return await _css_asset_route(request, base_name="subscription_webapp_admin") async def _css_asset_route(request: web.Request, *, base_name: str) -> web.Response: asset_hash = request.match_info.get("asset_hash") filename = f"{base_name}.{asset_hash}.css" if asset_hash else f"{base_name}.css" response = await _serve_template_asset( request, filename, "text/css", allow_precompressed=bool(asset_hash), ) response.headers["Cache-Control"] = ( "public, max-age=31536000, immutable" if asset_hash else "no-cache" ) return response def _safe_theme_css_relative_path(raw_path: str) -> Optional[Path]: return _safe_theme_relative_path(raw_path, allowed_suffixes={".css"}, max_length=180) def _safe_theme_asset_relative_path(raw_path: str) -> Optional[Path]: return _safe_theme_relative_path( raw_path, allowed_suffixes=set(WEBAPP_THEME_ASSET_CONTENT_TYPES), max_length=220, ) def _safe_theme_relative_path( raw_path: str, *, allowed_suffixes: set[str], max_length: int, ) -> Optional[Path]: value = str(raw_path or "").replace("\\", "/").strip().lstrip("/") if not value or len(value) > max_length or "\x00" in value: return None parts = [part for part in value.split("/") if part] if len(parts) < 2 or any(part in {".", ".."} for part in parts): return None if any(not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", part) for part in parts): return None rel_path = Path(*parts) if rel_path.suffix.lower() not in allowed_suffixes: return None return rel_path async def theme_css_asset_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] if not settings.WEBAPP_ENABLED: raise web.HTTPNotFound(text="webapp_disabled") ensure_default_webapp_theme_descriptor_files(settings.WEBAPP_THEMES_DIR) rel_path = _safe_theme_css_relative_path(request.match_info.get("path") or "") if rel_path is None: raise web.HTTPNotFound(text="theme_css_not_found") root = Path(settings.WEBAPP_THEMES_DIR).expanduser().resolve() path = (root / rel_path).resolve() try: path.relative_to(root) except ValueError: raise web.HTTPNotFound(text="theme_css_not_found") from None query = getattr(request, "query", {}) or {} cache_control = "public, max-age=31536000, immutable" if query.get("v") else "no-cache" try: stat = path.stat() if stat.st_size > WEBAPP_THEME_CSS_MAX_BYTES: raise web.HTTPNotFound(text="theme_css_too_large") etag = _theme_asset_etag( "theme-css", rel_path, stat_mtime_ns=stat.st_mtime_ns, size=stat.st_size, ) if _request_etag_matches(request, etag): return _not_modified_response( cache_control=cache_control, etag=etag, vary="Accept-Encoding", ) text = path.read_text(encoding="utf-8") except OSError: defaults = default_webapp_theme_css_files() text = defaults.get(rel_path.as_posix()) if text is None: raise web.HTTPNotFound(text="theme_css_not_found") from None etag = _theme_asset_etag( "theme-css", rel_path, body=text.encode("utf-8"), ) if _request_etag_matches(request, etag): return _not_modified_response( cache_control=cache_control, etag=etag, vary="Accept-Encoding", ) return _theme_text_response( request, text, content_type="text/css", cache_control=cache_control, etag=etag, ) async def theme_asset_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] if not settings.WEBAPP_ENABLED: raise web.HTTPNotFound(text="webapp_disabled") ensure_default_webapp_theme_descriptor_files(settings.WEBAPP_THEMES_DIR) rel_path = _safe_theme_asset_relative_path(request.match_info.get("path") or "") if rel_path is None: raise web.HTTPNotFound(text="theme_asset_not_found") root = Path(settings.WEBAPP_THEMES_DIR).expanduser().resolve() path = (root / rel_path).resolve() try: path.relative_to(root) except ValueError: raise web.HTTPNotFound(text="theme_asset_not_found") from None suffix = rel_path.suffix.lower() content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(suffix) if not content_type: raise web.HTTPNotFound(text="theme_asset_not_found") query = getattr(request, "query", {}) cache_control = ( "public, max-age=31536000, immutable" if query.get("v") else "public, max-age=3600" ) try: stat = path.stat() if stat.st_size > WEBAPP_THEME_ASSET_MAX_BYTES: raise web.HTTPNotFound(text="theme_asset_too_large") etag = _theme_asset_etag( "theme-asset", rel_path, stat_mtime_ns=stat.st_mtime_ns, size=stat.st_size, ) if _request_etag_matches(request, etag): return _not_modified_response(cache_control=cache_control, etag=etag) body = path.read_bytes() except OSError: fallback = default_webapp_theme_asset_file(rel_path) if fallback is None: raise web.HTTPNotFound(text="theme_asset_not_found") from None body, fallback_suffix = fallback content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(fallback_suffix, content_type) etag = _theme_asset_etag("theme-asset", rel_path, body=body) if _request_etag_matches(request, etag): return _not_modified_response(cache_control=cache_control, etag=etag) if not body or len(body) > WEBAPP_THEME_ASSET_MAX_BYTES: raise web.HTTPNotFound(text="theme_asset_not_found") response = web.Response(body=body, content_type=content_type) response.headers["Cache-Control"] = cache_control response.headers["ETag"] = etag return response def _resolve_webapp_logo_url(settings: Settings) -> str: raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip() if not raw_logo_url: return WEBAPP_DEFAULT_LOGO_PATH parsed_logo_url = urlsplit(raw_logo_url) if parsed_logo_url.scheme == "https": cache_key = hashlib.sha256(raw_logo_url.encode("utf-8")).hexdigest()[:12] return f"{WEBAPP_LOGO_PROXY_PATH}?v={cache_key}" if parsed_logo_url.scheme in {"http", "data"}: return raw_logo_url if raw_logo_url.startswith("/"): return raw_logo_url return WEBAPP_DEFAULT_LOGO_PATH def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str: raw_custom_url = (getattr(settings, "WEBAPP_FAVICON_URL", None) or "").strip() raw_logo_favicon_url = (getattr(settings, "WEBAPP_LOGO_FAVICON_URL", None) or "").strip() if getattr(settings, "WEBAPP_FAVICON_USE_CUSTOM", False) and raw_custom_url: return _resolve_webapp_asset_url(raw_custom_url) if logo_url and raw_logo_favicon_url: resolved = _resolve_webapp_asset_url(raw_logo_favicon_url) if resolved: return resolved if logo_url and logo_url != WEBAPP_DEFAULT_LOGO_PATH: return logo_url return WEBAPP_DEFAULT_FAVICON_URL def _resolve_webapp_asset_url(raw_url: str) -> str: parsed_url = urlsplit(raw_url) if parsed_url.scheme in {"https", "http", "data"}: return raw_url if raw_url.startswith("/"): return raw_url return "" def _webapp_logo_cache_key(logo_url: str) -> str: return hashlib.sha256(logo_url.encode("utf-8")).hexdigest() def _webapp_logo_disk_paths(logo_url: str) -> Tuple[Path, Path]: cache_key = _webapp_logo_cache_key(logo_url) return WEBAPP_LOGO_CACHE_DIR / f"{cache_key}.bin", WEBAPP_LOGO_CACHE_DIR / f"{cache_key}.json" def _is_proxyable_webapp_logo_url(logo_url: str) -> bool: parsed_logo_url = urlsplit(logo_url) return parsed_logo_url.scheme == "https" and bool(parsed_logo_url.hostname) def _uploaded_webapp_logo_filename(logo_url: str) -> Optional[str]: parsed_logo_url = urlsplit(str(logo_url or "")) path = parsed_logo_url.path if parsed_logo_url.scheme or parsed_logo_url.netloc else logo_url prefix = f"{WEBAPP_UPLOADED_LOGO_PATH}/" if not path.startswith(prefix): return None filename = path.removeprefix(prefix) if re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename): return filename return None def _uploaded_webapp_logo_response(filename: str) -> web.Response: if not re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename): raise web.HTTPNotFound(text="webapp_logo_not_found") root = WEBAPP_UPLOADED_LOGO_DIR.expanduser().resolve() path = (root / filename).resolve() try: path.relative_to(root) except ValueError: raise web.HTTPNotFound(text="webapp_logo_not_found") from None content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower()) if not content_type: raise web.HTTPNotFound(text="webapp_logo_not_found") try: if path.stat().st_size > WEBAPP_LOGO_MAX_BYTES: raise web.HTTPNotFound(text="webapp_logo_too_large") body = path.read_bytes() except OSError: raise web.HTTPNotFound(text="webapp_logo_not_found") from None if not body: raise web.HTTPNotFound(text="webapp_logo_not_found") response = web.Response(body=body, content_type=content_type) response.headers["Cache-Control"] = "public, max-age=31536000, immutable" return response async def webapp_logo_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url: raise web.HTTPNotFound(text="webapp_logo_not_configured") uploaded_filename = _uploaded_webapp_logo_filename(raw_logo_url) if uploaded_filename: return _uploaded_webapp_logo_response(uploaded_filename) if not _is_proxyable_webapp_logo_url(raw_logo_url): raise web.HTTPNotFound(text="webapp_logo_not_proxied") parsed_logo_url = urlsplit(raw_logo_url) if not await _hostname_resolves_to_public_address(parsed_logo_url.hostname): raise web.HTTPNotFound(text="webapp_logo_not_proxied") source_logo_url = raw_logo_url logo_cache: Optional[Tuple[str, bytes, str]] = request.app.get("webapp_logo_cache") if logo_cache is None or logo_cache[0] != source_logo_url: cache_lock: asyncio.Lock = request.app["webapp_logo_cache_lock"] async with cache_lock: logo_cache = request.app.get("webapp_logo_cache") if logo_cache is None or logo_cache[0] != source_logo_url: fetched_logo = await _load_or_fetch_webapp_logo(source_logo_url) logo_cache = ( (source_logo_url, fetched_logo[0], fetched_logo[1]) if fetched_logo else None ) request.app["webapp_logo_cache"] = logo_cache if not logo_cache: raise web.HTTPNotFound(text="webapp_logo_unavailable") _, body, content_type = logo_cache response = web.Response(body=body, content_type=content_type) response.headers["Cache-Control"] = "public, max-age=31536000, immutable" return response async def webapp_uploaded_logo_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] if not settings.WEBAPP_ENABLED: raise web.HTTPNotFound(text="webapp_disabled") filename = str(request.match_info.get("filename") or "").strip() return _uploaded_webapp_logo_response(filename) async def webapp_default_logo_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] if not settings.WEBAPP_ENABLED: raise web.HTTPNotFound(text="webapp_disabled") response = _webapp_default_brand_file_response(WEBAPP_DEFAULT_LOGO_FILE, "image/webp") response.headers["Cache-Control"] = "public, max-age=31536000, immutable" return response async def webapp_favicon_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] if not settings.WEBAPP_ENABLED: raise web.HTTPNotFound(text="webapp_disabled") digest = str(request.match_info.get("digest") or "").strip().lower() filename = str(request.match_info.get("filename") or "").strip() return _webapp_favicon_file_response(digest, filename) async def webapp_current_favicon_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] if not settings.WEBAPP_ENABLED: raise web.HTTPNotFound(text="webapp_disabled") requested_filename = str(request.path.rsplit("/", 1)[-1] or "").strip() target_filename = _webapp_root_favicon_target_filename(requested_filename) if not target_filename: raise web.HTTPNotFound(text="webapp_favicon_not_found") favicon_url = _resolve_webapp_favicon_url(settings, _resolve_webapp_logo_url(settings)) digest = _webapp_generated_favicon_digest(favicon_url) if digest: return _webapp_favicon_file_response(digest, target_filename) redirect_url = _webapp_redirectable_favicon_url(favicon_url, target_filename) if redirect_url: raise web.HTTPFound(location=redirect_url) raise web.HTTPNotFound(text="webapp_favicon_not_found") def _webapp_root_favicon_target_filename(filename: str) -> str: if filename == "apple-touch-icon-precomposed.png": return "apple-touch-icon.png" if filename in { "apple-touch-icon.png", "favicon.ico", "icon-192.png", "icon-512.png", }: return filename return "" def _webapp_generated_favicon_digest(favicon_url: str) -> str: parsed = urlsplit(str(favicon_url or "")) path = parsed.path if parsed.scheme or parsed.netloc else str(favicon_url or "") match = re.fullmatch( rf"{re.escape(WEBAPP_FAVICON_PATH)}/([0-9a-f]{{16}})/" r"(?:icon-(?:16|32|48|180|192|512)\.png|apple-touch-icon\.png|favicon\.(?:ico|svg))", path, ) return match.group(1) if match else "" def _webapp_redirectable_favicon_url(favicon_url: str, target_filename: str) -> str: href = str(favicon_url or "").strip() if not href: return "" parsed = urlsplit(href) path = parsed.path if parsed.scheme or parsed.netloc else href suffix = Path(path).suffix.lower() if target_filename in {"apple-touch-icon.png", "icon-192.png", "icon-512.png"}: if suffix != ".png": return "" elif target_filename == "favicon.ico": if suffix != ".ico": return "" else: return "" if parsed.scheme in {"http", "https"} or href.startswith("/"): return href return "" def _webapp_favicon_file_response(digest: str, filename: str) -> web.Response: if not re.fullmatch(r"[0-9a-f]{16}", digest): raise web.HTTPNotFound(text="webapp_favicon_not_found") if not re.fullmatch( r"(?:icon-(?:16|32|48|180|192|512)\.png|apple-touch-icon\.png|favicon\.(?:ico|svg))", filename, ): raise web.HTTPNotFound(text="webapp_favicon_not_found") if digest == WEBAPP_DEFAULT_FAVICON_DIGEST: return _webapp_default_favicon_file_response(filename) root = WEBAPP_FAVICON_DIR.expanduser().resolve() path = (root / digest / filename).resolve() try: path.relative_to(root) except ValueError: raise web.HTTPNotFound(text="webapp_favicon_not_found") from None content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower()) if not content_type: raise web.HTTPNotFound(text="webapp_favicon_not_found") try: if path.stat().st_size > WEBAPP_LOGO_MAX_BYTES: raise web.HTTPNotFound(text="webapp_favicon_too_large") body = path.read_bytes() except OSError: raise web.HTTPNotFound(text="webapp_favicon_not_found") from None if not body: raise web.HTTPNotFound(text="webapp_favicon_not_found") response = web.Response(body=body, content_type=content_type) response.headers["Cache-Control"] = "public, max-age=31536000, immutable" return response def _webapp_default_favicon_file_response(filename: str) -> web.Response: path = WEBAPP_DEFAULT_FAVICON_DIR / filename content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower()) if not content_type: raise web.HTTPNotFound(text="webapp_favicon_not_found") response = _webapp_default_brand_file_response(path, content_type) response.headers["Cache-Control"] = "public, max-age=31536000, immutable" return response def _webapp_default_brand_file_response(path: Path, content_type: str) -> web.Response: try: body = _read_template_binary_cached(path) except OSError: raise web.HTTPNotFound(text="webapp_default_brand_not_found") from None if not body or len(body) > WEBAPP_LOGO_MAX_BYTES: raise web.HTTPNotFound(text="webapp_default_brand_not_found") return web.Response(body=body, content_type=content_type) async def _warm_webapp_logo_cache(app: web.Application) -> None: settings: Settings = app["settings"] raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url): return parsed_logo_url = urlsplit(raw_logo_url) if not parsed_logo_url.hostname or not await _hostname_resolves_to_public_address( parsed_logo_url.hostname ): return cache_lock: asyncio.Lock = app["webapp_logo_cache_lock"] async with cache_lock: logo_cache: Optional[Tuple[str, bytes, str]] = app.get("webapp_logo_cache") if logo_cache and logo_cache[0] == raw_logo_url: return loaded_logo = await _load_or_fetch_webapp_logo(raw_logo_url) app["webapp_logo_cache"] = ( (raw_logo_url, loaded_logo[0], loaded_logo[1]) if loaded_logo else None ) async def _load_or_fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]: disk_logo = await asyncio.to_thread(_read_webapp_logo_from_disk, logo_url) if disk_logo: return disk_logo fetched_logo = await _fetch_webapp_logo(logo_url) if fetched_logo: await asyncio.to_thread(_write_webapp_logo_to_disk, logo_url, fetched_logo) return fetched_logo def _read_webapp_logo_from_disk(logo_url: str) -> Optional[Tuple[bytes, str]]: body_path, meta_path = _webapp_logo_disk_paths(logo_url) try: metadata = json.loads(meta_path.read_text(encoding="utf-8")) if metadata.get("source_url") != logo_url: return None content_type = str(metadata.get("content_type") or "").strip().lower() if not content_type.startswith("image/"): return None body = body_path.read_bytes() except (OSError, json.JSONDecodeError): return None if not body or len(body) > WEBAPP_LOGO_MAX_BYTES: return None return body, content_type def _write_webapp_logo_to_disk(logo_url: str, logo: Tuple[bytes, str]) -> None: body, content_type = logo if not body or len(body) > WEBAPP_LOGO_MAX_BYTES: return body_path, meta_path = _webapp_logo_disk_paths(logo_url) try: WEBAPP_LOGO_CACHE_DIR.mkdir(parents=True, exist_ok=True) body_path.write_bytes(body) meta_path.write_text( json.dumps( { "source_url": logo_url, "content_type": content_type, "cached_at": datetime.now(timezone.utc).isoformat(), "bytes": len(body), }, ensure_ascii=False, separators=(",", ":"), ), encoding="utf-8", ) except OSError as exc: logger.warning("Failed to write WEBAPP_LOGO_URL cache: %s", exc) 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=3) async with session.get( logo_url, allow_redirects=False, headers={"Accept": "image/avif,image/webp,image/svg+xml,image/png,image/*,*/*;q=0.8"}, timeout=timeout, ) as response: if response.status != 200: logger.warning( "WEBAPP_LOGO_URL returned HTTP %s; keeping the logo hidden.", response.status, ) return None content_type = ( (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower() ) if content_type and not content_type.startswith("image/"): logger.warning( "WEBAPP_LOGO_URL returned non-image content type %s; keeping the logo hidden.", content_type, ) return None body = bytearray() async for chunk in response.content.iter_chunked(64 * 1024): body.extend(chunk) if len(body) > WEBAPP_LOGO_MAX_BYTES: logger.warning("WEBAPP_LOGO_URL exceeded the 2 MiB limit.") return None if not body: logger.warning("WEBAPP_LOGO_URL returned an empty response body.") return None return bytes(body), content_type or "image/png" except Exception as exc: logger.warning("Failed to fetch WEBAPP_LOGO_URL: %s", exc) return None async def _get_shared_http_session() -> ClientSession: global _SHARED_HTTP_SESSION async with _SHARED_HTTP_SESSION_LOCK: if _SHARED_HTTP_SESSION is None or _SHARED_HTTP_SESSION.closed: _SHARED_HTTP_SESSION = ClientSession( timeout=ClientTimeout(total=30), headers={ "User-Agent": "Mozilla/5.0", "Accept": "*/*", }, ) return _SHARED_HTTP_SESSION async def _ensure_shared_http_session() -> None: await _get_shared_http_session() async def _close_shared_http_session() -> None: global _SHARED_HTTP_SESSION async with _SHARED_HTTP_SESSION_LOCK: if _SHARED_HTTP_SESSION and not _SHARED_HTTP_SESSION.closed: await _SHARED_HTTP_SESSION.close() _SHARED_HTTP_SESSION = None async def _hostname_resolves_to_public_address(hostname: str) -> bool: if not hostname: return False try: ip_obj = ipaddress.ip_address(hostname) return not ( ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_unspecified or ip_obj.is_reserved ) except ValueError: pass loop = asyncio.get_running_loop() try: resolved = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) except Exception: return False found_public_ip = False for entry in resolved: sockaddr = entry[4] if not sockaddr: continue candidate = sockaddr[0] try: ip_obj = ipaddress.ip_address(candidate) except ValueError: continue if ( ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_unspecified or ip_obj.is_reserved ): return False found_public_ip = True return found_public_ip @web.middleware async def _security_headers_middleware(request: web.Request, handler): request["csp_nonce"] = secrets.token_urlsafe(16) try: response = await handler(request) except web.HTTPException as exc: response = exc nonce = request.get("csp_nonce", "") response.headers.setdefault( "Content-Security-Policy", ( "default-src 'self'; " f"script-src 'self' 'nonce-{nonce}' 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 https://cdn.jsdelivr.net; " # noqa: E501 "font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; " "img-src 'self' data: blob: https:; " "connect-src 'self' https://oauth.telegram.org; " "object-src 'none'; " "base-uri 'self'; " "form-action 'self'" ), ) response.headers.setdefault("Referrer-Policy", "no-referrer") response.headers.setdefault("X-Content-Type-Options", "nosniff") response.headers.setdefault("X-Robots-Tag", "noindex, nofollow, noarchive") response.headers.setdefault( "Permissions-Policy", ( "accelerometer=(), autoplay=(), camera=(), display-capture=(), " "encrypted-media=(), geolocation=(), gyroscope=(), magnetometer=(), " "microphone=(), midi=(), payment=(), usb=()" ), ) return response @web.middleware async def _csrf_protection_middleware(request: web.Request, handler): settings: Settings = request.app["settings"] header = request.headers.get("Authorization", "") prefix = "Bearer " if header.startswith(prefix): if verify_webapp_session_token(settings, header[len(prefix) :].strip()): return await handler(request) if ( request.method in WEBAPP_STATE_CHANGING_METHODS and request.path not in WEBAPP_CSRF_EXEMPT_PATHS and request.cookies.get(WEBAPP_SESSION_COOKIE_NAME) ): csrf_cookie = request.cookies.get(WEBAPP_CSRF_COOKIE_NAME, "") csrf_header = request.headers.get(WEBAPP_CSRF_HEADER_NAME, "") if not csrf_cookie or not csrf_header or not hmac.compare_digest(csrf_header, csrf_cookie): return _json_error(403, "csrf_failed", "Invalid CSRF token") return await handler(request) def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]: settings: Settings = request.app["settings"] cache = request.app["webapp_settings_cache"] now = time.monotonic() if now - float(cache.get("ts", 0.0)) >= 60 or not cache.get("data"): logo_url = _resolve_webapp_logo_url(settings) cache["data"] = { "logo_url": logo_url, "favicon_url": _resolve_webapp_favicon_url(settings, logo_url), "subscription_options": settings.subscription_options, "stars_subscription_options": settings.stars_subscription_options, "traffic_packages": settings.traffic_packages, "stars_traffic_packages": settings.stars_traffic_packages, "support_url": settings.SUPPORT_LINK or "", "privacy_policy_url": settings.PRIVACY_POLICY_URL or "", "user_agreement_url": settings.USER_AGREEMENT_URL or "", "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", "email_auth_enabled": settings.email_auth_configured, "language": _normalize_language(settings.DEFAULT_LANGUAGE), } cache["ts"] = now return cache["data"] def _resolve_app_version() -> str: # Single source of truth shared with the telemetry worker so the admin # sidebar and the install beacon always report the same version. from bot.utils import app_version as app_version_module global _APP_VERSION_CACHE app_version_module.APP_ROOT = APP_ROOT app_version_module._run_git_command = _run_git_command app_version_module._APP_VERSION_CACHE = _APP_VERSION_CACHE version = app_version_module.resolve_app_version() _APP_VERSION_CACHE = app_version_module._APP_VERSION_CACHE return version def _run_git_command(*args: str) -> str: try: result = subprocess.run( ["git", *args], cwd=APP_ROOT, check=True, capture_output=True, text=True, timeout=1.5, ) except (OSError, subprocess.SubprocessError): return "" return result.stdout.strip() async def _enforce_webapp_rate_limit( request: web.Request, *, user_id: int, action: str, ) -> Optional[web.Response]: settings: Settings = request.app["settings"] ip_address = ( request_client_ip(request, trusted_proxies=settings.trusted_proxies) or request.remote or "unknown" ) key = f"{action}:{ip_address}:{int(user_id)}" try: redis = await get_redis(settings) if redis is not None: redis_rate_key = redis_key(settings, "rate-limit", "webapp", key) current = await redis.incr(redis_rate_key) if current == 1: await redis.expire(redis_rate_key, settings.WEBAPP_RATE_LIMIT_TTL_SECONDS) if current > settings.WEBAPP_RATE_LIMIT_MAX_REQUESTS: ttl = await redis.ttl(redis_rate_key) retry_after = max( 1, int(ttl if ttl and ttl > 0 else WEBAPP_RATE_LIMIT_WINDOW_SECONDS) ) return web.json_response( { "ok": False, "error": "rate_limited", "retry_after": retry_after, }, status=429, headers={"Retry-After": str(retry_after)}, ) return None except Exception as exc: logger.warning("Redis webapp rate limiter unavailable; using local fallback: %s", exc) buckets: Dict[str, deque[float]] = request.app["webapp_rate_limit_buckets"] lock: asyncio.Lock = request.app["webapp_rate_limit_lock"] now = time.monotonic() async with lock: bucket = buckets.setdefault(key, deque()) while bucket and now - bucket[0] >= WEBAPP_RATE_LIMIT_WINDOW_SECONDS: bucket.popleft() if not bucket: buckets.pop(key, None) bucket = buckets.setdefault(key, deque()) if len(bucket) >= WEBAPP_RATE_LIMIT_MAX_REQUESTS: retry_after = ( max( 1, int(WEBAPP_RATE_LIMIT_WINDOW_SECONDS - (now - bucket[0])), ) if bucket else WEBAPP_RATE_LIMIT_WINDOW_SECONDS ) return web.json_response( { "ok": False, "error": "rate_limited", "retry_after": retry_after, }, status=429, headers={"Retry-After": str(retry_after)}, ) bucket.append(now) return None async def js_asset_route(request: web.Request) -> web.Response: return await _js_asset_route(request, base_name="subscription_webapp") async def admin_js_asset_route(request: web.Request) -> web.Response: return await _js_asset_route(request, base_name="subscription_webapp_admin") async def _js_asset_route(request: web.Request, *, base_name: str) -> web.Response: asset_hash = request.match_info.get("asset_hash") filename = f"{base_name}.min.{asset_hash}.js" if asset_hash else f"{base_name}.js" response = await _serve_template_asset( request, filename, "application/javascript", allow_precompressed=bool(asset_hash), strip_dev_mock=not asset_hash, ) response.headers["Cache-Control"] = ( "public, max-age=31536000, immutable" if asset_hash else "no-cache" ) return response WEBAPP_BOOTSTRAP_I18N_PREFIXES = ("wa_",) WEBAPP_BOOTSTRAP_I18N_KEYS = {"menu_support_button"} WEBAPP_I18N_SCOPES = {"webapp", "admin"} APP_DEEPLINK_I18N_KEYS = { "title": "wa_app_launch_title", "hint": "wa_app_launch_opening_hint", "manualHint": "wa_app_launch_hint", "button": "wa_app_launch_button", "retryButton": "wa_app_launch_retry_button", "doneTitle": "wa_app_launch_done_title", "doneHint": "wa_app_launch_done_hint", "closeButton": "wa_app_launch_close_button", "unavailableTitle": "wa_app_launch_unavailable_title", "unavailableHint": "wa_app_launch_unavailable_hint", } APP_DEEPLINK_I18N_FALLBACKS = { "wa_app_launch_title": "Opening app", "wa_app_launch_opening_hint": "Opening the app on this device...", "wa_app_launch_hint": "If the app did not open automatically, tap the button below.", "wa_app_launch_button": "Open app", "wa_app_launch_retry_button": "Open again", "wa_app_launch_done_title": "Settings added", "wa_app_launch_done_hint": "If the app opened, you can close this window.", "wa_app_launch_close_button": "Close window", "wa_app_launch_unavailable_title": "App link unavailable", "wa_app_launch_unavailable_hint": "Return to Telegram and try again.", } def _is_webapp_bootstrap_i18n_key(key: str) -> bool: return key in WEBAPP_BOOTSTRAP_I18N_KEYS or key.startswith(WEBAPP_BOOTSTRAP_I18N_PREFIXES) def _normalize_i18n_scope(raw_scope: object) -> str: scope = str(raw_scope or "webapp").strip().lower() return scope if scope in WEBAPP_I18N_SCOPES else "webapp" def _i18n_cache_fingerprint( locales_data: Dict[str, Any], ) -> tuple[tuple[str, int, int], ...]: return tuple( sorted( (str(lang), id(messages), len(messages)) for lang, messages in locales_data.items() if isinstance(messages, dict) ) ) def _filter_webapp_i18n_payload(locales_data: object, scope: str = "webapp") -> Dict[str, Any]: if not isinstance(locales_data, dict): return {} normalized_scope = _normalize_i18n_scope(scope) cache_key = (id(locales_data), normalized_scope, _i18n_cache_fingerprint(locales_data)) cached = _I18N_PAYLOAD_CACHE.get(cache_key) if cached is not None: return cached payload: Dict[str, Any] = {} for lang, messages in locales_data.items(): if not isinstance(messages, dict): continue filtered: Dict[str, Any] = {} for key, value in messages.items(): key_text = str(key) is_bootstrap_key = _is_webapp_bootstrap_i18n_key(key_text) if (normalized_scope == "webapp" and is_bootstrap_key) or ( normalized_scope == "admin" and not is_bootstrap_key ): filtered[key_text] = value payload[str(lang)] = filtered if len(_I18N_PAYLOAD_CACHE) > 32: _I18N_PAYLOAD_CACHE.clear() _I18N_PAYLOAD_CACHE[cache_key] = payload return payload def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]: settings: Settings = request.app["settings"] cached = _get_cached_webapp_settings(request) themes_catalog = settings.webapp_themes_catalog primary_color = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a" preview_key = str(request.query.get("theme_preview") or "").strip() preview_theme = themes_catalog.theme_by_key(preview_key) if preview_key else None if preview_theme is None or not preview_theme.enabled: preview_key = "" i18n_instance: Optional[object] = request.app.get("i18n") i18n_scope = _normalize_i18n_scope(request.query.get("i18n_scope") or "webapp") if i18n_instance and hasattr(i18n_instance, "reload_overrides_from_file"): i18n_instance.reload_overrides_from_file() locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {} base_locales_data = getattr(i18n_instance, "base_locales_data", {}) if i18n_instance else {} return { "config": { "title": settings.WEBAPP_TITLE, "primaryColor": settings.WEBAPP_PRIMARY_COLOR, "themesCatalog": public_themes_catalog_payload( themes_catalog, primary_color, enabled_only=True, ), "themesDir": settings.WEBAPP_THEMES_DIR, "themePreviewKey": preview_key, "logoUrl": cached["logo_url"], "faviconUrl": cached["favicon_url"], "faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM), "apiBase": "/api", "adminJsAsset": f"/{_resolve_webapp_admin_js_asset_name()}", "adminCssAsset": f"/{_resolve_webapp_admin_css_asset_name()}", "telegramLoginBotUsername": request.app.get("bot_username") or "", "telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0, "telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0, "telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings), "supportUrl": cached["support_url"], "privacyPolicyUrl": cached["privacy_policy_url"], "userAgreementUrl": cached["user_agreement_url"], "currency": cached["currency"], "language": cached["language"], "languages": locale_language_options( locales_data.keys(), base_languages=base_locales_data.keys(), ), "emailAuthEnabled": cached["email_auth_enabled"], "appVersion": _resolve_app_version(), "appRepositoryUrl": APP_REPOSITORY_URL, }, "i18n": _filter_webapp_i18n_payload(locales_data, i18n_scope), } async def bootstrap_route(request: web.Request) -> web.Response: response = web.json_response({"ok": True, **_build_webapp_bootstrap_payload(request)}) response.headers["Cache-Control"] = "no-cache" return response async def i18n_route(request: web.Request) -> web.Response: i18n_instance: Optional[object] = request.app.get("i18n") if i18n_instance and hasattr(i18n_instance, "reload_overrides_from_file"): i18n_instance.reload_overrides_from_file() scope = _normalize_i18n_scope(request.query.get("scope") or "webapp") locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {} response = web.json_response( { "ok": True, "scope": scope, "i18n": _filter_webapp_i18n_payload(locales_data, scope), } ) response.headers["Cache-Control"] = "no-cache" return response def _webapp_page_title(settings: Settings, suffix: str = "") -> str: base = str(getattr(settings, "WEBAPP_TITLE", "") or "").strip() or "Subscription" suffix = str(suffix or "").strip() return f"{base} - {suffix}" if suffix else base def _webapp_preview_meta_markup(page_title: str) -> str: escaped_title = html.escape(str(page_title or ""), quote=True) return "\n".join( [ f'', f'', f'', '', f'', '', f'', ] ) def _replace_webapp_title(html_text: str, page_title: str) -> str: escaped_title = html.escape(str(page_title or ""), quote=False) next_title = f"