Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e1f1cb787 | ||
|
|
74da8ab98e | ||
|
|
19f7daff6c | ||
|
|
cf3af17243 | ||
|
|
93353db511 | ||
|
|
e7f93a5f47 | ||
|
|
c2ab881be2 | ||
|
|
4a84bba697 | ||
|
|
cda3b741a1 | ||
|
|
2e82febdcc | ||
|
|
bd7710d03c | ||
|
|
1a66d67e44 | ||
|
|
c1e2fe2c95 | ||
|
|
ea4ee4c4a7 | ||
|
|
fbc3e193bf | ||
|
|
3541f2f78b | ||
|
|
acc222da41 | ||
|
|
8c0e778388 | ||
|
|
c6c5352813 | ||
|
|
067d6fb59f | ||
|
|
c3e55bc853 | ||
|
|
09be82aad5 | ||
|
|
49781af921 | ||
|
|
604ba873dc | ||
|
|
923ff9b208 | ||
|
|
7e7a2e58a2 | ||
|
|
e27886e15d | ||
|
|
7fe8e676cd | ||
|
|
6803c7801f | ||
|
|
cab963dcdc | ||
|
|
001e54cfe2 | ||
|
|
5e257c0d3a | ||
|
|
19f0f27a3b | ||
|
|
6fbb8eebec | ||
|
|
32616c80ab | ||
|
|
3186be1e3e | ||
|
|
1329eb4fe2 | ||
|
|
913b2d428e | ||
|
|
5892a72575 | ||
|
|
d299a3c524 | ||
|
|
1c1ef06e29 | ||
|
|
bea62d75a9 | ||
|
|
e998b9ddd1 | ||
|
|
2f192447ce | ||
|
|
e89ed870dc | ||
|
|
6a722bfa17 | ||
|
|
a96aaa763f | ||
|
|
bbe05d7f57 | ||
|
|
da28b69461 | ||
|
|
cd469ae2bb | ||
|
|
9af49453f8 | ||
|
|
e543665704 | ||
|
|
39c0696e53 | ||
|
|
c6cc4ab963 | ||
|
|
4dfe71f392 | ||
|
|
cf05c6580d | ||
|
|
d544ebd879 | ||
|
|
7e0e8ff319 | ||
|
|
613a9860a0 | ||
|
|
92f0277dad | ||
|
|
749693078b | ||
|
|
b566a34725 | ||
|
|
3ab6c18a76 | ||
|
|
52458eb223 | ||
|
|
790946d89e | ||
|
|
30c5d9ae11 | ||
|
|
12e60629fe | ||
|
|
6c0d2932c3 | ||
|
|
ce19c7e2d1 | ||
|
|
93c9dde572 | ||
|
|
d46324cb6a | ||
|
|
f153ca5bf4 | ||
|
|
62e950f5c4 | ||
|
|
2b8bcd10b8 | ||
|
|
4c2ee19957 | ||
|
|
8217d13cd6 | ||
|
|
a2a887b898 | ||
|
|
1620de9a01 | ||
|
|
b6c6887842 | ||
|
|
ef4b493e65 | ||
|
|
50dd1951c4 | ||
|
|
b3894e53e4 | ||
|
|
ded044b4c0 | ||
|
|
bce78c4f28 | ||
|
|
75586d8883 | ||
|
|
f2fc335221 | ||
|
|
1c9e55d797 | ||
|
|
25056602d8 | ||
|
|
bd2e67059f | ||
|
|
fe34edfe73 | ||
|
|
4706be53ab | ||
|
|
4bd547f06a | ||
|
|
0250264fa0 | ||
|
|
3aede8fe95 | ||
|
|
e90988ea5c | ||
|
|
69d3400310 | ||
|
|
6a44da1f31 | ||
|
|
77c8785ec0 | ||
|
|
f2c0a9f6d5 | ||
|
|
f77a6ea46d | ||
|
|
a92ad32b23 | ||
|
|
a6af8f8415 | ||
|
|
da9db7b7f8 | ||
|
|
11048a6ed8 | ||
|
|
c3381bdd31 | ||
|
|
53f1cec401 | ||
|
|
bf46a15446 | ||
|
|
0df52d0235 | ||
|
|
0c167c8f09 | ||
|
|
804ccdabec | ||
|
|
5833b18052 | ||
|
|
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,15 @@ scratch/
|
|||||||
.claude/
|
.claude/
|
||||||
*.local.*
|
*.local.*
|
||||||
node_modules/
|
node_modules/
|
||||||
docker-compose-dev.yml
|
frontend/node_modules/
|
||||||
|
docs-site/node_modules/
|
||||||
|
docs-site/.astro/
|
||||||
|
docs-site/dist/
|
||||||
|
docs-site/src/content/docs/
|
||||||
|
deploy/compose/docker-compose-dev.yml
|
||||||
data/*
|
data/*
|
||||||
!data/tariffs.example.json
|
!data/tariffs.example.json
|
||||||
|
!data/locales-overrides.example.json
|
||||||
|
|
||||||
|
|
||||||
# CI
|
# CI
|
||||||
@@ -24,8 +30,7 @@ data/*
|
|||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
Dockerfile
|
deploy/compose/*.yml
|
||||||
.docker
|
|
||||||
.dockerignore
|
.dockerignore
|
||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
@@ -33,6 +38,35 @@ tmp/
|
|||||||
bot/app/web/templates/subscription_webapp.css
|
bot/app/web/templates/subscription_webapp.css
|
||||||
bot/app/web/templates/subscription_webapp.js
|
bot/app/web/templates/subscription_webapp.js
|
||||||
bot/app/web/templates/subscription_webapp.min.*.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
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
|
|||||||
@@ -1,223 +1,75 @@
|
|||||||
# Telegram Bot Token and Admin IDs
|
# Minimal bootstrap env.
|
||||||
BOT_TOKEN=your_bot_token_here # Telegram bot token
|
# Most product settings are configured later in Web App admin:
|
||||||
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
|
# Admin -> System -> Settings, Admin -> System -> Tariffs, Admin -> Appearance.
|
||||||
|
# Full reference: docs/env-vars.md
|
||||||
|
|
||||||
# PostgreSQL Database Connection Settings
|
# Telegram bot token from @BotFather.
|
||||||
POSTGRES_USER= # Required: database user name
|
# Example: 1234567890:AA...
|
||||||
POSTGRES_PASSWORD= # Required: database password
|
BOT_TOKEN=your_bot_token_here
|
||||||
POSTGRES_HOST=remnawave-minishop-db # Database container name
|
|
||||||
POSTGRES_PORT=5432 # Port
|
|
||||||
POSTGRES_DB=postgres # Database name
|
|
||||||
|
|
||||||
# Localization and Display
|
# Telegram numeric user IDs allowed to open the admin panel.
|
||||||
DEFAULT_LANGUAGE="ru" # or "en"
|
# Use commas for several admins, for example: 123456789,987654321
|
||||||
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
|
ADMIN_IDS=123456789
|
||||||
|
|
||||||
# External Links
|
# Public HTTPS base URL of the backend webhook server.
|
||||||
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
|
# Telegram, payment providers and Remnawave call webhook endpoints under this domain.
|
||||||
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
|
# This is usually the backend/API domain, not the Mini App frontend domain.
|
||||||
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
|
# Example: https://bot.yourdomain.tld
|
||||||
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)
|
|
||||||
WEBHOOK_BASE_URL=https://webhooks.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)
|
# PostgreSQL user created by Docker Compose and used by the backend.
|
||||||
WEBAPP_ENABLED=True # Run Mini App HTTP server
|
POSTGRES_USER=remnawave_minishop
|
||||||
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
|
|
||||||
|
|
||||||
# Email login and account linking via SMTP (Brevo SMTP relay defaults)
|
# PostgreSQL password. Change it before production deploy.
|
||||||
SMTP_HOST=smtp-relay.brevo.com # SMTP server
|
POSTGRES_PASSWORD=change_me
|
||||||
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
|
|
||||||
|
|
||||||
# Payment Method Toggles
|
# PostgreSQL database name created by Docker Compose.
|
||||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
POSTGRES_DB=remnawave_minishop
|
||||||
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
|
|
||||||
|
|
||||||
# YooKassa Payment Gateway Configuration
|
# Enables the Web App and the Web App admin panel.
|
||||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
# Keep True for the first setup. If set to False, the admin UI is unavailable
|
||||||
YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa
|
# until you change it back to True in .env and restart the app.
|
||||||
YOOKASSA_RETURN_URL=https://t.me/your_bot # URL to which the user will be returned after payment
|
WEBAPP_ENABLED=True
|
||||||
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)
|
|
||||||
|
|
||||||
# Nalogo (self-employed receipts)
|
# Stable secret for Web App sessions.
|
||||||
NALOGO_INN=your_inn # INN for nalog.ru
|
# Generate with: openssl rand -hex 32
|
||||||
NALOGO_PASSWORD=your_nalogo_password # Password for nalog.ru
|
# If empty, sessions are invalidated on every restart.
|
||||||
NALOGO_RECEIPT_NAME_SUBSCRIPTION=subscription {months} months # Receipt name for time-based subscriptions ({months} = duration)
|
WEBAPP_SESSION_SECRET=
|
||||||
NALOGO_RECEIPT_NAME_TRAFFIC=traffic package {gb} GB # Receipt name for traffic packages ({gb} = traffic amount)
|
|
||||||
|
|
||||||
# FreeKassa Payment Gateway Configuration
|
# Stable Telegram webhook secret_token.
|
||||||
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
# Generate with: openssl rand -hex 32
|
||||||
FREEKASSA_API_KEY=your_api_key # API key for REST requests
|
# If empty, a new token can be generated on process start.
|
||||||
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
|
WEBHOOK_SECRET_TOKEN=
|
||||||
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
|
|
||||||
|
|
||||||
# CryptoBot Payment Gateway Configuration
|
# Public HTTPS URL of the Mini App frontend, with trailing slash.
|
||||||
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
# This URL is opened by Telegram buttons and BotFather Mini App settings.
|
||||||
CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet)
|
# Do not put /api or webhook paths here.
|
||||||
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
|
# Example: https://app.yourdomain.tld/
|
||||||
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
|
SUBSCRIPTION_MINI_APP_URL=https://app.yourdomain.tld/
|
||||||
|
|
||||||
# Platega Payment Gateway Configuration
|
# Remnawave panel API URL. Usually the panel domain plus /api.
|
||||||
PLATEGA_BASE_URL=https://app.platega.io # Base API URL
|
# Example: https://panel.yourdomain.tld/api
|
||||||
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega
|
PANEL_API_URL=https://panel.yourdomain.tld/api
|
||||||
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)
|
|
||||||
|
|
||||||
# SeverPay Payment Gateway Configuration
|
# Remnawave API key with permissions to manage users, subscriptions and squads.
|
||||||
SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL
|
# Keep this secret. It can be overridden later in the admin panel if needed.
|
||||||
SEVERPAY_MID= # Your MID from SeverPay
|
PANEL_API_KEY=
|
||||||
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)
|
|
||||||
|
|
||||||
# Subscription Options. Specify cost parameters or payment links here.
|
# Shared secret for validating incoming Remnawave webhooks.
|
||||||
1_MONTH_ENABLED=True
|
# Create or set this secret in Remnawave Panel, then paste the same value here
|
||||||
RUB_PRICE_1_MONTH=150
|
# or into Admin -> System -> Settings -> Remnawave Panel.
|
||||||
STARS_PRICE_1_MONTH=0
|
# In Remnawave Panel, set WEBHOOK_URL to WEBHOOK_BASE_URL + /webhook/panel,
|
||||||
|
# for example: https://app.example.com/webhook/panel
|
||||||
|
PANEL_WEBHOOK_SECRET=
|
||||||
|
|
||||||
3_MONTHS_ENABLED=True
|
# Host port that publishes the backend webhook server from Docker Compose.
|
||||||
RUB_PRICE_3_MONTHS=300
|
# Your reverse proxy should route WEBHOOK_BASE_URL traffic to this port.
|
||||||
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"
|
|
||||||
WEB_SERVER_PORT=8080
|
WEB_SERVER_PORT=8080
|
||||||
|
|
||||||
# Admin Panel Log Pagination
|
# Host port that publishes the frontend nginx from Docker Compose.
|
||||||
LOGS_PAGE_SIZE=10 # Number of events in the log
|
# Your reverse proxy should route SUBSCRIPTION_MINI_APP_URL traffic to this port.
|
||||||
LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
FRONTEND_PORT=8082
|
||||||
|
|
||||||
# Admin Logging Configuration
|
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
||||||
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
|
# Keep loopback for local proxy; add your proxy network if needed.
|
||||||
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
|
TRUSTED_PROXIES=127.0.0.1,::1
|
||||||
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
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.gitattributes text eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
|
.github/workflows/*.yml text eol=lf
|
||||||
|
deploy/docker/frontend/*.sh text eol=lf
|
||||||
|
frontend/src/*.js text eol=lf
|
||||||
|
frontend/src/**/*.js text eol=lf
|
||||||
|
frontend/src/**/*.svelte text eol=lf
|
||||||
|
frontend/scripts/*.mjs text eol=lf
|
||||||
|
frontend/scripts/**/*.mjs text eol=lf
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
name: Docker build & push (reusable)
|
||||||
|
|
||||||
|
# Reusable workflow that builds the three image targets defined in
|
||||||
|
# deploy/docker/Dockerfile (backend, worker, frontend) and optionally pushes
|
||||||
|
# them to both ghcr.io and Docker Hub under the 3252a8/ namespace.
|
||||||
|
#
|
||||||
|
# Called by:
|
||||||
|
# - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev
|
||||||
|
# - docker-release.yml (tag_mode: release, push: true) on pushes to main
|
||||||
|
# - ci.yml (tag_mode: dev, push: false) on pull requests
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
push:
|
||||||
|
description: "Push the built images to the registries"
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
tag_mode:
|
||||||
|
description: "Tagging strategy: 'dev' or 'release'"
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
|
||||||
|
# No permissions block here on purpose: a reusable workflow cannot request more
|
||||||
|
# than its caller grants, so the token scope is set by each caller
|
||||||
|
# (docker-dev.yml / docker-release.yml grant packages: write to push; ci.yml
|
||||||
|
# only needs contents: read for a no-push build).
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: backend
|
||||||
|
image: remnawave-minishop-backend
|
||||||
|
- target: worker
|
||||||
|
image: remnawave-minishop-worker
|
||||||
|
- target: frontend
|
||||||
|
image: remnawave-minishop-frontend
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history + tags: the Dockerfile's version-builder stage runs
|
||||||
|
# `git describe --tags` against the copied .git tree.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Resolve release version
|
||||||
|
id: version
|
||||||
|
if: inputs.tag_mode == 'release'
|
||||||
|
run: |
|
||||||
|
# On a tag push github.ref_name is the tag (e.g. v3.4.5); for a
|
||||||
|
# manual workflow_dispatch on a branch, fall back to the latest tag.
|
||||||
|
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||||
|
raw="${{ github.ref_name }}"
|
||||||
|
else
|
||||||
|
raw="$(git describe --tags --abbrev=0 2>/dev/null)"
|
||||||
|
fi
|
||||||
|
version="${raw#v}"
|
||||||
|
if [ -z "$version" ]; then
|
||||||
|
echo "::error::No git tag found to derive the release version from"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Release version: ${version}"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
if: inputs.push
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
if: inputs.push
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
3252a8/${{ matrix.image }}
|
||||||
|
ghcr.io/3252a8/${{ matrix.image }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }}
|
||||||
|
type=sha,prefix=dev-,format=short,enable=${{ inputs.tag_mode == 'dev' }}
|
||||||
|
type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }}
|
||||||
|
type=raw,value=${{ steps.version.outputs.version }},enable=${{ inputs.tag_mode == 'release' }}
|
||||||
|
|
||||||
|
- name: Build${{ inputs.push && ' & push' || '' }} ${{ matrix.image }}
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: deploy/docker/Dockerfile
|
||||||
|
target: ${{ matrix.target }}
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: ${{ inputs.push }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
# The Dockerfile's version-builder appends a "-<branch>" suffix to the
|
||||||
|
# internal version string for non-main builds. Force "main" on release
|
||||||
|
# (the ref is the tag, not a branch) so release images stay un-suffixed.
|
||||||
|
build-args: |
|
||||||
|
GITHUB_REF_NAME=${{ inputs.tag_mode == 'release' && 'main' || github.ref_name }}
|
||||||
|
cache-from: type=gha,scope=${{ matrix.target }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
|
||||||
|
provenance: false
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
name: PR checks
|
||||||
|
|
||||||
|
# Runs on pull requests into main (typically from dev) and into dev (typically
|
||||||
|
# from feature/* branches): lint + format checks and a no-push image build to
|
||||||
|
# prove the Docker images still build.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint & format
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install ruff
|
||||||
|
run: pip install "ruff>=0.8.0"
|
||||||
|
|
||||||
|
- name: Ruff lint (Python)
|
||||||
|
run: ruff check .
|
||||||
|
|
||||||
|
- name: Ruff format check (Python)
|
||||||
|
run: ruff format --check .
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend deps
|
||||||
|
run: npm ci
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: ESLint (frontend)
|
||||||
|
run: npm run lint
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Prettier check (frontend)
|
||||||
|
run: npm run format:check
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Docker build
|
||||||
|
uses: ./.github/workflows/_docker-build-push.yml
|
||||||
|
with:
|
||||||
|
push: false
|
||||||
|
tag_mode: dev
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: Dependency review
|
||||||
|
|
||||||
|
# On PRs into main/dev, flag any newly added dependency that has a known
|
||||||
|
# vulnerability or an incompatible license before it gets merged.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dependency-review:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Dependency review
|
||||||
|
uses: actions/dependency-review-action@v4
|
||||||
|
with:
|
||||||
|
fail-on-severity: high
|
||||||
|
comment-summary-in-pr: on-failure
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: Dev images
|
||||||
|
|
||||||
|
# On every push to the dev branch, build all three images and push them to
|
||||||
|
# ghcr.io and Docker Hub tagged `dev` and `dev-<short-sha>`.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: docker-dev-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-push:
|
||||||
|
uses: ./.github/workflows/_docker-build-push.yml
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
tag_mode: dev
|
||||||
|
secrets: inherit
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: Release images
|
||||||
|
|
||||||
|
# Build all three images and push them to ghcr.io and Docker Hub tagged
|
||||||
|
# `latest` and the release version (the pushed tag with its leading `v`
|
||||||
|
# stripped, e.g. v3.4.5 -> 3.4.5). Triggered only when a new v* tag is pushed,
|
||||||
|
# so images are built once per release rather than on every commit to main.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: docker-release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-push:
|
||||||
|
uses: ./.github/workflows/_docker-build-push.yml
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
tag_mode: release
|
||||||
|
secrets: inherit
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
name: Security
|
||||||
|
|
||||||
|
# Audits the full dependency set (pip-audit, npm audit) and runs a Trivy
|
||||||
|
# filesystem scan (dependencies + Dockerfile/IaC misconfig). Trivy results are
|
||||||
|
# uploaded to the Security -> Code scanning tab.
|
||||||
|
#
|
||||||
|
# pip-audit / npm audit are informational (continue-on-error): they surface
|
||||||
|
# upstream/transitive advisories that aren't necessarily fixable in a given PR,
|
||||||
|
# so they report in the logs without blocking merges. The PR gate for newly
|
||||||
|
# introduced vulnerable deps is dependency-review.yml.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
push:
|
||||||
|
branches: [main, dev]
|
||||||
|
schedule:
|
||||||
|
- cron: "27 4 * * 1" # weekly, Monday 04:27 UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: security-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
python-audit:
|
||||||
|
name: pip-audit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install pip-audit
|
||||||
|
run: pip install pip-audit
|
||||||
|
|
||||||
|
- name: Audit Python dependencies
|
||||||
|
continue-on-error: true
|
||||||
|
run: pip-audit -r backend/requirements.txt
|
||||||
|
|
||||||
|
npm-audit:
|
||||||
|
name: npm audit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend deps
|
||||||
|
run: npm ci
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Audit npm dependencies
|
||||||
|
continue-on-error: true
|
||||||
|
run: npm audit --audit-level=high
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
trivy:
|
||||||
|
name: Trivy filesystem scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Run Trivy
|
||||||
|
uses: aquasecurity/trivy-action@v0.36.0
|
||||||
|
with:
|
||||||
|
scan-type: fs
|
||||||
|
scan-ref: .
|
||||||
|
format: sarif
|
||||||
|
output: trivy-results.sarif
|
||||||
|
severity: CRITICAL,HIGH
|
||||||
|
ignore-unfixed: true
|
||||||
|
|
||||||
|
- name: Upload Trivy results
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-results.sarif
|
||||||
|
category: trivy-fs
|
||||||
@@ -5,15 +5,62 @@ bot_database.sqlite3
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
docker-compose-dev.yml
|
||||||
scratch_*.py
|
scratch_*.py
|
||||||
|
scratch/
|
||||||
*.local.*
|
*.local.*
|
||||||
node_modules/
|
node_modules/
|
||||||
.git/
|
.git/
|
||||||
|
|
||||||
|
# Documentation site build artifacts
|
||||||
|
docs-site/.astro/
|
||||||
|
docs-site/dist/
|
||||||
|
docs-site/public/demo/runtime/
|
||||||
|
docs-site/src/content/docs/
|
||||||
|
|
||||||
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
|
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
|
||||||
bot/app/web/templates/subscription_webapp.css
|
bot/app/web/templates/subscription_webapp.css
|
||||||
bot/app/web/templates/subscription_webapp.js
|
bot/app/web/templates/subscription_webapp.js
|
||||||
bot/app/web/templates/subscription_webapp.min.*.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
|
||||||
|
bot/app/web/templates/subscription_webapp_docs_demo.css
|
||||||
|
bot/app/web/templates/subscription_webapp_docs_demo.js
|
||||||
|
bot/app/web/templates/subscription_webapp_docs_demo.*.css
|
||||||
|
bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
|
||||||
|
bot/app/web/templates/subscription_webapp_docs_demo.*.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
|
||||||
|
backend/bot/app/web/templates/subscription_webapp_docs_demo.css
|
||||||
|
backend/bot/app/web/templates/subscription_webapp_docs_demo.js
|
||||||
|
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css
|
||||||
|
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
|
||||||
|
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
|
||||||
tmp
|
tmp
|
||||||
.claude
|
.claude
|
||||||
|
|
||||||
@@ -32,4 +79,4 @@ locales/en_backup.json
|
|||||||
db/models_old.py
|
db/models_old.py
|
||||||
data/*
|
data/*
|
||||||
!data/tariffs.example.json
|
!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,8 +1,10 @@
|
|||||||
# Remnawave Minishop
|
# Remnawave Minishop
|
||||||
|
|
||||||
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. 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).
|
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/migrations/index.md).
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
@@ -12,8 +14,10 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
|||||||
- просмотр статуса подписки, даты окончания, ссылки подключения и трафика;
|
- просмотр статуса подписки, даты окончания, ссылки подключения и трафика;
|
||||||
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
|
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
|
||||||
- Web App / Mini App с входом через Telegram или email;
|
- 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`.
|
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
|
||||||
|
|
||||||
Для администраторов:
|
Для администраторов:
|
||||||
@@ -21,18 +25,26 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
|||||||
- админ-панель для пользователей из `ADMIN_IDS` (только при входе через Telegram, не для аккаунтов только с email);
|
- админ-панель для пользователей из `ADMIN_IDS` (только при входе через Telegram, не для аккаунтов только с email);
|
||||||
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
|
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
|
||||||
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
|
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
|
||||||
- блокировка пользователей, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
|
- блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
|
||||||
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
|
- редактор JSON-каталога тарифов с моделями на срок/по трафику, Internal Squads, premium-сквадами и HWID-пакетами;
|
||||||
|
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональное JSON-переопределение и переключатель поведения кнопок бота;
|
||||||
- ручная синхронизация пользователей и подписок с панелью.
|
- ручная синхронизация пользователей и подписок с панелью.
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
- [Настройка окружения](docs/configuration.md) - основные переменные `.env`, платежи, Remnawave, пробный период, SMTP для email-входа и секреты.
|
- [Входная страница документации](docs/index.md) - маршрут по установке, настройке, платежам, админке и диагностике.
|
||||||
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
- [Развертывание](docs/getting-started/deployment.md) - Docker Compose, Caddy, Nginx, Pangolin/Newt и запуск без обратного прокси.
|
||||||
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
- [Настройка окружения](docs/getting-started/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
|
||||||
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки.
|
- [Переменные `.env`](docs/configuration/env-vars.md) - полный справочник всех env-ключей по разделам.
|
||||||
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
|
- [Бэкапы и восстановление](docs/features/backups.md) - автоматические архивы, Telegram-отправка и restore через админку.
|
||||||
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
|
- [Тарифы](docs/features/tariffs.md) - каталог тарифов, модели на срок и по трафику, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
||||||
|
- [Админ-панель](docs/features/admin-panel.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
||||||
|
- [Веб-приложение / Mini App](docs/features/web-app.md) - отдельный порт, домен, инструкции установки и реферальные ссылки.
|
||||||
|
- [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина.
|
||||||
|
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
||||||
|
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
||||||
|
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`.
|
||||||
|
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) - готовый сценарий для legacy-стека.
|
||||||
|
|
||||||
## Совместимость
|
## Совместимость
|
||||||
|
|
||||||
@@ -40,13 +52,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** |
|
| 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) |
|
| Данные | **PostgreSQL** **17** (сервис `postgres` в Compose) и **Redis** **7** (сервис `redis`) |
|
||||||
| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `bot/app/web/templates/` |
|
| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `backend/bot/app/web/templates/` |
|
||||||
|
|
||||||
Локальная разработка без Docker возможна при установленных Python 3.12, PostgreSQL и (для пересборки фронта) Node 22; типичный сценарий — всё через Compose.
|
Локальная разработка без Docker возможна при установленных Python 3.12, PostgreSQL и (для пересборки фронта) Node 22; типичный сценарий — всё через Compose.
|
||||||
|
|
||||||
@@ -57,7 +69,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
|||||||
- Docker и Docker Compose;
|
- Docker и Docker Compose;
|
||||||
- рабочая панель Remnawave версии **`> 2.7.0`** (см. раздел «Совместимость»);
|
- рабочая панель Remnawave версии **`> 2.7.0`** (см. раздел «Совместимость»);
|
||||||
- токен Telegram-бота;
|
- токен Telegram-бота;
|
||||||
- параметры хотя бы одного платежного провайдера.
|
- публичные домены для webhook и Mini App.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/3252a8/remnawave-minishop
|
git clone https://github.com/3252a8/remnawave-minishop
|
||||||
@@ -65,7 +77,7 @@ cd remnawave-minishop
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
nano .env
|
nano .env
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
docker compose logs -f remnawave-minishop
|
docker compose logs -f backend worker frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
Минимально заполните в `.env`:
|
Минимально заполните в `.env`:
|
||||||
@@ -73,17 +85,24 @@ docker compose logs -f remnawave-minishop
|
|||||||
- `BOT_TOKEN` - токен Telegram-бота;
|
- `BOT_TOKEN` - токен Telegram-бота;
|
||||||
- `ADMIN_IDS` - Telegram ID администраторов через запятую;
|
- `ADMIN_IDS` - Telegram ID администраторов через запятую;
|
||||||
- `WEBHOOK_BASE_URL` - публичный URL вебхуков;
|
- `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;
|
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
||||||
- `USER_SQUAD_UUIDS` - Internal Squads для пользователей;
|
- остальные настройки удобнее задать в Web App админке.
|
||||||
- настройки платежного провайдера;
|
|
||||||
- `SUBSCRIPTION_MINI_APP_URL`, если используется Web App.
|
|
||||||
|
|
||||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
|
В Remnawave Panel укажите `WEBHOOK_URL` как публичный адрес Minishop с путем `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет вебхука задается в самой Remnawave Panel; это же значение вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или в **Система -> Настройки -> Remnawave Panel** в админке.
|
||||||
|
|
||||||
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, кеша логотипа Web App и animated emoji:
|
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/configuration/env-vars.md](docs/configuration/env-vars.md).
|
||||||
|
|
||||||
|
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md).
|
||||||
|
|
||||||
|
В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
|
||||||
|
|
||||||
```bash
|
```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
|
chown -R 10001:10001 data
|
||||||
chmod -R u+rwX data
|
chmod -R u+rwX data
|
||||||
```
|
```
|
||||||
@@ -95,15 +114,37 @@ chmod -R u+rwX data
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
|
|
||||||
# Логи приложения
|
# Логи приложения
|
||||||
docker compose logs -f remnawave-minishop
|
docker compose logs -f backend worker frontend
|
||||||
|
|
||||||
# Запуск с Caddy
|
# Рекомендуемый продакшен-вариант с Caddy
|
||||||
docker compose -f docker-compose-caddy.yml up -d --build
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Поддержка
|
Для продакшен-запуска удобнее брать готовые папки из [`deploy/examples`](deploy/examples), а читать каноничные инструкции в [docs/getting-started/deployment.md](docs/getting-started/deployment.md). Предпочтительный вариант для обычного публичного сервера - Caddy: он сам выпускает и продлевает HTTPS-сертификаты. В папках рядом с compose лежат только конфиги и короткие ссылки на документацию.
|
||||||
|
|
||||||
|
Имена образов для релизов:
|
||||||
|
|
||||||
|
- `ghcr.io/3252a8/remnawave-minishop-backend`
|
||||||
|
- `ghcr.io/3252a8/remnawave-minishop-worker`
|
||||||
|
- `ghcr.io/3252a8/remnawave-minishop-frontend`
|
||||||
|
- `docker.io/3252a8/remnawave-minishop-backend`
|
||||||
|
- `docker.io/3252a8/remnawave-minishop-worker`
|
||||||
|
- `docker.io/3252a8/remnawave-minishop-frontend`
|
||||||
|
|
||||||
|
Сборка и публикация сразу в GHCR и Docker Hub:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login ghcr.io
|
||||||
|
docker login docker.io
|
||||||
|
IMAGE_TAG=v3.4.3 bash scripts/docker-build-push-images.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Поддержать проект
|
||||||
|
|
||||||
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
|
- 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 aiogram.fsm.storage.memory import MemoryStorage
|
||||||
from sqlalchemy.orm import sessionmaker
|
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.action_logger_middleware import ActionLoggerMiddleware
|
||||||
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
||||||
from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
||||||
@@ -18,7 +23,11 @@ from config.settings import Settings
|
|||||||
def build_dispatcher(
|
def build_dispatcher(
|
||||||
settings: Settings, async_session_factory: sessionmaker
|
settings: Settings, async_session_factory: sessionmaker
|
||||||
) -> tuple[Dispatcher, Bot, Dict]:
|
) -> 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)
|
default_props = DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||||
bot = Bot(token=settings.BOT_TOKEN, default=default_props)
|
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
|
||||||
@@ -6,6 +6,7 @@ from bot.app.web.admin_api_impl import (
|
|||||||
_runtime as _runtime,
|
_runtime as _runtime,
|
||||||
ads as _ads,
|
ads as _ads,
|
||||||
auth as _auth,
|
auth as _auth,
|
||||||
|
backups as _backups,
|
||||||
broadcast as _broadcast,
|
broadcast as _broadcast,
|
||||||
common as _common,
|
common as _common,
|
||||||
logs as _logs,
|
logs as _logs,
|
||||||
@@ -15,8 +16,11 @@ from bot.app.web.admin_api_impl import (
|
|||||||
routes as _routes,
|
routes as _routes,
|
||||||
settings as _settings,
|
settings as _settings,
|
||||||
stats as _stats,
|
stats as _stats,
|
||||||
|
support as _support,
|
||||||
sync as _sync,
|
sync as _sync,
|
||||||
tariffs as _tariffs,
|
tariffs as _tariffs,
|
||||||
|
themes as _themes,
|
||||||
|
translations as _translations,
|
||||||
users as _users,
|
users as _users,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,11 +33,15 @@ _MODULES = (
|
|||||||
_payments,
|
_payments,
|
||||||
_promos,
|
_promos,
|
||||||
_logs,
|
_logs,
|
||||||
|
_support,
|
||||||
_broadcast,
|
_broadcast,
|
||||||
_sync,
|
_sync,
|
||||||
_ads,
|
_ads,
|
||||||
|
_backups,
|
||||||
_settings,
|
_settings,
|
||||||
_tariffs,
|
_tariffs,
|
||||||
|
_themes,
|
||||||
|
_translations,
|
||||||
_panel,
|
_panel,
|
||||||
_routes,
|
_routes,
|
||||||
)
|
)
|
||||||
@@ -28,6 +28,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
from bot.app.web.admin_settings_manifest import (
|
from bot.app.web.admin_settings_manifest import (
|
||||||
manifest_payload,
|
manifest_payload,
|
||||||
)
|
)
|
||||||
|
from bot.infra.webhook_queue import enqueue_webhook_event
|
||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.settings_override_service import (
|
from bot.services.settings_override_service import (
|
||||||
current_value,
|
current_value,
|
||||||
@@ -40,6 +41,7 @@ from config.tariffs_config import TariffsConfig
|
|||||||
from db.dal import (
|
from db.dal import (
|
||||||
ad_dal,
|
ad_dal,
|
||||||
app_settings_dal,
|
app_settings_dal,
|
||||||
|
locale_overrides_dal,
|
||||||
message_log_dal,
|
message_log_dal,
|
||||||
panel_sync_dal,
|
panel_sync_dal,
|
||||||
payment_dal,
|
payment_dal,
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from bot.infra.redis import redis_lock
|
||||||
|
from bot.services.backup_restore_service import (
|
||||||
|
BACKUP_UPLOAD_MAX_BYTES,
|
||||||
|
BackupArchiveError,
|
||||||
|
BackupArchiveInfo,
|
||||||
|
BackupRestoreError,
|
||||||
|
BackupRestoreService,
|
||||||
|
)
|
||||||
|
from bot.services.backup_worker import BackupWorker
|
||||||
|
|
||||||
|
|
||||||
|
def _backup_archive_payload(archive) -> Dict[str, Any]:
|
||||||
|
return archive.to_payload()
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_uploaded_backup_file(request: web.Request) -> BackupArchiveInfo:
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
|
service = BackupRestoreService(settings)
|
||||||
|
backup_dir = service.backup_dir()
|
||||||
|
temp_path: Optional[Path] = None
|
||||||
|
|
||||||
|
reader = await request.multipart()
|
||||||
|
try:
|
||||||
|
async for part in reader:
|
||||||
|
if part.name != "file":
|
||||||
|
continue
|
||||||
|
|
||||||
|
original_filename = part.filename or "backup.zip"
|
||||||
|
temp_path = backup_dir / f".upload-{secrets.token_urlsafe(12)}.zip.tmp"
|
||||||
|
size = 0
|
||||||
|
with temp_path.open("wb") as handle:
|
||||||
|
while True:
|
||||||
|
chunk = await part.read_chunk(size=1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
size += len(chunk)
|
||||||
|
if size > BACKUP_UPLOAD_MAX_BYTES:
|
||||||
|
raise BackupArchiveError("Backup archive is too large")
|
||||||
|
handle.write(chunk)
|
||||||
|
if size <= 0:
|
||||||
|
raise BackupArchiveError("Uploaded archive is empty")
|
||||||
|
archive = service.import_uploaded_archive(temp_path, original_filename)
|
||||||
|
temp_path = None
|
||||||
|
return archive
|
||||||
|
finally:
|
||||||
|
if temp_path is not None and temp_path.exists():
|
||||||
|
try:
|
||||||
|
temp_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Failed to remove temporary backup upload %s", temp_path)
|
||||||
|
|
||||||
|
raise BackupArchiveError("file field is required")
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_backups_list_route(request: web.Request) -> web.Response:
|
||||||
|
_require_admin_user_id(request)
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
|
try:
|
||||||
|
service = BackupRestoreService(settings)
|
||||||
|
archives = service.list_archives()
|
||||||
|
except OSError as exc:
|
||||||
|
logger.exception("Failed to list backup archives")
|
||||||
|
return _error(500, "backup_list_failed", str(exc))
|
||||||
|
return _ok(
|
||||||
|
{
|
||||||
|
"backup_dir": str(service.backup_dir()),
|
||||||
|
"archives": [_backup_archive_payload(archive) for archive in archives],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_backups_upload_route(request: web.Request) -> web.Response:
|
||||||
|
_require_admin_user_id(request)
|
||||||
|
content_type = (request.headers.get("Content-Type") or "").lower()
|
||||||
|
if not content_type.startswith("multipart/form-data"):
|
||||||
|
return _error(400, "invalid_backup_archive", "multipart file upload is required")
|
||||||
|
try:
|
||||||
|
archive = await _read_uploaded_backup_file(request)
|
||||||
|
except BackupArchiveError as exc:
|
||||||
|
return _error(400, "invalid_backup_archive", str(exc))
|
||||||
|
except OSError as exc:
|
||||||
|
logger.exception("Failed to save uploaded backup archive")
|
||||||
|
return _error(500, "backup_upload_failed", str(exc))
|
||||||
|
return _ok({"archive": _backup_archive_payload(archive)})
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_backups_create_route(request: web.Request) -> web.Response:
|
||||||
|
_require_admin_user_id(request)
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
|
bot = request.app["bot"]
|
||||||
|
session_factory = request.app.get("async_session_factory")
|
||||||
|
worker = BackupWorker(settings, bot, session_factory=session_factory)
|
||||||
|
|
||||||
|
ttl_seconds = max(
|
||||||
|
60,
|
||||||
|
int(
|
||||||
|
max(
|
||||||
|
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
|
||||||
|
getattr(settings, "BACKUP_PG_DUMP_TIMEOUT_SECONDS", 1800) or 1800,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
|
||||||
|
if not acquired:
|
||||||
|
return _error(409, "backup_create_busy", "Backup or restore is already running")
|
||||||
|
await worker.refresh_settings()
|
||||||
|
result = await worker.create_and_send_backup(backup_type="manual")
|
||||||
|
archive = BackupRestoreService(settings).inspect_archive(result.archive_path)
|
||||||
|
except BackupArchiveError as exc:
|
||||||
|
return _error(400, "invalid_backup_archive", str(exc))
|
||||||
|
except (OSError, RuntimeError, subprocess.SubprocessError, TimeoutError) as exc:
|
||||||
|
logger.exception("Manual backup creation failed")
|
||||||
|
return _error(500, "backup_create_failed", str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Manual backup creation failed")
|
||||||
|
return _error(500, "backup_create_failed", str(exc))
|
||||||
|
|
||||||
|
return _ok(
|
||||||
|
{
|
||||||
|
"result": result.to_payload(),
|
||||||
|
"archive": _backup_archive_payload(archive),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_backups_restore_route(request: web.Request) -> web.Response:
|
||||||
|
_require_admin_user_id(request)
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
|
payload = await _read_json(request)
|
||||||
|
|
||||||
|
archive_name = str(payload.get("archive_name") or "").strip()
|
||||||
|
restore_database = bool(payload.get("restore_database"))
|
||||||
|
restore_compose = bool(payload.get("restore_compose"))
|
||||||
|
confirm = bool(payload.get("confirm"))
|
||||||
|
if not confirm:
|
||||||
|
return _error(400, "restore_confirmation_required")
|
||||||
|
|
||||||
|
service = BackupRestoreService(settings)
|
||||||
|
ttl_seconds = max(
|
||||||
|
60,
|
||||||
|
int(
|
||||||
|
max(
|
||||||
|
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
|
||||||
|
getattr(settings, "BACKUP_PG_RESTORE_TIMEOUT_SECONDS", 1800) or 1800,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
|
||||||
|
if not acquired:
|
||||||
|
return _error(409, "backup_restore_busy", "Backup or restore is already running")
|
||||||
|
result = await service.restore_archive(
|
||||||
|
archive_name,
|
||||||
|
restore_database=restore_database,
|
||||||
|
restore_compose=restore_compose,
|
||||||
|
)
|
||||||
|
except BackupArchiveError as exc:
|
||||||
|
return _error(400, "invalid_backup_archive", str(exc))
|
||||||
|
except BackupRestoreError as exc:
|
||||||
|
logger.exception("Backup restore failed")
|
||||||
|
return _error(500, "backup_restore_failed", str(exc))
|
||||||
|
except (OSError, subprocess.SubprocessError, TimeoutError) as exc:
|
||||||
|
logger.exception("Backup restore failed")
|
||||||
|
return _error(500, "backup_restore_failed", str(exc))
|
||||||
|
|
||||||
|
if result.database_restored:
|
||||||
|
try:
|
||||||
|
from db import database_setup
|
||||||
|
|
||||||
|
if database_setup.async_engine is not None:
|
||||||
|
await database_setup.async_engine.dispose()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to dispose DB engine after backup restore")
|
||||||
|
|
||||||
|
return _ok({"result": result.to_payload()})
|
||||||
@@ -143,12 +143,18 @@ def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Option
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
def _user_display_label(
|
||||||
"""Human-facing name for payments tables: TG profile name, else email, else user id."""
|
loaded_user: Any,
|
||||||
if loaded_user is None:
|
fallback_user_id: Optional[int],
|
||||||
return str(payment_user_id)
|
*,
|
||||||
|
first_name: Optional[str] = None,
|
||||||
|
last_name: Optional[str] = None,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Human-facing name: TG profile name, else email, else user id."""
|
||||||
tid = getattr(loaded_user, "telegram_id", None)
|
tid = getattr(loaded_user, "telegram_id", None)
|
||||||
if tid is not None:
|
if loaded_user is not None and tid is not None:
|
||||||
fn = (getattr(loaded_user, "first_name", None) or "").strip()
|
fn = (getattr(loaded_user, "first_name", None) or "").strip()
|
||||||
ln = (getattr(loaded_user, "last_name", None) or "").strip()
|
ln = (getattr(loaded_user, "last_name", None) or "").strip()
|
||||||
full = f"{fn} {ln}".strip()
|
full = f"{fn} {ln}".strip()
|
||||||
@@ -157,10 +163,30 @@ def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
|||||||
un = (getattr(loaded_user, "username", None) or "").strip()
|
un = (getattr(loaded_user, "username", None) or "").strip()
|
||||||
if un:
|
if un:
|
||||||
return un if un.startswith("@") else f"@{un}"
|
return un if un.startswith("@") else f"@{un}"
|
||||||
return str(payment_user_id)
|
elif loaded_user is not None:
|
||||||
email = (getattr(loaded_user, "email", None) or "").strip()
|
email = (getattr(loaded_user, "email", None) or "").strip()
|
||||||
if email:
|
if email:
|
||||||
return email
|
return email
|
||||||
|
fn = (first_name or "").strip()
|
||||||
|
ln = (last_name or "").strip()
|
||||||
|
full = f"{fn} {ln}".strip()
|
||||||
|
if full:
|
||||||
|
return full
|
||||||
|
un = (username or "").strip()
|
||||||
|
if un:
|
||||||
|
return un if un.startswith("@") else f"@{un}"
|
||||||
|
email_value = (email or "").strip()
|
||||||
|
if email_value:
|
||||||
|
return email_value
|
||||||
|
if fallback_user_id is None:
|
||||||
|
return None
|
||||||
|
return str(fallback_user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
||||||
|
label = _user_display_label(loaded_user, payment_user_id)
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
return str(payment_user_id)
|
return str(payment_user_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -229,15 +255,27 @@ def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None)
|
|||||||
|
|
||||||
|
|
||||||
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
|
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
|
||||||
|
author_user = entry.__dict__.get("author_user")
|
||||||
|
target_user = entry.__dict__.get("target_user")
|
||||||
|
user_id = int(entry.user_id) if entry.user_id is not None else None
|
||||||
|
target_user_id = int(entry.target_user_id) if entry.target_user_id is not None else None
|
||||||
return {
|
return {
|
||||||
"log_id": int(entry.log_id),
|
"log_id": int(entry.log_id),
|
||||||
"user_id": int(entry.user_id) if entry.user_id else None,
|
"user_id": user_id,
|
||||||
|
"user_label": _user_display_label(
|
||||||
|
author_user,
|
||||||
|
user_id,
|
||||||
|
first_name=entry.telegram_first_name,
|
||||||
|
username=entry.telegram_username,
|
||||||
|
),
|
||||||
"telegram_username": entry.telegram_username,
|
"telegram_username": entry.telegram_username,
|
||||||
"telegram_first_name": entry.telegram_first_name,
|
"telegram_first_name": entry.telegram_first_name,
|
||||||
|
"email": getattr(author_user, "email", None),
|
||||||
"event_type": entry.event_type,
|
"event_type": entry.event_type,
|
||||||
"content": entry.content,
|
"content": entry.content,
|
||||||
"is_admin_event": bool(entry.is_admin_event),
|
"is_admin_event": bool(entry.is_admin_event),
|
||||||
"target_user_id": int(entry.target_user_id) if entry.target_user_id else None,
|
"target_user_id": target_user_id,
|
||||||
|
"target_user_label": _user_display_label(target_user, target_user_id),
|
||||||
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
|
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +308,10 @@ def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None:
|
|||||||
path.write_text(payload, encoding="utf-8")
|
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:
|
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")
|
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 ""
|
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:
|
async def admin_payments_export_route(request: web.Request) -> web.Response:
|
||||||
_require_admin_user_id(request)
|
_require_admin_user_id(request)
|
||||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
@@ -15,6 +15,10 @@ def setup_admin_routes(app: web.Application) -> None:
|
|||||||
router.add_post(
|
router.add_post(
|
||||||
"/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route
|
"/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+}/reset-trial", admin_user_reset_trial_route)
|
||||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route)
|
router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route)
|
||||||
router.add_post(
|
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_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", 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/payments/export.csv", admin_payments_export_route)
|
||||||
|
|
||||||
router.add_get("/api/admin/promos", admin_promos_list_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/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/broadcast", admin_broadcast_route)
|
||||||
router.add_post("/api/admin/sync", admin_sync_route)
|
router.add_post("/api/admin/sync", admin_sync_route)
|
||||||
|
|
||||||
@@ -51,7 +66,17 @@ def setup_admin_routes(app: web.Application) -> None:
|
|||||||
|
|
||||||
router.add_get("/api/admin/settings", admin_settings_get_route)
|
router.add_get("/api/admin/settings", admin_settings_get_route)
|
||||||
router.add_patch("/api/admin/settings", admin_settings_patch_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_get("/api/admin/tariffs", admin_tariffs_get_route)
|
||||||
router.add_put("/api/admin/tariffs", admin_tariffs_save_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/backups", admin_backups_list_route)
|
||||||
|
router.add_post("/api/admin/backups/create", admin_backups_create_route)
|
||||||
|
router.add_post("/api/admin/backups/upload", admin_backups_upload_route)
|
||||||
|
router.add_post("/api/admin/backups/restore", admin_backups_restore_route)
|
||||||
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
|
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# ruff: noqa: F401,F403,F405,I001
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
from ._runtime import * # noqa: F403,F405
|
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:
|
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}
|
overrides_by_key = {entry["key"]: entry for entry in overrides}
|
||||||
|
|
||||||
fields = manifest_payload()
|
fields = manifest_payload()
|
||||||
|
webhook_base_url = str(settings.WEBHOOK_BASE_URL or "").strip().rstrip("/")
|
||||||
sections: Dict[str, Dict[str, Any]] = {}
|
sections: Dict[str, Dict[str, Any]] = {}
|
||||||
for field in fields:
|
for field in fields:
|
||||||
key = field["key"]
|
key = field["key"]
|
||||||
@@ -26,14 +33,35 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
|
|||||||
override = overrides_by_key.get(key)
|
override = overrides_by_key.get(key)
|
||||||
value = current_value(settings, key)
|
value = current_value(settings, key)
|
||||||
is_secret = bool(field.get("secret"))
|
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 = {
|
response_field = {
|
||||||
**field,
|
**field,
|
||||||
"value": "" if is_secret else value,
|
"value": "" if is_secret else value,
|
||||||
"overridden": bool(override),
|
"overridden": overridden,
|
||||||
"updated_at": override.get("updated_at") if override else None,
|
"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:
|
if is_secret:
|
||||||
response_field["has_value"] = bool(value)
|
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)
|
sections[section_id]["fields"].append(response_field)
|
||||||
|
|
||||||
ordered_sections = sorted(sections.values(), key=lambda s: s["order"])
|
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")
|
return _error(400, "invalid_updates")
|
||||||
if not isinstance(deletes, list):
|
if not isinstance(deletes, list):
|
||||||
return _error(400, "invalid_deletes")
|
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(
|
result = await update_overrides(
|
||||||
settings,
|
settings,
|
||||||
@@ -65,10 +100,6 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
|
|||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Bust the public webapp settings cache so users see new values immediately.
|
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=deletes)
|
||||||
cache = request.app.get("webapp_settings_cache")
|
|
||||||
if isinstance(cache, dict):
|
|
||||||
cache["ts"] = 0.0
|
|
||||||
cache["data"] = {}
|
|
||||||
|
|
||||||
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
|
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
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
from ._runtime import * # noqa: F403,F405
|
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:
|
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)
|
logger.exception("Failed to write tariffs config to %s", path)
|
||||||
return _error(500, "write_failed", str(exc))
|
return _error(500, "write_failed", str(exc))
|
||||||
|
|
||||||
cache = request.app.get("webapp_settings_cache")
|
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
|
||||||
if isinstance(cache, dict):
|
|
||||||
cache["ts"] = 0.0
|
|
||||||
cache["data"] = {}
|
|
||||||
|
|
||||||
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
|
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,146 @@
|
|||||||
|
# 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
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
from ._runtime import * # noqa: F403,F405
|
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:
|
async def admin_users_list_route(request: web.Request) -> web.Response:
|
||||||
_require_admin_user_id(request)
|
_require_admin_user_id(request)
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
|
||||||
page = max(0, int(request.query.get("page", 0) or 0))
|
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()
|
premium_traffic = (request.query.get("premium_traffic") or "all").lower()
|
||||||
sort_value = (request.query.get("sort") or "registered_desc").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:
|
async with async_session_factory() as session:
|
||||||
users, total = await _filter_and_sort_users(
|
users, total = await _filter_and_sort_users(
|
||||||
session,
|
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))
|
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
|
||||||
serialized.append(payload)
|
serialized.append(payload)
|
||||||
|
|
||||||
return _ok(
|
return {
|
||||||
{
|
"users": serialized,
|
||||||
"users": serialized,
|
"page": page,
|
||||||
"page": page,
|
"page_size": page_size,
|
||||||
"page_size": page_size,
|
"total": total,
|
||||||
"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(
|
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)
|
payload = await _read_json(request)
|
||||||
desired = bool(payload.get("banned"))
|
desired = bool(payload.get("banned"))
|
||||||
|
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
user = await user_dal.get_user_by_id(session, target_id)
|
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)
|
user.is_banned = bool(desired)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
|
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||||
return _ok({"user": _serialize_user(user)})
|
return _ok({"user": _serialize_user(user)})
|
||||||
|
|
||||||
|
|
||||||
@@ -624,32 +766,180 @@ async def admin_user_message_preview_route(request: web.Request) -> web.Response
|
|||||||
return _ok({})
|
return _ok({})
|
||||||
|
|
||||||
|
|
||||||
async def admin_user_delete_route(request: web.Request) -> web.Response:
|
def _admin_user_display_name_for_message(user: User) -> str:
|
||||||
actor_id = _require_admin_user_id(request)
|
full = " ".join(
|
||||||
target_id = int(request.match_info["user_id"])
|
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_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
ok = await user_dal.delete_user_and_relations(session, target_id)
|
target_user = await user_dal.get_user_by_id(session, target_id)
|
||||||
if not ok:
|
if not target_user:
|
||||||
await session.rollback()
|
|
||||||
return _error(404, "not_found")
|
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(
|
await message_log_dal.create_message_log(
|
||||||
session,
|
session,
|
||||||
{
|
{
|
||||||
"user_id": actor_id,
|
"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",
|
"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,
|
"is_admin_event": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||||
return _ok({})
|
return _ok({})
|
||||||
|
|
||||||
|
|
||||||
async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
|
async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
|
||||||
actor_id = _require_admin_user_id(request)
|
actor_id = _require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
target_id = int(request.match_info["user_id"])
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
panel_service = request.app.get("panel_service")
|
panel_service = request.app.get("panel_service")
|
||||||
subscription_service = request.app.get("subscription_service")
|
subscription_service = request.app.get("subscription_service")
|
||||||
if panel_service is None or subscription_service is None:
|
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 session.commit()
|
||||||
|
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||||
return _ok({})
|
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)."""
|
"""Premium-squad traffic overrides only (unlimited toggle + bonus GB)."""
|
||||||
actor_id = _require_admin_user_id(request)
|
actor_id = _require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
target_id = int(request.match_info["user_id"])
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
payload = await _read_json(request)
|
payload = await _read_json(request)
|
||||||
subscription_service = request.app.get("subscription_service")
|
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.commit()
|
||||||
await session.refresh(active)
|
await session.refresh(active)
|
||||||
|
|
||||||
|
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||||
return _ok({"subscription": _serialize_subscription(active)})
|
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."""
|
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
|
||||||
actor_id = _require_admin_user_id(request)
|
actor_id = _require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
target_id = int(request.match_info["user_id"])
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
payload = await _read_json(request)
|
payload = await _read_json(request)
|
||||||
|
|
||||||
unlimited = bool(payload.get("unlimited"))
|
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.commit()
|
||||||
await session.refresh(active)
|
await session.refresh(active)
|
||||||
|
|
||||||
|
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||||
return _ok({"subscription": _serialize_subscription(active)})
|
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)
|
actor_id = _require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
target_id = int(request.match_info["user_id"])
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
payload = await _read_json(request)
|
payload = await _read_json(request)
|
||||||
|
|
||||||
kind = str(payload.get("kind") or "regular").strip().lower()
|
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)
|
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||||
|
|
||||||
|
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||||
return _ok(
|
return _ok(
|
||||||
{
|
{
|
||||||
"subscription": _serialize_subscription(refreshed) if refreshed else None,
|
"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:
|
async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||||
actor_id = _require_admin_user_id(request)
|
actor_id = _require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
target_id = int(request.match_info["user_id"])
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
payload = await _read_json(request)
|
payload = await _read_json(request)
|
||||||
try:
|
try:
|
||||||
days = int(payload.get("days") or 0)
|
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)
|
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||||
|
|
||||||
|
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||||
return _ok(
|
return _ok(
|
||||||
{
|
{
|
||||||
"subscription": _serialize_subscription(refreshed) if refreshed else None,
|
"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)
|
||||||
@@ -0,0 +1,803 @@
|
|||||||
|
"""Manifest of settings editable from the admin web app.
|
||||||
|
|
||||||
|
Each entry describes a single overridable attribute on the global
|
||||||
|
``Settings`` instance. The manifest is the only contract between the
|
||||||
|
admin UI and the backend: keys not listed here cannot be changed via
|
||||||
|
the API, even by an admin.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, List, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SettingField:
|
||||||
|
key: str
|
||||||
|
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon" | "json"
|
||||||
|
section: str
|
||||||
|
label: str
|
||||||
|
description: str = ""
|
||||||
|
placeholder: str = ""
|
||||||
|
optional: bool = True
|
||||||
|
secret: bool = False
|
||||||
|
min: Optional[float] = None
|
||||||
|
max: Optional[float] = None
|
||||||
|
choices: Optional[Tuple[Tuple[str, str], ...]] = None
|
||||||
|
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
|
||||||
|
webhook_path: Optional[str] = None
|
||||||
|
webhook_requires_base_url: bool = False
|
||||||
|
webhook_provider_id: Optional[str] = None
|
||||||
|
webhook_hint_i18n_key: Optional[str] = None
|
||||||
|
webhook_hint: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
SETTINGS_MANIFEST: List[SettingField] = [
|
||||||
|
# ─── General ────────────────────────────────────────────────────
|
||||||
|
SettingField(
|
||||||
|
"WEBAPP_TITLE",
|
||||||
|
"string",
|
||||||
|
"general",
|
||||||
|
"Web App title",
|
||||||
|
placeholder="My subscription",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"DEFAULT_LANGUAGE",
|
||||||
|
"string",
|
||||||
|
"general",
|
||||||
|
"Язык по умолчанию",
|
||||||
|
"Используется для приветственных сообщений и публичных страниц.",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"DEFAULT_CURRENCY_SYMBOL",
|
||||||
|
"string",
|
||||||
|
"general",
|
||||||
|
"Валюта",
|
||||||
|
"Например, RUB, USD, EUR.",
|
||||||
|
placeholder="RUB",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью."
|
||||||
|
),
|
||||||
|
SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"),
|
||||||
|
SettingField("TERMS_OF_SERVICE_URL", "url", "general", "Условия использования"),
|
||||||
|
SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"),
|
||||||
|
SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"),
|
||||||
|
SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"),
|
||||||
|
SettingField(
|
||||||
|
"START_COMMAND_DESCRIPTION", "string", "general", "Описание /start", placeholder=""
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REQUIRED_CHANNEL_ID",
|
||||||
|
"int",
|
||||||
|
"general",
|
||||||
|
"ID обязательного канала",
|
||||||
|
"Telegram ID канала, в котором нужно состоять.",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REQUIRED_CHANNEL_LINK",
|
||||||
|
"string",
|
||||||
|
"general",
|
||||||
|
"Ссылка на канал",
|
||||||
|
"Имя пользователя или invite-link.",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"PANEL_API_URL",
|
||||||
|
"url",
|
||||||
|
"remnawave",
|
||||||
|
"URL API Remnawave",
|
||||||
|
"Например, https://panel.example.com/api.",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"PANEL_API_KEY",
|
||||||
|
"string",
|
||||||
|
"remnawave",
|
||||||
|
"API-ключ Remnawave",
|
||||||
|
"Секретный ключ API панели.",
|
||||||
|
secret=True,
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"PANEL_WEBHOOK_SECRET",
|
||||||
|
"string",
|
||||||
|
"remnawave",
|
||||||
|
"Секрет вебхуков Remnawave",
|
||||||
|
"Используется для проверки входящих вебхуков панели.",
|
||||||
|
secret=True,
|
||||||
|
webhook_path="/webhook/panel",
|
||||||
|
webhook_requires_base_url=True,
|
||||||
|
webhook_provider_id="remnawave",
|
||||||
|
webhook_hint_i18n_key="admin_settings_panel_webhook_url_hint",
|
||||||
|
webhook_hint="Use this URL as WEBHOOK_URL in Remnawave Panel.",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"USER_SQUAD_UUIDS",
|
||||||
|
"string",
|
||||||
|
"remnawave",
|
||||||
|
"Internal Squads по умолчанию",
|
||||||
|
"UUID через запятую для legacy-режима без JSON-каталога тарифов.",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"USER_EXTERNAL_SQUAD_UUID",
|
||||||
|
"string",
|
||||||
|
"remnawave",
|
||||||
|
"External Squad по умолчанию",
|
||||||
|
"Необязательный UUID External Squad для новых пользователей.",
|
||||||
|
),
|
||||||
|
# ─── Web app appearance ────────────────────────────────────────
|
||||||
|
SettingField(
|
||||||
|
"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(
|
||||||
|
"WEBAPP_LOGO_EMOJI_FONT",
|
||||||
|
"string",
|
||||||
|
"appearance",
|
||||||
|
"Шрифт эмоджи-логотипа",
|
||||||
|
"Выберите шрифт для отображения эмодзи-логотипа",
|
||||||
|
choices=(
|
||||||
|
("system", "Системный (по умолчанию)"),
|
||||||
|
("noto-color", "Noto Color Emoji"),
|
||||||
|
("noto-color-animated", "Noto Color Emoji Animated"),
|
||||||
|
("noto-emoji", "Noto Emoji"),
|
||||||
|
("twemoji", "Twitter Emoji"),
|
||||||
|
("openmoji", "OpenMoji"),
|
||||||
|
("apple", "Apple Color Emoji (local)"),
|
||||||
|
("segoe", "Segoe UI Emoji (local)"),
|
||||||
|
("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 месяца"),
|
||||||
|
SettingField("MONTH_6_ENABLED", "bool", "pricing", "Тариф 6 месяцев"),
|
||||||
|
SettingField("MONTH_12_ENABLED", "bool", "pricing", "Тариф 12 месяцев"),
|
||||||
|
SettingField("RUB_PRICE_1_MONTH", "int", "pricing", "Цена 1 мес. (RUB)"),
|
||||||
|
SettingField("RUB_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (RUB)"),
|
||||||
|
SettingField("RUB_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (RUB)"),
|
||||||
|
SettingField("RUB_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (RUB)"),
|
||||||
|
SettingField("STARS_PRICE_1_MONTH", "int", "pricing", "Цена 1 мес. (Stars)"),
|
||||||
|
SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"),
|
||||||
|
SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"),
|
||||||
|
SettingField("STARS_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (Stars)"),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашающему: 1 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашающему: 3 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашающему: 6 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашающему: 12 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашённому: 1 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашённому: 3 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашённому: 6 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Бонус приглашённому: 12 мес.",
|
||||||
|
min=0,
|
||||||
|
subsection="legacy_tariffs",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
|
||||||
|
),
|
||||||
|
SettingField("STARS_TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика (Stars)"),
|
||||||
|
SettingField(
|
||||||
|
"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="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,heleket",
|
||||||
|
subsection="common",
|
||||||
|
),
|
||||||
|
# ─── Trial ─────────────────────────────────────────────────────
|
||||||
|
SettingField(
|
||||||
|
"TRIAL_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"pricing",
|
||||||
|
"Триал включён",
|
||||||
|
optional=False,
|
||||||
|
subsection="trial",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"TRIAL_DURATION_DAYS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Длительность триала (дней)",
|
||||||
|
optional=False,
|
||||||
|
min=0,
|
||||||
|
subsection="trial",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"TRIAL_TRAFFIC_LIMIT_GB",
|
||||||
|
"float",
|
||||||
|
"pricing",
|
||||||
|
"Лимит трафика триала (ГБ)",
|
||||||
|
optional=False,
|
||||||
|
min=0,
|
||||||
|
subsection="trial",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"TRIAL_TRAFFIC_STRATEGY",
|
||||||
|
"string",
|
||||||
|
"pricing",
|
||||||
|
"Стратегия сброса трафика триала",
|
||||||
|
optional=False,
|
||||||
|
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", "Один бонус на приглашённого"
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
|
||||||
|
),
|
||||||
|
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
|
||||||
|
# ─── Notifications ─────────────────────────────────────────────
|
||||||
|
SettingField(
|
||||||
|
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"notifications",
|
||||||
|
"Включены уведомления о подписке",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"notifications",
|
||||||
|
"Дублировать уведомления о подписке на email",
|
||||||
|
"Письма отправляются только пользователям с привязанным email и рабочим SMTP.",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", "bool", "notifications", "Уведомлять после истечения"
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUBSCRIPTION_NOTIFY_DAYS_BEFORE",
|
||||||
|
"int",
|
||||||
|
"notifications",
|
||||||
|
"За сколько дней предупреждать",
|
||||||
|
min=0,
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"SUBSCRIPTION_NOTIFY_HOURS_BEFORE",
|
||||||
|
"int",
|
||||||
|
"notifications",
|
||||||
|
"За сколько часов предупреждать",
|
||||||
|
min=0,
|
||||||
|
max=23,
|
||||||
|
),
|
||||||
|
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
|
||||||
|
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
|
||||||
|
SettingField("LOG_SUPPORT", "bool", "notifications", "Логировать тикеты поддержки"),
|
||||||
|
SettingField(
|
||||||
|
"LOG_PROMO_ACTIVATIONS", "bool", "notifications", "Логировать активации промокодов"
|
||||||
|
),
|
||||||
|
SettingField("LOG_TRIAL_ACTIVATIONS", "bool", "notifications", "Логировать активации триала"),
|
||||||
|
SettingField(
|
||||||
|
"LOG_SUSPICIOUS_ACTIVITY", "bool", "notifications", "Логировать подозрительные действия"
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"LOG_ADMIN_ACTIONS",
|
||||||
|
"bool",
|
||||||
|
"notifications",
|
||||||
|
"Логировать действия администраторов",
|
||||||
|
"Если выключено, события от пользователей из ADMIN_IDS не записываются в message logs.",
|
||||||
|
i18n_label_key="admin_settings_field_log_admin_actions_label",
|
||||||
|
i18n_description_key="admin_settings_field_log_admin_actions_description",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"LOG_LEVEL",
|
||||||
|
"string",
|
||||||
|
"notifications",
|
||||||
|
"Глобальный уровень логов",
|
||||||
|
"DEBUG / INFO / WARNING / ERROR",
|
||||||
|
),
|
||||||
|
SettingField("LOG_CHAT_ID", "int", "notifications", "ID чата для логов"),
|
||||||
|
SettingField("LOG_THREAD_ID", "int", "notifications", "ID треда (для супергрупп)"),
|
||||||
|
SettingField(
|
||||||
|
"LOG_SUPPORT_THREAD_ID",
|
||||||
|
"int",
|
||||||
|
"notifications",
|
||||||
|
"ID треда поддержки",
|
||||||
|
"Тред лог-чата для уведомлений о тикетах поддержки.",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"BACKUP_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"backups",
|
||||||
|
"Бэкапы включены",
|
||||||
|
"Worker будет периодически собирать ZIP-архив и отправлять его в Telegram.",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"BACKUP_CHAT_ID",
|
||||||
|
"int",
|
||||||
|
"backups",
|
||||||
|
"ID чата для бэкапов",
|
||||||
|
"Куда отправлять ZIP-архивы. Если пусто, используется LOG_CHAT_ID.",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"BACKUP_THREAD_ID",
|
||||||
|
"int",
|
||||||
|
"backups",
|
||||||
|
"ID треда для бэкапов",
|
||||||
|
"Необязательный topic/thread ID. Если пусто, используется LOG_THREAD_ID.",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"BACKUP_INTERVAL_SECONDS",
|
||||||
|
"int",
|
||||||
|
"backups",
|
||||||
|
"Период бэкапов (сек.)",
|
||||||
|
"По умолчанию 3600: запуск на границе часа (12:00, 13:00 и т.д.).",
|
||||||
|
optional=False,
|
||||||
|
min=60,
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"BACKUP_LOCAL_RETENTION",
|
||||||
|
"int",
|
||||||
|
"backups",
|
||||||
|
"Сколько архивов хранить",
|
||||||
|
"Сколько последних ZIP-архивов оставлять в data/backups на сервере.",
|
||||||
|
optional=False,
|
||||||
|
min=1,
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"BACKUP_COMPOSE_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"backups",
|
||||||
|
"Добавлять compose-папку",
|
||||||
|
(
|
||||||
|
"Добавляет snapshot /app/compose-source. Если папка не смонтирована, "
|
||||||
|
"бэкап БД все равно будет создан."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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(
|
||||||
|
"USER_HWID_DEVICE_LIMIT", "int", "devices", "Лимит устройств по умолчанию (0 = ∞)", min=0
|
||||||
|
),
|
||||||
|
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
|
||||||
|
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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 aggregated_manifest():
|
||||||
|
if field.key == key:
|
||||||
|
return field
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_keys() -> List[str]:
|
||||||
|
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() == ""):
|
||||||
|
if not field.optional:
|
||||||
|
raise ValueError(f"{field.key}: value required")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if field.type == "bool":
|
||||||
|
if isinstance(raw, bool):
|
||||||
|
return raw
|
||||||
|
if isinstance(raw, (int, float)):
|
||||||
|
return bool(raw)
|
||||||
|
if isinstance(raw, str):
|
||||||
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
return bool(raw)
|
||||||
|
|
||||||
|
if field.type == "int":
|
||||||
|
try:
|
||||||
|
value = int(str(raw).strip())
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError(f"{field.key}: integer expected") from exc
|
||||||
|
if field.min is not None and value < field.min:
|
||||||
|
raise ValueError(f"{field.key}: must be >= {field.min:g}")
|
||||||
|
if field.max is not None and value > field.max:
|
||||||
|
raise ValueError(f"{field.key}: must be <= {field.max:g}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
if field.type == "float":
|
||||||
|
try:
|
||||||
|
value = float(str(raw).strip())
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError(f"{field.key}: number expected") from exc
|
||||||
|
if field.min is not None and value < field.min:
|
||||||
|
raise ValueError(f"{field.key}: must be >= {field.min:g}")
|
||||||
|
if field.max is not None and value > field.max:
|
||||||
|
raise ValueError(f"{field.key}: must be <= {field.max:g}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
if isinstance(raw, str):
|
||||||
|
return raw.strip()
|
||||||
|
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.
|
||||||
|
|
||||||
|
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,
|
||||||
|
"remnawave": 3,
|
||||||
|
"pricing": 11,
|
||||||
|
"payments": 4,
|
||||||
|
"trial": 5,
|
||||||
|
"referral": 6,
|
||||||
|
"notifications": 7,
|
||||||
|
"support": 8,
|
||||||
|
"backups": 9,
|
||||||
|
"devices": 10,
|
||||||
|
"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 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,
|
||||||
|
"section": field.section,
|
||||||
|
"section_order": sections_order.get(field.section, 99),
|
||||||
|
"subsection": field.subsection,
|
||||||
|
"label": field.label,
|
||||||
|
"description": field.description,
|
||||||
|
"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),
|
||||||
|
"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.min is not None:
|
||||||
|
item["min"] = field.min
|
||||||
|
if field.max is not None:
|
||||||
|
item["max"] = field.max
|
||||||
|
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.webhook_path:
|
||||||
|
item["webhook_path"] = field.webhook_path
|
||||||
|
item["webhook_requires_base_url"] = field.webhook_requires_base_url
|
||||||
|
if field.webhook_provider_id:
|
||||||
|
item["provider_id"] = field.webhook_provider_id
|
||||||
|
if field.webhook_hint_i18n_key:
|
||||||
|
item["webhook_hint_i18n_key"] = field.webhook_hint_i18n_key
|
||||||
|
if field.webhook_hint:
|
||||||
|
item["webhook_hint"] = field.webhook_hint
|
||||||
|
if 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,12 @@ from bot.app.web.webapp import (
|
|||||||
billing as _billing,
|
billing as _billing,
|
||||||
common as _common,
|
common as _common,
|
||||||
devices as _devices,
|
devices as _devices,
|
||||||
|
guides as _guides,
|
||||||
payloads as _payloads,
|
payloads as _payloads,
|
||||||
routes as _routes,
|
routes as _routes,
|
||||||
serializers as _serializers,
|
serializers as _serializers,
|
||||||
|
support as _support,
|
||||||
|
telegram_notifications as _telegram_notifications,
|
||||||
)
|
)
|
||||||
|
|
||||||
_MODULES = (
|
_MODULES = (
|
||||||
@@ -23,9 +26,12 @@ _MODULES = (
|
|||||||
_assets,
|
_assets,
|
||||||
_auth,
|
_auth,
|
||||||
_account,
|
_account,
|
||||||
|
_telegram_notifications,
|
||||||
_serializers,
|
_serializers,
|
||||||
_billing,
|
_billing,
|
||||||
_devices,
|
_devices,
|
||||||
|
_guides,
|
||||||
|
_support,
|
||||||
_routes,
|
_routes,
|
||||||
_application,
|
_application,
|
||||||
)
|
)
|
||||||
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 738 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -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 |