From 30fb774d933e8c65a41697f4abc92a61828b1963 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sat, 16 May 2026 21:48:15 +0300 Subject: [PATCH] refactor: project architecture refactor, container splitting --- .dockerignore | 12 +- .env.example | 17 +- .gitignore | 5 +- Caddyfile | 10 - Dockerfile | 96 ---- README.md | 20 +- backend/app_logging.py | 12 + .../bot}/app/controllers/__init__.py | 0 .../app/controllers/dispatcher_controller.py | 11 +- .../bot}/app/factories/__init__.py | 0 .../bot}/app/factories/build_services.py | 0 {bot => backend/bot}/app/web/__init__.py | 0 {bot => backend/bot}/app/web/admin_api.py | 0 .../bot}/app/web/admin_api_impl/__init__.py | 0 .../bot}/app/web/admin_api_impl/_runtime.py | 1 + .../bot}/app/web/admin_api_impl/ads.py | 0 .../bot}/app/web/admin_api_impl/auth.py | 0 .../bot}/app/web/admin_api_impl/broadcast.py | 0 .../bot}/app/web/admin_api_impl/common.py | 0 .../bot}/app/web/admin_api_impl/logs.py | 0 .../bot}/app/web/admin_api_impl/panel.py | 0 .../bot}/app/web/admin_api_impl/payments.py | 0 .../bot}/app/web/admin_api_impl/promos.py | 0 .../bot}/app/web/admin_api_impl/routes.py | 0 .../bot}/app/web/admin_api_impl/settings.py | 3 + .../bot}/app/web/admin_api_impl/stats.py | 0 backend/bot/app/web/admin_api_impl/sync.py | 16 + .../bot}/app/web/admin_api_impl/tariffs.py | 0 .../bot}/app/web/admin_api_impl/themes.py | 142 +++++- .../bot}/app/web/admin_api_impl/users.py | 0 .../bot}/app/web/admin_settings_manifest.py | 7 +- {bot => backend/bot}/app/web/session.py | 0 .../bot}/app/web/subscription_webapp.py | 0 .../web/templates/subscription_webapp.html | 1 - .../bot}/app/web/themes/ascii/style.css | 0 .../bot}/app/web/themes/ascii/theme.json | 0 .../bot}/app/web/themes/dark/theme.json | 0 .../bot}/app/web/themes/light/style.css | 0 .../bot}/app/web/themes/light/theme.json | 0 .../web/themes/windows95/icons/arrow-left.png | Bin .../themes/windows95/icons/arrow-right.png | Bin .../web/themes/windows95/icons/bitcoin.png | Bin .../app/web/themes/windows95/icons/check.png | Bin .../web/themes/windows95/icons/chevrons.png | Bin .../app/web/themes/windows95/icons/coins.png | Bin .../web/themes/windows95/icons/dashboard.png | Bin .../web/themes/windows95/icons/database.png | Bin .../web/themes/windows95/icons/download.png | Bin .../app/web/themes/windows95/icons/error.png | Bin .../web/themes/windows95/icons/file-text.png | Bin .../app/web/themes/windows95/icons/folder.png | Bin .../app/web/themes/windows95/icons/gift.png | Bin .../app/web/themes/windows95/icons/globe.png | Bin .../app/web/themes/windows95/icons/help.png | Bin .../app/web/themes/windows95/icons/home.png | Bin .../app/web/themes/windows95/icons/info.png | Bin .../app/web/themes/windows95/icons/key.png | Bin .../app/web/themes/windows95/icons/lock.png | Bin .../web/themes/windows95/icons/megaphone.png | Bin .../web/themes/windows95/icons/paintbrush.png | Bin .../themes/windows95/icons/payment-card.png | Bin .../app/web/themes/windows95/icons/print.png | Bin .../web/themes/windows95/icons/refresh.png | Bin .../app/web/themes/windows95/icons/save.png | Bin .../app/web/themes/windows95/icons/search.png | Bin .../app/web/themes/windows95/icons/send.png | Bin .../web/themes/windows95/icons/settings.png | Bin .../app/web/themes/windows95/icons/shield.png | Bin .../web/themes/windows95/icons/sliders.png | Bin .../web/themes/windows95/icons/smartphone.png | Bin .../web/themes/windows95/icons/sparkles.png | Bin .../app/web/themes/windows95/icons/tag.png | Bin .../app/web/themes/windows95/icons/ticket.png | Bin .../app/web/themes/windows95/icons/trash.png | Bin .../app/web/themes/windows95/icons/user.png | Bin .../app/web/themes/windows95/icons/users.png | Bin .../web/themes/windows95/icons/warning.png | Bin .../bot}/app/web/themes/windows95/icons/x.png | Bin .../bot}/app/web/themes/windows95/style.css | 0 .../bot}/app/web/themes/windows95/theme.json | 0 {bot => backend/bot}/app/web/web_server.py | 17 +- .../bot}/app/web/webapp/__init__.py | 0 .../bot}/app/web/webapp/_runtime.py | 6 +- .../bot}/app/web/webapp/account.py | 6 + .../bot}/app/web/webapp/application.py | 0 {bot => backend/bot}/app/web/webapp/assets.py | 201 +++++--- {bot => backend/bot}/app/web/webapp/auth.py | 0 .../bot}/app/web/webapp/billing.py | 0 {bot => backend/bot}/app/web/webapp/common.py | 0 .../bot}/app/web/webapp/devices.py | 0 .../bot}/app/web/webapp/payloads.py | 0 {bot => backend/bot}/app/web/webapp/routes.py | 1 + .../bot}/app/web/webapp/serializers.py | 0 {bot => backend/bot}/app/web/webapp_auth.py | 0 {bot => backend/bot}/filters/admin_filter.py | 0 .../bot}/handlers/admin/__init__.py | 0 {bot => backend/bot}/handlers/admin/ads.py | 0 .../bot}/handlers/admin/broadcast.py | 0 {bot => backend/bot}/handlers/admin/common.py | 0 .../bot}/handlers/admin/logs_admin.py | 0 .../bot}/handlers/admin/payments.py | 0 .../bot}/handlers/admin/promo/__init__.py | 0 .../bot}/handlers/admin/promo/bulk.py | 0 .../bot}/handlers/admin/promo/create.py | 0 .../bot}/handlers/admin/promo/manage.py | 0 .../bot}/handlers/admin/statistics.py | 0 .../bot}/handlers/admin/sync_admin.py | 0 .../bot}/handlers/admin/user_management.py | 47 +- {bot => backend/bot}/handlers/inline_mode.py | 0 .../bot}/handlers/user/__init__.py | 0 {bot => backend/bot}/handlers/user/payment.py | 17 + .../bot}/handlers/user/promo_user.py | 0 .../bot}/handlers/user/referral.py | 0 {bot => backend/bot}/handlers/user/start.py | 0 .../handlers/user/subscription/__init__.py | 0 .../bot}/handlers/user/subscription/core.py | 0 .../user/subscription/payment_methods.py | 0 .../handlers/user/subscription/payments.py | 0 .../user/subscription/payments_crypto.py | 0 .../user/subscription/payments_freekassa.py | 0 .../user/subscription/payments_platega.py | 0 .../user/subscription/payments_severpay.py | 0 .../user/subscription/payments_stars.py | 0 .../subscription/payments_subscription.py | 0 .../user/subscription/payments_yookassa.py | 0 .../bot}/handlers/user/trial_handler.py | 0 backend/bot/infra/redis.py | 113 +++++ backend/bot/infra/webhook_queue.py | 63 +++ .../bot}/keyboards/inline/admin_keyboards.py | 0 .../bot}/keyboards/inline/user_keyboards.py | 0 {bot => backend/bot}/main_bot.py | 55 +-- .../middlewares/action_logger_middleware.py | 0 .../bot}/middlewares/ban_check_middleware.py | 0 .../bot}/middlewares/channel_subscription.py | 0 .../bot}/middlewares/db_session.py | 0 {bot => backend/bot}/middlewares/i18n.py | 0 .../bot}/middlewares/profile_sync.py | 0 {bot => backend/bot}/routers.py | 0 .../bot}/services/crypto_pay_service.py | 0 .../bot}/services/email_auth_service.py | 0 .../bot}/services/email_templates.py | 0 .../bot}/services/freekassa_service.py | 0 {bot => backend/bot}/services/lknpd_client.py | 0 .../bot}/services/lknpd_service.py | 0 .../bot}/services/notification_service.py | 0 .../bot}/services/panel_api_service.py | 21 +- .../bot}/services/panel_webhook_service.py | 20 +- .../bot}/services/platega_service.py | 0 .../bot}/services/promo_code_service.py | 0 .../bot}/services/referral_service.py | 0 .../services/settings_override_service.py | 105 ++++ .../bot}/services/severpay_service.py | 0 .../bot}/services/stars_service.py | 0 .../bot}/services/subscription_service.py | 0 .../subscription_service_impl/__init__.py | 0 .../subscription_service_impl/_runtime.py | 0 .../subscription_service_impl/core.py | 0 .../subscription_service_impl/devices.py | 0 .../subscription_service_impl/lifecycle.py | 0 .../panel_identity.py | 0 .../subscription_service_impl/payments.py | 0 .../subscription_service_impl/renewal.py | 0 .../subscription_service_impl/tariffs.py | 0 .../subscription_service_impl/traffic.py | 0 .../subscription_service_impl/trial.py | 1 - .../bot}/services/tariff_worker.py | 49 +- .../bot}/services/yookassa_service.py | 0 {bot => backend/bot}/states/admin_states.py | 0 {bot => backend/bot}/states/user_states.py | 0 {bot => backend/bot}/utils/__init__.py | 0 {bot => backend/bot}/utils/callback_answer.py | 0 {bot => backend/bot}/utils/config_link.py | 0 {bot => backend/bot}/utils/date_utils.py | 0 {bot => backend/bot}/utils/message_queue.py | 0 {bot => backend/bot}/utils/mini_app_url.py | 0 .../bot}/utils/request_security.py | 0 {bot => backend/bot}/utils/telegram_markup.py | 0 {bot => backend/bot}/utils/text_sanitizer.py | 0 {bot => backend/bot}/utils/ttl_cache.py | 24 +- {config => backend/config}/settings.py | 15 + {config => backend/config}/tariffs_config.py | 0 .../config}/webapp_themes_config.py | 0 {db => backend/db}/dal/__init__.py | 0 {db => backend/db}/dal/ad_dal.py | 0 {db => backend/db}/dal/app_settings_dal.py | 0 {db => backend/db}/dal/message_log_dal.py | 0 {db => backend/db}/dal/panel_sync_dal.py | 0 {db => backend/db}/dal/payment_dal.py | 0 {db => backend/db}/dal/promo_code_dal.py | 0 {db => backend/db}/dal/security_dal.py | 0 {db => backend/db}/dal/subscription_dal.py | 16 +- {db => backend/db}/dal/tariff_dal.py | 0 {db => backend/db}/dal/user_billing_dal.py | 0 {db => backend/db}/dal/user_dal.py | 0 {db => backend/db}/database_setup.py | 6 +- {db => backend/db}/migrator.py | 0 {db => backend/db}/models.py | 0 backend/main.py | 36 ++ backend/main_backend.py | 28 ++ backend/main_migrate.py | 27 ++ backend/main_worker.py | 189 ++++++++ requirements.txt => backend/requirements.txt | 1 + backend/startup_banner.py | 109 +++++ bot/app/web/admin_api_impl/sync.py | 23 - bot/app/web/frontend/src/main.js | 10 - deploy/compose/docker-compose-caddy.yml | 157 ++++++ .../compose/docker-compose-remote-server.yml | 136 ++++++ deploy/docker/Dockerfile | 82 ++++ deploy/docker/caddy/Caddyfile | 11 + deploy/docker/frontend/00-startup-banner.sh | 23 + deploy/docker/frontend/nginx.conf | 57 +++ docker-compose-caddy.yml | 62 --- docker-compose-remote-server.yml | 45 -- docker-compose.yml | 159 +++++-- docs/admin.md | 2 +- docs/architecture.md | 53 +++ docs/configuration.md | 4 +- docs/deployment.md | 448 ++++++++---------- docs/migration-to-minishop.md | 19 +- docs/webapp-themes.md | 6 +- docs/webapp.md | 46 +- .prettierignore => frontend/.prettierignore | 0 .prettierrc.json => frontend/.prettierrc.json | 0 components.json => frontend/components.json | 2 +- .../eslint.config.mjs | 8 +- .../web/frontend => frontend}/jsconfig.json | 0 .../package-lock.json | 0 frontend/package.json | 40 ++ .../scripts}/build_subscription_webapp_js.mjs | 12 +- .../web/frontend => frontend}/src/App.svelte | 0 .../src/PreviewBoard.svelte | 0 .../src/admin/AdminPanel.svelte | 0 .../src/admin/sections/AdsSection.svelte | 0 .../admin/sections/AppearanceSection.svelte | 22 +- .../admin/sections/BroadcastSection.svelte | 0 .../src/admin/sections/LogsSection.svelte | 0 .../src/admin/sections/PaymentsSection.svelte | 0 .../src/admin/sections/PromosSection.svelte | 0 .../src/admin/sections/SettingsSection.svelte | 0 .../src/admin/sections/StatsSection.svelte | 0 .../admin/sections/TariffEditorModal.svelte | 0 .../src/admin/sections/TariffsSection.svelte | 0 .../src/admin/sections/UserDetailModal.svelte | 0 .../src/admin/sections/UsersSection.svelte | 0 .../src/lib/admin/format.js | 0 .../src/lib/admin/revenueSeriesAgg.js | 0 .../src/lib/admin/stores/adsStore.js | 0 .../src/lib/admin/stores/broadcastStore.js | 0 .../src/lib/admin/stores/logsStore.js | 0 .../src/lib/admin/stores/paymentsStore.js | 0 .../src/lib/admin/stores/promosStore.js | 0 .../src/lib/admin/stores/settingsStore.js | 0 .../src/lib/admin/stores/statsStore.js | 0 .../src/lib/admin/stores/tariffsStore.js | 0 .../src/lib/admin/stores/themesStore.js | 8 +- .../src/lib/admin/stores/usersStore.js | 0 .../src/lib/admin/tariffDraft.js | 0 .../src/lib/admin/users.js | 0 .../patterns/admin/AdminBadge.svelte | 0 .../patterns/admin/AdminButton.svelte | 0 .../patterns/admin/AdminDashboardGrid.svelte | 0 .../patterns/admin/AdminDashboardStack.svelte | 0 .../patterns/admin/AdminEmptyState.svelte | 0 .../patterns/admin/AdminField.svelte | 0 .../patterns/admin/AdminPagination.svelte | 0 .../patterns/admin/AdminRevenueChart.svelte | 0 .../AdminRevenueCustomRangePopover.svelte | 0 .../patterns/admin/AdminSectionHeader.svelte | 0 .../patterns/admin/AdminSelect.svelte | 0 .../patterns/admin/AdminTable.svelte | 0 .../patterns/admin/AdminTableSkeleton.svelte | 0 .../patterns/admin/AdminTrafficCard.svelte | 0 .../lib/components/patterns/admin/index.js | 0 .../webapp/DialogOptionsSkeleton.svelte | 0 .../patterns/webapp/EmptyCard.svelte | 0 .../patterns/webapp/LanguageSelect.svelte | 0 .../patterns/webapp/LinearProgress.svelte | 0 .../patterns/webapp/PaymentMethodGrid.svelte | 0 .../patterns/webapp/StatusMessage.svelte | 0 .../lib/components/patterns/webapp/index.js | 0 .../src/lib/components/ui/badge.svelte | 0 .../src/lib/components/ui/button.svelte | 0 .../src/lib/components/ui/card.svelte | 0 .../lib/components/ui/card/card-action.svelte | 0 .../components/ui/card/card-content.svelte | 0 .../ui/card/card-description.svelte | 0 .../lib/components/ui/card/card-footer.svelte | 0 .../lib/components/ui/card/card-header.svelte | 0 .../lib/components/ui/card/card-title.svelte | 0 .../src/lib/components/ui/card/card.svelte | 0 .../src/lib/components/ui/card/index.js | 0 .../src/lib/components/ui/dialog.svelte | 0 .../src/lib/components/ui/icons.js | 0 .../src/lib/components/ui/index.js | 0 .../src/lib/components/ui/input.svelte | 0 .../src/lib/components/ui/primitives.js | 0 .../src/lib/components/ui/skeleton.svelte | 0 .../src/lib/components/ui/spinner.svelte | 0 .../frontend => frontend}/src/lib/utils.js | 0 .../src/lib/webapp/BrandMark.svelte | 0 .../src/lib/webapp/authHelpers.js | 0 .../src/lib/webapp/billingActions.js | 0 .../src/lib/webapp/billingOptionCache.js | 0 .../src/lib/webapp/browser.js | 0 .../src/lib/webapp/constants.js | 0 .../src/lib/webapp/devicesLabels.js | 0 .../src/lib/webapp/formatters.js | 0 .../src/lib/webapp/gravatar.js | 0 .../src/lib/webapp/i18n.js | 0 .../src/lib/webapp/mockApi.js | 0 .../src/lib/webapp/plurals.js | 0 .../src/lib/webapp/previewMock.js | 0 .../src/lib/webapp/publicApi.js | 0 .../src/lib/webapp/routes.js | 0 .../src/lib/webapp/session.js | 0 .../src/lib/webapp/stores/accountStore.js | 0 .../src/lib/webapp/stores/authStore.js | 0 .../src/lib/webapp/stores/billingStore.js | 0 .../src/lib/webapp/stores/devicesStore.js | 0 .../src/lib/webapp/tariffs.js | 0 .../src/lib/webapp/telegramSdk.js | 0 .../src/lib/webapp/themeStyle.js | 0 .../src/lib/webapp/traffic.js | 0 .../src/lib/webapp/webappBoot.js | 0 frontend/src/main.js | 36 ++ .../src/preview/BackTitle.svelte | 0 .../src/preview/PhoneFrame.svelte | 0 .../src/preview/PreviewMethods.svelte | 0 .../src/preview/PreviewNav.svelte | 0 .../web/frontend => frontend}/src/styles.css | 0 .../src/styles/admin-controls.css | 0 .../src/styles/admin-dialogs.css | 0 .../src/styles/admin.css | 0 .../frontend => frontend}/src/styles/base.css | 0 .../src/styles/components.css | 0 .../src/styles/controls.css | 0 .../src/styles/dialogs.css | 0 .../src/styles/tailwind.css | 0 .../src/styles/webapp.css | 0 .../src/webapp/BottomNav.svelte | 0 .../src/webapp/PaymentDialogs.svelte | 0 .../src/webapp/TariffDialogs.svelte | 0 .../src/webapp/WebAppShell.svelte | 0 .../src/webapp/auth/AuthScreen.svelte | 0 .../src/webapp/screens/DevicesScreen.svelte | 0 .../src/webapp/screens/HomeScreen.svelte | 0 .../src/webapp/screens/InviteScreen.svelte | 0 .../src/webapp/screens/SettingsScreen.svelte | 0 .../web/frontend => frontend}/vite.config.mjs | 2 +- locales/en.json | 2 + locales/ru.json | 2 + main.py | 68 --- package.json | 48 +- pyproject.toml | 4 +- pytest.ini | 2 +- requirements-dev.txt | 2 +- scripts/check-all.ps1 | 8 +- scripts/check-all.sh | 6 +- scripts/docker-build-images.ps1 | 18 + scripts/docker-build-images.sh | 19 + scripts/docker-push-images.ps1 | 21 + scripts/docker-push-images.sh | 18 + scripts/send_premium_traffic_warning_test.py | 3 + tests/test_settings.py | 41 +- tests/test_startup_banner.py | 36 ++ tests/test_subscription_service_behavior.py | 73 ++- tests/test_webapp_assets.py | 103 +++- 367 files changed, 2609 insertions(+), 864 deletions(-) delete mode 100644 Caddyfile delete mode 100644 Dockerfile create mode 100644 backend/app_logging.py rename {bot => backend/bot}/app/controllers/__init__.py (100%) rename {bot => backend/bot}/app/controllers/dispatcher_controller.py (83%) rename {bot => backend/bot}/app/factories/__init__.py (100%) rename {bot => backend/bot}/app/factories/build_services.py (100%) rename {bot => backend/bot}/app/web/__init__.py (100%) rename {bot => backend/bot}/app/web/admin_api.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/__init__.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/_runtime.py (97%) rename {bot => backend/bot}/app/web/admin_api_impl/ads.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/auth.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/broadcast.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/common.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/logs.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/panel.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/payments.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/promos.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/routes.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/settings.py (96%) rename {bot => backend/bot}/app/web/admin_api_impl/stats.py (100%) create mode 100644 backend/bot/app/web/admin_api_impl/sync.py rename {bot => backend/bot}/app/web/admin_api_impl/tariffs.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/themes.py (72%) rename {bot => backend/bot}/app/web/admin_api_impl/users.py (100%) rename {bot => backend/bot}/app/web/admin_settings_manifest.py (99%) rename {bot => backend/bot}/app/web/session.py (100%) rename {bot => backend/bot}/app/web/subscription_webapp.py (100%) rename {bot => backend/bot}/app/web/templates/subscription_webapp.html (89%) rename {bot => backend/bot}/app/web/themes/ascii/style.css (100%) rename {bot => backend/bot}/app/web/themes/ascii/theme.json (100%) rename {bot => backend/bot}/app/web/themes/dark/theme.json (100%) rename {bot => backend/bot}/app/web/themes/light/style.css (100%) rename {bot => backend/bot}/app/web/themes/light/theme.json (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/arrow-left.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/arrow-right.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/bitcoin.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/check.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/chevrons.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/coins.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/dashboard.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/database.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/download.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/error.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/file-text.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/folder.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/gift.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/globe.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/help.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/home.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/info.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/key.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/lock.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/megaphone.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/paintbrush.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/payment-card.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/print.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/refresh.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/save.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/search.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/send.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/settings.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/shield.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/sliders.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/smartphone.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/sparkles.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/tag.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/ticket.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/trash.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/user.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/users.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/warning.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/x.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/style.css (100%) rename {bot => backend/bot}/app/web/themes/windows95/theme.json (100%) rename {bot => backend/bot}/app/web/web_server.py (89%) rename {bot => backend/bot}/app/web/webapp/__init__.py (100%) rename {bot => backend/bot}/app/web/webapp/_runtime.py (94%) rename {bot => backend/bot}/app/web/webapp/account.py (98%) rename {bot => backend/bot}/app/web/webapp/application.py (100%) rename {bot => backend/bot}/app/web/webapp/assets.py (90%) rename {bot => backend/bot}/app/web/webapp/auth.py (100%) rename {bot => backend/bot}/app/web/webapp/billing.py (100%) rename {bot => backend/bot}/app/web/webapp/common.py (100%) rename {bot => backend/bot}/app/web/webapp/devices.py (100%) rename {bot => backend/bot}/app/web/webapp/payloads.py (100%) rename {bot => backend/bot}/app/web/webapp/routes.py (98%) rename {bot => backend/bot}/app/web/webapp/serializers.py (100%) rename {bot => backend/bot}/app/web/webapp_auth.py (100%) rename {bot => backend/bot}/filters/admin_filter.py (100%) rename {bot => backend/bot}/handlers/admin/__init__.py (100%) rename {bot => backend/bot}/handlers/admin/ads.py (100%) rename {bot => backend/bot}/handlers/admin/broadcast.py (100%) rename {bot => backend/bot}/handlers/admin/common.py (100%) rename {bot => backend/bot}/handlers/admin/logs_admin.py (100%) rename {bot => backend/bot}/handlers/admin/payments.py (100%) rename {bot => backend/bot}/handlers/admin/promo/__init__.py (100%) rename {bot => backend/bot}/handlers/admin/promo/bulk.py (100%) rename {bot => backend/bot}/handlers/admin/promo/create.py (100%) rename {bot => backend/bot}/handlers/admin/promo/manage.py (100%) rename {bot => backend/bot}/handlers/admin/statistics.py (100%) rename {bot => backend/bot}/handlers/admin/sync_admin.py (100%) rename {bot => backend/bot}/handlers/admin/user_management.py (98%) rename {bot => backend/bot}/handlers/inline_mode.py (100%) rename {bot => backend/bot}/handlers/user/__init__.py (100%) rename {bot => backend/bot}/handlers/user/payment.py (98%) rename {bot => backend/bot}/handlers/user/promo_user.py (100%) rename {bot => backend/bot}/handlers/user/referral.py (100%) rename {bot => backend/bot}/handlers/user/start.py (100%) rename {bot => backend/bot}/handlers/user/subscription/__init__.py (100%) rename {bot => backend/bot}/handlers/user/subscription/core.py (100%) rename {bot => backend/bot}/handlers/user/subscription/payment_methods.py (100%) rename {bot => backend/bot}/handlers/user/subscription/payments.py (100%) rename {bot => backend/bot}/handlers/user/subscription/payments_crypto.py (100%) rename {bot => backend/bot}/handlers/user/subscription/payments_freekassa.py (100%) rename {bot => backend/bot}/handlers/user/subscription/payments_platega.py (100%) rename {bot => backend/bot}/handlers/user/subscription/payments_severpay.py (100%) rename {bot => backend/bot}/handlers/user/subscription/payments_stars.py (100%) rename {bot => backend/bot}/handlers/user/subscription/payments_subscription.py (100%) rename {bot => backend/bot}/handlers/user/subscription/payments_yookassa.py (100%) rename {bot => backend/bot}/handlers/user/trial_handler.py (100%) create mode 100644 backend/bot/infra/redis.py create mode 100644 backend/bot/infra/webhook_queue.py rename {bot => backend/bot}/keyboards/inline/admin_keyboards.py (100%) rename {bot => backend/bot}/keyboards/inline/user_keyboards.py (100%) rename {bot => backend/bot}/main_bot.py (91%) rename {bot => backend/bot}/middlewares/action_logger_middleware.py (100%) rename {bot => backend/bot}/middlewares/ban_check_middleware.py (100%) rename {bot => backend/bot}/middlewares/channel_subscription.py (100%) rename {bot => backend/bot}/middlewares/db_session.py (100%) rename {bot => backend/bot}/middlewares/i18n.py (100%) rename {bot => backend/bot}/middlewares/profile_sync.py (100%) rename {bot => backend/bot}/routers.py (100%) rename {bot => backend/bot}/services/crypto_pay_service.py (100%) rename {bot => backend/bot}/services/email_auth_service.py (100%) rename {bot => backend/bot}/services/email_templates.py (100%) rename {bot => backend/bot}/services/freekassa_service.py (100%) rename {bot => backend/bot}/services/lknpd_client.py (100%) rename {bot => backend/bot}/services/lknpd_service.py (100%) rename {bot => backend/bot}/services/notification_service.py (100%) rename {bot => backend/bot}/services/panel_api_service.py (98%) rename {bot => backend/bot}/services/panel_webhook_service.py (95%) rename {bot => backend/bot}/services/platega_service.py (100%) rename {bot => backend/bot}/services/promo_code_service.py (100%) rename {bot => backend/bot}/services/referral_service.py (100%) rename {bot => backend/bot}/services/settings_override_service.py (56%) rename {bot => backend/bot}/services/severpay_service.py (100%) rename {bot => backend/bot}/services/stars_service.py (100%) rename {bot => backend/bot}/services/subscription_service.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/__init__.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/_runtime.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/core.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/devices.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/lifecycle.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/panel_identity.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/payments.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/renewal.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/tariffs.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/traffic.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/trial.py (98%) rename {bot => backend/bot}/services/tariff_worker.py (93%) rename {bot => backend/bot}/services/yookassa_service.py (100%) rename {bot => backend/bot}/states/admin_states.py (100%) rename {bot => backend/bot}/states/user_states.py (100%) rename {bot => backend/bot}/utils/__init__.py (100%) rename {bot => backend/bot}/utils/callback_answer.py (100%) rename {bot => backend/bot}/utils/config_link.py (100%) rename {bot => backend/bot}/utils/date_utils.py (100%) rename {bot => backend/bot}/utils/message_queue.py (100%) rename {bot => backend/bot}/utils/mini_app_url.py (100%) rename {bot => backend/bot}/utils/request_security.py (100%) rename {bot => backend/bot}/utils/telegram_markup.py (100%) rename {bot => backend/bot}/utils/text_sanitizer.py (100%) rename {bot => backend/bot}/utils/ttl_cache.py (64%) rename {config => backend/config}/settings.py (98%) rename {config => backend/config}/tariffs_config.py (100%) rename {config => backend/config}/webapp_themes_config.py (100%) rename {db => backend/db}/dal/__init__.py (100%) rename {db => backend/db}/dal/ad_dal.py (100%) rename {db => backend/db}/dal/app_settings_dal.py (100%) rename {db => backend/db}/dal/message_log_dal.py (100%) rename {db => backend/db}/dal/panel_sync_dal.py (100%) rename {db => backend/db}/dal/payment_dal.py (100%) rename {db => backend/db}/dal/promo_code_dal.py (100%) rename {db => backend/db}/dal/security_dal.py (100%) rename {db => backend/db}/dal/subscription_dal.py (93%) rename {db => backend/db}/dal/tariff_dal.py (100%) rename {db => backend/db}/dal/user_billing_dal.py (100%) rename {db => backend/db}/dal/user_dal.py (100%) rename {db => backend/db}/database_setup.py (96%) rename {db => backend/db}/migrator.py (100%) rename {db => backend/db}/models.py (100%) create mode 100644 backend/main.py create mode 100644 backend/main_backend.py create mode 100644 backend/main_migrate.py create mode 100644 backend/main_worker.py rename requirements.txt => backend/requirements.txt (95%) create mode 100644 backend/startup_banner.py delete mode 100644 bot/app/web/admin_api_impl/sync.py delete mode 100644 bot/app/web/frontend/src/main.js create mode 100644 deploy/compose/docker-compose-caddy.yml create mode 100644 deploy/compose/docker-compose-remote-server.yml create mode 100644 deploy/docker/Dockerfile create mode 100644 deploy/docker/caddy/Caddyfile create mode 100644 deploy/docker/frontend/00-startup-banner.sh create mode 100644 deploy/docker/frontend/nginx.conf delete mode 100644 docker-compose-caddy.yml delete mode 100644 docker-compose-remote-server.yml create mode 100644 docs/architecture.md rename .prettierignore => frontend/.prettierignore (100%) rename .prettierrc.json => frontend/.prettierrc.json (100%) rename components.json => frontend/components.json (85%) rename eslint.config.mjs => frontend/eslint.config.mjs (79%) rename {bot/app/web/frontend => frontend}/jsconfig.json (100%) rename package-lock.json => frontend/package-lock.json (100%) create mode 100644 frontend/package.json rename {scripts => frontend/scripts}/build_subscription_webapp_js.mjs (93%) rename {bot/app/web/frontend => frontend}/src/App.svelte (100%) rename {bot/app/web/frontend => frontend}/src/PreviewBoard.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/AdminPanel.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/AdsSection.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/AppearanceSection.svelte (97%) rename {bot/app/web/frontend => frontend}/src/admin/sections/BroadcastSection.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/LogsSection.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/PaymentsSection.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/PromosSection.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/SettingsSection.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/StatsSection.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/TariffEditorModal.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/TariffsSection.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/UserDetailModal.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/UsersSection.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/format.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/revenueSeriesAgg.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/adsStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/broadcastStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/logsStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/paymentsStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/promosStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/settingsStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/statsStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/tariffsStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/themesStore.js (94%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/usersStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/tariffDraft.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/users.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminBadge.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminButton.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminDashboardGrid.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminDashboardStack.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminEmptyState.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminField.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminPagination.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminRevenueChart.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminRevenueCustomRangePopover.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminSectionHeader.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminSelect.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminTable.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminTableSkeleton.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminTrafficCard.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/index.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/webapp/DialogOptionsSkeleton.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/webapp/EmptyCard.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/webapp/LanguageSelect.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/webapp/LinearProgress.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/webapp/StatusMessage.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/webapp/index.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/badge.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/button.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/card.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/card/card-action.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/card/card-content.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/card/card-description.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/card/card-footer.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/card/card-header.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/card/card-title.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/card/card.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/card/index.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/dialog.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/icons.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/index.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/input.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/primitives.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/skeleton.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/spinner.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/utils.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/BrandMark.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/authHelpers.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/billingActions.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/billingOptionCache.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/browser.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/constants.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/devicesLabels.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/formatters.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/gravatar.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/i18n.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/mockApi.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/plurals.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/previewMock.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/publicApi.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/routes.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/session.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/stores/accountStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/stores/authStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/stores/billingStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/stores/devicesStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/tariffs.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/telegramSdk.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/themeStyle.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/traffic.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/webappBoot.js (100%) create mode 100644 frontend/src/main.js rename {bot/app/web/frontend => frontend}/src/preview/BackTitle.svelte (100%) rename {bot/app/web/frontend => frontend}/src/preview/PhoneFrame.svelte (100%) rename {bot/app/web/frontend => frontend}/src/preview/PreviewMethods.svelte (100%) rename {bot/app/web/frontend => frontend}/src/preview/PreviewNav.svelte (100%) rename {bot/app/web/frontend => frontend}/src/styles.css (100%) rename {bot/app/web/frontend => frontend}/src/styles/admin-controls.css (100%) rename {bot/app/web/frontend => frontend}/src/styles/admin-dialogs.css (100%) rename {bot/app/web/frontend => frontend}/src/styles/admin.css (100%) rename {bot/app/web/frontend => frontend}/src/styles/base.css (100%) rename {bot/app/web/frontend => frontend}/src/styles/components.css (100%) rename {bot/app/web/frontend => frontend}/src/styles/controls.css (100%) rename {bot/app/web/frontend => frontend}/src/styles/dialogs.css (100%) rename {bot/app/web/frontend => frontend}/src/styles/tailwind.css (100%) rename {bot/app/web/frontend => frontend}/src/styles/webapp.css (100%) rename {bot/app/web/frontend => frontend}/src/webapp/BottomNav.svelte (100%) rename {bot/app/web/frontend => frontend}/src/webapp/PaymentDialogs.svelte (100%) rename {bot/app/web/frontend => frontend}/src/webapp/TariffDialogs.svelte (100%) rename {bot/app/web/frontend => frontend}/src/webapp/WebAppShell.svelte (100%) rename {bot/app/web/frontend => frontend}/src/webapp/auth/AuthScreen.svelte (100%) rename {bot/app/web/frontend => frontend}/src/webapp/screens/DevicesScreen.svelte (100%) rename {bot/app/web/frontend => frontend}/src/webapp/screens/HomeScreen.svelte (100%) rename {bot/app/web/frontend => frontend}/src/webapp/screens/InviteScreen.svelte (100%) rename {bot/app/web/frontend => frontend}/src/webapp/screens/SettingsScreen.svelte (100%) rename {bot/app/web/frontend => frontend}/vite.config.mjs (93%) delete mode 100644 main.py create mode 100644 scripts/docker-build-images.ps1 create mode 100644 scripts/docker-build-images.sh create mode 100644 scripts/docker-push-images.ps1 create mode 100644 scripts/docker-push-images.sh create mode 100644 tests/test_startup_banner.py diff --git a/.dockerignore b/.dockerignore index 224cdb1..9708826 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,7 +12,8 @@ scratch/ .claude/ *.local.* node_modules/ -docker-compose-dev.yml +frontend/node_modules/ +deploy/compose/docker-compose-dev.yml data/* !data/tariffs.example.json @@ -24,15 +25,14 @@ data/* # Docker docker-compose.yml -Dockerfile -.docker +deploy/compose/*.yml .dockerignore tmp/ # WebApp build artifacts (regenerated inside Docker) -bot/app/web/templates/subscription_webapp.css -bot/app/web/templates/subscription_webapp.js -bot/app/web/templates/subscription_webapp.min.*.js +backend/bot/app/web/templates/subscription_webapp.css +backend/bot/app/web/templates/subscription_webapp.js +backend/bot/app/web/templates/subscription_webapp.min.*.js # Byte-compiled / optimized / DLL files **/__pycache__/ diff --git a/.env.example b/.env.example index 16c6036..07c836b 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,21 @@ POSTGRES_PASSWORD= # R POSTGRES_HOST=remnawave-minishop-db # Database container name POSTGRES_PORT=5432 # Port POSTGRES_DB=postgres # Database name +DB_POOL_SIZE=20 # SQLAlchemy async pool size per backend/worker process +DB_MAX_OVERFLOW=10 # Extra transient DB connections above pool size +DB_POOL_TIMEOUT_SECONDS=30 # Seconds to wait for a DB pool connection +DB_POOL_RECYCLE_SECONDS=1800 # Recycle DB connections to avoid stale sockets + +REDIS_URL=redis://redis:6379/0 # Shared Redis for FSM, rate limits, cache, locks and queues +REDIS_KEY_PREFIX=remnawave-tg-shop # Prefix for Redis keys +WEBAPP_ME_CACHE_TTL_SECONDS=15 # Short TTL for /api/me payload cache +WEBAPP_RATE_LIMIT_TTL_SECONDS=60 # Redis rate-limit window +WEBAPP_RATE_LIMIT_MAX_REQUESTS=30 # Requests per window/action/user/IP +WEBHOOK_QUEUE_NAME=webhook-events # Redis queue for heavy webhook processing +WEBHOOK_QUEUE_CONCURRENCY=4 # Worker webhook consumers +WORKER_PANEL_SYNC_INTERVAL_SECONDS=900 # Worker panel sync interval +TARIFF_WORKER_LOCK_TTL_SECONDS=240 # Redis lock TTL for tariff tick +TARIFF_WORKER_TICK_SECONDS=300 # Tariff worker tick interval # Localization and Display DEFAULT_LANGUAGE="ru" # or "en" @@ -146,8 +161,6 @@ STARS_PRICE_12_MONTHS=0 TRAFFIC_PACKAGES=10:199,50:799 # Format: ":", 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 diff --git a/.gitignore b/.gitignore index d57223c..a4316fe 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ bot_database.sqlite3 .env .env.* !.env.example +docker-compose-dev.yml scratch_*.py *.local.* node_modules/ @@ -14,6 +15,9 @@ node_modules/ bot/app/web/templates/subscription_webapp.css bot/app/web/templates/subscription_webapp.js bot/app/web/templates/subscription_webapp.min.*.js +backend/bot/app/web/templates/subscription_webapp.css +backend/bot/app/web/templates/subscription_webapp.js +backend/bot/app/web/templates/subscription_webapp.min.*.js tmp .claude @@ -32,4 +36,3 @@ locales/en_backup.json db/models_old.py data/* !data/tariffs.example.json -docker-compose-dev.yml diff --git a/Caddyfile b/Caddyfile deleted file mode 100644 index 0b1d726..0000000 --- a/Caddyfile +++ /dev/null @@ -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} -} diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 524a2fd..0000000 --- a/Dockerfile +++ /dev/null @@ -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"] diff --git a/README.md b/README.md index afe9b61..21e354d 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,13 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи ## Стек -Сборка и runtime задаются **Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **requirements.txt** и **package.json**. +Сборка и runtime задаются **deploy/docker/Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **backend/requirements.txt** и **frontend/package.json**. | Слой | Технологии | | --- | --- | | Backend | Python **3.12**, [aiogram](https://docs.aiogram.dev/) 3.x (Telegram), **aiohttp** (HTTP и Web App), **SQLAlchemy** 2 async, **asyncpg**, **Pydantic** / pydantic-settings, **httpx**, платёжные SDK (в т.ч. YooKassa, aiocryptopay), **PyJWT** | -| Данные | **PostgreSQL** **17** (сервис `remnawave-minishop-db` в Compose) | -| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `bot/app/web/templates/` | +| Данные | **PostgreSQL** **17** (сервис `postgres` в Compose) и **Redis** **7** (сервис `redis`) | +| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `backend/bot/app/web/templates/` | Локальная разработка без Docker возможна при установленных Python 3.12, PostgreSQL и (для пересборки фронта) Node 22; типичный сценарий — всё через Compose. @@ -68,7 +68,7 @@ cd remnawave-minishop cp .env.example .env nano .env docker compose up -d --build -docker compose logs -f remnawave-minishop +docker compose logs -f backend worker frontend ``` Минимально заполните в `.env`: @@ -98,15 +98,21 @@ chmod -R u+rwX data docker compose up -d --build # Логи приложения -docker compose logs -f remnawave-minishop +docker compose logs -f backend worker frontend # Запуск с Caddy -docker compose -f docker-compose-caddy.yml up -d --build +docker compose -f deploy/compose/docker-compose-caddy.yml up -d # Запуск из готового образа -IMAGE_TAG=3.1.0 docker compose -f docker-compose-remote-server.yml up -d +IMAGE_TAG=3.1.0 docker compose -f deploy/compose/docker-compose-remote-server.yml up -d ``` +GHCR image names for releases: + +- `ghcr.io/3252a8/remnawave-minishop-backend` +- `ghcr.io/3252a8/remnawave-minishop-worker` +- `ghcr.io/3252a8/remnawave-minishop-frontend` + ## Поддержка - Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)` diff --git a/backend/app_logging.py b/backend/app_logging.py new file mode 100644 index 0000000..1619c2e --- /dev/null +++ b/backend/app_logging.py @@ -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", + ) diff --git a/bot/app/controllers/__init__.py b/backend/bot/app/controllers/__init__.py similarity index 100% rename from bot/app/controllers/__init__.py rename to backend/bot/app/controllers/__init__.py diff --git a/bot/app/controllers/dispatcher_controller.py b/backend/bot/app/controllers/dispatcher_controller.py similarity index 83% rename from bot/app/controllers/dispatcher_controller.py rename to backend/bot/app/controllers/dispatcher_controller.py index ed605b5..14deecf 100644 --- a/bot/app/controllers/dispatcher_controller.py +++ b/backend/bot/app/controllers/dispatcher_controller.py @@ -6,6 +6,11 @@ from aiogram.enums import ParseMode from aiogram.fsm.storage.memory import MemoryStorage from sqlalchemy.orm import sessionmaker +try: + from aiogram.fsm.storage.redis import RedisStorage +except ModuleNotFoundError: # pragma: no cover - dependency is installed in Docker image + RedisStorage = None # type: ignore[assignment] + from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware from bot.middlewares.ban_check_middleware import BanCheckMiddleware from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware @@ -18,7 +23,11 @@ from config.settings import Settings def build_dispatcher( settings: Settings, async_session_factory: sessionmaker ) -> tuple[Dispatcher, Bot, Dict]: - storage = MemoryStorage() + storage = ( + RedisStorage.from_url(settings.REDIS_URL) + if settings.REDIS_URL and RedisStorage is not None + else MemoryStorage() + ) default_props = DefaultBotProperties(parse_mode=ParseMode.HTML) bot = Bot(token=settings.BOT_TOKEN, default=default_props) diff --git a/bot/app/factories/__init__.py b/backend/bot/app/factories/__init__.py similarity index 100% rename from bot/app/factories/__init__.py rename to backend/bot/app/factories/__init__.py diff --git a/bot/app/factories/build_services.py b/backend/bot/app/factories/build_services.py similarity index 100% rename from bot/app/factories/build_services.py rename to backend/bot/app/factories/build_services.py diff --git a/bot/app/web/__init__.py b/backend/bot/app/web/__init__.py similarity index 100% rename from bot/app/web/__init__.py rename to backend/bot/app/web/__init__.py diff --git a/bot/app/web/admin_api.py b/backend/bot/app/web/admin_api.py similarity index 100% rename from bot/app/web/admin_api.py rename to backend/bot/app/web/admin_api.py diff --git a/bot/app/web/admin_api_impl/__init__.py b/backend/bot/app/web/admin_api_impl/__init__.py similarity index 100% rename from bot/app/web/admin_api_impl/__init__.py rename to backend/bot/app/web/admin_api_impl/__init__.py diff --git a/bot/app/web/admin_api_impl/_runtime.py b/backend/bot/app/web/admin_api_impl/_runtime.py similarity index 97% rename from bot/app/web/admin_api_impl/_runtime.py rename to backend/bot/app/web/admin_api_impl/_runtime.py index 560aef7..6cf286e 100644 --- a/bot/app/web/admin_api_impl/_runtime.py +++ b/backend/bot/app/web/admin_api_impl/_runtime.py @@ -28,6 +28,7 @@ from sqlalchemy.orm import sessionmaker from bot.app.web.admin_settings_manifest import ( manifest_payload, ) +from bot.infra.webhook_queue import enqueue_webhook_event from bot.services.referral_service import ReferralService from bot.services.settings_override_service import ( current_value, diff --git a/bot/app/web/admin_api_impl/ads.py b/backend/bot/app/web/admin_api_impl/ads.py similarity index 100% rename from bot/app/web/admin_api_impl/ads.py rename to backend/bot/app/web/admin_api_impl/ads.py diff --git a/bot/app/web/admin_api_impl/auth.py b/backend/bot/app/web/admin_api_impl/auth.py similarity index 100% rename from bot/app/web/admin_api_impl/auth.py rename to backend/bot/app/web/admin_api_impl/auth.py diff --git a/bot/app/web/admin_api_impl/broadcast.py b/backend/bot/app/web/admin_api_impl/broadcast.py similarity index 100% rename from bot/app/web/admin_api_impl/broadcast.py rename to backend/bot/app/web/admin_api_impl/broadcast.py diff --git a/bot/app/web/admin_api_impl/common.py b/backend/bot/app/web/admin_api_impl/common.py similarity index 100% rename from bot/app/web/admin_api_impl/common.py rename to backend/bot/app/web/admin_api_impl/common.py diff --git a/bot/app/web/admin_api_impl/logs.py b/backend/bot/app/web/admin_api_impl/logs.py similarity index 100% rename from bot/app/web/admin_api_impl/logs.py rename to backend/bot/app/web/admin_api_impl/logs.py diff --git a/bot/app/web/admin_api_impl/panel.py b/backend/bot/app/web/admin_api_impl/panel.py similarity index 100% rename from bot/app/web/admin_api_impl/panel.py rename to backend/bot/app/web/admin_api_impl/panel.py diff --git a/bot/app/web/admin_api_impl/payments.py b/backend/bot/app/web/admin_api_impl/payments.py similarity index 100% rename from bot/app/web/admin_api_impl/payments.py rename to backend/bot/app/web/admin_api_impl/payments.py diff --git a/bot/app/web/admin_api_impl/promos.py b/backend/bot/app/web/admin_api_impl/promos.py similarity index 100% rename from bot/app/web/admin_api_impl/promos.py rename to backend/bot/app/web/admin_api_impl/promos.py diff --git a/bot/app/web/admin_api_impl/routes.py b/backend/bot/app/web/admin_api_impl/routes.py similarity index 100% rename from bot/app/web/admin_api_impl/routes.py rename to backend/bot/app/web/admin_api_impl/routes.py diff --git a/bot/app/web/admin_api_impl/settings.py b/backend/bot/app/web/admin_api_impl/settings.py similarity index 96% rename from bot/app/web/admin_api_impl/settings.py rename to backend/bot/app/web/admin_api_impl/settings.py index e201123..5f13ee4 100644 --- a/bot/app/web/admin_api_impl/settings.py +++ b/backend/bot/app/web/admin_api_impl/settings.py @@ -83,5 +83,8 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response: or "WEBAPP_LOGO_FAVICON_URL" in deletes ): request.app["webapp_logo_cache"] = None + from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets + + prune_unused_appearance_assets(settings) return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)}) diff --git a/bot/app/web/admin_api_impl/stats.py b/backend/bot/app/web/admin_api_impl/stats.py similarity index 100% rename from bot/app/web/admin_api_impl/stats.py rename to backend/bot/app/web/admin_api_impl/stats.py diff --git a/backend/bot/app/web/admin_api_impl/sync.py b/backend/bot/app/web/admin_api_impl/sync.py new file mode 100644 index 0000000..6d98664 --- /dev/null +++ b/backend/bot/app/web/admin_api_impl/sync.py @@ -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") diff --git a/bot/app/web/admin_api_impl/tariffs.py b/backend/bot/app/web/admin_api_impl/tariffs.py similarity index 100% rename from bot/app/web/admin_api_impl/tariffs.py rename to backend/bot/app/web/admin_api_impl/tariffs.py diff --git a/bot/app/web/admin_api_impl/themes.py b/backend/bot/app/web/admin_api_impl/themes.py similarity index 72% rename from bot/app/web/admin_api_impl/themes.py rename to backend/bot/app/web/admin_api_impl/themes.py index 22b89f3..b173497 100644 --- a/bot/app/web/admin_api_impl/themes.py +++ b/backend/bot/app/web/admin_api_impl/themes.py @@ -4,6 +4,7 @@ from ._runtime import * # noqa: F403,F405 import asyncio import hashlib import ipaddress +import shutil import re import socket @@ -19,10 +20,11 @@ from config.webapp_themes_config import ( WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 -WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" / "uploads" +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[4] / "data" / "webapp-logo" / "favicons" +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", @@ -73,6 +75,113 @@ def _write_uploaded_logo(body: bytes, content_type: str = "", filename: str = "" 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 + + cache = request.app.get("webapp_settings_cache") + if isinstance(cache, dict): + cache["ts"] = 0.0 + cache["data"] = {} + request.app["webapp_logo_cache"] = None + prune_unused_appearance_assets(settings) + return True + + def _image_to_square_icon(source: Image.Image, size: int) -> Image.Image: fitted = source.copy() fitted.thumbnail((size, size), Image.Resampling.LANCZOS) @@ -221,7 +330,7 @@ async def _fetch_logo_from_url(url: str) -> tuple[bytes, str, str]: async def admin_appearance_logo_upload_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) + actor_id = _require_admin_user_id(request) content_type = (request.headers.get("Content-Type") or "").lower() try: if content_type.startswith("multipart/form-data"): @@ -242,12 +351,25 @@ async def admin_appearance_logo_upload_route(request: web.Request) -> web.Respon 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, **favicon_payload}) + return _ok({"logo_url": logo_url, "persisted": persisted, **favicon_payload}) async def admin_appearance_favicon_upload_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) + actor_id = _require_admin_user_id(request) content_type = (request.headers.get("Content-Type") or "").lower() try: if content_type.startswith("multipart/form-data"): @@ -264,8 +386,16 @@ async def admin_appearance_favicon_upload_route(request: web.Request) -> web.Res 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(favicon_payload) + return _ok({"persisted": persisted, **favicon_payload}) async def admin_themes_get_route(request: web.Request) -> web.Response: diff --git a/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py similarity index 100% rename from bot/app/web/admin_api_impl/users.py rename to backend/bot/app/web/admin_api_impl/users.py diff --git a/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py similarity index 99% rename from bot/app/web/admin_settings_manifest.py rename to backend/bot/app/web/admin_settings_manifest.py index 457bc95..739f99a 100644 --- a/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -100,7 +100,12 @@ SETTINGS_MANIFEST: List[SettingField] = [ ("noto-local", "Noto Emoji (local)"), ), ), - SettingField("WEBAPP_FAVICON_USE_CUSTOM", "bool", "appearance", "Использовать отдельную favicon"), + 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 включён"), diff --git a/bot/app/web/session.py b/backend/bot/app/web/session.py similarity index 100% rename from bot/app/web/session.py rename to backend/bot/app/web/session.py diff --git a/bot/app/web/subscription_webapp.py b/backend/bot/app/web/subscription_webapp.py similarity index 100% rename from bot/app/web/subscription_webapp.py rename to backend/bot/app/web/subscription_webapp.py diff --git a/bot/app/web/templates/subscription_webapp.html b/backend/bot/app/web/templates/subscription_webapp.html similarity index 89% rename from bot/app/web/templates/subscription_webapp.html rename to backend/bot/app/web/templates/subscription_webapp.html index 492c5c8..7e56ab7 100644 --- a/bot/app/web/templates/subscription_webapp.html +++ b/backend/bot/app/web/templates/subscription_webapp.html @@ -9,7 +9,6 @@ /minishop - diff --git a/bot/app/web/themes/ascii/style.css b/backend/bot/app/web/themes/ascii/style.css similarity index 100% rename from bot/app/web/themes/ascii/style.css rename to backend/bot/app/web/themes/ascii/style.css diff --git a/bot/app/web/themes/ascii/theme.json b/backend/bot/app/web/themes/ascii/theme.json similarity index 100% rename from bot/app/web/themes/ascii/theme.json rename to backend/bot/app/web/themes/ascii/theme.json diff --git a/bot/app/web/themes/dark/theme.json b/backend/bot/app/web/themes/dark/theme.json similarity index 100% rename from bot/app/web/themes/dark/theme.json rename to backend/bot/app/web/themes/dark/theme.json diff --git a/bot/app/web/themes/light/style.css b/backend/bot/app/web/themes/light/style.css similarity index 100% rename from bot/app/web/themes/light/style.css rename to backend/bot/app/web/themes/light/style.css diff --git a/bot/app/web/themes/light/theme.json b/backend/bot/app/web/themes/light/theme.json similarity index 100% rename from bot/app/web/themes/light/theme.json rename to backend/bot/app/web/themes/light/theme.json diff --git a/bot/app/web/themes/windows95/icons/arrow-left.png b/backend/bot/app/web/themes/windows95/icons/arrow-left.png similarity index 100% rename from bot/app/web/themes/windows95/icons/arrow-left.png rename to backend/bot/app/web/themes/windows95/icons/arrow-left.png diff --git a/bot/app/web/themes/windows95/icons/arrow-right.png b/backend/bot/app/web/themes/windows95/icons/arrow-right.png similarity index 100% rename from bot/app/web/themes/windows95/icons/arrow-right.png rename to backend/bot/app/web/themes/windows95/icons/arrow-right.png diff --git a/bot/app/web/themes/windows95/icons/bitcoin.png b/backend/bot/app/web/themes/windows95/icons/bitcoin.png similarity index 100% rename from bot/app/web/themes/windows95/icons/bitcoin.png rename to backend/bot/app/web/themes/windows95/icons/bitcoin.png diff --git a/bot/app/web/themes/windows95/icons/check.png b/backend/bot/app/web/themes/windows95/icons/check.png similarity index 100% rename from bot/app/web/themes/windows95/icons/check.png rename to backend/bot/app/web/themes/windows95/icons/check.png diff --git a/bot/app/web/themes/windows95/icons/chevrons.png b/backend/bot/app/web/themes/windows95/icons/chevrons.png similarity index 100% rename from bot/app/web/themes/windows95/icons/chevrons.png rename to backend/bot/app/web/themes/windows95/icons/chevrons.png diff --git a/bot/app/web/themes/windows95/icons/coins.png b/backend/bot/app/web/themes/windows95/icons/coins.png similarity index 100% rename from bot/app/web/themes/windows95/icons/coins.png rename to backend/bot/app/web/themes/windows95/icons/coins.png diff --git a/bot/app/web/themes/windows95/icons/dashboard.png b/backend/bot/app/web/themes/windows95/icons/dashboard.png similarity index 100% rename from bot/app/web/themes/windows95/icons/dashboard.png rename to backend/bot/app/web/themes/windows95/icons/dashboard.png diff --git a/bot/app/web/themes/windows95/icons/database.png b/backend/bot/app/web/themes/windows95/icons/database.png similarity index 100% rename from bot/app/web/themes/windows95/icons/database.png rename to backend/bot/app/web/themes/windows95/icons/database.png diff --git a/bot/app/web/themes/windows95/icons/download.png b/backend/bot/app/web/themes/windows95/icons/download.png similarity index 100% rename from bot/app/web/themes/windows95/icons/download.png rename to backend/bot/app/web/themes/windows95/icons/download.png diff --git a/bot/app/web/themes/windows95/icons/error.png b/backend/bot/app/web/themes/windows95/icons/error.png similarity index 100% rename from bot/app/web/themes/windows95/icons/error.png rename to backend/bot/app/web/themes/windows95/icons/error.png diff --git a/bot/app/web/themes/windows95/icons/file-text.png b/backend/bot/app/web/themes/windows95/icons/file-text.png similarity index 100% rename from bot/app/web/themes/windows95/icons/file-text.png rename to backend/bot/app/web/themes/windows95/icons/file-text.png diff --git a/bot/app/web/themes/windows95/icons/folder.png b/backend/bot/app/web/themes/windows95/icons/folder.png similarity index 100% rename from bot/app/web/themes/windows95/icons/folder.png rename to backend/bot/app/web/themes/windows95/icons/folder.png diff --git a/bot/app/web/themes/windows95/icons/gift.png b/backend/bot/app/web/themes/windows95/icons/gift.png similarity index 100% rename from bot/app/web/themes/windows95/icons/gift.png rename to backend/bot/app/web/themes/windows95/icons/gift.png diff --git a/bot/app/web/themes/windows95/icons/globe.png b/backend/bot/app/web/themes/windows95/icons/globe.png similarity index 100% rename from bot/app/web/themes/windows95/icons/globe.png rename to backend/bot/app/web/themes/windows95/icons/globe.png diff --git a/bot/app/web/themes/windows95/icons/help.png b/backend/bot/app/web/themes/windows95/icons/help.png similarity index 100% rename from bot/app/web/themes/windows95/icons/help.png rename to backend/bot/app/web/themes/windows95/icons/help.png diff --git a/bot/app/web/themes/windows95/icons/home.png b/backend/bot/app/web/themes/windows95/icons/home.png similarity index 100% rename from bot/app/web/themes/windows95/icons/home.png rename to backend/bot/app/web/themes/windows95/icons/home.png diff --git a/bot/app/web/themes/windows95/icons/info.png b/backend/bot/app/web/themes/windows95/icons/info.png similarity index 100% rename from bot/app/web/themes/windows95/icons/info.png rename to backend/bot/app/web/themes/windows95/icons/info.png diff --git a/bot/app/web/themes/windows95/icons/key.png b/backend/bot/app/web/themes/windows95/icons/key.png similarity index 100% rename from bot/app/web/themes/windows95/icons/key.png rename to backend/bot/app/web/themes/windows95/icons/key.png diff --git a/bot/app/web/themes/windows95/icons/lock.png b/backend/bot/app/web/themes/windows95/icons/lock.png similarity index 100% rename from bot/app/web/themes/windows95/icons/lock.png rename to backend/bot/app/web/themes/windows95/icons/lock.png diff --git a/bot/app/web/themes/windows95/icons/megaphone.png b/backend/bot/app/web/themes/windows95/icons/megaphone.png similarity index 100% rename from bot/app/web/themes/windows95/icons/megaphone.png rename to backend/bot/app/web/themes/windows95/icons/megaphone.png diff --git a/bot/app/web/themes/windows95/icons/paintbrush.png b/backend/bot/app/web/themes/windows95/icons/paintbrush.png similarity index 100% rename from bot/app/web/themes/windows95/icons/paintbrush.png rename to backend/bot/app/web/themes/windows95/icons/paintbrush.png diff --git a/bot/app/web/themes/windows95/icons/payment-card.png b/backend/bot/app/web/themes/windows95/icons/payment-card.png similarity index 100% rename from bot/app/web/themes/windows95/icons/payment-card.png rename to backend/bot/app/web/themes/windows95/icons/payment-card.png diff --git a/bot/app/web/themes/windows95/icons/print.png b/backend/bot/app/web/themes/windows95/icons/print.png similarity index 100% rename from bot/app/web/themes/windows95/icons/print.png rename to backend/bot/app/web/themes/windows95/icons/print.png diff --git a/bot/app/web/themes/windows95/icons/refresh.png b/backend/bot/app/web/themes/windows95/icons/refresh.png similarity index 100% rename from bot/app/web/themes/windows95/icons/refresh.png rename to backend/bot/app/web/themes/windows95/icons/refresh.png diff --git a/bot/app/web/themes/windows95/icons/save.png b/backend/bot/app/web/themes/windows95/icons/save.png similarity index 100% rename from bot/app/web/themes/windows95/icons/save.png rename to backend/bot/app/web/themes/windows95/icons/save.png diff --git a/bot/app/web/themes/windows95/icons/search.png b/backend/bot/app/web/themes/windows95/icons/search.png similarity index 100% rename from bot/app/web/themes/windows95/icons/search.png rename to backend/bot/app/web/themes/windows95/icons/search.png diff --git a/bot/app/web/themes/windows95/icons/send.png b/backend/bot/app/web/themes/windows95/icons/send.png similarity index 100% rename from bot/app/web/themes/windows95/icons/send.png rename to backend/bot/app/web/themes/windows95/icons/send.png diff --git a/bot/app/web/themes/windows95/icons/settings.png b/backend/bot/app/web/themes/windows95/icons/settings.png similarity index 100% rename from bot/app/web/themes/windows95/icons/settings.png rename to backend/bot/app/web/themes/windows95/icons/settings.png diff --git a/bot/app/web/themes/windows95/icons/shield.png b/backend/bot/app/web/themes/windows95/icons/shield.png similarity index 100% rename from bot/app/web/themes/windows95/icons/shield.png rename to backend/bot/app/web/themes/windows95/icons/shield.png diff --git a/bot/app/web/themes/windows95/icons/sliders.png b/backend/bot/app/web/themes/windows95/icons/sliders.png similarity index 100% rename from bot/app/web/themes/windows95/icons/sliders.png rename to backend/bot/app/web/themes/windows95/icons/sliders.png diff --git a/bot/app/web/themes/windows95/icons/smartphone.png b/backend/bot/app/web/themes/windows95/icons/smartphone.png similarity index 100% rename from bot/app/web/themes/windows95/icons/smartphone.png rename to backend/bot/app/web/themes/windows95/icons/smartphone.png diff --git a/bot/app/web/themes/windows95/icons/sparkles.png b/backend/bot/app/web/themes/windows95/icons/sparkles.png similarity index 100% rename from bot/app/web/themes/windows95/icons/sparkles.png rename to backend/bot/app/web/themes/windows95/icons/sparkles.png diff --git a/bot/app/web/themes/windows95/icons/tag.png b/backend/bot/app/web/themes/windows95/icons/tag.png similarity index 100% rename from bot/app/web/themes/windows95/icons/tag.png rename to backend/bot/app/web/themes/windows95/icons/tag.png diff --git a/bot/app/web/themes/windows95/icons/ticket.png b/backend/bot/app/web/themes/windows95/icons/ticket.png similarity index 100% rename from bot/app/web/themes/windows95/icons/ticket.png rename to backend/bot/app/web/themes/windows95/icons/ticket.png diff --git a/bot/app/web/themes/windows95/icons/trash.png b/backend/bot/app/web/themes/windows95/icons/trash.png similarity index 100% rename from bot/app/web/themes/windows95/icons/trash.png rename to backend/bot/app/web/themes/windows95/icons/trash.png diff --git a/bot/app/web/themes/windows95/icons/user.png b/backend/bot/app/web/themes/windows95/icons/user.png similarity index 100% rename from bot/app/web/themes/windows95/icons/user.png rename to backend/bot/app/web/themes/windows95/icons/user.png diff --git a/bot/app/web/themes/windows95/icons/users.png b/backend/bot/app/web/themes/windows95/icons/users.png similarity index 100% rename from bot/app/web/themes/windows95/icons/users.png rename to backend/bot/app/web/themes/windows95/icons/users.png diff --git a/bot/app/web/themes/windows95/icons/warning.png b/backend/bot/app/web/themes/windows95/icons/warning.png similarity index 100% rename from bot/app/web/themes/windows95/icons/warning.png rename to backend/bot/app/web/themes/windows95/icons/warning.png diff --git a/bot/app/web/themes/windows95/icons/x.png b/backend/bot/app/web/themes/windows95/icons/x.png similarity index 100% rename from bot/app/web/themes/windows95/icons/x.png rename to backend/bot/app/web/themes/windows95/icons/x.png diff --git a/bot/app/web/themes/windows95/style.css b/backend/bot/app/web/themes/windows95/style.css similarity index 100% rename from bot/app/web/themes/windows95/style.css rename to backend/bot/app/web/themes/windows95/style.css diff --git a/bot/app/web/themes/windows95/theme.json b/backend/bot/app/web/themes/windows95/theme.json similarity index 100% rename from bot/app/web/themes/windows95/theme.json rename to backend/bot/app/web/themes/windows95/theme.json diff --git a/bot/app/web/web_server.py b/backend/bot/app/web/web_server.py similarity index 89% rename from bot/app/web/web_server.py rename to backend/bot/app/web/web_server.py index cf3400a..6e00c33 100644 --- a/bot/app/web/web_server.py +++ b/backend/bot/app/web/web_server.py @@ -56,9 +56,24 @@ async def build_and_start_web_app( _inject_shared_instances(app, dp, bot, settings, async_session_factory) async def _healthcheck(request: web.Request) -> web.Response: - return web.json_response({"status": "ok"}) + payload = {"status": "ok"} + try: + from db.database_setup import async_engine + + pool = async_engine.pool if async_engine is not None else None + if pool is not None: + payload["db_pool"] = { + "checked_in": pool.checkedin(), + "checked_out": pool.checkedout(), + "size": pool.size(), + "overflow": pool.overflow(), + } + except Exception: + logging.exception("Failed to collect DB pool health metrics") + return web.json_response(payload) app.router.add_get("/healthz", _healthcheck) + app.router.add_get("/health", _healthcheck) setup_application(app, dp, bot=bot) diff --git a/bot/app/web/webapp/__init__.py b/backend/bot/app/web/webapp/__init__.py similarity index 100% rename from bot/app/web/webapp/__init__.py rename to backend/bot/app/web/webapp/__init__.py diff --git a/bot/app/web/webapp/_runtime.py b/backend/bot/app/web/webapp/_runtime.py similarity index 94% rename from bot/app/web/webapp/_runtime.py rename to backend/bot/app/web/webapp/_runtime.py index a148340..fddbe4e 100644 --- a/bot/app/web/webapp/_runtime.py +++ b/backend/bot/app/web/webapp/_runtime.py @@ -42,6 +42,7 @@ from bot.app.web.webapp_auth import ( verify_telegram_oauth_nonce, verify_webapp_session_token, ) +from bot.infra.redis import cache_get_json, cache_set_json, get_redis, redis_key from bot.services.crypto_pay_service import CryptoPayService from bot.services.email_auth_service import EmailAuthService, normalize_email from bot.services.email_templates import render_account_merged @@ -64,13 +65,14 @@ logger = logging.getLogger(__name__) TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html" ASSET_DIR = TEMPLATE_PATH.parent +APP_ROOT = Path(__file__).resolve().parents[5] WEBAPP_LOGO_PROXY_PATH = "/webapp-logo" -WEBAPP_LOGO_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" +WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo" WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads" WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo" WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons" WEBAPP_FAVICON_PATH = "/webapp-favicon" -WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-emoji" +WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji" WEBAPP_CONFIG_PLACEHOLDER = "" WEBAPP_I18N_PLACEHOLDER = "" WEBAPP_JS_PLACEHOLDER = "" diff --git a/bot/app/web/webapp/account.py b/backend/bot/app/web/webapp/account.py similarity index 98% rename from bot/app/web/webapp/account.py rename to backend/bot/app/web/webapp/account.py index 0471d38..a20a1ef 100644 --- a/bot/app/web/webapp/account.py +++ b/backend/bot/app/web/webapp/account.py @@ -350,7 +350,13 @@ async def account_telegram_link_route(request: web.Request) -> web.Response: async def me_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) + settings: Settings = request.app["settings"] + cache_key = redis_key(settings, "cache", "webapp", "me", user_id) + cached = await cache_get_json(settings, cache_key) + if cached: + return web.json_response({"ok": True, **cached}) data = await _build_user_payload(request, user_id) + await cache_set_json(settings, cache_key, data, settings.WEBAPP_ME_CACHE_TTL_SECONDS) return web.json_response({"ok": True, **data}) diff --git a/bot/app/web/webapp/application.py b/backend/bot/app/web/webapp/application.py similarity index 100% rename from bot/app/web/webapp/application.py rename to backend/bot/app/web/webapp/application.py diff --git a/bot/app/web/webapp/assets.py b/backend/bot/app/web/webapp/assets.py similarity index 90% rename from bot/app/web/webapp/assets.py rename to backend/bot/app/web/webapp/assets.py index ca3beee..f23bc0e 100644 --- a/bot/app/web/webapp/assets.py +++ b/backend/bot/app/web/webapp/assets.py @@ -182,6 +182,48 @@ def _is_proxyable_webapp_logo_url(logo_url: str) -> bool: return parsed_logo_url.scheme == "https" and bool(parsed_logo_url.hostname) +def _uploaded_webapp_logo_filename(logo_url: str) -> Optional[str]: + parsed_logo_url = urlsplit(str(logo_url or "")) + path = parsed_logo_url.path if parsed_logo_url.scheme or parsed_logo_url.netloc else logo_url + prefix = f"{WEBAPP_UPLOADED_LOGO_PATH}/" + if not path.startswith(prefix): + return None + filename = path.removeprefix(prefix) + if re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename): + return filename + return None + + +def _uploaded_webapp_logo_response(filename: str) -> web.Response: + if not re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename): + raise web.HTTPNotFound(text="webapp_logo_not_found") + + root = WEBAPP_UPLOADED_LOGO_DIR.expanduser().resolve() + path = (root / filename).resolve() + try: + path.relative_to(root) + except ValueError: + raise web.HTTPNotFound(text="webapp_logo_not_found") from None + + content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower()) + if not content_type: + raise web.HTTPNotFound(text="webapp_logo_not_found") + + try: + if path.stat().st_size > WEBAPP_LOGO_MAX_BYTES: + raise web.HTTPNotFound(text="webapp_logo_too_large") + body = path.read_bytes() + except OSError: + raise web.HTTPNotFound(text="webapp_logo_not_found") from None + + if not body: + raise web.HTTPNotFound(text="webapp_logo_not_found") + + response = web.Response(body=body, content_type=content_type) + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response + + def _emoji_to_codepoints(value: str) -> str: return "_".join(f"{ord(char):x}" for char in str(value or "").strip()) @@ -209,6 +251,10 @@ async def webapp_logo_route(request: web.Request) -> web.Response: if not raw_logo_url: raise web.HTTPNotFound(text="webapp_logo_not_configured") + uploaded_filename = _uploaded_webapp_logo_filename(raw_logo_url) + if uploaded_filename: + return _uploaded_webapp_logo_response(uploaded_filename) + if not _is_proxyable_webapp_logo_url(raw_logo_url): raise web.HTTPNotFound(text="webapp_logo_not_proxied") @@ -244,33 +290,7 @@ async def webapp_uploaded_logo_route(request: web.Request) -> web.Response: raise web.HTTPNotFound(text="webapp_disabled") filename = str(request.match_info.get("filename") or "").strip() - if not re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename): - raise web.HTTPNotFound(text="webapp_logo_not_found") - - root = WEBAPP_UPLOADED_LOGO_DIR.expanduser().resolve() - path = (root / filename).resolve() - try: - path.relative_to(root) - except ValueError: - raise web.HTTPNotFound(text="webapp_logo_not_found") from None - - content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower()) - if not content_type: - raise web.HTTPNotFound(text="webapp_logo_not_found") - - try: - if path.stat().st_size > WEBAPP_LOGO_MAX_BYTES: - raise web.HTTPNotFound(text="webapp_logo_too_large") - body = path.read_bytes() - except OSError: - raise web.HTTPNotFound(text="webapp_logo_not_found") from None - - if not body: - raise web.HTTPNotFound(text="webapp_logo_not_found") - - response = web.Response(body=body, content_type=content_type) - response.headers["Cache-Control"] = "public, max-age=31536000, immutable" - return response + return _uploaded_webapp_logo_response(filename) async def webapp_favicon_route(request: web.Request) -> web.Response: @@ -727,7 +747,7 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]: def _run_git_command(*args: str) -> str: - repo_root = Path(__file__).resolve().parents[3] + repo_root = APP_ROOT try: result = subprocess.run( ["git", *args], @@ -752,7 +772,7 @@ def _resolve_app_version() -> str: _APP_VERSION_CACHE = env_version return env_version - build_version_path = Path(__file__).resolve().parents[3] / ".build-version" + build_version_path = APP_ROOT / ".build-version" try: build_version = build_version_path.read_text(encoding="utf-8").strip() except OSError: @@ -796,6 +816,26 @@ async def _enforce_webapp_rate_limit( or "unknown" ) key = f"{action}:{ip_address}:{int(user_id)}" + redis = await get_redis(settings) + if redis is not None: + redis_rate_key = redis_key(settings, "rate-limit", "webapp", key) + current = await redis.incr(redis_rate_key) + if current == 1: + await redis.expire(redis_rate_key, settings.WEBAPP_RATE_LIMIT_TTL_SECONDS) + if current > settings.WEBAPP_RATE_LIMIT_MAX_REQUESTS: + ttl = await redis.ttl(redis_rate_key) + retry_after = max(1, int(ttl if ttl and ttl > 0 else WEBAPP_RATE_LIMIT_WINDOW_SECONDS)) + return web.json_response( + { + "ok": False, + "error": "rate_limited", + "retry_after": retry_after, + }, + status=429, + headers={"Retry-After": str(retry_after)}, + ) + return None + buckets: Dict[str, deque[float]] = request.app["webapp_rate_limit_buckets"] lock: asyncio.Lock = request.app["webapp_rate_limit_lock"] now = time.monotonic() @@ -847,6 +887,56 @@ async def js_asset_route(request: web.Request) -> web.Response: return response +def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]: + settings: Settings = request.app["settings"] + cached = _get_cached_webapp_settings(request) + themes_catalog = settings.webapp_themes_catalog + primary_color = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a" + preview_key = str(request.query.get("theme_preview") or "").strip() + preview_theme = themes_catalog.theme_by_key(preview_key) if preview_key else None + if preview_theme is None or not preview_theme.enabled: + preview_key = "" + i18n_instance: Optional[object] = request.app.get("i18n") + return { + "config": { + "title": settings.WEBAPP_TITLE, + "primaryColor": settings.WEBAPP_PRIMARY_COLOR, + "themesCatalog": public_themes_catalog_payload( + themes_catalog, + primary_color, + enabled_only=True, + ), + "themesDir": settings.WEBAPP_THEMES_DIR, + "themePreviewKey": preview_key, + "logoUrl": cached["logo_url"], + "logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI), + "logoEmoji": settings.WEBAPP_LOGO_EMOJI, + "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, + "faviconUrl": cached["favicon_url"], + "faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM), + "apiBase": "/api", + "telegramLoginBotUsername": request.app.get("bot_username") or "", + "telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0, + "telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0, + "telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings), + "supportUrl": cached["support_url"], + "termsUrl": cached["terms_url"], + "privacyPolicyUrl": cached["privacy_policy_url"], + "userAgreementUrl": cached["user_agreement_url"], + "currency": cached["currency"], + "language": cached["language"], + "emailAuthEnabled": cached["email_auth_enabled"], + "appVersion": _resolve_app_version(), + "appRepositoryUrl": APP_REPOSITORY_URL, + }, + "i18n": getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}, + } + + +async def bootstrap_route(request: web.Request) -> web.Response: + return web.json_response({"ok": True, **_build_webapp_bootstrap_payload(request)}) + + async def index_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] if not settings.WEBAPP_ENABLED: @@ -857,47 +947,13 @@ async def index_route(request: web.Request) -> web.Response: themes_catalog = settings.webapp_themes_catalog primary_color = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a" initial_theme = _initial_theme_for_request(request, themes_catalog) - preview_key = str(request.query.get("theme_preview") or "").strip() - preview_theme = themes_catalog.theme_by_key(preview_key) if preview_key else None - if preview_theme is None or not preview_theme.enabled: - preview_key = "" - config = { - "title": settings.WEBAPP_TITLE, - "primaryColor": settings.WEBAPP_PRIMARY_COLOR, - "themesCatalog": public_themes_catalog_payload( - themes_catalog, - primary_color, - enabled_only=True, - ), - "themesDir": settings.WEBAPP_THEMES_DIR, - "themePreviewKey": preview_key, - "logoUrl": cached["logo_url"], - "logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI), - "logoEmoji": settings.WEBAPP_LOGO_EMOJI, - "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, - "faviconUrl": cached["favicon_url"], - "faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM), - "apiBase": "/api", - "telegramLoginBotUsername": request.app.get("bot_username") or "", - "telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0, - "telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0, - "telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings), - "supportUrl": cached["support_url"], - "termsUrl": cached["terms_url"], - "privacyPolicyUrl": cached["privacy_policy_url"], - "userAgreementUrl": cached["user_agreement_url"], - "currency": cached["currency"], - "language": cached["language"], - "emailAuthEnabled": cached["email_auth_enabled"], - "appVersion": _resolve_app_version(), - "appRepositoryUrl": APP_REPOSITORY_URL, - } + bootstrap = _build_webapp_bootstrap_payload(request) + config = bootstrap["config"] html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER) initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color) if initial_theme_markup: html = html.replace("", f"{initial_theme_markup}\n", 1) - i18n_instance: Optional[object] = request.app.get("i18n") - i18n_payload = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {} + i18n_payload = bootstrap["i18n"] nonce = request.get("csp_nonce", "") html = html.replace( WEBAPP_CONFIG_PLACEHOLDER, @@ -934,13 +990,12 @@ async def index_route(request: web.Request) -> web.Response: brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI) if brand_asset_url: html = html.replace( - '', - f'', - ) - else: - html = html.replace( - '', - "", + "", + ( + f'\n' + ), + 1, ) return web.Response(text=html, content_type="text/html", charset="utf-8") diff --git a/bot/app/web/webapp/auth.py b/backend/bot/app/web/webapp/auth.py similarity index 100% rename from bot/app/web/webapp/auth.py rename to backend/bot/app/web/webapp/auth.py diff --git a/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py similarity index 100% rename from bot/app/web/webapp/billing.py rename to backend/bot/app/web/webapp/billing.py diff --git a/bot/app/web/webapp/common.py b/backend/bot/app/web/webapp/common.py similarity index 100% rename from bot/app/web/webapp/common.py rename to backend/bot/app/web/webapp/common.py diff --git a/bot/app/web/webapp/devices.py b/backend/bot/app/web/webapp/devices.py similarity index 100% rename from bot/app/web/webapp/devices.py rename to backend/bot/app/web/webapp/devices.py diff --git a/bot/app/web/webapp/payloads.py b/backend/bot/app/web/webapp/payloads.py similarity index 100% rename from bot/app/web/webapp/payloads.py rename to backend/bot/app/web/webapp/payloads.py diff --git a/bot/app/web/webapp/routes.py b/backend/bot/app/web/webapp/routes.py similarity index 98% rename from bot/app/web/webapp/routes.py rename to backend/bot/app/web/webapp/routes.py index d8a0b59..5d1f5ca 100644 --- a/bot/app/web/webapp/routes.py +++ b/backend/bot/app/web/webapp/routes.py @@ -44,6 +44,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_post("/api/auth/email/verify", email_auth_verify_route) app.router.add_post("/api/auth/email/magic", email_auth_magic_route) app.router.add_post("/api/auth/logout", logout_route) + app.router.add_get("/api/bootstrap", bootstrap_route) app.router.add_get("/api/me", me_route) app.router.add_get("/api/account/avatar", account_avatar_route) app.router.add_post("/api/account/language", account_language_route) diff --git a/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py similarity index 100% rename from bot/app/web/webapp/serializers.py rename to backend/bot/app/web/webapp/serializers.py diff --git a/bot/app/web/webapp_auth.py b/backend/bot/app/web/webapp_auth.py similarity index 100% rename from bot/app/web/webapp_auth.py rename to backend/bot/app/web/webapp_auth.py diff --git a/bot/filters/admin_filter.py b/backend/bot/filters/admin_filter.py similarity index 100% rename from bot/filters/admin_filter.py rename to backend/bot/filters/admin_filter.py diff --git a/bot/handlers/admin/__init__.py b/backend/bot/handlers/admin/__init__.py similarity index 100% rename from bot/handlers/admin/__init__.py rename to backend/bot/handlers/admin/__init__.py diff --git a/bot/handlers/admin/ads.py b/backend/bot/handlers/admin/ads.py similarity index 100% rename from bot/handlers/admin/ads.py rename to backend/bot/handlers/admin/ads.py diff --git a/bot/handlers/admin/broadcast.py b/backend/bot/handlers/admin/broadcast.py similarity index 100% rename from bot/handlers/admin/broadcast.py rename to backend/bot/handlers/admin/broadcast.py diff --git a/bot/handlers/admin/common.py b/backend/bot/handlers/admin/common.py similarity index 100% rename from bot/handlers/admin/common.py rename to backend/bot/handlers/admin/common.py diff --git a/bot/handlers/admin/logs_admin.py b/backend/bot/handlers/admin/logs_admin.py similarity index 100% rename from bot/handlers/admin/logs_admin.py rename to backend/bot/handlers/admin/logs_admin.py diff --git a/bot/handlers/admin/payments.py b/backend/bot/handlers/admin/payments.py similarity index 100% rename from bot/handlers/admin/payments.py rename to backend/bot/handlers/admin/payments.py diff --git a/bot/handlers/admin/promo/__init__.py b/backend/bot/handlers/admin/promo/__init__.py similarity index 100% rename from bot/handlers/admin/promo/__init__.py rename to backend/bot/handlers/admin/promo/__init__.py diff --git a/bot/handlers/admin/promo/bulk.py b/backend/bot/handlers/admin/promo/bulk.py similarity index 100% rename from bot/handlers/admin/promo/bulk.py rename to backend/bot/handlers/admin/promo/bulk.py diff --git a/bot/handlers/admin/promo/create.py b/backend/bot/handlers/admin/promo/create.py similarity index 100% rename from bot/handlers/admin/promo/create.py rename to backend/bot/handlers/admin/promo/create.py diff --git a/bot/handlers/admin/promo/manage.py b/backend/bot/handlers/admin/promo/manage.py similarity index 100% rename from bot/handlers/admin/promo/manage.py rename to backend/bot/handlers/admin/promo/manage.py diff --git a/bot/handlers/admin/statistics.py b/backend/bot/handlers/admin/statistics.py similarity index 100% rename from bot/handlers/admin/statistics.py rename to backend/bot/handlers/admin/statistics.py diff --git a/bot/handlers/admin/sync_admin.py b/backend/bot/handlers/admin/sync_admin.py similarity index 100% rename from bot/handlers/admin/sync_admin.py rename to backend/bot/handlers/admin/sync_admin.py diff --git a/bot/handlers/admin/user_management.py b/backend/bot/handlers/admin/user_management.py similarity index 98% rename from bot/handlers/admin/user_management.py rename to backend/bot/handlers/admin/user_management.py index 4d1e0bb..3a14cf8 100644 --- a/bot/handlers/admin/user_management.py +++ b/backend/bot/handlers/admin/user_management.py @@ -599,18 +599,29 @@ async def user_action_handler( return if action == "reset_trial": - await handle_reset_trial(callback, user, subscription_service, session, i18n, current_lang) + await handle_reset_trial( + callback, user, subscription_service, session, settings, i18n, current_lang + ) elif action == "add_subscription": await handle_add_subscription_prompt(callback, state, user, i18n, current_lang) elif action == "toggle_ban": - await handle_toggle_ban(callback, user, panel_service, session, i18n, current_lang) + await handle_toggle_ban( + callback, + user, + panel_service, + subscription_service, + session, + settings, + i18n, + current_lang, + ) elif action == "send_message": await handle_send_message_prompt(callback, state, user, i18n, current_lang) elif action == "view_logs": await handle_view_user_logs(callback, user, session, settings, i18n, current_lang) elif action == "refresh": await handle_refresh_user_card( - callback, user, subscription_service, session, i18n, current_lang + callback, user, subscription_service, session, settings, i18n, current_lang ) elif action == "delete_user": await handle_delete_user_prompt( @@ -626,6 +637,7 @@ async def user_action_handler( user, subscription_service, session, + settings, i18n, current_lang, unlimited=True, @@ -637,6 +649,7 @@ async def user_action_handler( user, subscription_service, session, + settings, i18n, current_lang, unlimited=False, @@ -724,6 +737,7 @@ async def handle_premium_override_apply( user: User, subscription_service: SubscriptionService, session: AsyncSession, + settings: Settings, i18n_instance, lang: str, *, @@ -764,7 +778,7 @@ async def handle_premium_override_apply( await callback.answer(_("admin_premium_override_saved"), show_alert=False) await handle_refresh_user_card( - callback, user, subscription_service, session, i18n_instance, lang + callback, user, subscription_service, session, settings, i18n_instance, lang ) except Exception as exc: logging.error( @@ -867,6 +881,7 @@ async def handle_reset_trial( user: User, subscription_service: SubscriptionService, session: AsyncSession, + settings: Settings, i18n_instance, lang: str, ): @@ -882,7 +897,7 @@ async def handle_reset_trial( # Refresh user card await handle_refresh_user_card( - callback, user, subscription_service, session, i18n_instance, lang + callback, user, subscription_service, session, settings, i18n_instance, lang ) except Exception as e: @@ -914,7 +929,9 @@ async def handle_toggle_ban( callback: types.CallbackQuery, user: User, panel_service: PanelApiService, + subscription_service: SubscriptionService, session: AsyncSession, + settings: Settings, i18n_instance, lang: str, ): @@ -946,15 +963,9 @@ async def handle_toggle_ban( # Refresh user card with updated ban status user.is_banned = new_ban_status # Update local object - from bot.services.panel_api_service import PanelApiService - from config.settings import Settings - - settings = Settings() - async with PanelApiService(settings) as panel_service: - subscription_service = SubscriptionService(settings, panel_service) - await handle_refresh_user_card( - callback, user, subscription_service, session, i18n_instance, lang - ) + await handle_refresh_user_card( + callback, user, subscription_service, session, settings, i18n_instance, lang + ) except Exception as e: logging.error(f"Error toggling ban for user {user.user_id}: {e}") @@ -1050,6 +1061,7 @@ async def handle_refresh_user_card( user: User, subscription_service: SubscriptionService, session: AsyncSession, + settings: Settings, i18n_instance, lang: str, ): @@ -1061,11 +1073,8 @@ async def handle_refresh_user_card( await callback.answer("User not found", show_alert=True) return - from config.settings import Settings as _Settings - - _settings = _Settings() referral_service = ReferralService( - _settings, subscription_service, callback.message.bot, i18n_instance + settings, subscription_service, callback.message.bot, i18n_instance ) bot_username = await _resolve_bot_username(callback.message.bot) user_card_text = await format_user_card( @@ -1075,7 +1084,7 @@ async def handle_refresh_user_card( i18n_instance, lang, referral_service, - settings=_settings, + settings=settings, bot_username=bot_username, ) keyboard = get_user_card_keyboard( diff --git a/bot/handlers/inline_mode.py b/backend/bot/handlers/inline_mode.py similarity index 100% rename from bot/handlers/inline_mode.py rename to backend/bot/handlers/inline_mode.py diff --git a/bot/handlers/user/__init__.py b/backend/bot/handlers/user/__init__.py similarity index 100% rename from bot/handlers/user/__init__.py rename to backend/bot/handlers/user/__init__.py diff --git a/bot/handlers/user/payment.py b/backend/bot/handlers/user/payment.py similarity index 98% rename from bot/handlers/user/payment.py rename to backend/bot/handlers/user/payment.py index b00eaa7..7b21aee 100644 --- a/bot/handlers/user/payment.py +++ b/backend/bot/handlers/user/payment.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker from yookassa.domain.notification import WebhookNotification +from bot.infra.webhook_queue import enqueue_webhook_event from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard from bot.middlewares.i18n import JsonI18n from bot.services.lknpd_service import LknpdService @@ -611,6 +612,22 @@ async def yookassa_webhook_route(request: web.Request): "payment_method": pm_dict, } + if notification_object.event in { + YOOKASSA_EVENT_PAYMENT_SUCCEEDED, + YOOKASSA_EVENT_PAYMENT_CANCELED, + }: + queued = await enqueue_webhook_event( + settings, + "yookassa", + { + "event": notification_object.event, + "payment": payment_dict_for_processing, + }, + event_id=f"{notification_object.event}:{payment_dict_for_processing.get('id')}", + ) + if queued: + return web.Response(status=200, text="queued") + async with payment_processing_lock: async with async_session_factory() as session: try: diff --git a/bot/handlers/user/promo_user.py b/backend/bot/handlers/user/promo_user.py similarity index 100% rename from bot/handlers/user/promo_user.py rename to backend/bot/handlers/user/promo_user.py diff --git a/bot/handlers/user/referral.py b/backend/bot/handlers/user/referral.py similarity index 100% rename from bot/handlers/user/referral.py rename to backend/bot/handlers/user/referral.py diff --git a/bot/handlers/user/start.py b/backend/bot/handlers/user/start.py similarity index 100% rename from bot/handlers/user/start.py rename to backend/bot/handlers/user/start.py diff --git a/bot/handlers/user/subscription/__init__.py b/backend/bot/handlers/user/subscription/__init__.py similarity index 100% rename from bot/handlers/user/subscription/__init__.py rename to backend/bot/handlers/user/subscription/__init__.py diff --git a/bot/handlers/user/subscription/core.py b/backend/bot/handlers/user/subscription/core.py similarity index 100% rename from bot/handlers/user/subscription/core.py rename to backend/bot/handlers/user/subscription/core.py diff --git a/bot/handlers/user/subscription/payment_methods.py b/backend/bot/handlers/user/subscription/payment_methods.py similarity index 100% rename from bot/handlers/user/subscription/payment_methods.py rename to backend/bot/handlers/user/subscription/payment_methods.py diff --git a/bot/handlers/user/subscription/payments.py b/backend/bot/handlers/user/subscription/payments.py similarity index 100% rename from bot/handlers/user/subscription/payments.py rename to backend/bot/handlers/user/subscription/payments.py diff --git a/bot/handlers/user/subscription/payments_crypto.py b/backend/bot/handlers/user/subscription/payments_crypto.py similarity index 100% rename from bot/handlers/user/subscription/payments_crypto.py rename to backend/bot/handlers/user/subscription/payments_crypto.py diff --git a/bot/handlers/user/subscription/payments_freekassa.py b/backend/bot/handlers/user/subscription/payments_freekassa.py similarity index 100% rename from bot/handlers/user/subscription/payments_freekassa.py rename to backend/bot/handlers/user/subscription/payments_freekassa.py diff --git a/bot/handlers/user/subscription/payments_platega.py b/backend/bot/handlers/user/subscription/payments_platega.py similarity index 100% rename from bot/handlers/user/subscription/payments_platega.py rename to backend/bot/handlers/user/subscription/payments_platega.py diff --git a/bot/handlers/user/subscription/payments_severpay.py b/backend/bot/handlers/user/subscription/payments_severpay.py similarity index 100% rename from bot/handlers/user/subscription/payments_severpay.py rename to backend/bot/handlers/user/subscription/payments_severpay.py diff --git a/bot/handlers/user/subscription/payments_stars.py b/backend/bot/handlers/user/subscription/payments_stars.py similarity index 100% rename from bot/handlers/user/subscription/payments_stars.py rename to backend/bot/handlers/user/subscription/payments_stars.py diff --git a/bot/handlers/user/subscription/payments_subscription.py b/backend/bot/handlers/user/subscription/payments_subscription.py similarity index 100% rename from bot/handlers/user/subscription/payments_subscription.py rename to backend/bot/handlers/user/subscription/payments_subscription.py diff --git a/bot/handlers/user/subscription/payments_yookassa.py b/backend/bot/handlers/user/subscription/payments_yookassa.py similarity index 100% rename from bot/handlers/user/subscription/payments_yookassa.py rename to backend/bot/handlers/user/subscription/payments_yookassa.py diff --git a/bot/handlers/user/trial_handler.py b/backend/bot/handlers/user/trial_handler.py similarity index 100% rename from bot/handlers/user/trial_handler.py rename to backend/bot/handlers/user/trial_handler.py diff --git a/backend/bot/infra/redis.py b/backend/bot/infra/redis.py new file mode 100644 index 0000000..7703bfc --- /dev/null +++ b/backend/bot/infra/redis.py @@ -0,0 +1,113 @@ +import asyncio +import json +import logging +import secrets +from contextlib import asynccontextmanager +from typing import Any, AsyncIterator, Optional + +try: + from redis.asyncio import Redis +except ModuleNotFoundError: # pragma: no cover - local dev environments may not be installed yet + Redis = None # type: ignore[assignment] + +from config.settings import Settings + +logger = logging.getLogger(__name__) + +_redis: Optional["Redis"] = None + + +def redis_key(settings: Settings, *parts: object) -> str: + prefix = (settings.REDIS_KEY_PREFIX or "remnawave-tg-shop").strip(":") + clean = [str(part).strip(":") for part in parts if str(part).strip(":")] + return ":".join([prefix, *clean]) + + +async def get_redis(settings: Settings) -> Optional["Redis"]: + global _redis + if not settings.REDIS_URL: + return None + if Redis is None: + logger.warning("REDIS_URL is set but redis package is not installed") + return None + if _redis is None: + _redis = Redis.from_url( + settings.REDIS_URL, + encoding="utf-8", + decode_responses=True, + health_check_interval=30, + ) + return _redis + + +async def close_redis() -> None: + global _redis + if _redis is not None: + await _redis.aclose() + _redis = None + + +async def cache_get_json(settings: Settings, key: str) -> Any: + redis = await get_redis(settings) + if redis is None: + return None + raw = await redis.get(key) + if raw is None: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + logger.warning("Invalid JSON in Redis cache key %s", key) + return None + + +async def cache_set_json(settings: Settings, key: str, value: Any, ttl_seconds: int) -> None: + redis = await get_redis(settings) + if redis is None: + return + await redis.set(key, json.dumps(value, ensure_ascii=False, default=str), ex=ttl_seconds) + + +async def cache_delete(settings: Settings, *keys: str) -> None: + redis = await get_redis(settings) + if redis is None or not keys: + return + await redis.delete(*keys) + + +@asynccontextmanager +async def redis_lock( + settings: Settings, + name: str, + *, + ttl_seconds: int, +) -> AsyncIterator[bool]: + redis = await get_redis(settings) + if redis is None: + yield True + return + + key = redis_key(settings, "lock", name) + token = secrets.token_urlsafe(16) + acquired = bool(await redis.set(key, token, nx=True, ex=ttl_seconds)) + try: + yield acquired + finally: + if acquired: + script = """ + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + end + return 0 + """ + try: + await redis.eval(script, 1, key, token) + except Exception: + logger.exception("Failed to release Redis lock %s", key) + + +async def sleep_or_stop(stop_event: asyncio.Event, seconds: float) -> None: + try: + await asyncio.wait_for(stop_event.wait(), timeout=seconds) + except asyncio.TimeoutError: + return diff --git a/backend/bot/infra/webhook_queue.py b/backend/bot/infra/webhook_queue.py new file mode 100644 index 0000000..5d794be --- /dev/null +++ b/backend/bot/infra/webhook_queue.py @@ -0,0 +1,63 @@ +import json +import logging +import time +from typing import Any, Dict, Optional + +from bot.infra.redis import get_redis, redis_key +from config.settings import Settings + +logger = logging.getLogger(__name__) + + +def webhook_queue_key(settings: Settings) -> str: + return redis_key(settings, "queue", settings.WEBHOOK_QUEUE_NAME) + + +async def enqueue_webhook_event( + settings: Settings, + provider: str, + payload: Dict[str, Any], + *, + event_id: Optional[str] = None, +) -> bool: + redis = await get_redis(settings) + if redis is None: + return False + + dedupe_id = event_id or payload.get("id") or payload.get("event_id") + if dedupe_id: + dedupe_key = redis_key(settings, "webhook", "seen", provider, dedupe_id) + if not await redis.set(dedupe_key, "1", nx=True, ex=24 * 60 * 60): + logger.info("Skipping duplicate %s webhook event %s", provider, dedupe_id) + return True + + message = { + "provider": provider, + "event_id": dedupe_id, + "payload": payload, + "enqueued_at": time.time(), + } + await redis.lpush(webhook_queue_key(settings), json.dumps(message, ensure_ascii=False)) + return True + + +async def pop_webhook_event(settings: Settings, timeout_seconds: int = 5) -> Optional[dict]: + redis = await get_redis(settings) + if redis is None: + return None + item = await redis.brpop(webhook_queue_key(settings), timeout=timeout_seconds) + if not item: + return None + _, raw = item + try: + return json.loads(raw) + except json.JSONDecodeError: + logger.warning("Invalid webhook queue payload discarded") + return None + + +async def webhook_queue_depth(settings: Settings) -> int: + redis = await get_redis(settings) + if redis is None: + return 0 + return int(await redis.llen(webhook_queue_key(settings))) diff --git a/bot/keyboards/inline/admin_keyboards.py b/backend/bot/keyboards/inline/admin_keyboards.py similarity index 100% rename from bot/keyboards/inline/admin_keyboards.py rename to backend/bot/keyboards/inline/admin_keyboards.py diff --git a/bot/keyboards/inline/user_keyboards.py b/backend/bot/keyboards/inline/user_keyboards.py similarity index 100% rename from bot/keyboards/inline/user_keyboards.py rename to backend/bot/keyboards/inline/user_keyboards.py diff --git a/bot/main_bot.py b/backend/bot/main_bot.py similarity index 91% rename from bot/main_bot.py rename to backend/bot/main_bot.py index b8b9647..8cf1f81 100644 --- a/bot/main_bot.py +++ b/backend/bot/main_bot.py @@ -10,9 +10,11 @@ from bot.app.controllers.dispatcher_controller import build_dispatcher from bot.app.factories.build_services import build_core_services from bot.app.web.web_server import build_and_start_web_app from bot.handlers.admin.sync_admin import perform_sync +from bot.infra.redis import close_redis from bot.middlewares.i18n import JsonI18n from bot.routers import build_root_router from bot.services.panel_api_service import PanelApiService +from bot.services.settings_override_service import load_overrides_from_db from bot.services.tariff_worker import TariffTrafficWorker from bot.utils.message_queue import init_queue_manager from config.settings import Settings @@ -34,9 +36,6 @@ async def on_startup_configured(dispatcher: Dispatcher): bot: Bot = dispatcher["bot_instance"] settings: Settings = dispatcher["settings"] i18n_instance: JsonI18n = dispatcher["i18n_instance"] - panel_service: PanelApiService = dispatcher["panel_service"] - - async_session_factory: sessionmaker = dispatcher["async_session_factory"] logging.info("STARTUP: on_startup_configured executing...") @@ -134,20 +133,6 @@ async def on_startup_configured(dispatcher: Dispatcher): # start serving Telegram webhooks immediately even if the panel is slow. # perform_sync is single-flight, so concurrent admin-triggered runs will # be skipped while this one is in progress. - try: - logging.info("STARTUP: Scheduling automatic panel sync in background...") - asyncio.create_task( - _background_startup_sync( - panel_service=panel_service, - session_factory=async_session_factory, - settings=settings, - i18n_instance=i18n_instance, - ), - name="StartupPanelSync", - ) - except Exception: - logging.exception("STARTUP: Failed to schedule automatic sync.") - logging.info("STARTUP: Bot on_startup_configured completed.") @@ -233,15 +218,22 @@ async def on_shutdown_configured(dispatcher: Dispatcher): logging.info("SHUTDOWN: Disposing SQLAlchemy engine...") await global_async_engine.dispose() logging.info("SHUTDOWN: SQLAlchemy engine disposed.") + await close_redis() logging.info("SHUTDOWN: Bot on_shutdown_configured completed.") -async def run_bot(settings_param: Settings): +async def run_bot( + settings_param: Settings, + *, + run_web: bool = True, + run_tariff_worker: bool = False, +): local_async_session_factory = init_db_connection(settings_param) if local_async_session_factory is None: logging.critical("Failed to initialize database connection and session factory. Exiting.") return + await load_overrides_from_db(settings_param, local_async_session_factory) dp, bot, extra = build_dispatcher(settings_param, local_async_session_factory) i18n_instance = extra["i18n_instance"] @@ -271,15 +263,17 @@ async def run_bot(settings_param: Settings): dp[key] = service dp["panel_service"] = services["panel_service"] dp["async_session_factory"] = local_async_session_factory - tariff_worker = TariffTrafficWorker( - settings_param, - local_async_session_factory, - services["panel_service"], - services["subscription_service"], - bot, - i18n_instance, - ) - dp["tariff_worker"] = tariff_worker + tariff_worker = None + if run_tariff_worker: + tariff_worker = TariffTrafficWorker( + settings_param, + local_async_session_factory, + services["panel_service"], + services["subscription_service"], + bot, + i18n_instance, + ) + dp["tariff_worker"] = tariff_worker # Wrap startup/shutdown handlers to satisfy aiogram event signature (no args passed) async def _on_startup_wrapper(): @@ -314,13 +308,14 @@ async def run_bot(settings_param: Settings): async def web_server_task(): await build_and_start_web_app(dp, bot, settings_param, local_async_session_factory) - main_tasks.append(asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")) - if settings_param.tariffs_config: + if run_web: + main_tasks.append(asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")) + if run_tariff_worker and tariff_worker and settings_param.tariffs_config: main_tasks.append(asyncio.create_task(tariff_worker.run(), name="TariffTrafficWorker")) # Recurring billing moved to panel webhook (24h before expiry). No periodic task needed here. - logging.info("Starting bot in Webhook mode with AIOHTTP server...") + logging.info("Starting bot runtime...") logging.info(f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}") try: diff --git a/bot/middlewares/action_logger_middleware.py b/backend/bot/middlewares/action_logger_middleware.py similarity index 100% rename from bot/middlewares/action_logger_middleware.py rename to backend/bot/middlewares/action_logger_middleware.py diff --git a/bot/middlewares/ban_check_middleware.py b/backend/bot/middlewares/ban_check_middleware.py similarity index 100% rename from bot/middlewares/ban_check_middleware.py rename to backend/bot/middlewares/ban_check_middleware.py diff --git a/bot/middlewares/channel_subscription.py b/backend/bot/middlewares/channel_subscription.py similarity index 100% rename from bot/middlewares/channel_subscription.py rename to backend/bot/middlewares/channel_subscription.py diff --git a/bot/middlewares/db_session.py b/backend/bot/middlewares/db_session.py similarity index 100% rename from bot/middlewares/db_session.py rename to backend/bot/middlewares/db_session.py diff --git a/bot/middlewares/i18n.py b/backend/bot/middlewares/i18n.py similarity index 100% rename from bot/middlewares/i18n.py rename to backend/bot/middlewares/i18n.py diff --git a/bot/middlewares/profile_sync.py b/backend/bot/middlewares/profile_sync.py similarity index 100% rename from bot/middlewares/profile_sync.py rename to backend/bot/middlewares/profile_sync.py diff --git a/bot/routers.py b/backend/bot/routers.py similarity index 100% rename from bot/routers.py rename to backend/bot/routers.py diff --git a/bot/services/crypto_pay_service.py b/backend/bot/services/crypto_pay_service.py similarity index 100% rename from bot/services/crypto_pay_service.py rename to backend/bot/services/crypto_pay_service.py diff --git a/bot/services/email_auth_service.py b/backend/bot/services/email_auth_service.py similarity index 100% rename from bot/services/email_auth_service.py rename to backend/bot/services/email_auth_service.py diff --git a/bot/services/email_templates.py b/backend/bot/services/email_templates.py similarity index 100% rename from bot/services/email_templates.py rename to backend/bot/services/email_templates.py diff --git a/bot/services/freekassa_service.py b/backend/bot/services/freekassa_service.py similarity index 100% rename from bot/services/freekassa_service.py rename to backend/bot/services/freekassa_service.py diff --git a/bot/services/lknpd_client.py b/backend/bot/services/lknpd_client.py similarity index 100% rename from bot/services/lknpd_client.py rename to backend/bot/services/lknpd_client.py diff --git a/bot/services/lknpd_service.py b/backend/bot/services/lknpd_service.py similarity index 100% rename from bot/services/lknpd_service.py rename to backend/bot/services/lknpd_service.py diff --git a/bot/services/notification_service.py b/backend/bot/services/notification_service.py similarity index 100% rename from bot/services/notification_service.py rename to backend/bot/services/notification_service.py diff --git a/bot/services/panel_api_service.py b/backend/bot/services/panel_api_service.py similarity index 98% rename from bot/services/panel_api_service.py rename to backend/bot/services/panel_api_service.py index eb1d666..2f0e980 100644 --- a/bot/services/panel_api_service.py +++ b/backend/bot/services/panel_api_service.py @@ -2,6 +2,7 @@ import asyncio import json import logging import re +import time from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional from urllib.parse import urlencode @@ -30,8 +31,16 @@ class PanelApiService: self.default_client_ip = "127.0.0.1" # Cache slow-changing reference data fetched from the panel. Errors and # None responses are not cached, so transient failures self-heal. - self._squads_cache: AsyncTTLCache = AsyncTTLCache(ttl_seconds=300) - self._hosts_cache: AsyncTTLCache = AsyncTTLCache(ttl_seconds=300) + self._squads_cache: AsyncTTLCache = AsyncTTLCache( + ttl_seconds=300, + settings=settings, + namespace="panel:squads", + ) + self._hosts_cache: AsyncTTLCache = AsyncTTLCache( + ttl_seconds=300, + settings=settings, + namespace="panel:hosts", + ) async def __aenter__(self): """Context manager entry""" @@ -132,11 +141,19 @@ class PanelApiService: except Exception: log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..." try: + started = time.monotonic() async with aiohttp_session.request( method.upper(), url_for_request, headers=headers, **kwargs ) as response: response_status = response.status response_text = await response.text() + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=%s", + time.monotonic() - started, + method.upper(), + endpoint, + response_status, + ) log_suffix = f"| Status: {response_status}" diff --git a/bot/services/panel_webhook_service.py b/backend/bot/services/panel_webhook_service.py similarity index 95% rename from bot/services/panel_webhook_service.py rename to backend/bot/services/panel_webhook_service.py index 63be592..3320f64 100644 --- a/bot/services/panel_webhook_service.py +++ b/backend/bot/services/panel_webhook_service.py @@ -10,6 +10,7 @@ from aiogram.types import InlineKeyboardMarkup from aiohttp import web from sqlalchemy.orm import sessionmaker +from bot.infra.webhook_queue import enqueue_webhook_event from bot.keyboards.inline.user_keyboards import ( get_autorenew_cancel_keyboard, get_subscribe_only_markup, @@ -249,13 +250,20 @@ class PanelWebhookService: telegram_id if telegram_id is not None else "N/A", ) - # Acknowledge immediately so the panel does not retry while we run - # auto-renew charges, DB lookups and SMTP. Heavy work is bounded by a - # semaphore to survive expiry bursts. - asyncio.create_task( - self._run_event_in_background(event_name, user_data), - name=f"panel_event_{event_name}", + queued = await enqueue_webhook_event( + self.settings, + "panel", + {"event": event_name, "user": user_data}, + event_id=( + f"{event_name}:" + f"{telegram_id or user_data.get('uuid') or user_data.get('shortUuid')}" + ), ) + if not queued: + asyncio.create_task( + self._run_event_in_background(event_name, user_data), + name=f"panel_event_{event_name}", + ) return web.Response(status=200, text="ok") async def _run_event_in_background(self, event_name: str, user_payload: dict) -> None: diff --git a/bot/services/platega_service.py b/backend/bot/services/platega_service.py similarity index 100% rename from bot/services/platega_service.py rename to backend/bot/services/platega_service.py diff --git a/bot/services/promo_code_service.py b/backend/bot/services/promo_code_service.py similarity index 100% rename from bot/services/promo_code_service.py rename to backend/bot/services/promo_code_service.py diff --git a/bot/services/referral_service.py b/backend/bot/services/referral_service.py similarity index 100% rename from bot/services/referral_service.py rename to backend/bot/services/referral_service.py diff --git a/bot/services/settings_override_service.py b/backend/bot/services/settings_override_service.py similarity index 56% rename from bot/services/settings_override_service.py rename to backend/bot/services/settings_override_service.py index 413aa5d..d4d8ca6 100644 --- a/bot/services/settings_override_service.py +++ b/backend/bot/services/settings_override_service.py @@ -9,7 +9,9 @@ without restarting the container. from __future__ import annotations +import json import logging +from pathlib import Path from typing import Any, Dict, Optional from sqlalchemy.orm import sessionmaker @@ -25,6 +27,19 @@ from db.dal import app_settings_dal logger = logging.getLogger(__name__) +APPEARANCE_OVERRIDE_KEYS = { + "WEBAPP_LOGO_USE_EMOJI", + "WEBAPP_LOGO_URL", + "WEBAPP_LOGO_EMOJI", + "WEBAPP_LOGO_EMOJI_FONT", + "WEBAPP_FAVICON_USE_CUSTOM", + "WEBAPP_FAVICON_URL", + "WEBAPP_LOGO_FAVICON_URL", + "WEBAPP_PRIMARY_COLOR", +} +APP_ROOT = Path(__file__).resolve().parents[3] +APPEARANCE_OVERRIDES_BACKUP_PATH = APP_ROOT / "data" / "webapp-logo" / "appearance-settings.json" + def _resolve_attribute_name(settings: Settings, key: str) -> Optional[str]: """Resolve the actual attribute name on the Settings model. @@ -73,12 +88,97 @@ def apply_overrides(settings: Settings, overrides: Dict[str, Any]) -> int: return applied +def _appearance_snapshot(settings: Settings) -> Dict[str, Any]: + snapshot: Dict[str, Any] = {} + logo_url = getattr(settings, "WEBAPP_LOGO_URL", None) + logo_favicon_url = getattr(settings, "WEBAPP_LOGO_FAVICON_URL", None) + favicon_url = getattr(settings, "WEBAPP_FAVICON_URL", None) + if logo_url: + snapshot["WEBAPP_LOGO_URL"] = logo_url + if logo_favicon_url: + snapshot["WEBAPP_LOGO_FAVICON_URL"] = logo_favicon_url + if favicon_url: + snapshot["WEBAPP_FAVICON_URL"] = favicon_url + if getattr(settings, "WEBAPP_FAVICON_USE_CUSTOM", False): + snapshot["WEBAPP_FAVICON_USE_CUSTOM"] = True + if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + snapshot["WEBAPP_LOGO_USE_EMOJI"] = True + snapshot["WEBAPP_LOGO_EMOJI"] = getattr(settings, "WEBAPP_LOGO_EMOJI", "") + emoji_font = getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") + if emoji_font and emoji_font != "system": + snapshot["WEBAPP_LOGO_EMOJI_FONT"] = emoji_font + primary_color = getattr(settings, "WEBAPP_PRIMARY_COLOR", None) + if primary_color and primary_color != "#00fe7a": + snapshot["WEBAPP_PRIMARY_COLOR"] = primary_color + return snapshot + + +def _read_appearance_backup() -> Dict[str, Any]: + try: + payload = json.loads(APPEARANCE_OVERRIDES_BACKUP_PATH.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Failed to read appearance settings backup: %s", exc) + return {} + if not isinstance(payload, dict): + return {} + values = payload.get("settings") if isinstance(payload.get("settings"), dict) else payload + restored: Dict[str, Any] = {} + for key, value in values.items(): + if key not in APPEARANCE_OVERRIDE_KEYS: + continue + if value in (None, "") or value is False: + continue + field = get_field_by_key(key) + if not field: + continue + try: + restored[key] = coerce_value(field, value) + except ValueError as exc: + logger.warning("Skipping appearance backup key %s: %s", key, exc) + return restored + + +def write_appearance_backup(settings: Settings) -> None: + payload = { + "version": 1, + "settings": _appearance_snapshot(settings), + } + try: + APPEARANCE_OVERRIDES_BACKUP_PATH.parent.mkdir(parents=True, exist_ok=True) + APPEARANCE_OVERRIDES_BACKUP_PATH.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + except OSError as exc: + logger.warning("Failed to write appearance settings backup: %s", exc) + + async def load_overrides_from_db(settings: Settings, async_session_factory: sessionmaker) -> int: """Fetch overrides from the DB and apply them to the in-memory settings.""" try: async with async_session_factory() as session: overrides = await app_settings_dal.get_all_overrides(session) + backup_overrides = _read_appearance_backup() + missing_backup_overrides = { + key: value + for key, value in backup_overrides.items() + if key not in overrides + } + if missing_backup_overrides: + for key, value in missing_backup_overrides.items(): + await app_settings_dal.upsert_override( + session, key=key, value=value, updated_by=None + ) + await session.commit() + overrides.update(missing_backup_overrides) + logger.info( + "Restored %s appearance setting overrides from %s", + len(missing_backup_overrides), + APPEARANCE_OVERRIDES_BACKUP_PATH, + ) except Exception as exc: logger.warning("Could not load setting overrides from DB: %s", exc) return 0 @@ -146,6 +246,11 @@ async def update_overrides( logger.warning("Failed to restore env defaults: %s", exc) apply_overrides(settings, coerced_updates) + appearance_changed = APPEARANCE_OVERRIDE_KEYS.intersection( + coerced_updates + ) or APPEARANCE_OVERRIDE_KEYS.intersection(valid_deletes) + if appearance_changed: + write_appearance_backup(settings) return {"ok": True, "applied": len(coerced_updates), "reverted": len(valid_deletes)} diff --git a/bot/services/severpay_service.py b/backend/bot/services/severpay_service.py similarity index 100% rename from bot/services/severpay_service.py rename to backend/bot/services/severpay_service.py diff --git a/bot/services/stars_service.py b/backend/bot/services/stars_service.py similarity index 100% rename from bot/services/stars_service.py rename to backend/bot/services/stars_service.py diff --git a/bot/services/subscription_service.py b/backend/bot/services/subscription_service.py similarity index 100% rename from bot/services/subscription_service.py rename to backend/bot/services/subscription_service.py diff --git a/bot/services/subscription_service_impl/__init__.py b/backend/bot/services/subscription_service_impl/__init__.py similarity index 100% rename from bot/services/subscription_service_impl/__init__.py rename to backend/bot/services/subscription_service_impl/__init__.py diff --git a/bot/services/subscription_service_impl/_runtime.py b/backend/bot/services/subscription_service_impl/_runtime.py similarity index 100% rename from bot/services/subscription_service_impl/_runtime.py rename to backend/bot/services/subscription_service_impl/_runtime.py diff --git a/bot/services/subscription_service_impl/core.py b/backend/bot/services/subscription_service_impl/core.py similarity index 100% rename from bot/services/subscription_service_impl/core.py rename to backend/bot/services/subscription_service_impl/core.py diff --git a/bot/services/subscription_service_impl/devices.py b/backend/bot/services/subscription_service_impl/devices.py similarity index 100% rename from bot/services/subscription_service_impl/devices.py rename to backend/bot/services/subscription_service_impl/devices.py diff --git a/bot/services/subscription_service_impl/lifecycle.py b/backend/bot/services/subscription_service_impl/lifecycle.py similarity index 100% rename from bot/services/subscription_service_impl/lifecycle.py rename to backend/bot/services/subscription_service_impl/lifecycle.py diff --git a/bot/services/subscription_service_impl/panel_identity.py b/backend/bot/services/subscription_service_impl/panel_identity.py similarity index 100% rename from bot/services/subscription_service_impl/panel_identity.py rename to backend/bot/services/subscription_service_impl/panel_identity.py diff --git a/bot/services/subscription_service_impl/payments.py b/backend/bot/services/subscription_service_impl/payments.py similarity index 100% rename from bot/services/subscription_service_impl/payments.py rename to backend/bot/services/subscription_service_impl/payments.py diff --git a/bot/services/subscription_service_impl/renewal.py b/backend/bot/services/subscription_service_impl/renewal.py similarity index 100% rename from bot/services/subscription_service_impl/renewal.py rename to backend/bot/services/subscription_service_impl/renewal.py diff --git a/bot/services/subscription_service_impl/tariffs.py b/backend/bot/services/subscription_service_impl/tariffs.py similarity index 100% rename from bot/services/subscription_service_impl/tariffs.py rename to backend/bot/services/subscription_service_impl/tariffs.py diff --git a/bot/services/subscription_service_impl/traffic.py b/backend/bot/services/subscription_service_impl/traffic.py similarity index 100% rename from bot/services/subscription_service_impl/traffic.py rename to backend/bot/services/subscription_service_impl/traffic.py diff --git a/bot/services/subscription_service_impl/trial.py b/backend/bot/services/subscription_service_impl/trial.py similarity index 98% rename from bot/services/subscription_service_impl/trial.py rename to backend/bot/services/subscription_service_impl/trial.py index 23dcfcb..59f2a50 100644 --- a/bot/services/subscription_service_impl/trial.py +++ b/backend/bot/services/subscription_service_impl/trial.py @@ -61,7 +61,6 @@ class TrialSubscriptionMixin: "is_active": True, "status_from_panel": "TRIAL", "traffic_limit_bytes": self.settings.trial_traffic_limit_bytes, - "traffic_limit_strategy": self.settings.TRIAL_TRAFFIC_STRATEGY, "auto_renew_enabled": False, } try: diff --git a/bot/services/tariff_worker.py b/backend/bot/services/tariff_worker.py similarity index 93% rename from bot/services/tariff_worker.py rename to backend/bot/services/tariff_worker.py index c0e2351..88c2c21 100644 --- a/bot/services/tariff_worker.py +++ b/backend/bot/services/tariff_worker.py @@ -1,5 +1,6 @@ import asyncio import logging +import time from datetime import datetime, timezone from typing import Optional @@ -10,6 +11,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker +from bot.infra.redis import redis_lock from bot.middlewares.i18n import JsonI18n from bot.services.panel_api_service import PanelApiService from bot.services.subscription_service import SubscriptionService @@ -47,6 +49,7 @@ class TariffTrafficWorker: self.i18n = i18n self._stopped = asyncio.Event() self._premium_nodes_cache = {} + self._premium_node_stats_tick_cache = {} async def _user_lang(self, session: AsyncSession, user_id: int) -> str: try: @@ -96,16 +99,32 @@ class TariffTrafficWorker: return while not self._stopped.is_set(): try: - async with self.session_factory() as session: - await self.traffic_period_tick(session) - await session.commit() - async with self.session_factory() as session: - await self.legacy_throttle_recovery_tick(session) - await session.commit() + async with redis_lock( + self.settings, + "tariff-traffic-worker", + ttl_seconds=self.settings.TARIFF_WORKER_LOCK_TTL_SECONDS, + ) as acquired: + if not acquired: + logging.info("TariffTrafficWorker tick skipped: Redis lock is held") + else: + started = time.monotonic() + async with self.session_factory() as session: + await self.traffic_period_tick(session) + await session.commit() + async with self.session_factory() as session: + await self.legacy_throttle_recovery_tick(session) + await session.commit() + logging.info( + "metric worker_tick_duration_seconds=%.3f worker=tariff", + time.monotonic() - started, + ) except Exception: logging.exception("TariffTrafficWorker tick failed") try: - await asyncio.wait_for(self._stopped.wait(), timeout=300) + await asyncio.wait_for( + self._stopped.wait(), + timeout=self.settings.TARIFF_WORKER_TICK_SECONDS, + ) except asyncio.TimeoutError: pass @@ -114,6 +133,7 @@ class TariffTrafficWorker: async def traffic_period_tick(self, session: AsyncSession) -> None: now = datetime.now(timezone.utc) + self._premium_node_stats_tick_cache = {} warning_period_start = month_start(now) result = await session.execute( select(Subscription).where( @@ -622,11 +642,16 @@ class TariffTrafficWorker: found = False username = (panel_username or "").strip() or None for node_uuid in node_uuids: - stats = await self.panel_service.get_node_users_bandwidth_stats( - node_uuid, - start=start_date, - end=end_date, - ) + stats_cache_key = (node_uuid, start_date, end_date) + if stats_cache_key not in self._premium_node_stats_tick_cache: + self._premium_node_stats_tick_cache[stats_cache_key] = ( + await self.panel_service.get_node_users_bandwidth_stats( + node_uuid, + start=start_date, + end=end_date, + ) + ) + stats = self._premium_node_stats_tick_cache.get(stats_cache_key) if not stats: continue entries = stats.get("topUsers") or stats.get("usersStats") or stats.get("users") or [] diff --git a/bot/services/yookassa_service.py b/backend/bot/services/yookassa_service.py similarity index 100% rename from bot/services/yookassa_service.py rename to backend/bot/services/yookassa_service.py diff --git a/bot/states/admin_states.py b/backend/bot/states/admin_states.py similarity index 100% rename from bot/states/admin_states.py rename to backend/bot/states/admin_states.py diff --git a/bot/states/user_states.py b/backend/bot/states/user_states.py similarity index 100% rename from bot/states/user_states.py rename to backend/bot/states/user_states.py diff --git a/bot/utils/__init__.py b/backend/bot/utils/__init__.py similarity index 100% rename from bot/utils/__init__.py rename to backend/bot/utils/__init__.py diff --git a/bot/utils/callback_answer.py b/backend/bot/utils/callback_answer.py similarity index 100% rename from bot/utils/callback_answer.py rename to backend/bot/utils/callback_answer.py diff --git a/bot/utils/config_link.py b/backend/bot/utils/config_link.py similarity index 100% rename from bot/utils/config_link.py rename to backend/bot/utils/config_link.py diff --git a/bot/utils/date_utils.py b/backend/bot/utils/date_utils.py similarity index 100% rename from bot/utils/date_utils.py rename to backend/bot/utils/date_utils.py diff --git a/bot/utils/message_queue.py b/backend/bot/utils/message_queue.py similarity index 100% rename from bot/utils/message_queue.py rename to backend/bot/utils/message_queue.py diff --git a/bot/utils/mini_app_url.py b/backend/bot/utils/mini_app_url.py similarity index 100% rename from bot/utils/mini_app_url.py rename to backend/bot/utils/mini_app_url.py diff --git a/bot/utils/request_security.py b/backend/bot/utils/request_security.py similarity index 100% rename from bot/utils/request_security.py rename to backend/bot/utils/request_security.py diff --git a/bot/utils/telegram_markup.py b/backend/bot/utils/telegram_markup.py similarity index 100% rename from bot/utils/telegram_markup.py rename to backend/bot/utils/telegram_markup.py diff --git a/bot/utils/text_sanitizer.py b/backend/bot/utils/text_sanitizer.py similarity index 100% rename from bot/utils/text_sanitizer.py rename to backend/bot/utils/text_sanitizer.py diff --git a/bot/utils/ttl_cache.py b/backend/bot/utils/ttl_cache.py similarity index 64% rename from bot/utils/ttl_cache.py rename to backend/bot/utils/ttl_cache.py index bf5bd1f..a65ad6a 100644 --- a/bot/utils/ttl_cache.py +++ b/backend/bot/utils/ttl_cache.py @@ -9,8 +9,10 @@ class AsyncTTLCache: Concurrent get_or_load() calls for the same key share one loader execution. """ - def __init__(self, ttl_seconds: float): + def __init__(self, ttl_seconds: float, settings: Any = None, namespace: Optional[str] = None): self.ttl_seconds = ttl_seconds + self.settings = settings + self.namespace = namespace self._data: Dict[str, Tuple[float, Any]] = {} self._locks: Dict[str, asyncio.Lock] = {} @@ -35,6 +37,26 @@ class AsyncTTLCache: return True async def get_or_load(self, key: str, loader: Callable[[], Awaitable[Any]]) -> Any: + if self.settings is not None and self.namespace: + try: + from bot.infra.redis import cache_get_json, cache_set_json, redis_key + + cache_key = redis_key(self.settings, "cache", self.namespace, key) + cached = await cache_get_json(self.settings, cache_key) + if cached is not None: + return cached + value = await loader() + if self._is_cacheable(value): + await cache_set_json( + self.settings, + cache_key, + value, + max(1, int(self.ttl_seconds)), + ) + return value + except Exception: + pass + cached = self.get_fresh(key) if cached is not None: return cached diff --git a/config/settings.py b/backend/config/settings.py similarity index 98% rename from config/settings.py rename to backend/config/settings.py index 99ea47a..e845a3c 100644 --- a/config/settings.py +++ b/backend/config/settings.py @@ -120,6 +120,21 @@ class Settings(BaseSettings): POSTGRES_HOST: str = Field(default="localhost") POSTGRES_PORT: int = Field(default=5432) POSTGRES_DB: str = Field(default="vpn_shop_db") + DB_POOL_SIZE: int = Field(default=20) + DB_MAX_OVERFLOW: int = Field(default=10) + DB_POOL_TIMEOUT_SECONDS: int = Field(default=30) + DB_POOL_RECYCLE_SECONDS: int = Field(default=1800) + + REDIS_URL: Optional[str] = Field(default=None) + REDIS_KEY_PREFIX: str = Field(default="remnawave-tg-shop") + WEBAPP_ME_CACHE_TTL_SECONDS: int = Field(default=15) + WEBAPP_RATE_LIMIT_TTL_SECONDS: int = Field(default=60) + WEBAPP_RATE_LIMIT_MAX_REQUESTS: int = Field(default=30) + WEBHOOK_QUEUE_NAME: str = Field(default="webhook-events") + WEBHOOK_QUEUE_CONCURRENCY: int = Field(default=4) + WORKER_PANEL_SYNC_INTERVAL_SECONDS: int = Field(default=900) + TARIFF_WORKER_LOCK_TTL_SECONDS: int = Field(default=240) + TARIFF_WORKER_TICK_SECONDS: int = Field(default=300) DEFAULT_LANGUAGE: str = Field(default="ru") DEFAULT_CURRENCY_SYMBOL: str = Field(default="RUB") diff --git a/config/tariffs_config.py b/backend/config/tariffs_config.py similarity index 100% rename from config/tariffs_config.py rename to backend/config/tariffs_config.py diff --git a/config/webapp_themes_config.py b/backend/config/webapp_themes_config.py similarity index 100% rename from config/webapp_themes_config.py rename to backend/config/webapp_themes_config.py diff --git a/db/dal/__init__.py b/backend/db/dal/__init__.py similarity index 100% rename from db/dal/__init__.py rename to backend/db/dal/__init__.py diff --git a/db/dal/ad_dal.py b/backend/db/dal/ad_dal.py similarity index 100% rename from db/dal/ad_dal.py rename to backend/db/dal/ad_dal.py diff --git a/db/dal/app_settings_dal.py b/backend/db/dal/app_settings_dal.py similarity index 100% rename from db/dal/app_settings_dal.py rename to backend/db/dal/app_settings_dal.py diff --git a/db/dal/message_log_dal.py b/backend/db/dal/message_log_dal.py similarity index 100% rename from db/dal/message_log_dal.py rename to backend/db/dal/message_log_dal.py diff --git a/db/dal/panel_sync_dal.py b/backend/db/dal/panel_sync_dal.py similarity index 100% rename from db/dal/panel_sync_dal.py rename to backend/db/dal/panel_sync_dal.py diff --git a/db/dal/payment_dal.py b/backend/db/dal/payment_dal.py similarity index 100% rename from db/dal/payment_dal.py rename to backend/db/dal/payment_dal.py diff --git a/db/dal/promo_code_dal.py b/backend/db/dal/promo_code_dal.py similarity index 100% rename from db/dal/promo_code_dal.py rename to backend/db/dal/promo_code_dal.py diff --git a/db/dal/security_dal.py b/backend/db/dal/security_dal.py similarity index 100% rename from db/dal/security_dal.py rename to backend/db/dal/security_dal.py diff --git a/db/dal/subscription_dal.py b/backend/db/dal/subscription_dal.py similarity index 93% rename from db/dal/subscription_dal.py rename to backend/db/dal/subscription_dal.py index b02c1c3..e168c31 100644 --- a/db/dal/subscription_dal.py +++ b/backend/db/dal/subscription_dal.py @@ -10,6 +10,15 @@ from sqlalchemy.orm import selectinload from db.models import Subscription +def _subscription_model_payload(sub_payload: Dict[str, Any]) -> Dict[str, Any]: + model_columns = Subscription.__mapper__.columns.keys() + filtered_payload = {key: value for key, value in sub_payload.items() if key in model_columns} + ignored_keys = sorted(set(sub_payload) - set(filtered_payload)) + if ignored_keys: + logging.warning("Ignoring unsupported subscription payload keys: %s", ignored_keys) + return filtered_payload + + async def get_active_subscription_by_user_id( session: AsyncSession, user_id: int, panel_user_uuid: Optional[str] = None ) -> Optional[Subscription]: @@ -101,9 +110,8 @@ async def upsert_subscription(session: AsyncSession, sub_payload: Dict[str, Any] logging.info( f"Updating existing subscription {existing_sub.subscription_id} by panel_sub_uuid {panel_sub_uuid}" # noqa: E501 ) - for key, value in sub_payload.items(): - if hasattr(existing_sub, key): - setattr(existing_sub, key, value) + for key, value in _subscription_model_payload(sub_payload).items(): + setattr(existing_sub, key, value) await session.flush() await session.refresh(existing_sub) return existing_sub @@ -123,7 +131,7 @@ async def upsert_subscription(session: AsyncSession, sub_payload: Dict[str, Any] f"User {sub_payload['user_id']} not found for new subscription with panel_uuid {panel_sub_uuid}." # noqa: E501 ) - new_sub = Subscription(**sub_payload) + new_sub = Subscription(**_subscription_model_payload(sub_payload)) session.add(new_sub) await session.flush() await session.refresh(new_sub) diff --git a/db/dal/tariff_dal.py b/backend/db/dal/tariff_dal.py similarity index 100% rename from db/dal/tariff_dal.py rename to backend/db/dal/tariff_dal.py diff --git a/db/dal/user_billing_dal.py b/backend/db/dal/user_billing_dal.py similarity index 100% rename from db/dal/user_billing_dal.py rename to backend/db/dal/user_billing_dal.py diff --git a/db/dal/user_dal.py b/backend/db/dal/user_dal.py similarity index 100% rename from db/dal/user_dal.py rename to backend/db/dal/user_dal.py diff --git a/db/database_setup.py b/backend/db/database_setup.py similarity index 96% rename from db/database_setup.py rename to backend/db/database_setup.py index 5763187..43aa367 100644 --- a/db/database_setup.py +++ b/backend/db/database_setup.py @@ -31,8 +31,10 @@ def init_db_connection(settings: Settings) -> sessionmaker: settings.DATABASE_URL, echo=False, pool_pre_ping=True, - pool_size=20, - max_overflow=10, + pool_size=settings.DB_POOL_SIZE, + max_overflow=settings.DB_MAX_OVERFLOW, + pool_timeout=settings.DB_POOL_TIMEOUT_SECONDS, + pool_recycle=settings.DB_POOL_RECYCLE_SECONDS, ) local_async_session_factory = async_sessionmaker( diff --git a/db/migrator.py b/backend/db/migrator.py similarity index 100% rename from db/migrator.py rename to backend/db/migrator.py diff --git a/db/models.py b/backend/db/models.py similarity index 100% rename from db/models.py rename to backend/db/models.py diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..b8f2676 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,36 @@ +import asyncio +import logging +import os +import sys + +from dotenv import load_dotenv +from startup_banner import print_startup_banner + +from bot.main_bot import run_bot +from config.settings import get_settings + + +def _resolve_log_level(value: str) -> int: + return getattr(logging, value.upper(), logging.INFO) + + +async def main(): + settings = get_settings() + await run_bot(settings, run_web=True, run_tariff_worker=False) + + +if __name__ == "__main__": + load_dotenv() + print_startup_banner("bot") + logging.basicConfig( + level=_resolve_log_level(os.getenv("LOG_LEVEL", "INFO")), + stream=sys.stdout, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + try: + asyncio.run(main()) + except (KeyboardInterrupt, SystemExit): + logging.info("Bot stopped manually") + except Exception as e_global: + logging.critical(f"Global unhandled exception in main: {e_global}", exc_info=True) + sys.exit(1) diff --git a/backend/main_backend.py b/backend/main_backend.py new file mode 100644 index 0000000..9916f79 --- /dev/null +++ b/backend/main_backend.py @@ -0,0 +1,28 @@ +import asyncio +import logging +import sys + +from dotenv import load_dotenv +from startup_banner import print_startup_banner + +from app_logging import configure_logging +from bot.main_bot import run_bot +from config.settings import get_settings + + +async def main() -> None: + settings = get_settings() + await run_bot(settings, run_web=True, run_tariff_worker=False) + + +if __name__ == "__main__": + load_dotenv() + print_startup_banner("backend") + configure_logging() + try: + asyncio.run(main()) + except (KeyboardInterrupt, SystemExit): + logging.info("Backend stopped") + except Exception as exc: + logging.critical("Backend failed: %s", exc, exc_info=True) + sys.exit(1) diff --git a/backend/main_migrate.py b/backend/main_migrate.py new file mode 100644 index 0000000..4ff1f54 --- /dev/null +++ b/backend/main_migrate.py @@ -0,0 +1,27 @@ +import asyncio +import logging +import sys + +from dotenv import load_dotenv +from startup_banner import print_startup_banner + +from app_logging import configure_logging +from config.settings import get_settings +from db.database_setup import init_db, init_db_connection + + +async def main() -> None: + settings = get_settings() + session_factory = init_db_connection(settings) + await init_db(settings, session_factory) + + +if __name__ == "__main__": + load_dotenv() + print_startup_banner("migrate") + configure_logging() + try: + asyncio.run(main()) + except Exception as exc: + logging.critical("Migration failed: %s", exc, exc_info=True) + sys.exit(1) diff --git a/backend/main_worker.py b/backend/main_worker.py new file mode 100644 index 0000000..f86b730 --- /dev/null +++ b/backend/main_worker.py @@ -0,0 +1,189 @@ +import asyncio +import logging +import sys +import time + +from aiogram import Bot +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from dotenv import load_dotenv +from startup_banner import print_startup_banner + +import db.database_setup as database_setup +from app_logging import configure_logging +from bot.app.factories.build_services import build_core_services +from bot.handlers.admin.sync_admin import perform_sync +from bot.handlers.user.payment import ( + YOOKASSA_EVENT_PAYMENT_CANCELED, + YOOKASSA_EVENT_PAYMENT_SUCCEEDED, + payment_processing_lock, + process_cancelled_payment, + process_successful_payment, +) +from bot.infra.redis import close_redis, redis_lock +from bot.infra.webhook_queue import pop_webhook_event, webhook_queue_depth +from bot.middlewares.i18n import get_i18n_instance +from bot.services.settings_override_service import load_overrides_from_db +from bot.services.tariff_worker import TariffTrafficWorker +from bot.utils.message_queue import init_queue_manager +from config.settings import get_settings + + +async def _build_worker_context(settings): + session_factory = database_setup.init_db_connection(settings) + await load_overrides_from_db(settings, session_factory) + bot = Bot( + token=settings.BOT_TOKEN, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), + ) + i18n = get_i18n_instance(path="locales", default=settings.DEFAULT_LANGUAGE) + bot_username = "your_bot_username" + try: + bot_info = await bot.get_me() + bot_username = bot_info.username or bot_username + except Exception: + logging.exception("Worker failed to resolve bot username") + services = build_core_services(settings, bot, session_factory, i18n, bot_username) + init_queue_manager(bot) + return session_factory, bot, i18n, services + + +async def _webhook_consumer(settings, session_factory, bot, i18n, services): + while True: + event = await pop_webhook_event(settings) + if not event: + continue + provider = event.get("provider") + payload = event.get("payload") or {} + started = time.monotonic() + try: + if provider == "yookassa": + payment_payload = payload.get("payment") or {} + async with payment_processing_lock: + async with session_factory() as session: + if payload.get("event") == YOOKASSA_EVENT_PAYMENT_SUCCEEDED: + await process_successful_payment( + session, + bot, + payment_payload, + i18n, + settings, + services["panel_service"], + services["subscription_service"], + services["referral_service"], + services.get("lknpd_service"), + ) + elif payload.get("event") == YOOKASSA_EVENT_PAYMENT_CANCELED: + await process_cancelled_payment( + session, + bot, + payment_payload, + i18n, + settings, + ) + await session.commit() + elif provider == "panel": + await services["panel_webhook_service"].handle_event( + str(payload.get("event") or ""), + payload.get("user") or {}, + ) + elif provider == "panel_sync": + async with redis_lock( + settings, + "panel-sync", + ttl_seconds=max(60, settings.WORKER_PANEL_SYNC_INTERVAL_SECONDS - 10), + ) as acquired: + if acquired: + async with session_factory() as session: + await perform_sync( + panel_service=services["panel_service"], + session=session, + settings=settings, + i18n_instance=i18n, + ) + else: + logging.warning("Unknown webhook event provider: %s", provider) + except Exception: + logging.exception("Webhook queue event failed: %s", event.get("event_id")) + finally: + depth = await webhook_queue_depth(settings) + logging.info( + "metric webhook_event_duration_seconds=%.3f provider=%s queue_depth=%s", + time.monotonic() - started, + provider, + depth, + ) + + +async def _panel_sync_loop(settings, session_factory, i18n, services): + while True: + try: + async with redis_lock( + settings, + "panel-sync", + ttl_seconds=max(60, settings.WORKER_PANEL_SYNC_INTERVAL_SECONDS - 10), + ) as acquired: + if acquired: + started = time.monotonic() + async with session_factory() as session: + await perform_sync( + panel_service=services["panel_service"], + session=session, + settings=settings, + i18n_instance=i18n, + ) + logging.info( + "metric worker_tick_duration_seconds=%.3f worker=panel_sync", + time.monotonic() - started, + ) + except Exception: + logging.exception("Panel sync worker tick failed") + await asyncio.sleep(settings.WORKER_PANEL_SYNC_INTERVAL_SECONDS) + + +async def main() -> None: + settings = get_settings() + session_factory, bot, i18n, services = await _build_worker_context(settings) + tariff_worker = TariffTrafficWorker( + settings, + session_factory, + services["panel_service"], + services["subscription_service"], + bot, + i18n, + ) + tasks = [] + if settings.tariffs_config: + tasks.append(asyncio.create_task(tariff_worker.run(), name="TariffTrafficWorker")) + tasks.append(asyncio.create_task(_panel_sync_loop(settings, session_factory, i18n, services))) + for idx in range(max(1, settings.WEBHOOK_QUEUE_CONCURRENCY)): + tasks.append( + asyncio.create_task( + _webhook_consumer(settings, session_factory, bot, i18n, services), + name=f"WebhookConsumer{idx + 1}", + ) + ) + try: + await asyncio.gather(*tasks) + finally: + for service in services.values(): + close = getattr(service, "close", None) or getattr(service, "close_session", None) + if callable(close): + await close() + await bot.session.close() + await close_redis() + if database_setup.async_engine: + await database_setup.async_engine.dispose() + + +if __name__ == "__main__": + load_dotenv() + print_startup_banner("worker") + configure_logging() + try: + asyncio.run(main()) + except (KeyboardInterrupt, SystemExit): + logging.info("Worker stopped") + except Exception as exc: + logging.critical("Worker failed: %s", exc, exc_info=True) + sys.exit(1) diff --git a/requirements.txt b/backend/requirements.txt similarity index 95% rename from requirements.txt rename to backend/requirements.txt index 55c818f..b1aacda 100644 --- a/requirements.txt +++ b/backend/requirements.txt @@ -11,3 +11,4 @@ asyncpg==0.31.0 aiocryptopay==0.4.8 PyJWT[crypto]==2.12.1 Pillow==12.2.0 +redis==6.4.0 diff --git a/backend/startup_banner.py b/backend/startup_banner.py new file mode 100644 index 0000000..a7ab6da --- /dev/null +++ b/backend/startup_banner.py @@ -0,0 +1,109 @@ +import os +import sys +from urllib.parse import urlsplit + +_BANNER_TEMPLATE = """ + ~ ~ ~ r e m n a w a v e ~ ~ ~ + + ███╗ ███╗██╗███╗ ██╗██╗███████╗██╗ ██╗ ██████╗ ██████╗ + ████╗ ████║██║████╗ ██║██║██╔════╝██║ ██║██╔═══██╗██╔══██╗ + ██╔████╔██║██║██╔██╗ ██║██║███████╗███████║██║ ██║██████╔╝ + ██║╚██╔╝██║██║██║╚██╗██║██║╚════██║██╔══██║██║ ██║██╔═══╝ + ██║ ╚═╝ ██║██║██║ ╚████║██║███████║██║ ██║╚██████╔╝██║ + ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ + + container :: {service} +{details} + https://github.com/3252a8/remnawave-minishop +""" + + +def _env(name: str, default: str = "-") -> str: + return os.getenv(name) or default + + +def _bool_env(name: str) -> str: + value = _env(name, "").lower() + if value in {"1", "true", "yes", "on"}: + return "on" + if value in {"0", "false", "no", "off"}: + return "off" + return _env(name) + + +def _redis_target() -> str: + value = _env("REDIS_URL") + try: + parsed = urlsplit(value) + except ValueError: + return "-" + if not parsed.hostname: + return "-" + port = f":{parsed.port}" if parsed.port else "" + db = parsed.path.lstrip("/") or "0" + return f"{parsed.hostname}{port}/{db}" + + +def _postgres_target() -> str: + return f"{_env('POSTGRES_HOST')}:{_env('POSTGRES_PORT', '5432')}/{_env('POSTGRES_DB')}" + + +def _detail_line(text: str) -> str: + return f" {text[:64]}" + + +def _service_details(service: str) -> str: + image_tag = _env("IMAGE_TAG", "local") + log_level = _env("LOG_LEVEL", "INFO") + common = [f"image tag :: {image_tag}", f"log level :: {log_level}"] + if service == "backend": + lines = [ + *common, + f"webhooks :: :{_env('WEB_SERVER_PORT', '8080')}", + f"webapp api :: {_bool_env('WEBAPP_ENABLED')} / :{_env('WEBAPP_SERVER_PORT', '8081')}", + f"postgres :: {_postgres_target()}", + f"redis :: {_redis_target()}", + ] + elif service == "worker": + panel_sync_interval = _env("WORKER_PANEL_SYNC_INTERVAL_SECONDS") + panel_sync_interval = f"{panel_sync_interval}s" if panel_sync_interval != "-" else "-" + lines = [ + *common, + f"queue concurrency :: {_env('WEBHOOK_QUEUE_CONCURRENCY', '1')}", + f"panel sync interval :: {panel_sync_interval}", + f"tariffs config :: {_env('TARIFFS_CONFIG_PATH', '-')}", + f"postgres :: {_postgres_target()}", + f"redis :: {_redis_target()}", + ] + elif service == "migrate": + lines = [ + *common, + "mode :: one-shot migrations", + f"postgres :: {_postgres_target()}", + "data dir :: /app/data", + ] + else: + lines = common + return "\n".join(_detail_line(line) for line in lines) + + +def print_startup_banner(service: str) -> None: + normalized_service = service.lower() + banner = ( + _BANNER_TEMPLATE.format( + service=normalized_service.upper(), + details=_service_details(normalized_service), + ) + + "\n" + ) + try: + sys.stdout.write(banner) + sys.stdout.flush() + except UnicodeEncodeError: + try: + sys.stdout.buffer.write(banner.encode("utf-8")) + sys.stdout.buffer.flush() + except Exception: + pass + except Exception: + pass diff --git a/bot/app/web/admin_api_impl/sync.py b/bot/app/web/admin_api_impl/sync.py deleted file mode 100644 index ec62cac..0000000 --- a/bot/app/web/admin_api_impl/sync.py +++ /dev/null @@ -1,23 +0,0 @@ -# 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) - panel_service = request.app.get("panel_service") - if panel_service is None: - return _error(503, "panel_unavailable") - settings: Settings = request.app["settings"] - async_session_factory: sessionmaker = request.app["async_session_factory"] - i18n = request.app.get("i18n") - - from bot.handlers.admin.sync_admin import perform_sync - - async with async_session_factory() as session: - result = await perform_sync( - panel_service=panel_service, - session=session, - settings=settings, - i18n_instance=i18n, - ) - return _ok({"result": result or {}}) diff --git a/bot/app/web/frontend/src/main.js b/bot/app/web/frontend/src/main.js deleted file mode 100644 index 4eb14c2..0000000 --- a/bot/app/web/frontend/src/main.js +++ /dev/null @@ -1,10 +0,0 @@ -import { mount } from "svelte"; - -import App from "./App.svelte"; -import "./styles.css"; - -const target = document.getElementById("app"); - -if (target) { - mount(App, { target }); -} diff --git a/deploy/compose/docker-compose-caddy.yml b/deploy/compose/docker-compose-caddy.yml new file mode 100644 index 0000000..d1c64fe --- /dev/null +++ b/deploy/compose/docker-compose-caddy.yml @@ -0,0 +1,157 @@ +services: + postgres: + image: postgres:17 + container_name: remnawave-minishop-postgres + env_file: + - ${APP_ENV_FILE:-../../.env} + volumes: + - remnawave-minishop-db-data:/var/lib/postgresql/data + networks: + - remnawave-network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:7-alpine + container_name: remnawave-minishop-redis + command: ["redis-server", "--appendonly", "yes"] + volumes: + - remnawave-minishop-redis-data:/data + networks: + - remnawave-network + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + + migrate: + container_name: remnawave-minishop-migrate + image: ghcr.io/3252a8/remnawave-minishop-backend:${IMAGE_TAG:-latest} + command: ["python", "backend/main_migrate.py"] + env_file: + - ${APP_ENV_FILE:-../../.env} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + POSTGRES_HOST: postgres + REDIS_URL: redis://redis:6379/0 + volumes: + - shop-data:/app/data + networks: + - remnawave-network + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + backend: + container_name: remnawave-minishop-backend + image: ghcr.io/3252a8/remnawave-minishop-backend:${IMAGE_TAG:-latest} + env_file: + - ${APP_ENV_FILE:-../../.env} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + POSTGRES_HOST: postgres + REDIS_URL: redis://redis:6379/0 + WEBAPP_ENABLED: "true" + volumes: + - shop-data:/app/data + networks: + - remnawave-network + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + migrate: + condition: service_completed_successfully + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=3).read()\""] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + + worker: + container_name: remnawave-minishop-worker + image: ghcr.io/3252a8/remnawave-minishop-worker:${IMAGE_TAG:-latest} + env_file: + - ${APP_ENV_FILE:-../../.env} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + POSTGRES_HOST: postgres + REDIS_URL: redis://redis:6379/0 + volumes: + - shop-data:/app/data + networks: + - remnawave-network + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + migrate: + condition: service_completed_successfully + + frontend: + container_name: remnawave-minishop-frontend + image: ghcr.io/3252a8/remnawave-minishop-frontend:${IMAGE_TAG:-latest} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + FRONTEND_PORT: ${FRONTEND_PORT:-8082} + FRONTEND_PUBLIC: via caddy + networks: + - remnawave-network + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health | grep -q ok"] + interval: 30s + timeout: 5s + retries: 5 + + caddy: + image: caddy:2-alpine + container_name: remnawave-minishop-caddy + environment: + PUBLIC_HOST: ${PUBLIC_HOST:-localhost} + depends_on: + frontend: + condition: service_healthy + backend: + condition: service_healthy + ports: + - "${HTTP_BIND:-0.0.0.0:80}:80" + - "${HTTPS_BIND:-0.0.0.0:443}:443" + volumes: + - ../docker/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + networks: + - remnawave-network + restart: unless-stopped + +networks: + remnawave-network: + +volumes: + remnawave-minishop-db-data: + name: remnawave-minishop-db-data + remnawave-minishop-redis-data: + name: remnawave-minishop-redis-data + shop-data: + name: remnawave-minishop-shop-data + caddy-data: + name: remnawave-minishop-caddy-data + caddy-config: + name: remnawave-minishop-caddy-config diff --git a/deploy/compose/docker-compose-remote-server.yml b/deploy/compose/docker-compose-remote-server.yml new file mode 100644 index 0000000..a07e2bf --- /dev/null +++ b/deploy/compose/docker-compose-remote-server.yml @@ -0,0 +1,136 @@ +services: + postgres: + image: postgres:17 + container_name: remnawave-minishop-postgres + env_file: + - ${APP_ENV_FILE:-../../.env} + volumes: + - remnawave-minishop-db-data:/var/lib/postgresql/data + networks: + - remnawave-network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:7-alpine + container_name: remnawave-minishop-redis + command: ["redis-server", "--appendonly", "yes"] + volumes: + - remnawave-minishop-redis-data:/data + networks: + - remnawave-network + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + + migrate: + container_name: remnawave-minishop-migrate + image: ghcr.io/3252a8/remnawave-minishop-backend:${IMAGE_TAG:-latest} + command: ["python", "backend/main_migrate.py"] + env_file: + - ${APP_ENV_FILE:-../../.env} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + POSTGRES_HOST: postgres + REDIS_URL: redis://redis:6379/0 + volumes: + - shop-data:/app/data + networks: + - remnawave-network + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + backend: + container_name: remnawave-minishop-backend + image: ghcr.io/3252a8/remnawave-minishop-backend:${IMAGE_TAG:-latest} + env_file: + - ${APP_ENV_FILE:-../../.env} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + POSTGRES_HOST: postgres + REDIS_URL: redis://redis:6379/0 + WEBAPP_ENABLED: "true" + volumes: + - shop-data:/app/data + networks: + - remnawave-network + ports: + - "127.0.0.1:${WEB_SERVER_PORT:-8080}:8080" + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + migrate: + condition: service_completed_successfully + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=3).read()\""] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + + worker: + container_name: remnawave-minishop-worker + image: ghcr.io/3252a8/remnawave-minishop-worker:${IMAGE_TAG:-latest} + env_file: + - ${APP_ENV_FILE:-../../.env} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + POSTGRES_HOST: postgres + REDIS_URL: redis://redis:6379/0 + volumes: + - shop-data:/app/data + networks: + - remnawave-network + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + migrate: + condition: service_completed_successfully + + frontend: + container_name: remnawave-minishop-frontend + image: ghcr.io/3252a8/remnawave-minishop-frontend:${IMAGE_TAG:-latest} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + FRONTEND_PORT: ${FRONTEND_PORT:-8082} + FRONTEND_PUBLIC: 127.0.0.1:${FRONTEND_PORT:-8082}->80 + networks: + - remnawave-network + ports: + - "127.0.0.1:${FRONTEND_PORT:-8082}:80" + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health | grep -q ok"] + interval: 30s + timeout: 5s + retries: 5 + +networks: + remnawave-network: + +volumes: + remnawave-minishop-db-data: + name: remnawave-minishop-db-data + remnawave-minishop-redis-data: + name: remnawave-minishop-redis-data + shop-data: + name: remnawave-minishop-shop-data diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile new file mode 100644 index 0000000..2fdfcf9 --- /dev/null +++ b/deploy/docker/Dockerfile @@ -0,0 +1,82 @@ +FROM python:3.12-slim AS python-base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app/backend + +WORKDIR /app + +RUN useradd -u 10001 -m appuser + +COPY backend/requirements.txt backend/requirements.txt +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install --no-cache-dir -r backend/requirements.txt + +COPY backend ./backend +COPY locales ./locales +COPY data ./data +RUN mkdir -p /app/logs /app/data && chown -R appuser:appuser /app/logs /app/data + +USER appuser + + +FROM python-base AS backend + +LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \ + org.opencontainers.image.title="remnawave-minishop-backend" \ + org.opencontainers.image.description="Remnawave Minishop backend (Telegram bot API, web app, webhooks)." \ + org.opencontainers.image.licenses="MIT" + +EXPOSE 8080 8081 +CMD ["python", "backend/main_backend.py"] + + +FROM python-base AS worker + +LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \ + org.opencontainers.image.title="remnawave-minishop-worker" \ + org.opencontainers.image.description="Remnawave Minishop background worker (scheduled jobs, async tasks)." \ + org.opencontainers.image.licenses="MIT" + +CMD ["python", "backend/main_worker.py"] + + +FROM node:22-slim AS frontend-builder + +WORKDIR /app/frontend + +COPY frontend/package.json frontend/package-lock.json* ./ +RUN --mount=type=cache,target=/root/.npm \ + if [ -f package-lock.json ]; then npm ci; else npm install; fi + +COPY frontend ./ +COPY backend/bot/app/web/templates ../backend/bot/app/web/templates + +RUN npm run build:webapp + + +FROM nginx:1.27-alpine AS frontend + +LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \ + org.opencontainers.image.title="remnawave-minishop-frontend" \ + org.opencontainers.image.description="Remnawave Minishop frontend (nginx serving the subscription Mini App)." \ + org.opencontainers.image.licenses="MIT" + +COPY deploy/docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf +COPY deploy/docker/frontend/00-startup-banner.sh /docker-entrypoint.d/00-startup-banner.sh +COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html +COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css +COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.js /usr/share/nginx/html/subscription_webapp.js +COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.min.*.js /usr/share/nginx/html/ + +RUN sed -i \ + -e '/WEBAPP_I18N_SCRIPT/d' \ + -e '/WEBAPP_CONFIG_SCRIPT/d' \ + -e '/WEBAPP_JS_SCRIPT/c\ ' \ + -e '/WEBAPP_DEV_MOCK_START/d' \ + -e '/WEBAPP_DEV_MOCK_END/d' \ + -e '/subscription_webapp.js" defer/d' \ + /usr/share/nginx/html/index.html \ + && chmod +x /docker-entrypoint.d/00-startup-banner.sh + +EXPOSE 80 diff --git a/deploy/docker/caddy/Caddyfile b/deploy/docker/caddy/Caddyfile new file mode 100644 index 0000000..fb9721b --- /dev/null +++ b/deploy/docker/caddy/Caddyfile @@ -0,0 +1,11 @@ +{$PUBLIC_HOST:localhost} { + encode zstd gzip + + @webhooks path /tg/webhook /webhook/* + reverse_proxy @webhooks backend:8080 + + @backend_health path /health /healthz + reverse_proxy @backend_health backend:8080 + + reverse_proxy frontend:80 +} diff --git a/deploy/docker/frontend/00-startup-banner.sh b/deploy/docker/frontend/00-startup-banner.sh new file mode 100644 index 0000000..4cc58f9 --- /dev/null +++ b/deploy/docker/frontend/00-startup-banner.sh @@ -0,0 +1,23 @@ +#!/bin/sh +PUBLISHED="${FRONTEND_PUBLIC:-127.0.0.1:${FRONTEND_PORT:-8082}->80}" + +cat <80 depends_on: - remnawave-minishop-db: + backend: condition: service_healthy + networks: + - remnawave-shop ports: - - 127.0.0.1:8080:8080 - - 127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081} - # Tariffs 2.0 configuration. Mount the data directory to persist - # changes made via the admin panel. - # volumes: - # - ./data:/app/data + - "127.0.0.1:${FRONTEND_PORT:-8082}:80" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health | grep -q ok"] + interval: 30s + timeout: 5s + retries: 5 + + backend: + container_name: remnawave-minishop-backend + build: + context: . + dockerfile: deploy/docker/Dockerfile + target: backend + image: ghcr.io/3252a8/remnawave-minishop-backend:${IMAGE_TAG:-latest} + restart: unless-stopped + env_file: + - ${APP_ENV_FILE:-.env} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + POSTGRES_HOST: postgres + REDIS_URL: redis://redis:6379/0 + WEBAPP_ENABLED: "true" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + migrate: + condition: service_completed_successfully + volumes: + - shop-data:/app/data + networks: + - remnawave-shop + ports: + - "127.0.0.1:${WEB_SERVER_PORT:-8080}:8080" healthcheck: test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=3).read()\""] interval: 30s @@ -25,27 +59,96 @@ services: retries: 5 start_period: 30s - remnawave-minishop-db: - image: postgres:17 - container_name: remnawave-minishop-db - hostname: remnawave-minishop-db - env_file: - - .env - volumes: - - remnawave-minishop-db-data:/var/lib/postgresql/data - networks: - - remnawave-network + worker: + container_name: remnawave-minishop-worker + build: + context: . + dockerfile: deploy/docker/Dockerfile + target: worker + image: ghcr.io/3252a8/remnawave-minishop-worker:${IMAGE_TAG:-latest} restart: unless-stopped + env_file: + - ${APP_ENV_FILE:-.env} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + POSTGRES_HOST: postgres + REDIS_URL: redis://redis:6379/0 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + migrate: + condition: service_completed_successfully + volumes: + - shop-data:/app/data + networks: + - remnawave-shop + + migrate: + container_name: remnawave-minishop-migrate + image: ghcr.io/3252a8/remnawave-minishop-backend:${IMAGE_TAG:-latest} + build: + context: . + dockerfile: deploy/docker/Dockerfile + target: backend + command: ["python", "backend/main_migrate.py"] + env_file: + - ${APP_ENV_FILE:-.env} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + POSTGRES_HOST: postgres + REDIS_URL: redis://redis:6379/0 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - shop-data:/app/data + networks: + - remnawave-shop + + postgres: + image: postgres:17 + container_name: remnawave-minishop-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - remnawave-shop healthcheck: test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] interval: 5s timeout: 5s retries: 20 + redis: + image: redis:7-alpine + container_name: remnawave-minishop-redis + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis-data:/data + networks: + - remnawave-shop + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + networks: - remnawave-network: - external: true + remnawave-shop: volumes: - remnawave-minishop-db-data: + postgres-data: name: remnawave-minishop-db-data + redis-data: + name: remnawave-minishop-redis-data + shop-data: + name: remnawave-minishop-shop-data diff --git a/docs/admin.md b/docs/admin.md index 9ccee4e..4645470 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -77,4 +77,4 @@ Отключенный тариф исчезает с витрины, но активные подписки с его `tariff_key` продолжают существовать. Удаление тарифа безопасно только если не осталось активных подписок, которым нужна докупка, продление или смена с этого тарифа. -Для Docker важно, чтобы путь `TARIFFS_CONFIG_PATH` был доступен на запись контейнеру. В dev-compose каталог `./data` монтируется в `/app/data`, поэтому админка может сохранять `data/tariffs.json`. +Для Docker важно, чтобы путь `TARIFFS_CONFIG_PATH` был доступен на запись контейнеру. Если каталог `./data` смонтирован в `/app/data`, админка может сохранять `data/tariffs.json`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..53a3dc2 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,53 @@ +# Project Architecture + +The repository is split by runtime responsibility: + +```text +backend/ Python application code + bot/ Telegram bot, aiohttp APIs, webhooks, services + config/ Pydantic settings and tariff/theme config loaders + db/ SQLAlchemy models, DAL, migrations + main_backend.py aiohttp backend entrypoint + main_worker.py background worker entrypoint + main_migrate.py one-shot migration entrypoint + requirements.txt Python runtime dependencies + +frontend/ Svelte/Vite Mini App and admin UI + src/ Svelte source code + scripts/ frontend build helpers + package.json Node scripts and dependencies + +deploy/ + docker/ Dockerfile, nginx and caddy runtime config + compose/ legacy/alternate compose examples + +data/ runtime data mounted in containers +locales/ bot and Web App translations +tests/ Python test suite +``` + +The default `docker-compose.yml` stays in the repository root so `docker compose up` remains the +simple production path. It builds three application images from `deploy/docker/Dockerfile`: + +- `backend`: aiohttp APIs and webhooks only. +- `worker`: tariff traffic worker, panel sync, webhook queue consumers. +- `frontend`: static Svelte assets served by nginx. + +The `migrate` service is a one-shot container based on the backend image. It is part of the +default Compose dependency graph: Postgres and Redis become healthy, `migrate` applies +`Base.metadata.create_all` and pending `schema_migrations`, then `backend` and `worker` start +only after `migrate` exits successfully. This keeps migrations automatic for `docker compose up` +without running them inside every backend replica. + +Python imports intentionally remain `bot.*`, `config.*`, and `db.*`. Runtime containers set +`PYTHONPATH=/app/backend`; local tests use the same layout through `pytest.ini`. + +Common commands: + +```bash +docker compose up -d --build +docker compose run --rm migrate +docker compose logs -f backend worker frontend +npm run build:webapp +pytest -q +``` diff --git a/docs/configuration.md b/docs/configuration.md index 9864358..f0f7e49 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -96,7 +96,7 @@ nano .env Если файл из `TARIFFS_CONFIG_PATH` существует, бот использует каталог тарифов. Если файла нет, применяется конфигурация из переменных `.env`. -В штатном `docker-compose.yml` том `./data:/app/data` у сервиса приложения **закомментирован по умолчанию**. Раскомментируйте блок `volumes`, чтобы админка сохраняла `data/tariffs.json`, каталог тем (`data/themes`), кеш логотипа Web App (`data/webapp-logo`) и animated emoji (`data/webapp-emoji`). Отдельный `docker-compose-dev.yml` в репозиторий не входит (может быть у вас локально); логика та же — монтирование `./data` в `/app/data`. Если bind mount включён на Ubuntu-сервере, создайте подкаталоги и отдайте `data` UID `10001`, под которым работает приложение внутри контейнера: +В штатном `docker-compose.yml` данные хранятся в named volumes. Если для локальной разработки включён bind mount `./data:/app/data`, админка сможет сохранять `data/tariffs.json`, каталог тем (`data/themes`), кеш логотипа Web App (`data/webapp-logo`) и animated emoji (`data/webapp-emoji`) прямо в рабочую копию. Если bind mount включён на Ubuntu-сервере, создайте подкаталоги и отдайте `data` UID `10001`, под которым работает приложение внутри контейнера: ```bash mkdir -p data/themes data/webapp-logo data/webapp-emoji @@ -119,7 +119,7 @@ docker compose up -d --build --force-recreate | Переменная | Назначение | | --- | --- | | `WEBAPP_ENABLED` | Включает Web App в том же контейнере. | -| `WEBAPP_SERVER_HOST` / `WEBAPP_SERVER_PORT` | Хост и порт Web App. По умолчанию порт `8081`. | +| `WEBAPP_SERVER_HOST` / `WEBAPP_SERVER_PORT` | Внутренний aiohttp server для WebApp API/auth/theme assets. По умолчанию порт `8081`; статический frontend отдается отдельным nginx image. | | `SUBSCRIPTION_MINI_APP_URL` | Публичный URL Web App. | | `WEBAPP_TITLE` | Заголовок Web App. | | `WEBAPP_THEMES_DIR` | Каталог тем Web App. По умолчанию `data/themes`; внутри ожидаются папки `/theme.json` и опциональные CSS/ассеты. | diff --git a/docs/deployment.md b/docs/deployment.md index e15dc52..3a3b351 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,295 +1,251 @@ # Развертывание -Документ описывает запуск через Docker Compose, маршруты вебхуков и варианты reverse proxy. Перед запуском заполните `.env` по [configuration.md](configuration.md). +Документ описывает продакшен-запуск после разделения проекта на `backend`, `frontend` и `worker`. +Перед стартом заполните `.env` по [configuration.md](configuration.md). -## Docker Compose +## Быстрый старт -Локальная сборка: +```bash +cp .env.example .env +nano .env +docker compose up -d --build +docker compose ps +docker compose logs -f backend worker frontend +``` + +Обычный `docker compose up -d --build` поднимает: + +- `postgres` и `redis` с проверками здоровья; +- `migrate` как одноразовый сервис на backend-образе; +- `backend` только после успешных миграций; +- `worker` только после успешных миграций; +- `frontend` как отдельный nginx-образ без Python runtime. + +Для обратного прокси с Caddy используйте отдельный `deploy/compose/docker-compose-caddy.yml`. + +Миграции не запускаются внутри backend. Их выполняет отдельный сервис `migrate`, поэтому старт +приложения не создает гонки на схеме БД. + +## Миграции + +При обычном старте миграции применяются автоматически: ```bash docker compose up -d --build -docker compose logs -f remnawave-minishop ``` -Запуск из готового образа: +Для ручного повторного запуска: ```bash -IMAGE_TAG=3.1.0 docker compose -f docker-compose-remote-server.yml up -d +docker compose run --rm migrate ``` -`docker-compose-remote-server.yml` можно использовать как шаблон и заменить `image:` на нужный образ. По умолчанию используется `ghcr.io/3252a8/remnawave-minishop:latest`. - -### Права на `./data` - -Если в Compose включен bind mount `./data:/app/data` или `./data:/app/data:rw`, каталог на хосте должен быть доступен на запись пользователю контейнера. Контейнер запускает приложение от `appuser` с UID `10001`; запуск Docker от `root` на Ubuntu не делает этот каталог writable внутри контейнера, если на хосте он принадлежит `root:root` с обычными правами `755`. - -Перед запуском или после добавления mount выполните на сервере из каталога проекта: +Проверить логи миграций: ```bash -mkdir -p data/themes data/webapp-logo data/webapp-emoji +docker compose logs migrate +``` + +`backend` и `worker` зависят от `migrate` через `service_completed_successfully`; если миграции +падают, приложение не стартует поверх неподготовленной БД. + +## Сервисы + +- `backend`: aiohttp API, Telegram webhook, платежные webhooks, panel webhooks, проверка здоровья `/healthz`. +- `worker`: TariffTrafficWorker, задачи синхронизации с панелью, обработка рассылок, потребители очереди webhooks. +- `frontend`: статические Svelte-ассеты через nginx. +- `postgres`: PostgreSQL 17. +- `redis`: Redis 7 для FSM, кеша, rate-limit, очередей и locks. + +`deploy/compose/docker-compose-caddy.yml` добавляет `caddy` как внешний HTTP/HTTPS reverse proxy. + +## Логи и проверка + +```bash +docker compose ps +docker compose logs -f backend +docker compose logs -f worker +docker compose logs -f frontend +``` + +Эндпоинты проверки здоровья: + +```bash +curl http://127.0.0.1:8080/healthz +curl http://127.0.0.1:8080/health +``` + +В обычном compose backend публикуется на `127.0.0.1:${WEB_SERVER_PORT:-8080}`, frontend на +`127.0.0.1:${FRONTEND_PORT:-8082}`. В Caddy-варианте проверяйте `HTTP_BIND`. + +## Обновление + +Локальная сборка из репозитория: + +```bash +git pull +docker compose up -d --build +docker compose logs -f migrate backend worker +``` + +Если нужно пересобрать только образы приложения: + +```bash +docker compose build frontend backend worker +docker compose up -d +``` + +## Образы GHCR + +Образы приложения называются единообразно: + +```text +ghcr.io/3252a8/remnawave-minishop-backend: +ghcr.io/3252a8/remnawave-minishop-worker: +ghcr.io/3252a8/remnawave-minishop-frontend: +``` + +Сборка образов с конкретным тегом: + +```bash +IMAGE_TAG=3.2.0 scripts/docker-build-images.sh +``` + +Публикация после `docker login ghcr.io`: + +```bash +IMAGE_TAG=3.2.0 scripts/docker-push-images.sh +``` + +Для PowerShell есть варианты `scripts/docker-build-images.ps1` и +`scripts/docker-push-images.ps1`. Если публикуете образы в другой registry, namespace или с другим +префиксом имени, переопределите `IMAGE_NAMESPACE`, `IMAGE_REGISTRY` или `IMAGE_PREFIX`. + +Если PowerShell блокирует локальные скрипты ошибкой `PSSecurityException` / Execution Policy, +запустите те же скрипты с обходом политики только для текущего процесса: + +```powershell +$env:IMAGE_TAG = "3.2.0" +docker login ghcr.io +powershell -ExecutionPolicy Bypass -File .\scripts\docker-build-images.ps1 +powershell -ExecutionPolicy Bypass -File .\scripts\docker-push-images.ps1 +``` + +Этот bypass действует только для запущенного процесса `powershell` и не меняет системную политику. + +## Масштабирование + +В текущих Compose-файлах заданы явные `container_name`, поэтому `docker compose --scale` для +`backend`, `frontend` и `worker` не используется: Docker не может создать несколько контейнеров с +одним именем. Если понадобится горизонтальное масштабирование, уберите `container_name` у +масштабируемых сервисов или перенесите конфигурацию в orchestrator. + +Состояние FSM, rate-limit и краткоживущие кеши вынесены в Redis, а tariff tick защищен Redis +distributed lock; код подготовлен к нескольким репликам, но текущие Compose-файлы ориентированы на +фиксированные имена контейнеров. + +## Данные и volumes + +Продакшен compose использует именованные volumes: + +- `postgres-data`; +- `redis-data`; +- `shop-data`; +В Caddy-варианте также используются `caddy-data` и `caddy-config`. + +`shop-data` монтируется целиком в `/app/data`; внутри него лежат тарифы, темы, логотипы и прочие +файловые данные приложения. + +Если вместо именованного volume включаете bind mount `./data:/app/data`, на сервере заранее дайте права +пользователю контейнера `10001`: + +```bash +mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs chown -R 10001:10001 data chmod -R u+rwX data -docker compose up -d --force-recreate remnawave-minishop +docker compose up -d --force-recreate backend worker ``` Проверка прав: ```bash -docker compose exec remnawave-minishop sh -lc 'id; ls -ldn /app/data /app/data/themes /app/data/webapp-emoji; touch /app/data/themes/test /app/data/webapp-emoji/test && rm /app/data/themes/test /app/data/webapp-emoji/test' +docker compose exec backend sh -lc 'id; touch /app/data/themes/test && rm /app/data/themes/test' ``` -Если проверочный `touch` проходит без `Permission denied`, Web App сможет сохранять каталог тарифов, темы в `/app/data/themes`, кеш логотипов в `/app/data/webapp-logo` и кеш animated emoji в `/app/data/webapp-emoji`. - -## Обновление версии - -Образ приложения: `ghcr.io/3252a8/remnawave-minishop`. Тег задаётся переменной окружения **`IMAGE_TAG`** (в Compose подставляется как `${IMAGE_TAG:-latest}`). Для продакшена разумно закрепить **конкретный тег релиза** вместо `latest`, чтобы обновляться осознанно и иметь откат. - -**Запуск из готового образа** (`docker-compose-remote-server.yml` или свой файл с тем же шаблоном): - -1. Сделайте резервную копию базы (особенно перед крупными обновлениями) — см. раздел «Резервная копия и восстановление PostgreSQL» ниже на этой странице. -2. Укажите нужный тег, например в `.env`: `IMAGE_TAG=3.2.0` (или экспортируйте переменную перед командой). -3. Подтяните образ и пересоздайте контейнер приложения (БД при этом не удаляется, том данных сохраняется): +## Резервная копия PostgreSQL ```bash -docker compose -f docker-compose-remote-server.yml pull remnawave-minishop -docker compose -f docker-compose-remote-server.yml up -d --no-deps remnawave-minishop +docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > backup.sql ``` -При необходимости перезапустите оба сервиса: `docker compose -f docker-compose-remote-server.yml up -d`. Флаг `--force-recreate` добавляют, если нужно гарантированно пересоздать контейнер при неизменённом образе. - -**Локальная сборка из репозитория** (ваш `Dockerfile`): +Восстановление в чистую БД: ```bash -git pull -docker compose up -d --build +docker compose stop backend worker +docker compose exec postgres sh -c 'dropdb -U "$POSTGRES_USER" --if-exists "$POSTGRES_DB"' +docker compose exec postgres sh -c 'createdb -U "$POSTGRES_USER" "$POSTGRES_DB"' +docker compose exec -T postgres sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < backup.sql +docker compose run --rm migrate +docker compose up -d backend worker ``` -После обновления проверьте логи (`docker compose logs -f remnawave-minishop`), работу бота, вебхуков и Web App. Совместимость с панелью Remnawave — см. раздел «Совместимость» в [README.md](../README.md). +## Обратный прокси -## Резервная копия и восстановление PostgreSQL +Вариант `deploy/compose/docker-compose-caddy.yml` проксирует: -Имя контейнера БД в типичном Compose — `remnawave-minishop-db`. Учётные данные уже передаются в контейнер через `env_file: .env`, поэтому надёжнее вызывать `pg_dump` / `psql` **внутри** контейнера через `sh -c '...'`, чтобы переменные раскрылись там, а не на хосте. +- статические ассеты frontend; +- API и webhook-маршруты backend; +- эндпоинты проверки здоровья. -Если написать на хосте `pg_dump -U "$POSTGRES_USER" ...` без экспорта переменных из `.env`, подставится пустая строка: PostgreSQL тогда берёт имя пользователя ОС (часто `root`) и выдаёт `FATAL: role "root" does not exist`. В **PowerShell** `$POSTGRES_DB` из файла `.env` сам не подхватывается — пустое имя базы даёт у `dropdb` ошибку `missing required argument database name`. - -**Вариант A (рекомендуется):** переменные только внутри контейнера: - -```bash -docker exec remnawave-minishop-db sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > backup.sql -``` - -**Вариант B:** сначала загрузить `.env` в текущую сессию на сервере, затем обычная команда (переменные раскроются на хосте): - -```bash -set -a && source .env && set +a -docker exec remnawave-minishop-db pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" > backup.sql -``` - -Такой файл удобно хранить вне сервера. При необходимости добавьте к `pg_dump` параметры сжатия или расписание через cron. - -### Восстановление из `backup.sql` - -Дамп выше — **обычный текст SQL** без удаления существующих объектов. Чтобы накатить его на **чистую** базу с тем же именем: - -1. Остановите приложение, чтобы не было записей в БД: - -```bash -docker compose -f docker-compose-remote-server.yml stop remnawave-minishop -``` - -(или ваш файл Compose без `-f`, если работаете из каталога проекта.) - -2. Удалите базу и создайте пустую с тем же именем — пользователь из `.env` в официальном образе PostgreSQL обычно суперпользователь и может это сделать: - -```bash -docker exec remnawave-minishop-db sh -c 'dropdb -U "$POSTGRES_USER" --if-exists "$POSTGRES_DB"' -docker exec remnawave-minishop-db sh -c 'createdb -U "$POSTGRES_USER" "$POSTGRES_DB"' -``` - -Имя базы у `dropdb` — последний аргумент; флаг **`--if-exists`** ставьте перед ним (иначе клиент может неверно разобрать командную строку). - -Если `dropdb` сообщает, что база занята, убедитесь, что остановлен сервис `remnawave-minishop` и к базе нет других подключений. - -3. Восстановите данные из файла на хосте. Переменные снова должны раскрываться **внутри** контейнера; на стороне `psql` имеет смысл включить **`ON_ERROR_STOP`**, чтобы при первой ошибке в дампе команда завершилась с ненулевым кодом. - -**Bash:** - -```bash -docker exec -i remnawave-minishop-db sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < backup.sql -``` - -**PowerShell** (перенаправление `<` в `docker exec` часто не подходит; надёжнее передать дамп в stdin через pipe; явная **UTF-8**, чтобы кириллица в комментариях/SQL не исказилась): - -```powershell -Get-Content backup.sql -Encoding utf8 | docker exec -i remnawave-minishop-db sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' -``` - -Если путь к файлу содержит пробелы, используйте кавычки: `Get-Content "E:\backups\backup.sql" -Encoding utf8 | ...` - -4. Запустите приложение снова: - -```bash -docker compose -f docker-compose-remote-server.yml start remnawave-minishop -``` - -Проверьте логи и работу бота. При ошибках импорта убедитесь, что версия приложения совместима со схемой в дампе (после обновлений бота иногда нужны шаги из changelog релиза). - -### Замечание про «накат поверх» без пересоздания БД - -Если нужно применить дамп к уже заполненной базе без `dropdb`, создавайте резервные копии с **`pg_dump --clean --if-exists`** — в файл попадут команды `DROP` перед `CREATE`, и `psql` сможет перезаписать объекты. Это разрушительно для текущих данных; перед таким сценарием сделайте отдельный свежий бэкап. - -## Порты - -| Порт | Назначение | -| --- | --- | -| `WEB_SERVER_PORT` (`8080`) | Telegram webhook, платежные вебхуки, Remnawave webhook. | -| `WEBAPP_SERVER_PORT` (`8081`) | Web App / Mini App. | - -Web App не должен проксироваться на `WEB_SERVER_PORT`. - -## Маршруты вебхуков - -Проксируйте платежные и системные вебхуки на `WEB_SERVER_PORT`: - -- `https:///webhook/yookassa` -> `http://remnawave-minishop:/webhook/yookassa`; -- `https:///webhook/freekassa` -> `http://remnawave-minishop:/webhook/freekassa`; -- `https:///webhook/platega` -> `http://remnawave-minishop:/webhook/platega`; -- `https:///webhook/severpay` -> `http://remnawave-minishop:/webhook/severpay`; -- `https:///webhook/cryptopay` -> `http://remnawave-minishop:/webhook/cryptopay`; -- `https:///webhook/panel` -> `http://remnawave-minishop:/webhook/panel`. - -Telegram webhook устанавливается приложением, если задан `WEBHOOK_BASE_URL`. Полный URL — это базовый URL **без** токена в пути, с суффиксом **`/tg/webhook`** (например `https://webhook.domain.com/tg/webhook`). Прокси должен передавать на приложение POST-запросы по этому пути на `WEB_SERVER_PORT`. - -## Nginx рядом с Remnawave - -Пример upstream и server-блока для домена вебхуков: +Для внешнего Nginx используйте DNS-имена сервисов внутри Docker network: ```nginx -upstream remnawave-minishop { - server remnawave-minishop:8080; +upstream remnawave_backend_webhooks { + server backend:8080; } -map $http_upgrade $connection_upgrade { - default upgrade; - "" close; +upstream remnawave_backend_webapp { + server backend:8081; } -server { - server_name webhook.domain.com; - listen 443 ssl; - http2 on; - - ssl_certificate "/etc/nginx/ssl/webhook_fullchain.pem"; - ssl_certificate_key "/etc/nginx/ssl/webhook_privkey.key"; - ssl_trusted_certificate "/etc/nginx/ssl/webhook_fullchain.pem"; - - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Port $server_port; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - proxy_intercept_errors on; - error_page 400 404 500 502 @redirect; - - location / { - proxy_pass http://remnawave-minishop$request_uri; - } - - location @redirect { - return 404; - } +upstream remnawave_frontend { + server frontend:80; } ``` -Для Web App используйте отдельный upstream на `WEBAPP_SERVER_PORT`; пример есть в [webapp.md](webapp.md). +Webhook и health-маршруты должны идти в `backend:8080`. Статические ассеты Mini App отдавайте через +`frontend:80`; frontend nginx уже проксирует `/api/*`, `/auth/*` и ассеты тем/логотипов в +`backend:8081`. -## SSL для домена вебхуков +## Newt -Пример выпуска сертификата через `acme.sh`: +Если `newt` запущен в одной Compose network с приложением, указывайте внутренние имена сервисов, +а не порты на хосте. + +Если используете Caddy-вариант и хотите публиковать стек одной точкой через Newt/Pangolin, укажите: + +```text +http://caddy:80 +``` + +Если публикуете через Newt/Pangolin без Caddy, настройте отдельные ресурсы: + +```text +Mini App / frontend: http://frontend:80 +Webhooks / backend: http://backend:8080 +``` + +Такой вариант нормальный: Caddy не обязателен. Он нужен только если вы хотите заранее объединить +frontend и backend-маршруты в один внутренний upstream. + +`backend:8081` является внутренним WebApp API/auth-сервером для frontend nginx; обычно его не нужно +указывать в Newt напрямую. + +## Переменный env-файл + +По умолчанию compose читает `.env`. Для smoke-тестов или отдельного окружения можно подставить +другой файл: ```bash -sudo apt-get install cron socat -curl https://get.acme.sh | sh -s email=EMAIL -source ~/.bashrc -ufw allow 80/tcp -ufw reload - -acme.sh --set-default-ca --server letsencrypt -acme.sh --issue --standalone -d 'webhook.domain.com' \ - --key-file /opt/remnawave/nginx/webhook_privkey.key \ - --fullchain-file /opt/remnawave/nginx/webhook_fullchain.pem +APP_ENV_FILE=.env.staging docker compose --env-file .env.staging up -d --build ``` - -Если Nginx панели Remnawave запускается в Docker, добавьте сертификаты в `volumes` сервиса Nginx: - -```yaml -services: - remnawave-nginx: - volumes: - - ./webhook_fullchain.pem:/etc/nginx/ssl/webhook_fullchain.pem:ro - - ./webhook_privkey.key:/etc/nginx/ssl/webhook_privkey.key:ro -``` - -После изменения конфигурации перезапустите Nginx: - -```bash -cd /opt/remnawave/nginx -docker compose down -docker compose up -d -docker compose logs -f -t -``` - -## Caddy - -Для схемы с Caddy используйте `docker-compose-caddy.yml` и `Caddyfile`. Caddy публикует наружу `80` и `443`, выпускает TLS-сертификаты и проксирует вебхуки и Web App на разные внутренние порты. - -Пример `Caddyfile`: - -```caddyfile -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} -} -``` - -В `.env` укажите: - -```env -WEBHOOK_BASE_URL=https://webhook.domain.com -SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/ -``` - -Запуск: - -```bash -docker compose -f docker-compose-caddy.yml up -d --build -``` - -В BotFather укажите домен Mini App через настройки домена, чтобы он совпадал с `SUBSCRIPTION_MINI_APP_URL`. - -## Проверка - -```bash -docker compose ps -docker compose logs -f remnawave-minishop -``` - -Проверьте: - -- бот отвечает в Telegram; -- Telegram webhook установлен без ошибок в логах; -- платежные вебхуки доходят до приложения; -- Remnawave webhook проходит проверку `PANEL_WEBHOOK_SECRET`; -- Web App открывается по домену из `SUBSCRIPTION_MINI_APP_URL`; -- в BotFather разрешены URL Web App и callback. diff --git a/docs/migration-to-minishop.md b/docs/migration-to-minishop.md index 77bd822..7043d57 100644 --- a/docs/migration-to-minishop.md +++ b/docs/migration-to-minishop.md @@ -1,5 +1,10 @@ # Миграция с `remnawave-tg-shop` на `remnawave-minishop` +> Примечание для новой Docker-архитектуры: основной production stack теперь состоит из +> `frontend`, `backend`, `worker`, `migrate`, `postgres` и `redis`. После переноса данных +> запускайте корневой `docker-compose.yml`; миграции применит one-shot сервис `migrate`, а логи +> приложения смотрите через `docker compose logs -f backend worker frontend`. + Начиная с этой версии контейнеры и тома названы `remnawave-minishop*` вместо `remnawave-tg-shop*`. Старый и новый стеки используют **разные имена томов**, поэтому простой `docker compose up -d` после `git pull` создаст пустую БД. Эта инструкция описывает, как перенести данные. Есть два пути: @@ -9,7 +14,7 @@ В обоих случаях: - старые тома **не удаляются** автоматически — это безопасный бэкап на случай отката; -- сертификаты Caddy (если используется `docker-compose-caddy.yml`) тоже переносятся, чтобы Let's Encrypt не выписывал их заново и не упереться в rate limit. +- сертификаты Caddy (если используется `deploy/compose/docker-compose-caddy.yml`) тоже переносятся, чтобы Let's Encrypt не выписывал их заново и не упереться в rate limit. ## Автоматический способ (через скрипт) @@ -42,7 +47,7 @@ bash scripts/migrate_to_minishop.sh ```bash # Caddy-вариант из raw -COMPOSE_FILE=docker-compose-caddy.yml \ +COMPOSE_FILE=deploy/compose/docker-compose-caddy.yml \ bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh) # С переключением origin на форк 3252a8 @@ -99,10 +104,10 @@ docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/ docker compose up --no-start --build # Или Caddy-вариант - docker compose -f docker-compose-caddy.yml up --no-start --build + docker compose -f deploy/compose/docker-compose-caddy.yml up --no-start # Или готовый образ - docker compose -f docker-compose-remote-server.yml up --no-start + docker compose -f deploy/compose/docker-compose-remote-server.yml up --no-start ``` 4. **Перенесите том БД в новое имя:** @@ -130,16 +135,16 @@ docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/ ```bash docker compose up -d # или - docker compose -f docker-compose-caddy.yml up -d --build + docker compose -f deploy/compose/docker-compose-caddy.yml up -d # или - docker compose -f docker-compose-remote-server.yml up -d + docker compose -f deploy/compose/docker-compose-remote-server.yml up -d ``` 7. **Проверьте:** ```bash docker compose ps - docker compose logs -f remnawave-minishop + docker compose logs -f backend worker frontend ``` 8. **(Опционально) удалите старые тома**, когда убедитесь, что новый стек стабилен: diff --git a/docs/webapp-themes.md b/docs/webapp-themes.md index 2df8e37..c499496 100644 --- a/docs/webapp-themes.md +++ b/docs/webapp-themes.md @@ -22,7 +22,7 @@ Web App поддерживает файловые темы, предпросмо - иконки и изображения, если CSS ссылается на ассеты темы; - стили только пользовательской части, только админки или обеих частей сразу. -Готовые темы лежат в `bot/app/web/themes`: `dark`, `light`, `windows95`, `ascii`. При первом запуске они копируются в `WEBAPP_THEMES_DIR`, по умолчанию `data/themes`. +Готовые темы лежат в `backend/bot/app/web/themes`: `dark`, `light`, `windows95`, `ascii`. При первом запуске они копируются в `WEBAPP_THEMES_DIR`, по умолчанию `data/themes`. ## Где живут темы @@ -274,7 +274,7 @@ CSS можно писать для пользовательской части Для обычной брендовой темы чаще всего удобнее начать с `dark` или `light`. Для глубокого CSS-скина можно взять `ascii` или `windows95` как пример того, насколько далеко можно уйти от стандартного вида. ```bash - cp bot/app/web/themes/dark/theme.json data/themes/neon/theme.json + cp backend/bot/app/web/themes/dark/theme.json data/themes/neon/theme.json ``` 4. Отредактируйте `theme.json`. @@ -328,7 +328,7 @@ CSS можно писать для пользовательской части 12. Зафиксируйте тему. - Для темы, которая должна ехать вместе с проектом, добавьте ее в репозиторий в `bot/app/web/themes` и при необходимости расширьте `DEFAULT_THEME_KEYS` в `config/webapp_themes_config.py`. Для приватной инсталляции достаточно хранить ее в `data/themes`. + Для темы, которая должна ехать вместе с проектом, добавьте ее в репозиторий в `backend/bot/app/web/themes` и при необходимости расширьте `DEFAULT_THEME_KEYS` в `backend/config/webapp_themes_config.py`. Для приватной инсталляции достаточно хранить ее в `data/themes`. ## Насколько глубоко можно менять вид diff --git a/docs/webapp.md b/docs/webapp.md index d0878f3..e2193ac 100644 --- a/docs/webapp.md +++ b/docs/webapp.md @@ -1,6 +1,6 @@ # Web App / Mini App -Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Порт `WEB_SERVER_PORT` остается для Telegram, платежных и Remnawave вебхуков. +Web App собирается в отдельный `frontend` image и отдается через nginx. Static/Mini App запросы идут в `frontend:80`; frontend nginx проксирует `/api/*`, `/auth/*` и theme/logo assets в backend WebApp server на `backend:8081`. Telegram, payment и panel webhook routes остаются на backend webhook server `backend:8080`. ## Что показывает Web App @@ -89,8 +89,16 @@ Email-вход работает через одноразовый код: Web App должен проксироваться отдельно от вебхуков: ```nginx -upstream remnawave-minishop-webapp { - server remnawave-minishop:8081; +upstream remnawave_frontend { + server frontend:80; +} + +upstream remnawave_backend_webapp { + server backend:8081; +} + +upstream remnawave_backend_webhooks { + server backend:8080; } server { @@ -102,7 +110,25 @@ server { ssl_certificate_key "/etc/nginx/ssl/app_privkey.key"; location / { - proxy_pass http://remnawave-minishop-webapp; + proxy_pass http://remnawave_frontend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location ~ ^/(api|auth)/ { + proxy_pass http://remnawave_backend_webapp; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /webhook/ { + proxy_pass http://remnawave_backend_webhooks; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -112,12 +138,16 @@ server { } ``` -В `docker-compose.yml` порт Web App публикуется отдельно: +В default `docker-compose.yml` наружу публикуются `frontend` и webhook/backend port, а внутри Docker network сервисы доступны друг другу по service DNS names: ```yaml -ports: - - 127.0.0.1:8080:8080 - - 127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081} +services: + frontend: + expose: + - "80" + backend: + expose: + - "8080" ``` ## Реферальные ссылки diff --git a/.prettierignore b/frontend/.prettierignore similarity index 100% rename from .prettierignore rename to frontend/.prettierignore diff --git a/.prettierrc.json b/frontend/.prettierrc.json similarity index 100% rename from .prettierrc.json rename to frontend/.prettierrc.json diff --git a/components.json b/frontend/components.json similarity index 85% rename from components.json rename to frontend/components.json index 246776b..62c457a 100644 --- a/components.json +++ b/frontend/components.json @@ -3,7 +3,7 @@ "style": "new-york", "tailwind": { "config": "", - "css": "bot/app/web/frontend/src/styles.css", + "css": "src/styles.css", "baseColor": "slate" }, "aliases": { diff --git a/eslint.config.mjs b/frontend/eslint.config.mjs similarity index 79% rename from eslint.config.mjs rename to frontend/eslint.config.mjs index a6f01e9..6a9bafc 100644 --- a/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -6,12 +6,12 @@ import globals from "globals"; /** @type {import("eslint").Linter.Config[]} */ export default [ { - ignores: ["**/node_modules/**", "bot/app/web/templates/**"], + ignores: ["**/node_modules/**", "../backend/bot/app/web/templates/**"], }, js.configs.recommended, ...svelte.configs["flat/base"], { - files: ["bot/app/web/frontend/**/*.{js,svelte}", "scripts/**/*.mjs"], + files: ["src/**/*.{js,svelte}", "scripts/**/*.mjs"], rules: { "no-unused-vars": [ "warn", @@ -25,13 +25,13 @@ export default [ }, }, { - files: ["bot/app/web/frontend/**/*.svelte"], + files: ["src/**/*.svelte"], rules: { "no-useless-assignment": "off", }, }, { - files: ["bot/app/web/frontend/**/*.{js,svelte}"], + files: ["src/**/*.{js,svelte}"], languageOptions: { globals: { ...globals.browser, diff --git a/bot/app/web/frontend/jsconfig.json b/frontend/jsconfig.json similarity index 100% rename from bot/app/web/frontend/jsconfig.json rename to frontend/jsconfig.json diff --git a/package-lock.json b/frontend/package-lock.json similarity index 100% rename from package-lock.json rename to frontend/package-lock.json diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5c2eeff --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,40 @@ +{ + "private": true, + "scripts": { + "build:webapp:svelte": "vite build --config ./vite.config.mjs", + "build:webapp:css": "npm run build:webapp:svelte", + "build:webapp:js": "node ./scripts/build_subscription_webapp_js.mjs", + "build:webapp": "npm run build:webapp:svelte && npm run build:webapp:js", + "lint": "eslint src scripts", + "lint:fix": "npm run lint:js:fix", + "lint:js:fix": "eslint src scripts --fix", + "format": "prettier --write \"src/**/*.{js,svelte}\" \"scripts/**/*.mjs\"", + "format:check": "prettier --check \"src/**/*.{js,svelte}\" \"scripts/**/*.mjs\"", + "check": "npm run lint && npm run format:check", + "fix": "npm run format && npm run lint:js:fix" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@internationalized/date": "^3.12.1", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tailwindcss/cli": "4.3.0", + "@tailwindcss/vite": "^4.3.0", + "bits-ui": "^2.18.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "esbuild": "^0.28.0", + "eslint": "^10.3.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.14.0", + "globals": "^17.6.0", + "lucide-svelte": "^1.0.1", + "prettier": "^3.7.4", + "prettier-plugin-svelte": "^3.4.0", + "svelte": "^5.55.5", + "svelte-eslint-parser": "^1.4.1", + "tailwind-merge": "^3.6.0", + "tailwindcss": "4.3.0", + "uplot": "^1.6.32", + "vite": "^8.0.12" + } +} diff --git a/scripts/build_subscription_webapp_js.mjs b/frontend/scripts/build_subscription_webapp_js.mjs similarity index 93% rename from scripts/build_subscription_webapp_js.mjs rename to frontend/scripts/build_subscription_webapp_js.mjs index 2affc29..6bcd671 100644 --- a/scripts/build_subscription_webapp_js.mjs +++ b/frontend/scripts/build_subscription_webapp_js.mjs @@ -7,8 +7,16 @@ import { fileURLToPath } from "node:url"; import { transform } from "esbuild"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(__dirname, ".."); -const sourcePath = path.join(repoRoot, "bot", "app", "web", "templates", "subscription_webapp.js"); +const repoRoot = path.resolve(__dirname, "..", ".."); +const sourcePath = path.join( + repoRoot, + "backend", + "bot", + "app", + "web", + "templates", + "subscription_webapp.js" +); function normalizeLineEndings(value) { return value.replace(/\r\n/g, "\n"); diff --git a/bot/app/web/frontend/src/App.svelte b/frontend/src/App.svelte similarity index 100% rename from bot/app/web/frontend/src/App.svelte rename to frontend/src/App.svelte diff --git a/bot/app/web/frontend/src/PreviewBoard.svelte b/frontend/src/PreviewBoard.svelte similarity index 100% rename from bot/app/web/frontend/src/PreviewBoard.svelte rename to frontend/src/PreviewBoard.svelte diff --git a/bot/app/web/frontend/src/admin/AdminPanel.svelte b/frontend/src/admin/AdminPanel.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/AdminPanel.svelte rename to frontend/src/admin/AdminPanel.svelte diff --git a/bot/app/web/frontend/src/admin/sections/AdsSection.svelte b/frontend/src/admin/sections/AdsSection.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/AdsSection.svelte rename to frontend/src/admin/sections/AdsSection.svelte diff --git a/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte b/frontend/src/admin/sections/AppearanceSection.svelte similarity index 97% rename from bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte rename to frontend/src/admin/sections/AppearanceSection.svelte index a6766c1..b856842 100644 --- a/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte +++ b/frontend/src/admin/sections/AppearanceSection.svelte @@ -225,11 +225,11 @@ clearPendingObjectUrl(); return; } - settingsStore.markDirty("WEBAPP_LOGO_URL", uploadedUrl); + settingsStore.setFieldValue("WEBAPP_LOGO_URL", uploadedUrl); if (uploaded?.faviconUrl) { - settingsStore.markDirty("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl); + settingsStore.setFieldValue("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl); } - settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", false); + settingsStore.setFieldValue("WEBAPP_LOGO_USE_EMOJI", false); if (logoFileInput) logoFileInput.value = ""; }); } @@ -240,11 +240,11 @@ if (!uploadedUrl) return; setPendingLogoPreview(uploadedUrl); logoSourceUrl = ""; - settingsStore.markDirty("WEBAPP_LOGO_URL", uploadedUrl); + settingsStore.setFieldValue("WEBAPP_LOGO_URL", uploadedUrl); if (uploaded?.faviconUrl) { - settingsStore.markDirty("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl); + settingsStore.setFieldValue("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl); } - settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", false); + settingsStore.setFieldValue("WEBAPP_LOGO_USE_EMOJI", false); }); } @@ -262,8 +262,9 @@ clearPendingFaviconObjectUrl(); return; } - settingsStore.markDirty("WEBAPP_FAVICON_URL", uploadedUrl); - setCustomFavicon(true); + settingsStore.setFieldValue("WEBAPP_FAVICON_URL", uploadedUrl); + settingsStore.setFieldValue("WEBAPP_FAVICON_USE_CUSTOM", true); + faviconUseCustomDraft = true; if (faviconFileInput) faviconFileInput.value = ""; }); } @@ -274,8 +275,9 @@ if (!uploadedUrl) return; setPendingFaviconPreview(uploadedUrl); faviconSourceUrl = ""; - settingsStore.markDirty("WEBAPP_FAVICON_URL", uploadedUrl); - setCustomFavicon(true); + settingsStore.setFieldValue("WEBAPP_FAVICON_URL", uploadedUrl); + settingsStore.setFieldValue("WEBAPP_FAVICON_USE_CUSTOM", true); + faviconUseCustomDraft = true; }); } diff --git a/bot/app/web/frontend/src/admin/sections/BroadcastSection.svelte b/frontend/src/admin/sections/BroadcastSection.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/BroadcastSection.svelte rename to frontend/src/admin/sections/BroadcastSection.svelte diff --git a/bot/app/web/frontend/src/admin/sections/LogsSection.svelte b/frontend/src/admin/sections/LogsSection.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/LogsSection.svelte rename to frontend/src/admin/sections/LogsSection.svelte diff --git a/bot/app/web/frontend/src/admin/sections/PaymentsSection.svelte b/frontend/src/admin/sections/PaymentsSection.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/PaymentsSection.svelte rename to frontend/src/admin/sections/PaymentsSection.svelte diff --git a/bot/app/web/frontend/src/admin/sections/PromosSection.svelte b/frontend/src/admin/sections/PromosSection.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/PromosSection.svelte rename to frontend/src/admin/sections/PromosSection.svelte diff --git a/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte b/frontend/src/admin/sections/SettingsSection.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/SettingsSection.svelte rename to frontend/src/admin/sections/SettingsSection.svelte diff --git a/bot/app/web/frontend/src/admin/sections/StatsSection.svelte b/frontend/src/admin/sections/StatsSection.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/StatsSection.svelte rename to frontend/src/admin/sections/StatsSection.svelte diff --git a/bot/app/web/frontend/src/admin/sections/TariffEditorModal.svelte b/frontend/src/admin/sections/TariffEditorModal.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/TariffEditorModal.svelte rename to frontend/src/admin/sections/TariffEditorModal.svelte diff --git a/bot/app/web/frontend/src/admin/sections/TariffsSection.svelte b/frontend/src/admin/sections/TariffsSection.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/TariffsSection.svelte rename to frontend/src/admin/sections/TariffsSection.svelte diff --git a/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte b/frontend/src/admin/sections/UserDetailModal.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte rename to frontend/src/admin/sections/UserDetailModal.svelte diff --git a/bot/app/web/frontend/src/admin/sections/UsersSection.svelte b/frontend/src/admin/sections/UsersSection.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/UsersSection.svelte rename to frontend/src/admin/sections/UsersSection.svelte diff --git a/bot/app/web/frontend/src/lib/admin/format.js b/frontend/src/lib/admin/format.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/format.js rename to frontend/src/lib/admin/format.js diff --git a/bot/app/web/frontend/src/lib/admin/revenueSeriesAgg.js b/frontend/src/lib/admin/revenueSeriesAgg.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/revenueSeriesAgg.js rename to frontend/src/lib/admin/revenueSeriesAgg.js diff --git a/bot/app/web/frontend/src/lib/admin/stores/adsStore.js b/frontend/src/lib/admin/stores/adsStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/stores/adsStore.js rename to frontend/src/lib/admin/stores/adsStore.js diff --git a/bot/app/web/frontend/src/lib/admin/stores/broadcastStore.js b/frontend/src/lib/admin/stores/broadcastStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/stores/broadcastStore.js rename to frontend/src/lib/admin/stores/broadcastStore.js diff --git a/bot/app/web/frontend/src/lib/admin/stores/logsStore.js b/frontend/src/lib/admin/stores/logsStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/stores/logsStore.js rename to frontend/src/lib/admin/stores/logsStore.js diff --git a/bot/app/web/frontend/src/lib/admin/stores/paymentsStore.js b/frontend/src/lib/admin/stores/paymentsStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/stores/paymentsStore.js rename to frontend/src/lib/admin/stores/paymentsStore.js diff --git a/bot/app/web/frontend/src/lib/admin/stores/promosStore.js b/frontend/src/lib/admin/stores/promosStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/stores/promosStore.js rename to frontend/src/lib/admin/stores/promosStore.js diff --git a/bot/app/web/frontend/src/lib/admin/stores/settingsStore.js b/frontend/src/lib/admin/stores/settingsStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/stores/settingsStore.js rename to frontend/src/lib/admin/stores/settingsStore.js diff --git a/bot/app/web/frontend/src/lib/admin/stores/statsStore.js b/frontend/src/lib/admin/stores/statsStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/stores/statsStore.js rename to frontend/src/lib/admin/stores/statsStore.js diff --git a/bot/app/web/frontend/src/lib/admin/stores/tariffsStore.js b/frontend/src/lib/admin/stores/tariffsStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/stores/tariffsStore.js rename to frontend/src/lib/admin/stores/tariffsStore.js diff --git a/bot/app/web/frontend/src/lib/admin/stores/themesStore.js b/frontend/src/lib/admin/stores/themesStore.js similarity index 94% rename from bot/app/web/frontend/src/lib/admin/stores/themesStore.js rename to frontend/src/lib/admin/stores/themesStore.js index 9d07793..25b7b41 100644 --- a/bot/app/web/frontend/src/lib/admin/stores/themesStore.js +++ b/frontend/src/lib/admin/stores/themesStore.js @@ -73,7 +73,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { at( "appearance_logo_uploaded_pending", {}, - "Логотип загружен. Сохраните изменения, чтобы применить его." + "Логотип загружен и применен." ) ); return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" }; @@ -103,7 +103,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { at( "appearance_logo_uploaded_pending", {}, - "Логотип загружен. Сохраните изменения, чтобы применить его." + "Логотип загружен и применен." ) ); return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" }; @@ -134,7 +134,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { at( "appearance_favicon_uploaded_pending", {}, - "Favicon загружена. Сохраните изменения, чтобы применить ее." + "Favicon загружена и применена." ) ); return { faviconUrl: data.favicon_url || "", variants: data.variants || {} }; @@ -164,7 +164,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { at( "appearance_favicon_uploaded_pending", {}, - "Favicon загружена. Сохраните изменения, чтобы применить ее." + "Favicon загружена и применена." ) ); return { faviconUrl: data.favicon_url || "", variants: data.variants || {} }; diff --git a/bot/app/web/frontend/src/lib/admin/stores/usersStore.js b/frontend/src/lib/admin/stores/usersStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/stores/usersStore.js rename to frontend/src/lib/admin/stores/usersStore.js diff --git a/bot/app/web/frontend/src/lib/admin/tariffDraft.js b/frontend/src/lib/admin/tariffDraft.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/tariffDraft.js rename to frontend/src/lib/admin/tariffDraft.js diff --git a/bot/app/web/frontend/src/lib/admin/users.js b/frontend/src/lib/admin/users.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/users.js rename to frontend/src/lib/admin/users.js diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminBadge.svelte b/frontend/src/lib/components/patterns/admin/AdminBadge.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminBadge.svelte rename to frontend/src/lib/components/patterns/admin/AdminBadge.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminButton.svelte b/frontend/src/lib/components/patterns/admin/AdminButton.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminButton.svelte rename to frontend/src/lib/components/patterns/admin/AdminButton.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminDashboardGrid.svelte b/frontend/src/lib/components/patterns/admin/AdminDashboardGrid.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminDashboardGrid.svelte rename to frontend/src/lib/components/patterns/admin/AdminDashboardGrid.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminDashboardStack.svelte b/frontend/src/lib/components/patterns/admin/AdminDashboardStack.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminDashboardStack.svelte rename to frontend/src/lib/components/patterns/admin/AdminDashboardStack.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminEmptyState.svelte b/frontend/src/lib/components/patterns/admin/AdminEmptyState.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminEmptyState.svelte rename to frontend/src/lib/components/patterns/admin/AdminEmptyState.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminField.svelte b/frontend/src/lib/components/patterns/admin/AdminField.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminField.svelte rename to frontend/src/lib/components/patterns/admin/AdminField.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminPagination.svelte b/frontend/src/lib/components/patterns/admin/AdminPagination.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminPagination.svelte rename to frontend/src/lib/components/patterns/admin/AdminPagination.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte b/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte rename to frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueCustomRangePopover.svelte b/frontend/src/lib/components/patterns/admin/AdminRevenueCustomRangePopover.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueCustomRangePopover.svelte rename to frontend/src/lib/components/patterns/admin/AdminRevenueCustomRangePopover.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminSectionHeader.svelte b/frontend/src/lib/components/patterns/admin/AdminSectionHeader.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminSectionHeader.svelte rename to frontend/src/lib/components/patterns/admin/AdminSectionHeader.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminSelect.svelte b/frontend/src/lib/components/patterns/admin/AdminSelect.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminSelect.svelte rename to frontend/src/lib/components/patterns/admin/AdminSelect.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminTable.svelte b/frontend/src/lib/components/patterns/admin/AdminTable.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminTable.svelte rename to frontend/src/lib/components/patterns/admin/AdminTable.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminTableSkeleton.svelte b/frontend/src/lib/components/patterns/admin/AdminTableSkeleton.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminTableSkeleton.svelte rename to frontend/src/lib/components/patterns/admin/AdminTableSkeleton.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminTrafficCard.svelte b/frontend/src/lib/components/patterns/admin/AdminTrafficCard.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminTrafficCard.svelte rename to frontend/src/lib/components/patterns/admin/AdminTrafficCard.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/index.js b/frontend/src/lib/components/patterns/admin/index.js similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/index.js rename to frontend/src/lib/components/patterns/admin/index.js diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/DialogOptionsSkeleton.svelte b/frontend/src/lib/components/patterns/webapp/DialogOptionsSkeleton.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/webapp/DialogOptionsSkeleton.svelte rename to frontend/src/lib/components/patterns/webapp/DialogOptionsSkeleton.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/EmptyCard.svelte b/frontend/src/lib/components/patterns/webapp/EmptyCard.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/webapp/EmptyCard.svelte rename to frontend/src/lib/components/patterns/webapp/EmptyCard.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/LanguageSelect.svelte b/frontend/src/lib/components/patterns/webapp/LanguageSelect.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/webapp/LanguageSelect.svelte rename to frontend/src/lib/components/patterns/webapp/LanguageSelect.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/LinearProgress.svelte b/frontend/src/lib/components/patterns/webapp/LinearProgress.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/webapp/LinearProgress.svelte rename to frontend/src/lib/components/patterns/webapp/LinearProgress.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte b/frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte rename to frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/StatusMessage.svelte b/frontend/src/lib/components/patterns/webapp/StatusMessage.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/webapp/StatusMessage.svelte rename to frontend/src/lib/components/patterns/webapp/StatusMessage.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/index.js b/frontend/src/lib/components/patterns/webapp/index.js similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/webapp/index.js rename to frontend/src/lib/components/patterns/webapp/index.js diff --git a/bot/app/web/frontend/src/lib/components/ui/badge.svelte b/frontend/src/lib/components/ui/badge.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/badge.svelte rename to frontend/src/lib/components/ui/badge.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/button.svelte b/frontend/src/lib/components/ui/button.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/button.svelte rename to frontend/src/lib/components/ui/button.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/card.svelte b/frontend/src/lib/components/ui/card.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/card.svelte rename to frontend/src/lib/components/ui/card.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/card/card-action.svelte b/frontend/src/lib/components/ui/card/card-action.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/card/card-action.svelte rename to frontend/src/lib/components/ui/card/card-action.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/card/card-content.svelte b/frontend/src/lib/components/ui/card/card-content.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/card/card-content.svelte rename to frontend/src/lib/components/ui/card/card-content.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/card/card-description.svelte b/frontend/src/lib/components/ui/card/card-description.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/card/card-description.svelte rename to frontend/src/lib/components/ui/card/card-description.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/card/card-footer.svelte b/frontend/src/lib/components/ui/card/card-footer.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/card/card-footer.svelte rename to frontend/src/lib/components/ui/card/card-footer.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/card/card-header.svelte b/frontend/src/lib/components/ui/card/card-header.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/card/card-header.svelte rename to frontend/src/lib/components/ui/card/card-header.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/card/card-title.svelte b/frontend/src/lib/components/ui/card/card-title.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/card/card-title.svelte rename to frontend/src/lib/components/ui/card/card-title.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/card/card.svelte b/frontend/src/lib/components/ui/card/card.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/card/card.svelte rename to frontend/src/lib/components/ui/card/card.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/card/index.js b/frontend/src/lib/components/ui/card/index.js similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/card/index.js rename to frontend/src/lib/components/ui/card/index.js diff --git a/bot/app/web/frontend/src/lib/components/ui/dialog.svelte b/frontend/src/lib/components/ui/dialog.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/dialog.svelte rename to frontend/src/lib/components/ui/dialog.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/icons.js b/frontend/src/lib/components/ui/icons.js similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/icons.js rename to frontend/src/lib/components/ui/icons.js diff --git a/bot/app/web/frontend/src/lib/components/ui/index.js b/frontend/src/lib/components/ui/index.js similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/index.js rename to frontend/src/lib/components/ui/index.js diff --git a/bot/app/web/frontend/src/lib/components/ui/input.svelte b/frontend/src/lib/components/ui/input.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/input.svelte rename to frontend/src/lib/components/ui/input.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/primitives.js b/frontend/src/lib/components/ui/primitives.js similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/primitives.js rename to frontend/src/lib/components/ui/primitives.js diff --git a/bot/app/web/frontend/src/lib/components/ui/skeleton.svelte b/frontend/src/lib/components/ui/skeleton.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/skeleton.svelte rename to frontend/src/lib/components/ui/skeleton.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/spinner.svelte b/frontend/src/lib/components/ui/spinner.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/spinner.svelte rename to frontend/src/lib/components/ui/spinner.svelte diff --git a/bot/app/web/frontend/src/lib/utils.js b/frontend/src/lib/utils.js similarity index 100% rename from bot/app/web/frontend/src/lib/utils.js rename to frontend/src/lib/utils.js diff --git a/bot/app/web/frontend/src/lib/webapp/BrandMark.svelte b/frontend/src/lib/webapp/BrandMark.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/BrandMark.svelte rename to frontend/src/lib/webapp/BrandMark.svelte diff --git a/bot/app/web/frontend/src/lib/webapp/authHelpers.js b/frontend/src/lib/webapp/authHelpers.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/authHelpers.js rename to frontend/src/lib/webapp/authHelpers.js diff --git a/bot/app/web/frontend/src/lib/webapp/billingActions.js b/frontend/src/lib/webapp/billingActions.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/billingActions.js rename to frontend/src/lib/webapp/billingActions.js diff --git a/bot/app/web/frontend/src/lib/webapp/billingOptionCache.js b/frontend/src/lib/webapp/billingOptionCache.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/billingOptionCache.js rename to frontend/src/lib/webapp/billingOptionCache.js diff --git a/bot/app/web/frontend/src/lib/webapp/browser.js b/frontend/src/lib/webapp/browser.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/browser.js rename to frontend/src/lib/webapp/browser.js diff --git a/bot/app/web/frontend/src/lib/webapp/constants.js b/frontend/src/lib/webapp/constants.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/constants.js rename to frontend/src/lib/webapp/constants.js diff --git a/bot/app/web/frontend/src/lib/webapp/devicesLabels.js b/frontend/src/lib/webapp/devicesLabels.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/devicesLabels.js rename to frontend/src/lib/webapp/devicesLabels.js diff --git a/bot/app/web/frontend/src/lib/webapp/formatters.js b/frontend/src/lib/webapp/formatters.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/formatters.js rename to frontend/src/lib/webapp/formatters.js diff --git a/bot/app/web/frontend/src/lib/webapp/gravatar.js b/frontend/src/lib/webapp/gravatar.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/gravatar.js rename to frontend/src/lib/webapp/gravatar.js diff --git a/bot/app/web/frontend/src/lib/webapp/i18n.js b/frontend/src/lib/webapp/i18n.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/i18n.js rename to frontend/src/lib/webapp/i18n.js diff --git a/bot/app/web/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/mockApi.js rename to frontend/src/lib/webapp/mockApi.js diff --git a/bot/app/web/frontend/src/lib/webapp/plurals.js b/frontend/src/lib/webapp/plurals.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/plurals.js rename to frontend/src/lib/webapp/plurals.js diff --git a/bot/app/web/frontend/src/lib/webapp/previewMock.js b/frontend/src/lib/webapp/previewMock.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/previewMock.js rename to frontend/src/lib/webapp/previewMock.js diff --git a/bot/app/web/frontend/src/lib/webapp/publicApi.js b/frontend/src/lib/webapp/publicApi.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/publicApi.js rename to frontend/src/lib/webapp/publicApi.js diff --git a/bot/app/web/frontend/src/lib/webapp/routes.js b/frontend/src/lib/webapp/routes.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/routes.js rename to frontend/src/lib/webapp/routes.js diff --git a/bot/app/web/frontend/src/lib/webapp/session.js b/frontend/src/lib/webapp/session.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/session.js rename to frontend/src/lib/webapp/session.js diff --git a/bot/app/web/frontend/src/lib/webapp/stores/accountStore.js b/frontend/src/lib/webapp/stores/accountStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/stores/accountStore.js rename to frontend/src/lib/webapp/stores/accountStore.js diff --git a/bot/app/web/frontend/src/lib/webapp/stores/authStore.js b/frontend/src/lib/webapp/stores/authStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/stores/authStore.js rename to frontend/src/lib/webapp/stores/authStore.js diff --git a/bot/app/web/frontend/src/lib/webapp/stores/billingStore.js b/frontend/src/lib/webapp/stores/billingStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/stores/billingStore.js rename to frontend/src/lib/webapp/stores/billingStore.js diff --git a/bot/app/web/frontend/src/lib/webapp/stores/devicesStore.js b/frontend/src/lib/webapp/stores/devicesStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/stores/devicesStore.js rename to frontend/src/lib/webapp/stores/devicesStore.js diff --git a/bot/app/web/frontend/src/lib/webapp/tariffs.js b/frontend/src/lib/webapp/tariffs.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/tariffs.js rename to frontend/src/lib/webapp/tariffs.js diff --git a/bot/app/web/frontend/src/lib/webapp/telegramSdk.js b/frontend/src/lib/webapp/telegramSdk.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/telegramSdk.js rename to frontend/src/lib/webapp/telegramSdk.js diff --git a/bot/app/web/frontend/src/lib/webapp/themeStyle.js b/frontend/src/lib/webapp/themeStyle.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/themeStyle.js rename to frontend/src/lib/webapp/themeStyle.js diff --git a/bot/app/web/frontend/src/lib/webapp/traffic.js b/frontend/src/lib/webapp/traffic.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/traffic.js rename to frontend/src/lib/webapp/traffic.js diff --git a/bot/app/web/frontend/src/lib/webapp/webappBoot.js b/frontend/src/lib/webapp/webappBoot.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/webappBoot.js rename to frontend/src/lib/webapp/webappBoot.js diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..e06f2b0 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,36 @@ +import { mount } from "svelte"; + +import App from "./App.svelte"; +import "./styles.css"; + +async function loadBootstrap() { + if (document.getElementById("webapp-config")) return; + try { + const response = await fetch("/api/bootstrap", { + credentials: "include", + headers: { Accept: "application/json" }, + }); + if (!response.ok) return; + const payload = await response.json(); + for (const [id, value] of [ + ["webapp-config", payload.config], + ["i18n", payload.i18n], + ]) { + const script = document.createElement("script"); + script.id = id; + script.type = "application/json"; + script.textContent = JSON.stringify(value || {}); + document.head.appendChild(script); + } + } catch (_error) { + void _error; + } +} + +const target = document.getElementById("app"); + +if (target) { + loadBootstrap().finally(() => { + mount(App, { target }); + }); +} diff --git a/bot/app/web/frontend/src/preview/BackTitle.svelte b/frontend/src/preview/BackTitle.svelte similarity index 100% rename from bot/app/web/frontend/src/preview/BackTitle.svelte rename to frontend/src/preview/BackTitle.svelte diff --git a/bot/app/web/frontend/src/preview/PhoneFrame.svelte b/frontend/src/preview/PhoneFrame.svelte similarity index 100% rename from bot/app/web/frontend/src/preview/PhoneFrame.svelte rename to frontend/src/preview/PhoneFrame.svelte diff --git a/bot/app/web/frontend/src/preview/PreviewMethods.svelte b/frontend/src/preview/PreviewMethods.svelte similarity index 100% rename from bot/app/web/frontend/src/preview/PreviewMethods.svelte rename to frontend/src/preview/PreviewMethods.svelte diff --git a/bot/app/web/frontend/src/preview/PreviewNav.svelte b/frontend/src/preview/PreviewNav.svelte similarity index 100% rename from bot/app/web/frontend/src/preview/PreviewNav.svelte rename to frontend/src/preview/PreviewNav.svelte diff --git a/bot/app/web/frontend/src/styles.css b/frontend/src/styles.css similarity index 100% rename from bot/app/web/frontend/src/styles.css rename to frontend/src/styles.css diff --git a/bot/app/web/frontend/src/styles/admin-controls.css b/frontend/src/styles/admin-controls.css similarity index 100% rename from bot/app/web/frontend/src/styles/admin-controls.css rename to frontend/src/styles/admin-controls.css diff --git a/bot/app/web/frontend/src/styles/admin-dialogs.css b/frontend/src/styles/admin-dialogs.css similarity index 100% rename from bot/app/web/frontend/src/styles/admin-dialogs.css rename to frontend/src/styles/admin-dialogs.css diff --git a/bot/app/web/frontend/src/styles/admin.css b/frontend/src/styles/admin.css similarity index 100% rename from bot/app/web/frontend/src/styles/admin.css rename to frontend/src/styles/admin.css diff --git a/bot/app/web/frontend/src/styles/base.css b/frontend/src/styles/base.css similarity index 100% rename from bot/app/web/frontend/src/styles/base.css rename to frontend/src/styles/base.css diff --git a/bot/app/web/frontend/src/styles/components.css b/frontend/src/styles/components.css similarity index 100% rename from bot/app/web/frontend/src/styles/components.css rename to frontend/src/styles/components.css diff --git a/bot/app/web/frontend/src/styles/controls.css b/frontend/src/styles/controls.css similarity index 100% rename from bot/app/web/frontend/src/styles/controls.css rename to frontend/src/styles/controls.css diff --git a/bot/app/web/frontend/src/styles/dialogs.css b/frontend/src/styles/dialogs.css similarity index 100% rename from bot/app/web/frontend/src/styles/dialogs.css rename to frontend/src/styles/dialogs.css diff --git a/bot/app/web/frontend/src/styles/tailwind.css b/frontend/src/styles/tailwind.css similarity index 100% rename from bot/app/web/frontend/src/styles/tailwind.css rename to frontend/src/styles/tailwind.css diff --git a/bot/app/web/frontend/src/styles/webapp.css b/frontend/src/styles/webapp.css similarity index 100% rename from bot/app/web/frontend/src/styles/webapp.css rename to frontend/src/styles/webapp.css diff --git a/bot/app/web/frontend/src/webapp/BottomNav.svelte b/frontend/src/webapp/BottomNav.svelte similarity index 100% rename from bot/app/web/frontend/src/webapp/BottomNav.svelte rename to frontend/src/webapp/BottomNav.svelte diff --git a/bot/app/web/frontend/src/webapp/PaymentDialogs.svelte b/frontend/src/webapp/PaymentDialogs.svelte similarity index 100% rename from bot/app/web/frontend/src/webapp/PaymentDialogs.svelte rename to frontend/src/webapp/PaymentDialogs.svelte diff --git a/bot/app/web/frontend/src/webapp/TariffDialogs.svelte b/frontend/src/webapp/TariffDialogs.svelte similarity index 100% rename from bot/app/web/frontend/src/webapp/TariffDialogs.svelte rename to frontend/src/webapp/TariffDialogs.svelte diff --git a/bot/app/web/frontend/src/webapp/WebAppShell.svelte b/frontend/src/webapp/WebAppShell.svelte similarity index 100% rename from bot/app/web/frontend/src/webapp/WebAppShell.svelte rename to frontend/src/webapp/WebAppShell.svelte diff --git a/bot/app/web/frontend/src/webapp/auth/AuthScreen.svelte b/frontend/src/webapp/auth/AuthScreen.svelte similarity index 100% rename from bot/app/web/frontend/src/webapp/auth/AuthScreen.svelte rename to frontend/src/webapp/auth/AuthScreen.svelte diff --git a/bot/app/web/frontend/src/webapp/screens/DevicesScreen.svelte b/frontend/src/webapp/screens/DevicesScreen.svelte similarity index 100% rename from bot/app/web/frontend/src/webapp/screens/DevicesScreen.svelte rename to frontend/src/webapp/screens/DevicesScreen.svelte diff --git a/bot/app/web/frontend/src/webapp/screens/HomeScreen.svelte b/frontend/src/webapp/screens/HomeScreen.svelte similarity index 100% rename from bot/app/web/frontend/src/webapp/screens/HomeScreen.svelte rename to frontend/src/webapp/screens/HomeScreen.svelte diff --git a/bot/app/web/frontend/src/webapp/screens/InviteScreen.svelte b/frontend/src/webapp/screens/InviteScreen.svelte similarity index 100% rename from bot/app/web/frontend/src/webapp/screens/InviteScreen.svelte rename to frontend/src/webapp/screens/InviteScreen.svelte diff --git a/bot/app/web/frontend/src/webapp/screens/SettingsScreen.svelte b/frontend/src/webapp/screens/SettingsScreen.svelte similarity index 100% rename from bot/app/web/frontend/src/webapp/screens/SettingsScreen.svelte rename to frontend/src/webapp/screens/SettingsScreen.svelte diff --git a/bot/app/web/frontend/vite.config.mjs b/frontend/vite.config.mjs similarity index 93% rename from bot/app/web/frontend/vite.config.mjs rename to frontend/vite.config.mjs index 69aa3b6..f087fc6 100644 --- a/bot/app/web/frontend/vite.config.mjs +++ b/frontend/vite.config.mjs @@ -6,7 +6,7 @@ import { svelte } from "@sveltejs/vite-plugin-svelte"; import { defineConfig } from "vite"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const templateDir = path.resolve(__dirname, "../templates"); +const templateDir = path.resolve(__dirname, "../backend/bot/app/web/templates"); export default defineConfig({ resolve: { diff --git a/locales/en.json b/locales/en.json index 6812dc6..a0ab36e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1235,6 +1235,8 @@ "admin_themes_catalog_empty": "The catalog is empty. Add a theme folder to data/themes and refresh.", "admin_themes_saved": "Themes saved", "admin_themes_save_failed": "Failed to save themes", + "appearance_logo_uploaded_pending": "Logo uploaded and applied.", + "appearance_favicon_uploaded_pending": "Favicon uploaded and applied.", "admin_no_description": "No description", "admin_tariff_model_traffic": "Traffic", "admin_tariff_model_periods": "Periods", diff --git a/locales/ru.json b/locales/ru.json index 113ee5a..ed230ae 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1235,6 +1235,8 @@ "admin_themes_catalog_empty": "Каталог пуст. Добавьте папку темы в data/themes и обновите список.", "admin_themes_saved": "Темы сохранены", "admin_themes_save_failed": "Не удалось сохранить темы", + "appearance_logo_uploaded_pending": "Логотип загружен и применен.", + "appearance_favicon_uploaded_pending": "Favicon загружена и применена.", "admin_no_description": "Без описания", "admin_tariff_model_traffic": "Трафик", "admin_tariff_model_periods": "Периоды", diff --git a/main.py b/main.py deleted file mode 100644 index 1791056..0000000 --- a/main.py +++ /dev/null @@ -1,68 +0,0 @@ -import asyncio -import logging -import os -import sys - -from dotenv import load_dotenv - -from bot.main_bot import run_bot -from config.settings import get_settings -from db.database_setup import init_db, init_db_connection - -_STARTUP_BANNER = r""" - ~ ~ ~ r e m n a w a v e ~ ~ ~ - - ███╗ ███╗██╗███╗ ██╗██╗███████╗██╗ ██╗ ██████╗ ██████╗ - ████╗ ████║██║████╗ ██║██║██╔════╝██║ ██║██╔═══██╗██╔══██╗ - ██╔████╔██║██║██╔██╗ ██║██║███████╗███████║██║ ██║██████╔╝ - ██║╚██╔╝██║██║██║╚██╗██║██║╚════██║██╔══██║██║ ██║██╔═══╝ - ██║ ╚═╝ ██║██║██║ ╚████║██║███████║██║ ██║╚██████╔╝██║ - ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ - - https://github.com/3252a8/remnawave-minishop -""" - - -def _print_startup_banner() -> None: - # Bypass the logging formatter so the ASCII art renders without per-line - # timestamp/level prefixes mangling the box characters. - try: - sys.stdout.write(_STARTUP_BANNER + "\n") - sys.stdout.flush() - except Exception: - # Banner is purely cosmetic; never fail startup over it. - pass - - -def _resolve_log_level(value: str) -> int: - return getattr(logging, value.upper(), logging.INFO) - - -async def main(): - settings = get_settings() - - session_factory = init_db_connection(settings) - if not session_factory: - logging.critical("Failed to initialize DB connection and session factory. Exiting.") - return - - await init_db(settings, session_factory) - - await run_bot(settings) - - -if __name__ == "__main__": - _print_startup_banner() - load_dotenv() - logging.basicConfig( - level=_resolve_log_level(os.getenv("LOG_LEVEL", "INFO")), - stream=sys.stdout, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) - try: - asyncio.run(main()) - except (KeyboardInterrupt, SystemExit): - logging.info("Bot stopped manually") - except Exception as e_global: - logging.critical(f"Global unhandled exception in main: {e_global}", exc_info=True) - sys.exit(1) diff --git a/package.json b/package.json index 1b5a7c5..581c0f4 100644 --- a/package.json +++ b/package.json @@ -1,47 +1,17 @@ { "private": true, "scripts": { - "build:webapp:svelte": "vite build --config ./bot/app/web/frontend/vite.config.mjs", - "build:webapp:css": "npm run build:webapp:svelte", - "build:webapp:js": "node ./scripts/build_subscription_webapp_js.mjs", - "build:webapp": "npm run build:webapp:svelte && npm run build:webapp:js", - "lint": "npm run lint:py && npm run lint:js", + "build:webapp": "npm --prefix frontend run build:webapp", "lint:py": "python -m ruff check .", - "lint:js": "eslint bot/app/web/frontend scripts", - "lint:fix": "npm run lint:py:fix && npm run lint:js:fix", - "lint:py:fix": "python -m ruff check --fix .", - "lint:js:fix": "eslint bot/app/web/frontend scripts --fix", - "format": "npm run format:py && npm run format:js", + "lint:js": "npm --prefix frontend run lint", + "lint": "npm run lint:py && npm run lint:js", "format:py": "python -m ruff format .", - "format:js": "prettier --write \"bot/app/web/frontend/**/*.{js,svelte}\" \"scripts/**/*.mjs\"", - "format:check": "npm run format:check:py && npm run format:check:js", + "format:js": "npm --prefix frontend run format", + "format": "npm run format:py && npm run format:js", "format:check:py": "python -m ruff format --check .", - "format:check:js": "prettier --check \"bot/app/web/frontend/**/*.{js,svelte}\" \"scripts/**/*.mjs\"", - "check": "npm run lint && npm run format:check", - "fix": "npm run format && npm run lint:fix" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@internationalized/date": "^3.12.1", - "@sveltejs/vite-plugin-svelte": "^7.1.2", - "@tailwindcss/cli": "4.3.0", - "@tailwindcss/vite": "^4.3.0", - "bits-ui": "^2.18.1", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "esbuild": "^0.28.0", - "eslint": "^10.3.0", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-svelte": "^3.14.0", - "globals": "^17.6.0", - "lucide-svelte": "^1.0.1", - "prettier": "^3.7.4", - "prettier-plugin-svelte": "^3.4.0", - "svelte": "^5.55.5", - "svelte-eslint-parser": "^1.4.1", - "tailwind-merge": "^3.6.0", - "tailwindcss": "4.3.0", - "uplot": "^1.6.32", - "vite": "^8.0.12" + "format:check:js": "npm --prefix frontend run format:check", + "format:check": "npm run format:check:py && npm run format:check:js", + "test": "pytest -q", + "check": "npm run lint && npm test && npm run build:webapp" } } diff --git a/pyproject.toml b/pyproject.toml index d15daf2..2941ca7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ exclude = [ ".pytest_cache", "__pycache__", "node_modules", - "bot/app/web/templates", + "backend/bot/app/web/templates", ] [tool.ruff.lint] @@ -22,7 +22,7 @@ ignore = [ ] [tool.ruff.lint.isort] -known-first-party = ["bot", "config", "db", "tests"] +known-first-party = ["app_logging", "bot", "config", "db", "tests"] [tool.ruff.format] quote-style = "double" diff --git a/pytest.ini b/pytest.ini index a635c5c..013b45f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,2 @@ [pytest] -pythonpath = . +pythonpath = backend . diff --git a/requirements-dev.txt b/requirements-dev.txt index f079b95..e0f35a7 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,3 @@ # Dev-only tooling (install: pip install -r requirements-dev.txt) --r requirements.txt +-r backend/requirements.txt ruff>=0.8.0 diff --git a/scripts/check-all.ps1 b/scripts/check-all.ps1 index f17bd8a..5b247d8 100644 --- a/scripts/check-all.ps1 +++ b/scripts/check-all.ps1 @@ -1,4 +1,4 @@ -# Запуск из корня репозитория: .\scripts\check-all.ps1 +# Run from repository root: .\scripts\check-all.ps1 $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot Set-Location $root @@ -6,9 +6,9 @@ Set-Location $root Write-Host "Python: pip install -r requirements-dev.txt" -ForegroundColor Cyan python -m pip install -q -r requirements-dev.txt -if (-not (Test-Path (Join-Path $root "node_modules"))) { - Write-Host "npm install (нет node_modules)" -ForegroundColor Cyan - npm install +if (-not (Test-Path (Join-Path $root "frontend/node_modules"))) { + Write-Host "npm install --prefix frontend" -ForegroundColor Cyan + npm --prefix frontend install } Write-Host "npm run check" -ForegroundColor Cyan diff --git a/scripts/check-all.sh b/scripts/check-all.sh index 473b971..ec5392f 100644 --- a/scripts/check-all.sh +++ b/scripts/check-all.sh @@ -6,9 +6,9 @@ cd "$ROOT" echo "Python: pip install -r requirements-dev.txt" python -m pip install -q -r requirements-dev.txt -if [[ ! -d node_modules ]]; then - echo "npm install (no node_modules)" - npm install +if [[ ! -d frontend/node_modules ]]; then + echo "npm install --prefix frontend" + npm --prefix frontend install fi echo "npm run check" diff --git a/scripts/docker-build-images.ps1 b/scripts/docker-build-images.ps1 new file mode 100644 index 0000000..e4baa86 --- /dev/null +++ b/scripts/docker-build-images.ps1 @@ -0,0 +1,18 @@ +$ErrorActionPreference = "Stop" + +$imageRegistry = if ($env:IMAGE_REGISTRY) { $env:IMAGE_REGISTRY } else { "ghcr.io" } +$imageNamespace = if ($env:IMAGE_NAMESPACE) { $env:IMAGE_NAMESPACE } else { "3252a8" } +$imageTag = if ($env:IMAGE_TAG) { $env:IMAGE_TAG } else { "local" } +$imagePrefix = if ($env:IMAGE_PREFIX) { $env:IMAGE_PREFIX } else { "remnawave-minishop" } +$dockerfile = if ($env:DOCKERFILE) { $env:DOCKERFILE } else { "deploy/docker/Dockerfile" } + +function Build-Image { + param([string]$Target) + $image = "$imageRegistry/$imageNamespace/$imagePrefix-$Target`:$imageTag" + Write-Host "Building $image" -ForegroundColor Cyan + docker build -f $dockerfile --target $Target -t $image . +} + +Build-Image backend +Build-Image worker +Build-Image frontend diff --git a/scripts/docker-build-images.sh b/scripts/docker-build-images.sh new file mode 100644 index 0000000..3c1e315 --- /dev/null +++ b/scripts/docker-build-images.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE_REGISTRY="${IMAGE_REGISTRY:-ghcr.io}" +IMAGE_NAMESPACE="${IMAGE_NAMESPACE:-3252a8}" +IMAGE_TAG="${IMAGE_TAG:-local}" +IMAGE_PREFIX="${IMAGE_PREFIX:-remnawave-minishop}" +DOCKERFILE="${DOCKERFILE:-deploy/docker/Dockerfile}" + +build_image() { + local target="$1" + local image="$IMAGE_REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX-$target:$IMAGE_TAG" + echo "Building $image" + docker build -f "$DOCKERFILE" --target "$target" -t "$image" . +} + +build_image backend +build_image worker +build_image frontend diff --git a/scripts/docker-push-images.ps1 b/scripts/docker-push-images.ps1 new file mode 100644 index 0000000..af0c416 --- /dev/null +++ b/scripts/docker-push-images.ps1 @@ -0,0 +1,21 @@ +$ErrorActionPreference = "Stop" + +if (-not $env:IMAGE_TAG) { + throw "Set IMAGE_TAG to the release tag you want to push" +} + +$imageRegistry = if ($env:IMAGE_REGISTRY) { $env:IMAGE_REGISTRY } else { "ghcr.io" } +$imageNamespace = if ($env:IMAGE_NAMESPACE) { $env:IMAGE_NAMESPACE } else { "3252a8" } +$imageTag = $env:IMAGE_TAG +$imagePrefix = if ($env:IMAGE_PREFIX) { $env:IMAGE_PREFIX } else { "remnawave-minishop" } + +function Push-Image { + param([string]$Target) + $image = "$imageRegistry/$imageNamespace/$imagePrefix-$Target`:$imageTag" + Write-Host "Pushing $image" -ForegroundColor Cyan + docker push $image +} + +Push-Image backend +Push-Image worker +Push-Image frontend diff --git a/scripts/docker-push-images.sh b/scripts/docker-push-images.sh new file mode 100644 index 0000000..be5b13b --- /dev/null +++ b/scripts/docker-push-images.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE_REGISTRY="${IMAGE_REGISTRY:-ghcr.io}" +IMAGE_NAMESPACE="${IMAGE_NAMESPACE:-3252a8}" +IMAGE_TAG="${IMAGE_TAG:?Set IMAGE_TAG to the release tag you want to push}" +IMAGE_PREFIX="${IMAGE_PREFIX:-remnawave-minishop}" + +push_image() { + local target="$1" + local image="$IMAGE_REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX-$target:$IMAGE_TAG" + echo "Pushing $image" + docker push "$image" +} + +push_image backend +push_image worker +push_image frontend diff --git a/scripts/send_premium_traffic_warning_test.py b/scripts/send_premium_traffic_warning_test.py index 33e1ff8..bdc0b62 100644 --- a/scripts/send_premium_traffic_warning_test.py +++ b/scripts/send_premium_traffic_warning_test.py @@ -11,6 +11,9 @@ No default Telegram ID is embedded in this script. import asyncio import os import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "backend")) from aiogram import Bot from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo diff --git a/tests/test_settings.py b/tests/test_settings.py index d3ec935..c093149 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,8 +1,12 @@ import json +import tempfile import unittest +from pathlib import Path +from unittest.mock import patch from pydantic import ValidationError +from bot.services import settings_override_service from config.settings import Settings @@ -67,9 +71,6 @@ class SettingsTests(unittest.TestCase): self.assertTrue(settings.traffic_sale_mode) def test_existing_tariffs_config_disables_legacy_traffic_mode(self): - import tempfile - from pathlib import Path - with tempfile.TemporaryDirectory() as tmpdir: path = Path(tmpdir) / "tariffs.json" path.write_text( @@ -106,6 +107,40 @@ class SettingsTests(unittest.TestCase): self.assertIsNotNone(settings.tariffs_config) self.assertFalse(settings.traffic_sale_mode) + def test_appearance_backup_roundtrip_preserves_logo_theme_and_favicon_settings(self): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + ) + settings.WEBAPP_LOGO_URL = "/webapp-uploaded-logo/logo-1111111111111111.png" + settings.WEBAPP_LOGO_USE_EMOJI = False + settings.WEBAPP_LOGO_FAVICON_URL = "/webapp-favicon/aaaaaaaaaaaaaaaa/icon-180.png" + settings.WEBAPP_FAVICON_USE_CUSTOM = True + settings.WEBAPP_FAVICON_URL = "/webapp-favicon/bbbbbbbbbbbbbbbb/icon-180.png" + settings.WEBAPP_PRIMARY_COLOR = "#123456" + + with tempfile.TemporaryDirectory() as tmpdir: + backup_path = Path(tmpdir) / "appearance-settings.json" + with patch.object( + settings_override_service, + "APPEARANCE_OVERRIDES_BACKUP_PATH", + backup_path, + ): + settings_override_service.write_appearance_backup(settings) + restored = settings_override_service._read_appearance_backup() + + self.assertEqual( + restored["WEBAPP_LOGO_URL"], + "/webapp-uploaded-logo/logo-1111111111111111.png", + ) + self.assertEqual(restored["WEBAPP_PRIMARY_COLOR"], "#123456") + self.assertEqual( + restored["WEBAPP_FAVICON_URL"], + "/webapp-favicon/bbbbbbbbbbbbbbbb/icon-180.png", + ) + def test_trial_traffic_strategy_is_available(self): settings = Settings( _env_file=None, diff --git a/tests/test_startup_banner.py b/tests/test_startup_banner.py new file mode 100644 index 0000000..e37ba68 --- /dev/null +++ b/tests/test_startup_banner.py @@ -0,0 +1,36 @@ +import io +import os +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch + +from startup_banner import print_startup_banner + + +class StartupBannerTests(unittest.TestCase): + def test_startup_banner_marks_services_without_mojibake(self): + for service in ("frontend", "backend", "worker", "migrate"): + with self.subTest(service=service): + buffer = io.StringIO() + + with ( + patch.dict( + os.environ, + { + "IMAGE_TAG": "test-tag", + "POSTGRES_HOST": "postgres", + "POSTGRES_DB": "postgres", + "REDIS_URL": "redis://redis:6379/0", + }, + clear=False, + ), + redirect_stdout(buffer), + ): + print_startup_banner(service) + + output = buffer.getvalue() + self.assertIn(f"container :: {service.upper()}", output) + self.assertIn("image tag :: test-tag", output) + self.assertIn("remnawave-minishop", output) + self.assertIn("███", output) + self.assertNotIn("в", output) diff --git a/tests/test_subscription_service_behavior.py b/tests/test_subscription_service_behavior.py index c331aac..52cc953 100644 --- a/tests/test_subscription_service_behavior.py +++ b/tests/test_subscription_service_behavior.py @@ -3,11 +3,13 @@ import tempfile import unittest from datetime import datetime, timezone from pathlib import Path -from unittest.mock import AsyncMock +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch from bot.services.panel_api_service import PanelApiService from bot.services.subscription_service import SubscriptionService from config.settings import Settings +from db.dal.subscription_dal import _subscription_model_payload GIB = 1024**3 @@ -187,6 +189,59 @@ class SubscriptionServiceCalculationTests(unittest.TestCase): class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCase): + async def test_activate_trial_keeps_panel_strategy_out_of_local_subscription_payload(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings( + _tariffs_config_payload(), + tmpdir, + TRIAL_ENABLED=True, + TRIAL_DURATION_DAYS=3, + TRIAL_TRAFFIC_LIMIT_GB=5, + TRIAL_TRAFFIC_STRATEGY="WEEK", + USER_SQUAD_UUIDS="trial-squad", + ) + service = _make_service(settings) + service.has_had_any_subscription = AsyncMock(return_value=False) + service._get_or_create_panel_user_link_details = AsyncMock( + return_value=("panel-user", "panel-sub", "short", True) + ) + service.panel_service.update_user_details_on_panel = AsyncMock( + return_value={"subscriptionUrl": "https://example.test/sub", "shortUuid": "short"} + ) + session = AsyncMock() + db_user = SimpleNamespace( + user_id=42, + telegram_id=42, + email=None, + username="trial-user", + first_name="Trial", + last_name="User", + ) + + with ( + patch( + "bot.services.subscription_service_impl.trial.user_dal.get_user_by_id", + AsyncMock(return_value=db_user), + ), + patch( + "bot.services.subscription_service_impl.trial.subscription_dal.deactivate_other_active_subscriptions", + AsyncMock(), + ), + patch( + "bot.services.subscription_service_impl.trial.subscription_dal.upsert_subscription", + AsyncMock(), + ) as upsert_subscription, + ): + result = await service.activate_trial_subscription(session, user_id=42) + + self.assertTrue(result["activated"]) + sub_payload = upsert_subscription.await_args.args[1] + self.assertNotIn("traffic_limit_strategy", sub_payload) + self.assertEqual(sub_payload["traffic_limit_bytes"], 5 * GIB) + + panel_payload = service.panel_service.update_user_details_on_panel.await_args.args[1] + self.assertEqual(panel_payload["trafficLimitStrategy"], "WEEK") + async def test_activate_subscription_dispatches_traffic_sale_mode(self): with tempfile.TemporaryDirectory() as tmpdir: settings = _make_settings(_tariffs_config_payload(), tmpdir) @@ -288,5 +343,21 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas self.assertEqual(kwargs["payment_db_id"], 12) +class SubscriptionDalPayloadTests(unittest.TestCase): + def test_subscription_model_payload_drops_panel_only_keys(self): + payload = _subscription_model_payload( + { + "user_id": 42, + "panel_user_uuid": "panel-user", + "panel_subscription_uuid": "panel-sub", + "end_date": datetime(2026, 1, 1, tzinfo=timezone.utc), + "traffic_limit_strategy": "WEEK", + } + ) + + self.assertEqual(payload["user_id"], 42) + self.assertNotIn("traffic_limit_strategy", payload) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index 664ae12..b87bbf8 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -7,7 +7,7 @@ import unittest from datetime import datetime, timezone from pathlib import Path from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from PIL import Image @@ -86,6 +86,8 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): self.assertNotIn("https://telegram.org/js/telegram-web-app.js", html) self.assertNotIn("https://fonts.googleapis.com", html) + self.assertNotIn('id="logo-preload"', html) + self.assertNotIn('href=""', html) self.assertLess(html.index("/subscription_webapp.css"), html.index("WEBAPP_JS_SCRIPT")) def test_https_webapp_logo_uses_same_origin_proxy(self): @@ -106,6 +108,23 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): "/webapp-uploaded-logo/logo-abcdef1234567890.png", ) + async def test_webapp_logo_route_serves_configured_uploaded_logo(self): + settings = SimpleNamespace( + WEBAPP_LOGO_USE_EMOJI=False, + WEBAPP_LOGO_URL="/webapp-uploaded-logo/logo-1111111111111111.png", + ) + request = SimpleNamespace(app={"settings": settings}) + + with tempfile.TemporaryDirectory() as tmpdir: + logo_dir = Path(tmpdir) + (logo_dir / "logo-1111111111111111.png").write_bytes(b"logo") + with patch.object(webapp_assets, "WEBAPP_UPLOADED_LOGO_DIR", logo_dir): + response = await webapp_assets.webapp_logo_route(request) + + self.assertEqual(response.status, 200) + self.assertEqual(response.content_type, "image/png") + self.assertEqual(response.body, b"logo") + def test_webapp_logo_is_hidden_when_emoji_logo_is_enabled(self): settings = SimpleNamespace( WEBAPP_LOGO_USE_EMOJI=True, @@ -172,6 +191,88 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): self.assertTrue((Path(tmpdir) / digest / "apple-touch-icon.png").exists()) self.assertTrue((Path(tmpdir) / digest / "favicon.ico").exists()) + def test_prune_unused_appearance_assets_keeps_only_referenced_logo_and_favicons(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + uploads = root / "uploads" + favicons = root / "favicons" + emoji = root / "emoji" + uploads.mkdir() + favicons.mkdir() + emoji.mkdir() + (uploads / "logo-1111111111111111.png").write_bytes(b"keep") + (uploads / "logo-2222222222222222.png").write_bytes(b"remove") + (favicons / "aaaaaaaaaaaaaaaa").mkdir() + (favicons / "bbbbbbbbbbbbbbbb").mkdir() + (favicons / "cccccccccccccccc").mkdir() + (favicons / "aaaaaaaaaaaaaaaa" / "icon-180.png").write_bytes(b"keep") + (favicons / "bbbbbbbbbbbbbbbb" / "icon-180.png").write_bytes(b"keep") + (favicons / "cccccccccccccccc" / "icon-180.png").write_bytes(b"remove") + (emoji / "1f929.512.gif").write_bytes(b"keep") + (emoji / "1f929.512.webp").write_bytes(b"keep") + (emoji / "1f525.512.gif").write_bytes(b"remove") + settings = SimpleNamespace( + WEBAPP_LOGO_URL="/webapp-uploaded-logo/logo-1111111111111111.png", + WEBAPP_FAVICON_URL="/webapp-favicon/aaaaaaaaaaaaaaaa/icon-180.png", + WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/bbbbbbbbbbbbbbbb/icon-180.png", + WEBAPP_LOGO_USE_EMOJI=True, + WEBAPP_LOGO_EMOJI="🤩", + WEBAPP_LOGO_EMOJI_FONT="noto-color-animated", + ) + + with ( + patch.object(admin_themes, "WEBAPP_UPLOADED_LOGO_DIR", uploads), + patch.object(admin_themes, "WEBAPP_FAVICON_DIR", favicons), + patch.object(admin_themes, "WEBAPP_EMOJI_CACHE_DIR", emoji), + ): + admin_themes.prune_unused_appearance_assets(settings) + + self.assertTrue((uploads / "logo-1111111111111111.png").exists()) + self.assertFalse((uploads / "logo-2222222222222222.png").exists()) + self.assertTrue((favicons / "aaaaaaaaaaaaaaaa").exists()) + self.assertTrue((favicons / "bbbbbbbbbbbbbbbb").exists()) + self.assertFalse((favicons / "cccccccccccccccc").exists()) + self.assertTrue((emoji / "1f929.512.gif").exists()) + self.assertTrue((emoji / "1f929.512.webp").exists()) + self.assertFalse((emoji / "1f525.512.gif").exists()) + + async def test_persist_appearance_upload_writes_overrides_and_clears_caches(self): + settings = SimpleNamespace() + request = SimpleNamespace( + app={ + "settings": settings, + "async_session_factory": object(), + "webapp_settings_cache": {"ts": 123.0, "data": {"stale": True}}, + "webapp_logo_cache": ("url", b"body", "image/png"), + } + ) + updates = { + "WEBAPP_LOGO_URL": "/webapp-uploaded-logo/logo-1111111111111111.png", + "WEBAPP_LOGO_USE_EMOJI": False, + } + + with ( + patch.object( + admin_themes, + "update_overrides", + AsyncMock(return_value={"ok": True}), + ) as update_mock, + patch.object(admin_themes, "prune_unused_appearance_assets") as prune_mock, + ): + persisted = await admin_themes._persist_appearance_upload(request, updates, 42) + + self.assertTrue(persisted) + update_mock.assert_awaited_once_with( + settings, + request.app["async_session_factory"], + updates=updates, + deletes=[], + actor_id=42, + ) + self.assertEqual(request.app["webapp_settings_cache"], {"ts": 0.0, "data": {}}) + self.assertIsNone(request.app["webapp_logo_cache"]) + prune_mock.assert_called_once_with(settings) + def test_initial_theme_head_markup_includes_css_and_tokens(self): cfg = builtin_webapp_themes_config("#123456") theme = cfg.theme_by_key("light")