# ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 async def health_route(request: web.Request) -> web.Response: return web.json_response({"ok": True}) async def css_asset_route(request: web.Request) -> web.Response: return await _serve_template_asset(request, "subscription_webapp.css", "text/css") def _resolve_webapp_logo_url(settings: Settings) -> str: raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url: return "" 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 "" 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 _emoji_to_codepoints(value: str) -> str: return "_".join(f"{ord(char):x}" for char in str(value or "").strip()) def _webapp_emoji_disk_path(codepoints: str, ext: str) -> Path: return WEBAPP_EMOJI_CACHE_DIR / f"{codepoints}.512.{ext}" def _webapp_animated_emoji_source_url(codepoints: str, ext: str) -> str: return f"https://fonts.gstatic.com/s/e/notoemoji/latest/{codepoints}/512.{ext}" def _webapp_animated_emoji_asset_path(emoji: str, ext: str = "gif") -> str: codepoints = _emoji_to_codepoints(emoji) if not codepoints or ext not in {"gif", "webp"}: return "" return f"/webapp-emoji/{codepoints}/512.{ext}" 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") 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_animated_emoji_route(request: web.Request) -> web.Response: codepoints = str(request.match_info.get("codepoints") or "").strip().lower() ext = str(request.match_info.get("ext") or "").strip().lower() if not re.fullmatch(r"[0-9a-f]+(?:_[0-9a-f]+)*", codepoints) or ext not in {"gif", "webp"}: raise web.HTTPNotFound(text="webapp_emoji_not_found") emoji_cache_key = f"{codepoints}:{ext}" emoji_caches: Dict[str, Tuple[bytes, str]] = request.app.setdefault("webapp_emoji_cache", {}) emoji_cache = emoji_caches.get(emoji_cache_key) if emoji_cache is None: cache_lock: asyncio.Lock = request.app.setdefault("webapp_emoji_cache_lock", asyncio.Lock()) async with cache_lock: emoji_cache = emoji_caches.get(emoji_cache_key) if emoji_cache is None: emoji_cache = await _load_or_fetch_webapp_animated_emoji(codepoints, ext) if emoji_cache: emoji_caches[emoji_cache_key] = emoji_cache if not emoji_cache: raise web.HTTPNotFound(text="webapp_emoji_unavailable") body, content_type = emoji_cache response = web.Response(body=body, content_type=content_type) response.headers["Cache-Control"] = "public, max-age=31536000, immutable" return response 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 _warm_webapp_animated_emoji_cache(app: web.Application) -> None: settings: Settings = app["settings"] if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated": return codepoints = _emoji_to_codepoints(settings.WEBAPP_LOGO_EMOJI) if not codepoints: return app.setdefault("webapp_emoji_cache", {}) app.setdefault("webapp_emoji_cache_lock", asyncio.Lock()) emoji_caches: Dict[str, Tuple[bytes, str]] = app["webapp_emoji_cache"] for ext in ("gif", "webp"): emoji_cache_key = f"{codepoints}:{ext}" if emoji_cache_key in emoji_caches: continue loaded_emoji = await _load_or_fetch_webapp_animated_emoji(codepoints, ext) if loaded_emoji: emoji_caches[emoji_cache_key] = loaded_emoji if ext == "gif": return async def _load_or_fetch_webapp_animated_emoji( codepoints: str, ext: str ) -> Optional[Tuple[bytes, str]]: disk_emoji = await asyncio.to_thread(_read_webapp_animated_emoji_from_disk, codepoints, ext) if disk_emoji: return disk_emoji fetched_emoji = await _fetch_webapp_animated_emoji(codepoints, ext) if fetched_emoji: await asyncio.to_thread( _write_webapp_animated_emoji_to_disk, codepoints, ext, fetched_emoji ) return fetched_emoji def _read_webapp_animated_emoji_from_disk(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]: path = _webapp_emoji_disk_path(codepoints, ext) try: body = path.read_bytes() except OSError: return None if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES: return None return body, "image/gif" if ext == "gif" else "image/webp" def _write_webapp_animated_emoji_to_disk( codepoints: str, ext: str, emoji: Tuple[bytes, str] ) -> None: body, _content_type = emoji if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES: return path = _webapp_emoji_disk_path(codepoints, ext) try: WEBAPP_EMOJI_CACHE_DIR.mkdir(parents=True, exist_ok=True) path.write_bytes(body) except OSError as exc: logger.warning("Failed to write WEBAPP animated emoji cache: %s", exc) async def _fetch_webapp_animated_emoji(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]: try: session = await _get_shared_http_session() timeout = ClientTimeout(total=4) source_url = _webapp_animated_emoji_source_url(codepoints, ext) async with session.get( source_url, allow_redirects=False, headers={"Accept": "image/gif,image/webp,image/*,*/*;q=0.8"}, timeout=timeout, ) as response: if response.status != 200: return None content_type = ( (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower() ) expected_content_type = "image/gif" if ext == "gif" else "image/webp" if content_type and content_type != expected_content_type: return None body = bytearray() async for chunk in response.content.iter_chunked(64 * 1024): body.extend(chunk) if len(body) > WEBAPP_EMOJI_MAX_BYTES: logger.warning("WEBAPP animated emoji exceeded the 4 MiB limit.") return None if not body: return None return bytes(body), expected_content_type except Exception as exc: logger.warning("Failed to fetch WEBAPP animated emoji: %s", exc) return 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: 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( "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"): cache["data"] = { "logo_url": _resolve_webapp_logo_url(settings), "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 "", "terms_url": settings.TERMS_OF_SERVICE_URL 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 _run_git_command(*args: str) -> str: repo_root = Path(__file__).resolve().parents[3] try: result = subprocess.run( ["git", *args], cwd=repo_root, check=True, capture_output=True, text=True, timeout=1.5, ) except (OSError, subprocess.SubprocessError): return "" return result.stdout.strip() def _resolve_app_version() -> str: global _APP_VERSION_CACHE if _APP_VERSION_CACHE: return _APP_VERSION_CACHE env_version = os.getenv("REMNAWAVE_MINISHOP_VERSION", "").strip() if env_version: _APP_VERSION_CACHE = env_version return env_version build_version_path = Path(__file__).resolve().parents[3] / ".build-version" try: build_version = build_version_path.read_text(encoding="utf-8").strip() except OSError: build_version = "" if build_version: _APP_VERSION_CACHE = build_version return build_version tag = _run_git_command("describe", "--tags", "--abbrev=0") sha = _run_git_command("rev-parse", "--short", "HEAD") dirty = bool(_run_git_command("status", "--porcelain")) if tag and sha: commits_since_tag = _run_git_command("rev-list", f"{tag}..HEAD", "--count") if commits_since_tag and commits_since_tag != "0": version = f"{tag}+{commits_since_tag}.g{sha}" else: version = tag elif sha: version = f"dev+g{sha}" else: version = "dev+unknown" if dirty: version = f"{version}-dirty" _APP_VERSION_CACHE = version return version 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)}" 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: asset_hash = request.match_info.get("asset_hash") filename = ( f"subscription_webapp.min.{asset_hash}.js" if asset_hash else "subscription_webapp.js" ) response = await _serve_template_asset( request, filename, "application/javascript", strip_dev_mock=not asset_hash, ) response.headers["Cache-Control"] = ( "public, max-age=31536000, immutable" if asset_hash else "no-cache" ) return response async def index_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] if not settings.WEBAPP_ENABLED: raise web.HTTPNotFound(text="webapp_disabled") html = TEMPLATE_PATH.read_text(encoding="utf-8") cached = _get_cached_webapp_settings(request) config = { "title": settings.WEBAPP_TITLE, "primaryColor": settings.WEBAPP_PRIMARY_COLOR, "logoUrl": cached["logo_url"], "logoEmoji": settings.WEBAPP_LOGO_EMOJI, "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, "apiBase": "/api", "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"], "termsUrl": cached["terms_url"], "privacyPolicyUrl": cached["privacy_policy_url"], "userAgreementUrl": cached["user_agreement_url"], "currency": cached["currency"], "language": cached["language"], "emailAuthEnabled": cached["email_auth_enabled"], "appVersion": _resolve_app_version(), "appRepositoryUrl": APP_REPOSITORY_URL, } html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER) i18n_instance: Optional[object] = request.app.get("i18n") i18n_payload = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {} nonce = request.get("csp_nonce", "") html = html.replace( WEBAPP_CONFIG_PLACEHOLDER, ( f'" ), ) html = html.replace( WEBAPP_I18N_PLACEHOLDER, ( f'" ), ) html = html.replace( WEBAPP_JS_PLACEHOLDER, f'', ) brand_asset_url = cached["logo_url"] if not brand_asset_url and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated": brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI) if brand_asset_url: html = html.replace( '', # noqa: E501 f'', # noqa: E501 ) else: html = html.replace( '', # noqa: E501 "", ) return web.Response(text=html, content_type="text/html", charset="utf-8") async def _serve_template_asset( request: web.Request, filename: str, content_type: str, *, strip_dev_mock: bool = False, ) -> web.Response: settings: Settings = request.app["settings"] if not settings.WEBAPP_ENABLED: raise web.HTTPNotFound(text="webapp_disabled") path = ASSET_DIR / filename text = path.read_text(encoding="utf-8") if strip_dev_mock: text = _strip_marked_block( text, "/* WEBAPP_DEV_MOCK_START */", "/* WEBAPP_DEV_MOCK_END */", ) return web.Response(text=text, content_type=content_type, charset="utf-8") def _resolve_webapp_js_asset_name() -> str: minified_assets = [] for path in ASSET_DIR.glob("subscription_webapp.min.*.js"): try: minified_assets.append((path.stat().st_mtime, path.name)) except OSError: continue if minified_assets: minified_assets.sort(reverse=True) return minified_assets[0][1] return "subscription_webapp.js" def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str: start = html.find(start_marker) if start == -1: return html end = html.find(end_marker, start) if end == -1: return html[:start] return html[:start] + html[end + len(end_marker) :]