Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f30e729f1f | ||
|
|
1e63431ae3 | ||
|
|
072e7273c4 | ||
|
|
df079138ee | ||
|
|
87f32114d7 | ||
|
|
b330e604f6 | ||
|
|
827d69231c | ||
|
|
0623850c3f | ||
|
|
4c77d129e7 | ||
|
|
c68cb97964 | ||
|
|
59fa301344 | ||
|
|
23784d00fe | ||
|
|
17cff74b7c | ||
|
|
2a3a3c21ba | ||
|
|
ba7811621e | ||
|
|
340afe1b80 | ||
|
|
11b7823188 | ||
|
|
de1763cfe1 | ||
|
|
d30b069876 | ||
|
|
0d68da9624 | ||
|
|
4d857b386b | ||
|
|
be7a3bc153 | ||
|
|
5c71fc0de2 | ||
|
|
82cc33587c | ||
|
|
121f3c6ddf | ||
|
|
3410eeaec1 | ||
|
|
85ecd644b8 | ||
|
|
7cffb4667f | ||
|
|
0449ded505 | ||
|
|
e15cbffdb1 | ||
|
|
9674c674a2 | ||
|
|
bafc5f4709 | ||
|
|
a60de46173 | ||
|
|
1165dc3fa6 | ||
|
|
f5ab18a67b | ||
|
|
65db4aaff6 | ||
|
|
d9a23235e5 | ||
|
|
00b54e1f15 | ||
|
|
3c4ff66150 | ||
|
|
2b3078fcce | ||
|
|
6f4074e8d6 | ||
|
|
235ee25d9f | ||
|
|
d1b9990bac | ||
|
|
60d1ba4efc | ||
|
|
29b6e6eb24 | ||
|
|
aa65972483 | ||
|
|
ad275a7c83 | ||
|
|
02b57ead00 | ||
|
|
7f484fa7b3 | ||
|
|
3ea906a74d | ||
|
|
939c40ccbf | ||
|
|
f0eb291f56 | ||
|
|
46391b10e2 | ||
|
|
5918a6cc71 | ||
|
|
0eeabc7b3a | ||
|
|
31eb5c06ad | ||
|
|
60d8c297f9 | ||
|
|
dab70d5a97 | ||
|
|
520a6289a8 | ||
|
|
7adca53116 | ||
|
|
fbc3e6c084 | ||
|
|
19ba5c8f11 | ||
|
|
ce0d4dccf7 | ||
|
|
7c874cd4aa | ||
|
|
17224b4f74 | ||
|
|
40414264be | ||
|
|
d9a4a007e2 | ||
|
|
d3925cad22 | ||
|
|
443e2e62db | ||
|
|
4b7c58a6c9 | ||
|
|
19aa2ec9c9 | ||
|
|
c4c5b8e3a0 | ||
|
|
33a7dbc0e6 | ||
|
|
72921b9a8f | ||
|
|
835436fa1a | ||
|
|
648f4ba4bc | ||
|
|
f5023dc46b | ||
|
|
4c0a798050 | ||
|
|
cfe7c4ec5f | ||
|
|
c82779e15b | ||
|
|
9e56715e77 | ||
|
|
2254b9ad19 | ||
|
|
81707d9c7c | ||
|
|
9e61a3d8a8 | ||
|
|
8a38524774 | ||
|
|
87d3f1b410 | ||
|
|
1094a65852 | ||
|
|
0ab4b11f97 | ||
|
|
6d4fb5888f | ||
|
|
e7301e2c48 | ||
|
|
f69f6546f0 | ||
|
|
7fe53993aa | ||
|
|
028f0680c6 | ||
|
|
b3f67a5398 | ||
|
|
db3611487e | ||
|
|
f2c722bdfc | ||
|
|
ccb125ab9d | ||
|
|
a300a4a9c0 | ||
|
|
044cb4de7e | ||
|
|
3405d12696 | ||
|
|
11cd35373e | ||
|
|
5001185bf8 | ||
|
|
a7f298743d | ||
|
|
8192eaf55b | ||
|
|
5a0e0033ec | ||
|
|
7521f89ffd | ||
|
|
d8a1da1f13 | ||
|
|
3b846f0d44 | ||
|
|
f5006af6c0 | ||
|
|
3152631911 | ||
|
|
1d9f069f45 | ||
|
|
707f569f62 | ||
|
|
e3643ee9a0 | ||
|
|
49cf5ebad8 | ||
|
|
b79f5f4d7d | ||
|
|
d870915dc0 | ||
|
|
fcd494f815 | ||
|
|
a7728f80d0 | ||
|
|
9eb6387973 | ||
|
|
7be5510208 | ||
|
|
e0269bbf86 | ||
|
|
e76558d68b | ||
|
|
cceba2e98e | ||
|
|
1e93d25a40 | ||
|
|
74ef9d31b4 | ||
|
|
15b3f9c084 | ||
|
|
87efa6c77b | ||
|
|
07b35036e7 | ||
|
|
c9c12b9ed6 | ||
|
|
960754c54e | ||
|
|
51c9c8b4f0 | ||
|
|
fff7e90e14 | ||
|
|
11429e887e | ||
|
|
820548cb2d | ||
|
|
e2038085ca | ||
|
|
3676bf1ae7 | ||
|
|
57f766dda2 | ||
|
|
da8f2d08df | ||
|
|
9579f019d4 | ||
|
|
7c27337e0a | ||
|
|
2dffce4244 | ||
|
|
27a9f0aff8 | ||
|
|
67920040ea | ||
|
|
fd1b910236 | ||
|
|
09ba53d185 | ||
|
|
91e79388d1 | ||
|
|
cb5d59571e | ||
|
|
365c6c7858 | ||
|
|
6886a90ee4 | ||
|
|
7d1c9ee373 | ||
|
|
dc05595b9b | ||
|
|
d2581b1c52 | ||
|
|
eb37ed4c74 | ||
|
|
6800734136 | ||
|
|
d4b7da3a54 | ||
|
|
8382fa9232 | ||
|
|
071b9f2b25 | ||
|
|
1e8ffa15d0 | ||
|
|
fc7f97c136 | ||
|
|
99732211b7 | ||
|
|
aaa8e957f6 | ||
|
|
30fb774d93 | ||
|
|
e0b5218037 | ||
|
|
387bdb6abc | ||
|
|
3298dc3e77 | ||
|
|
55dce99d34 | ||
|
|
87f9e23bae | ||
|
|
1e97dd9fe5 | ||
|
|
a05c54fc66 | ||
|
|
aeb51b9ccc | ||
|
|
b72a23f1e1 | ||
|
|
4d96a3e646 | ||
|
|
fc8e573243 | ||
|
|
f80e336e1f | ||
|
|
2a1ff8e45e | ||
|
|
e9ad259957 | ||
|
|
86ddceb646 | ||
|
|
a1725a6872 | ||
|
|
af7cee5014 | ||
|
|
b6ee5e8790 |
@@ -12,9 +12,11 @@ scratch/
|
||||
.claude/
|
||||
*.local.*
|
||||
node_modules/
|
||||
docker-compose-dev.yml
|
||||
frontend/node_modules/
|
||||
deploy/compose/docker-compose-dev.yml
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
!data/locales-overrides.example.json
|
||||
|
||||
|
||||
# CI
|
||||
@@ -24,15 +26,15 @@ data/*
|
||||
|
||||
# Docker
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
.docker
|
||||
deploy/compose/*.yml
|
||||
.dockerignore
|
||||
tmp/
|
||||
|
||||
# WebApp build artifacts (regenerated inside Docker)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp.css
|
||||
backend/bot/app/web/templates/subscription_webapp.js
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
**/__pycache__/
|
||||
|
||||
@@ -1,223 +1,72 @@
|
||||
# Telegram Bot Token and Admin IDs
|
||||
BOT_TOKEN=your_bot_token_here # Telegram bot token
|
||||
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
|
||||
# Minimal bootstrap env.
|
||||
# Most product settings are configured later in Web App admin:
|
||||
# Admin -> System -> Settings, Admin -> System -> Tariffs, Admin -> Appearance.
|
||||
# Full reference: docs/env-vars.md
|
||||
|
||||
# PostgreSQL Database Connection Settings
|
||||
POSTGRES_USER= # Required: database user name
|
||||
POSTGRES_PASSWORD= # Required: database password
|
||||
POSTGRES_HOST=remnawave-minishop-db # Database container name
|
||||
POSTGRES_PORT=5432 # Port
|
||||
POSTGRES_DB=postgres # Database name
|
||||
# Telegram bot token from @BotFather.
|
||||
# Example: 1234567890:AA...
|
||||
BOT_TOKEN=your_bot_token_here
|
||||
|
||||
# Localization and Display
|
||||
DEFAULT_LANGUAGE="ru" # or "en"
|
||||
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
|
||||
# Telegram numeric user IDs allowed to open the admin panel.
|
||||
# Use commas for several admins, for example: 123456789,987654321
|
||||
ADMIN_IDS=123456789
|
||||
|
||||
# External Links
|
||||
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
|
||||
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
|
||||
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
|
||||
PRIVACY_POLICY_URL=https://example.com/privacy # Link to the privacy policy
|
||||
USER_AGREEMENT_URL=https://example.com/user-agreement # Link to the user agreement
|
||||
SUBSCRIPTION_MINI_APP_URL= # Public URL of the subscription Mini App, e.g. https://app.yourdomain.tld/
|
||||
START_COMMAND_DESCRIPTION= # Description of the /start command
|
||||
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
||||
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
|
||||
USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited)
|
||||
|
||||
# Required channel subscription
|
||||
REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join
|
||||
REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens
|
||||
|
||||
# Webhook Base URL (used for Telegram and payment providers)
|
||||
# Public HTTPS base URL of the backend webhook server.
|
||||
# Telegram, payment providers and Remnawave call webhook endpoints under this domain.
|
||||
# This is usually the backend/API domain, not the Mini App frontend domain.
|
||||
# Example: https://bot.yourdomain.tld
|
||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
TRUSTED_PROXIES=127.0.0.1,::1 # Reverse proxies trusted for X-Forwarded-For
|
||||
|
||||
# Subscription Mini App (same container, separate port)
|
||||
WEBAPP_ENABLED=True # Run Mini App HTTP server
|
||||
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
|
||||
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
|
||||
WEBAPP_TITLE="/minishop" # Mini App title
|
||||
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
|
||||
WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used
|
||||
WEBAPP_LOGO_EMOJI="🫥" # Emoji logo fallback shown in the header and login screen
|
||||
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
|
||||
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
|
||||
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
|
||||
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
|
||||
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
|
||||
TELEGRAM_OAUTH_CLIENT_ID= # Telegram Web Login Client ID from BotFather; defaults to bot ID from BOT_TOKEN
|
||||
TELEGRAM_OAUTH_CLIENT_SECRET= # Optional Telegram Web Login Client Secret; reserved for full OIDC code flow
|
||||
TELEGRAM_OAUTH_REQUEST_ACCESS=write # Optional comma-separated permissions: write,phone; empty = OpenID profile only
|
||||
# PostgreSQL user created by Docker Compose and used by the backend.
|
||||
POSTGRES_USER=remnawave_minishop
|
||||
|
||||
# Email login and account linking via SMTP (Brevo SMTP relay defaults)
|
||||
SMTP_HOST=smtp-relay.brevo.com # SMTP server
|
||||
SMTP_PORT=587 # Brevo recommends 587 with STARTTLS
|
||||
SMTP_FALLBACK_PORTS=2525,465 # Tried after SMTP_PORT; 465 uses SSL automatically
|
||||
SMTP_TIMEOUT_SECONDS=30 # Per SMTP connection/send attempt timeout
|
||||
SMTP_USERNAME= # Brevo SMTP login
|
||||
SMTP_PASSWORD= # Brevo SMTP key/password
|
||||
SMTP_FROM_EMAIL= # Verified sender email
|
||||
SMTP_FROM_NAME= # Optional sender name
|
||||
SMTP_STARTTLS=True # Use STARTTLS on SMTP_PORT
|
||||
SMTP_USE_SSL=False # Use SSL wrapper, usually only for port 465
|
||||
EMAIL_CODE_TTL_SECONDS=600 # Email verification code lifetime
|
||||
EMAIL_CODE_RESEND_SECONDS=60 # Minimum delay between code sends
|
||||
EMAIL_CODE_MAX_ATTEMPTS=5 # Max attempts per code
|
||||
BRUTE_FORCE_MAX_FAILURES=5 # Max failed code attempts in the throttle window
|
||||
BRUTE_FORCE_WINDOW_SECONDS=900 # Rolling window used to count failures
|
||||
BRUTE_FORCE_LOCK_SECONDS=1800 # Temporary lockout duration after too many failures
|
||||
# PostgreSQL password. Change it before production deploy.
|
||||
POSTGRES_PASSWORD=change_me
|
||||
|
||||
# Payment Method Toggles
|
||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
||||
FREEKASSA_ENABLED=True # Turn on FreeKassa
|
||||
STARS_ENABLED=True # Turn on STARS
|
||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||
PLATEGA_ENABLED=False # Turn on PLATEGA
|
||||
SEVERPAY_ENABLED=False # Turn on SeverPay
|
||||
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, stars, cryptopay
|
||||
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars
|
||||
# PostgreSQL database name created by Docker Compose.
|
||||
POSTGRES_DB=remnawave_minishop
|
||||
|
||||
# YooKassa Payment Gateway Configuration
|
||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
||||
YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa
|
||||
YOOKASSA_RETURN_URL=https://t.me/your_bot # URL to which the user will be returned after payment
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
|
||||
YOOKASSA_VAT_CODE=1 # VAT code
|
||||
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
||||
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox)
|
||||
# Enables the Web App and the Web App admin panel.
|
||||
# Keep True for the first setup. If set to False, the admin UI is unavailable
|
||||
# until you change it back to True in .env and restart the app.
|
||||
WEBAPP_ENABLED=True
|
||||
|
||||
# Nalogo (self-employed receipts)
|
||||
NALOGO_INN=your_inn # INN for nalog.ru
|
||||
NALOGO_PASSWORD=your_nalogo_password # Password for nalog.ru
|
||||
NALOGO_RECEIPT_NAME_SUBSCRIPTION=subscription {months} months # Receipt name for time-based subscriptions ({months} = duration)
|
||||
NALOGO_RECEIPT_NAME_TRAFFIC=traffic package {gb} GB # Receipt name for traffic packages ({gb} = traffic amount)
|
||||
# Stable secret for Web App sessions.
|
||||
# Generate with: openssl rand -hex 32
|
||||
# If empty, sessions are invalidated on every restart.
|
||||
WEBAPP_SESSION_SECRET=
|
||||
|
||||
# FreeKassa Payment Gateway Configuration
|
||||
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
||||
FREEKASSA_API_KEY=your_api_key # API key for REST requests
|
||||
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
|
||||
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
|
||||
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
|
||||
FREEKASSA_TRUSTED_IPS=168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238 # FreeKassa webhook source IP allowlist
|
||||
# Stable Telegram webhook secret_token.
|
||||
# Generate with: openssl rand -hex 32
|
||||
# If empty, a new token can be generated on process start.
|
||||
WEBHOOK_SECRET_TOKEN=
|
||||
|
||||
# CryptoBot Payment Gateway Configuration
|
||||
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
||||
CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet)
|
||||
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
|
||||
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
|
||||
# Public HTTPS URL of the Mini App frontend, with trailing slash.
|
||||
# This URL is opened by Telegram buttons and BotFather Mini App settings.
|
||||
# Do not put /api or webhook paths here.
|
||||
# Example: https://app.yourdomain.tld/
|
||||
SUBSCRIPTION_MINI_APP_URL=https://app.yourdomain.tld/
|
||||
|
||||
# Platega Payment Gateway Configuration
|
||||
PLATEGA_BASE_URL=https://app.platega.io # Base API URL
|
||||
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega
|
||||
PLATEGA_SECRET= # API secret from Platega
|
||||
PLATEGA_PAYMENT_METHOD=2 # Legacy method ID; fallback for the SBP button when PLATEGA_SBP_METHOD stays default
|
||||
PLATEGA_SBP_ENABLED=False # Show a separate "Pay via SBP" Platega button
|
||||
PLATEGA_CRYPTO_ENABLED=False # Show a separate "Pay with crypto" Platega button
|
||||
PLATEGA_SBP_METHOD=2 # Platega method ID for SBP QR (default 2)
|
||||
PLATEGA_CRYPTO_METHOD=13 # Platega method ID for crypto (default 13)
|
||||
PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link)
|
||||
PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL)
|
||||
# Remnawave panel API URL. Usually the panel domain plus /api.
|
||||
# Example: https://panel.yourdomain.tld/api
|
||||
PANEL_API_URL=https://panel.yourdomain.tld/api
|
||||
|
||||
# SeverPay Payment Gateway Configuration
|
||||
SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL
|
||||
SEVERPAY_MID= # Your MID from SeverPay
|
||||
SEVERPAY_TOKEN= # API token/secret for signing requests
|
||||
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link)
|
||||
SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
|
||||
# Remnawave API key with permissions to manage users, subscriptions and squads.
|
||||
# Keep this secret. It can be overridden later in the admin panel if needed.
|
||||
PANEL_API_KEY=
|
||||
|
||||
# Subscription Options. Specify cost parameters or payment links here.
|
||||
1_MONTH_ENABLED=True
|
||||
RUB_PRICE_1_MONTH=150
|
||||
STARS_PRICE_1_MONTH=0
|
||||
# Shared secret for validating incoming Remnawave webhooks.
|
||||
# Use the same value when configuring the webhook in Remnawave panel.
|
||||
PANEL_WEBHOOK_SECRET=
|
||||
|
||||
3_MONTHS_ENABLED=True
|
||||
RUB_PRICE_3_MONTHS=300
|
||||
STARS_PRICE_3_MONTHS=0
|
||||
|
||||
6_MONTHS_ENABLED=True
|
||||
RUB_PRICE_6_MONTHS=500
|
||||
STARS_PRICE_6_MONTHS=0
|
||||
|
||||
12_MONTHS_ENABLED=True
|
||||
RUB_PRICE_12_MONTHS=900
|
||||
STARS_PRICE_12_MONTHS=0
|
||||
|
||||
# Traffic Packages (enables traffic sale mode when set)
|
||||
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
|
||||
STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars
|
||||
TARIFFS_CONFIG_PATH=data/tariffs.json # Optional Tariffs 2.0 JSON config. If missing, legacy .env pricing is used.
|
||||
NEWT_ID= # Optional: local docker-compose-dev.yml Newt tunnel id
|
||||
NEWT_SECRET= # Optional: local docker-compose-dev.yml Newt tunnel secret
|
||||
TARIFF_TRAFFIC_WARNING_LEVELS=85,90,95 # Tariffs 2.0 traffic warning levels, percent used
|
||||
|
||||
# Subscription Notifications
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
|
||||
|
||||
|
||||
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
|
||||
REFERRAL_WELCOME_BONUS_DAYS=3 # Welcome bonus for newly registered user from referral link
|
||||
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
|
||||
# Referral Bonus Days
|
||||
# Bonus for the inviting user
|
||||
REFERRAL_BONUS_DAYS_1_MONTH=3
|
||||
REFERRAL_BONUS_DAYS_3_MONTHS=7
|
||||
REFERRAL_BONUS_DAYS_6_MONTHS=15
|
||||
REFERRAL_BONUS_DAYS_12_MONTHS=30
|
||||
# Invited User Bonus
|
||||
REFEREE_BONUS_DAYS_1_MONTH=1
|
||||
REFEREE_BONUS_DAYS_3_MONTHS=3
|
||||
REFEREE_BONUS_DAYS_6_MONTHS=7
|
||||
REFEREE_BONUS_DAYS_12_MONTHS=15
|
||||
|
||||
# Panel API Configuration
|
||||
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
|
||||
PANEL_API_KEY=your_panel_api_key # Panel API key
|
||||
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
|
||||
|
||||
# User traffic limits (applied for all users)
|
||||
# 0 means unlimited
|
||||
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
|
||||
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
|
||||
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
|
||||
# Default External Squad for Users (Optional, single UUID)
|
||||
USER_EXTERNAL_SQUAD_UUID= # Optional: UUID from Remnawave External Squads to auto-link new panel users
|
||||
|
||||
# Trial Settings
|
||||
TRIAL_ENABLED=True # Enable the trial period
|
||||
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
|
||||
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
|
||||
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
|
||||
|
||||
# Connection link handling (happ crypt4)
|
||||
CRYPT4_ENABLED=False # Enable happ crypt4 encryption for subscription URLs
|
||||
CRYPT4_REDIRECT_URL= # Base redirect to wrap the connect button, e.g. https://redir.example.com?url=
|
||||
|
||||
# Web Server Settings (for handling webhooks)
|
||||
WEB_SERVER_HOST="0.0.0.0"
|
||||
# Host port that publishes the backend webhook server from Docker Compose.
|
||||
# Your reverse proxy should route WEBHOOK_BASE_URL traffic to this port.
|
||||
WEB_SERVER_PORT=8080
|
||||
|
||||
# Admin Panel Log Pagination
|
||||
LOGS_PAGE_SIZE=10 # Number of events in the log
|
||||
LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
# Host port that publishes the frontend nginx from Docker Compose.
|
||||
# Your reverse proxy should route SUBSCRIPTION_MINI_APP_URL traffic to this port.
|
||||
FRONTEND_PORT=8082
|
||||
|
||||
# Admin Logging Configuration
|
||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
||||
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
|
||||
LOG_NEW_USERS=True # Log new user registrations
|
||||
LOG_PAYMENTS=True # Log payments
|
||||
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
|
||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
||||
LOG_ADMIN_ACTIONS=True # Log actions from users listed in ADMIN_IDS
|
||||
|
||||
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
|
||||
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
|
||||
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
|
||||
INLINE_FINANCIAL_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2769/2769339.png
|
||||
INLINE_SYSTEM_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2920/2920277.png
|
||||
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
||||
# Keep loopback for local proxy; add your proxy network if needed.
|
||||
TRUSTED_PROXIES=127.0.0.1,::1
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
*.sh text eol=lf
|
||||
deploy/docker/frontend/*.sh text eol=lf
|
||||
@@ -5,7 +5,9 @@ bot_database.sqlite3
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
docker-compose-dev.yml
|
||||
scratch_*.py
|
||||
scratch/
|
||||
*.local.*
|
||||
node_modules/
|
||||
.git/
|
||||
@@ -14,6 +16,35 @@ node_modules/
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
bot/app/web/templates/subscription_webapp.*.css
|
||||
bot/app/web/templates/subscription_webapp.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp.*.css.br
|
||||
bot/app/web/templates/subscription_webapp.*.css.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.css
|
||||
bot/app/web/templates/subscription_webapp_admin.js
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.css
|
||||
backend/bot/app/web/templates/subscription_webapp.js
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.css
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.js
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
tmp
|
||||
.claude
|
||||
|
||||
@@ -32,4 +63,4 @@ locales/en_backup.json
|
||||
db/models_old.py
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
docker-compose-dev.yml
|
||||
!data/locales-overrides.example.json
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# Replace the example domains below with your real webhook and Mini App hostnames.
|
||||
webhook.domain.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy remnawave-minishop:{$WEB_SERVER_PORT:8080}
|
||||
}
|
||||
|
||||
app.domain.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy remnawave-minishop:{$WEBAPP_SERVER_PORT:8081}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
FROM python:3.12-slim AS python-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
FROM node:22-slim AS webapp-builder
|
||||
|
||||
WORKDIR /webapp
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
|
||||
COPY bot/app/web/frontend ./bot/app/web/frontend
|
||||
COPY bot/app/web/templates ./bot/app/web/templates
|
||||
COPY scripts/build_subscription_webapp_js.mjs ./scripts/build_subscription_webapp_js.mjs
|
||||
|
||||
RUN npm run build:webapp
|
||||
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG APP_VERSION=""
|
||||
ARG APP_REVISION=""
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \
|
||||
org.opencontainers.image.version="${APP_VERSION}" \
|
||||
org.opencontainers.image.revision="${APP_REVISION}"
|
||||
|
||||
RUN useradd -u 10001 -m appuser
|
||||
|
||||
COPY --from=python-builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends git
|
||||
|
||||
COPY . .
|
||||
|
||||
# Replace template assets with freshly built ones
|
||||
RUN rm -f bot/app/web/templates/subscription_webapp.css \
|
||||
bot/app/web/templates/subscription_webapp.js \
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.css \
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.js \
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.min.*.js \
|
||||
bot/app/web/templates/
|
||||
|
||||
RUN set -eux; \
|
||||
if [ -n "$APP_VERSION" ]; then \
|
||||
printf '%s\n' "$APP_VERSION" > .build-version; \
|
||||
elif [ -d .git ]; then \
|
||||
tag="$(git describe --tags --abbrev=0 2>/dev/null || true)"; \
|
||||
sha="$(git rev-parse --short HEAD 2>/dev/null || true)"; \
|
||||
dirty=""; \
|
||||
if ! git diff --quiet --ignore-submodules HEAD 2>/dev/null; then dirty="-dirty"; fi; \
|
||||
if [ -n "$tag" ] && [ -n "$sha" ]; then \
|
||||
count="$(git rev-list "${tag}..HEAD" --count 2>/dev/null || true)"; \
|
||||
if [ -n "$count" ] && [ "$count" != "0" ]; then \
|
||||
printf '%s+%s.g%s%s\n' "$tag" "$count" "$sha" "$dirty" > .build-version; \
|
||||
else \
|
||||
printf '%s%s\n' "$tag" "$dirty" > .build-version; \
|
||||
fi; \
|
||||
elif [ -n "$sha" ]; then \
|
||||
printf 'dev+g%s%s\n' "$sha" "$dirty" > .build-version; \
|
||||
else \
|
||||
printf 'dev+container\n' > .build-version; \
|
||||
fi; \
|
||||
else \
|
||||
printf 'dev+container\n' > .build-version; \
|
||||
fi; \
|
||||
if [ -n "$APP_REVISION" ]; then \
|
||||
printf '%s\n' "$APP_REVISION" > .build-revision; \
|
||||
elif [ -d .git ]; then \
|
||||
git rev-parse HEAD > .build-revision 2>/dev/null || printf 'unknown\n' > .build-revision; \
|
||||
else \
|
||||
printf 'unknown\n' > .build-revision; \
|
||||
fi; \
|
||||
apt-get purge -y --auto-remove git; \
|
||||
rm -rf .git /root/.cache
|
||||
|
||||
RUN mkdir -p /app/logs /app/data && chown -R appuser:appuser /app/logs /app/data
|
||||
|
||||
USER appuser
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
@@ -1,7 +1,21 @@
|
||||
Copyright 2025 machka-pasla
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
Copyright (c) 2025-2026 machka-pasla, 3252a8 and other contributors
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Remnawave Minishop
|
||||
|
||||
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
|
||||

|
||||
|
||||
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками панели [Remnawave](https://docs.rw/). Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
|
||||
|
||||
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
|
||||
|
||||
@@ -12,8 +14,10 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
||||
- просмотр статуса подписки, даты окончания, ссылки подключения и трафика;
|
||||
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
|
||||
- Web App / Mini App с входом через Telegram или email;
|
||||
- встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/<token>` для передачи инструкции;
|
||||
- пробный период, промокоды и реферальная программа;
|
||||
- оплата через YooKassa, FreeKassa, Platega, SeverPay, CryptoPay и Telegram Stars;
|
||||
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Telegram Stars;
|
||||
- тикеты поддержки в Web App и внешняя ссылка на поддержку;
|
||||
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
|
||||
|
||||
Для администраторов:
|
||||
@@ -21,16 +25,20 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
||||
- админ-панель для пользователей из `ADMIN_IDS` (только при входе через Telegram, не для аккаунтов только с email);
|
||||
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
|
||||
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
|
||||
- блокировка пользователей, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
|
||||
- блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
|
||||
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
|
||||
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональный JSON-override и переключатель поведения кнопок бота;
|
||||
- ручная синхронизация пользователей и подписок с панелью.
|
||||
|
||||
## Документация
|
||||
|
||||
- [Настройка окружения](docs/configuration.md) - основные переменные `.env`, платежи, Remnawave, пробный период, SMTP для email-входа и секреты.
|
||||
- [Настройка окружения](docs/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
|
||||
- [Переменные `.env`](docs/env-vars.md) - полный справочник всех env-ключей по разделам.
|
||||
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
||||
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
||||
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки.
|
||||
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход, инструкции установки и реферальные ссылки.
|
||||
- [Поддержка](docs/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
||||
- [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
||||
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
|
||||
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
|
||||
|
||||
@@ -40,13 +48,13 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
||||
|
||||
## Стек
|
||||
|
||||
Сборка и runtime задаются **Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **requirements.txt** и **package.json**.
|
||||
Сборка и runtime задаются **deploy/docker/Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **backend/requirements.txt** и **frontend/package.json**.
|
||||
|
||||
| Слой | Технологии |
|
||||
| --- | --- |
|
||||
| Backend | Python **3.12**, [aiogram](https://docs.aiogram.dev/) 3.x (Telegram), **aiohttp** (HTTP и Web App), **SQLAlchemy** 2 async, **asyncpg**, **Pydantic** / pydantic-settings, **httpx**, платёжные SDK (в т.ч. YooKassa, aiocryptopay), **PyJWT** |
|
||||
| Данные | **PostgreSQL** **17** (сервис `remnawave-minishop-db` в Compose) |
|
||||
| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `bot/app/web/templates/` |
|
||||
| Данные | **PostgreSQL** **17** (сервис `postgres` в Compose) и **Redis** **7** (сервис `redis`) |
|
||||
| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `backend/bot/app/web/templates/` |
|
||||
|
||||
Локальная разработка без Docker возможна при установленных Python 3.12, PostgreSQL и (для пересборки фронта) Node 22; типичный сценарий — всё через Compose.
|
||||
|
||||
@@ -57,7 +65,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
||||
- Docker и Docker Compose;
|
||||
- рабочая панель Remnawave версии **`> 2.7.0`** (см. раздел «Совместимость»);
|
||||
- токен Telegram-бота;
|
||||
- параметры хотя бы одного платежного провайдера.
|
||||
- публичные домены для webhook и Mini App.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/3252a8/remnawave-minishop
|
||||
@@ -65,7 +73,7 @@ cd remnawave-minishop
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
docker compose up -d --build
|
||||
docker compose logs -f remnawave-minishop
|
||||
docker compose logs -f backend worker frontend
|
||||
```
|
||||
|
||||
Минимально заполните в `.env`:
|
||||
@@ -73,17 +81,22 @@ docker compose logs -f remnawave-minishop
|
||||
- `BOT_TOKEN` - токен Telegram-бота;
|
||||
- `ADMIN_IDS` - Telegram ID администраторов через запятую;
|
||||
- `WEBHOOK_BASE_URL` - публичный URL вебхуков;
|
||||
- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` - доступы PostgreSQL;
|
||||
- `WEBAPP_ENABLED=True` - включает Web App и админку для первого входа;
|
||||
- `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN` - стабильные секреты;
|
||||
- `SUBSCRIPTION_MINI_APP_URL` - публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`;
|
||||
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
||||
- `USER_SQUAD_UUIDS` - Internal Squads для пользователей;
|
||||
- настройки платежного провайдера;
|
||||
- `SUBSCRIPTION_MINI_APP_URL`, если используется Web App.
|
||||
- остальные настройки удобнее задать в Web App админке.
|
||||
|
||||
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/env-vars.md](docs/env-vars.md).
|
||||
|
||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
|
||||
|
||||
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, кеша логотипа Web App и animated emoji:
|
||||
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
|
||||
|
||||
```bash
|
||||
mkdir -p data/webapp-logo data/webapp-emoji
|
||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji
|
||||
touch data/locales-overrides.json
|
||||
chown -R 10001:10001 data
|
||||
chmod -R u+rwX data
|
||||
```
|
||||
@@ -95,15 +108,26 @@ chmod -R u+rwX data
|
||||
docker compose up -d --build
|
||||
|
||||
# Логи приложения
|
||||
docker compose logs -f remnawave-minishop
|
||||
docker compose logs -f backend worker frontend
|
||||
|
||||
# Запуск с Caddy
|
||||
docker compose -f docker-compose-caddy.yml up -d --build
|
||||
# Готовые production-примеры
|
||||
cd deploy/examples/caddy # или nginx, newt, no-proxy
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
docker compose up -d
|
||||
|
||||
# Запуск из готового образа
|
||||
IMAGE_TAG=3.1.0 docker compose -f docker-compose-remote-server.yml up -d
|
||||
# Запуск из готового образа с конкретным тегом
|
||||
IMAGE_TAG=3.1.0 docker compose up -d
|
||||
```
|
||||
|
||||
## Поддержка
|
||||
Для production-запуска удобнее брать готовые папки из [`deploy/examples`](deploy/examples): там отдельно собраны варианты для Caddy, Nginx, Newt/Pangolin и прямой публикации портов без reverse proxy. В каждой папке рядом лежат `docker-compose.yml`, `.env.example`, README и нужный proxy-конфиг.
|
||||
|
||||
GHCR image names for releases:
|
||||
|
||||
- `ghcr.io/3252a8/remnawave-minishop-backend`
|
||||
- `ghcr.io/3252a8/remnawave-minishop-worker`
|
||||
- `ghcr.io/3252a8/remnawave-minishop-frontend`
|
||||
|
||||
## Поддержать проект
|
||||
|
||||
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
level = getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO)
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
stream=sys.stdout,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
@@ -6,6 +6,11 @@ from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
try:
|
||||
from aiogram.fsm.storage.redis import RedisStorage
|
||||
except ModuleNotFoundError: # pragma: no cover - dependency is installed in Docker image
|
||||
RedisStorage = None # type: ignore[assignment]
|
||||
|
||||
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
|
||||
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
||||
from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
||||
@@ -18,7 +23,11 @@ from config.settings import Settings
|
||||
def build_dispatcher(
|
||||
settings: Settings, async_session_factory: sessionmaker
|
||||
) -> tuple[Dispatcher, Bot, Dict]:
|
||||
storage = MemoryStorage()
|
||||
storage = (
|
||||
RedisStorage.from_url(settings.REDIS_URL)
|
||||
if settings.REDIS_URL and RedisStorage is not None
|
||||
else MemoryStorage()
|
||||
)
|
||||
default_props = DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=default_props)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.payment_providers import (
|
||||
ServiceFactoryContext,
|
||||
build_provider_configs,
|
||||
build_provider_services,
|
||||
)
|
||||
from bot.services.email_auth_service import EmailAuthService
|
||||
from bot.services.lknpd_service import LknpdService
|
||||
from bot.services.notification_service import NotificationService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.panel_webhook_service import PanelWebhookService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.support_service import SupportService
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
def build_core_services(
|
||||
settings: Settings,
|
||||
bot: Bot,
|
||||
async_session_factory: sessionmaker,
|
||||
i18n: JsonI18n,
|
||||
bot_username_for_default_return: str,
|
||||
):
|
||||
panel_service = PanelApiService(settings)
|
||||
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
|
||||
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
|
||||
email_auth_service = EmailAuthService(settings, i18n)
|
||||
notification_service = NotificationService(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
session_factory=async_session_factory,
|
||||
email_auth_service=email_auth_service,
|
||||
bot_username=bot_username_for_default_return,
|
||||
)
|
||||
support_service = SupportService(
|
||||
async_session_factory,
|
||||
settings,
|
||||
bot,
|
||||
i18n,
|
||||
notification_service,
|
||||
email_auth_service,
|
||||
)
|
||||
panel_webhook_service = PanelWebhookService(
|
||||
bot, settings, i18n, async_session_factory, panel_service
|
||||
)
|
||||
provider_configs = build_provider_configs()
|
||||
payment_services = build_provider_services(
|
||||
ServiceFactoryContext(
|
||||
settings=settings,
|
||||
bot=bot,
|
||||
async_session_factory=async_session_factory,
|
||||
i18n=i18n,
|
||||
bot_username_for_default_return=bot_username_for_default_return,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
provider_configs=provider_configs,
|
||||
)
|
||||
)
|
||||
lknpd_service = LknpdService(
|
||||
settings.LKNPD_INN,
|
||||
settings.LKNPD_PASSWORD,
|
||||
api_url=settings.LKNPD_API_URL,
|
||||
)
|
||||
|
||||
# These attachments are critical for auto-renew and panel pre-expiry hooks.
|
||||
subscription_service.yookassa_service = payment_services.get("yookassa_service")
|
||||
panel_webhook_service.subscription_service = subscription_service
|
||||
|
||||
services = {
|
||||
"panel_service": panel_service,
|
||||
"subscription_service": subscription_service,
|
||||
"referral_service": referral_service,
|
||||
"promo_code_service": promo_code_service,
|
||||
"notification_service": notification_service,
|
||||
"email_auth_service": email_auth_service,
|
||||
"support_service": support_service,
|
||||
"panel_webhook_service": panel_webhook_service,
|
||||
"lknpd_service": lknpd_service,
|
||||
}
|
||||
services.update(payment_services)
|
||||
return services
|
||||
@@ -15,8 +15,11 @@ from bot.app.web.admin_api_impl import (
|
||||
routes as _routes,
|
||||
settings as _settings,
|
||||
stats as _stats,
|
||||
support as _support,
|
||||
sync as _sync,
|
||||
tariffs as _tariffs,
|
||||
themes as _themes,
|
||||
translations as _translations,
|
||||
users as _users,
|
||||
)
|
||||
|
||||
@@ -29,11 +32,14 @@ _MODULES = (
|
||||
_payments,
|
||||
_promos,
|
||||
_logs,
|
||||
_support,
|
||||
_broadcast,
|
||||
_sync,
|
||||
_ads,
|
||||
_settings,
|
||||
_tariffs,
|
||||
_themes,
|
||||
_translations,
|
||||
_panel,
|
||||
_routes,
|
||||
)
|
||||
@@ -28,6 +28,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
from bot.app.web.admin_settings_manifest import (
|
||||
manifest_payload,
|
||||
)
|
||||
from bot.infra.webhook_queue import enqueue_webhook_event
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.settings_override_service import (
|
||||
current_value,
|
||||
@@ -40,6 +41,7 @@ from config.tariffs_config import TariffsConfig
|
||||
from db.dal import (
|
||||
ad_dal,
|
||||
app_settings_dal,
|
||||
locale_overrides_dal,
|
||||
message_log_dal,
|
||||
panel_sync_dal,
|
||||
payment_dal,
|
||||
@@ -234,6 +234,7 @@ def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
|
||||
"user_id": int(entry.user_id) if entry.user_id else None,
|
||||
"telegram_username": entry.telegram_username,
|
||||
"telegram_first_name": entry.telegram_first_name,
|
||||
"email": getattr(getattr(entry, "author_user", None), "email", None),
|
||||
"event_type": entry.event_type,
|
||||
"content": entry.content,
|
||||
"is_admin_event": bool(entry.is_admin_event),
|
||||
@@ -270,6 +271,10 @@ def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None:
|
||||
path.write_text(payload, encoding="utf-8")
|
||||
|
||||
|
||||
def _webapp_themes_catalog_payload(config: Any) -> Dict[str, Any]:
|
||||
return config.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
|
||||
def _panel_node_uuid_key(node: Dict[str, Any]) -> str:
|
||||
uid = node.get("nodeUuid") or node.get("node_uuid") or node.get("uuid") or node.get("id")
|
||||
return str(uid).strip().lower() if uid else ""
|
||||
@@ -32,6 +32,35 @@ async def admin_payments_list_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
async def admin_payment_detail_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
try:
|
||||
payment_id = int(request.match_info["payment_id"])
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_payment", "Invalid payment id")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_id)
|
||||
if not payment:
|
||||
return _error(404, "not_found", "Payment not found")
|
||||
|
||||
payload = _serialize_payment(payment)
|
||||
payload.update(
|
||||
{
|
||||
"yookassa_payment_id": payment.yookassa_payment_id,
|
||||
"idempotence_key": payment.idempotence_key,
|
||||
"promo_code": (
|
||||
payment.promo_code_used.code if payment.promo_code_used is not None else None
|
||||
),
|
||||
"updated_at": payment.updated_at.isoformat() if payment.updated_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
return _ok({"payment": payload})
|
||||
|
||||
|
||||
async def admin_payments_export_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
@@ -15,6 +15,10 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route
|
||||
)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/telegram-profile-link",
|
||||
admin_user_telegram_profile_link_route,
|
||||
)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route)
|
||||
router.add_post(
|
||||
@@ -32,6 +36,7 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route)
|
||||
|
||||
router.add_get("/api/admin/payments", admin_payments_list_route)
|
||||
router.add_get("/api/admin/payments/{payment_id:\\d+}", admin_payment_detail_route)
|
||||
router.add_get("/api/admin/payments/export.csv", admin_payments_export_route)
|
||||
|
||||
router.add_get("/api/admin/promos", admin_promos_list_route)
|
||||
@@ -41,6 +46,16 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
|
||||
router.add_get("/api/admin/logs", admin_logs_route)
|
||||
|
||||
router.add_get("/api/admin/support/tickets", admin_support_tickets_route)
|
||||
router.add_get("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_detail_route)
|
||||
router.add_post(
|
||||
"/api/admin/support/tickets/{id:\\d+}/messages",
|
||||
admin_support_ticket_reply_route,
|
||||
)
|
||||
router.add_patch("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_patch_route)
|
||||
router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route)
|
||||
router.add_get("/api/admin/support/stats", admin_support_stats_route)
|
||||
|
||||
router.add_post("/api/admin/broadcast", admin_broadcast_route)
|
||||
router.add_post("/api/admin/sync", admin_sync_route)
|
||||
|
||||
@@ -51,7 +66,13 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
|
||||
router.add_get("/api/admin/settings", admin_settings_get_route)
|
||||
router.add_patch("/api/admin/settings", admin_settings_patch_route)
|
||||
router.add_get("/api/admin/translations", admin_translations_get_route)
|
||||
router.add_patch("/api/admin/translations", admin_translations_patch_route)
|
||||
|
||||
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
|
||||
router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
|
||||
router.add_get("/api/admin/themes", admin_themes_get_route)
|
||||
router.add_put("/api/admin/themes", admin_themes_save_route)
|
||||
router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route)
|
||||
router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_upload_route)
|
||||
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
|
||||
@@ -1,5 +1,11 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
|
||||
|
||||
from config.subscription_guides_config import (
|
||||
SubscriptionGuidesConfigError,
|
||||
subscription_guides_admin_config_json,
|
||||
)
|
||||
|
||||
|
||||
async def admin_settings_get_route(request: web.Request) -> web.Response:
|
||||
@@ -13,6 +19,7 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
|
||||
overrides_by_key = {entry["key"]: entry for entry in overrides}
|
||||
|
||||
fields = manifest_payload()
|
||||
webhook_base_url = str(settings.WEBHOOK_BASE_URL or "").strip().rstrip("/")
|
||||
sections: Dict[str, Dict[str, Any]] = {}
|
||||
for field in fields:
|
||||
key = field["key"]
|
||||
@@ -26,14 +33,35 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
|
||||
override = overrides_by_key.get(key)
|
||||
value = current_value(settings, key)
|
||||
is_secret = bool(field.get("secret"))
|
||||
overridden = bool(override)
|
||||
source = None
|
||||
read_error = None
|
||||
if key == "SUBSCRIPTION_PAGE_CONFIG_JSON":
|
||||
try:
|
||||
value, source = subscription_guides_admin_config_json(settings)
|
||||
overridden = source == "admin_json"
|
||||
except SubscriptionGuidesConfigError as exc:
|
||||
read_error = str(exc)
|
||||
response_field = {
|
||||
**field,
|
||||
"value": "" if is_secret else value,
|
||||
"overridden": bool(override),
|
||||
"overridden": overridden,
|
||||
"updated_at": override.get("updated_at") if override else None,
|
||||
}
|
||||
if source:
|
||||
response_field["source"] = source
|
||||
if read_error:
|
||||
response_field["read_error"] = read_error
|
||||
if is_secret:
|
||||
response_field["has_value"] = bool(value)
|
||||
webhook_path = str(response_field.get("webhook_path") or "").strip()
|
||||
if webhook_path:
|
||||
if not webhook_path.startswith("/"):
|
||||
webhook_path = f"/{webhook_path}"
|
||||
response_field["webhook_path"] = webhook_path
|
||||
response_field["webhook_base_url_configured"] = bool(webhook_base_url)
|
||||
if webhook_base_url:
|
||||
response_field["webhook_url"] = f"{webhook_base_url}{webhook_path}"
|
||||
sections[section_id]["fields"].append(response_field)
|
||||
|
||||
ordered_sections = sorted(sections.values(), key=lambda s: s["order"])
|
||||
@@ -51,6 +79,13 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
|
||||
return _error(400, "invalid_updates")
|
||||
if not isinstance(deletes, list):
|
||||
return _error(400, "invalid_deletes")
|
||||
if (
|
||||
"SUBSCRIPTION_PAGE_CONFIG_JSON" in updates
|
||||
and not str(updates.get("SUBSCRIPTION_PAGE_CONFIG_JSON") or "").strip()
|
||||
):
|
||||
updates = dict(updates)
|
||||
updates.pop("SUBSCRIPTION_PAGE_CONFIG_JSON", None)
|
||||
deletes = [*deletes, "SUBSCRIPTION_PAGE_CONFIG_JSON"]
|
||||
|
||||
result = await update_overrides(
|
||||
settings,
|
||||
@@ -65,10 +100,6 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Bust the public webapp settings cache so users see new values immediately.
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=deletes)
|
||||
|
||||
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
|
||||
@@ -0,0 +1,168 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
import asyncio
|
||||
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .auth import _require_admin_user_id
|
||||
from .common import _ok, _serialize_payment
|
||||
from bot.utils.ttl_cache import AsyncTTLCache
|
||||
|
||||
_ADMIN_PANEL_STATS_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
|
||||
_ADMIN_DB_STATS_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
|
||||
|
||||
|
||||
async def admin_me_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
return _ok({}, user_id=user_id, admin_ids=list(settings.ADMIN_IDS or []))
|
||||
|
||||
|
||||
async def admin_stats_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
payload = dict(await _load_admin_db_stats(settings, async_session_factory))
|
||||
|
||||
panel_service = request.app.get("panel_service")
|
||||
if panel_service is not None:
|
||||
payload["panel"] = await _load_admin_panel_stats(request, settings, panel_service)
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if queue_manager:
|
||||
try:
|
||||
payload["queue"] = queue_manager.get_queue_stats()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
payload["queue"] = None
|
||||
|
||||
payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
return _ok(payload)
|
||||
|
||||
|
||||
async def _load_admin_db_stats(
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
) -> Dict[str, Any]:
|
||||
cache = _admin_db_stats_cache(settings)
|
||||
if cache is None:
|
||||
return await _load_admin_db_stats_uncached(async_session_factory)
|
||||
return await cache.get_or_load(
|
||||
"db",
|
||||
lambda: _load_admin_db_stats_uncached(async_session_factory),
|
||||
)
|
||||
|
||||
|
||||
async def _load_admin_db_stats_uncached(async_session_factory: sessionmaker) -> Dict[str, Any]:
|
||||
async with async_session_factory() as session:
|
||||
user_stats = await user_dal.get_enhanced_user_statistics(session)
|
||||
financial_stats = await payment_dal.get_financial_statistics(session)
|
||||
sync_status = await panel_sync_dal.get_panel_sync_status(session)
|
||||
recent_payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10)
|
||||
|
||||
return {
|
||||
"users": user_stats,
|
||||
"financial": financial_stats,
|
||||
"panel_sync": {
|
||||
"status": sync_status.status if sync_status else "never_run",
|
||||
"last_sync_time": sync_status.last_sync_time.isoformat()
|
||||
if sync_status and sync_status.last_sync_time
|
||||
else None,
|
||||
"details": sync_status.details if sync_status else None,
|
||||
"users_processed": sync_status.users_processed_from_panel if sync_status else 0,
|
||||
"subscriptions_synced": sync_status.subscriptions_synced if sync_status else 0,
|
||||
},
|
||||
"recent_payments": [_serialize_payment(p) for p in recent_payments],
|
||||
}
|
||||
|
||||
|
||||
def _admin_db_stats_cache(settings: Settings) -> Optional[AsyncTTLCache]:
|
||||
ttl_seconds = int(getattr(settings, "ADMIN_DB_STATS_CACHE_TTL_SECONDS", 5) or 0)
|
||||
if ttl_seconds <= 0:
|
||||
return None
|
||||
cache_key = (id(settings), ttl_seconds)
|
||||
cache = _ADMIN_DB_STATS_CACHES.get(cache_key)
|
||||
if cache is None:
|
||||
cache = AsyncTTLCache(
|
||||
ttl_seconds=ttl_seconds,
|
||||
settings=settings,
|
||||
namespace="admin:db_stats",
|
||||
)
|
||||
_ADMIN_DB_STATS_CACHES[cache_key] = cache
|
||||
return cache
|
||||
|
||||
|
||||
async def _load_admin_panel_stats(
|
||||
request: web.Request,
|
||||
settings: Settings,
|
||||
panel_service,
|
||||
) -> Dict[str, Any]:
|
||||
cache = _admin_panel_stats_cache(settings)
|
||||
if cache is None:
|
||||
return await _load_admin_panel_stats_uncached(panel_service)
|
||||
return await cache.get_or_load("panel", lambda: _load_admin_panel_stats_uncached(panel_service))
|
||||
|
||||
|
||||
def _admin_panel_stats_cache(settings: Settings) -> Optional[AsyncTTLCache]:
|
||||
ttl_seconds = int(getattr(settings, "ADMIN_PANEL_STATS_CACHE_TTL_SECONDS", 15) or 0)
|
||||
if ttl_seconds <= 0:
|
||||
return None
|
||||
cache_key = (id(settings), ttl_seconds)
|
||||
cache = _ADMIN_PANEL_STATS_CACHES.get(cache_key)
|
||||
if cache is None:
|
||||
cache = AsyncTTLCache(
|
||||
ttl_seconds=ttl_seconds,
|
||||
settings=settings,
|
||||
namespace="admin:panel_stats",
|
||||
)
|
||||
_ADMIN_PANEL_STATS_CACHES[cache_key] = cache
|
||||
return cache
|
||||
|
||||
|
||||
async def _load_admin_panel_stats_uncached(panel_service) -> Dict[str, Any]:
|
||||
try:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
start_d = today - timedelta(days=7)
|
||||
system, bandwidth, nodes, nodes_bw, lookups = await asyncio.gather(
|
||||
_safe_panel_call(panel_service.get_system_stats(), "system stats"),
|
||||
_safe_panel_call(panel_service.get_bandwidth_stats(), "bandwidth stats"),
|
||||
_safe_panel_call(panel_service.get_nodes_statistics(), "nodes stats"),
|
||||
_safe_panel_call(
|
||||
panel_service.get_nodes_bandwidth_usage(
|
||||
start=start_d.isoformat(),
|
||||
end=today.isoformat(),
|
||||
top_nodes_limit=64,
|
||||
),
|
||||
"nodes bandwidth range",
|
||||
),
|
||||
_safe_panel_call(panel_service.get_nodes_online_lookups(), "nodes online lookups"),
|
||||
)
|
||||
|
||||
panel_body: Dict[str, Any] = {
|
||||
"system": system or {},
|
||||
"bandwidth": bandwidth or {},
|
||||
"nodes": nodes or {},
|
||||
"nodes_bandwidth": nodes_bw or {},
|
||||
}
|
||||
if isinstance(lookups, dict):
|
||||
try:
|
||||
online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes"))
|
||||
for k, v in lookups.get("byUuid", {}).items():
|
||||
online_map[k] = v
|
||||
_enrich_bandwidth_nodes_with_online(
|
||||
panel_body.get("nodes_bandwidth"),
|
||||
online_map,
|
||||
lookups.get("byName") or {},
|
||||
)
|
||||
except Exception as exc_merge: # pragma: no cover
|
||||
logger.debug("Panel nodes online merge skipped: %s", exc_merge)
|
||||
return panel_body
|
||||
except Exception as exc:
|
||||
logger.debug("Panel stats unavailable: %s", exc)
|
||||
return {"error": "unavailable"}
|
||||
|
||||
|
||||
async def _safe_panel_call(awaitable, label: str) -> Any:
|
||||
try:
|
||||
return await awaitable
|
||||
except Exception as exc: # pragma: no cover - optional panel endpoints
|
||||
logger.debug("Panel %s unavailable: %s", label, exc)
|
||||
return None
|
||||
@@ -0,0 +1,251 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, constr, field_validator
|
||||
|
||||
from bot.services.support_service import TicketNotFound
|
||||
from db.dal import support_dal, user_dal
|
||||
from db.models import SupportTicket, SupportTicketMessage
|
||||
|
||||
|
||||
class AdminTicketReplyPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
body: constr(min_length=1, max_length=4000)
|
||||
is_internal_note: bool = False
|
||||
|
||||
@field_validator("body")
|
||||
@classmethod
|
||||
def _strip_body(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("empty_text")
|
||||
return stripped
|
||||
|
||||
|
||||
class AdminTicketPatchPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
status: Optional[Literal["open", "awaiting_user", "awaiting_admin", "resolved", "closed"]] = (
|
||||
None
|
||||
)
|
||||
priority: Optional[Literal["low", "normal", "high", "urgent"]] = None
|
||||
category: Optional[Literal["billing", "technical", "account", "other"]] = None
|
||||
assigned_admin_id: Optional[int] = None
|
||||
|
||||
|
||||
def _validate_model_payload(model_cls, payload: Dict[str, Any]):
|
||||
try:
|
||||
return model_cls.model_validate(payload), None
|
||||
except ValidationError:
|
||||
return None, _error(400, "invalid_request", "Invalid request")
|
||||
|
||||
|
||||
def _support_ticket_payload(ticket: SupportTicket) -> Dict[str, Any]:
|
||||
return {
|
||||
"ticket_id": ticket.ticket_id,
|
||||
"user_id": ticket.user_id,
|
||||
"subject": ticket.subject,
|
||||
"category": ticket.category,
|
||||
"priority": ticket.priority,
|
||||
"status": ticket.status,
|
||||
"assigned_admin_id": ticket.assigned_admin_id,
|
||||
"last_message_at": ticket.last_message_at.isoformat() if ticket.last_message_at else None,
|
||||
"last_message_role": ticket.last_message_role,
|
||||
"unread_user_count": int(ticket.unread_user_count or 0),
|
||||
"unread_admin_count": int(ticket.unread_admin_count or 0),
|
||||
"created_at": ticket.created_at.isoformat() if ticket.created_at else None,
|
||||
"updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
|
||||
"closed_at": ticket.closed_at.isoformat() if ticket.closed_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _user_display_name(user) -> Optional[str]:
|
||||
if not user:
|
||||
return None
|
||||
name = " ".join(
|
||||
part.strip() for part in [user.first_name, user.last_name] if part and part.strip()
|
||||
).strip()
|
||||
return name or user.username or user.email or str(user.user_id)
|
||||
|
||||
|
||||
def _support_message_payload(
|
||||
message: SupportTicketMessage,
|
||||
*,
|
||||
authors: Optional[Dict[int, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
author = authors.get(message.author_user_id) if authors and message.author_user_id else None
|
||||
return {
|
||||
"message_id": message.message_id,
|
||||
"ticket_id": message.ticket_id,
|
||||
"author_role": message.author_role,
|
||||
"author_user_id": message.author_user_id,
|
||||
"author_name": _user_display_name(author),
|
||||
"body": message.body,
|
||||
"is_internal_note": bool(message.is_internal_note),
|
||||
"created_at": message.created_at.isoformat() if message.created_at else None,
|
||||
"read_by_user_at": message.read_by_user_at.isoformat() if message.read_by_user_at else None,
|
||||
"read_by_admin_at": message.read_by_admin_at.isoformat()
|
||||
if message.read_by_admin_at
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
def _admin_support_user_payload(user) -> Dict[str, Any]:
|
||||
if not user:
|
||||
return {}
|
||||
return {
|
||||
"user_id": user.user_id,
|
||||
"telegram_id": user.telegram_id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"email": user.email,
|
||||
"telegram_photo_url": user.telegram_photo_url,
|
||||
"is_banned": bool(user.is_banned),
|
||||
"registration_date": user.registration_date.isoformat() if user.registration_date else None,
|
||||
}
|
||||
|
||||
|
||||
def _support_limit_offset(request: web.Request) -> tuple[int, int]:
|
||||
limit = max(1, min(100, int(request.query.get("limit", 25) or 25)))
|
||||
offset = max(0, int(request.query.get("offset", 0) or 0))
|
||||
return limit, offset
|
||||
|
||||
|
||||
async def admin_support_tickets_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
limit, offset = _support_limit_offset(request)
|
||||
assigned_raw = request.query.get("assigned")
|
||||
assigned_admin_id = None
|
||||
if assigned_raw and assigned_raw not in {"all", "any"}:
|
||||
assigned_admin_id = int(assigned_raw)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
tickets = await support_dal.list_admin_tickets(
|
||||
session,
|
||||
status=request.query.get("status") or None,
|
||||
priority=request.query.get("priority") or None,
|
||||
category=request.query.get("category") or None,
|
||||
assigned_admin_id=assigned_admin_id,
|
||||
search=request.query.get("search") or None,
|
||||
sort=request.query.get("sort") or "updated_desc",
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tickets": [
|
||||
{
|
||||
**_support_ticket_payload(ticket),
|
||||
"user": _admin_support_user_payload(getattr(ticket, "user", None)),
|
||||
}
|
||||
for ticket in tickets
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_support_ticket_detail_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
service = request.app["support_service"]
|
||||
async with async_session_factory() as session:
|
||||
ticket, messages = await support_dal.get_ticket(session, ticket_id, include_internal=True)
|
||||
if not ticket:
|
||||
return _error(404, "not_found", "Ticket not found")
|
||||
user = await user_dal.get_user_by_id(session, ticket.user_id)
|
||||
snapshot = await service.build_user_snapshot(user, session=session) if user else {}
|
||||
author_ids = {m.author_user_id for m in messages if m.author_user_id is not None}
|
||||
authors = {}
|
||||
for author_id in author_ids:
|
||||
author = await user_dal.get_user_by_id(session, author_id)
|
||||
if author:
|
||||
authors[author_id] = author
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"ticket": {
|
||||
**_support_ticket_payload(ticket),
|
||||
"user": _admin_support_user_payload(user),
|
||||
},
|
||||
"messages": [_support_message_payload(m, authors=authors) for m in messages],
|
||||
"user_snapshot": snapshot,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_support_ticket_reply_route(request: web.Request) -> web.Response:
|
||||
admin_id = _require_admin_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
payload, error = _validate_model_payload(AdminTicketReplyPayload, await _read_json(request))
|
||||
if error:
|
||||
return error
|
||||
try:
|
||||
ticket, message = await request.app["support_service"].reply_as_admin(
|
||||
admin_id,
|
||||
ticket_id,
|
||||
payload.body,
|
||||
is_internal_note=payload.is_internal_note,
|
||||
)
|
||||
except TicketNotFound:
|
||||
return _error(404, "not_found", "Ticket not found")
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
admin = await user_dal.get_user_by_id(session, admin_id)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"ticket": _support_ticket_payload(ticket),
|
||||
"message": _support_message_payload(
|
||||
message, authors={admin_id: admin} if admin else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_support_ticket_patch_route(request: web.Request) -> web.Response:
|
||||
admin_id = _require_admin_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
payload, error = _validate_model_payload(AdminTicketPatchPayload, await _read_json(request))
|
||||
if error:
|
||||
return error
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
try:
|
||||
if updates.get("status") == "closed":
|
||||
ticket = await request.app["support_service"].close_ticket(admin_id, ticket_id)
|
||||
updates.pop("status", None)
|
||||
if updates:
|
||||
ticket = await request.app["support_service"]._update_and_audit(
|
||||
admin_id,
|
||||
ticket_id,
|
||||
**updates,
|
||||
)
|
||||
else:
|
||||
ticket = await request.app["support_service"]._update_and_audit(
|
||||
admin_id,
|
||||
ticket_id,
|
||||
**updates,
|
||||
)
|
||||
except TicketNotFound:
|
||||
return _error(404, "not_found", "Ticket not found")
|
||||
return web.json_response({"ok": True, "ticket": _support_ticket_payload(ticket)})
|
||||
|
||||
|
||||
async def admin_support_ticket_read_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
ticket_id = int(request.match_info["id"])
|
||||
await request.app["support_service"].mark_read_as_admin(ticket_id)
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
|
||||
async def admin_support_stats_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
stats = await support_dal.admin_stats(session)
|
||||
return web.json_response({"ok": True, "stats": stats})
|
||||
@@ -0,0 +1,16 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
async def admin_sync_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
queued = await enqueue_webhook_event(
|
||||
settings,
|
||||
"panel_sync",
|
||||
{"requested_by": _require_admin_user_id(request)},
|
||||
event_id=None,
|
||||
)
|
||||
if queued:
|
||||
return _ok({"result": {"status": "queued"}})
|
||||
return _error(503, "queue_unavailable")
|
||||
@@ -1,5 +1,6 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
|
||||
|
||||
|
||||
async def admin_tariffs_get_route(request: web.Request) -> web.Response:
|
||||
@@ -55,9 +56,6 @@ async def admin_tariffs_save_route(request: web.Request) -> web.Response:
|
||||
logger.exception("Failed to write tariffs config to %s", path)
|
||||
return _error(500, "write_failed", str(exc))
|
||||
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
|
||||
|
||||
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
|
||||
@@ -0,0 +1,489 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import shutil
|
||||
import re
|
||||
import socket
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
|
||||
from config.webapp_themes_config import (
|
||||
WebappThemesConfig,
|
||||
ensure_webapp_core_themes,
|
||||
resolved_webapp_themes_catalog,
|
||||
write_webapp_theme_dir,
|
||||
)
|
||||
|
||||
|
||||
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
||||
WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "uploads"
|
||||
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
||||
WEBAPP_FAVICON_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "favicons"
|
||||
WEBAPP_FAVICON_PATH = "/webapp-favicon"
|
||||
WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-emoji"
|
||||
WEBAPP_FAVICON_SIZES = (16, 32, 48, 180, 192, 512)
|
||||
WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = {
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
|
||||
|
||||
def _theme_payload_for_version_compare(theme: Any) -> Dict[str, Any]:
|
||||
if hasattr(theme, "model_dump"):
|
||||
data = theme.model_dump(mode="json", exclude_none=True)
|
||||
elif isinstance(theme, dict):
|
||||
data = dict(theme)
|
||||
else:
|
||||
data = {}
|
||||
data.pop("assets_version", None)
|
||||
data.pop("default", None)
|
||||
return data
|
||||
|
||||
|
||||
def _bump_theme_asset_versions(
|
||||
config: WebappThemesConfig,
|
||||
previous: WebappThemesConfig,
|
||||
) -> WebappThemesConfig:
|
||||
previous_by_key = {theme.key: theme for theme in previous.themes}
|
||||
default_changed = config.default_theme != previous.default_theme
|
||||
data = config.model_dump(mode="json", exclude_none=True)
|
||||
for theme in data.get("themes", []):
|
||||
if not isinstance(theme, dict):
|
||||
continue
|
||||
if not str(theme.get("css_file") or "").strip():
|
||||
continue
|
||||
key = str(theme.get("key") or "")
|
||||
previous_theme = previous_by_key.get(key)
|
||||
previous_version = int(getattr(previous_theme, "assets_version", 0) or 0)
|
||||
current_version = int(theme.get("assets_version") or 1)
|
||||
theme_changed = previous_theme is None or _theme_payload_for_version_compare(
|
||||
theme
|
||||
) != _theme_payload_for_version_compare(previous_theme)
|
||||
if theme_changed or (default_changed and key == config.default_theme):
|
||||
theme["assets_version"] = max(previous_version + 1, current_version, 1)
|
||||
elif previous_version > current_version:
|
||||
theme["assets_version"] = previous_version
|
||||
return WebappThemesConfig.model_validate(data)
|
||||
|
||||
|
||||
def _detect_logo_extension(
|
||||
body: bytes, content_type: str = "", filename: str = ""
|
||||
) -> Optional[str]:
|
||||
content_type = (content_type or "").split(";", 1)[0].strip().lower()
|
||||
suffix = Path(filename or "").suffix.lower()
|
||||
if content_type == "image/png" or body.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return ".png"
|
||||
if content_type == "image/jpeg" or body.startswith(b"\xff\xd8\xff"):
|
||||
return ".jpg"
|
||||
if content_type == "image/gif" or body.startswith((b"GIF87a", b"GIF89a")):
|
||||
return ".gif"
|
||||
if content_type == "image/webp" or (
|
||||
len(body) > 12 and body[:4] == b"RIFF" and body[8:12] == b"WEBP"
|
||||
):
|
||||
return ".webp"
|
||||
if content_type in {"image/svg+xml", "image/svg"} or suffix == ".svg":
|
||||
head = body[:512].lstrip().lower()
|
||||
if head.startswith(b"<svg") or b"<svg" in head:
|
||||
return ".svg"
|
||||
if content_type == "image/x-icon" or suffix == ".ico":
|
||||
if body.startswith(b"\x00\x00\x01\x00"):
|
||||
return ".ico"
|
||||
return suffix if suffix in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES else None
|
||||
|
||||
|
||||
def _write_uploaded_logo(body: bytes, content_type: str = "", filename: str = "") -> str:
|
||||
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
raise ValueError("logo must be a non-empty image up to 2 MiB")
|
||||
ext = _detect_logo_extension(body, content_type, filename)
|
||||
if ext not in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES:
|
||||
raise ValueError("unsupported image type")
|
||||
digest = hashlib.sha256(body).hexdigest()[:16]
|
||||
safe_name = f"logo-{digest}{ext}"
|
||||
WEBAPP_UPLOADED_LOGO_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(WEBAPP_UPLOADED_LOGO_DIR / safe_name).write_bytes(body)
|
||||
return f"{WEBAPP_UPLOADED_LOGO_PATH}/{safe_name}"
|
||||
|
||||
|
||||
def _uploaded_logo_filename(url: str) -> Optional[str]:
|
||||
parsed = urlsplit(str(url or ""))
|
||||
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
|
||||
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 _favicon_digest(url: str) -> Optional[str]:
|
||||
parsed = urlsplit(str(url or ""))
|
||||
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
|
||||
match = re.fullmatch(
|
||||
rf"{re.escape(WEBAPP_FAVICON_PATH)}/([0-9a-f]{{16}})/(?:[A-Za-z0-9_.-]+)",
|
||||
path,
|
||||
)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _emoji_to_codepoints(value: str) -> str:
|
||||
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
|
||||
|
||||
|
||||
def prune_unused_appearance_assets(settings: Settings) -> None:
|
||||
keep_logos = {
|
||||
filename
|
||||
for filename in [
|
||||
_uploaded_logo_filename(getattr(settings, "WEBAPP_LOGO_URL", "")),
|
||||
]
|
||||
if filename
|
||||
}
|
||||
keep_favicons = {
|
||||
digest
|
||||
for digest in [
|
||||
_favicon_digest(getattr(settings, "WEBAPP_FAVICON_URL", "")),
|
||||
_favicon_digest(getattr(settings, "WEBAPP_LOGO_FAVICON_URL", "")),
|
||||
]
|
||||
if digest
|
||||
}
|
||||
keep_emoji_prefixes = set()
|
||||
if (
|
||||
getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False)
|
||||
and str(getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") or "").strip()
|
||||
== "noto-color-animated"
|
||||
):
|
||||
codepoints = _emoji_to_codepoints(getattr(settings, "WEBAPP_LOGO_EMOJI", ""))
|
||||
if codepoints:
|
||||
keep_emoji_prefixes.add(f"{codepoints}.512.")
|
||||
|
||||
for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"):
|
||||
if path.is_file() and path.name not in keep_logos:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp logo %s", path, exc_info=True)
|
||||
|
||||
for path in WEBAPP_FAVICON_DIR.glob("*"):
|
||||
if (
|
||||
path.is_dir()
|
||||
and re.fullmatch(r"[0-9a-f]{16}", path.name)
|
||||
and path.name not in keep_favicons
|
||||
):
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
|
||||
|
||||
for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"):
|
||||
if path.is_file() and not any(
|
||||
path.name.startswith(prefix) for prefix in keep_emoji_prefixes
|
||||
):
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp emoji asset %s", path, exc_info=True)
|
||||
|
||||
|
||||
async def _persist_appearance_upload(
|
||||
request: web.Request,
|
||||
updates: Dict[str, Any],
|
||||
actor_id: int,
|
||||
) -> bool:
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
result = await update_overrides(
|
||||
settings,
|
||||
async_session_factory,
|
||||
updates=updates,
|
||||
deletes=[],
|
||||
actor_id=actor_id,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
logger.warning("Failed to persist uploaded appearance asset settings: %s", result)
|
||||
return False
|
||||
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=[])
|
||||
return True
|
||||
|
||||
|
||||
def _image_to_square_icon(source: Image.Image, size: int) -> Image.Image:
|
||||
fitted = source.copy()
|
||||
fitted.thumbnail((size, size), Image.Resampling.LANCZOS)
|
||||
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
left = (size - fitted.width) // 2
|
||||
top = (size - fitted.height) // 2
|
||||
canvas.alpha_composite(fitted, (left, top))
|
||||
return canvas
|
||||
|
||||
|
||||
def _write_favicon_set(body: bytes, content_type: str = "", filename: str = "") -> Dict[str, Any]:
|
||||
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
raise ValueError("favicon source must be a non-empty image up to 2 MiB")
|
||||
|
||||
ext = _detect_logo_extension(body, content_type, filename)
|
||||
digest = hashlib.sha256(body).hexdigest()[:16]
|
||||
target_dir = WEBAPP_FAVICON_DIR / digest
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if ext == ".svg":
|
||||
safe_name = "favicon.svg"
|
||||
(target_dir / safe_name).write_bytes(body)
|
||||
return {
|
||||
"favicon_url": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}",
|
||||
"variants": {"svg": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}"},
|
||||
}
|
||||
|
||||
try:
|
||||
with Image.open(io.BytesIO(body)) as image:
|
||||
image.seek(0)
|
||||
source = ImageOps.exif_transpose(image).convert("RGBA")
|
||||
except (OSError, UnidentifiedImageError, ValueError) as exc:
|
||||
raise ValueError("favicon source must be a raster image") from exc
|
||||
|
||||
if source.width < 1 or source.height < 1 or source.width > 8192 or source.height > 8192:
|
||||
raise ValueError("favicon source dimensions are not supported")
|
||||
|
||||
variants: Dict[str, str] = {}
|
||||
png_icons: Dict[int, Image.Image] = {}
|
||||
for size in WEBAPP_FAVICON_SIZES:
|
||||
icon = _image_to_square_icon(source, size)
|
||||
png_icons[size] = icon
|
||||
filename = f"icon-{size}.png"
|
||||
icon.save(target_dir / filename, format="PNG", optimize=True)
|
||||
variants[f"{size}"] = f"{WEBAPP_FAVICON_PATH}/{digest}/{filename}"
|
||||
|
||||
png_icons[180].save(target_dir / "apple-touch-icon.png", format="PNG", optimize=True)
|
||||
variants["apple_touch"] = f"{WEBAPP_FAVICON_PATH}/{digest}/apple-touch-icon.png"
|
||||
png_icons[32].save(
|
||||
target_dir / "favicon.ico",
|
||||
format="ICO",
|
||||
sizes=[(16, 16), (32, 32), (48, 48)],
|
||||
)
|
||||
variants["ico"] = f"{WEBAPP_FAVICON_PATH}/{digest}/favicon.ico"
|
||||
return {
|
||||
"favicon_url": variants["180"],
|
||||
"variants": variants,
|
||||
}
|
||||
|
||||
|
||||
async def _read_uploaded_logo_file(request: web.Request) -> tuple[bytes, str, str]:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name != "file":
|
||||
continue
|
||||
body = bytearray()
|
||||
while True:
|
||||
chunk = await part.read_chunk(size=64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
body.extend(chunk)
|
||||
if len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
raise ValueError("logo must be up to 2 MiB")
|
||||
return bytes(body), part.headers.get("Content-Type", ""), part.filename or ""
|
||||
raise ValueError("file field is required")
|
||||
|
||||
|
||||
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]
|
||||
candidate = sockaddr[0] if sockaddr else ""
|
||||
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
|
||||
|
||||
|
||||
async def _fetch_logo_from_url(url: str) -> tuple[bytes, str, str]:
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme != "https" or not parsed.hostname:
|
||||
raise ValueError("only https image URLs are supported")
|
||||
if not await _hostname_resolves_to_public_address(parsed.hostname):
|
||||
raise ValueError("logo URL must resolve to a public address")
|
||||
|
||||
timeout = ClientTimeout(total=5)
|
||||
async with ClientSession(timeout=timeout, headers={"User-Agent": "Mozilla/5.0"}) as session:
|
||||
async with session.get(
|
||||
url,
|
||||
allow_redirects=False,
|
||||
headers={"Accept": "image/avif,image/webp,image/svg+xml,image/png,image/*,*/*;q=0.8"},
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
raise ValueError(f"logo URL returned HTTP {response.status}")
|
||||
content_type = (
|
||||
(response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
|
||||
)
|
||||
if content_type and not content_type.startswith("image/"):
|
||||
raise ValueError("logo URL returned non-image content")
|
||||
body = bytearray()
|
||||
async for chunk in response.content.iter_chunked(64 * 1024):
|
||||
body.extend(chunk)
|
||||
if len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
raise ValueError("logo must be up to 2 MiB")
|
||||
return bytes(body), content_type, Path(parsed.path).name
|
||||
|
||||
|
||||
async def admin_appearance_logo_upload_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
content_type = (request.headers.get("Content-Type") or "").lower()
|
||||
try:
|
||||
if content_type.startswith("multipart/form-data"):
|
||||
body, detected_content_type, filename = await _read_uploaded_logo_file(request)
|
||||
else:
|
||||
payload = await _read_json(request)
|
||||
source_url = str(payload.get("url") or "").strip()
|
||||
if not source_url:
|
||||
return _error(400, "invalid_payload", "url or file is required")
|
||||
body, detected_content_type, filename = await _fetch_logo_from_url(source_url)
|
||||
logo_url = _write_uploaded_logo(body, detected_content_type, filename)
|
||||
try:
|
||||
favicon_payload = _write_favicon_set(body, detected_content_type, filename)
|
||||
except ValueError:
|
||||
favicon_payload = {}
|
||||
except ValueError as exc:
|
||||
return _error(400, "invalid_logo", str(exc))
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to save uploaded webapp logo")
|
||||
return _error(500, "write_failed", str(exc))
|
||||
persisted = await _persist_appearance_upload(
|
||||
request,
|
||||
{
|
||||
"WEBAPP_LOGO_URL": logo_url,
|
||||
"WEBAPP_LOGO_USE_EMOJI": False,
|
||||
**(
|
||||
{"WEBAPP_LOGO_FAVICON_URL": favicon_payload["favicon_url"]}
|
||||
if favicon_payload.get("favicon_url")
|
||||
else {}
|
||||
),
|
||||
},
|
||||
actor_id,
|
||||
)
|
||||
|
||||
return _ok({"logo_url": logo_url, "persisted": persisted, **favicon_payload})
|
||||
|
||||
|
||||
async def admin_appearance_favicon_upload_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
content_type = (request.headers.get("Content-Type") or "").lower()
|
||||
try:
|
||||
if content_type.startswith("multipart/form-data"):
|
||||
body, detected_content_type, filename = await _read_uploaded_logo_file(request)
|
||||
else:
|
||||
payload = await _read_json(request)
|
||||
source_url = str(payload.get("url") or "").strip()
|
||||
if not source_url:
|
||||
return _error(400, "invalid_payload", "url or file is required")
|
||||
body, detected_content_type, filename = await _fetch_logo_from_url(source_url)
|
||||
favicon_payload = _write_favicon_set(body, detected_content_type, filename)
|
||||
except ValueError as exc:
|
||||
return _error(400, "invalid_favicon", str(exc))
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to save uploaded webapp favicon")
|
||||
return _error(500, "write_failed", str(exc))
|
||||
persisted = await _persist_appearance_upload(
|
||||
request,
|
||||
{
|
||||
"WEBAPP_FAVICON_URL": favicon_payload["favicon_url"],
|
||||
"WEBAPP_FAVICON_USE_CUSTOM": True,
|
||||
},
|
||||
actor_id,
|
||||
)
|
||||
|
||||
return _ok({"persisted": persisted, **favicon_payload})
|
||||
|
||||
|
||||
async def admin_themes_get_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
primary = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a"
|
||||
catalog = resolved_webapp_themes_catalog(
|
||||
primary_accent=primary,
|
||||
env_default_theme=settings.WEBAPP_DEFAULT_THEME,
|
||||
theme_dir=settings.WEBAPP_THEMES_DIR,
|
||||
)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"exists": Path(settings.WEBAPP_THEMES_DIR).expanduser().exists(),
|
||||
"themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()),
|
||||
"catalog": _webapp_themes_catalog_payload(catalog),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_themes_save_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
previous_config = resolved_webapp_themes_catalog(
|
||||
primary_accent=settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
|
||||
env_default_theme=settings.WEBAPP_DEFAULT_THEME,
|
||||
theme_dir=settings.WEBAPP_THEMES_DIR,
|
||||
)
|
||||
payload = await _read_json(request)
|
||||
catalog = payload.get("catalog") if "catalog" in payload else payload
|
||||
if not isinstance(catalog, dict):
|
||||
return _error(400, "invalid_payload", "catalog must be an object")
|
||||
|
||||
try:
|
||||
config = WebappThemesConfig.model_validate(catalog)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
return _error(400, "invalid_webapp_themes_config", str(exc))
|
||||
|
||||
config, _changed = ensure_webapp_core_themes(config, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a")
|
||||
config = _bump_theme_asset_versions(config, previous_config)
|
||||
|
||||
try:
|
||||
write_webapp_theme_dir(settings.WEBAPP_THEMES_DIR, config, delete_missing=True)
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR)
|
||||
return _error(500, "write_failed", str(exc))
|
||||
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"exists": True,
|
||||
"themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()),
|
||||
"catalog": _webapp_themes_catalog_payload(config),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n, locale_language_options, resolve_locale_key
|
||||
from bot.services.locale_override_service import (
|
||||
LOCALE_OVERRIDES_PATH,
|
||||
audience_for_locale_key,
|
||||
group_id_for_locale_key,
|
||||
locale_group_catalog,
|
||||
load_locale_overrides,
|
||||
update_locale_overrides,
|
||||
)
|
||||
|
||||
def _locale_languages(
|
||||
i18n: JsonI18n,
|
||||
overrides: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
base_languages = set((i18n.base_locales_data or {}).keys())
|
||||
override_languages = {str(entry.get("lang") or "") for entry in overrides or []}
|
||||
override_languages.update((i18n.locale_overrides or {}).keys())
|
||||
return locale_language_options(
|
||||
base_languages | override_languages,
|
||||
base_languages=base_languages,
|
||||
)
|
||||
|
||||
|
||||
def _locale_override_meta_map(overrides: List[Dict[str, Any]]) -> Dict[Tuple[str, str], Dict]:
|
||||
result: Dict[Tuple[str, str], Dict] = {}
|
||||
for entry in overrides:
|
||||
lang = str(entry.get("lang") or "")
|
||||
raw_key = str(entry.get("key") or "")
|
||||
key = resolve_locale_key(raw_key)
|
||||
if lang and key:
|
||||
if raw_key != key and (lang, key) in result:
|
||||
continue
|
||||
result[(lang, key)] = entry
|
||||
return result
|
||||
|
||||
|
||||
def _admin_translations_payload(
|
||||
i18n: JsonI18n,
|
||||
overrides: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
base_data = i18n.base_locales_data or i18n.locales_data or {}
|
||||
effective_data = i18n.locales_data or {}
|
||||
override_meta = _locale_override_meta_map(overrides)
|
||||
language_items = _locale_languages(i18n, overrides)
|
||||
languages = [item["code"] for item in language_items]
|
||||
all_keys = sorted(
|
||||
{key for messages in base_data.values() for key in messages.keys()}
|
||||
| {key for _, key in override_meta.keys()}
|
||||
)
|
||||
|
||||
groups_by_id = {
|
||||
group["id"]: {
|
||||
**group,
|
||||
"items": [],
|
||||
}
|
||||
for group in locale_group_catalog()
|
||||
}
|
||||
|
||||
for key in all_keys:
|
||||
values: Dict[str, Dict[str, Any]] = {}
|
||||
for lang in languages:
|
||||
meta = override_meta.get((lang, key))
|
||||
fallback_base = base_data.get(i18n.default_lang, {}).get(key, "")
|
||||
values[lang] = {
|
||||
"base": base_data.get(lang, {}).get(key, ""),
|
||||
"fallback": fallback_base,
|
||||
"effective": effective_data.get(lang, {}).get(key, ""),
|
||||
"override": meta.get("value") if meta else "",
|
||||
"overridden": bool(meta),
|
||||
"updated_at": meta.get("updated_at") if meta else None,
|
||||
"updated_by": meta.get("updated_by") if meta else None,
|
||||
}
|
||||
group_id = group_id_for_locale_key(key)
|
||||
groups_by_id.setdefault(
|
||||
group_id,
|
||||
{"id": group_id, "title": group_id, "description": "", "items": []},
|
||||
)
|
||||
groups_by_id[group_id]["items"].append(
|
||||
{
|
||||
"key": key,
|
||||
"audience": audience_for_locale_key(key),
|
||||
"values": values,
|
||||
}
|
||||
)
|
||||
|
||||
groups = [group for group in groups_by_id.values() if group["items"]]
|
||||
return {
|
||||
"languages": language_items,
|
||||
"groups": groups,
|
||||
"path": str(LOCALE_OVERRIDES_PATH),
|
||||
"override_count": len(overrides),
|
||||
}
|
||||
|
||||
|
||||
async def admin_translations_get_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
i18n: Optional[JsonI18n] = request.app.get("i18n")
|
||||
if i18n is None:
|
||||
return _error(503, "i18n_unavailable")
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
await load_locale_overrides(i18n, async_session_factory)
|
||||
async with async_session_factory() as session:
|
||||
overrides = await locale_overrides_dal.get_overrides_with_meta(session)
|
||||
|
||||
return _ok(_admin_translations_payload(i18n, overrides))
|
||||
|
||||
|
||||
async def admin_translations_patch_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
i18n: Optional[JsonI18n] = request.app.get("i18n")
|
||||
if i18n is None:
|
||||
return _error(503, "i18n_unavailable")
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
payload = await _read_json(request)
|
||||
updates = payload.get("updates") or {}
|
||||
deletes = payload.get("deletes") or []
|
||||
if not isinstance(updates, dict):
|
||||
return _error(400, "invalid_updates")
|
||||
if not isinstance(deletes, list):
|
||||
return _error(400, "invalid_deletes")
|
||||
|
||||
result = await update_locale_overrides(
|
||||
i18n,
|
||||
async_session_factory,
|
||||
updates=updates,
|
||||
deletes=deletes,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "validation_failed", "errors": result.get("errors", {})},
|
||||
status=400,
|
||||
)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"applied": result.get("applied", 0),
|
||||
"reverted": result.get("reverted", 0),
|
||||
"file_written": result.get("file_written", False),
|
||||
}
|
||||
)
|
||||
@@ -1,9 +1,32 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .auth import _require_admin_user_id
|
||||
from .common import (
|
||||
_build_admin_webapp_referral_link,
|
||||
_error,
|
||||
_ok,
|
||||
_premium_traffic_list_payload,
|
||||
_read_json,
|
||||
_serialize_payment,
|
||||
_serialize_subscription,
|
||||
_serialize_user,
|
||||
)
|
||||
|
||||
import hashlib
|
||||
from html import escape as html_escape
|
||||
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
||||
from bot.infra.redis import cache_delete_pattern, redis_key
|
||||
from bot.utils.ttl_cache import AsyncTTLCache
|
||||
|
||||
_ADMIN_USERS_LIST_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
|
||||
|
||||
|
||||
async def admin_users_list_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
page = max(0, int(request.query.get("page", 0) or 0))
|
||||
@@ -14,6 +37,79 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
|
||||
premium_traffic = (request.query.get("premium_traffic") or "all").lower()
|
||||
sort_value = (request.query.get("sort") or "registered_desc").lower()
|
||||
|
||||
payload = await _load_admin_users_list_payload(
|
||||
settings,
|
||||
async_session_factory,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
query=query,
|
||||
filter_value=filter_value,
|
||||
panel_status=panel_status,
|
||||
premium_traffic=premium_traffic,
|
||||
sort_value=sort_value,
|
||||
)
|
||||
return _ok(payload)
|
||||
|
||||
|
||||
async def _load_admin_users_list_payload(
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
query: str,
|
||||
filter_value: str,
|
||||
panel_status: str,
|
||||
premium_traffic: str,
|
||||
sort_value: str,
|
||||
) -> Dict[str, Any]:
|
||||
cache = _admin_users_list_cache(settings)
|
||||
cache_key = _admin_users_list_cache_key(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
query=query,
|
||||
filter_value=filter_value,
|
||||
panel_status=panel_status,
|
||||
premium_traffic=premium_traffic,
|
||||
sort_value=sort_value,
|
||||
)
|
||||
if cache is None:
|
||||
return await _load_admin_users_list_payload_uncached(
|
||||
async_session_factory,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
query=query,
|
||||
filter_value=filter_value,
|
||||
panel_status=panel_status,
|
||||
premium_traffic=premium_traffic,
|
||||
sort_value=sort_value,
|
||||
)
|
||||
return await cache.get_or_load(
|
||||
cache_key,
|
||||
lambda: _load_admin_users_list_payload_uncached(
|
||||
async_session_factory,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
query=query,
|
||||
filter_value=filter_value,
|
||||
panel_status=panel_status,
|
||||
premium_traffic=premium_traffic,
|
||||
sort_value=sort_value,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _load_admin_users_list_payload_uncached(
|
||||
async_session_factory: sessionmaker,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
query: str,
|
||||
filter_value: str,
|
||||
panel_status: str,
|
||||
premium_traffic: str,
|
||||
sort_value: str,
|
||||
) -> Dict[str, Any]:
|
||||
async with async_session_factory() as session:
|
||||
users, total = await _filter_and_sort_users(
|
||||
session,
|
||||
@@ -47,14 +143,58 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
|
||||
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
|
||||
serialized.append(payload)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"users": serialized,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"users": serialized,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
def _admin_users_list_cache(settings: Settings) -> Optional[AsyncTTLCache]:
|
||||
ttl_seconds = int(getattr(settings, "ADMIN_USERS_LIST_CACHE_TTL_SECONDS", 3) or 0)
|
||||
if ttl_seconds <= 0:
|
||||
return None
|
||||
cache_key = (id(settings), ttl_seconds)
|
||||
cache = _ADMIN_USERS_LIST_CACHES.get(cache_key)
|
||||
if cache is None:
|
||||
cache = AsyncTTLCache(
|
||||
ttl_seconds=ttl_seconds,
|
||||
settings=settings,
|
||||
namespace="admin:users_list",
|
||||
)
|
||||
_ADMIN_USERS_LIST_CACHES[cache_key] = cache
|
||||
return cache
|
||||
|
||||
|
||||
def _admin_users_list_cache_key(**params: Any) -> str:
|
||||
raw = json.dumps(params, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _invalidate_admin_users_list_cache(settings: Settings) -> None:
|
||||
for settings_id, _ttl in tuple(_ADMIN_USERS_LIST_CACHES):
|
||||
if settings_id == id(settings):
|
||||
_ADMIN_USERS_LIST_CACHES[(settings_id, _ttl)].invalidate()
|
||||
try:
|
||||
await cache_delete_pattern(settings, redis_key(settings, "cache", "admin:users_list", "*"))
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
async def _invalidate_after_admin_user_mutation(
|
||||
settings: Settings,
|
||||
user_id: Optional[int] = None,
|
||||
*,
|
||||
include_devices: bool = True,
|
||||
) -> None:
|
||||
await _invalidate_admin_users_list_cache(settings)
|
||||
if user_id is not None:
|
||||
await invalidate_webapp_user_caches(
|
||||
settings,
|
||||
user_id,
|
||||
include_devices=include_devices,
|
||||
)
|
||||
|
||||
|
||||
async def _bulk_user_statuses(
|
||||
@@ -526,6 +666,7 @@ async def admin_user_ban_route(request: web.Request) -> web.Response:
|
||||
payload = await _read_json(request)
|
||||
desired = bool(payload.get("banned"))
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, target_id)
|
||||
@@ -534,6 +675,7 @@ async def admin_user_ban_route(request: web.Request) -> web.Response:
|
||||
user.is_banned = bool(desired)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||
return _ok({"user": _serialize_user(user)})
|
||||
|
||||
|
||||
@@ -624,32 +766,180 @@ async def admin_user_message_preview_route(request: web.Request) -> web.Response
|
||||
return _ok({})
|
||||
|
||||
|
||||
async def admin_user_delete_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
def _admin_user_display_name_for_message(user: User) -> str:
|
||||
full = " ".join(
|
||||
part
|
||||
for part in [getattr(user, "first_name", None), getattr(user, "last_name", None)]
|
||||
if part
|
||||
).strip()
|
||||
return (
|
||||
full
|
||||
or (f"@{user.username}" if getattr(user, "username", None) else None)
|
||||
or getattr(user, "email", None)
|
||||
or f"User #{user.user_id}"
|
||||
)
|
||||
|
||||
|
||||
async def admin_user_telegram_profile_link_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
admin_telegram_id = request.get("admin_telegram_id")
|
||||
if not admin_telegram_id:
|
||||
return _error(403, "admin_telegram_unavailable")
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
return _error(503, "queue_unavailable")
|
||||
|
||||
target_id = int(request.match_info["user_id"])
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
ok = await user_dal.delete_user_and_relations(session, target_id)
|
||||
if not ok:
|
||||
await session.rollback()
|
||||
target_user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not target_user:
|
||||
return _error(404, "not_found")
|
||||
if not target_user.telegram_id:
|
||||
return _error(404, "no_telegram_account")
|
||||
|
||||
admin_user = await user_dal.get_user_by_id(session, actor_id)
|
||||
lang = (
|
||||
getattr(admin_user, "language_code", None)
|
||||
or getattr(settings, "DEFAULT_LANGUAGE", None)
|
||||
or "ru"
|
||||
)
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_profile_link_webapp",
|
||||
"content": f"Requested Telegram profile link for user_id={target_id}",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
i18n_instance = request.app.get("i18n")
|
||||
translate = (
|
||||
(lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs))
|
||||
if i18n_instance is not None
|
||||
else (lambda key, **kwargs: key.format(**kwargs) if kwargs else key)
|
||||
)
|
||||
target_name = _admin_user_display_name_for_message(target_user)
|
||||
telegram_id = int(target_user.telegram_id)
|
||||
profile_url = f"tg://user?id={telegram_id}"
|
||||
message_text = translate(
|
||||
"admin_user_profile_link_message",
|
||||
name=html_escape(target_name),
|
||||
user_id=target_user.user_id,
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
if message_text == "admin_user_profile_link_message":
|
||||
message_text = (
|
||||
f"Профиль пользователя: <b>{html_escape(target_name)}</b>\n"
|
||||
f"User ID: <code>{target_user.user_id}</code>\n"
|
||||
f"Telegram ID: <code>{telegram_id}</code>\n\n"
|
||||
"Нажмите кнопку ниже, чтобы открыть профиль в Telegram."
|
||||
)
|
||||
|
||||
button_text = translate("user_card_open_profile_button")
|
||||
if button_text == "user_card_open_profile_button":
|
||||
button_text = "👤 Открыть профиль"
|
||||
|
||||
markup = InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text=button_text, url=profile_url)]]
|
||||
)
|
||||
|
||||
try:
|
||||
await send_message_via_queue(
|
||||
queue_manager,
|
||||
int(admin_telegram_id),
|
||||
MessageContent(content_type="text", text=message_text),
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
reply_markup=markup,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Admin profile link message enqueue failed: %s", exc)
|
||||
return _error(502, "send_failed", str(exc))
|
||||
|
||||
return _ok({"queued": True})
|
||||
|
||||
|
||||
async def admin_user_delete_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
panel_service = request.app.get("panel_service")
|
||||
if panel_service is None:
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not user:
|
||||
return _error(404, "not_found")
|
||||
|
||||
panel_user_uuids = await user_dal.get_panel_user_uuids_for_user(
|
||||
session,
|
||||
target_id,
|
||||
user=user,
|
||||
)
|
||||
if panel_user_uuids and panel_service is None:
|
||||
await session.rollback()
|
||||
return _error(503, "panel_service_unavailable")
|
||||
|
||||
for panel_uuid in panel_user_uuids:
|
||||
try:
|
||||
panel_deleted = await panel_service.delete_user_from_panel(
|
||||
panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Admin webapp failed to delete panel user %s for user %s: %s",
|
||||
panel_uuid,
|
||||
target_id,
|
||||
exc,
|
||||
)
|
||||
await session.rollback()
|
||||
return _error(502, "panel_delete_failed", str(exc))
|
||||
|
||||
if not panel_deleted:
|
||||
await session.rollback()
|
||||
return _error(
|
||||
502,
|
||||
"panel_delete_failed",
|
||||
f"Failed to delete panel user {panel_uuid}",
|
||||
)
|
||||
|
||||
ok = await user_dal.delete_user_and_relations(session, target_id)
|
||||
if not ok:
|
||||
await session.rollback()
|
||||
return _error(404, "not_found")
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id if actor_id != target_id else None,
|
||||
"event_type": "admin_delete_user_webapp",
|
||||
"content": f"Deleted user_id={target_id}",
|
||||
"content": (
|
||||
f"Deleted user_id={target_id}; "
|
||||
f"panel_uuids={','.join(panel_user_uuids) or 'none'}"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||
return _ok({})
|
||||
|
||||
|
||||
async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
settings: Settings = request.app["settings"]
|
||||
panel_service = request.app.get("panel_service")
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
if panel_service is None or subscription_service is None:
|
||||
@@ -676,6 +966,7 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||
return _ok({})
|
||||
|
||||
|
||||
@@ -683,6 +974,7 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons
|
||||
"""Premium-squad traffic overrides only (unlimited toggle + bonus GB)."""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
|
||||
@@ -734,6 +1026,7 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons
|
||||
await session.commit()
|
||||
await session.refresh(active)
|
||||
|
||||
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||
return _ok({"subscription": _serialize_subscription(active)})
|
||||
|
||||
|
||||
@@ -741,6 +1034,7 @@ async def admin_user_regular_traffic_override_route(request: web.Request) -> web
|
||||
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
|
||||
unlimited = bool(payload.get("unlimited"))
|
||||
@@ -791,6 +1085,7 @@ async def admin_user_regular_traffic_override_route(request: web.Request) -> web
|
||||
await session.commit()
|
||||
await session.refresh(active)
|
||||
|
||||
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||
return _ok({"subscription": _serialize_subscription(active)})
|
||||
|
||||
|
||||
@@ -805,6 +1100,7 @@ async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
||||
"""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
|
||||
kind = str(payload.get("kind") or "regular").strip().lower()
|
||||
@@ -865,6 +1161,7 @@ async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
||||
|
||||
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
|
||||
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||
return _ok(
|
||||
{
|
||||
"subscription": _serialize_subscription(refreshed) if refreshed else None,
|
||||
@@ -880,6 +1177,7 @@ async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
||||
async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
try:
|
||||
days = int(payload.get("days") or 0)
|
||||
@@ -918,6 +1216,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
|
||||
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
|
||||
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||
return _ok(
|
||||
{
|
||||
"subscription": _serialize_subscription(refreshed) if refreshed else None,
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import (
|
||||
invalidate_all_webapp_user_payloads,
|
||||
reset_subscription_guides_cache,
|
||||
reset_webapp_settings_cache,
|
||||
)
|
||||
|
||||
WEBAPP_APPEARANCE_SETTING_KEYS = frozenset(
|
||||
{
|
||||
"WEBAPP_TITLE",
|
||||
"WEBAPP_LOGO_URL",
|
||||
"WEBAPP_LOGO_USE_EMOJI",
|
||||
"WEBAPP_LOGO_EMOJI",
|
||||
"WEBAPP_LOGO_EMOJI_FONT",
|
||||
"WEBAPP_FAVICON_URL",
|
||||
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||
"WEBAPP_LOGO_FAVICON_URL",
|
||||
}
|
||||
)
|
||||
|
||||
WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS = frozenset(
|
||||
{
|
||||
"MY_DEVICES_SECTION_ENABLED",
|
||||
"USER_HWID_DEVICE_LIMIT",
|
||||
"USER_TRAFFIC_LIMIT_GB",
|
||||
"USER_TRAFFIC_STRATEGY",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def changed_setting_keys(
|
||||
updates: Mapping[str, Any] | None = None,
|
||||
deletes: Sequence[Any] | None = None,
|
||||
) -> set[str]:
|
||||
keys = {str(key) for key in (updates or {}).keys()}
|
||||
keys.update(str(key) for key in (deletes or []) if key is not None)
|
||||
return keys
|
||||
|
||||
|
||||
async def refresh_webapp_runtime_after_settings_change(
|
||||
request: Any,
|
||||
*,
|
||||
updates: Mapping[str, Any] | None = None,
|
||||
deletes: Sequence[Any] | None = None,
|
||||
include_user_payloads: bool = True,
|
||||
) -> None:
|
||||
settings = request.app["settings"]
|
||||
keys = changed_setting_keys(updates, deletes)
|
||||
|
||||
reset_webapp_settings_cache(request.app)
|
||||
reset_subscription_guides_cache(request.app)
|
||||
|
||||
if include_user_payloads:
|
||||
await invalidate_all_webapp_user_payloads(
|
||||
settings,
|
||||
include_devices=bool(keys & WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS),
|
||||
)
|
||||
|
||||
if keys & WEBAPP_APPEARANCE_SETTING_KEYS:
|
||||
request.app["webapp_logo_cache"] = None
|
||||
from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets
|
||||
|
||||
prune_unused_appearance_assets(settings)
|
||||
@@ -8,6 +8,7 @@ the API, even by an admin.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
@@ -15,7 +16,7 @@ from typing import Any, List, Optional, Tuple
|
||||
@dataclass(frozen=True)
|
||||
class SettingField:
|
||||
key: str
|
||||
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "secret"
|
||||
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon" | "json"
|
||||
section: str
|
||||
label: str
|
||||
description: str = ""
|
||||
@@ -28,10 +29,18 @@ class SettingField:
|
||||
subsection: Optional[str] = None # group label inside a section
|
||||
i18n_label_key: Optional[str] = None
|
||||
i18n_description_key: Optional[str] = None
|
||||
i18n_subsection_key: Optional[str] = None
|
||||
|
||||
|
||||
SETTINGS_MANIFEST: List[SettingField] = [
|
||||
# ─── General ────────────────────────────────────────────────────
|
||||
SettingField(
|
||||
"WEBAPP_TITLE",
|
||||
"string",
|
||||
"general",
|
||||
"Web App title",
|
||||
placeholder="My subscription",
|
||||
),
|
||||
SettingField(
|
||||
"DEFAULT_LANGUAGE",
|
||||
"string",
|
||||
@@ -72,13 +81,60 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"Ссылка на канал",
|
||||
"Имя пользователя или invite-link.",
|
||||
),
|
||||
SettingField(
|
||||
"PANEL_API_URL",
|
||||
"url",
|
||||
"general",
|
||||
"URL API Remnawave",
|
||||
"Например, https://panel.example.com/api.",
|
||||
subsection="Remnawave",
|
||||
),
|
||||
SettingField(
|
||||
"PANEL_API_KEY",
|
||||
"string",
|
||||
"general",
|
||||
"API-ключ Remnawave",
|
||||
"Секретный ключ API панели.",
|
||||
secret=True,
|
||||
subsection="Remnawave",
|
||||
),
|
||||
SettingField(
|
||||
"PANEL_WEBHOOK_SECRET",
|
||||
"string",
|
||||
"general",
|
||||
"Секрет вебхуков Remnawave",
|
||||
"Используется для проверки входящих вебхуков панели.",
|
||||
secret=True,
|
||||
subsection="Remnawave",
|
||||
),
|
||||
SettingField(
|
||||
"USER_SQUAD_UUIDS",
|
||||
"string",
|
||||
"general",
|
||||
"Internal Squads по умолчанию",
|
||||
"UUID через запятую для legacy-режима без JSON-каталога тарифов.",
|
||||
subsection="Remnawave",
|
||||
),
|
||||
SettingField(
|
||||
"USER_EXTERNAL_SQUAD_UUID",
|
||||
"string",
|
||||
"general",
|
||||
"External Squad по умолчанию",
|
||||
"Необязательный UUID External Squad для новых пользователей.",
|
||||
subsection="Remnawave",
|
||||
),
|
||||
# ─── Web app appearance ────────────────────────────────────────
|
||||
SettingField(
|
||||
"WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка"
|
||||
"SUBSCRIPTION_MINI_APP_URL",
|
||||
"url",
|
||||
"appearance",
|
||||
"Публичный URL Mini App",
|
||||
"Например, https://app.example.com/.",
|
||||
),
|
||||
SettingField(
|
||||
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
|
||||
),
|
||||
SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"),
|
||||
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
|
||||
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
|
||||
SettingField(
|
||||
@@ -99,7 +155,68 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
("noto-local", "Noto Emoji (local)"),
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||
"bool",
|
||||
"appearance",
|
||||
"Использовать отдельную favicon",
|
||||
),
|
||||
SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"),
|
||||
SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"),
|
||||
SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_GUIDES_ENABLED",
|
||||
"bool",
|
||||
"subscription_guides",
|
||||
"Embedded install guides",
|
||||
"Open install instructions inside the Web App instead of an external connect page.",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED",
|
||||
"bool",
|
||||
"subscription_guides",
|
||||
"Open install guides from bot",
|
||||
(
|
||||
"Use the Telegram Mini App install screen for bot connect buttons and show "
|
||||
"public install guide links."
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED",
|
||||
"bool",
|
||||
"subscription_guides",
|
||||
"Use Remnawave Panel config",
|
||||
(
|
||||
"Fetch Subscription Page config from Remnawave Panel by the user's "
|
||||
"subscription short UUID."
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED",
|
||||
"bool",
|
||||
"subscription_guides",
|
||||
"Enable admin JSON override",
|
||||
"Use the JSON field below instead of Remnawave Panel config. Disabled by default.",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PAGE_CONFIG_PATH",
|
||||
"string",
|
||||
"subscription_guides",
|
||||
"Subscription Page config path",
|
||||
"Fallback path to a Remnawave Subscription Page v1 JSON config file.",
|
||||
placeholder="data/subpage-config/multiapp.json",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PAGE_CONFIG_JSON",
|
||||
"json",
|
||||
"subscription_guides",
|
||||
"Subscription Page config JSON",
|
||||
(
|
||||
"Optional admin JSON override. It is applied only when the JSON override "
|
||||
"switch is enabled."
|
||||
),
|
||||
placeholder='{\n "version": "1"\n}',
|
||||
),
|
||||
# ─── Subscription periods & pricing ────────────────────────────
|
||||
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
|
||||
SettingField("MONTH_3_ENABLED", "bool", "pricing", "Тариф 3 месяца"),
|
||||
@@ -118,203 +235,86 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
),
|
||||
SettingField("STARS_TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика (Stars)"),
|
||||
SettingField(
|
||||
"PAYMENT_METHODS_ORDER",
|
||||
"string",
|
||||
"pricing",
|
||||
"Порядок методов оплаты",
|
||||
"Через запятую, например: severpay,freekassa,yookassa",
|
||||
"SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED",
|
||||
"bool",
|
||||
"payments",
|
||||
"Показывать описание подписки",
|
||||
"Текст появится перед выбором срока покупки или продления.",
|
||||
subsection="checkout",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PURCHASE_DESCRIPTION_RU",
|
||||
"text",
|
||||
"payments",
|
||||
"Описание подписки (RU)",
|
||||
"Русская версия текста на этапе оплаты.",
|
||||
subsection="checkout",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PURCHASE_DESCRIPTION_EN",
|
||||
"text",
|
||||
"payments",
|
||||
"Описание подписки (EN)",
|
||||
"Английская версия текста на этапе оплаты.",
|
||||
subsection="checkout",
|
||||
),
|
||||
# ─── Payment providers (toggles) ───────────────────────────────
|
||||
# Common
|
||||
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="Общие"),
|
||||
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="common"),
|
||||
SettingField(
|
||||
"STARS_ADMIN_ONLY_ENABLED",
|
||||
"bool",
|
||||
"payments",
|
||||
"Telegram Stars admin-only",
|
||||
(
|
||||
"Shows Telegram Stars only to users from ADMIN_IDS. "
|
||||
"Payment callbacks remain active for admin test payments."
|
||||
),
|
||||
subsection="common",
|
||||
i18n_label_key="admin_settings_provider_admin_only_label",
|
||||
i18n_description_key="admin_settings_provider_admin_only_description",
|
||||
),
|
||||
SettingField(
|
||||
"PAYMENT_METHODS_ORDER",
|
||||
"string",
|
||||
"payments",
|
||||
"Порядок методов оплаты",
|
||||
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay",
|
||||
subsection="Общие",
|
||||
),
|
||||
# YooKassa
|
||||
SettingField("YOOKASSA_ENABLED", "bool", "payments", "Включена", subsection="YooKassa"),
|
||||
SettingField("YOOKASSA_SHOP_ID", "string", "payments", "Shop ID", subsection="YooKassa"),
|
||||
SettingField(
|
||||
"YOOKASSA_SECRET_KEY",
|
||||
"string",
|
||||
"payments",
|
||||
"Secret key",
|
||||
subsection="YooKassa",
|
||||
secret=True,
|
||||
),
|
||||
SettingField("YOOKASSA_RETURN_URL", "url", "payments", "Return URL", subsection="YooKassa"),
|
||||
SettingField(
|
||||
"YOOKASSA_DEFAULT_RECEIPT_EMAIL",
|
||||
"string",
|
||||
"payments",
|
||||
"Email для чека по умолчанию",
|
||||
subsection="YooKassa",
|
||||
),
|
||||
SettingField(
|
||||
"YOOKASSA_VAT_CODE",
|
||||
"int",
|
||||
"payments",
|
||||
"VAT code",
|
||||
"1..6 в зависимости от системы налогообложения",
|
||||
subsection="YooKassa",
|
||||
min=1,
|
||||
max=6,
|
||||
),
|
||||
SettingField(
|
||||
"YOOKASSA_AUTOPAYMENTS_ENABLED",
|
||||
"bool",
|
||||
"payments",
|
||||
"Автоплатежи (recurring)",
|
||||
subsection="YooKassa",
|
||||
),
|
||||
SettingField(
|
||||
"YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING",
|
||||
"bool",
|
||||
"payments",
|
||||
"Принудительная привязка карты",
|
||||
subsection="YooKassa",
|
||||
),
|
||||
# FreeKassa
|
||||
SettingField("FREEKASSA_ENABLED", "bool", "payments", "Включена", subsection="FreeKassa"),
|
||||
SettingField(
|
||||
"FREEKASSA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="FreeKassa"
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_FIRST_SECRET",
|
||||
"string",
|
||||
"payments",
|
||||
"First secret",
|
||||
subsection="FreeKassa",
|
||||
secret=True,
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_SECOND_SECRET",
|
||||
"string",
|
||||
"payments",
|
||||
"Second secret",
|
||||
"Используется для проверки подписи входящих уведомлений",
|
||||
subsection="FreeKassa",
|
||||
secret=True,
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_API_KEY", "string", "payments", "API key", subsection="FreeKassa", secret=True
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_PAYMENT_URL",
|
||||
"url",
|
||||
"payments",
|
||||
"Payment URL",
|
||||
placeholder="https://pay.freekassa.ru/",
|
||||
subsection="FreeKassa",
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_PAYMENT_METHOD_ID",
|
||||
"int",
|
||||
"payments",
|
||||
"Метод оплаты по умолчанию",
|
||||
subsection="FreeKassa",
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_PAYMENT_IP",
|
||||
"string",
|
||||
"payments",
|
||||
"IP сервера",
|
||||
"Передаётся в подпись запроса при создании платежа",
|
||||
subsection="FreeKassa",
|
||||
),
|
||||
SettingField(
|
||||
"FREEKASSA_TRUSTED_IPS",
|
||||
"string",
|
||||
"payments",
|
||||
"Доверенные IP",
|
||||
"Через запятую — IP-адреса, с которых принимаются нотификации",
|
||||
subsection="FreeKassa",
|
||||
),
|
||||
# Platega
|
||||
SettingField("PLATEGA_ENABLED", "bool", "payments", "Включена", subsection="Platega"),
|
||||
SettingField(
|
||||
"PLATEGA_BASE_URL",
|
||||
"url",
|
||||
"payments",
|
||||
"Base URL",
|
||||
placeholder="https://app.platega.io",
|
||||
subsection="Platega",
|
||||
),
|
||||
SettingField("PLATEGA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="Platega"),
|
||||
SettingField(
|
||||
"PLATEGA_SECRET", "string", "payments", "Secret", subsection="Platega", secret=True
|
||||
),
|
||||
SettingField(
|
||||
"PLATEGA_PAYMENT_METHOD", "int", "payments", "Метод оплаты (legacy)", subsection="Platega"
|
||||
),
|
||||
SettingField("PLATEGA_SBP_ENABLED", "bool", "payments", "SBP-кнопка", subsection="Platega"),
|
||||
SettingField("PLATEGA_SBP_METHOD", "int", "payments", "SBP method ID", subsection="Platega"),
|
||||
SettingField(
|
||||
"PLATEGA_CRYPTO_ENABLED", "bool", "payments", "Crypto-кнопка", subsection="Platega"
|
||||
),
|
||||
SettingField(
|
||||
"PLATEGA_CRYPTO_METHOD", "int", "payments", "Crypto method ID", subsection="Platega"
|
||||
),
|
||||
SettingField("PLATEGA_RETURN_URL", "url", "payments", "Return URL", subsection="Platega"),
|
||||
SettingField("PLATEGA_FAILED_URL", "url", "payments", "Failed URL", subsection="Platega"),
|
||||
# SeverPay
|
||||
SettingField("SEVERPAY_ENABLED", "bool", "payments", "Включена", subsection="SeverPay"),
|
||||
SettingField("SEVERPAY_MID", "int", "payments", "MID", subsection="SeverPay"),
|
||||
SettingField(
|
||||
"SEVERPAY_TOKEN", "string", "payments", "Token", subsection="SeverPay", secret=True
|
||||
),
|
||||
SettingField(
|
||||
"SEVERPAY_BASE_URL",
|
||||
"url",
|
||||
"payments",
|
||||
"Base URL",
|
||||
placeholder="https://severpay.io/api/merchant",
|
||||
subsection="SeverPay",
|
||||
),
|
||||
SettingField("SEVERPAY_RETURN_URL", "url", "payments", "Return URL", subsection="SeverPay"),
|
||||
SettingField(
|
||||
"SEVERPAY_LIFETIME_MINUTES",
|
||||
"int",
|
||||
"payments",
|
||||
"Срок жизни ссылки (мин)",
|
||||
"30..4320; пусто — значение провайдера",
|
||||
subsection="SeverPay",
|
||||
min=30,
|
||||
max=4320,
|
||||
),
|
||||
# CryptoPay
|
||||
SettingField("CRYPTOPAY_ENABLED", "bool", "payments", "Включена", subsection="CryptoPay"),
|
||||
SettingField(
|
||||
"CRYPTOPAY_TOKEN", "string", "payments", "Token", subsection="CryptoPay", secret=True
|
||||
),
|
||||
SettingField(
|
||||
"CRYPTOPAY_NETWORK",
|
||||
"string",
|
||||
"payments",
|
||||
"Network",
|
||||
"mainnet или testnet",
|
||||
subsection="CryptoPay",
|
||||
),
|
||||
SettingField(
|
||||
"CRYPTOPAY_CURRENCY_TYPE",
|
||||
"string",
|
||||
"payments",
|
||||
"Currency type",
|
||||
"fiat или crypto",
|
||||
subsection="CryptoPay",
|
||||
),
|
||||
SettingField(
|
||||
"CRYPTOPAY_ASSET", "string", "payments", "Asset", placeholder="RUB", subsection="CryptoPay"
|
||||
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket",
|
||||
subsection="common",
|
||||
),
|
||||
# ─── Trial ─────────────────────────────────────────────────────
|
||||
SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"),
|
||||
SettingField("TRIAL_DURATION_DAYS", "int", "trial", "Длительность триала (дней)", min=0),
|
||||
SettingField("TRIAL_TRAFFIC_LIMIT_GB", "float", "trial", "Лимит трафика триала (ГБ)", min=0),
|
||||
SettingField("TRIAL_TRAFFIC_STRATEGY", "string", "trial", "Стратегия сброса трафика триала"),
|
||||
SettingField("TRIAL_ENABLED", "bool", "pricing", "Триал включён", subsection="trial"),
|
||||
SettingField(
|
||||
"TRIAL_DURATION_DAYS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Длительность триала (дней)",
|
||||
min=0,
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
"TRIAL_TRAFFIC_LIMIT_GB",
|
||||
"float",
|
||||
"pricing",
|
||||
"Лимит трафика триала (ГБ)",
|
||||
min=0,
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
"TRIAL_TRAFFIC_STRATEGY",
|
||||
"string",
|
||||
"pricing",
|
||||
"Стратегия сброса трафика триала",
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
"TRIAL_SQUAD_UUIDS",
|
||||
"string",
|
||||
"pricing",
|
||||
"Internal Squads для триала",
|
||||
"UUID через запятую. Если пусто, используется USER_SQUAD_UUIDS.",
|
||||
subsection="trial",
|
||||
),
|
||||
# ─── Referral program ──────────────────────────────────────────
|
||||
SettingField(
|
||||
"REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"
|
||||
@@ -401,6 +401,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
),
|
||||
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
|
||||
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
|
||||
SettingField("LOG_SUPPORT", "bool", "notifications", "Логировать тикеты поддержки"),
|
||||
SettingField(
|
||||
"LOG_PROMO_ACTIVATIONS", "bool", "notifications", "Логировать активации промокодов"
|
||||
),
|
||||
@@ -414,8 +415,8 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"notifications",
|
||||
"Логировать действия администраторов",
|
||||
"Если выключено, события от пользователей из ADMIN_IDS не записываются в message logs.",
|
||||
i18n_label_key="settings_field_log_admin_actions_label",
|
||||
i18n_description_key="settings_field_log_admin_actions_description",
|
||||
i18n_label_key="admin_settings_field_log_admin_actions_label",
|
||||
i18n_description_key="admin_settings_field_log_admin_actions_description",
|
||||
),
|
||||
SettingField(
|
||||
"LOG_LEVEL",
|
||||
@@ -426,6 +427,73 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
),
|
||||
SettingField("LOG_CHAT_ID", "int", "notifications", "ID чата для логов"),
|
||||
SettingField("LOG_THREAD_ID", "int", "notifications", "ID треда (для супергрупп)"),
|
||||
SettingField(
|
||||
"LOG_SUPPORT_THREAD_ID",
|
||||
"int",
|
||||
"notifications",
|
||||
"ID треда поддержки",
|
||||
"Тред лог-чата для уведомлений о тикетах поддержки.",
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_TICKETS_ENABLED",
|
||||
"bool",
|
||||
"support",
|
||||
"Тикеты поддержки включены",
|
||||
"Показывает раздел поддержки в ЛК и включает создание тикетов.",
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED",
|
||||
"bool",
|
||||
"support",
|
||||
"Email-уведомления админам",
|
||||
(
|
||||
"Если выключено, новые тикеты и ответы пользователей останутся "
|
||||
"только в Telegram и лог-чате."
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS",
|
||||
"int",
|
||||
"support",
|
||||
"Пауза Telegram-уведомлений",
|
||||
(
|
||||
"Минимум секунд между повторными Telegram/log уведомлениями "
|
||||
"по одному непрочитанному тикету."
|
||||
),
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS",
|
||||
"int",
|
||||
"support",
|
||||
"Пауза email-уведомлений",
|
||||
"Минимум секунд между повторными email-уведомлениями по одному непрочитанному тикету.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_TICKET_MAX_BODY_LENGTH",
|
||||
"int",
|
||||
"support",
|
||||
"Макс. длина сообщения",
|
||||
"Максимальное количество символов в сообщении тикета.",
|
||||
min=1,
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_TICKET_MAX_SUBJECT_LENGTH",
|
||||
"int",
|
||||
"support",
|
||||
"Макс. длина темы",
|
||||
"Максимальное количество символов в теме тикета.",
|
||||
min=1,
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_TICKET_RATE_LIMIT_PER_HOUR",
|
||||
"int",
|
||||
"support",
|
||||
"Лимит тикетов в час",
|
||||
"Сколько новых тикетов пользователь может создать за час. 0 — без лимита.",
|
||||
min=0,
|
||||
),
|
||||
# ─── Devices ───────────────────────────────────────────────────
|
||||
SettingField("MY_DEVICES_SECTION_ENABLED", "bool", "devices", "Раздел «Мои устройства»"),
|
||||
SettingField(
|
||||
@@ -436,20 +504,62 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
]
|
||||
|
||||
|
||||
def _provider_field_to_setting_field(spec: Any, manifest_field: Any) -> SettingField:
|
||||
return SettingField(
|
||||
key=manifest_field.key,
|
||||
type=manifest_field.type,
|
||||
section="payments",
|
||||
label=manifest_field.label,
|
||||
description=manifest_field.description,
|
||||
placeholder=manifest_field.placeholder,
|
||||
optional=manifest_field.optional,
|
||||
secret=manifest_field.secret,
|
||||
min=manifest_field.min,
|
||||
max=manifest_field.max,
|
||||
choices=tuple(manifest_field.choices) if manifest_field.choices else None,
|
||||
subsection=manifest_field.subsection,
|
||||
i18n_label_key=getattr(manifest_field, "i18n_label_key", None),
|
||||
i18n_description_key=getattr(manifest_field, "i18n_description_key", None),
|
||||
i18n_subsection_key=getattr(manifest_field, "i18n_subsection_key", None),
|
||||
)
|
||||
|
||||
|
||||
def aggregated_manifest() -> List[SettingField]:
|
||||
"""SETTINGS_MANIFEST + per-provider fragments declared in provider SPECs."""
|
||||
from bot.payment_providers import iter_provider_manifest_fields # local to avoid cycle
|
||||
|
||||
fields: List[SettingField] = list(SETTINGS_MANIFEST)
|
||||
for spec, manifest_field in iter_provider_manifest_fields():
|
||||
fields.append(_provider_field_to_setting_field(spec, manifest_field))
|
||||
return fields
|
||||
|
||||
|
||||
def get_field_by_key(key: str) -> Optional[SettingField]:
|
||||
for field in SETTINGS_MANIFEST:
|
||||
for field in aggregated_manifest():
|
||||
if field.key == key:
|
||||
return field
|
||||
return None
|
||||
|
||||
|
||||
def manifest_keys() -> List[str]:
|
||||
return [f.key for f in SETTINGS_MANIFEST]
|
||||
return [f.key for f in aggregated_manifest()]
|
||||
|
||||
|
||||
def coerce_value(field: SettingField, raw: Any) -> Any:
|
||||
"""Coerce a value coming from JSON to the type declared by the field."""
|
||||
|
||||
if field.type == "json":
|
||||
if raw is None:
|
||||
return ""
|
||||
text = raw if isinstance(raw, str) else str(raw)
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return ""
|
||||
from config.subscription_guides_config import validate_subscription_guides_config_text
|
||||
|
||||
validate_subscription_guides_config_text(text)
|
||||
return text
|
||||
|
||||
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
|
||||
return None
|
||||
|
||||
@@ -489,23 +599,66 @@ def coerce_value(field: SettingField, raw: Any) -> Any:
|
||||
return str(raw)
|
||||
|
||||
|
||||
def _i18n_slug(value: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "_", value.strip().lower()).strip("_")
|
||||
return slug or "default"
|
||||
|
||||
|
||||
def manifest_payload() -> List[dict]:
|
||||
"""Serialize the manifest for the admin UI."""
|
||||
"""Serialize the manifest for the admin UI.
|
||||
|
||||
For provider presentation fields we resolve the SPEC-declared default
|
||||
(e.g. the button text the bot would use if the admin leaves the override
|
||||
blank) and expose it as ``default``; ``placeholder`` falls back to the
|
||||
same value so existing UIs that only read ``placeholder`` also show the
|
||||
hint inside the empty input.
|
||||
"""
|
||||
from bot.payment_providers import (
|
||||
find_manifest_owner,
|
||||
manifest_field_default,
|
||||
provider_admin_only_pairs,
|
||||
provider_webhook_metadata,
|
||||
)
|
||||
|
||||
sections_order = {
|
||||
"general": 1,
|
||||
"appearance": 2,
|
||||
"pricing": 3,
|
||||
"pricing": 11,
|
||||
"payments": 4,
|
||||
"trial": 5,
|
||||
"referral": 6,
|
||||
"notifications": 7,
|
||||
"devices": 8,
|
||||
"support": 8,
|
||||
"devices": 9,
|
||||
"subscription_guides": 10,
|
||||
}
|
||||
exclusive_map = {
|
||||
key: opposite
|
||||
for public_key, admin_key in provider_admin_only_pairs()
|
||||
for key, opposite in ((public_key, admin_key), (admin_key, public_key))
|
||||
}
|
||||
items: List[dict] = []
|
||||
for field in SETTINGS_MANIFEST:
|
||||
auto_label_i18n_key = f"settings_field_{field.key.lower()}_label"
|
||||
auto_description_i18n_key = f"settings_field_{field.key.lower()}_description"
|
||||
for field in aggregated_manifest():
|
||||
auto_label_i18n_key = f"admin_settings_field_{field.key.lower()}_label"
|
||||
auto_description_i18n_key = f"admin_settings_field_{field.key.lower()}_description"
|
||||
auto_subsection_i18n_key = (
|
||||
f"admin_settings_subsection_{_i18n_slug(field.subsection)}"
|
||||
if field.subsection
|
||||
else None
|
||||
)
|
||||
|
||||
default_value: Optional[str] = None
|
||||
webhook_metadata: Optional[dict] = None
|
||||
owner = find_manifest_owner(field.key)
|
||||
if owner is not None:
|
||||
spec, manifest_field = owner
|
||||
default_value = manifest_field_default(spec, manifest_field)
|
||||
webhook_metadata = provider_webhook_metadata(spec)
|
||||
|
||||
placeholder = field.placeholder
|
||||
if not placeholder and default_value:
|
||||
placeholder = default_value
|
||||
|
||||
item = {
|
||||
"key": field.key,
|
||||
"type": field.type,
|
||||
@@ -517,11 +670,30 @@ def manifest_payload() -> List[dict]:
|
||||
"i18n_label_key": field.i18n_label_key or auto_label_i18n_key,
|
||||
"i18n_description_key": field.i18n_description_key
|
||||
or (auto_description_i18n_key if field.description else None),
|
||||
"placeholder": field.placeholder,
|
||||
"i18n_subsection_key": field.i18n_subsection_key or auto_subsection_i18n_key,
|
||||
"i18n_placeholder_key": (
|
||||
f"admin_settings_field_{field.key.lower()}_placeholder" if placeholder else None
|
||||
),
|
||||
"placeholder": placeholder,
|
||||
"optional": field.optional,
|
||||
"secret": field.secret,
|
||||
}
|
||||
if field.key in exclusive_map:
|
||||
item["mutually_exclusive_key"] = exclusive_map[field.key]
|
||||
if default_value is not None:
|
||||
item["default"] = default_value
|
||||
if webhook_metadata:
|
||||
item.update(webhook_metadata)
|
||||
if field.choices:
|
||||
item["choices"] = [{"value": v, "label": lbl} for v, lbl in field.choices]
|
||||
item["choices"] = [
|
||||
{
|
||||
"value": v,
|
||||
"label": lbl,
|
||||
"i18n_label_key": (
|
||||
f"admin_settings_field_{field.key.lower()}_choice_{_i18n_slug(str(v))}"
|
||||
),
|
||||
}
|
||||
for v, lbl in field.choices
|
||||
]
|
||||
items.append(item)
|
||||
return items
|
||||
@@ -11,9 +11,11 @@ from bot.app.web.webapp import (
|
||||
billing as _billing,
|
||||
common as _common,
|
||||
devices as _devices,
|
||||
guides as _guides,
|
||||
payloads as _payloads,
|
||||
routes as _routes,
|
||||
serializers as _serializers,
|
||||
support as _support,
|
||||
)
|
||||
|
||||
_MODULES = (
|
||||
@@ -26,6 +28,8 @@ _MODULES = (
|
||||
_serializers,
|
||||
_billing,
|
||||
_devices,
|
||||
_guides,
|
||||
_support,
|
||||
_routes,
|
||||
_application,
|
||||
)
|
||||
@@ -0,0 +1,219 @@
|
||||
<!doctype html>
|
||||
<html lang="__LANG__">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link id="app-favicon" rel="icon" href="data:," sizes="any">
|
||||
<title>__PAGE_TITLE__</title>
|
||||
<style nonce="__NONCE__">
|
||||
:root {
|
||||
color-scheme: dark light;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", sans-serif;
|
||||
background: #0b1017;
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(100%, 420px);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #aeb8c5;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
min-height: 46px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: #14b86f;
|
||||
color: #03120b;
|
||||
padding: 0 18px;
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
border-color: #2d3847;
|
||||
background: transparent;
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.button[aria-disabled="true"] {
|
||||
pointer-events: none;
|
||||
background: #344052;
|
||||
color: #aeb8c5;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1 id="title"></h1>
|
||||
<p id="status"></p>
|
||||
<div class="actions">
|
||||
<a id="open-link" class="button" href="#" rel="noreferrer"></a>
|
||||
<button id="close-button" class="button secondary" type="button" hidden></button>
|
||||
</div>
|
||||
</main>
|
||||
<script nonce="__NONCE__">
|
||||
(() => {
|
||||
const messages = __MESSAGES_JSON__;
|
||||
const titleEl = document.getElementById("title");
|
||||
const statusEl = document.getElementById("status");
|
||||
const openLink = document.getElementById("open-link");
|
||||
const closeButton = document.getElementById("close-button");
|
||||
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
const target = String(params.get("url") || "").trim();
|
||||
const isUnsafe =
|
||||
!target ||
|
||||
hasControlChars(target) ||
|
||||
/^(?:javascript|data|vbscript|https?):/i.test(target);
|
||||
let attempted = false;
|
||||
let pageLeft = false;
|
||||
let state = "opening";
|
||||
|
||||
function hasControlChars(value) {
|
||||
return Array.from(String(value || "")).some((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code <= 31 || code === 127;
|
||||
});
|
||||
}
|
||||
|
||||
function text(key, fallback) {
|
||||
const value = messages && messages[key];
|
||||
return typeof value === "string" && value ? value : fallback;
|
||||
}
|
||||
|
||||
function tryCloseWindow() {
|
||||
try {
|
||||
window.close();
|
||||
} catch (_error) {
|
||||
void _error;
|
||||
}
|
||||
}
|
||||
|
||||
function render(nextState) {
|
||||
state = nextState;
|
||||
if (nextState === "unavailable") {
|
||||
titleEl.textContent = text("unavailableTitle", "App link unavailable");
|
||||
statusEl.textContent = text("unavailableHint", "Return to Telegram and try again.");
|
||||
openLink.textContent = text("button", "Open app");
|
||||
openLink.setAttribute("aria-disabled", "true");
|
||||
openLink.removeAttribute("href");
|
||||
closeButton.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextState === "done") {
|
||||
titleEl.textContent = text("doneTitle", "Settings added");
|
||||
statusEl.textContent = text("doneHint", "You can close this window.");
|
||||
openLink.textContent = text("retryButton", "Open again");
|
||||
openLink.removeAttribute("aria-disabled");
|
||||
openLink.href = target;
|
||||
closeButton.textContent = text("closeButton", "Close window");
|
||||
closeButton.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
titleEl.textContent = text("title", "Opening app");
|
||||
statusEl.textContent =
|
||||
nextState === "manual"
|
||||
? text("manualHint", "If the app did not open automatically, tap the button below.")
|
||||
: text("hint", "Opening the app on this device...");
|
||||
openLink.textContent = text("button", "Open app");
|
||||
openLink.removeAttribute("aria-disabled");
|
||||
openLink.href = target;
|
||||
closeButton.hidden = true;
|
||||
}
|
||||
|
||||
function markDone() {
|
||||
if (state === "done" || isUnsafe) return;
|
||||
render("done");
|
||||
window.setTimeout(tryCloseWindow, 120);
|
||||
}
|
||||
|
||||
function notePageLeft() {
|
||||
if (!attempted) return;
|
||||
pageLeft = true;
|
||||
window.setTimeout(markDone, 900);
|
||||
}
|
||||
|
||||
function openTarget() {
|
||||
if (isUnsafe) return;
|
||||
attempted = true;
|
||||
pageLeft = false;
|
||||
render("opening");
|
||||
window.location.href = target;
|
||||
window.setTimeout(() => {
|
||||
if (state === "opening" && !pageLeft) render("manual");
|
||||
}, 1600);
|
||||
}
|
||||
|
||||
if (isUnsafe) {
|
||||
render("unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
openLink.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
openTarget();
|
||||
});
|
||||
closeButton.addEventListener("click", () => {
|
||||
tryCloseWindow();
|
||||
render("done");
|
||||
});
|
||||
window.addEventListener("pagehide", notePageLeft);
|
||||
window.addEventListener("blur", notePageLeft);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!attempted) return;
|
||||
if (document.hidden) {
|
||||
pageLeft = true;
|
||||
} else if (pageLeft) {
|
||||
markDone();
|
||||
}
|
||||
});
|
||||
|
||||
render("opening");
|
||||
window.setTimeout(openTarget, 80);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,73 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#03070b" />
|
||||
<link id="app-favicon" rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png" />
|
||||
<link
|
||||
id="app-apple-touch-icon"
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon-precomposed"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon-precomposed.png"
|
||||
/>
|
||||
<title>/minishop</title>
|
||||
<link rel="stylesheet" href="/subscription_webapp.css" />
|
||||
<style>
|
||||
.app-boot-fallback {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: #03070b;
|
||||
}
|
||||
|
||||
.app-boot-fallback__spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 2px solid rgba(242, 247, 244, 0.18);
|
||||
border-top-color: #00fe7a;
|
||||
border-radius: 999px;
|
||||
animation: appBootSpin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-boot-fallback__spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes appBootSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main id="app">
|
||||
<div class="app-boot-fallback" role="status" aria-label="Загрузка">
|
||||
<div class="app-boot-fallback__spinner" aria-hidden="true"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- WEBAPP_I18N_SCRIPT -->
|
||||
<!-- WEBAPP_CONFIG_SCRIPT -->
|
||||
<!-- WEBAPP_JS_SCRIPT -->
|
||||
<!-- WEBAPP_DEV_MOCK_START -->
|
||||
<script src="/subscription_webapp.js" defer></script>
|
||||
<!-- WEBAPP_DEV_MOCK_END -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"key": "ascii",
|
||||
"names": {
|
||||
"ru": "ASCII",
|
||||
"en": "ASCII"
|
||||
},
|
||||
"enabled": true,
|
||||
"default": false,
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 4,
|
||||
"tokens": {
|
||||
"color_scheme": "dark",
|
||||
"style_preset": "ascii"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"key": "dark",
|
||||
"names": {
|
||||
"ru": "Темная",
|
||||
"en": "Dark"
|
||||
},
|
||||
"enabled": true,
|
||||
"default": true,
|
||||
"use_primary_accent": true,
|
||||
"use_in_admin": true,
|
||||
"assets_version": 1,
|
||||
"tokens": {
|
||||
"color_scheme": "dark",
|
||||
"bg": "#03070b",
|
||||
"panel": "#111820",
|
||||
"panel_2": "#0b1118",
|
||||
"panel_3": "#17212b",
|
||||
"border": "rgba(255, 255, 255, 0.12)",
|
||||
"border_strong": "rgba(255, 255, 255, 0.2)",
|
||||
"text": "#f2f7f4",
|
||||
"muted": "#a9b4b0",
|
||||
"dim": "#68736f",
|
||||
"danger": "#ff6b6b",
|
||||
"blue": "#2d9cff",
|
||||
"radius": "8px"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
.theme-key-light {
|
||||
color-scheme: light;
|
||||
--accent: #047857;
|
||||
--bg: #f7f8fb;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f1f5f9;
|
||||
--panel-3: #e8edf3;
|
||||
--border: rgba(15, 23, 42, 0.11);
|
||||
--border-strong: rgba(15, 23, 42, 0.2);
|
||||
--text: #0f172a;
|
||||
--muted: #475569;
|
||||
--dim: #64748b;
|
||||
--danger: #dc2626;
|
||||
--danger-text: #b91c1c;
|
||||
--danger-soft: color-mix(in srgb, var(--danger) 9%, var(--panel));
|
||||
--danger-border: color-mix(in srgb, var(--danger) 34%, var(--border));
|
||||
--success: #16a34a;
|
||||
--success-text: #166534;
|
||||
--success-soft: color-mix(in srgb, var(--success) 10%, var(--panel));
|
||||
--success-border: color-mix(in srgb, var(--success) 34%, var(--border));
|
||||
--warning: #d97706;
|
||||
--warning-text: #92400e;
|
||||
--warning-soft: color-mix(in srgb, var(--warning) 11%, var(--panel));
|
||||
--warning-border: color-mix(in srgb, var(--warning) 34%, var(--border));
|
||||
--info: #2563eb;
|
||||
--info-text: #1d4ed8;
|
||||
--info-soft: color-mix(in srgb, var(--info) 9%, var(--panel));
|
||||
--info-border: color-mix(in srgb, var(--info) 30%, var(--border));
|
||||
--blue: #2563eb;
|
||||
--radius: 8px;
|
||||
--accent-contrast: #ffffff;
|
||||
--surface-sheen: rgba(15, 23, 42, 0.035);
|
||||
--surface-sheen-soft: rgba(15, 23, 42, 0.012);
|
||||
--surface-hover: rgba(15, 23, 42, 0.045);
|
||||
--surface-muted: rgba(15, 23, 42, 0.035);
|
||||
--surface-subtle: rgba(15, 23, 42, 0.025);
|
||||
--surface-subtle-border: rgba(15, 23, 42, 0.1);
|
||||
--overlay-scrim: rgba(15, 23, 42, 0.34);
|
||||
--nav-bg: rgba(255, 255, 255, 0.88);
|
||||
--rail-bg: rgba(255, 255, 255, 0.72);
|
||||
--shadow-soft: 0 6px 18px rgba(15, 23, 42, 0.06);
|
||||
--shadow-strong: 0 18px 44px rgba(15, 23, 42, 0.12);
|
||||
--shadow-popover: 0 14px 28px rgba(15, 23, 42, 0.12);
|
||||
--inset-highlight: rgba(255, 255, 255, 0.75);
|
||||
--admin-bg: #f7f8fb;
|
||||
--admin-surface: #ffffff;
|
||||
--admin-surface-2: #f1f5f9;
|
||||
--admin-elev: #e8edf3;
|
||||
--admin-border: rgba(15, 23, 42, 0.1);
|
||||
--admin-border-strong: rgba(15, 23, 42, 0.18);
|
||||
--admin-text: #0f172a;
|
||||
--admin-muted: #64748b;
|
||||
--admin-dim: #64748b;
|
||||
--admin-chart-stroke: #065f46;
|
||||
--admin-chart-fill: rgba(6, 95, 70, 0.22);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-spinner,
|
||||
.theme-key-light .brand-mark-spinner {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.theme-key-light .telegram-button-spinner {
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-light .btn-primary,
|
||||
.theme-key-light .admin-btn.admin-btn-primary,
|
||||
.theme-key-light .admin-extend-control .admin-btn.admin-btn-primary {
|
||||
background: color-mix(in srgb, var(--accent) 50%, #000000);
|
||||
border-color: color-mix(in srgb, var(--accent) 42%, #000000);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-light .btn-primary:hover:not(:disabled),
|
||||
.theme-key-light .admin-btn.admin-btn-primary:hover:not(:disabled),
|
||||
.theme-key-light .admin-extend-control .admin-btn.admin-btn-primary:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent) 52%, #000000);
|
||||
}
|
||||
|
||||
.theme-key-light.app-shell {
|
||||
background: var(--bg) !important;
|
||||
}
|
||||
|
||||
.theme-key-light .phone-screen {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* Flatten Settings rows: no gradient sheen, no inset highlight that reads as a 3D bevel */
|
||||
.theme-key-light .settings-row {
|
||||
background: var(--panel);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-key-light .settings-row-linked {
|
||||
background: var(--success-soft);
|
||||
}
|
||||
|
||||
/* Avatar/profile card: bigger lift, but rows below have an opaque background and
|
||||
stack above, so the shadow stays visually under them instead of bleeding through. */
|
||||
.theme-key-light .settings-profile {
|
||||
box-shadow:
|
||||
0 10px 24px rgba(15, 23, 42, 0.10),
|
||||
inset 0 1px 0 var(--inset-highlight);
|
||||
}
|
||||
|
||||
.theme-key-light .settings-links-block {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* New user-facing activation surfaces */
|
||||
.theme-key-light .trial-offer-card,
|
||||
.theme-key-light .trial-activation-card,
|
||||
.theme-key-light .activation-success-dialog {
|
||||
border-color: color-mix(in srgb, var(--accent) 24%, var(--border));
|
||||
background: #ffffff;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.theme-key-light .trial-card-head > svg,
|
||||
.theme-key-light .dialog-title-icon {
|
||||
color: color-mix(in srgb, var(--accent) 54%, #000000);
|
||||
}
|
||||
|
||||
.theme-key-light .trial-card-facts span,
|
||||
.theme-key-light .trial-activation-facts div {
|
||||
border-color: rgba(15, 23, 42, 0.12);
|
||||
background: rgba(15, 23, 42, 0.025);
|
||||
}
|
||||
|
||||
/* Slightly stronger axis/grid contrast for the revenue chart on a light surface */
|
||||
.theme-key-light .admin-revenue-svg-frame {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* Bonus section: drop accent color from body strongs; only the bonus-system heading
|
||||
and explicitly-accent card headings stay tinted — and they use the same darkened
|
||||
accent technique as .btn-primary on light, so they remain readable on white. */
|
||||
.theme-key-light .bonus-card strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.theme-key-light .bonus-card-head strong,
|
||||
.theme-key-light .card-heading-accent {
|
||||
color: color-mix(in srgb, var(--accent) 50%, #000000);
|
||||
}
|
||||
|
||||
.theme-key-light .bonus-card-head > svg {
|
||||
color: color-mix(in srgb, var(--accent) 50%, #000000);
|
||||
}
|
||||
|
||||
/* Install guide theme surfaces */
|
||||
|
||||
.theme-key-light .install-platform-trigger,
|
||||
.theme-key-light .install-app-button,
|
||||
.theme-key-light .install-step,
|
||||
.theme-key-light .install-subscription-card,
|
||||
.theme-key-light .install-qr-wrap {
|
||||
background: #ffffff;
|
||||
border-color: rgba(15, 23, 42, 0.12);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.055);
|
||||
}
|
||||
|
||||
.theme-key-light .install-app-button.active {
|
||||
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.theme-key-light .install-platform-trigger:focus-visible,
|
||||
.theme-key-light .install-platform-trigger[data-state="open"],
|
||||
.theme-key-light .install-app-button:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--accent) 48%, var(--border));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
|
||||
body:has(.theme-key-light) .install-platform-content {
|
||||
background: #ffffff;
|
||||
border-color: rgba(15, 23, 42, 0.14);
|
||||
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
body:has(.theme-key-light) .install-platform-item[data-highlighted],
|
||||
body:has(.theme-key-light) .install-platform-item[data-selected] {
|
||||
background: color-mix(in srgb, var(--accent) 9%, #ffffff);
|
||||
}
|
||||
|
||||
.theme-key-light .install-step-icon,
|
||||
.theme-key-light .install-subscription-header-icon {
|
||||
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
|
||||
color: color-mix(in srgb, var(--accent) 55%, #000000);
|
||||
}
|
||||
|
||||
.theme-key-light .install-qr-divider {
|
||||
color: rgba(15, 23, 42, 0.24);
|
||||
}
|
||||
|
||||
.theme-key-light .install-feature-star.attention-dot {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.theme-key-light .install-loading .ui-spinner {
|
||||
color: color-mix(in srgb, var(--accent) 55%, #000000);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"key": "light",
|
||||
"names": {
|
||||
"ru": "Светлая",
|
||||
"en": "Light"
|
||||
},
|
||||
"enabled": true,
|
||||
"default": false,
|
||||
"use_primary_accent": true,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 3,
|
||||
"tokens": {
|
||||
"color_scheme": "light"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 340 B |
|
After Width: | Height: | Size: 375 B |
|
After Width: | Height: | Size: 424 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 356 B |
|
After Width: | Height: | Size: 419 B |
|
After Width: | Height: | Size: 372 B |
|
After Width: | Height: | Size: 388 B |
|
After Width: | Height: | Size: 378 B |
|
After Width: | Height: | Size: 424 B |
|
After Width: | Height: | Size: 636 B |
|
After Width: | Height: | Size: 364 B |
|
After Width: | Height: | Size: 390 B |
|
After Width: | Height: | Size: 385 B |
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 356 B |
|
After Width: | Height: | Size: 393 B |
|
After Width: | Height: | Size: 474 B |
|
After Width: | Height: | Size: 395 B |
|
After Width: | Height: | Size: 461 B |
|
After Width: | Height: | Size: 327 B |
|
After Width: | Height: | Size: 411 B |
|
After Width: | Height: | Size: 395 B |
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 393 B |
|
After Width: | Height: | Size: 422 B |
|
After Width: | Height: | Size: 478 B |
|
After Width: | Height: | Size: 500 B |
|
After Width: | Height: | Size: 589 B |
|
After Width: | Height: | Size: 392 B |
|
After Width: | Height: | Size: 392 B |
|
After Width: | Height: | Size: 384 B |
|
After Width: | Height: | Size: 419 B |
|
After Width: | Height: | Size: 403 B |
|
After Width: | Height: | Size: 371 B |
|
After Width: | Height: | Size: 344 B |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"key": "windows95",
|
||||
"names": {
|
||||
"ru": "Windows 95",
|
||||
"en": "Windows 95"
|
||||
},
|
||||
"enabled": true,
|
||||
"default": false,
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 11,
|
||||
"tokens": {
|
||||
"color_scheme": "light",
|
||||
"style_preset": "win95"
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_applicati
|
||||
from aiohttp import web
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.payment_providers import iter_provider_specs, iter_service_keys
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
@@ -29,19 +30,15 @@ def _inject_shared_instances(
|
||||
app["settings"] = settings
|
||||
app["async_session_factory"] = async_session_factory
|
||||
app["i18n"] = dp.get("i18n_instance")
|
||||
for key in (
|
||||
"yookassa_service",
|
||||
"lknpd_service",
|
||||
shared_keys = [
|
||||
"subscription_service",
|
||||
"referral_service",
|
||||
"panel_service",
|
||||
"stars_service",
|
||||
"freekassa_service",
|
||||
"cryptopay_service",
|
||||
"panel_webhook_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
):
|
||||
"lknpd_service",
|
||||
*iter_service_keys(),
|
||||
]
|
||||
for key in shared_keys:
|
||||
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
|
||||
app[key] = dp.workflow_data[key] # type: ignore
|
||||
|
||||
@@ -56,9 +53,24 @@ async def build_and_start_web_app(
|
||||
_inject_shared_instances(app, dp, bot, settings, async_session_factory)
|
||||
|
||||
async def _healthcheck(request: web.Request) -> web.Response:
|
||||
return web.json_response({"status": "ok"})
|
||||
payload = {"status": "ok"}
|
||||
try:
|
||||
from db.database_setup import async_engine
|
||||
|
||||
pool = async_engine.pool if async_engine is not None else None
|
||||
if pool is not None:
|
||||
payload["db_pool"] = {
|
||||
"checked_in": pool.checkedin(),
|
||||
"checked_out": pool.checkedout(),
|
||||
"size": pool.size(),
|
||||
"overflow": pool.overflow(),
|
||||
}
|
||||
except Exception:
|
||||
logging.exception("Failed to collect DB pool health metrics")
|
||||
return web.json_response(payload)
|
||||
|
||||
app.router.add_get("/healthz", _healthcheck)
|
||||
app.router.add_get("/health", _healthcheck)
|
||||
|
||||
setup_application(app, dp, bot=bot)
|
||||
|
||||
@@ -75,38 +87,21 @@ async def build_and_start_web_app(
|
||||
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)" # noqa: E501
|
||||
)
|
||||
|
||||
from bot.handlers.user.payment import yookassa_webhook_route
|
||||
from bot.services.crypto_pay_service import cryptopay_webhook_route
|
||||
from bot.services.freekassa_service import freekassa_webhook_route
|
||||
from bot.services.panel_webhook_service import panel_webhook_route
|
||||
from bot.services.platega_service import platega_webhook_route
|
||||
from bot.services.severpay_service import severpay_webhook_route
|
||||
|
||||
cp_path = settings.cryptopay_webhook_path
|
||||
if cp_path.startswith("/"):
|
||||
app.router.add_post(cp_path, cryptopay_webhook_route)
|
||||
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
|
||||
|
||||
fk_path = settings.freekassa_webhook_path
|
||||
if fk_path.startswith("/"):
|
||||
app.router.add_post(fk_path, freekassa_webhook_route)
|
||||
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}")
|
||||
|
||||
pg_path = settings.platega_webhook_path
|
||||
if pg_path.startswith("/"):
|
||||
app.router.add_post(pg_path, platega_webhook_route)
|
||||
logging.info(f"Platega webhook route configured at: [POST] {pg_path}")
|
||||
|
||||
sp_path = settings.severpay_webhook_path
|
||||
if sp_path.startswith("/"):
|
||||
app.router.add_post(sp_path, severpay_webhook_route)
|
||||
logging.info(f"SeverPay webhook route configured at: [POST] {sp_path}")
|
||||
|
||||
# YooKassa webhook (register only when base URL present and path configured)
|
||||
yk_path = settings.yookassa_webhook_path
|
||||
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
|
||||
app.router.add_post(yk_path, yookassa_webhook_route)
|
||||
logging.info(f"YooKassa webhook route configured at: [POST] {yk_path}")
|
||||
registered_webhook_paths: set[str] = set()
|
||||
for spec in iter_provider_specs():
|
||||
webhook_route = spec.load_webhook_route()
|
||||
if not spec.webhook_path or not webhook_route:
|
||||
continue
|
||||
if spec.webhook_requires_base_url and not settings.WEBHOOK_BASE_URL:
|
||||
continue
|
||||
path = spec.webhook_path(settings)
|
||||
if not path or not path.startswith("/") or path in registered_webhook_paths:
|
||||
continue
|
||||
registered_webhook_paths.add(path)
|
||||
app.router.add_post(path, webhook_route)
|
||||
logging.info("%s webhook route configured at: [POST] %s", spec.label, path)
|
||||
|
||||
panel_path = settings.panel_webhook_path
|
||||
if panel_path.startswith("/"):
|
||||
@@ -2,6 +2,7 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import html
|
||||
import hmac
|
||||
import io
|
||||
import ipaddress
|
||||
@@ -17,10 +18,9 @@ from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.types import LabeledPrice
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, field_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -41,21 +41,21 @@ from bot.app.web.webapp_auth import (
|
||||
verify_telegram_oauth_nonce,
|
||||
verify_webapp_session_token,
|
||||
)
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.infra.redis import cache_delete, cache_get_json, cache_set_json, get_redis, redis_key
|
||||
from bot.services.email_auth_service import EmailAuthService, normalize_email
|
||||
from bot.services.email_templates import render_account_merged
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from bot.services.platega_service import PlategaService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.request_security import parse_ip_entries, request_client_ip
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||
from bot.utils.text_sanitizer import (
|
||||
panel_description_from_profile,
|
||||
sanitize_display_name,
|
||||
sanitize_username,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, subscription_dal, user_dal
|
||||
from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal
|
||||
from db.dal.user_dal import UserMergeConflictError
|
||||
from db.models import Payment, User, UserTelegramAvatar
|
||||
|
||||
@@ -63,9 +63,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html"
|
||||
ASSET_DIR = TEMPLATE_PATH.parent
|
||||
APP_DEEPLINK_TEMPLATE_PATH = ASSET_DIR / "open_app_gateway.html"
|
||||
APP_ROOT = Path(__file__).resolve().parents[5]
|
||||
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
|
||||
WEBAPP_LOGO_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo"
|
||||
WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-emoji"
|
||||
WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo"
|
||||
WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads"
|
||||
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
||||
WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons"
|
||||
WEBAPP_FAVICON_PATH = "/webapp-favicon"
|
||||
WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji"
|
||||
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
||||
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
||||
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->"
|
||||
@@ -76,6 +82,17 @@ WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
|
||||
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
|
||||
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
||||
WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024
|
||||
WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024
|
||||
WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024
|
||||
WEBAPP_THEME_ASSET_CONTENT_TYPES = {
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
WEBAPP_TELEGRAM_AVATAR_MAX_BYTES = 128 * 1024
|
||||
WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60
|
||||
WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4
|
||||
@@ -91,6 +108,7 @@ WEBAPP_CSRF_EXEMPT_PATHS = {
|
||||
"/api/auth/email/request",
|
||||
"/api/auth/email/verify",
|
||||
"/api/auth/email/magic",
|
||||
"/api/auth/email/password",
|
||||
"/api/auth/logout",
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
|
||||
from .auth import (
|
||||
_hash_email_password,
|
||||
_notify_account_merged,
|
||||
_sync_merged_panel_identity_for_user,
|
||||
)
|
||||
from .common import _invalidate_webapp_user_caches
|
||||
|
||||
|
||||
async def account_email_request_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
@@ -105,7 +113,8 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
current_user.email = email
|
||||
current_user.email_verified_at = datetime.now(timezone.utc)
|
||||
await _sync_panel_identity_for_user(request, current_user)
|
||||
if not merge_notice:
|
||||
await _sync_panel_identity_for_user(request, current_user)
|
||||
await session.commit()
|
||||
final_user_id = int(current_user.user_id)
|
||||
final_telegram_id = _telegram_id_for_user(current_user)
|
||||
@@ -118,28 +127,13 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
merge_end_date = (
|
||||
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||
)
|
||||
await _sync_panel_identity_for_user(
|
||||
await _sync_merged_panel_identity_for_user(
|
||||
request,
|
||||
current_user,
|
||||
source_panel_uuid=source_panel_uuid,
|
||||
final_panel_uuid=final_panel_uuid,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
# Best-effort cleanup of the removed panel account after the DB merge.
|
||||
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
|
||||
subscription_service: SubscriptionService = request.app.get(
|
||||
"subscription_service"
|
||||
)
|
||||
if subscription_service and subscription_service.panel_service:
|
||||
try:
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
|
||||
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||
if email_service and final_email:
|
||||
@@ -173,6 +167,17 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
logger.exception("Email account link failed")
|
||||
return _json_error(500, "link_failed", "Link failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
|
||||
if merge_notice:
|
||||
await _notify_account_merged(
|
||||
request,
|
||||
settings,
|
||||
merge_notice=merge_notice,
|
||||
email=final_email,
|
||||
telegram_id=final_telegram_id,
|
||||
username=final_username,
|
||||
first_name=final_first_name,
|
||||
)
|
||||
if should_notify_email_linked:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
@@ -201,6 +206,84 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
return _build_webapp_auth_response(settings, response_payload, token=token)
|
||||
|
||||
|
||||
async def account_password_request_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
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:
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
if not db_user.email or not db_user.email_verified_at:
|
||||
return _json_error(400, "email_not_linked", "Email is not linked")
|
||||
email = db_user.email
|
||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
|
||||
return await _request_email_code(
|
||||
request,
|
||||
email=email,
|
||||
purpose="set_password",
|
||||
language_code=lang,
|
||||
target_user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
async def account_password_confirm_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
payload = await _read_json(request)
|
||||
password_payload, validation_error = _validate_model_payload(WebAppSetPasswordPayload, payload)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
if password_payload.password != password_payload.password_confirm:
|
||||
return _json_error(400, "password_mismatch", "Passwords do not match")
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
email_service: EmailAuthService = request.app["email_auth_service"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
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")
|
||||
if not db_user.email or not db_user.email_verified_at:
|
||||
await session.rollback()
|
||||
return _json_error(400, "email_not_linked", "Email is not linked")
|
||||
|
||||
verify_result = await email_service.verify_code(
|
||||
session,
|
||||
email=db_user.email,
|
||||
purpose="set_password",
|
||||
code=str(password_payload.code or ""),
|
||||
target_user_id=user_id,
|
||||
)
|
||||
if not verify_result.ok:
|
||||
await session.commit()
|
||||
status = 429 if verify_result.error == "rate_limited" else 400
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": verify_result.error or "invalid_code",
|
||||
"retry_after": verify_result.retry_after,
|
||||
"message": "Invalid code",
|
||||
},
|
||||
status=status,
|
||||
)
|
||||
|
||||
db_user.password_hash = _hash_email_password(str(password_payload.password))
|
||||
db_user.password_set_at = datetime.now(timezone.utc)
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Email password setup failed")
|
||||
return _json_error(500, "password_setup_failed", "Password setup failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, user_id)
|
||||
return web.json_response({"ok": True, "password_auth_enabled": True})
|
||||
|
||||
|
||||
async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
@@ -262,28 +345,13 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
merge_end_date = (
|
||||
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||
)
|
||||
await _sync_panel_identity_for_user(
|
||||
await _sync_merged_panel_identity_for_user(
|
||||
request,
|
||||
db_user,
|
||||
source_panel_uuid=source_panel_uuid,
|
||||
final_panel_uuid=final_panel_uuid,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
# Best-effort cleanup of the removed panel account after the DB merge.
|
||||
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
|
||||
subscription_service: SubscriptionService = request.app.get(
|
||||
"subscription_service"
|
||||
)
|
||||
if subscription_service and subscription_service.panel_service:
|
||||
try:
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
|
||||
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||
if email_service and final_email:
|
||||
@@ -317,6 +385,17 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
logger.exception("Telegram account link failed")
|
||||
return _json_error(500, "link_failed", "Link failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
|
||||
if merge_notice:
|
||||
await _notify_account_merged(
|
||||
request,
|
||||
settings,
|
||||
merge_notice=merge_notice,
|
||||
email=final_email,
|
||||
telegram_id=final_telegram_id,
|
||||
username=final_username,
|
||||
first_name=final_first_name,
|
||||
)
|
||||
if should_notify_telegram_linked and final_telegram_id:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
@@ -350,7 +429,25 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
|
||||
async def me_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
data = await _build_user_payload(request, user_id)
|
||||
settings: Settings = request.app["settings"]
|
||||
fresh = str(request.query.get("fresh") or "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
if fresh:
|
||||
await _invalidate_webapp_user_caches(settings, user_id)
|
||||
data = await _build_user_payload(request, user_id)
|
||||
return web.json_response({"ok": True, **data})
|
||||
|
||||
data = await webapp_cached_user_payload(
|
||||
settings,
|
||||
"me",
|
||||
user_id,
|
||||
int(getattr(settings, "WEBAPP_ME_CACHE_TTL_SECONDS", 15) or 0),
|
||||
lambda: _build_user_payload(request, user_id),
|
||||
)
|
||||
return web.json_response({"ok": True, **data})
|
||||
|
||||
|
||||
@@ -391,6 +488,11 @@ async def account_language_route(request: web.Request) -> web.Response:
|
||||
return validation_error
|
||||
|
||||
language = _normalize_language(str(language_payload.language or ""))
|
||||
i18n = request.app.get("i18n")
|
||||
if i18n and hasattr(i18n, "reload_overrides_from_file"):
|
||||
i18n.reload_overrides_from_file()
|
||||
if i18n and language not in getattr(i18n, "locales_data", {}):
|
||||
return _json_error(400, "unsupported_language", "Unsupported language")
|
||||
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)
|
||||
@@ -403,6 +505,7 @@ async def account_language_route(request: web.Request) -> web.Response:
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, user_id)
|
||||
return web.json_response({"ok": True, "language": language})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .guides import warm_subscription_guides_config
|
||||
|
||||
|
||||
def create_subscription_webapp_application(
|
||||
@@ -20,10 +21,12 @@ def create_subscription_webapp_application(
|
||||
app["settings"] = settings
|
||||
app["async_session_factory"] = async_session_factory
|
||||
app["i18n"] = dp.get("i18n_instance")
|
||||
app["email_auth_service"] = EmailAuthService(settings)
|
||||
app["email_auth_service"] = EmailAuthService(settings, app["i18n"])
|
||||
app["webapp_logo_cache"] = None
|
||||
app["webapp_logo_cache_lock"] = asyncio.Lock()
|
||||
app["webapp_settings_cache"] = {"ts": 0.0, "data": {}}
|
||||
app["subscription_guides_config_cache"] = {"fingerprint": None, "status": None}
|
||||
app["subscription_guides_config_lock"] = asyncio.Lock()
|
||||
app["webapp_rate_limit_buckets"] = {}
|
||||
app["webapp_rate_limit_lock"] = asyncio.Lock()
|
||||
|
||||
@@ -31,6 +34,7 @@ def create_subscription_webapp_application(
|
||||
await _ensure_shared_http_session()
|
||||
await _warm_webapp_logo_cache(app_obj)
|
||||
await _warm_webapp_animated_emoji_cache(app_obj)
|
||||
await warm_subscription_guides_config(app_obj)
|
||||
|
||||
async def _shutdown(app_obj: web.Application) -> None:
|
||||
await _close_shared_http_session()
|
||||
@@ -38,22 +42,22 @@ def create_subscription_webapp_application(
|
||||
app.on_startup.append(_startup)
|
||||
app.on_shutdown.append(_shutdown)
|
||||
|
||||
from bot.payment_providers import iter_service_keys
|
||||
|
||||
for key in (
|
||||
"subscription_service",
|
||||
"yookassa_service",
|
||||
"freekassa_service",
|
||||
"cryptopay_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
"promo_code_service",
|
||||
"referral_service",
|
||||
"support_service",
|
||||
"notification_service",
|
||||
"email_auth_service",
|
||||
"panel_service",
|
||||
*iter_service_keys(),
|
||||
):
|
||||
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined]
|
||||
app[key] = dp.workflow_data[key] # type: ignore[index]
|
||||
|
||||
# type: ignore[attr-defined]
|
||||
if hasattr(dp, "workflow_data") and "bot_username" in dp.workflow_data:
|
||||
if hasattr(dp, "workflow_data") and "bot_username" in dp.workflow_data: # type: ignore[attr-defined]
|
||||
app["bot_username"] = dp.workflow_data["bot_username"] # type: ignore[index]
|
||||
|
||||
setup_subscription_webapp_routes(app)
|
||||
@@ -1,5 +1,6 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .common import _invalidate_webapp_user_caches
|
||||
|
||||
|
||||
def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]:
|
||||
@@ -132,6 +133,58 @@ def _urlsafe_sha256(value: str) -> str:
|
||||
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
PASSWORD_HASH_ALGORITHM = "pbkdf2_sha256"
|
||||
PASSWORD_HASH_ITERATIONS = 260_000
|
||||
|
||||
|
||||
def _password_hash_b64(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _password_hash_unb64(value: str) -> bytes:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode((value + padding).encode("ascii"))
|
||||
|
||||
|
||||
def _hash_email_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(18)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt,
|
||||
PASSWORD_HASH_ITERATIONS,
|
||||
)
|
||||
return "$".join(
|
||||
[
|
||||
PASSWORD_HASH_ALGORITHM,
|
||||
str(PASSWORD_HASH_ITERATIONS),
|
||||
_password_hash_b64(salt),
|
||||
_password_hash_b64(digest),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _verify_email_password(password: str, stored_hash: Optional[str]) -> bool:
|
||||
if not stored_hash:
|
||||
return False
|
||||
try:
|
||||
algorithm, iterations_raw, salt_raw, digest_raw = stored_hash.split("$", 3)
|
||||
if algorithm != PASSWORD_HASH_ALGORITHM:
|
||||
return False
|
||||
iterations = int(iterations_raw)
|
||||
salt = _password_hash_unb64(salt_raw)
|
||||
expected_digest = _password_hash_unb64(digest_raw)
|
||||
actual_digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt,
|
||||
iterations,
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
return hmac.compare_digest(actual_digest, expected_digest)
|
||||
|
||||
|
||||
async def _exchange_telegram_oauth_code(
|
||||
request: web.Request,
|
||||
*,
|
||||
@@ -286,10 +339,20 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
||||
redirect_path = "/settings" if purpose == "link" else "/"
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
final_user_id: Optional[int] = None
|
||||
source_user_id_for_cache: Optional[int] = None
|
||||
linked_user_for_panel: Optional[User] = None
|
||||
link_source_panel_uuid: Optional[str] = None
|
||||
link_final_panel_uuid: Optional[str] = None
|
||||
link_merge_notice: Optional[Dict[str, Any]] = None
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
if purpose == "link":
|
||||
current_user_id = int(state.get("user_id") or 0)
|
||||
source_user_id_for_cache = current_user_id
|
||||
current_user_before_link = await user_dal.get_user_by_id(session, current_user_id)
|
||||
link_source_panel_uuid = (
|
||||
current_user_before_link.panel_user_uuid if current_user_before_link else None
|
||||
)
|
||||
db_user = await _link_telegram_to_user(
|
||||
request,
|
||||
session,
|
||||
@@ -297,6 +360,16 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
||||
telegram_user=telegram_user,
|
||||
settings=settings,
|
||||
)
|
||||
if int(db_user.user_id) != current_user_id:
|
||||
link_final_panel_uuid = db_user.panel_user_uuid
|
||||
link_merge_notice = await _build_account_merge_notice(
|
||||
session,
|
||||
merged_user=db_user,
|
||||
source_user_id=current_user_id,
|
||||
source_panel_uuid=link_source_panel_uuid,
|
||||
settings=settings,
|
||||
)
|
||||
linked_user_for_panel = db_user
|
||||
else:
|
||||
db_user = await _ensure_user_from_telegram(
|
||||
session,
|
||||
@@ -334,6 +407,35 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
||||
logger.exception("Telegram OAuth callback failed")
|
||||
raise redirect(redirect_path, "failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, final_user_id, include_devices=True)
|
||||
if source_user_id_for_cache and source_user_id_for_cache != final_user_id:
|
||||
await _invalidate_webapp_user_caches(
|
||||
settings,
|
||||
source_user_id_for_cache,
|
||||
final_user_id,
|
||||
include_devices=True,
|
||||
)
|
||||
|
||||
if purpose == "link" and link_merge_notice and linked_user_for_panel:
|
||||
merge_end_date_raw = link_merge_notice.get("final_end_date")
|
||||
merge_end_date = datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||
await _sync_merged_panel_identity_for_user(
|
||||
request,
|
||||
linked_user_for_panel,
|
||||
source_panel_uuid=link_source_panel_uuid,
|
||||
final_panel_uuid=link_final_panel_uuid,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
await _notify_account_merged(
|
||||
request,
|
||||
settings,
|
||||
merge_notice=link_merge_notice,
|
||||
email=linked_user_for_panel.email,
|
||||
telegram_id=_telegram_id_for_user(linked_user_for_panel),
|
||||
username=linked_user_for_panel.username,
|
||||
first_name=linked_user_for_panel.first_name,
|
||||
)
|
||||
|
||||
token = create_webapp_session_token(settings, int(final_user_id))
|
||||
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
|
||||
_clear_telegram_oauth_state_cookie(response)
|
||||
@@ -428,6 +530,7 @@ async def auth_token_route(request: web.Request) -> web.Response:
|
||||
logger.exception("WebApp auth failed")
|
||||
return _json_error(500, "auth_failed", "Auth failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, authenticated_user_id, include_devices=True)
|
||||
token = create_webapp_session_token(settings, int(authenticated_user_id))
|
||||
return _build_webapp_auth_response(settings, {"ok": True}, token=token)
|
||||
|
||||
@@ -438,6 +541,114 @@ async def logout_route(request: web.Request) -> web.Response:
|
||||
return response
|
||||
|
||||
|
||||
def _password_login_failure_response(
|
||||
*,
|
||||
status: int = 401,
|
||||
retry_after: Optional[int] = None,
|
||||
) -> web.Response:
|
||||
payload: Dict[str, Any] = {
|
||||
"ok": False,
|
||||
"error": "password_login_failed",
|
||||
"fallback": "email_code",
|
||||
"message": "Password login failed",
|
||||
}
|
||||
if retry_after is not None:
|
||||
payload["retry_after"] = retry_after
|
||||
return web.json_response(payload, status=status)
|
||||
|
||||
|
||||
async def email_password_auth_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
if not settings.email_auth_configured:
|
||||
return _json_error(503, "email_auth_not_configured", "Email auth is not configured")
|
||||
|
||||
payload = await _read_json(request)
|
||||
password_payload, validation_error = _validate_model_payload(
|
||||
WebAppEmailPasswordPayload,
|
||||
payload,
|
||||
)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
|
||||
email = password_payload.email
|
||||
password = str(password_payload.password or "")
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
authenticated_user_id: Optional[int] = None
|
||||
authenticated_telegram_id: Optional[int] = None
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
throttle = await security_dal.check_throttle(
|
||||
session,
|
||||
scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE,
|
||||
identifier=email,
|
||||
now=now,
|
||||
)
|
||||
if throttle.locked:
|
||||
await session.commit()
|
||||
return _json_error(
|
||||
429,
|
||||
"rate_limited",
|
||||
"Too many password attempts",
|
||||
)
|
||||
|
||||
db_user = await user_dal.get_user_by_email(session, email)
|
||||
password_ok = bool(
|
||||
db_user
|
||||
and db_user.email_verified_at
|
||||
and db_user.password_hash
|
||||
and _verify_email_password(password, db_user.password_hash)
|
||||
)
|
||||
|
||||
if not password_ok:
|
||||
throttle_result = await security_dal.record_throttle_failure(
|
||||
session,
|
||||
scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE,
|
||||
identifier=email,
|
||||
max_failures=settings.BRUTE_FORCE_MAX_FAILURES,
|
||||
window_seconds=settings.BRUTE_FORCE_WINDOW_SECONDS,
|
||||
lock_seconds=settings.BRUTE_FORCE_LOCK_SECONDS,
|
||||
now=now,
|
||||
)
|
||||
await session.commit()
|
||||
if throttle_result.locked:
|
||||
return _json_error(
|
||||
429,
|
||||
"rate_limited",
|
||||
"Too many password attempts",
|
||||
)
|
||||
return _password_login_failure_response()
|
||||
|
||||
if db_user.is_banned:
|
||||
await session.rollback()
|
||||
return _json_error(403, "banned", "Access denied")
|
||||
|
||||
await security_dal.clear_throttle_state(
|
||||
session,
|
||||
scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE,
|
||||
identifier=email,
|
||||
)
|
||||
authenticated_user_id = int(db_user.user_id)
|
||||
authenticated_telegram_id = _telegram_id_for_user(db_user)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Email password auth failed")
|
||||
return _json_error(500, "auth_failed", "Auth failed")
|
||||
|
||||
token = create_webapp_session_token(settings, int(authenticated_user_id))
|
||||
return _build_webapp_auth_response(
|
||||
settings,
|
||||
{
|
||||
"ok": True,
|
||||
"user_id": int(authenticated_user_id),
|
||||
"telegram_id": authenticated_telegram_id,
|
||||
},
|
||||
token=token,
|
||||
)
|
||||
|
||||
|
||||
async def email_auth_request_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
@@ -534,6 +745,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
|
||||
logger.exception("Email WebApp auth failed")
|
||||
return _json_error(500, "auth_failed", "Auth failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, int(db_user.user_id), include_devices=True)
|
||||
if created_user:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
@@ -641,6 +853,7 @@ async def email_auth_magic_route(request: web.Request) -> web.Response:
|
||||
logger.exception("Email magic-link auth failed")
|
||||
return _json_error(500, "auth_failed", "Auth failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, int(db_user.user_id), include_devices=True)
|
||||
if created_user and verified_email:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
@@ -800,13 +1013,19 @@ def _telegram_id_for_user(user: User) -> Optional[int]:
|
||||
|
||||
|
||||
def _panel_description_for_user(user: User) -> str:
|
||||
lines = [
|
||||
user.email or "",
|
||||
user.username or "",
|
||||
user.first_name or "",
|
||||
user.last_name or "",
|
||||
]
|
||||
return "\n".join(line for line in lines if line).strip()
|
||||
return panel_description_from_profile(
|
||||
user.username,
|
||||
user.first_name,
|
||||
user.last_name,
|
||||
)
|
||||
|
||||
|
||||
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
|
||||
raw_value = telegram_user.get("photo_url")
|
||||
if not raw_value:
|
||||
return None
|
||||
value = str(raw_value).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
async def _sync_panel_identity_for_user(
|
||||
@@ -821,23 +1040,33 @@ async def _sync_panel_identity_for_user(
|
||||
if not subscription_service or not subscription_service.panel_service:
|
||||
return False
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"description": _panel_description_for_user(user),
|
||||
}
|
||||
payload: Dict[str, Any] = {}
|
||||
telegram_id = _telegram_id_for_user(user)
|
||||
if telegram_id:
|
||||
payload["telegramId"] = telegram_id
|
||||
if user.email:
|
||||
payload["email"] = user.email
|
||||
if expire_at is not None:
|
||||
if expire_at.tzinfo is None:
|
||||
expire_at = expire_at.replace(tzinfo=timezone.utc)
|
||||
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
if expire_at > datetime.now(timezone.utc):
|
||||
payload["status"] = "ACTIVE"
|
||||
|
||||
try:
|
||||
await subscription_service.panel_service.update_user_details_on_panel(
|
||||
updated_panel_user = await subscription_service.panel_service.update_user_details_on_panel(
|
||||
user.panel_user_uuid,
|
||||
payload,
|
||||
log_response=False,
|
||||
)
|
||||
if not updated_panel_user or (
|
||||
isinstance(updated_panel_user, dict) and updated_panel_user.get("error")
|
||||
):
|
||||
logger.warning(
|
||||
"Panel identity update returned no success payload for user %s",
|
||||
user.user_id,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -848,6 +1077,53 @@ async def _sync_panel_identity_for_user(
|
||||
return False
|
||||
|
||||
|
||||
async def _delete_merged_source_panel_user(
|
||||
request: web.Request,
|
||||
*,
|
||||
source_panel_uuid: Optional[str],
|
||||
final_panel_uuid: Optional[str],
|
||||
) -> bool:
|
||||
if not source_panel_uuid or not final_panel_uuid or source_panel_uuid == final_panel_uuid:
|
||||
return True
|
||||
|
||||
subscription_service: SubscriptionService = request.app.get("subscription_service")
|
||||
if not subscription_service or not subscription_service.panel_service:
|
||||
return False
|
||||
|
||||
try:
|
||||
return bool(
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def _sync_merged_panel_identity_for_user(
|
||||
request: web.Request,
|
||||
user: User,
|
||||
*,
|
||||
source_panel_uuid: Optional[str],
|
||||
final_panel_uuid: Optional[str],
|
||||
expire_at: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
# Remnawave keeps email/telegramId unique. Remove the losing panel identity
|
||||
# before patching the surviving one so merged accounts can accept both IDs.
|
||||
await _delete_merged_source_panel_user(
|
||||
request,
|
||||
source_panel_uuid=source_panel_uuid,
|
||||
final_panel_uuid=final_panel_uuid or user.panel_user_uuid,
|
||||
)
|
||||
return await _sync_panel_identity_for_user(request, user, expire_at=expire_at)
|
||||
|
||||
|
||||
async def _build_account_merge_notice(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
@@ -885,16 +1161,50 @@ async def _build_account_merge_notice(
|
||||
}
|
||||
|
||||
|
||||
async def _notify_account_merged(
|
||||
request: web.Request,
|
||||
settings: Settings,
|
||||
*,
|
||||
merge_notice: Optional[Dict[str, Any]],
|
||||
email: Optional[str],
|
||||
telegram_id: Optional[int],
|
||||
username: Optional[str],
|
||||
first_name: Optional[str],
|
||||
) -> None:
|
||||
if not merge_notice:
|
||||
return
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
|
||||
bot: Bot = request.app["bot"]
|
||||
notification_service = NotificationService(
|
||||
bot,
|
||||
settings,
|
||||
request.app.get("i18n"),
|
||||
)
|
||||
await notification_service.notify_account_merged(
|
||||
primary_user_id=int(merge_notice.get("primary_user_id") or 0),
|
||||
removed_user_id=int(merge_notice.get("removed_user_id") or 0),
|
||||
email=email,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
final_end_date_text=str(merge_notice.get("final_end_date_text") or ""),
|
||||
primary_panel_user_uuid=merge_notice.get("primary_panel_user_uuid"),
|
||||
removed_panel_user_uuid=merge_notice.get("removed_panel_user_uuid"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send account merged notification")
|
||||
|
||||
|
||||
def _apply_telegram_profile_to_user(
|
||||
user: User,
|
||||
telegram_user: Dict[str, Any],
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
language_code = (
|
||||
language_code = _normalize_language(
|
||||
telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
if language_code not in {"ru", "en"}:
|
||||
language_code = user.language_code or settings.DEFAULT_LANGUAGE
|
||||
|
||||
user.telegram_id = int(telegram_user["id"])
|
||||
user.username = sanitize_username(telegram_user.get("username"))
|
||||
@@ -937,17 +1247,14 @@ async def _link_telegram_to_user(
|
||||
)
|
||||
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
|
||||
await session.flush()
|
||||
await _sync_panel_identity_for_user(request, merged_user)
|
||||
return merged_user
|
||||
|
||||
if not existing_telegram_user and int(current_user.user_id) < 0:
|
||||
language_code = (
|
||||
language_code = _normalize_language(
|
||||
telegram_user.get("language_code")
|
||||
or current_user.language_code
|
||||
or settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
if language_code not in {"ru", "en"}:
|
||||
language_code = current_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
target_user, _ = await user_dal.create_user(
|
||||
session,
|
||||
{
|
||||
@@ -969,7 +1276,6 @@ async def _link_telegram_to_user(
|
||||
)
|
||||
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
|
||||
await session.flush()
|
||||
await _sync_panel_identity_for_user(request, merged_user)
|
||||
return merged_user
|
||||
|
||||
if current_user.telegram_id and int(current_user.telegram_id) != telegram_id:
|
||||
@@ -1094,9 +1400,9 @@ async def _ensure_user_from_telegram(
|
||||
referral_param: Optional[str] = None,
|
||||
) -> User:
|
||||
user_id = int(telegram_user["id"])
|
||||
language_code = telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
||||
if language_code not in {"ru", "en"}:
|
||||
language_code = settings.DEFAULT_LANGUAGE
|
||||
language_code = _normalize_language(
|
||||
telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
update_data = {
|
||||
"telegram_id": user_id,
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
|
||||
from bot.infra.redis import cache_delete, cache_delete_pattern, redis_key
|
||||
from bot.utils.ttl_cache import AsyncTTLCache
|
||||
from config.settings import Settings
|
||||
|
||||
_WEBAPP_USER_PAYLOAD_CACHES: dict[tuple[int, str, int], AsyncTTLCache] = {}
|
||||
|
||||
|
||||
def reset_webapp_settings_cache(app: Any) -> None:
|
||||
cache = app.get("webapp_settings_cache") if hasattr(app, "get") else None
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
|
||||
|
||||
def reset_subscription_guides_cache(app: Any) -> None:
|
||||
cache = app.get("subscription_guides_config_cache") if hasattr(app, "get") else None
|
||||
if isinstance(cache, dict):
|
||||
cache["fingerprint"] = None
|
||||
cache["status"] = None
|
||||
|
||||
|
||||
def _payload_namespaces(include_devices: bool = False) -> tuple[str, ...]:
|
||||
return ("me", "devices") if include_devices else ("me",)
|
||||
|
||||
|
||||
def _webapp_user_payload_cache(
|
||||
settings: Settings,
|
||||
namespace: str,
|
||||
ttl_seconds: int,
|
||||
) -> Optional[AsyncTTLCache]:
|
||||
ttl = max(0, int(ttl_seconds or 0))
|
||||
if ttl <= 0:
|
||||
return None
|
||||
cache_key = (id(settings), namespace, ttl)
|
||||
cache = _WEBAPP_USER_PAYLOAD_CACHES.get(cache_key)
|
||||
if cache is None:
|
||||
cache = AsyncTTLCache(
|
||||
ttl_seconds=ttl,
|
||||
settings=settings,
|
||||
namespace=f"webapp:{namespace}",
|
||||
)
|
||||
_WEBAPP_USER_PAYLOAD_CACHES[cache_key] = cache
|
||||
return cache
|
||||
|
||||
|
||||
async def webapp_cached_user_payload(
|
||||
settings: Settings,
|
||||
namespace: str,
|
||||
user_id: int,
|
||||
ttl_seconds: int,
|
||||
loader: Callable[[], Awaitable[Any]],
|
||||
) -> Any:
|
||||
cache = _webapp_user_payload_cache(settings, namespace, ttl_seconds)
|
||||
if cache is None:
|
||||
return await loader()
|
||||
return await cache.get_or_load(str(int(user_id)), loader)
|
||||
|
||||
|
||||
def invalidate_local_webapp_user_payload(
|
||||
settings: Settings,
|
||||
namespace: str,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
key = str(int(user_id))
|
||||
for (settings_id, cache_namespace, _ttl), cache in tuple(_WEBAPP_USER_PAYLOAD_CACHES.items()):
|
||||
if settings_id == id(settings) and cache_namespace == namespace:
|
||||
cache.invalidate(key)
|
||||
|
||||
|
||||
def invalidate_all_local_webapp_user_payloads(
|
||||
settings: Settings,
|
||||
namespace: Optional[str] = None,
|
||||
*,
|
||||
include_devices: Optional[bool] = None,
|
||||
) -> None:
|
||||
if include_devices is not None:
|
||||
namespaces: Optional[set[str]] = set(_payload_namespaces(include_devices))
|
||||
elif namespace is not None:
|
||||
namespaces = {namespace}
|
||||
else:
|
||||
namespaces = None
|
||||
|
||||
for (settings_id, cache_namespace, _ttl), cache in tuple(_WEBAPP_USER_PAYLOAD_CACHES.items()):
|
||||
if settings_id != id(settings):
|
||||
continue
|
||||
if namespaces is not None and cache_namespace not in namespaces:
|
||||
continue
|
||||
cache.invalidate()
|
||||
|
||||
|
||||
async def invalidate_webapp_user_caches(
|
||||
settings: Settings,
|
||||
*user_ids: Optional[int],
|
||||
include_devices: bool = False,
|
||||
) -> None:
|
||||
keys: list[str] = []
|
||||
seen: set[int] = set()
|
||||
for raw_user_id in user_ids:
|
||||
if raw_user_id is None:
|
||||
continue
|
||||
try:
|
||||
user_id = int(raw_user_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if user_id in seen:
|
||||
continue
|
||||
seen.add(user_id)
|
||||
keys.append(redis_key(settings, "cache", "webapp", "me", user_id))
|
||||
invalidate_local_webapp_user_payload(settings, "me", user_id)
|
||||
if include_devices:
|
||||
keys.append(redis_key(settings, "cache", "webapp", "devices", user_id))
|
||||
invalidate_local_webapp_user_payload(settings, "devices", user_id)
|
||||
if keys:
|
||||
await cache_delete(settings, *keys)
|
||||
|
||||
|
||||
async def invalidate_all_webapp_user_payloads(
|
||||
settings: Settings,
|
||||
*,
|
||||
include_devices: bool = False,
|
||||
) -> None:
|
||||
for namespace in _payload_namespaces(include_devices):
|
||||
invalidate_all_local_webapp_user_payloads(settings, namespace=namespace)
|
||||
try:
|
||||
pattern = redis_key(settings, "cache", "webapp", namespace, "*")
|
||||
await cache_delete_pattern(settings, pattern)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
async def invalidate_all_webapp_user_caches(
|
||||
settings: Settings,
|
||||
*,
|
||||
include_devices: bool = False,
|
||||
) -> None:
|
||||
await invalidate_all_webapp_user_payloads(settings, include_devices=include_devices)
|
||||
@@ -1,6 +1,14 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import (
|
||||
invalidate_webapp_user_caches as _invalidate_user_payload_caches,
|
||||
)
|
||||
from bot.middlewares.i18n import (
|
||||
is_valid_locale_language_code,
|
||||
normalize_locale_language_code,
|
||||
)
|
||||
|
||||
|
||||
async def _read_json(request: web.Request) -> Dict[str, Any]:
|
||||
try:
|
||||
@@ -17,6 +25,14 @@ def _json_error(status: int, code: str, message: str) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
async def _invalidate_webapp_user_caches(
|
||||
settings: Settings,
|
||||
*user_ids: Optional[int],
|
||||
include_devices: bool = False,
|
||||
) -> None:
|
||||
await _invalidate_user_payload_caches(settings, *user_ids, include_devices=include_devices)
|
||||
|
||||
|
||||
def _validation_error_response(exc: ValidationError) -> web.Response:
|
||||
for error in exc.errors():
|
||||
loc = error.get("loc") or ()
|
||||
@@ -37,6 +53,13 @@ def _validation_error_response(exc: ValidationError) -> web.Response:
|
||||
if field in {"description", "comment", "note"} and error_type == "string_too_long":
|
||||
return _json_error(400, f"{field}_too_long", f"{field.capitalize()} is too long")
|
||||
|
||||
if field in {"password", "password_confirm"}:
|
||||
if error_type == "string_too_short":
|
||||
return _json_error(400, "password_too_short", "Password is too short")
|
||||
if error_type == "string_too_long":
|
||||
return _json_error(400, "password_too_long", "Password is too long")
|
||||
return _json_error(400, "invalid_password", "Invalid password")
|
||||
|
||||
if error_type == "string_too_long":
|
||||
return _json_error(400, "text_too_long", "Text is too long")
|
||||
|
||||
@@ -54,8 +77,8 @@ def _validate_model_payload(
|
||||
|
||||
|
||||
def _normalize_language(lang: Optional[str]) -> str:
|
||||
value = (lang or "ru").split("-")[0].lower()
|
||||
return value if value in {"ru", "en"} else "ru"
|
||||
value = normalize_locale_language_code(lang, prefer_known_base=False)
|
||||
return value if is_valid_locale_language_code(value) else "ru"
|
||||
|
||||
|
||||
def _format_remaining(seconds: int, lang: str) -> str:
|
||||
@@ -1,6 +1,8 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
|
||||
|
||||
|
||||
async def devices_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
@@ -15,26 +17,70 @@ async def devices_route(request: web.Request) -> web.Response:
|
||||
if not db_user or db_user.is_banned:
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
panel_user_uuid = active.get("user_id") if active else None
|
||||
if not panel_user_uuid:
|
||||
return _json_error(400, "subscription_not_active", "Subscription is not active")
|
||||
result = await webapp_cached_user_payload(
|
||||
settings,
|
||||
"devices",
|
||||
user_id,
|
||||
int(getattr(settings, "WEBAPP_DEVICES_CACHE_TTL_SECONDS", 5) or 0),
|
||||
lambda: _load_devices_payload(subscription_service, session, user_id),
|
||||
)
|
||||
if isinstance(result, dict) and result.get("ok") is True:
|
||||
return web.json_response({"ok": True, **(result.get("payload") or {})})
|
||||
if isinstance(result, dict) and not result.get("error"):
|
||||
# Backward-compatible with payloads written by older versions under
|
||||
# the same Redis cache key.
|
||||
return web.json_response({"ok": True, **result})
|
||||
if not isinstance(result, dict):
|
||||
result = {}
|
||||
if not result.get("ok"):
|
||||
return _json_error(
|
||||
int(result.get("status") or 500),
|
||||
str(result.get("error") or "devices_load_failed"),
|
||||
str(result.get("message") or "Failed to load devices"),
|
||||
)
|
||||
return web.json_response({"ok": True, **(result.get("payload") or {})})
|
||||
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
if not panel_service:
|
||||
return _json_error(503, "panel_unavailable", "Panel service unavailable")
|
||||
|
||||
try:
|
||||
devices_response = await panel_service.get_user_devices(panel_user_uuid)
|
||||
except Exception:
|
||||
logger.exception("Failed to load WebApp devices for user %s", user_id)
|
||||
return _json_error(502, "devices_load_failed", "Failed to load devices")
|
||||
async def _load_devices_payload(
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Dict[str, Any]:
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
panel_user_uuid = active.get("user_id") if active else None
|
||||
if not panel_user_uuid:
|
||||
return {
|
||||
"ok": False,
|
||||
"status": 400,
|
||||
"error": "subscription_not_active",
|
||||
"message": "Subscription is not active",
|
||||
}
|
||||
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
if not panel_service:
|
||||
return {
|
||||
"ok": False,
|
||||
"status": 503,
|
||||
"error": "panel_unavailable",
|
||||
"message": "Panel service unavailable",
|
||||
}
|
||||
|
||||
try:
|
||||
devices_response = await panel_service.get_user_devices(panel_user_uuid)
|
||||
except Exception:
|
||||
logger.exception("Failed to load WebApp devices for user %s", user_id)
|
||||
return {
|
||||
"ok": False,
|
||||
"status": 502,
|
||||
"error": "devices_load_failed",
|
||||
"message": "Failed to load devices",
|
||||
}
|
||||
|
||||
devices = _normalize_devices_response(devices_response)
|
||||
max_devices = _coerce_int_or_none(active.get("max_devices")) if active else None
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
return {
|
||||
"ok": True,
|
||||
"payload": {
|
||||
"enabled": True,
|
||||
"current_devices": len(devices),
|
||||
"max_devices": max_devices,
|
||||
@@ -42,8 +88,8 @@ async def devices_route(request: web.Request) -> web.Response:
|
||||
"devices": [
|
||||
_serialize_device(device, index) for index, device in enumerate(devices, start=1)
|
||||
],
|
||||
}
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def disconnect_device_route(request: web.Request) -> web.Response:
|
||||
@@ -103,6 +149,7 @@ async def disconnect_device_route(request: web.Request) -> web.Response:
|
||||
success = await panel_service.disconnect_device(panel_user_uuid, target_hwid)
|
||||
if not success:
|
||||
return _json_error(502, "device_disconnect_failed", "Failed to disconnect device")
|
||||
await cache_delete(settings, redis_key(settings, "cache", "webapp", "devices", user_id))
|
||||
await session.commit()
|
||||
|
||||
return web.json_response({"ok": True})
|
||||
@@ -0,0 +1,287 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from config.subscription_guides_config import (
|
||||
SubscriptionGuidesConfigError,
|
||||
subscription_guides_status,
|
||||
validate_panel_subscription_guides_config,
|
||||
)
|
||||
|
||||
PANEL_DEFAULT_SUBPAGE_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"
|
||||
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS = 30
|
||||
|
||||
|
||||
async def warm_subscription_guides_config(app: web.Application) -> None:
|
||||
try:
|
||||
await _subscription_guides_status_shared(app)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to warm subscription guides config: %s", exc)
|
||||
|
||||
|
||||
async def subscription_guides_route(request: web.Request) -> web.Response:
|
||||
_require_user_id(request)
|
||||
status = await _subscription_guides_status_shared(request.app)
|
||||
payload = {
|
||||
"enabled": bool(status.get("enabled")),
|
||||
"config": status.get("config") if status.get("enabled") else None,
|
||||
"source": status.get("source"),
|
||||
}
|
||||
if status.get("error"):
|
||||
payload["error"] = status["error"]
|
||||
return web.json_response({"ok": True, **payload})
|
||||
|
||||
|
||||
async def public_subscription_guides_route(request: web.Request) -> web.Response:
|
||||
share_token = subscription_dal.normalize_install_share_token(
|
||||
request.match_info.get("share_token")
|
||||
)
|
||||
if not share_token:
|
||||
return web.json_response({"ok": False, "error": "invalid_share_token"}, status=404)
|
||||
|
||||
subscription = await _public_subscription_payload(request, share_token)
|
||||
if not subscription.get("active"):
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"enabled": False,
|
||||
"config": None,
|
||||
"source": None,
|
||||
"subscription": subscription,
|
||||
"error": "subscription_unavailable",
|
||||
},
|
||||
status=404,
|
||||
)
|
||||
|
||||
status = await _subscription_guides_status_shared(request.app)
|
||||
payload = {
|
||||
"enabled": bool(status.get("enabled")),
|
||||
"config": status.get("config") if status.get("enabled") else None,
|
||||
"source": status.get("source"),
|
||||
"subscription": subscription,
|
||||
}
|
||||
if status.get("error"):
|
||||
payload["error"] = status["error"]
|
||||
return web.json_response({"ok": True, **payload})
|
||||
|
||||
|
||||
async def _subscription_guides_status_shared(app: web.Application) -> Dict[str, Any]:
|
||||
settings: Settings = app["settings"]
|
||||
cache = app.setdefault("subscription_guides_config_cache", {})
|
||||
lock: asyncio.Lock = app.setdefault("subscription_guides_config_lock", asyncio.Lock())
|
||||
fingerprint = _subscription_guides_settings_fingerprint(settings)
|
||||
now = time.monotonic()
|
||||
|
||||
cached = cache.get("status")
|
||||
if cached is not None and cache.get("fingerprint") == fingerprint:
|
||||
if cached.get("enabled") or now - float(cache.get("ts", 0.0)) < (
|
||||
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS
|
||||
):
|
||||
return cached
|
||||
|
||||
async with lock:
|
||||
cached = cache.get("status")
|
||||
if cached is not None and cache.get("fingerprint") == fingerprint:
|
||||
if cached.get("enabled") or now - float(cache.get("ts", 0.0)) < (
|
||||
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS
|
||||
):
|
||||
return cached
|
||||
|
||||
status = await _load_subscription_guides_status(app, settings)
|
||||
cache["fingerprint"] = fingerprint
|
||||
cache["status"] = status
|
||||
cache["ts"] = time.monotonic()
|
||||
return status
|
||||
|
||||
|
||||
async def _load_subscription_guides_status(
|
||||
app: web.Application,
|
||||
settings: Settings,
|
||||
) -> Dict[str, Any]:
|
||||
if not bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)):
|
||||
return {"enabled": False, "config": None, "source": None, "error": None}
|
||||
|
||||
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "").strip()
|
||||
json_override_enabled = bool(
|
||||
getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False)
|
||||
)
|
||||
if admin_json and json_override_enabled:
|
||||
return subscription_guides_status(settings)
|
||||
|
||||
if bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED", True)):
|
||||
panel_status = await _subscription_guides_status_from_panel_config(app, settings)
|
||||
if panel_status.get("enabled"):
|
||||
return panel_status
|
||||
|
||||
return subscription_guides_status(settings)
|
||||
|
||||
|
||||
async def _subscription_guides_status_from_panel_config(
|
||||
app: web.Application,
|
||||
settings: Settings,
|
||||
) -> Dict[str, Any]:
|
||||
panel_service = _panel_service_from_app(app)
|
||||
if panel_service is None:
|
||||
return {
|
||||
"enabled": False,
|
||||
"config": None,
|
||||
"source": "panel",
|
||||
"error": "Panel service is unavailable",
|
||||
}
|
||||
|
||||
try:
|
||||
config_uuid = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_UUID", "") or "").strip()
|
||||
if not config_uuid:
|
||||
config_uuid = await _default_panel_subscription_page_config_uuid(panel_service)
|
||||
config_uuid = config_uuid or PANEL_DEFAULT_SUBPAGE_CONFIG_UUID
|
||||
detail = await panel_service.get_subscription_page_config_by_uuid(config_uuid)
|
||||
if detail is None and config_uuid != PANEL_DEFAULT_SUBPAGE_CONFIG_UUID:
|
||||
detail = await panel_service.get_subscription_page_config_by_uuid(
|
||||
PANEL_DEFAULT_SUBPAGE_CONFIG_UUID
|
||||
)
|
||||
if detail is None:
|
||||
raise SubscriptionGuidesConfigError(
|
||||
f"Panel subscription page config {config_uuid} is unavailable"
|
||||
)
|
||||
config = validate_panel_subscription_guides_config(detail)
|
||||
except (SubscriptionGuidesConfigError, Exception) as exc:
|
||||
logger.warning("Failed to load subscription guides config from Remnawave Panel: %s", exc)
|
||||
return {"enabled": False, "config": None, "source": "panel", "error": str(exc)}
|
||||
|
||||
return {"enabled": True, "config": config, "source": "panel", "error": None}
|
||||
|
||||
|
||||
async def _default_panel_subscription_page_config_uuid(panel_service: Any) -> str:
|
||||
get_list = getattr(panel_service, "get_subscription_page_config_list", None)
|
||||
if not callable(get_list):
|
||||
return ""
|
||||
payload = await get_list()
|
||||
configs = (payload or {}).get("configs")
|
||||
if not isinstance(configs, list):
|
||||
return ""
|
||||
|
||||
candidates: list[Dict[str, Any]] = [item for item in configs if isinstance(item, dict)]
|
||||
for item in candidates:
|
||||
uuid = str(item.get("uuid") or "").strip()
|
||||
if uuid == PANEL_DEFAULT_SUBPAGE_CONFIG_UUID:
|
||||
return uuid
|
||||
|
||||
candidates.sort(key=lambda item: int(item.get("viewPosition") or 0))
|
||||
for item in candidates:
|
||||
uuid = str(item.get("uuid") or "").strip()
|
||||
if uuid:
|
||||
return uuid
|
||||
return ""
|
||||
|
||||
|
||||
async def _public_subscription_payload(
|
||||
request: web.Request,
|
||||
share_token: str,
|
||||
) -> Dict[str, Any]:
|
||||
settings: Settings = request.app["settings"]
|
||||
panel_service = _panel_service_from_app(request.app)
|
||||
raw_link = ""
|
||||
username = ""
|
||||
resolved_short_uuid = ""
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
local_sub = await subscription_dal.get_subscription_by_install_share_token(
|
||||
session,
|
||||
share_token,
|
||||
)
|
||||
|
||||
if (
|
||||
local_sub
|
||||
and getattr(local_sub, "panel_user_uuid", None)
|
||||
and _local_subscription_is_publicly_active(local_sub)
|
||||
and panel_service
|
||||
):
|
||||
panel_user = await panel_service.get_user_by_uuid(local_sub.panel_user_uuid)
|
||||
if panel_user:
|
||||
raw_link = str(panel_user.get("subscriptionUrl") or "").strip()
|
||||
username = str(panel_user.get("username") or "").strip()
|
||||
resolved_short_uuid = str(panel_user.get("shortUuid") or "").strip()
|
||||
|
||||
display_link, connect_url = await prepare_config_links(settings, raw_link)
|
||||
return {
|
||||
"active": bool(display_link),
|
||||
"config_link": display_link,
|
||||
"connect_url": connect_url or display_link,
|
||||
"panel_short_uuid": resolved_short_uuid or None,
|
||||
"install_share_token": share_token,
|
||||
"username": username,
|
||||
"share_url": _public_install_url(request, share_token),
|
||||
}
|
||||
|
||||
|
||||
def _panel_service_from_app(app: web.Application) -> Any:
|
||||
subscription_service: Optional[SubscriptionService] = app.get("subscription_service")
|
||||
panel_service = (
|
||||
getattr(subscription_service, "panel_service", None) if subscription_service else None
|
||||
)
|
||||
return panel_service or app.get("panel_service")
|
||||
|
||||
|
||||
def _subscription_guides_settings_fingerprint(settings: Settings) -> Tuple[Any, ...]:
|
||||
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "")
|
||||
return (
|
||||
bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)),
|
||||
bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED", True)),
|
||||
bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False)),
|
||||
str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PATH", "") or ""),
|
||||
str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_UUID", "") or ""),
|
||||
hashlib.sha256(admin_json.encode("utf-8")).hexdigest(),
|
||||
str(getattr(settings, "PANEL_API_URL", "") or ""),
|
||||
bool(getattr(settings, "PANEL_API_KEY", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _local_subscription_is_publicly_active(subscription: Any) -> bool:
|
||||
end_date = getattr(subscription, "end_date", None)
|
||||
if end_date and end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=timezone.utc)
|
||||
return bool(
|
||||
getattr(subscription, "is_active", False)
|
||||
and end_date
|
||||
and end_date > datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
def _public_install_url(request: web.Request, share_token: str) -> str:
|
||||
settings: Settings = request.app["settings"]
|
||||
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
|
||||
if configured_base:
|
||||
parts = urlsplit(configured_base)
|
||||
if parts.scheme and parts.netloc:
|
||||
base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
||||
else:
|
||||
base = configured_base.rstrip("/")
|
||||
else:
|
||||
host = (
|
||||
request.headers.get("X-Forwarded-Host") or request.headers.get("Host") or request.host
|
||||
)
|
||||
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
|
||||
base = f"{proto}://{host}"
|
||||
return f"{base.rstrip('/')}/s/{quote(share_token)}"
|
||||
|
||||
|
||||
def _subscription_page_request_headers(request: web.Request) -> Dict[str, str]:
|
||||
headers = request.headers
|
||||
host = headers.get("X-Forwarded-Host") or headers.get("Host") or request.host
|
||||
proto = headers.get("X-Forwarded-Proto") or request.scheme or "https"
|
||||
user_agent = headers.get(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari",
|
||||
)
|
||||
return {
|
||||
"host": host,
|
||||
"x-forwarded-host": host,
|
||||
"x-forwarded-proto": proto,
|
||||
"user-agent": user_agent,
|
||||
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"accept-language": headers.get("Accept-Language", "ru,en;q=0.9"),
|
||||
"sec-fetch-dest": "document",
|
||||
"sec-fetch-mode": "navigate",
|
||||
"sec-fetch-site": "none",
|
||||
"upgrade-insecure-requests": "1",
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from typing import Literal
|
||||
|
||||
|
||||
class WebAppEmailPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
email: EmailStr
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def _normalize_and_limit_email(cls, value: EmailStr) -> str:
|
||||
normalized = normalize_email(str(value))
|
||||
if len(normalized) > 254:
|
||||
raise ValueError("email_too_long")
|
||||
return normalized
|
||||
|
||||
|
||||
class WebAppEmailCodePayload(WebAppEmailPayload):
|
||||
code: str = ""
|
||||
|
||||
|
||||
class WebAppEmailPasswordPayload(WebAppEmailPayload):
|
||||
password: constr(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class WebAppSetPasswordPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
password: constr(min_length=8, max_length=128)
|
||||
password_confirm: constr(min_length=8, max_length=128)
|
||||
code: constr(min_length=1, max_length=32)
|
||||
|
||||
|
||||
class WebAppEmailMagicPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
token: constr(min_length=8, max_length=512)
|
||||
|
||||
|
||||
class WebAppPaymentCreatePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
method: str = ""
|
||||
months: Any = None
|
||||
traffic_gb: Any = None
|
||||
device_count: Any = None
|
||||
tariff_key: Optional[constr(max_length=128)] = None
|
||||
sale_mode: Optional[constr(max_length=64)] = None
|
||||
description: Optional[constr(max_length=4096)] = None
|
||||
comment: Optional[constr(max_length=4096)] = None
|
||||
note: Optional[constr(max_length=4096)] = None
|
||||
|
||||
|
||||
class WebAppTariffChangePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
tariff_key: constr(min_length=1, max_length=128)
|
||||
mode: constr(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class WebAppLanguagePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
language: constr(min_length=2, max_length=16)
|
||||
|
||||
|
||||
class WebAppDeviceDisconnectPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
token: constr(min_length=8, max_length=128)
|
||||
|
||||
|
||||
SupportCategory = Literal["billing", "technical", "account", "other"]
|
||||
SupportPriority = Literal["low", "normal", "high", "urgent"]
|
||||
SupportStatus = Literal["open", "awaiting_user", "awaiting_admin", "resolved", "closed"]
|
||||
|
||||
|
||||
class CreateTicketPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
subject: constr(min_length=1, max_length=160)
|
||||
category: SupportCategory = "other"
|
||||
priority: Literal["normal", "high"] = "normal"
|
||||
body: constr(min_length=1, max_length=4000)
|
||||
|
||||
@field_validator("subject", "body")
|
||||
@classmethod
|
||||
def _strip_required_text(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("empty_text")
|
||||
return stripped
|
||||
|
||||
|
||||
class TicketReplyPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
body: constr(min_length=1, max_length=4000)
|
||||
|
||||
@field_validator("body")
|
||||
@classmethod
|
||||
def _strip_body(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("empty_text")
|
||||
return stripped
|
||||
|
||||
|
||||
class AdminTicketReplyPayload(TicketReplyPayload):
|
||||
is_internal_note: bool = False
|
||||
|
||||
|
||||
class AdminTicketPatchPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
status: Optional[SupportStatus] = None
|
||||
priority: Optional[SupportPriority] = None
|
||||
category: Optional[SupportCategory] = None
|
||||
assigned_admin_id: Optional[int] = None
|
||||
@@ -0,0 +1,103 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/", index_route)
|
||||
app.router.add_get("/login/password", index_route)
|
||||
app.router.add_get("/home", index_route)
|
||||
app.router.add_get("/install", index_route)
|
||||
app.router.add_get("/trial", index_route)
|
||||
app.router.add_get("/open-app", app_deeplink_route)
|
||||
app.router.add_get(r"/s/{share_token:[a-f0-9]{32}}", index_route)
|
||||
app.router.add_get("/invite", index_route)
|
||||
app.router.add_get("/devices", index_route)
|
||||
app.router.add_get("/settings", index_route)
|
||||
app.router.add_get("/support", index_route)
|
||||
app.router.add_get("/support/{ticket_id:\\d+}", index_route)
|
||||
app.router.add_get("/admin", index_route)
|
||||
app.router.add_get(
|
||||
(
|
||||
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
|
||||
"appearance|settings|translations|support}"
|
||||
),
|
||||
index_route,
|
||||
)
|
||||
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route)
|
||||
app.router.add_get("/admin/payments/users/{user_id:-?[0-9]+}", index_route)
|
||||
app.router.add_get("/admin/payments/{payment_id:\\d+}", index_route)
|
||||
app.router.add_get("/admin/support/{ticket_id:\\d+}", index_route)
|
||||
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
|
||||
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
|
||||
app.router.add_get("/health", health_route)
|
||||
app.router.add_get("/favicon.ico", webapp_current_favicon_route)
|
||||
app.router.add_get("/apple-touch-icon.png", webapp_current_favicon_route)
|
||||
app.router.add_get("/apple-touch-icon-precomposed.png", webapp_current_favicon_route)
|
||||
app.router.add_get("/icon-192.png", webapp_current_favicon_route)
|
||||
app.router.add_get("/icon-512.png", webapp_current_favicon_route)
|
||||
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
|
||||
app.router.add_get(
|
||||
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
|
||||
webapp_uploaded_logo_route,
|
||||
)
|
||||
app.router.add_get(
|
||||
rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}",
|
||||
webapp_favicon_route,
|
||||
)
|
||||
app.router.add_get(
|
||||
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
|
||||
webapp_animated_emoji_route,
|
||||
)
|
||||
app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route)
|
||||
app.router.add_get("/subscription_webapp.css", css_asset_route)
|
||||
app.router.add_get(
|
||||
"/subscription_webapp_admin.{asset_hash:[0-9a-f]{8}}.css",
|
||||
admin_css_asset_route,
|
||||
)
|
||||
app.router.add_get("/subscription_webapp_admin.css", admin_css_asset_route)
|
||||
app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route)
|
||||
app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_asset_route)
|
||||
app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route)
|
||||
app.router.add_get("/subscription_webapp.js", js_asset_route)
|
||||
app.router.add_get("/subscription_webapp_admin.min.{asset_hash}.js", admin_js_asset_route)
|
||||
app.router.add_get("/subscription_webapp_admin.js", admin_js_asset_route)
|
||||
app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route)
|
||||
app.router.add_post("/api/auth/token", auth_token_route)
|
||||
app.router.add_post("/api/auth/email/request", email_auth_request_route)
|
||||
app.router.add_post("/api/auth/email/verify", email_auth_verify_route)
|
||||
app.router.add_post("/api/auth/email/magic", email_auth_magic_route)
|
||||
app.router.add_post("/api/auth/email/password", email_password_auth_route)
|
||||
app.router.add_post("/api/auth/logout", logout_route)
|
||||
app.router.add_get("/api/bootstrap", bootstrap_route)
|
||||
app.router.add_get("/api/i18n", i18n_route)
|
||||
app.router.add_get("/api/me", me_route)
|
||||
app.router.add_get("/api/subscription-guides", subscription_guides_route)
|
||||
app.router.add_get(
|
||||
r"/api/subscription-guides/public/{share_token:[a-f0-9]{32}}",
|
||||
public_subscription_guides_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)
|
||||
app.router.add_post("/api/account/password/request", account_password_request_route)
|
||||
app.router.add_post("/api/account/password/confirm", account_password_confirm_route)
|
||||
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
|
||||
app.router.add_post("/api/promo/apply", apply_promo_route)
|
||||
app.router.add_post("/api/trial/activate", activate_trial_route)
|
||||
app.router.add_get("/api/devices", devices_route)
|
||||
app.router.add_post("/api/devices/disconnect", disconnect_device_route)
|
||||
app.router.add_get("/api/devices/topup-options", device_topup_options_route)
|
||||
app.router.add_get("/api/support/tickets", support_tickets_route)
|
||||
app.router.add_post("/api/support/tickets", support_create_ticket_route)
|
||||
app.router.add_get("/api/support/tickets/{id:\\d+}", support_ticket_detail_route)
|
||||
app.router.add_post("/api/support/tickets/{id:\\d+}/messages", support_ticket_reply_route)
|
||||
app.router.add_post("/api/support/tickets/{id:\\d+}/read", support_ticket_read_route)
|
||||
app.router.add_get("/api/support/unread", support_unread_route)
|
||||
app.router.add_get("/api/tariffs/topup-options", tariff_topup_options_route)
|
||||
app.router.add_get("/api/tariffs/change-options", tariff_change_options_route)
|
||||
app.router.add_post("/api/tariffs/change", tariff_change_route)
|
||||
app.router.add_post("/api/tariffs/change-payment", tariff_change_payment_route)
|
||||
app.router.add_post("/api/payments", create_payment_route)
|
||||
app.router.add_get("/api/payments/{payment_id}", payment_status_route)
|
||||
setup_admin_routes(app)
|
||||