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 7882f23..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" @@ -38,9 +53,8 @@ WEBAPP_ENABLED=True # WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port WEBAPP_TITLE="/minishop" # Mini App title -WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color -WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used -WEBAPP_LOGO_EMOJI="🫥" # Emoji logo fallback shown in the header and login screen +WEBAPP_THEMES_DIR=data/themes # Folder with theme subfolders: /theme.json and optional CSS/assets +WEBAPP_DEFAULT_THEME= # Optional: override descriptor default theme key (e.g. light) WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h) @@ -147,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/.gitattributes b/.gitattributes new file mode 100644 index 0000000..466d9f0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.sh text eol=lf +deploy/docker/frontend/*.sh text eol=lf 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 d619f58..21e354d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Remnawave Minishop +![Remnawave Minishop](docs/remnawave-minishop.webp) + Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду. Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md). @@ -31,6 +33,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи - [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика. - [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога. - [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки. +- [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы. - [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`). - [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека. @@ -40,13 +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. @@ -65,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`: @@ -80,10 +83,10 @@ docker compose logs -f remnawave-minishop Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md). -Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, кеша логотипа Web App и animated emoji: +Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji: ```bash -mkdir -p data/webapp-logo data/webapp-emoji +mkdir -p data/themes data/webapp-logo data/webapp-emoji chown -R 10001:10001 data chmod -R u+rwX data ``` @@ -95,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 89% rename from bot/app/factories/build_services.py rename to backend/bot/app/factories/build_services.py index ed9363e..6d7f4a3 100644 --- a/bot/app/factories/build_services.py +++ b/backend/bot/app/factories/build_services.py @@ -81,14 +81,12 @@ def build_core_services( api_url=settings.LKNPD_API_URL, ) - # Wire services that depend on each other - try: - # Attach YooKassa to subscription service for auto-renew charges - setattr(subscription_service, "yookassa_service", yookassa_service) - # Allow panel webhook to trigger renewals through subscription service - setattr(panel_webhook_service, "subscription_service", subscription_service) - except Exception: - pass + # Wire services that depend on each other. These attachments are critical + # for auto-renew (subscription_service.yookassa_service) and for the panel + # webhook handler's 24h pre-expiry renewal trigger; do NOT swallow errors — + # silent wiring failures previously caused auto-renew to disappear. + subscription_service.yookassa_service = yookassa_service + panel_webhook_service.subscription_service = subscription_service return { "panel_service": panel_service, 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 96% rename from bot/app/web/admin_api.py rename to backend/bot/app/web/admin_api.py index 8dcb349..385a0e3 100644 --- a/bot/app/web/admin_api.py +++ b/backend/bot/app/web/admin_api.py @@ -17,6 +17,7 @@ from bot.app.web.admin_api_impl import ( stats as _stats, sync as _sync, tariffs as _tariffs, + themes as _themes, users as _users, ) @@ -34,6 +35,7 @@ _MODULES = ( _ads, _settings, _tariffs, + _themes, _panel, _routes, ) 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 99% rename from bot/app/web/admin_api_impl/common.py rename to backend/bot/app/web/admin_api_impl/common.py index 7a337c6..2ef0e05 100644 --- a/bot/app/web/admin_api_impl/common.py +++ b/backend/bot/app/web/admin_api_impl/common.py @@ -270,6 +270,10 @@ def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None: path.write_text(payload, encoding="utf-8") +def _webapp_themes_catalog_payload(config: Any) -> Dict[str, Any]: + return config.model_dump(mode="json", exclude_none=True) + + def _panel_node_uuid_key(node: Dict[str, Any]) -> str: uid = node.get("nodeUuid") or node.get("node_uuid") or node.get("uuid") or node.get("id") return str(uid).strip().lower() if uid else "" 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 90% rename from bot/app/web/admin_api_impl/routes.py rename to backend/bot/app/web/admin_api_impl/routes.py index ad2c89d..3a46e7b 100644 --- a/bot/app/web/admin_api_impl/routes.py +++ b/backend/bot/app/web/admin_api_impl/routes.py @@ -54,4 +54,8 @@ def setup_admin_routes(app: web.Application) -> None: router.add_get("/api/admin/tariffs", admin_tariffs_get_route) router.add_put("/api/admin/tariffs", admin_tariffs_save_route) + router.add_get("/api/admin/themes", admin_themes_get_route) + router.add_put("/api/admin/themes", admin_themes_save_route) + router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route) + router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_upload_route) router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route) diff --git a/bot/app/web/admin_api_impl/settings.py b/backend/bot/app/web/admin_api_impl/settings.py similarity index 80% rename from bot/app/web/admin_api_impl/settings.py rename to backend/bot/app/web/admin_api_impl/settings.py index 787778f..5f13ee4 100644 --- a/bot/app/web/admin_api_impl/settings.py +++ b/backend/bot/app/web/admin_api_impl/settings.py @@ -70,5 +70,21 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response: if isinstance(cache, dict): cache["ts"] = 0.0 cache["data"] = {} + if ( + "WEBAPP_LOGO_URL" in updates + or "WEBAPP_LOGO_URL" in deletes + or "WEBAPP_LOGO_USE_EMOJI" in updates + or "WEBAPP_LOGO_USE_EMOJI" in deletes + or "WEBAPP_FAVICON_URL" in updates + or "WEBAPP_FAVICON_URL" in deletes + or "WEBAPP_FAVICON_USE_CUSTOM" in updates + or "WEBAPP_FAVICON_USE_CUSTOM" in deletes + or "WEBAPP_LOGO_FAVICON_URL" in updates + 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/backend/bot/app/web/admin_api_impl/themes.py b/backend/bot/app/web/admin_api_impl/themes.py new file mode 100644 index 0000000..b173497 --- /dev/null +++ b/backend/bot/app/web/admin_api_impl/themes.py @@ -0,0 +1,452 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + +import asyncio +import hashlib +import ipaddress +import shutil +import re +import socket + +from aiohttp import ClientSession, ClientTimeout +from PIL import Image, ImageOps, UnidentifiedImageError + +from config.webapp_themes_config import ( + WebappThemesConfig, + ensure_webapp_core_themes, + resolved_webapp_themes_catalog, + write_webapp_theme_dir, +) + + +WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 +WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "uploads" +WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo" +WEBAPP_FAVICON_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "favicons" +WEBAPP_FAVICON_PATH = "/webapp-favicon" +WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-emoji" +WEBAPP_FAVICON_SIZES = (16, 32, 48, 180, 192, 512) +WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = { + ".gif": "image/gif", + ".ico": "image/x-icon", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +} + + +def _detect_logo_extension( + body: bytes, content_type: str = "", filename: str = "" +) -> Optional[str]: + content_type = (content_type or "").split(";", 1)[0].strip().lower() + suffix = Path(filename or "").suffix.lower() + if content_type == "image/png" or body.startswith(b"\x89PNG\r\n\x1a\n"): + return ".png" + if content_type == "image/jpeg" or body.startswith(b"\xff\xd8\xff"): + return ".jpg" + if content_type == "image/gif" or body.startswith((b"GIF87a", b"GIF89a")): + return ".gif" + if content_type == "image/webp" or ( + len(body) > 12 and body[:4] == b"RIFF" and body[8:12] == b"WEBP" + ): + return ".webp" + if content_type in {"image/svg+xml", "image/svg"} or suffix == ".svg": + head = body[:512].lstrip().lower() + if head.startswith(b" str: + if not body or len(body) > WEBAPP_LOGO_MAX_BYTES: + raise ValueError("logo must be a non-empty image up to 2 MiB") + ext = _detect_logo_extension(body, content_type, filename) + if ext not in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES: + raise ValueError("unsupported image type") + digest = hashlib.sha256(body).hexdigest()[:16] + safe_name = f"logo-{digest}{ext}" + WEBAPP_UPLOADED_LOGO_DIR.mkdir(parents=True, exist_ok=True) + (WEBAPP_UPLOADED_LOGO_DIR / safe_name).write_bytes(body) + return f"{WEBAPP_UPLOADED_LOGO_PATH}/{safe_name}" + + +def _uploaded_logo_filename(url: str) -> Optional[str]: + parsed = urlsplit(str(url or "")) + path = parsed.path if parsed.scheme or parsed.netloc else str(url or "") + prefix = f"{WEBAPP_UPLOADED_LOGO_PATH}/" + if not path.startswith(prefix): + return None + filename = path.removeprefix(prefix) + if re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename): + return filename + return None + + +def _favicon_digest(url: str) -> Optional[str]: + parsed = urlsplit(str(url or "")) + path = parsed.path if parsed.scheme or parsed.netloc else str(url or "") + match = re.fullmatch( + rf"{re.escape(WEBAPP_FAVICON_PATH)}/([0-9a-f]{{16}})/(?:[A-Za-z0-9_.-]+)", + path, + ) + return match.group(1) if match else None + + +def _emoji_to_codepoints(value: str) -> str: + return "_".join(f"{ord(char):x}" for char in str(value or "").strip()) + + +def prune_unused_appearance_assets(settings: Settings) -> None: + keep_logos = { + filename + for filename in [ + _uploaded_logo_filename(getattr(settings, "WEBAPP_LOGO_URL", "")), + ] + if filename + } + keep_favicons = { + digest + for digest in [ + _favicon_digest(getattr(settings, "WEBAPP_FAVICON_URL", "")), + _favicon_digest(getattr(settings, "WEBAPP_LOGO_FAVICON_URL", "")), + ] + if digest + } + keep_emoji_prefixes = set() + if ( + getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False) + and str(getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") or "").strip() + == "noto-color-animated" + ): + codepoints = _emoji_to_codepoints(getattr(settings, "WEBAPP_LOGO_EMOJI", "")) + if codepoints: + keep_emoji_prefixes.add(f"{codepoints}.512.") + + for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"): + if path.is_file() and path.name not in keep_logos: + try: + path.unlink() + except OSError: + logger.warning("Failed to remove unused webapp logo %s", path, exc_info=True) + + for path in WEBAPP_FAVICON_DIR.glob("*"): + if ( + path.is_dir() + and re.fullmatch(r"[0-9a-f]{16}", path.name) + and path.name not in keep_favicons + ): + try: + shutil.rmtree(path) + except OSError: + logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True) + + for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"): + if path.is_file() and not any( + path.name.startswith(prefix) for prefix in keep_emoji_prefixes + ): + try: + path.unlink() + except OSError: + logger.warning("Failed to remove unused webapp emoji asset %s", path, exc_info=True) + + +async def _persist_appearance_upload( + request: web.Request, + updates: Dict[str, Any], + actor_id: int, +) -> bool: + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + result = await update_overrides( + settings, + async_session_factory, + updates=updates, + deletes=[], + actor_id=actor_id, + ) + if not result.get("ok"): + logger.warning("Failed to persist uploaded appearance asset settings: %s", result) + return False + + 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) + canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + left = (size - fitted.width) // 2 + top = (size - fitted.height) // 2 + canvas.alpha_composite(fitted, (left, top)) + return canvas + + +def _write_favicon_set(body: bytes, content_type: str = "", filename: str = "") -> Dict[str, Any]: + if not body or len(body) > WEBAPP_LOGO_MAX_BYTES: + raise ValueError("favicon source must be a non-empty image up to 2 MiB") + + ext = _detect_logo_extension(body, content_type, filename) + digest = hashlib.sha256(body).hexdigest()[:16] + target_dir = WEBAPP_FAVICON_DIR / digest + target_dir.mkdir(parents=True, exist_ok=True) + + if ext == ".svg": + safe_name = "favicon.svg" + (target_dir / safe_name).write_bytes(body) + return { + "favicon_url": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}", + "variants": {"svg": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}"}, + } + + try: + with Image.open(io.BytesIO(body)) as image: + image.seek(0) + source = ImageOps.exif_transpose(image).convert("RGBA") + except (OSError, UnidentifiedImageError, ValueError) as exc: + raise ValueError("favicon source must be a raster image") from exc + + if source.width < 1 or source.height < 1 or source.width > 8192 or source.height > 8192: + raise ValueError("favicon source dimensions are not supported") + + variants: Dict[str, str] = {} + png_icons: Dict[int, Image.Image] = {} + for size in WEBAPP_FAVICON_SIZES: + icon = _image_to_square_icon(source, size) + png_icons[size] = icon + filename = f"icon-{size}.png" + icon.save(target_dir / filename, format="PNG", optimize=True) + variants[f"{size}"] = f"{WEBAPP_FAVICON_PATH}/{digest}/{filename}" + + png_icons[180].save(target_dir / "apple-touch-icon.png", format="PNG", optimize=True) + variants["apple_touch"] = f"{WEBAPP_FAVICON_PATH}/{digest}/apple-touch-icon.png" + png_icons[32].save( + target_dir / "favicon.ico", + format="ICO", + sizes=[(16, 16), (32, 32), (48, 48)], + ) + variants["ico"] = f"{WEBAPP_FAVICON_PATH}/{digest}/favicon.ico" + return { + "favicon_url": variants["180"], + "variants": variants, + } + + +async def _read_uploaded_logo_file(request: web.Request) -> tuple[bytes, str, str]: + reader = await request.multipart() + async for part in reader: + if part.name != "file": + continue + body = bytearray() + while True: + chunk = await part.read_chunk(size=64 * 1024) + if not chunk: + break + body.extend(chunk) + if len(body) > WEBAPP_LOGO_MAX_BYTES: + raise ValueError("logo must be up to 2 MiB") + return bytes(body), part.headers.get("Content-Type", ""), part.filename or "" + raise ValueError("file field is required") + + +async def _hostname_resolves_to_public_address(hostname: str) -> bool: + if not hostname: + return False + try: + ip_obj = ipaddress.ip_address(hostname) + return not ( + ip_obj.is_private + or ip_obj.is_loopback + or ip_obj.is_link_local + or ip_obj.is_unspecified + or ip_obj.is_reserved + ) + except ValueError: + pass + + loop = asyncio.get_running_loop() + try: + resolved = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) + except Exception: + return False + + found_public_ip = False + for entry in resolved: + sockaddr = entry[4] + candidate = sockaddr[0] if sockaddr else "" + try: + ip_obj = ipaddress.ip_address(candidate) + except ValueError: + continue + if ( + ip_obj.is_private + or ip_obj.is_loopback + or ip_obj.is_link_local + or ip_obj.is_unspecified + or ip_obj.is_reserved + ): + return False + found_public_ip = True + return found_public_ip + + +async def _fetch_logo_from_url(url: str) -> tuple[bytes, str, str]: + parsed = urlsplit(url) + if parsed.scheme != "https" or not parsed.hostname: + raise ValueError("only https image URLs are supported") + if not await _hostname_resolves_to_public_address(parsed.hostname): + raise ValueError("logo URL must resolve to a public address") + + timeout = ClientTimeout(total=5) + async with ClientSession(timeout=timeout, headers={"User-Agent": "Mozilla/5.0"}) as session: + async with session.get( + url, + allow_redirects=False, + headers={"Accept": "image/avif,image/webp,image/svg+xml,image/png,image/*,*/*;q=0.8"}, + ) as response: + if response.status != 200: + raise ValueError(f"logo URL returned HTTP {response.status}") + content_type = ( + (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower() + ) + if content_type and not content_type.startswith("image/"): + raise ValueError("logo URL returned non-image content") + body = bytearray() + async for chunk in response.content.iter_chunked(64 * 1024): + body.extend(chunk) + if len(body) > WEBAPP_LOGO_MAX_BYTES: + raise ValueError("logo must be up to 2 MiB") + return bytes(body), content_type, Path(parsed.path).name + + +async def admin_appearance_logo_upload_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + content_type = (request.headers.get("Content-Type") or "").lower() + try: + if content_type.startswith("multipart/form-data"): + body, detected_content_type, filename = await _read_uploaded_logo_file(request) + else: + payload = await _read_json(request) + source_url = str(payload.get("url") or "").strip() + if not source_url: + return _error(400, "invalid_payload", "url or file is required") + body, detected_content_type, filename = await _fetch_logo_from_url(source_url) + logo_url = _write_uploaded_logo(body, detected_content_type, filename) + try: + favicon_payload = _write_favicon_set(body, detected_content_type, filename) + except ValueError: + favicon_payload = {} + except ValueError as exc: + return _error(400, "invalid_logo", str(exc)) + except OSError as exc: + logger.exception("Failed to save uploaded webapp logo") + return _error(500, "write_failed", str(exc)) + persisted = await _persist_appearance_upload( + request, + { + "WEBAPP_LOGO_URL": logo_url, + "WEBAPP_LOGO_USE_EMOJI": False, + **( + {"WEBAPP_LOGO_FAVICON_URL": favicon_payload["favicon_url"]} + if favicon_payload.get("favicon_url") + else {} + ), + }, + actor_id, + ) + + return _ok({"logo_url": logo_url, "persisted": persisted, **favicon_payload}) + + +async def admin_appearance_favicon_upload_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + content_type = (request.headers.get("Content-Type") or "").lower() + try: + if content_type.startswith("multipart/form-data"): + body, detected_content_type, filename = await _read_uploaded_logo_file(request) + else: + payload = await _read_json(request) + source_url = str(payload.get("url") or "").strip() + if not source_url: + return _error(400, "invalid_payload", "url or file is required") + body, detected_content_type, filename = await _fetch_logo_from_url(source_url) + favicon_payload = _write_favicon_set(body, detected_content_type, filename) + except ValueError as exc: + return _error(400, "invalid_favicon", str(exc)) + except OSError as exc: + logger.exception("Failed to save uploaded webapp favicon") + return _error(500, "write_failed", str(exc)) + persisted = await _persist_appearance_upload( + request, + { + "WEBAPP_FAVICON_URL": favicon_payload["favicon_url"], + "WEBAPP_FAVICON_USE_CUSTOM": True, + }, + actor_id, + ) + + return _ok({"persisted": persisted, **favicon_payload}) + + +async def admin_themes_get_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + primary = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a" + catalog = resolved_webapp_themes_catalog( + primary_accent=primary, + env_default_theme=settings.WEBAPP_DEFAULT_THEME, + theme_dir=settings.WEBAPP_THEMES_DIR, + ) + + return _ok( + { + "exists": Path(settings.WEBAPP_THEMES_DIR).expanduser().exists(), + "themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()), + "catalog": _webapp_themes_catalog_payload(catalog), + } + ) + + +async def admin_themes_save_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + payload = await _read_json(request) + catalog = payload.get("catalog") if "catalog" in payload else payload + if not isinstance(catalog, dict): + return _error(400, "invalid_payload", "catalog must be an object") + + try: + config = WebappThemesConfig.model_validate(catalog) + except (ValidationError, ValueError) as exc: + return _error(400, "invalid_webapp_themes_config", str(exc)) + + config, _changed = ensure_webapp_core_themes(config, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a") + + try: + write_webapp_theme_dir(settings.WEBAPP_THEMES_DIR, config, delete_missing=True) + except OSError as exc: + logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR) + return _error(500, "write_failed", str(exc)) + + cache = request.app.get("webapp_settings_cache") + if isinstance(cache, dict): + cache["ts"] = 0.0 + cache["data"] = {} + + return _ok( + { + "exists": True, + "themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()), + "catalog": _webapp_themes_catalog_payload(config), + } + ) 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 97% rename from bot/app/web/admin_settings_manifest.py rename to backend/bot/app/web/admin_settings_manifest.py index 1b4b578..739f99a 100644 --- a/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -79,6 +79,7 @@ SETTINGS_MANIFEST: List[SettingField] = [ SettingField( "WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a" ), + SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"), SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"), SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"), SettingField( @@ -99,6 +100,14 @@ SETTINGS_MANIFEST: List[SettingField] = [ ("noto-local", "Noto Emoji (local)"), ), ), + SettingField( + "WEBAPP_FAVICON_USE_CUSTOM", + "bool", + "appearance", + "Использовать отдельную favicon", + ), + SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"), + SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"), SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"), # ─── Subscription periods & pricing ──────────────────────────── SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"), 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 87% rename from bot/app/web/templates/subscription_webapp.html rename to backend/bot/app/web/templates/subscription_webapp.html index cf213f6..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/backend/bot/app/web/themes/ascii/style.css b/backend/bot/app/web/themes/ascii/style.css new file mode 100644 index 0000000..77d1878 --- /dev/null +++ b/backend/bot/app/web/themes/ascii/style.css @@ -0,0 +1,953 @@ +/* + * ASCII / console theme. + * Pure black background, white foreground, monospace everything, + * 1px white borders, animated ASCII spinners and block-progress bars. + */ + +.theme-key-ascii { + color-scheme: dark; + --accent: #ffffff; + --accent-contrast: #000000; + --bg: #000000; + --panel: #000000; + --panel-2: #050505; + --panel-3: #0c0c0c; + --border: #ffffff; + --border-strong: #ffffff; + --text: #ffffff; + --muted: #b0b0b0; + --dim: #6a6a6a; + --danger: #ff5555; + --blue: #ffffff; + --radius: 0px; + --font-sans: "JetBrains Mono", "Cascadia Code", "Fira Code", "Consolas", + "Source Code Pro", "Courier New", ui-monospace, monospace; + --font-logo: "JetBrains Mono", "Cascadia Code", "Consolas", "Courier New", + ui-monospace, monospace; + --font-mono: "JetBrains Mono", "Cascadia Code", "Consolas", "Courier New", + ui-monospace, monospace; + --surface-sheen: transparent; + --surface-sheen-soft: transparent; + --surface-hover: rgba(255, 255, 255, 0.08); + --surface-muted: #0a0a0a; + --surface-subtle-border: #ffffff; + --overlay-scrim: rgba(0, 0, 0, 0.85); + --nav-bg: #000000; + --rail-bg: #000000; + --shadow-soft: none; + --shadow-strong: none; + --shadow-popover: 0 0 0 1px #ffffff; + --inset-highlight: transparent; + --admin-bg: #000000; + --admin-surface: #000000; + --admin-surface-2: #050505; + --admin-elev: #0c0c0c; + --admin-border: #ffffff; + --admin-border-strong: #ffffff; + --admin-text: #ffffff; + --admin-muted: #b0b0b0; + --admin-dim: #6a6a6a; +} + +/* ---------- Base typography ---------- */ + +.theme-key-ascii, +.theme-key-ascii body, +.theme-key-ascii button, +.theme-key-ascii input, +.theme-key-ascii textarea, +.theme-key-ascii select { + font-family: var(--font-sans); + letter-spacing: 0; + font-synthesis: none; + -webkit-font-smoothing: none; + font-smooth: never; + font-variant-ligatures: none; +} + +.theme-key-ascii.app-shell { + background: var(--bg) !important; + background-image: + repeating-linear-gradient( + 0deg, + rgba(255, 255, 255, 0.025) 0, + rgba(255, 255, 255, 0.025) 1px, + transparent 1px, + transparent 3px + ) !important; +} + +/* Slight CRT-like flicker on the shell. */ +@keyframes ascii-flicker { + 0%, 96%, 100% { opacity: 1; } + 97% { opacity: 0.96; } + 98% { opacity: 1; } + 99% { opacity: 0.94; } +} + +.theme-key-ascii.app-shell::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9998; + background: repeating-linear-gradient( + 180deg, + rgba(255, 255, 255, 0.02) 0, + rgba(255, 255, 255, 0.02) 1px, + transparent 1px, + transparent 2px + ); + animation: ascii-flicker 5s infinite; +} + +/* ---------- Panels / cards ---------- */ + +.theme-key-ascii .card, +.theme-key-ascii .period-card, +.theme-key-ascii .method-card, +.theme-key-ascii .settings-row, +.theme-key-ascii .option-row, +.theme-key-ascii .tariff-selected-card, +.theme-key-ascii .tariff-action-card, +.theme-key-ascii .tariff-warning-card, +.theme-key-ascii .topup-carryover-note, +.theme-key-ascii .input, +.theme-key-ascii .dialog-card, +.theme-key-ascii .language-select-content, +.theme-key-ascii .bottom-nav, +.theme-key-ascii .toast, +.theme-key-ascii .admin-sidebar, +.theme-key-ascii .admin-header, +.theme-key-ascii .admin-card, +.theme-key-ascii .admin-stat-card, +.theme-key-ascii .admin-revenue-panel, +.theme-key-ascii .admin-empty, +.theme-key-ascii .admin-tariff-card, +.theme-key-ascii .admin-toolbar-card, +.theme-key-ascii .admin-table-card, +.theme-key-ascii .admin-panel-dash-card, +.theme-key-ascii .admin-select-trigger, +.theme-key-ascii .admin-select-content, +.theme-key-ascii .admin-cn-card[data-slot="card"], +.theme-key-ascii .admin-dialog .dialog-card, +.theme-key-ascii .admin-theme-editor-section { + border: 1px solid #ffffff; + border-radius: 0; + background: var(--panel); + box-shadow: none; +} + +/* No ribbon/corner overlays: those caused dialog overflow scrollbars. + * The console feel comes from the crisp 1px borders, monospace text, + * and the animated marquees / glitches applied to interactive elements. */ + +/* ---------- Buttons ---------- */ + +.theme-key-ascii .btn, +.theme-key-ascii .language-select-trigger, +.theme-key-ascii .bottom-nav button, +.theme-key-ascii .admin-btn, +.theme-key-ascii .admin-chip, +.theme-key-ascii .admin-tabs-trigger, +.theme-key-ascii .admin-revenue-period-btn, +.theme-key-ascii .admin-mobile-toggle, +.theme-key-ascii .admin-nav-item { + border: 1px solid #ffffff; + border-radius: 0; + background: #000000; + color: #ffffff; + box-shadow: none; + text-transform: none; + font-family: var(--font-sans); + transform: none; + position: relative; +} + +.theme-key-ascii .btn:hover:not(:disabled), +.theme-key-ascii .admin-btn:hover:not(:disabled), +.theme-key-ascii .admin-nav-item:hover, +.theme-key-ascii .admin-tabs-trigger:hover, +.theme-key-ascii .admin-revenue-period-btn:hover, +.theme-key-ascii .bottom-nav button:hover { + background: #ffffff; + color: #000000; +} + +.theme-key-ascii .btn:active:not(:disabled), +.theme-key-ascii .bottom-nav button:active, +.theme-key-ascii .admin-btn:active:not(:disabled) { + background: #ffffff; + color: #000000; + transform: translate(1px, 1px); +} + +.theme-key-ascii .btn-primary, +.theme-key-ascii .admin-btn-primary, +.theme-key-ascii .bottom-nav button.active, +.theme-key-ascii .period-card.active, +.theme-key-ascii .method-card.active, +.theme-key-ascii .option-row.active, +.theme-key-ascii .admin-nav-item.active, +.theme-key-ascii .admin-tabs-trigger[data-state="active"], +.theme-key-ascii .admin-revenue-period-btn.is-active { + background: #ffffff; + color: #000000; + border-color: #ffffff; +} + +.theme-key-ascii .btn-primary:hover:not(:disabled), +.theme-key-ascii .admin-btn-primary:hover:not(:disabled) { + background: #000000; + color: #ffffff; + outline: 1px solid #ffffff; + outline-offset: -2px; +} + +/* Blinking caret-style focus ring. */ +@keyframes ascii-caret { + 0%, 49% { outline-color: #ffffff; } + 50%, 100% { outline-color: transparent; } +} + +.theme-key-ascii .btn:focus-visible, +.theme-key-ascii .admin-btn:focus-visible, +.theme-key-ascii .admin-nav-item:focus-visible, +.theme-key-ascii .admin-tabs-trigger:focus-visible, +.theme-key-ascii .admin-revenue-period-btn:focus-visible, +.theme-key-ascii .admin-mobile-toggle:focus-visible, +.theme-key-ascii .language-select-trigger:focus-visible, +.theme-key-ascii .bottom-nav button:focus-visible { + outline: 2px solid #ffffff; + outline-offset: 1px; + animation: ascii-caret 1s steps(1) infinite; +} + +/* ---------- Inputs ---------- */ + +.theme-key-ascii .input, +.theme-key-ascii .admin-input, +.theme-key-ascii .admin-textarea, +.theme-key-ascii .admin-screen-wrap textarea, +.theme-key-ascii .admin-dialog textarea { + border: 1px solid #ffffff; + border-radius: 0; + background: #000000; + color: #ffffff; + box-shadow: none; + font-family: var(--font-mono); + caret-color: #ffffff; +} + +.theme-key-ascii .input::placeholder, +.theme-key-ascii .admin-input::placeholder, +.theme-key-ascii .admin-textarea::placeholder, +.theme-key-ascii .admin-screen-wrap textarea::placeholder, +.theme-key-ascii .admin-dialog textarea::placeholder { + color: var(--dim); + font-style: normal; +} + +.theme-key-ascii .input:focus, +.theme-key-ascii .admin-input:focus, +.theme-key-ascii .admin-textarea:focus, +.theme-key-ascii .admin-screen-wrap textarea:focus, +.theme-key-ascii .admin-dialog textarea:focus { + outline: none; + border-color: #ffffff; + box-shadow: inset 0 0 0 1px #ffffff; +} + +/* ---------- Bottom nav (desktop rail) ---------- */ + +@media (min-width: 1024px) { + .theme-key-ascii .bottom-nav { + border-right: 1px solid #ffffff !important; + background: var(--rail-bg) !important; + backdrop-filter: none !important; + box-shadow: none !important; + } + + .theme-key-ascii .bottom-nav button { + border: 1px solid #ffffff !important; + border-radius: 0 !important; + background: #000000 !important; + color: #ffffff !important; + box-shadow: none !important; + } + + .theme-key-ascii .bottom-nav button.active { + background: #ffffff !important; + color: #000000 !important; + } +} + +/* ---------- ASCII progress bar ---------- * + * Empty track: ░░░░░░░░░░░ (low-contrast dotted fill). + * Filled span: ██████ (solid white blocks). + */ + +.theme-key-ascii .progress { + height: 14px; + border: 1px solid #ffffff; + border-radius: 0 !important; + background-color: #000000; + background-image: repeating-linear-gradient( + 90deg, + rgba(255, 255, 255, 0.22) 0, + rgba(255, 255, 255, 0.22) 1px, + transparent 1px, + transparent 4px + ); + position: relative; + overflow: hidden; + font-family: var(--font-mono); +} + +.theme-key-ascii .progress span { + border-radius: 0 !important; + background: #ffffff !important; + background-image: repeating-linear-gradient( + 90deg, + rgba(0, 0, 0, 0.0) 0, + rgba(0, 0, 0, 0.0) 5px, + rgba(0, 0, 0, 0.35) 5px, + rgba(0, 0, 0, 0.35) 6px + ) !important; + box-shadow: none; +} + +/* Indeterminate scanning effect for any progress lacking a width-set span. */ +@keyframes ascii-scan { + 0% { background-position: 0 0; } + 100% { background-position: 12px 0; } +} + +/* ---------- ASCII spinner replacement ---------- */ + +.theme-key-ascii .ui-spinner, +.theme-key-ascii .telegram-button-spinner, +.theme-key-ascii .brand-mark-spinner { + border: none !important; + border-radius: 0 !important; + width: 1ch !important; + height: 1em !important; + background: transparent !important; + position: relative; + animation: none !important; + color: currentColor; + font-family: var(--font-mono); + font-weight: 700; + text-align: center; + vertical-align: middle; + overflow: visible; +} + +.theme-key-ascii .ui-spinner::before, +.theme-key-ascii .telegram-button-spinner::before, +.theme-key-ascii .brand-mark-spinner::before { + content: "|"; + display: inline-block; + animation: ascii-spin 0.8s steps(1) infinite; + font-family: var(--font-mono); + line-height: 1; +} + +@keyframes ascii-spin { + 0% { content: "|"; } + 25% { content: "/"; } + 50% { content: "-"; } + 75% { content: "\\"; } + 100% { content: "|"; } +} + +/* Some browsers don't animate content; fallback rotation of a glyph. */ +@supports not (animation-name: ascii-spin) { + .theme-key-ascii .ui-spinner::before, + .theme-key-ascii .telegram-button-spinner::before, + .theme-key-ascii .brand-mark-spinner::before { + content: "+"; + animation: ascii-spin-rotate 0.8s steps(4) infinite; + } + @keyframes ascii-spin-rotate { + to { transform: rotate(360deg); } + } +} + +/* Blinking cursor appended to brand text. */ +.theme-key-ascii .login-brand h1::after, +.theme-key-ascii .admin-sidebar-brand strong::after, +.theme-key-ascii .brand-row strong::after { + content: "_"; + display: inline-block; + margin-left: 0.2ch; + color: #ffffff; + animation: ascii-blink 1s steps(1) infinite; +} + +@keyframes ascii-blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } +} + +/* Section heading prompt prefix. */ +.theme-key-ascii .admin-card-head h2::before, +.theme-key-ascii .admin-card-head h3::before, +.theme-key-ascii .card > h2:first-child::before, +.theme-key-ascii .card > h3:first-child::before { + content: "> "; + color: #ffffff; + opacity: 0.85; + font-family: var(--font-mono); +} + +/* ---------- Tables ---------- */ + +.theme-key-ascii .admin-table thead th { + background: #000000; + color: #ffffff; + border-bottom: 1px solid #ffffff; + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 700; +} + +.theme-key-ascii .admin-table tbody tr { + border-bottom: 1px dashed #ffffff; +} + +.theme-key-ascii .admin-table tbody tr:hover { + background: rgba(255, 255, 255, 0.08); +} + +/* ---------- Badges / chips ---------- */ + +.theme-key-ascii .admin-badge, +.theme-key-ascii .admin-cn-badge { + border: 1px solid #ffffff; + border-radius: 0; + background: #000000; + color: #ffffff; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.theme-key-ascii .admin-badge::before, +.theme-key-ascii .admin-cn-badge::before { + content: "["; +} + +.theme-key-ascii .admin-badge::after, +.theme-key-ascii .admin-cn-badge::after { + content: "]"; +} + +/* ---------- Links ---------- */ + +.theme-key-ascii a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]), +.theme-key-ascii .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item) { + color: #ffffff; + text-decoration: underline; + text-underline-offset: 2px; +} + +.theme-key-ascii a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):hover, +.theme-key-ascii .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item):hover { + background: #ffffff; + color: #000000; + text-decoration: none; +} + +/* ---------- Selection ---------- */ + +.theme-key-ascii ::selection { + background: #ffffff; + color: #000000; +} + +/* ---------- Scrollbars ---------- */ + +.theme-key-ascii ::-webkit-scrollbar { + width: 12px; + height: 12px; +} + +.theme-key-ascii ::-webkit-scrollbar-track { + background-color: #000000; + background-image: repeating-linear-gradient( + 0deg, + #ffffff 0, + #ffffff 1px, + transparent 1px, + transparent 4px + ); +} + +.theme-key-ascii ::-webkit-scrollbar-thumb { + background: #ffffff; + border: 1px solid #000000; +} + +.theme-key-ascii ::-webkit-scrollbar-thumb:active { + background: #b0b0b0; +} + +.theme-key-ascii ::-webkit-scrollbar-corner { + background: #000000; +} + +/* ---------- Lucide icons: render as crisp white outlines ---------- */ + +.theme-key-ascii svg.lucide, +.theme-key-ascii svg[class*="lucide-"] { + color: #ffffff !important; + stroke: #ffffff !important; + fill: none !important; + stroke-width: 1.75; + filter: none; +} + +.theme-key-ascii .btn-primary svg.lucide, +.theme-key-ascii .bottom-nav button.active svg.lucide, +.theme-key-ascii .period-card.active svg.lucide, +.theme-key-ascii .method-card.active svg.lucide, +.theme-key-ascii .option-row.active svg.lucide, +.theme-key-ascii .admin-btn-primary svg.lucide, +.theme-key-ascii .admin-nav-item.active svg.lucide, +.theme-key-ascii .admin-tabs-trigger[data-state="active"] svg.lucide, +.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide { + color: #000000 !important; + stroke: #000000 !important; +} + +/* ---------- Toast / language select polish ---------- */ + +.theme-key-ascii .toast { + background: #000000; + border: 1px solid #ffffff; + color: #ffffff; +} + +.theme-key-ascii .language-select-item { + border-radius: 0; +} + +.theme-key-ascii .language-select-item[data-highlighted], +.theme-key-ascii .language-select-item[data-selected] { + background: #ffffff; + color: #000000 !important; +} + +/* ---------- Headings: stronger console feel ---------- */ + +.theme-key-ascii h1, +.theme-key-ascii h2, +.theme-key-ascii h3, +.theme-key-ascii h4 { + font-family: var(--font-mono); + letter-spacing: 0; + text-transform: none; +} + +.theme-key-ascii .admin-header { + background: #000000; + border-bottom: 1px solid #ffffff; + color: #ffffff; +} + +.theme-key-ascii .admin-header-title h2, +.theme-key-ascii .admin-header-title small { + color: #ffffff; +} + +/* Make any element with role progressbar but no inner span show animated stripes. */ +.theme-key-ascii [role="progressbar"]:not(.progress) { + background: + repeating-linear-gradient( + 90deg, + #ffffff 0, + #ffffff 6px, + #000000 6px, + #000000 8px + ); + animation: ascii-scan 0.6s linear infinite; + border: 1px solid #ffffff; + border-radius: 0; + color: #000000; +} + +/* ============================================================ + * Console-themed extras + * ============================================================ */ + +/* ---------- ASCII skeletons ---------- * + * Subtle dark shimmer with a single bright scan line moving across. + */ + +@keyframes ascii-skeleton-scan { + 0% { background-position: -120% 0; } + 100% { background-position: 220% 0; } +} + +.theme-key-ascii .ui-skeleton, +.theme-key-ascii .admin-skeleton, +.theme-key-ascii .skeleton-line, +.theme-key-ascii .skeleton-dot, +.theme-key-ascii .skeleton-pay-button, +.theme-key-ascii .ui-skeleton-line, +.theme-key-ascii .admin-skeleton-line, +.theme-key-ascii .admin-skeleton-line-strong, +.theme-key-ascii .admin-skeleton-line-soft, +.theme-key-ascii .admin-skeleton-line-short, +.theme-key-ascii .admin-skeleton-line-tiny, +.theme-key-ascii .ui-skeleton-title, +.theme-key-ascii .ui-skeleton-short, +.theme-key-ascii .ui-skeleton-tiny, +.theme-key-ascii .ui-skeleton-badge, +.theme-key-ascii .admin-skeleton-badge, +.theme-key-ascii .admin-skeleton-avatar, +.theme-key-ascii .admin-stat-skeleton-card, +.theme-key-ascii .admin-stat-skeleton-wide, +.theme-key-ascii .admin-cn-card-skeleton--tall { + border-radius: 0 !important; + border: 1px solid #ffffff !important; + background-color: #050505 !important; + background-image: linear-gradient( + 90deg, + transparent 0%, + transparent 40%, + rgba(255, 255, 255, 0.18) 50%, + transparent 60%, + transparent 100% + ) !important; + background-size: 200% 100% !important; + background-repeat: no-repeat !important; + color: #ffffff !important; + animation: ascii-skeleton-scan 1.6s linear infinite !important; +} + +.theme-key-ascii .admin-skeleton-avatar { + width: 32px !important; + height: 32px !important; +} + +/* ---------- Empty / loading state console message ---------- */ +.theme-key-ascii .admin-empty { + position: relative; +} + +.theme-key-ascii .admin-empty::before { + content: "$ tail -f /var/log/empty.log"; + display: block; + font-family: var(--font-mono); + color: var(--muted); + margin-bottom: 8px; + letter-spacing: 0; +} + +/* ---------- Buttons: glitch on hover ---------- */ + +@keyframes ascii-glitch { + 0%, 100% { transform: translate(0, 0); clip-path: inset(0 0 0 0); } + 20% { transform: translate(-1px, 0); clip-path: inset(20% 0 50% 0); } + 40% { transform: translate(1px, 0); clip-path: inset(40% 0 30% 0); } + 60% { transform: translate(-1px, 0); clip-path: inset(10% 0 70% 0); } + 80% { transform: translate(1px, 0); clip-path: inset(60% 0 10% 0); } +} + +.theme-key-ascii .btn:hover:not(:disabled)::after, +.theme-key-ascii .admin-btn:hover:not(:disabled)::after { + content: attr(data-label, ""); + pointer-events: none; +} + +/* Disable glitch text duplication if the button has no data-label. + * Apply a subtle scanline overlay instead, which is content-agnostic. */ +.theme-key-ascii .btn, +.theme-key-ascii .admin-btn, +.theme-key-ascii .admin-nav-item, +.theme-key-ascii .bottom-nav button { + overflow: hidden; +} + +.theme-key-ascii .btn:hover:not(:disabled)::before, +.theme-key-ascii .admin-btn:hover:not(:disabled)::before, +.theme-key-ascii .admin-nav-item:hover::before, +.theme-key-ascii .bottom-nav button:hover::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: repeating-linear-gradient( + 0deg, + rgba(0, 0, 0, 0.4) 0, + rgba(0, 0, 0, 0.4) 1px, + transparent 1px, + transparent 3px + ); + animation: ascii-glitch 0.6s steps(1) infinite; + z-index: 1; +} + +/* ---------- Bottom nav active markers "> item <" ---------- */ + +.theme-key-ascii .bottom-nav button.active::before, +.theme-key-ascii .admin-nav-item.active::before { + content: ">"; + position: absolute; + left: 6px; + top: 50%; + transform: translateY(-50%); + font-family: var(--font-mono); + color: #000000; + animation: ascii-blink 1s steps(1) infinite; + z-index: 2; +} + +.theme-key-ascii .bottom-nav button.active::after, +.theme-key-ascii .admin-nav-item.active::after { + content: "<"; + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + font-family: var(--font-mono); + color: #000000; + animation: ascii-blink 1s steps(1) infinite; + animation-delay: 0.5s; + z-index: 2; +} + +.theme-key-ascii .bottom-nav button.active, +.theme-key-ascii .admin-nav-item.active { + position: relative; +} + +/* On the mobile bottom-bar (compact) hide the markers to avoid overlap. */ +@media (max-width: 1023px) { + .theme-key-ascii .bottom-nav button.active::before, + .theme-key-ascii .bottom-nav button.active::after { + content: none; + } +} + +/* ---------- ASCII block progress fill ---------- * + * The actual fill renders as alternating █ blocks via the existing span + * gradient. We also overlay a slow scanning highlight to make it feel + * "live", and add a soft typed counter to the right edge. + */ + +/* (duplicate progress fill rules removed — see definition above) */ + +/* ---------- Headings: subtle CRT glitch on hover ---------- */ + +@keyframes ascii-heading-jitter { + 0%, 92%, 100% { transform: translate(0, 0); } + 93% { transform: translate(-1px, 0); } + 94% { transform: translate(1px, 0); } + 95% { transform: translate(0, -1px); } + 96% { transform: translate(0, 1px); } +} + +.theme-key-ascii h1, +.theme-key-ascii h2, +.theme-key-ascii h3, +.theme-key-ascii .login-brand h1, +.theme-key-ascii .admin-sidebar-brand strong, +.theme-key-ascii .admin-card-head h2, +.theme-key-ascii .admin-card-head h3 { + display: inline-block; + animation: ascii-heading-jitter 7s steps(1) infinite; +} + +/* ---------- App-shell boot banner ---------- * + * A non-blocking strip at the very top of the viewport that displays a + * typed "booting…" line, then settles. Pure CSS so it cannot interfere + * with any DOM. The animation runs once on mount. + */ + +@keyframes ascii-boot-type { + 0% { width: 0; } + 85% { width: 28ch; } + 100% { width: 28ch; } +} + +@keyframes ascii-boot-fade { + 0%, 70% { opacity: 1; } + 100% { opacity: 0; visibility: hidden; } +} + +.theme-key-ascii.app-shell::after { + content: "$ remnawave --start --tty=0"; + position: fixed; + top: 0; + left: 0; + z-index: 9999; + display: block; + padding: 2px 8px; + max-width: 28ch; + overflow: hidden; + white-space: nowrap; + background: #000000; + color: #ffffff; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.5; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + pointer-events: none; + animation: + ascii-boot-type 1.6s steps(28) 1 both, + ascii-boot-fade 3s linear 1.6s 1 forwards; +} + +/* ---------- Toggle / checkbox squareification (best-effort) ---------- */ + +.theme-key-ascii input[type="checkbox"], +.theme-key-ascii input[type="radio"] { + appearance: none; + -webkit-appearance: none; + width: 1em; + height: 1em; + border: 1px solid #ffffff; + background: #000000; + border-radius: 0 !important; + position: relative; + vertical-align: middle; + cursor: pointer; +} + +.theme-key-ascii input[type="checkbox"]:checked::after, +.theme-key-ascii input[type="radio"]:checked::after { + content: "x"; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-mono); + font-weight: 700; + color: #ffffff; + line-height: 1; +} + +/* ---------- Code-like "$ " prefix on toast messages ---------- */ + +.theme-key-ascii .toast::before { + content: "$ "; + color: #ffffff; + font-family: var(--font-mono); + font-weight: 700; +} + +/* ---------- Disabled state — strikethrough hatching ---------- */ + +.theme-key-ascii .btn:disabled, +.theme-key-ascii .admin-btn:disabled, +.theme-key-ascii button:disabled { + background-image: repeating-linear-gradient( + -45deg, + transparent 0, + transparent 4px, + rgba(255, 255, 255, 0.18) 4px, + rgba(255, 255, 255, 0.18) 5px + ); + color: var(--dim) !important; + border-color: var(--dim) !important; + cursor: not-allowed; +} + +/* ============================================================ + * Square everything: drop all rounded corners on touched surfaces. + * ============================================================ */ + +.theme-key-ascii :is( + .card, .dialog-card, .toast, + .btn, .input, + .period-card, .method-card, .settings-row, .option-row, + .tariff-selected-card, .tariff-action-card, .tariff-warning-card, + .topup-carryover-note, .language-select-content, .language-select-item, + .language-select-trigger, .bottom-nav, .bottom-nav button, + .field-error-tooltip, + .admin-card, .admin-card-head, .admin-card-body, + .admin-stat-card, .admin-stat-skeleton-card, .admin-stat-skeleton-wide, + .admin-revenue-panel, .admin-empty, + .admin-tariff-card, .admin-toolbar-card, .admin-table-card, + .admin-panel-dash-card, + .admin-select-trigger, .admin-select-content, .admin-select-item, + .admin-cn-card, .admin-cn-badge, .admin-badge, + .admin-cn-card-skeleton--tall, + .admin-input, .admin-textarea, .admin-btn, .admin-chip, + .admin-tabs-trigger, .admin-tabs-list, + .admin-nav-item, .admin-revenue-period-btn, .admin-mobile-toggle, + .admin-header, .admin-sidebar, .admin-sidebar-brand, + .admin-dialog, + .admin-theme-editor-section, + [data-slot="card"], [data-slot="card-header"], + [data-slot="card-content"], [data-slot="card-footer"] +), +.theme-key-ascii :is( + .card, .dialog-card, .toast, + .btn, .input, + .admin-card, .admin-card-head, .admin-card-body, + .admin-stat-card, .admin-revenue-panel, .admin-empty, + .admin-tariff-card, .admin-toolbar-card, .admin-table-card, + .admin-panel-dash-card, + .admin-select-trigger, .admin-select-content, + .admin-cn-card, + .admin-input, .admin-textarea, .admin-btn, + .admin-nav-item, .admin-tabs-trigger +) * { + border-radius: 0 !important; +} + +.theme-key-ascii img, +.theme-key-ascii .admin-avatar, +.theme-key-ascii .admin-skeleton-avatar { + border-radius: 0 !important; +} + +/* ============================================================ + * Console-style tables: cell borders, header underline, + * row separator using dashed line. + * ============================================================ */ + +.theme-key-ascii .admin-table, +.theme-key-ascii table { + border-collapse: collapse; + border: 1px solid #ffffff; + font-family: var(--font-mono); +} + +.theme-key-ascii .admin-table th, +.theme-key-ascii .admin-table td, +.theme-key-ascii table th, +.theme-key-ascii table td { + border: 1px solid #ffffff; + border-radius: 0 !important; + padding: 6px 10px; +} + +.theme-key-ascii .admin-table thead th, +.theme-key-ascii table thead th { + background: #000000; + border-bottom: 2px solid #ffffff; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.theme-key-ascii .admin-table tbody tr, +.theme-key-ascii table tbody tr { + border-bottom: 1px solid #ffffff; +} + +.theme-key-ascii .admin-table tbody tr:hover, +.theme-key-ascii table tbody tr:hover { + background: rgba(255, 255, 255, 0.07); +} + +.theme-key-ascii .admin-table tbody tr:hover td, +.theme-key-ascii table tbody tr:hover td { + color: #ffffff; +} diff --git a/backend/bot/app/web/themes/ascii/theme.json b/backend/bot/app/web/themes/ascii/theme.json new file mode 100644 index 0000000..cd89c99 --- /dev/null +++ b/backend/bot/app/web/themes/ascii/theme.json @@ -0,0 +1,17 @@ +{ + "key": "ascii", + "names": { + "ru": "ASCII", + "en": "ASCII" + }, + "enabled": true, + "default": false, + "use_primary_accent": false, + "use_in_admin": true, + "css_file": "style.css", + "assets_version": 1, + "tokens": { + "color_scheme": "dark", + "style_preset": "ascii" + } +} diff --git a/backend/bot/app/web/themes/dark/theme.json b/backend/bot/app/web/themes/dark/theme.json new file mode 100644 index 0000000..d620dcb --- /dev/null +++ b/backend/bot/app/web/themes/dark/theme.json @@ -0,0 +1,27 @@ +{ + "key": "dark", + "names": { + "ru": "Темная", + "en": "Dark" + }, + "enabled": true, + "default": true, + "use_primary_accent": true, + "use_in_admin": true, + "assets_version": 1, + "tokens": { + "color_scheme": "dark", + "bg": "#03070b", + "panel": "#111820", + "panel_2": "#0b1118", + "panel_3": "#17212b", + "border": "rgba(255, 255, 255, 0.12)", + "border_strong": "rgba(255, 255, 255, 0.2)", + "text": "#f2f7f4", + "muted": "#a9b4b0", + "dim": "#68736f", + "danger": "#ff6b6b", + "blue": "#2d9cff", + "radius": "8px" + } +} diff --git a/backend/bot/app/web/themes/light/style.css b/backend/bot/app/web/themes/light/style.css new file mode 100644 index 0000000..25f6148 --- /dev/null +++ b/backend/bot/app/web/themes/light/style.css @@ -0,0 +1,131 @@ +.theme-key-light { + color-scheme: light; + --accent: #047857; + --bg: #f7f8fb; + --panel: #ffffff; + --panel-2: #f1f5f9; + --panel-3: #e8edf3; + --border: rgba(15, 23, 42, 0.11); + --border-strong: rgba(15, 23, 42, 0.2); + --text: #0f172a; + --muted: #475569; + --dim: #64748b; + --danger: #dc2626; + --danger-text: #b91c1c; + --danger-soft: color-mix(in srgb, var(--danger) 9%, var(--panel)); + --danger-border: color-mix(in srgb, var(--danger) 34%, var(--border)); + --success: #16a34a; + --success-text: #166534; + --success-soft: color-mix(in srgb, var(--success) 10%, var(--panel)); + --success-border: color-mix(in srgb, var(--success) 34%, var(--border)); + --warning: #d97706; + --warning-text: #92400e; + --warning-soft: color-mix(in srgb, var(--warning) 11%, var(--panel)); + --warning-border: color-mix(in srgb, var(--warning) 34%, var(--border)); + --info: #2563eb; + --info-text: #1d4ed8; + --info-soft: color-mix(in srgb, var(--info) 9%, var(--panel)); + --info-border: color-mix(in srgb, var(--info) 30%, var(--border)); + --blue: #2563eb; + --radius: 8px; + --accent-contrast: #ffffff; + --surface-sheen: rgba(15, 23, 42, 0.035); + --surface-sheen-soft: rgba(15, 23, 42, 0.012); + --surface-hover: rgba(15, 23, 42, 0.045); + --surface-muted: rgba(15, 23, 42, 0.035); + --surface-subtle-border: rgba(15, 23, 42, 0.1); + --overlay-scrim: rgba(15, 23, 42, 0.34); + --nav-bg: rgba(255, 255, 255, 0.88); + --rail-bg: rgba(255, 255, 255, 0.72); + --shadow-soft: 0 6px 18px rgba(15, 23, 42, 0.06); + --shadow-strong: 0 18px 44px rgba(15, 23, 42, 0.12); + --shadow-popover: 0 14px 28px rgba(15, 23, 42, 0.12); + --inset-highlight: rgba(255, 255, 255, 0.75); + --admin-bg: #f7f8fb; + --admin-surface: #ffffff; + --admin-surface-2: #f1f5f9; + --admin-elev: #e8edf3; + --admin-border: rgba(15, 23, 42, 0.1); + --admin-border-strong: rgba(15, 23, 42, 0.18); + --admin-text: #0f172a; + --admin-muted: #64748b; + --admin-dim: #64748b; + --admin-chart-stroke: #065f46; + --admin-chart-fill: rgba(6, 95, 70, 0.22); +} + +.theme-key-light .ui-spinner, +.theme-key-light .brand-mark-spinner { + color: inherit; +} + +.theme-key-light .telegram-button-spinner { + border-color: rgba(255, 255, 255, 0.35); + border-top-color: #ffffff; +} + +.theme-key-light .btn-primary, +.theme-key-light .admin-btn.admin-btn-primary, +.theme-key-light .admin-extend-control .admin-btn.admin-btn-primary { + background: color-mix(in srgb, var(--accent) 50%, #000000); + border-color: color-mix(in srgb, var(--accent) 42%, #000000); + color: #ffffff; +} + +.theme-key-light .btn-primary:hover:not(:disabled), +.theme-key-light .admin-btn.admin-btn-primary:hover:not(:disabled), +.theme-key-light .admin-extend-control .admin-btn.admin-btn-primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 52%, #000000); +} + +.theme-key-light.app-shell { + background: var(--bg) !important; +} + +.theme-key-light .phone-screen { + background: var(--bg); +} + +/* Flatten Settings rows: no gradient sheen, no inset highlight that reads as a 3D bevel */ +.theme-key-light .settings-row { + background: var(--panel); + box-shadow: none; +} + +.theme-key-light .settings-row-linked { + background: var(--success-soft); +} + +/* Avatar/profile card: bigger lift, but rows below have an opaque background and + stack above, so the shadow stays visually under them instead of bleeding through. */ +.theme-key-light .settings-profile { + box-shadow: + 0 10px 24px rgba(15, 23, 42, 0.10), + inset 0 1px 0 var(--inset-highlight); +} + +.theme-key-light .settings-links-block { + position: relative; + z-index: 1; +} + +/* Slightly stronger axis/grid contrast for the revenue chart on a light surface */ +.theme-key-light .admin-revenue-svg-frame { + background: #ffffff; +} + +/* Bonus section: drop accent color from body strongs; only the bonus-system heading + and explicitly-accent card headings stay tinted — and they use the same darkened + accent technique as .btn-primary on light, so they remain readable on white. */ +.theme-key-light .bonus-card strong { + color: var(--text); +} + +.theme-key-light .bonus-card-head strong, +.theme-key-light .card-heading-accent { + color: color-mix(in srgb, var(--accent) 50%, #000000); +} + +.theme-key-light .bonus-card-head > svg { + color: color-mix(in srgb, var(--accent) 50%, #000000); +} diff --git a/backend/bot/app/web/themes/light/theme.json b/backend/bot/app/web/themes/light/theme.json new file mode 100644 index 0000000..9713516 --- /dev/null +++ b/backend/bot/app/web/themes/light/theme.json @@ -0,0 +1,16 @@ +{ + "key": "light", + "names": { + "ru": "Светлая", + "en": "Light" + }, + "enabled": true, + "default": false, + "use_primary_accent": true, + "use_in_admin": true, + "css_file": "style.css", + "assets_version": 2, + "tokens": { + "color_scheme": "light" + } +} diff --git a/backend/bot/app/web/themes/windows95/icons/arrow-left.png b/backend/bot/app/web/themes/windows95/icons/arrow-left.png new file mode 100644 index 0000000..38a3df6 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/arrow-left.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/arrow-right.png b/backend/bot/app/web/themes/windows95/icons/arrow-right.png new file mode 100644 index 0000000..5c1cb20 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/arrow-right.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/bitcoin.png b/backend/bot/app/web/themes/windows95/icons/bitcoin.png new file mode 100644 index 0000000..55de9d3 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/bitcoin.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/check.png b/backend/bot/app/web/themes/windows95/icons/check.png new file mode 100644 index 0000000..78e2094 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/check.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/chevrons.png b/backend/bot/app/web/themes/windows95/icons/chevrons.png new file mode 100644 index 0000000..cb925ce Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/chevrons.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/coins.png b/backend/bot/app/web/themes/windows95/icons/coins.png new file mode 100644 index 0000000..0697922 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/coins.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/dashboard.png b/backend/bot/app/web/themes/windows95/icons/dashboard.png new file mode 100644 index 0000000..18f939b Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/dashboard.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/database.png b/backend/bot/app/web/themes/windows95/icons/database.png new file mode 100644 index 0000000..698e6d5 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/database.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/download.png b/backend/bot/app/web/themes/windows95/icons/download.png new file mode 100644 index 0000000..5d96a1b Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/download.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/error.png b/backend/bot/app/web/themes/windows95/icons/error.png new file mode 100644 index 0000000..5d046a3 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/error.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/file-text.png b/backend/bot/app/web/themes/windows95/icons/file-text.png new file mode 100644 index 0000000..c1ab35e Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/file-text.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/folder.png b/backend/bot/app/web/themes/windows95/icons/folder.png new file mode 100644 index 0000000..437efbf Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/folder.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/gift.png b/backend/bot/app/web/themes/windows95/icons/gift.png new file mode 100644 index 0000000..0697922 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/gift.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/globe.png b/backend/bot/app/web/themes/windows95/icons/globe.png new file mode 100644 index 0000000..f740cee Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/globe.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/help.png b/backend/bot/app/web/themes/windows95/icons/help.png new file mode 100644 index 0000000..1aa4921 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/help.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/home.png b/backend/bot/app/web/themes/windows95/icons/home.png new file mode 100644 index 0000000..a713e61 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/home.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/info.png b/backend/bot/app/web/themes/windows95/icons/info.png new file mode 100644 index 0000000..d540eb7 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/info.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/key.png b/backend/bot/app/web/themes/windows95/icons/key.png new file mode 100644 index 0000000..eb69306 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/key.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/lock.png b/backend/bot/app/web/themes/windows95/icons/lock.png new file mode 100644 index 0000000..224052b Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/lock.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/megaphone.png b/backend/bot/app/web/themes/windows95/icons/megaphone.png new file mode 100644 index 0000000..a697102 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/megaphone.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/paintbrush.png b/backend/bot/app/web/themes/windows95/icons/paintbrush.png new file mode 100644 index 0000000..b895700 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/paintbrush.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/payment-card.png b/backend/bot/app/web/themes/windows95/icons/payment-card.png new file mode 100644 index 0000000..a9b7212 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/payment-card.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/print.png b/backend/bot/app/web/themes/windows95/icons/print.png new file mode 100644 index 0000000..04e2839 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/print.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/refresh.png b/backend/bot/app/web/themes/windows95/icons/refresh.png new file mode 100644 index 0000000..be08925 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/refresh.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/save.png b/backend/bot/app/web/themes/windows95/icons/save.png new file mode 100644 index 0000000..2e70ec1 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/save.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/search.png b/backend/bot/app/web/themes/windows95/icons/search.png new file mode 100644 index 0000000..0102a91 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/search.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/send.png b/backend/bot/app/web/themes/windows95/icons/send.png new file mode 100644 index 0000000..67b9ae1 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/send.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/settings.png b/backend/bot/app/web/themes/windows95/icons/settings.png new file mode 100644 index 0000000..0d56f79 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/settings.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/shield.png b/backend/bot/app/web/themes/windows95/icons/shield.png new file mode 100644 index 0000000..8ce794a Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/shield.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/sliders.png b/backend/bot/app/web/themes/windows95/icons/sliders.png new file mode 100644 index 0000000..d3badce Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/sliders.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/smartphone.png b/backend/bot/app/web/themes/windows95/icons/smartphone.png new file mode 100644 index 0000000..e3b9b22 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/smartphone.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/sparkles.png b/backend/bot/app/web/themes/windows95/icons/sparkles.png new file mode 100644 index 0000000..5f99ecb Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/sparkles.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/tag.png b/backend/bot/app/web/themes/windows95/icons/tag.png new file mode 100644 index 0000000..13364e3 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/tag.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/ticket.png b/backend/bot/app/web/themes/windows95/icons/ticket.png new file mode 100644 index 0000000..13364e3 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/ticket.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/trash.png b/backend/bot/app/web/themes/windows95/icons/trash.png new file mode 100644 index 0000000..c4ca801 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/trash.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/user.png b/backend/bot/app/web/themes/windows95/icons/user.png new file mode 100644 index 0000000..fa18aaf Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/user.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/users.png b/backend/bot/app/web/themes/windows95/icons/users.png new file mode 100644 index 0000000..6d980f6 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/users.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/warning.png b/backend/bot/app/web/themes/windows95/icons/warning.png new file mode 100644 index 0000000..ca701de Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/warning.png differ diff --git a/backend/bot/app/web/themes/windows95/icons/x.png b/backend/bot/app/web/themes/windows95/icons/x.png new file mode 100644 index 0000000..1d69477 Binary files /dev/null and b/backend/bot/app/web/themes/windows95/icons/x.png differ diff --git a/backend/bot/app/web/themes/windows95/style.css b/backend/bot/app/web/themes/windows95/style.css new file mode 100644 index 0000000..d5aaede --- /dev/null +++ b/backend/bot/app/web/themes/windows95/style.css @@ -0,0 +1,1043 @@ +@import url("https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap&subset=cyrillic"); + +.theme-key-windows95 { + color-scheme: light; + image-rendering: pixelated; + --accent: #000080; + --bg: #008080; + --panel: #c0c0c0; + --panel-2: #dfdfdf; + --panel-3: #808080; + --border: #808080; + --border-strong: #000000; + --text: #000000; + --muted: #202020; + --dim: #404040; + --danger: #800000; + --blue: #000080; + --radius: 0px; + --font-sans: "MS Sans Serif", Tahoma, "Segoe UI", sans-serif; + --font-logo: "Press Start 2P", "Pixel Cyr", "MS Sans Serif", Tahoma, "Segoe UI", sans-serif; + --font-mono: Consolas, "Courier New", monospace; + --accent-contrast: #ffffff; + --surface-sheen: transparent; + --surface-sheen-soft: transparent; + --surface-hover: rgba(0, 0, 128, 0.14); + --surface-muted: #c0c0c0; + --surface-subtle-border: #808080; + --overlay-scrim: rgba(0, 0, 0, 0.35); + --nav-bg: #c0c0c0; + --rail-bg: #c0c0c0; + --shadow-soft: 2px 2px 0 #000000; + --shadow-strong: 3px 3px 0 #000000; + --shadow-popover: 2px 2px 0 #000000; + --inset-highlight: #ffffff; + --admin-bg: #008080; + --admin-surface: #c0c0c0; + --admin-surface-2: #dfdfdf; + --admin-elev: #808080; + --admin-border: #808080; + --admin-border-strong: #000000; + --admin-text: #000000; + --admin-muted: #202020; + --admin-dim: #404040; + --win95-icon-arrow-left: url("/webapp-theme-assets/windows95/icons/arrow-left.png?v=6"); + --win95-icon-arrow-right: url("/webapp-theme-assets/windows95/icons/arrow-right.png?v=6"); + --win95-icon-bitcoin: url("/webapp-theme-assets/windows95/icons/bitcoin.png?v=6"); + --win95-icon-check: url("/webapp-theme-assets/windows95/icons/check.png?v=6"); + --win95-icon-chevrons: url("/webapp-theme-assets/windows95/icons/chevrons.png?v=6"); + --win95-icon-coins: url("/webapp-theme-assets/windows95/icons/coins.png?v=6"); + --win95-icon-dashboard: url("/webapp-theme-assets/windows95/icons/dashboard.png?v=6"); + --win95-icon-database: url("/webapp-theme-assets/windows95/icons/database.png?v=6"); + --win95-icon-download: url("/webapp-theme-assets/windows95/icons/download.png?v=6"); + --win95-icon-error: url("/webapp-theme-assets/windows95/icons/error.png?v=6"); + --win95-icon-file-text: url("/webapp-theme-assets/windows95/icons/file-text.png?v=6"); + --win95-icon-folder: url("/webapp-theme-assets/windows95/icons/folder.png?v=6"); + --win95-icon-gift: url("/webapp-theme-assets/windows95/icons/gift.png?v=6"); + --win95-icon-globe: url("/webapp-theme-assets/windows95/icons/globe.png?v=6"); + --win95-icon-help: url("/webapp-theme-assets/windows95/icons/help.png?v=6"); + --win95-icon-home: url("/webapp-theme-assets/windows95/icons/home.png?v=6"); + --win95-icon-info: url("/webapp-theme-assets/windows95/icons/info.png?v=6"); + --win95-icon-key: url("/webapp-theme-assets/windows95/icons/key.png?v=6"); + --win95-icon-lock: url("/webapp-theme-assets/windows95/icons/lock.png?v=6"); + --win95-icon-megaphone: url("/webapp-theme-assets/windows95/icons/megaphone.png?v=6"); + --win95-icon-paintbrush: url("/webapp-theme-assets/windows95/icons/paintbrush.png?v=6"); + --win95-icon-payment-card: url("/webapp-theme-assets/windows95/icons/payment-card.png?v=6"); + --win95-icon-print: url("/webapp-theme-assets/windows95/icons/print.png?v=6"); + --win95-icon-refresh: url("/webapp-theme-assets/windows95/icons/refresh.png?v=6"); + --win95-icon-save: url("/webapp-theme-assets/windows95/icons/save.png?v=6"); + --win95-icon-search: url("/webapp-theme-assets/windows95/icons/search.png?v=6"); + --win95-icon-send: url("/webapp-theme-assets/windows95/icons/send.png?v=6"); + --win95-icon-settings: url("/webapp-theme-assets/windows95/icons/settings.png?v=6"); + --win95-icon-shield: url("/webapp-theme-assets/windows95/icons/shield.png?v=6"); + --win95-icon-smartphone: url("/webapp-theme-assets/windows95/icons/smartphone.png?v=6"); + --win95-icon-sliders: url("/webapp-theme-assets/windows95/icons/sliders.png?v=6"); + --win95-icon-sparkles: url("/webapp-theme-assets/windows95/icons/sparkles.png?v=6"); + --win95-icon-tag: url("/webapp-theme-assets/windows95/icons/tag.png?v=6"); + --win95-icon-ticket: url("/webapp-theme-assets/windows95/icons/ticket.png?v=6"); + --win95-icon-trash: url("/webapp-theme-assets/windows95/icons/trash.png?v=6"); + --win95-icon-user: url("/webapp-theme-assets/windows95/icons/user.png?v=6"); + --win95-icon-users: url("/webapp-theme-assets/windows95/icons/users.png?v=6"); + --win95-icon-warning: url("/webapp-theme-assets/windows95/icons/warning.png?v=6"); + --win95-icon-x: url("/webapp-theme-assets/windows95/icons/x.png?v=6"); +} + +.theme-key-windows95.app-shell { + background: var(--bg) !important; +} + +.theme-key-windows95 *, +.theme-key-windows95 *::before, +.theme-key-windows95 *::after { + border-radius: 0 !important; +} + +.theme-key-windows95 svg.lucide-arrow-left, +.theme-key-windows95 svg.lucide-arrow-right, +.theme-key-windows95 svg.lucide-bitcoin, +.theme-key-windows95 svg.lucide-check, +.theme-key-windows95 svg.lucide-check-circle-2, +.theme-key-windows95 svg.lucide-circle-check, +.theme-key-windows95 svg.lucide-circle-check-big, +.theme-key-windows95 svg.lucide-chevrons-up-down, +.theme-key-windows95 svg.lucide-circle-x, +.theme-key-windows95 svg.lucide-coins, +.theme-key-windows95 svg.lucide-copy, +.theme-key-windows95 svg.lucide-credit-card, +.theme-key-windows95 svg.lucide-database, +.theme-key-windows95 svg.lucide-download, +.theme-key-windows95 svg.lucide-earth, +.theme-key-windows95 svg.lucide-external-link, +.theme-key-windows95 svg.lucide-file-text, +.theme-key-windows95 svg.lucide-gift, +.theme-key-windows95 svg.lucide-globe-2, +.theme-key-windows95 svg.lucide-home, +.theme-key-windows95 svg.lucide-house, +.theme-key-windows95 svg.lucide-info, +.theme-key-windows95 svg.lucide-key, +.theme-key-windows95 svg.lucide-layout-dashboard, +.theme-key-windows95 svg.lucide-lock-keyhole, +.theme-key-windows95 svg.lucide-mail, +.theme-key-windows95 svg.lucide-megaphone, +.theme-key-windows95 svg.lucide-message-square, +.theme-key-windows95 svg.lucide-paintbrush, +.theme-key-windows95 svg.lucide-plus, +.theme-key-windows95 svg.lucide-refresh-cw, +.theme-key-windows95 svg.lucide-save, +.theme-key-windows95 svg.lucide-search, +.theme-key-windows95 svg.lucide-send, +.theme-key-windows95 svg.lucide-settings, +.theme-key-windows95 svg.lucide-shield, +.theme-key-windows95 svg.lucide-sliders, +.theme-key-windows95 svg.lucide-smartphone, +.theme-key-windows95 svg.lucide-sparkles, +.theme-key-windows95 svg.lucide-tag, +.theme-key-windows95 svg.lucide-ticket, +.theme-key-windows95 svg.lucide-trash-2, +.theme-key-windows95 svg.lucide-triangle-alert, +.theme-key-windows95 svg.lucide-user, +.theme-key-windows95 svg.lucide-user-round, +.theme-key-windows95 svg.lucide-users, +.theme-key-windows95 svg.lucide-users-round, +.theme-key-windows95 svg.lucide-wallet-cards, +.theme-key-windows95 svg.lucide-x { + flex: 0 0 auto; + width: 22px !important; + height: 22px !important; + color: transparent !important; + stroke: transparent !important; + fill: transparent !important; + background-image: var(--win95-button-icon); + background-position: center; + background-repeat: no-repeat; + background-size: contain; + image-rendering: pixelated; + filter: none !important; +} + +.theme-key-windows95 svg.lucide-arrow-left { + --win95-button-icon: var(--win95-icon-arrow-left); +} + +.theme-key-windows95 svg.lucide-arrow-right { + --win95-button-icon: var(--win95-icon-arrow-right); +} + +.theme-key-windows95 svg.lucide-bitcoin { + --win95-button-icon: var(--win95-icon-bitcoin); +} + +.theme-key-windows95 svg.lucide-check, +.theme-key-windows95 svg.lucide-check-circle-2, +.theme-key-windows95 svg.lucide-circle-check, +.theme-key-windows95 svg.lucide-circle-check-big { + --win95-button-icon: var(--win95-icon-check); +} + +.theme-key-windows95 svg.lucide-chevrons-up-down { + --win95-button-icon: var(--win95-icon-chevrons); +} + +.theme-key-windows95 svg.lucide-coins { + --win95-button-icon: var(--win95-icon-coins); +} + +.theme-key-windows95 svg.lucide-circle-x, +.theme-key-windows95 svg.lucide-x { + --win95-button-icon: var(--win95-icon-x); +} + +.theme-key-windows95 svg.lucide-copy { + --win95-button-icon: var(--win95-icon-file-text); +} + +.theme-key-windows95 svg.lucide-credit-card, +.theme-key-windows95 svg.lucide-wallet-cards { + --win95-button-icon: var(--win95-icon-payment-card); +} + +.theme-key-windows95 svg.lucide-database { + --win95-button-icon: var(--win95-icon-database); +} + +.theme-key-windows95 svg.lucide-download { + --win95-button-icon: var(--win95-icon-download); +} + +.theme-key-windows95 svg.lucide-earth, +.theme-key-windows95 svg.lucide-external-link, +.theme-key-windows95 svg.lucide-globe-2 { + --win95-button-icon: var(--win95-icon-globe); +} + +.theme-key-windows95 svg.lucide-file-text { + --win95-button-icon: var(--win95-icon-file-text); +} + +.theme-key-windows95 svg.lucide-gift { + --win95-button-icon: var(--win95-icon-gift); +} + +.theme-key-windows95 svg.lucide-home, +.theme-key-windows95 svg.lucide-house { + --win95-button-icon: var(--win95-icon-home); +} + +.theme-key-windows95 svg.lucide-info { + --win95-button-icon: var(--win95-icon-info); +} + +.theme-key-windows95 svg.lucide-key { + --win95-button-icon: var(--win95-icon-key); +} + +.theme-key-windows95 svg.lucide-layout-dashboard { + --win95-button-icon: var(--win95-icon-dashboard); +} + +.theme-key-windows95 svg.lucide-lock-keyhole { + --win95-button-icon: var(--win95-icon-lock); +} + +.theme-key-windows95 svg.lucide-megaphone { + --win95-button-icon: var(--win95-icon-megaphone); +} + +.theme-key-windows95 svg.lucide-mail, +.theme-key-windows95 svg.lucide-message-square, +.theme-key-windows95 svg.lucide-send { + --win95-button-icon: var(--win95-icon-send); +} + +.theme-key-windows95 svg.lucide-paintbrush { + --win95-button-icon: var(--win95-icon-paintbrush); +} + +.theme-key-windows95 svg.lucide-plus { + --win95-button-icon: var(--win95-icon-folder); +} + +.theme-key-windows95 svg.lucide-refresh-cw { + --win95-button-icon: var(--win95-icon-refresh); +} + +.theme-key-windows95 svg.lucide-save { + --win95-button-icon: var(--win95-icon-save); +} + +.theme-key-windows95 svg.lucide-search { + --win95-button-icon: var(--win95-icon-search); +} + +.theme-key-windows95 svg.lucide-settings { + --win95-button-icon: var(--win95-icon-settings); +} + +.theme-key-windows95 svg.lucide-shield { + --win95-button-icon: var(--win95-icon-shield); +} + +.theme-key-windows95 svg.lucide-sliders { + --win95-button-icon: var(--win95-icon-sliders); +} + +.theme-key-windows95 svg.lucide-smartphone { + --win95-button-icon: var(--win95-icon-smartphone); +} + +.theme-key-windows95 svg.lucide-sparkles { + --win95-button-icon: var(--win95-icon-sparkles); +} + +.theme-key-windows95 svg.lucide-tag { + --win95-button-icon: var(--win95-icon-tag); +} + +.theme-key-windows95 svg.lucide-ticket { + --win95-button-icon: var(--win95-icon-ticket); +} + +.theme-key-windows95 svg.lucide-trash-2 { + --win95-button-icon: var(--win95-icon-trash); +} + +.theme-key-windows95 svg.lucide-triangle-alert { + --win95-button-icon: var(--win95-icon-warning); +} + +.theme-key-windows95 svg.lucide-user, +.theme-key-windows95 svg.lucide-user-round { + --win95-button-icon: var(--win95-icon-user); +} + +.theme-key-windows95 svg.lucide-users, +.theme-key-windows95 svg.lucide-users-round { + --win95-button-icon: var(--win95-icon-users); +} + +.theme-key-windows95 :is( + svg.lucide-arrow-left, + svg.lucide-arrow-right, + svg.lucide-bitcoin, + svg.lucide-check, + svg.lucide-check-circle-2, + svg.lucide-chevrons-up-down, + svg.lucide-circle-check, + svg.lucide-circle-check-big, + svg.lucide-circle-x, + svg.lucide-coins, + svg.lucide-copy, + svg.lucide-credit-card, + svg.lucide-database, + svg.lucide-download, + svg.lucide-earth, + svg.lucide-external-link, + svg.lucide-file-text, + svg.lucide-gift, + svg.lucide-globe-2, + svg.lucide-home, + svg.lucide-house, + svg.lucide-info, + svg.lucide-key, + svg.lucide-layout-dashboard, + svg.lucide-lock-keyhole, + svg.lucide-mail, + svg.lucide-megaphone, + svg.lucide-message-square, + svg.lucide-paintbrush, + svg.lucide-plus, + svg.lucide-refresh-cw, + svg.lucide-save, + svg.lucide-search, + svg.lucide-send, + svg.lucide-settings, + svg.lucide-shield, + svg.lucide-sliders, + svg.lucide-smartphone, + svg.lucide-sparkles, + svg.lucide-tag, + svg.lucide-ticket, + svg.lucide-trash-2, + svg.lucide-triangle-alert, + svg.lucide-user, + svg.lucide-user-round, + svg.lucide-users, + svg.lucide-users-round, + svg.lucide-wallet-cards, + svg.lucide-x +) *, +.theme-key-windows95 :is( + svg.lucide-arrow-left, + svg.lucide-arrow-right, + svg.lucide-bitcoin, + svg.lucide-check, + svg.lucide-check-circle-2, + svg.lucide-chevrons-up-down, + svg.lucide-circle-check, + svg.lucide-circle-check-big, + svg.lucide-circle-x, + svg.lucide-coins, + svg.lucide-copy, + svg.lucide-credit-card, + svg.lucide-database, + svg.lucide-download, + svg.lucide-earth, + svg.lucide-external-link, + svg.lucide-file-text, + svg.lucide-gift, + svg.lucide-globe-2, + svg.lucide-home, + svg.lucide-house, + svg.lucide-info, + svg.lucide-key, + svg.lucide-layout-dashboard, + svg.lucide-lock-keyhole, + svg.lucide-mail, + svg.lucide-megaphone, + svg.lucide-message-square, + svg.lucide-paintbrush, + svg.lucide-plus, + svg.lucide-refresh-cw, + svg.lucide-save, + svg.lucide-search, + svg.lucide-send, + svg.lucide-settings, + svg.lucide-shield, + svg.lucide-sliders, + svg.lucide-smartphone, + svg.lucide-sparkles, + svg.lucide-tag, + svg.lucide-ticket, + svg.lucide-trash-2, + svg.lucide-triangle-alert, + svg.lucide-user, + svg.lucide-user-round, + svg.lucide-users, + svg.lucide-users-round, + svg.lucide-wallet-cards, + svg.lucide-x +) { + color: transparent !important; + stroke: transparent !important; + fill: transparent !important; +} + +.theme-key-windows95, +.theme-key-windows95 button, +.theme-key-windows95 input, +.theme-key-windows95 textarea { + font-synthesis: none; +} + +.theme-key-windows95 .card, +.theme-key-windows95 .period-card, +.theme-key-windows95 .method-card, +.theme-key-windows95 .settings-row, +.theme-key-windows95 .option-row, +.theme-key-windows95 .tariff-selected-card, +.theme-key-windows95 .tariff-action-card, +.theme-key-windows95 .tariff-warning-card, +.theme-key-windows95 .topup-carryover-note, +.theme-key-windows95 .input, +.theme-key-windows95 .dialog-card, +.theme-key-windows95 .language-select-content, +.theme-key-windows95 .bottom-nav, +.theme-key-windows95 .toast { + border-width: 2px; + border-style: solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0; + background: var(--panel); + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; +} + +.theme-key-windows95 .dialog-card, +.theme-key-windows95 .language-select-content, +.theme-key-windows95 .toast { + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080, + 2px 2px 0 #000000; +} + +body:has(.theme-key-windows95) .language-select-content, +body:has(.theme-key-windows95) .admin-select-content { + border-width: 2px; + border-style: solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0 !important; + background: #c0c0c0; + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080, + 2px 2px 0 #000000; +} + +body:has(.theme-key-windows95) .language-select-item, +body:has(.theme-key-windows95) .admin-select-item { + border-radius: 0 !important; +} + +.theme-key-windows95 .card::before, +.theme-key-windows95 .dialog-card::before { + content: ""; + display: block; + height: 18px; + margin: -14px -14px 12px; + background: linear-gradient(90deg, var(--accent), #1084d0); + border-bottom: 2px solid #000000; +} + +.theme-key-windows95 .dialog-card::before { + margin: -18px -18px 14px; +} + +.theme-key-windows95 .btn, +.theme-key-windows95 .language-select-trigger, +.theme-key-windows95 .bottom-nav button { + min-height: 34px; + border: 2px solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0; + background: var(--panel); + color: var(--text); + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; + transform: none; +} + +@media (min-width: 1024px) { + .theme-key-windows95 .bottom-nav { + border-right: 2px solid #404040 !important; + background: var(--rail-bg) !important; + backdrop-filter: none !important; + box-shadow: + inset 1px 1px 0 #ffffff, + inset -1px -1px 0 #808080 !important; + } + + .theme-key-windows95 .bottom-nav button { + min-height: 34px !important; + border: 2px solid !important; + border-color: #ffffff #404040 #404040 #ffffff !important; + border-radius: 0 !important; + background: var(--panel) !important; + color: var(--text) !important; + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080 !important; + } + + .theme-key-windows95 .bottom-nav button.active { + background: var(--accent) !important; + color: #ffffff !important; + border-color: #ffffff #404040 #404040 #ffffff !important; + } + + .theme-key-windows95 .bottom-nav button:active { + border-color: #404040 #ffffff #ffffff #404040 !important; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf !important; + } +} + +.theme-key-windows95 .btn:active:not(:disabled), +.theme-key-windows95 .bottom-nav button:active { + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 .btn-primary, +.theme-key-windows95 .bottom-nav button.active, +.theme-key-windows95 .period-card.active, +.theme-key-windows95 .method-card.active, +.theme-key-windows95 .option-row.active { + background: var(--accent); + color: #ffffff; +} + +.theme-key-windows95 .card-heading-accent, +.theme-key-windows95 .brand-row strong, +.theme-key-windows95 .login-brand h1, +.theme-key-windows95 .bottom-nav button.active { + color: var(--accent); +} + +.theme-key-windows95 .traffic-top strong { + color: var(--muted); +} + +.theme-key-windows95 .btn-primary, +.theme-key-windows95 .bottom-nav button.active { + color: #ffffff; +} + +.theme-key-windows95 .progress { + height: 8px; + margin-top: 12px; + overflow: hidden; + border: 0; + border-radius: 999px !important; + background: color-mix(in srgb, var(--muted) 18%, transparent); +} + +.theme-key-windows95 .progress span { + border-radius: inherit !important; + background: linear-gradient(90deg, var(--accent), #1084d0); + box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 30%, transparent); +} + +.theme-key-windows95 .language-select-item { + border-radius: 0; +} + +.theme-key-windows95 .settings-profile { + padding-left: 14px; +} + +.theme-key-windows95 .settings-profile-meta strong { + color: var(--accent); +} + +.theme-key-windows95 .language-select-copy { + grid-template-columns: minmax(0, auto) minmax(0, 1fr); + align-items: center; + gap: 8px; +} + +.theme-key-windows95 .language-select-copy strong, +.theme-key-windows95 .language-select-current { + min-width: 0; + white-space: nowrap; +} + +.theme-key-windows95 .language-select-current { + overflow: hidden; + text-overflow: ellipsis; +} + +.theme-key-windows95 .language-select-item[data-highlighted], +.theme-key-windows95 .language-select-item[data-selected] { + background: var(--accent); + color: #ffffff !important; +} + +.theme-key-windows95 .field-error-tooltip { + border-radius: 0; +} + +.theme-key-windows95 .admin-screen-wrap { + background: var(--admin-bg); +} + +.theme-key-windows95 .admin-sidebar, +.theme-key-windows95 .admin-header, +.theme-key-windows95 .admin-card, +.theme-key-windows95 .admin-stat-card, +.theme-key-windows95 .admin-revenue-panel, +.theme-key-windows95 .admin-empty, +.theme-key-windows95 .admin-tariff-card, +.theme-key-windows95 .admin-toolbar-card, +.theme-key-windows95 .admin-table-card, +.theme-key-windows95 .admin-panel-dash-card, +.theme-key-windows95 .admin-select-trigger, +.theme-key-windows95 .admin-select-content, +.theme-key-windows95 .admin-cn-card[data-slot="card"], +.theme-key-windows95 .admin-dialog .dialog-card, +.theme-key-windows95 .admin-theme-editor-section { + border-width: 2px; + border-style: solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0; + background: var(--admin-surface); + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; +} + +.theme-key-windows95 .admin-header, +.theme-key-windows95 .admin-sidebar-brand, +.theme-key-windows95 .admin-card-head { + border-bottom: 2px solid #000000; +} + +.theme-key-windows95 .admin-header { + background: linear-gradient(90deg, var(--accent), #1084d0); + color: #ffffff; +} + +.theme-key-windows95 .admin-header-title h2, +.theme-key-windows95 .admin-header-title small { + color: #ffffff; +} + +.theme-key-windows95 .admin-nav-item, +.theme-key-windows95 .admin-btn, +.theme-key-windows95 .admin-chip, +.theme-key-windows95 .admin-tabs-trigger, +.theme-key-windows95 .admin-revenue-period-btn, +.theme-key-windows95 .admin-mobile-toggle, +.theme-key-windows95 .admin-language-trigger { + border: 2px solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0; + background: var(--admin-surface); + color: var(--admin-text); + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; +} + +.theme-key-windows95 .admin-language-switch { + grid-template-columns: 18px minmax(0, 1fr); +} + +.theme-key-windows95 .admin-language-trigger { + min-height: 34px; + height: auto; +} + +.theme-key-windows95 .admin-language-trigger > span { + display: grid; + grid-template-columns: minmax(0, auto) minmax(0, 1fr); + align-items: center; + gap: 8px; +} + +.theme-key-windows95 .admin-language-trigger strong, +.theme-key-windows95 .admin-language-trigger small { + min-width: 0; + margin: 0; + white-space: nowrap; +} + +.theme-key-windows95 .admin-language-trigger small { + display: inline-flex; + align-items: center; + gap: 5px; + overflow: hidden; + text-overflow: ellipsis; +} + +.theme-key-windows95 .admin-nav-item:hover, +.theme-key-windows95 .admin-select-item[data-highlighted], +.theme-key-windows95 .admin-select-item:hover, +.theme-key-windows95 .admin-table tbody tr:hover { + background: color-mix(in srgb, var(--accent) 20%, var(--admin-surface)); +} + +.theme-key-windows95 .admin-nav-item.active, +.theme-key-windows95 .admin-btn-primary, +.theme-key-windows95 .admin-tabs-trigger[data-state="active"], +.theme-key-windows95 .admin-revenue-period-btn.is-active { + background: var(--accent); + color: #ffffff; + border-color: #ffffff #404040 #404040 #ffffff; +} + +.theme-key-windows95 .admin-input, +.theme-key-windows95 .admin-textarea, +.theme-key-windows95 .admin-screen-wrap textarea, +.theme-key-windows95 .admin-dialog textarea, +.theme-key-windows95 .input, +.theme-key-windows95 input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), +.theme-key-windows95 textarea, +.theme-key-windows95 select { + min-height: 28px; + padding: 3px 6px; + border: 2px solid; + border-color: #404040 #ffffff #ffffff #404040; + border-radius: 0; + background: #ffffff; + color: #000000; + font: 13px "MS Sans Serif", Tahoma, "Segoe UI", sans-serif; + line-height: 1.35; + box-shadow: + inset 1px 1px 0 #000000, + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; + transition: none; +} + +.theme-key-windows95 input[type="color"] { + min-height: 28px; + padding: 2px; +} + +.theme-key-windows95 input[type="file"] { + background: var(--panel); +} + +.theme-key-windows95 input[type="range"] { + width: 100%; + height: 22px; + padding: 0; + border: 0; + background: transparent; + accent-color: var(--accent); + cursor: pointer; + -webkit-appearance: none; + appearance: none; +} + +.theme-key-windows95 input[type="range"]::-webkit-slider-runnable-track { + height: 6px; + border: 2px solid; + border-color: #404040 #ffffff #ffffff #404040; + border-radius: 0 !important; + background: #ffffff; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 input[type="range"]::-webkit-slider-thumb { + width: 14px; + height: 20px; + margin-top: -9px; + border: 2px solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0 !important; + background: #c0c0c0; + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; + -webkit-appearance: none; + appearance: none; +} + +.theme-key-windows95 input[type="range"]:active::-webkit-slider-thumb { + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 input[type="range"]::-moz-range-track { + height: 6px; + border: 2px solid; + border-color: #404040 #ffffff #ffffff #404040; + border-radius: 0 !important; + background: #ffffff; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 input[type="range"]::-moz-range-progress { + height: 6px; + border-radius: 0 !important; + background: var(--accent); +} + +.theme-key-windows95 input[type="range"]::-moz-range-thumb { + width: 14px; + height: 20px; + border: 2px solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0 !important; + background: #c0c0c0; + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; +} + +.theme-key-windows95 input[type="range"]:active::-moz-range-thumb { + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 input[type="range"]:focus-visible { + outline: 1px dotted #000000; + outline-offset: 2px; +} + +.theme-key-windows95 input[type="range"]:disabled { + cursor: not-allowed; + opacity: 0.52; +} + +.theme-key-windows95 input::placeholder, +.theme-key-windows95 textarea::placeholder, +.theme-key-windows95 .input::placeholder { + color: #808080; +} + +.theme-key-windows95 .admin-cn-badge, +.theme-key-windows95 .admin-badge { + border-radius: 0; +} + +.theme-key-windows95 ::selection { + background: var(--accent); + color: #ffffff; +} + +.theme-key-windows95 .btn:focus-visible, +.theme-key-windows95 .admin-btn:focus-visible, +.theme-key-windows95 .admin-nav-item:focus-visible, +.theme-key-windows95 .admin-tabs-trigger:focus-visible, +.theme-key-windows95 .admin-revenue-period-btn:focus-visible, +.theme-key-windows95 .admin-mobile-toggle:focus-visible, +.theme-key-windows95 .language-select-trigger:focus-visible, +.theme-key-windows95 .bottom-nav button:focus-visible { + outline: 1px dotted #000000; + outline-offset: -4px; +} + +.theme-key-windows95 .admin-input:focus, +.theme-key-windows95 .admin-textarea:focus, +.theme-key-windows95 .admin-screen-wrap textarea:focus, +.theme-key-windows95 .admin-dialog textarea:focus, +.theme-key-windows95 .input:focus, +.theme-key-windows95 input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):focus, +.theme-key-windows95 textarea:focus, +.theme-key-windows95 select:focus { + outline: none; + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #000000, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 .admin-header-title h2, +.theme-key-windows95 .admin-sidebar-brand strong, +.theme-key-windows95 .admin-card-head h3, +.theme-key-windows95 .admin-card-head h2, +.theme-key-windows95 .login-brand h1, +.theme-key-windows95 .brand-row strong { + font-weight: 700; + letter-spacing: 0; +} + +.theme-key-windows95 .admin-nav-item:hover, +.theme-key-windows95 .admin-select-item[data-highlighted], +.theme-key-windows95 .admin-select-item:hover { + background: color-mix(in srgb, var(--accent) 30%, var(--admin-surface)); +} + +.theme-key-windows95 .admin-nav-item.active, +.theme-key-windows95 .admin-tabs-trigger[data-state="active"], +.theme-key-windows95 .admin-revenue-period-btn.is-active, +.theme-key-windows95 .admin-btn:active:not(:disabled), +.theme-key-windows95 .admin-btn[aria-pressed="true"] { + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #000000, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 .btn-primary svg.lucide, +.theme-key-windows95 .bottom-nav button.active svg.lucide, +.theme-key-windows95 .period-card.active svg.lucide, +.theme-key-windows95 .method-card.active svg.lucide, +.theme-key-windows95 .option-row.active svg.lucide, +.theme-key-windows95 .admin-btn-primary svg.lucide, +.theme-key-windows95 .admin-nav-item.active svg.lucide, +.theme-key-windows95 .admin-tabs-trigger[data-state="active"] svg.lucide, +.theme-key-windows95 .admin-revenue-period-btn.is-active svg.lucide, +.theme-key-windows95 .admin-header svg.lucide { + filter: brightness(0) invert(1); +} + +.theme-key-windows95 svg.lucide-activity, +.theme-key-windows95 svg.lucide-calendar-days, +.theme-key-windows95 svg.lucide-chevron-down, +.theme-key-windows95 svg.lucide-chevron-left, +.theme-key-windows95 svg.lucide-chevron-right, +.theme-key-windows95 svg.lucide-chevron-up, +.theme-key-windows95 svg.lucide-circle, +.theme-key-windows95 svg.lucide-crown, +.theme-key-windows95 svg.lucide-eye, +.theme-key-windows95 svg.lucide-eye-off, +.theme-key-windows95 svg.lucide-map, +.theme-key-windows95 svg.lucide-menu, +.theme-key-windows95 svg.lucide-mouse-pointer-click, +.theme-key-windows95 svg.lucide-qr-code, +.theme-key-windows95 svg.lucide-radio, +.theme-key-windows95 svg.lucide-repeat-2, +.theme-key-windows95 svg.lucide-server, +.theme-key-windows95 svg.lucide-trending-down, +.theme-key-windows95 svg.lucide-trending-up, +.theme-key-windows95 svg.lucide-user-minus, +.theme-key-windows95 svg.lucide-user-plus, +.theme-key-windows95 svg.lucide-zap { + color: var(--text); + stroke-width: 2.5; +} + +.theme-key-windows95 ::-webkit-scrollbar { + width: 16px; + height: 16px; +} + +.theme-key-windows95 ::-webkit-scrollbar-track { + background-color: #c0c0c0; + background-image: + linear-gradient(45deg, #ffffff 25%, transparent 25%, transparent 75%, #ffffff 75%), + linear-gradient(45deg, #ffffff 25%, transparent 25%, transparent 75%, #ffffff 75%); + background-size: 4px 4px; + background-position: 0 0, 2px 2px; +} + +.theme-key-windows95 ::-webkit-scrollbar-thumb { + background: var(--panel); + border: 2px solid; + border-color: #ffffff #404040 #404040 #ffffff; + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; +} + +.theme-key-windows95 ::-webkit-scrollbar-thumb:active { + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 ::-webkit-scrollbar-corner { + background: var(--panel); +} + +.theme-key-windows95 .ui-spinner, +.theme-key-windows95 .telegram-button-spinner { + border-radius: 999px !important; + color: var(--accent); +} + +.theme-key-windows95 .card::before, +.theme-key-windows95 .dialog-card::before { + height: 20px; +} + +.theme-key-windows95 .admin-table tbody tr { + border-bottom: 1px solid #808080; +} + +.theme-key-windows95 .admin-badge, +.theme-key-windows95 .admin-cn-badge { + border: 1px solid #000000; + background: var(--panel); + color: var(--text); + font-weight: 700; +} + +.theme-key-windows95 .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item), +.theme-key-windows95 a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]) { + color: var(--accent); + text-decoration: underline; +} + +.theme-key-windows95 .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item):visited, +.theme-key-windows95 a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):visited { + color: #800080; +} diff --git a/backend/bot/app/web/themes/windows95/theme.json b/backend/bot/app/web/themes/windows95/theme.json new file mode 100644 index 0000000..73583f0 --- /dev/null +++ b/backend/bot/app/web/themes/windows95/theme.json @@ -0,0 +1,17 @@ +{ + "key": "windows95", + "names": { + "ru": "Windows 95", + "en": "Windows 95" + }, + "enabled": true, + "default": false, + "use_primary_accent": false, + "use_in_admin": true, + "css_file": "style.css", + "assets_version": 6, + "tokens": { + "color_scheme": "light", + "style_preset": "win95" + } +} 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 81% rename from bot/app/web/webapp/_runtime.py rename to backend/bot/app/web/webapp/_runtime.py index e9b4ee0..fddbe4e 100644 --- a/bot/app/web/webapp/_runtime.py +++ b/backend/bot/app/web/webapp/_runtime.py @@ -2,6 +2,7 @@ import asyncio import base64 import hashlib +import html import hmac import io import ipaddress @@ -17,7 +18,7 @@ from collections import deque from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit from aiogram import Bot, Dispatcher from aiogram.types import LabeledPrice @@ -41,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 @@ -63,9 +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_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-emoji" +WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo" +WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads" +WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo" +WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons" +WEBAPP_FAVICON_PATH = "/webapp-favicon" +WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji" WEBAPP_CONFIG_PLACEHOLDER = "" WEBAPP_I18N_PLACEHOLDER = "" WEBAPP_JS_PLACEHOLDER = "" @@ -76,6 +83,17 @@ WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60 WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30 WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024 +WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024 +WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024 +WEBAPP_THEME_ASSET_CONTENT_TYPES = { + ".gif": "image/gif", + ".ico": "image/x-icon", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +} WEBAPP_TELEGRAM_AVATAR_MAX_BYTES = 128 * 1024 WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60 WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4 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 97% rename from bot/app/web/webapp/application.py rename to backend/bot/app/web/webapp/application.py index 150f865..df988af 100644 --- a/bot/app/web/webapp/application.py +++ b/backend/bot/app/web/webapp/application.py @@ -52,8 +52,7 @@ def create_subscription_webapp_application( if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined] app[key] = dp.workflow_data[key] # type: ignore[index] - # type: ignore[attr-defined] - if hasattr(dp, "workflow_data") and "bot_username" in dp.workflow_data: + if hasattr(dp, "workflow_data") and "bot_username" in dp.workflow_data: # type: ignore[attr-defined] app["bot_username"] = dp.workflow_data["bot_username"] # type: ignore[index] setup_subscription_webapp_routes(app) diff --git a/bot/app/web/webapp/assets.py b/backend/bot/app/web/webapp/assets.py similarity index 57% rename from bot/app/web/webapp/assets.py rename to backend/bot/app/web/webapp/assets.py index f879a30..f23bc0e 100644 --- a/bot/app/web/webapp/assets.py +++ b/backend/bot/app/web/webapp/assets.py @@ -1,6 +1,14 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +from config.webapp_themes_config import ( + default_webapp_theme_asset_file, + default_webapp_theme_css_files, + ensure_default_webapp_theme_descriptor_files, + public_theme_payload, + public_themes_catalog_payload, +) + async def health_route(request: web.Request) -> web.Response: return web.json_response({"ok": True}) @@ -10,7 +18,120 @@ async def css_asset_route(request: web.Request) -> web.Response: return await _serve_template_asset(request, "subscription_webapp.css", "text/css") +def _safe_theme_css_relative_path(raw_path: str) -> Optional[Path]: + return _safe_theme_relative_path(raw_path, allowed_suffixes={".css"}, max_length=180) + + +def _safe_theme_asset_relative_path(raw_path: str) -> Optional[Path]: + return _safe_theme_relative_path( + raw_path, + allowed_suffixes=set(WEBAPP_THEME_ASSET_CONTENT_TYPES), + max_length=220, + ) + + +def _safe_theme_relative_path( + raw_path: str, + *, + allowed_suffixes: set[str], + max_length: int, +) -> Optional[Path]: + value = str(raw_path or "").replace("\\", "/").strip().lstrip("/") + if not value or len(value) > max_length or "\x00" in value: + return None + parts = [part for part in value.split("/") if part] + if len(parts) < 2 or any(part in {".", ".."} for part in parts): + return None + if any(not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", part) for part in parts): + return None + rel_path = Path(*parts) + if rel_path.suffix.lower() not in allowed_suffixes: + return None + return rel_path + + +async def theme_css_asset_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.WEBAPP_ENABLED: + raise web.HTTPNotFound(text="webapp_disabled") + + ensure_default_webapp_theme_descriptor_files(settings.WEBAPP_THEMES_DIR) + rel_path = _safe_theme_css_relative_path(request.match_info.get("path") or "") + if rel_path is None: + raise web.HTTPNotFound(text="theme_css_not_found") + + root = Path(settings.WEBAPP_THEMES_DIR).expanduser().resolve() + path = (root / rel_path).resolve() + try: + path.relative_to(root) + except ValueError: + raise web.HTTPNotFound(text="theme_css_not_found") from None + + try: + if path.stat().st_size > WEBAPP_THEME_CSS_MAX_BYTES: + raise web.HTTPNotFound(text="theme_css_too_large") + text = path.read_text(encoding="utf-8") + except OSError: + defaults = default_webapp_theme_css_files() + text = defaults.get(rel_path.as_posix()) + if text is None: + raise web.HTTPNotFound(text="theme_css_not_found") from None + + response = web.Response(text=text, content_type="text/css", charset="utf-8") + response.headers["Cache-Control"] = "no-cache" + return response + + +async def theme_asset_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.WEBAPP_ENABLED: + raise web.HTTPNotFound(text="webapp_disabled") + + ensure_default_webapp_theme_descriptor_files(settings.WEBAPP_THEMES_DIR) + rel_path = _safe_theme_asset_relative_path(request.match_info.get("path") or "") + if rel_path is None: + raise web.HTTPNotFound(text="theme_asset_not_found") + + root = Path(settings.WEBAPP_THEMES_DIR).expanduser().resolve() + path = (root / rel_path).resolve() + try: + path.relative_to(root) + except ValueError: + raise web.HTTPNotFound(text="theme_asset_not_found") from None + + suffix = rel_path.suffix.lower() + content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(suffix) + if not content_type: + raise web.HTTPNotFound(text="theme_asset_not_found") + + try: + if path.stat().st_size > WEBAPP_THEME_ASSET_MAX_BYTES: + raise web.HTTPNotFound(text="theme_asset_too_large") + body = path.read_bytes() + except OSError: + fallback = default_webapp_theme_asset_file(rel_path) + if fallback is None: + raise web.HTTPNotFound(text="theme_asset_not_found") from None + body, fallback_suffix = fallback + content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(fallback_suffix, content_type) + + if not body or len(body) > WEBAPP_THEME_ASSET_MAX_BYTES: + raise web.HTTPNotFound(text="theme_asset_not_found") + + query = getattr(request, "query", {}) + response = web.Response(body=body, content_type=content_type) + response.headers["Cache-Control"] = ( + "public, max-age=31536000, immutable" + if query.get("v") + else "public, max-age=3600" + ) + return response + + def _resolve_webapp_logo_url(settings: Settings) -> str: + if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + return "" + raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url: return "" @@ -26,6 +147,27 @@ def _resolve_webapp_logo_url(settings: Settings) -> str: return "" +def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str: + raw_custom_url = (getattr(settings, "WEBAPP_FAVICON_URL", None) or "").strip() + raw_logo_favicon_url = (getattr(settings, "WEBAPP_LOGO_FAVICON_URL", None) or "").strip() + if getattr(settings, "WEBAPP_FAVICON_USE_CUSTOM", False) and raw_custom_url: + return _resolve_webapp_asset_url(raw_custom_url) + if logo_url and raw_logo_favicon_url: + resolved = _resolve_webapp_asset_url(raw_logo_favicon_url) + if resolved: + return resolved + return logo_url or "" + + +def _resolve_webapp_asset_url(raw_url: str) -> str: + parsed_url = urlsplit(raw_url) + if parsed_url.scheme in {"https", "http", "data"}: + return raw_url + if raw_url.startswith("/"): + return raw_url + return "" + + def _webapp_logo_cache_key(logo_url: str) -> str: return hashlib.sha256(logo_url.encode("utf-8")).hexdigest() @@ -40,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()) @@ -61,10 +245,16 @@ def _webapp_animated_emoji_asset_path(emoji: str, ext: str = "gif") -> str: async def webapp_logo_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] + if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + raise web.HTTPNotFound(text="webapp_logo_disabled") raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url: raise web.HTTPNotFound(text="webapp_logo_not_configured") + uploaded_filename = _uploaded_webapp_logo_filename(raw_logo_url) + if uploaded_filename: + return _uploaded_webapp_logo_response(uploaded_filename) + if not _is_proxyable_webapp_logo_url(raw_logo_url): raise web.HTTPNotFound(text="webapp_logo_not_proxied") @@ -94,6 +284,56 @@ async def webapp_logo_route(request: web.Request) -> web.Response: return response +async def webapp_uploaded_logo_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.WEBAPP_ENABLED: + raise web.HTTPNotFound(text="webapp_disabled") + + filename = str(request.match_info.get("filename") or "").strip() + return _uploaded_webapp_logo_response(filename) + + +async def webapp_favicon_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.WEBAPP_ENABLED: + raise web.HTTPNotFound(text="webapp_disabled") + + digest = str(request.match_info.get("digest") or "").strip().lower() + filename = str(request.match_info.get("filename") or "").strip() + if not re.fullmatch(r"[0-9a-f]{16}", digest): + raise web.HTTPNotFound(text="webapp_favicon_not_found") + if not re.fullmatch( + r"(?:icon-(?:16|32|48|180|192|512)\.png|apple-touch-icon\.png|favicon\.(?:ico|svg))", + filename, + ): + raise web.HTTPNotFound(text="webapp_favicon_not_found") + + root = WEBAPP_FAVICON_DIR.expanduser().resolve() + path = (root / digest / filename).resolve() + try: + path.relative_to(root) + except ValueError: + raise web.HTTPNotFound(text="webapp_favicon_not_found") from None + + content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower()) + if not content_type: + raise web.HTTPNotFound(text="webapp_favicon_not_found") + + try: + if path.stat().st_size > WEBAPP_LOGO_MAX_BYTES: + raise web.HTTPNotFound(text="webapp_favicon_too_large") + body = path.read_bytes() + except OSError: + raise web.HTTPNotFound(text="webapp_favicon_not_found") from None + + if not body: + raise web.HTTPNotFound(text="webapp_favicon_not_found") + + response = web.Response(body=body, content_type=content_type) + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response + + async def webapp_animated_emoji_route(request: web.Request) -> web.Response: codepoints = str(request.match_info.get("codepoints") or "").strip().lower() ext = str(request.match_info.get("ext") or "").strip().lower() @@ -123,6 +363,8 @@ async def webapp_animated_emoji_route(request: web.Request) -> web.Response: async def _warm_webapp_logo_cache(app: web.Application) -> None: settings: Settings = app["settings"] + if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + return raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url): return @@ -146,6 +388,8 @@ async def _warm_webapp_logo_cache(app: web.Application) -> None: async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None: settings: Settings = app["settings"] + if not getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + return if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated": return @@ -435,7 +679,7 @@ async def _security_headers_middleware(request: web.Request, handler): "frame-ancestors https://web.telegram.org https://t.me; " "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " # noqa: E501 "font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; " - "img-src 'self' data: https:; " + "img-src 'self' data: blob: https:; " "connect-src 'self' https://oauth.telegram.org; " "object-src 'none'; " "base-uri 'self'; " @@ -482,8 +726,10 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]: cache = request.app["webapp_settings_cache"] now = time.monotonic() if now - float(cache.get("ts", 0.0)) >= 60 or not cache.get("data"): + logo_url = _resolve_webapp_logo_url(settings) cache["data"] = { - "logo_url": _resolve_webapp_logo_url(settings), + "logo_url": logo_url, + "favicon_url": _resolve_webapp_favicon_url(settings, logo_url), "subscription_options": settings.subscription_options, "stars_subscription_options": settings.stars_subscription_options, "traffic_packages": settings.traffic_packages, @@ -501,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], @@ -526,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: @@ -570,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() @@ -621,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: @@ -628,30 +944,16 @@ async def index_route(request: web.Request) -> web.Response: html = TEMPLATE_PATH.read_text(encoding="utf-8") cached = _get_cached_webapp_settings(request) - config = { - "title": settings.WEBAPP_TITLE, - "primaryColor": settings.WEBAPP_PRIMARY_COLOR, - "logoUrl": cached["logo_url"], - "logoEmoji": settings.WEBAPP_LOGO_EMOJI, - "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, - "apiBase": "/api", - "telegramLoginBotUsername": request.app.get("bot_username") or "", - "telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0, - "telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0, - "telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings), - "supportUrl": cached["support_url"], - "termsUrl": cached["terms_url"], - "privacyPolicyUrl": cached["privacy_policy_url"], - "userAgreementUrl": cached["user_agreement_url"], - "currency": cached["currency"], - "language": cached["language"], - "emailAuthEnabled": cached["email_auth_enabled"], - "appVersion": _resolve_app_version(), - "appRepositoryUrl": APP_REPOSITORY_URL, - } + themes_catalog = settings.webapp_themes_catalog + primary_color = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a" + initial_theme = _initial_theme_for_request(request, themes_catalog) + bootstrap = _build_webapp_bootstrap_payload(request) + config = bootstrap["config"] html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER) - i18n_instance: Optional[object] = request.app.get("i18n") - i18n_payload = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {} + 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_payload = bootstrap["i18n"] nonce = request.get("csp_nonce", "") html = html.replace( WEBAPP_CONFIG_PLACEHOLDER, @@ -673,18 +975,27 @@ async def index_route(request: web.Request) -> web.Response: WEBAPP_JS_PLACEHOLDER, f'', ) + favicon_markup = _favicon_head_markup(cached["favicon_url"]) + if favicon_markup: + html = html.replace( + '', + favicon_markup, + ) brand_asset_url = cached["logo_url"] - if not brand_asset_url and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated": + if ( + not brand_asset_url + and settings.WEBAPP_LOGO_USE_EMOJI + and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated" + ): brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI) if brand_asset_url: html = html.replace( - '', # noqa: E501 - f'', # noqa: E501 - ) - else: - html = html.replace( - '', # noqa: E501 - "", + "", + ( + f'\n' + ), + 1, ) return web.Response(text=html, content_type="text/html", charset="utf-8") @@ -724,6 +1035,129 @@ def _resolve_webapp_js_asset_name() -> str: return "subscription_webapp.js" +_INITIAL_THEME_TOKEN_CSS_MAP = { + "accent": "--accent", + "bg": "--bg", + "panel": "--panel", + "panel_2": "--panel-2", + "panel_3": "--panel-3", + "border": "--border", + "border_strong": "--border-strong", + "text": "--text", + "muted": "--muted", + "dim": "--dim", + "danger": "--danger", + "blue": "--blue", + "radius": "--radius", + "font_sans": "--font-sans", + "font_logo": "--font-logo", + "font_mono": "--font-mono", + "admin_bg": "--admin-bg", + "admin_surface": "--admin-surface", + "admin_surface_2": "--admin-surface-2", + "admin_elev": "--admin-elev", + "admin_border": "--admin-border", + "admin_border_strong": "--admin-border-strong", + "admin_text": "--admin-text", + "admin_muted": "--admin-muted", + "admin_dim": "--admin-dim", +} + + +def _theme_css_href_for_html(theme: Any) -> str: + css_file = str(getattr(theme, "css_file", "") or "").strip() + key = str(getattr(theme, "key", "") or "").strip() + if not css_file or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key): + return "" + parts = [part for part in css_file.replace("\\", "/").split("/") if part] + if any(part in {".", ".."} for part in parts): + return "" + themed_path = "/".join([key, *parts]) + encoded = "/".join(quote(part, safe="") for part in themed_path.split("/")) + return f"/webapp-theme-css/{encoded}" if encoded else "" + + +def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any: + preview_key = str(request.query.get("theme_preview") or "").strip() + if preview_key: + preview_theme = catalog.theme_by_key(preview_key) + if preview_theme is not None and preview_theme.enabled: + return preview_theme + + theme = catalog.theme_by_key(catalog.default_theme) + if theme is not None: + return theme + return catalog.enabled_themes()[0] if catalog.enabled_themes() else None + + +def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: str) -> str: + if theme is None: + return "" + + payload = public_theme_payload(theme, primary_color) + tokens = payload.get("tokens") if isinstance(payload, dict) else {} + tokens = tokens if isinstance(tokens, dict) else {} + declarations = [] + for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items(): + value = str(tokens.get(token_key) or "").strip() + if value: + declarations.append(f"{css_name}:{value}") + + scheme = "light" if tokens.get("color_scheme") == "light" else "dark" + bg = str(tokens.get("bg") or "").strip() + css_rules = [f"html{{color-scheme:{scheme};}}"] + if bg: + css_rules.append(f"body{{background-color:{bg};}}") + if declarations: + css_rules.append(f".app-shell{{{';'.join(declarations)}}}") + + nonce = html.escape(str(request.get("csp_nonce", "")), quote=True) + style_tag = ( + f'" + ) + href = _theme_css_href_for_html(theme) + if not href: + return style_tag + stylesheet = ( + f'' + ) + return stylesheet + "\n" + style_tag + + +def _favicon_head_markup(favicon_url: str) -> str: + href = str(favicon_url or "").strip() + if not href: + return "" + + escaped_href = html.escape(href, quote=True) + match = re.fullmatch( + rf"{re.escape(WEBAPP_FAVICON_PATH)}/([0-9a-f]{{16}})/icon-(?:16|32|48|180|192|512)\.png", + href, + ) + if not match: + rel = "apple-touch-icon" if href.endswith(".png") else "icon" + return ( + f'\n' + f'' + ) + + digest = match.group(1) + base = f"{WEBAPP_FAVICON_PATH}/{digest}" + return "\n".join( + [ + ( + f'' + ), + f'', + f'', + f'', + f'', + ] + ) + + def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str: start = html.find(start_marker) if start == -1: 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 80% rename from bot/app/web/webapp/routes.py rename to backend/bot/app/web/webapp/routes.py index ee78959..5d1f5ca 100644 --- a/bot/app/web/webapp/routes.py +++ b/backend/bot/app/web/webapp/routes.py @@ -9,17 +9,33 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_get("/devices", index_route) app.router.add_get("/settings", index_route) app.router.add_get("/admin", index_route) - app.router.add_get("/admin/{section:[a-z][a-z0-9_-]*}", index_route) + app.router.add_get( + ( + "/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|" + "appearance|settings}" + ), + index_route, + ) app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route) app.router.add_get("/auth/telegram/start", telegram_oauth_start_route) app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route) app.router.add_get("/health", health_route) app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route) + app.router.add_get( + rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}", + webapp_uploaded_logo_route, + ) + app.router.add_get( + rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}", + webapp_favicon_route, + ) app.router.add_get( r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}", webapp_animated_emoji_route, ) app.router.add_get("/subscription_webapp.css", css_asset_route) + app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route) + app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_asset_route) app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route) app.router.add_get("/subscription_webapp.js", js_asset_route) app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route) @@ -28,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 97% rename from bot/app/web/webapp/serializers.py rename to backend/bot/app/web/webapp/serializers.py index 174cd53..bc0c158 100644 --- a/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -1,6 +1,8 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +from config.webapp_themes_config import public_themes_catalog_payload + async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]: settings: Settings = request.app["settings"] @@ -96,6 +98,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A stars_traffic_packages=cached["stars_traffic_packages"], ), "payment_methods": _serialize_payment_methods(settings, request.app), + "themes_catalog": public_themes_catalog_payload( + settings.webapp_themes_catalog, + settings.WEBAPP_PRIMARY_COLOR or "#00fe7a", + enabled_only=True, + ), "settings": { "support_url": settings.SUPPORT_LINK, "traffic_mode": bool(settings.traffic_sale_mode), @@ -186,6 +193,7 @@ def _serialize_subscription( can_topup_regular_traffic = False can_topup_premium_traffic = False can_topup_traffic = False + can_topup_devices = False if settings.tariffs_config and active.get("tariff_key"): try: tariff = settings.tariffs_config.require(str(active.get("tariff_key"))) @@ -197,10 +205,16 @@ def _serialize_subscription( and tariff.premium_topup_packages.has_any() ) can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic) + # max_devices == 0 means unlimited — top-up is pointless in that case. + can_topup_devices = bool( + tariff.has_hwid_device_packages() + and _coerce_int_or_none(active.get("max_devices")) != 0 + ) except Exception: can_topup_regular_traffic = False can_topup_premium_traffic = False can_topup_traffic = False + can_topup_devices = False return { "active": seconds_left > 0, @@ -242,6 +256,7 @@ def _serialize_subscription( "can_topup_traffic": can_topup_traffic, "can_topup_regular_traffic": can_topup_regular_traffic, "can_topup_premium_traffic": can_topup_premium_traffic, + "can_topup_devices": can_topup_devices, "period_start_at": active.get("period_start_at").isoformat() if active.get("period_start_at") else None, 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 96% rename from bot/handlers/admin/sync_admin.py rename to backend/bot/handlers/admin/sync_admin.py index 19eacfd..5de5b62 100644 --- a/bot/handlers/admin/sync_admin.py +++ b/backend/bot/handlers/admin/sync_admin.py @@ -1,3 +1,4 @@ +import asyncio import logging from datetime import datetime, timezone from typing import Optional, Union @@ -16,6 +17,11 @@ from db.models import Subscription router = Router(name="admin_sync_router") +# Single-flight guard: panel sync runs concurrently with the bot, but only one +# sync at a time. Overlapping callers (startup, /sync, admin API) return early +# instead of queueing behind the running sync. +_sync_lock = asyncio.Lock() + def _normalize_panel_email(value: Optional[str]) -> Optional[str]: email = (value or "").strip().lower() @@ -122,6 +128,31 @@ async def perform_sync( session: AsyncSession, settings: Settings, i18n_instance: JsonI18n, +) -> dict: + """Single-flight entry point — skips when another sync is already running.""" + if _sync_lock.locked(): + logging.info("perform_sync: skipped because another sync is already in progress") + return { + "status": "skipped", + "details": "Another sync run is already in progress.", + "errors": [], + "users_processed": 0, + "subs_synced": 0, + } + async with _sync_lock: + return await _perform_sync_impl( + panel_service=panel_service, + session=session, + settings=settings, + i18n_instance=i18n_instance, + ) + + +async def _perform_sync_impl( + panel_service: PanelApiService, + session: AsyncSession, + settings: Settings, + i18n_instance: JsonI18n, ) -> dict: """ Perform panel synchronization and return results 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 99% rename from bot/handlers/user/subscription/core.py rename to backend/bot/handlers/user/subscription/core.py index cb53d4a..c50e9b1 100644 --- a/bot/handlers/user/subscription/core.py +++ b/backend/bot/handlers/user/subscription/core.py @@ -994,7 +994,16 @@ async def my_subscription_command_handler( ) ] ) - if settings.tariffs_config and local_sub and local_sub.tariff_key: + # Skip the buy-devices button entirely when the user has unlimited + # devices (max_devices == 0). Otherwise the button leads to an + # alert "hwid_devices_unlimited_no_topup" — confusing dead end. + devices_topup_allowed = ( + settings.tariffs_config + and local_sub + and local_sub.tariff_key + and max_devices_value not in (None, 0) + ) + if devices_topup_allowed: try: tariff_for_devices = settings.tariffs_config.require(local_sub.tariff_key) if ( 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 82% rename from bot/main_bot.py rename to backend/bot/main_bot.py index 9c802ca..06c67f4 100644 --- a/bot/main_bot.py +++ b/backend/bot/main_bot.py @@ -10,10 +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.tariff_worker import TariffTrafficWorker +from bot.services.settings_override_service import load_overrides_from_db from bot.utils.message_queue import init_queue_manager from config.settings import Settings from db.database_setup import init_db_connection @@ -34,9 +35,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...") @@ -130,31 +128,40 @@ async def on_startup_configured(dispatcher: Dispatcher): except Exception: logging.exception("STARTUP: Failed to initialize message queue manager.") - # Automatic sync on startup - try: - logging.info("STARTUP: Running automatic panel sync...") + # Automatic sync on startup — runs in background so the dispatcher can + # 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. + logging.info("STARTUP: Bot on_startup_configured completed.") - async with async_session_factory() as session: + +async def _background_startup_sync( + *, + panel_service: PanelApiService, + session_factory: sessionmaker, + settings: Settings, + i18n_instance: JsonI18n, +) -> None: + try: + async with session_factory() as session: sync_result = await perform_sync( panel_service=panel_service, session=session, settings=settings, i18n_instance=i18n_instance, ) - - if sync_result.get("status") == "completed": - logging.info( - f"STARTUP: Automatic sync completed successfully. Details: {sync_result.get('details', 'N/A')}" # noqa: E501 - ) + status = sync_result.get("status") + details = sync_result.get("details", "N/A") + if status == "completed": + logging.info(f"STARTUP: Background sync completed successfully. Details: {details}") + elif status == "skipped": + logging.info(f"STARTUP: Background sync skipped: {details}") else: logging.warning( - f"STARTUP: Automatic sync completed with issues. Status: {sync_result.get('status', 'unknown')}" # noqa: E501 + f"STARTUP: Background sync finished with status '{status}'. Details: {details}" ) - except Exception: - logging.exception("STARTUP: Failed to run automatic sync.") - - logging.info("STARTUP: Bot on_startup_configured completed.") + logging.exception("STARTUP: Background sync failed.") async def on_shutdown_configured(dispatcher: Dispatcher): @@ -210,6 +217,7 @@ 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.") @@ -219,6 +227,7 @@ async def run_bot(settings_param: Settings): 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"] @@ -248,15 +257,6 @@ 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 # Wrap startup/shutdown handlers to satisfy aiogram event signature (no args passed) async def _on_startup_wrapper(): @@ -270,35 +270,21 @@ async def run_bot(settings_param: Settings): await register_all_routers(dp, settings_param) - tg_webhook_base = settings_param.WEBHOOK_BASE_URL - - # Webhook mode is now required - exit if not configured - if not tg_webhook_base: + if not settings_param.WEBHOOK_BASE_URL: logging.error("WEBHOOK_BASE_URL is required. Polling mode is disabled. Exiting.") await dp.emit_shutdown() raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.") - logging.info("--- Bot Run Mode Decision ---") - logging.info(f"Configured WEBHOOK_BASE_URL: '{tg_webhook_base}' -> Webhook Mode: ENABLED") - logging.info(f"YooKassa webhook path: '{settings_param.yookassa_webhook_path}'") - logging.info("Decision: Run AIOHTTP server: ENABLED (required for webhooks)") - logging.info("--- End Bot Run Mode Decision ---") + logging.info( + "Starting AIOHTTP server: webhook_base=%s yookassa_path=%s", + settings_param.WEBHOOK_BASE_URL, + settings_param.yookassa_webhook_path, + ) - web_app_runner = None - main_tasks = [] - - # Only run AIOHTTP server for webhook mode 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: - 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(f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}") + main_tasks = [asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")] try: await asyncio.gather(*main_tasks) @@ -319,10 +305,6 @@ async def run_bot(settings_param: Settings): exc_info=True, ) - if web_app_runner: - await web_app_runner.cleanup() - logging.info("AIOHTTP AppRunner cleaned up.") - await dp.emit_shutdown() logging.info("Dispatcher shutdown sequence emitted.") 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 98% rename from bot/services/email_templates.py rename to backend/bot/services/email_templates.py index bca9a36..a17ac85 100644 --- a/bot/services/email_templates.py +++ b/backend/bot/services/email_templates.py @@ -1,7 +1,7 @@ """Branded HTML email templates that mirror the subscription Mini App look. The web app uses a dark theme with a configurable accent colour -(`WEBAPP_PRIMARY_COLOR`) and an optional logo (`WEBAPP_LOGO_URL`). The same +admin-configured accent colour and logo. The same accent + logo are reused here so emails feel like part of the product. All copy goes through the shared `JsonI18n` instance so translations live in ``locales/.json`` next to the rest of the bot strings. @@ -45,8 +45,10 @@ def _safe_color(value: Optional[str]) -> str: def _public_logo_url(settings: Settings) -> Optional[str]: - """Email recipients can't reach the in-app /webapp-logo proxy, so the - raw https URL from the env is used directly. Anything else is dropped.""" + """Email recipients can't reach the in-app /webapp-logo proxy, so only a + stored public https URL can be used directly. Anything else is dropped.""" + if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + return None raw = (settings.WEBAPP_LOGO_URL or "").strip() if not raw: return None 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 89% rename from bot/services/panel_api_service.py rename to backend/bot/services/panel_api_service.py index 21ba81e..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 @@ -9,18 +10,37 @@ from urllib.parse import urlencode import aiohttp from sqlalchemy.ext.asyncio import AsyncSession +from bot.utils.ttl_cache import AsyncTTLCache from config.settings import Settings from db.dal import panel_sync_dal from db.models import PanelSyncStatus class PanelApiService: + # Status codes returned by _request_once for failures we consider transient + # (connect error, request timeout) and therefore worth retrying on safe methods. + _TRANSIENT_STATUS_CODES = (-1, -3) + _SAFE_METHODS = frozenset({"GET", "HEAD"}) + _RETRY_BACKOFF_SECONDS = 0.5 + def __init__(self, settings: Settings): self.settings = settings self.base_url = settings.PANEL_API_URL self.api_key = settings.PANEL_API_KEY self._session: Optional[aiohttp.ClientSession] = None 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, + 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""" @@ -32,7 +52,14 @@ class PanelApiService: async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: - timeout = aiohttp.ClientTimeout(total=30) + # Separate connect/read timeouts so a stuck panel does not hold a + # bot worker for the full window; total caps worst-case latency. + timeout = aiohttp.ClientTimeout( + total=15, + connect=3, + sock_connect=3, + sock_read=10, + ) self._session = aiohttp.ClientSession(timeout=timeout) return self._session @@ -58,8 +85,31 @@ class PanelApiService: headers["Authorization"] = f"Bearer {self.api_key}" return headers + def _is_transient_error(self, result: Optional[Dict[str, Any]]) -> bool: + if not isinstance(result, dict) or not result.get("error"): + return False + code = result.get("status_code") + if code in self._TRANSIENT_STATUS_CODES: + return True + return isinstance(code, int) and 500 <= code < 600 + async def _request( self, method: str, endpoint: str, log_full_response: bool = False, **kwargs + ) -> Optional[Dict[str, Any]]: + # Retry safe (idempotent) methods once on transient failures to absorb + # network blips and short panel restarts without surfacing errors. + max_attempts = 2 if method.upper() in self._SAFE_METHODS else 1 + result: Optional[Dict[str, Any]] = None + for attempt in range(max_attempts): + result = await self._request_once(method, endpoint, log_full_response, **kwargs) + if attempt + 1 < max_attempts and self._is_transient_error(result): + await asyncio.sleep(self._RETRY_BACKOFF_SECONDS) + continue + return result + return result + + async def _request_once( + self, method: str, endpoint: str, log_full_response: bool = False, **kwargs ) -> Optional[Dict[str, Any]]: if not self.base_url: logging.error("Panel API URL (PANEL_API_URL) not configured in settings.") @@ -91,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}" @@ -194,7 +252,7 @@ class PanelApiService: return all_users async def get_user_by_uuid( - self, user_uuid: str, log_response: bool = True + self, user_uuid: str, log_response: bool = False ) -> Optional[Dict[str, Any]]: endpoint = f"/users/{user_uuid}" full_response = await self._request("GET", endpoint, log_full_response=log_response) @@ -210,7 +268,7 @@ class PanelApiService: telegram_id: Optional[int] = None, username: Optional[str] = None, email: Optional[str] = None, - log_response: bool = True, + log_response: bool = False, ) -> Optional[Dict[str, Any]]: if uuid: return await self.get_user_by_uuid(uuid, log_response=log_response) @@ -230,7 +288,7 @@ class PanelApiService: telegram_id: Optional[int] = None, username: Optional[str] = None, email: Optional[str] = None, - log_response: bool = True, + log_response: bool = False, ) -> Optional[List[Dict[str, Any]]]: response_data = None @@ -307,7 +365,7 @@ class PanelApiService: description: Optional[str] = None, tag: Optional[str] = None, status: str = "ACTIVE", - log_response: bool = True, + log_response: bool = False, ) -> Optional[Dict[str, Any]]: username_is_valid = ( @@ -393,7 +451,7 @@ class PanelApiService: return None async def update_user_status_on_panel( - self, user_uuid: str, enable: bool, log_response: bool = True + self, user_uuid: str, enable: bool, log_response: bool = False ) -> bool: action = "enable" if enable else "disable" endpoint = f"/users/{user_uuid}/actions/{action}" @@ -418,7 +476,7 @@ class PanelApiService: ) return False - async def delete_user_from_panel(self, user_uuid: str, log_response: bool = True) -> bool: + async def delete_user_from_panel(self, user_uuid: str, log_response: bool = False) -> bool: """Delete a user from the panel. Treat not-found as already deleted.""" endpoint = f"/users/{user_uuid}" response_data = await self._request("DELETE", endpoint, log_full_response=log_response) @@ -568,7 +626,13 @@ class PanelApiService: ) return None + def _invalidate_squad_caches(self) -> None: + self._squads_cache.invalidate() + async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]: + return await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached) + + async def _get_internal_squads_uncached(self) -> Optional[List[Dict[str, Any]]]: response_data = await self._request("GET", "/internal-squads", log_full_response=False) if response_data and not response_data.get("error") and "response" in response_data: response = response_data.get("response") @@ -583,6 +647,12 @@ class PanelApiService: return None async def get_internal_squad(self, squad_uuid: str) -> Optional[Dict[str, Any]]: + return await self._squads_cache.get_or_load( + f"detail:{squad_uuid}", + lambda: self._get_internal_squad_uncached(squad_uuid), + ) + + async def _get_internal_squad_uncached(self, squad_uuid: str) -> Optional[Dict[str, Any]]: response_data = await self._request( "GET", f"/internal-squads/{squad_uuid}", log_full_response=False ) @@ -603,6 +673,15 @@ class PanelApiService: async def get_internal_squad_accessible_nodes( self, squad_uuid: str, + ) -> Optional[List[Dict[str, Any]]]: + return await self._squads_cache.get_or_load( + f"nodes:{squad_uuid}", + lambda: self._get_internal_squad_accessible_nodes_uncached(squad_uuid), + ) + + async def _get_internal_squad_accessible_nodes_uncached( + self, + squad_uuid: str, ) -> Optional[List[Dict[str, Any]]]: endpoints = ( f"/internal-squads/{squad_uuid}/accessible-nodes", @@ -629,6 +708,9 @@ class PanelApiService: return None async def get_hosts(self) -> Optional[List[Dict[str, Any]]]: + return await self._hosts_cache.get_or_load("list", self._get_hosts_uncached) + + async def _get_hosts_uncached(self) -> Optional[List[Dict[str, Any]]]: response_data = await self._request("GET", "/hosts", log_full_response=False) if response_data and not response_data.get("error") and "response" in response_data: response = response_data.get("response") @@ -659,6 +741,7 @@ class PanelApiService: log_full_response=False, ) if response_data and not response_data.get("error"): + self._invalidate_squad_caches() return True logging.error("Failed to add users to squad %s. Response: %s", squad_uuid, response_data) return False @@ -674,6 +757,7 @@ class PanelApiService: log_full_response=False, ) if response_data and not response_data.get("error"): + self._invalidate_squad_caches() return True logging.error( "Failed to remove users from squad %s. Response: %s", squad_uuid, response_data diff --git a/bot/services/panel_webhook_service.py b/backend/bot/services/panel_webhook_service.py similarity index 89% rename from bot/services/panel_webhook_service.py rename to backend/bot/services/panel_webhook_service.py index 30c7be3..3320f64 100644 --- a/bot/services/panel_webhook_service.py +++ b/backend/bot/services/panel_webhook_service.py @@ -1,3 +1,4 @@ +import asyncio import hashlib import hmac import json @@ -9,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, @@ -29,6 +31,10 @@ EVENT_MAP = { class PanelWebhookService: + # Cap parallel background event handlers so an expiry burst from the panel + # cannot exhaust the DB pool or the YooKassa client. + _MAX_CONCURRENT_EVENTS = 50 + def __init__( self, bot: Bot, @@ -42,6 +48,7 @@ class PanelWebhookService: self.i18n = i18n self.async_session_factory = async_session_factory self.panel_service = panel_service + self._event_semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_EVENTS) if not self.settings.PANEL_WEBHOOK_SECRET: logging.error( "PANEL_WEBHOOK_SECRET is not configured. Panel webhooks will be rejected." @@ -243,9 +250,31 @@ class PanelWebhookService: telegram_id if telegram_id is not None else "N/A", ) - await self.handle_event(event_name, user_data) + 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: + async with self._event_semaphore: + try: + await self.handle_event(event_name, user_payload) + except Exception: + logging.exception( + "Panel webhook background handler failed for event %s", event_name + ) + async def panel_webhook_route(request: web.Request): service: PanelWebhookService = request.app["panel_webhook_service"] 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 98% rename from bot/services/subscription_service_impl/lifecycle.py rename to backend/bot/services/subscription_service_impl/lifecycle.py index 3f8102e..e4154ad 100644 --- a/bot/services/subscription_service_impl/lifecycle.py +++ b/backend/bot/services/subscription_service_impl/lifecycle.py @@ -118,9 +118,21 @@ class SubscriptionLifecycleMixin: include_premium=not bool(updated.premium_is_limited), ) panel_payload.update(self._panel_identity_payload_for_user(db_user)) - await self.panel_service.update_user_details_on_panel( + updated_panel = await self.panel_service.update_user_details_on_panel( db_user.panel_user_uuid, panel_payload ) + if not updated_panel or updated_panel.get("error"): + # The tariff row is already swapped locally; if the panel rejects + # the squad/limit update the user sees the new tariff in the app + # but stays on the old squads on Remnawave. Surface the failure + # so the caller can roll back. + logging.warning( + "Panel user details update FAILED for tariff switch user %s -> %s. Response: %s", + user_id, + target.key, + updated_panel, + ) + return None if converted_bytes: await tariff_dal.create_traffic_topup( session, 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 90% rename from bot/services/subscription_service_impl/payments.py rename to backend/bot/services/subscription_service_impl/payments.py index fc817c2..d9f2df6 100644 --- a/bot/services/subscription_service_impl/payments.py +++ b/backend/bot/services/subscription_service_impl/payments.py @@ -3,6 +3,19 @@ from ._runtime import * # noqa: F403,F405 class PaymentContextMixin: + # Human-readable provider names rendered in payment-success emails. + # Keys are the lowercased value persisted in ``subscriptions.provider`` + # (see the call sites in lifecycle.py / traffic.py); missing keys produce + # no row in the email rather than raising. + _PROVIDER_LABELS = { + "yookassa": "YooKassa", + "freekassa": "FreeKassa", + "platega": "Platega", + "severpay": "SeverPay", + "cryptopay": "Crypto Pay", + "telegram_stars": "Telegram Stars", + } + async def _record_payment_context( self, session: AsyncSession, diff --git a/bot/services/subscription_service_impl/renewal.py b/backend/bot/services/subscription_service_impl/renewal.py similarity index 85% rename from bot/services/subscription_service_impl/renewal.py rename to backend/bot/services/subscription_service_impl/renewal.py index d8aed1e..8d9cc5b 100644 --- a/bot/services/subscription_service_impl/renewal.py +++ b/backend/bot/services/subscription_service_impl/renewal.py @@ -30,12 +30,12 @@ class RenewalMixin: logging.info(f"Auto-renew skipped: no saved payment method for user {sub.user_id}") return False - try: - from .yookassa_service import YooKassaService # local import to avoid cycles - - yk: YooKassaService = self.yookassa_service # type: ignore[attr-defined] - except Exception: - yk = None # type: ignore + # ``yookassa_service`` is wired onto the subscription service via + # ``build_core_services`` (setattr). Read it directly — the previous + # ``from .yookassa_service import …`` pointed at a non-existent module + # inside this package, the ImportError got swallowed, and every + # auto-renew silently returned False. + yk = getattr(self, "yookassa_service", None) if not yk or not getattr(yk, "configured", False): logging.warning("YooKassa unavailable for auto-renew") return False 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 96% rename from bot/services/subscription_service_impl/traffic.py rename to backend/bot/services/subscription_service_impl/traffic.py index 13cd3d1..ed5d88e 100644 --- a/bot/services/subscription_service_impl/traffic.py +++ b/backend/bot/services/subscription_service_impl/traffic.py @@ -249,9 +249,18 @@ class TrafficMixin: include_premium=not bool(getattr(updated_sub, "premium_is_limited", False)), ) panel_payload.update(self._panel_identity_payload_for_user(db_user)) - await self.panel_service.update_user_details_on_panel( + updated_panel = await self.panel_service.update_user_details_on_panel( db_user.panel_user_uuid, panel_payload ) + if not updated_panel or updated_panel.get("error"): + # Otherwise the user pays for top-up bytes that are recorded locally + # but never reach Remnawave — they cannot actually use the traffic. + logging.warning( + "Panel user details update FAILED for traffic top-up user %s. Response: %s", + user_id, + updated_panel, + ) + return None await tariff_dal.create_traffic_topup( session, subscription_id=sub.subscription_id, @@ -347,9 +356,19 @@ class TrafficMixin: include_premium=not premium_is_limited, ), } - await self.panel_service.update_user_details_on_panel( + updated_panel = await self.panel_service.update_user_details_on_panel( db_user.panel_user_uuid, panel_payload ) + if not updated_panel or updated_panel.get("error"): + # Otherwise the user pays for premium top-up but the panel never + # re-grants premium squad access (the most common case here is + # transitioning from premium_is_limited=True back to False). + logging.warning( + "Panel user details update FAILED for premium top-up user %s. Response: %s", + user_id, + updated_panel, + ) + return None await tariff_dal.create_traffic_topup( session, subscription_id=sub.subscription_id, 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 84% rename from bot/services/tariff_worker.py rename to backend/bot/services/tariff_worker.py index 7132252..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 @@ -23,6 +25,11 @@ PREMIUM_WARNING_LEVEL_OFFSET = 1000 # Single warning per premium billing period when usage reached or exceeded the quota. PREMIUM_WARNING_DEPLETED_LEVEL = PREMIUM_WARNING_LEVEL_OFFSET + 100 +# Process active subscriptions in chunks and prefetch panel data concurrently +# to avoid an N+1 serial chain to the Remnawave panel each tick. +TARIFF_WORKER_BATCH_SIZE = 50 +TARIFF_WORKER_PANEL_CONCURRENCY = 10 + class TariffTrafficWorker: def __init__( @@ -42,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: @@ -91,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 @@ -109,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( @@ -117,48 +142,71 @@ class TariffTrafficWorker: Subscription.tariff_key.is_not(None), ) ) - for sub in result.scalars().all(): - try: - tariff = self.settings.tariffs_config.require(sub.tariff_key) - except Exception: - continue - panel_data = ( - await self.panel_service.get_user_by_uuid(sub.panel_user_uuid, log_response=False) - or {} - ) - used, limit, panel_strategy = self.subscription_service._extract_panel_traffic_details( - panel_data - ) - panel_status = str(panel_data.get("status") or "").upper() - panel_username = panel_data.get("username") if isinstance(panel_data, dict) else None - if used is not None and used != sub.traffic_used_bytes: - sub.traffic_used_bytes = used - if limit is not None and limit != sub.traffic_limit_bytes: - sub.traffic_limit_bytes = limit - if panel_status and panel_status != (sub.status_from_panel or "").upper(): - sub.status_from_panel = panel_status + subs = list(result.scalars().all()) + if not subs: + return - if tariff.billing_model == "period": - await self._ensure_period_reset_strategy(sub, tariff, limit, panel_strategy) - await self._maybe_warn_or_throttle( - session, - sub, - tariff, - used, - limit, - warning_period_start=warning_period_start - if tariff.billing_model == "period" - else None, - ) + semaphore = asyncio.Semaphore(TARIFF_WORKER_PANEL_CONCURRENCY) - await self._sync_premium_squad_limit( - session, - sub, - tariff, - now, - panel_username=panel_username, - panel_user_dict=panel_data, - ) + async def _fetch_panel(sub: Subscription) -> dict: + async with semaphore: + try: + data = await self.panel_service.get_user_by_uuid( + sub.panel_user_uuid, log_response=False + ) + except Exception: + logging.exception( + "TariffTrafficWorker: failed to fetch panel user %s", + sub.panel_user_uuid, + ) + return {} + return data or {} + + for chunk_start in range(0, len(subs), TARIFF_WORKER_BATCH_SIZE): + chunk = subs[chunk_start : chunk_start + TARIFF_WORKER_BATCH_SIZE] + panel_payloads = await asyncio.gather(*(_fetch_panel(s) for s in chunk)) + for sub, panel_data in zip(chunk, panel_payloads): + try: + tariff = self.settings.tariffs_config.require(sub.tariff_key) + except Exception: + continue + ( + used, + limit, + panel_strategy, + ) = self.subscription_service._extract_panel_traffic_details(panel_data) + panel_status = str(panel_data.get("status") or "").upper() + panel_username = ( + panel_data.get("username") if isinstance(panel_data, dict) else None + ) + if used is not None and used != sub.traffic_used_bytes: + sub.traffic_used_bytes = used + if limit is not None and limit != sub.traffic_limit_bytes: + sub.traffic_limit_bytes = limit + if panel_status and panel_status != (sub.status_from_panel or "").upper(): + sub.status_from_panel = panel_status + + if tariff.billing_model == "period": + await self._ensure_period_reset_strategy(sub, tariff, limit, panel_strategy) + await self._maybe_warn_or_throttle( + session, + sub, + tariff, + used, + limit, + warning_period_start=warning_period_start + if tariff.billing_model == "period" + else None, + ) + + await self._sync_premium_squad_limit( + session, + sub, + tariff, + now, + panel_username=panel_username, + panel_user_dict=panel_data, + ) async def _ensure_period_reset_strategy( self, @@ -594,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 84% rename from bot/utils/message_queue.py rename to backend/bot/utils/message_queue.py index d0088c7..28fea39 100644 --- a/bot/utils/message_queue.py +++ b/backend/bot/utils/message_queue.py @@ -27,6 +27,11 @@ class QueuedMessage: class MessageQueue: """Message queue with rate limiting for Telegram API""" + # Telegram allows ~1 message/sec to the same chat before returning 429. + PER_CHAT_MIN_INTERVAL_SECONDS = 1.0 + # Drop per-chat timestamps older than this to keep the dict bounded. + PER_CHAT_TTL_SECONDS = 5.0 + def __init__(self, messages_per_second: float, burst_size: int = 5): self.messages_per_second = messages_per_second self.burst_size = burst_size @@ -36,6 +41,7 @@ class MessageQueue: self.delay_between_messages = 1.0 / messages_per_second self.total_sent = 0 self.total_failed = 0 + self._chat_last_sent: Dict[int, datetime] = {} async def add_message(self, message: QueuedMessage) -> None: """Add message to queue""" @@ -52,14 +58,14 @@ class MessageQueue: try: while self.queue: - # Check if we need to wait - await self._wait_if_needed() + # Peek at the head to honor per-chat throttling before popping. + message = self.queue[0] + await self._wait_if_needed(message.chat_id) + self.queue.popleft() - # Get and process next message - message = self.queue.popleft() try: await self._send_message(message) - self._record_send_time() + self._record_send_time(message.chat_id) except TelegramBadRequest as exc: fallback_message = self._build_profile_link_fallback(message, exc) @@ -72,7 +78,7 @@ class MessageQueue: ) try: await self._send_message(fallback_message) - self._record_send_time() + self._record_send_time(message.chat_id) continue except Exception as retry_exc: self.total_failed += 1 @@ -91,19 +97,27 @@ class MessageQueue: finally: self.is_processing = False - async def _wait_if_needed(self) -> None: - """Wait if we need to respect rate limits""" - if not self.last_send_times: - return + async def _wait_if_needed(self, chat_id: Optional[int] = None) -> None: + """Wait if we need to respect global and per-chat rate limits.""" + now = datetime.now() + waits: list[float] = [] - # Calculate time since last message - time_since_last = (datetime.now() - self.last_send_times[-1]).total_seconds() + if self.last_send_times: + time_since_last = (now - self.last_send_times[-1]).total_seconds() + if time_since_last < self.delay_between_messages: + waits.append(self.delay_between_messages - time_since_last) - if time_since_last < self.delay_between_messages: - wait_time = self.delay_between_messages - time_since_last - await asyncio.sleep(wait_time) + if chat_id is not None: + last_chat = self._chat_last_sent.get(chat_id) + if last_chat is not None: + time_since_chat = (now - last_chat).total_seconds() + if time_since_chat < self.PER_CHAT_MIN_INTERVAL_SECONDS: + waits.append(self.PER_CHAT_MIN_INTERVAL_SECONDS - time_since_chat) - def _record_send_time(self) -> None: + if waits: + await asyncio.sleep(max(waits)) + + def _record_send_time(self, chat_id: Optional[int] = None) -> None: """Track sent message timestamps and purge old entries for rate limiting.""" now = datetime.now() self.last_send_times.append(now) @@ -113,6 +127,13 @@ class MessageQueue: while self.last_send_times and self.last_send_times[0] < cutoff_time: self.last_send_times.popleft() + if chat_id is not None: + self._chat_last_sent[chat_id] = now + chat_cutoff = now - timedelta(seconds=self.PER_CHAT_TTL_SECONDS) + stale = [cid for cid, ts in self._chat_last_sent.items() if ts < chat_cutoff] + for cid in stale: + self._chat_last_sent.pop(cid, None) + def _build_profile_link_fallback( self, message: QueuedMessage, exc: Exception ) -> Optional[QueuedMessage]: 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/backend/bot/utils/ttl_cache.py b/backend/bot/utils/ttl_cache.py new file mode 100644 index 0000000..a65ad6a --- /dev/null +++ b/backend/bot/utils/ttl_cache.py @@ -0,0 +1,77 @@ +import asyncio +import time +from typing import Any, Awaitable, Callable, Dict, Optional, Tuple + + +class AsyncTTLCache: + """In-memory async-safe TTL cache with single-flight loader. + + Concurrent get_or_load() calls for the same key share one loader execution. + """ + + 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] = {} + + def _is_fresh(self, expires_at: float) -> bool: + return time.monotonic() < expires_at + + def get_fresh(self, key: str) -> Optional[Any]: + entry = self._data.get(key) + if entry is None: + return None + expires_at, value = entry + if not self._is_fresh(expires_at): + return None + return value + + @staticmethod + def _is_cacheable(value: Any) -> bool: + if value is None: + return False + if isinstance(value, dict) and value.get("error"): + return False + 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 + lock = self._locks.setdefault(key, asyncio.Lock()) + async with lock: + cached = self.get_fresh(key) + if cached is not None: + return cached + value = await loader() + if self._is_cacheable(value): + self._data[key] = (time.monotonic() + self.ttl_seconds, value) + return value + + def invalidate(self, key: Optional[str] = None) -> None: + if key is None: + self._data.clear() + return + self._data.pop(key, None) diff --git a/config/settings.py b/backend/config/settings.py similarity index 92% rename from config/settings.py rename to backend/config/settings.py index 31d8302..e845a3c 100644 --- a/config/settings.py +++ b/backend/config/settings.py @@ -7,6 +7,10 @@ from pydantic import BaseModel, Field, ValidationError, computed_field, field_va from pydantic_settings import BaseSettings, SettingsConfigDict from config.tariffs_config import TariffsConfig, load_tariffs_config +from config.webapp_themes_config import ( + WebappThemesConfig, + resolved_webapp_themes_catalog, +) def _split_csv(value: Optional[str]) -> List[str]: @@ -88,8 +92,12 @@ class WebAppSettings(BaseModel): title: str primary_color: str logo_url: Optional[str] + logo_use_emoji: bool logo_emoji: str logo_emoji_font: str + favicon_use_custom: bool + favicon_url: Optional[str] + logo_favicon_url: Optional[str] session_ttl_seconds: int session_secret: str webhook_secret_token: str @@ -112,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") @@ -351,7 +374,21 @@ class Settings(BaseSettings): WEBAPP_SERVER_PORT: int = Field(default=8081) WEBAPP_TITLE: str = Field(default="Моя подписка") WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a") + WEBAPP_THEMES_DIR: str = Field( + default="data/themes", + description=( + "Directory with per-theme folders. Each theme lives in " + "/theme.json with optional CSS/assets next to it." + ), + ) + WEBAPP_DEFAULT_THEME: Optional[str] = Field( + default=None, + description=( + "Override the descriptor-marked default theme when set to an existing theme key." + ), + ) WEBAPP_LOGO_URL: Optional[str] = Field(default=None) + WEBAPP_LOGO_USE_EMOJI: bool = Field(default=False) WEBAPP_LOGO_EMOJI: str = Field(default="🫥") WEBAPP_LOGO_EMOJI_FONT: str = Field( default="system", @@ -360,6 +397,9 @@ class Settings(BaseSettings): "noto-emoji, twemoji, openmoji, apple, segoe, noto-local" ), ) + WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False) + WEBAPP_FAVICON_URL: Optional[str] = Field(default=None) + WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None) WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60) @@ -528,8 +568,12 @@ class Settings(BaseSettings): title=self.WEBAPP_TITLE, primary_color=self.WEBAPP_PRIMARY_COLOR, logo_url=self.WEBAPP_LOGO_URL, + logo_use_emoji=self.WEBAPP_LOGO_USE_EMOJI, logo_emoji=self.WEBAPP_LOGO_EMOJI, logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT, + favicon_use_custom=self.WEBAPP_FAVICON_USE_CUSTOM, + favicon_url=self.WEBAPP_FAVICON_URL, + logo_favicon_url=self.WEBAPP_LOGO_FAVICON_URL, session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS, session_secret=self.WEBAPP_SESSION_SECRET, webhook_secret_token=self.WEBHOOK_SECRET_TOKEN, @@ -810,6 +854,55 @@ class Settings(BaseSettings): def tariffs_config(self) -> Optional[TariffsConfig]: return load_tariffs_config(self.TARIFFS_CONFIG_PATH) + @computed_field + @property + def webapp_themes_catalog(self) -> WebappThemesConfig: + return resolved_webapp_themes_catalog( + primary_accent=self.WEBAPP_PRIMARY_COLOR or "#00fe7a", + env_default_theme=self.WEBAPP_DEFAULT_THEME, + theme_dir=self.WEBAPP_THEMES_DIR, + ) + + @field_validator("WEBAPP_PRIMARY_COLOR", mode="before") + @classmethod + def ignore_deprecated_webapp_primary_color_env(cls, _value): + return "#00fe7a" + + @field_validator("WEBAPP_LOGO_URL", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_url_env(cls, _value): + return None + + @field_validator("WEBAPP_LOGO_USE_EMOJI", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_use_emoji_env(cls, _value): + return False + + @field_validator("WEBAPP_LOGO_EMOJI", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_emoji_env(cls, _value): + return "🫥" + + @field_validator("WEBAPP_LOGO_EMOJI_FONT", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_emoji_font_env(cls, _value): + return "system" + + @field_validator("WEBAPP_FAVICON_USE_CUSTOM", mode="before") + @classmethod + def ignore_deprecated_webapp_favicon_use_custom_env(cls, _value): + return False + + @field_validator("WEBAPP_FAVICON_URL", mode="before") + @classmethod + def ignore_deprecated_webapp_favicon_url_env(cls, _value): + return None + + @field_validator("WEBAPP_LOGO_FAVICON_URL", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_favicon_url_env(cls, _value): + return None + @computed_field @property def referral_bonus_inviter(self) -> Dict[int, int]: 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/backend/config/webapp_themes_config.py b/backend/config/webapp_themes_config.py new file mode 100644 index 0000000..e6cc7f7 --- /dev/null +++ b/backend/config/webapp_themes_config.py @@ -0,0 +1,633 @@ +"""File-backed catalog of Web App UI themes.""" + +from __future__ import annotations + +import json +import logging +import re +from pathlib import Path +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, Field, field_validator, model_validator + +logger = logging.getLogger(__name__) + +ColorScheme = Literal["light", "dark"] + + +class ThemeTokens(BaseModel): + """CSS design tokens for the subscription Mini App shell.""" + + model_config = {"extra": "ignore"} + + color_scheme: ColorScheme = "dark" + style_preset: Optional[str] = None + accent: Optional[str] = None + bg: Optional[str] = None + panel: Optional[str] = None + panel_2: Optional[str] = None + panel_3: Optional[str] = None + border: Optional[str] = None + border_strong: Optional[str] = None + text: Optional[str] = None + muted: Optional[str] = None + dim: Optional[str] = None + danger: Optional[str] = None + danger_text: Optional[str] = None + danger_soft: Optional[str] = None + danger_border: Optional[str] = None + success: Optional[str] = None + success_text: Optional[str] = None + success_soft: Optional[str] = None + success_border: Optional[str] = None + warning: Optional[str] = None + warning_text: Optional[str] = None + warning_soft: Optional[str] = None + warning_border: Optional[str] = None + info: Optional[str] = None + info_text: Optional[str] = None + info_soft: Optional[str] = None + info_border: Optional[str] = None + blue: Optional[str] = None + radius: Optional[str] = None + font_sans: Optional[str] = None + font_logo: Optional[str] = None + font_mono: Optional[str] = None + home_logo_scale: Optional[int] = None + admin_bg: Optional[str] = None + admin_surface: Optional[str] = None + admin_surface_2: Optional[str] = None + admin_elev: Optional[str] = None + admin_border: Optional[str] = None + admin_border_strong: Optional[str] = None + admin_text: Optional[str] = None + admin_muted: Optional[str] = None + admin_dim: Optional[str] = None + + @field_validator("accent") + @classmethod + def _normalize_accent_hex(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + raw = str(value).strip() + if not raw: + return None + match = re.fullmatch(r"#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})", raw) + if not match: + raise ValueError("accent must be a hex color (#RGB or #RRGGBB)") + hex_value = match.group(1).lower() + if len(hex_value) == 3: + hex_value = "".join(char * 2 for char in hex_value) + return f"#{hex_value}" + + @field_validator("home_logo_scale") + @classmethod + def _normalize_home_logo_scale(cls, value: Optional[int]) -> Optional[int]: + if value is None: + return None + scale = int(value) + if scale < 50 or scale > 300: + raise ValueError("home_logo_scale must be between 50 and 300 percent") + return scale + + +class WebappTheme(BaseModel): + """Single theme descriptor loaded from WEBAPP_THEMES_DIR//theme.json.""" + + model_config = {"extra": "ignore"} + + key: str = Field(min_length=1, max_length=64) + names: Dict[str, str] = Field(default_factory=dict) + enabled: bool = True + default: bool = False + use_primary_accent: bool = True + use_in_admin: bool = True + css_file: Optional[str] = None + assets_version: int = 1 + tokens: ThemeTokens = Field(default_factory=ThemeTokens) + + +class WebappThemesConfig(BaseModel): + """Runtime catalog assembled from individual theme descriptor files.""" + + model_config = {"extra": "ignore"} + + default_theme: str = "dark" + themes: List[WebappTheme] = Field(default_factory=list) + + @model_validator(mode="after") + def _validate_default_and_keys(self) -> WebappThemesConfig: + keys = [t.key for t in self.themes] + if len(keys) != len(set(keys)): + raise ValueError("duplicate theme keys") + if self.themes and self.default_theme not in keys: + raise ValueError("default_theme must match a theme key") + return self + + def theme_by_key(self, key: str) -> Optional[WebappTheme]: + for theme in self.themes: + if theme.key == key: + return theme + return None + + def enabled_themes(self) -> List[WebappTheme]: + return [theme for theme in self.themes if theme.enabled] + + +DEFAULT_THEME_KEYS = ("dark", "light", "windows95", "ascii") +THEME_DESCRIPTOR_FILENAME = "theme.json" +DEFAULT_THEMES_SOURCE_DIR = Path(__file__).resolve().parents[1] / "bot" / "app" / "web" / "themes" + + +def _safe_theme_key(value: str) -> Optional[str]: + key = str(value or "").strip() + if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key): + return key + return None + + +def _theme_dir_path(theme_dir: str | Path, key: str) -> Path: + safe_key = _safe_theme_key(key) + if not safe_key: + raise ValueError(f"invalid theme key: {key!r}") + return Path(theme_dir).expanduser() / safe_key + + +def _theme_file_path(theme_dir: str | Path, key: str) -> Path: + return _theme_dir_path(theme_dir, key) / THEME_DESCRIPTOR_FILENAME + + +def default_webapp_theme_css_files() -> Dict[str, str]: + """Read default theme CSS from repository files.""" + out: Dict[str, str] = {} + for key in DEFAULT_THEME_KEYS: + source_dir = DEFAULT_THEMES_SOURCE_DIR / key + for source_path in sorted(source_dir.rglob("*.css")): + rel_path = Path(key) / source_path.relative_to(source_dir) + try: + content = source_path.read_text(encoding="utf-8") + except OSError as exc: + logger.warning( + "Default webapp theme CSS source file is missing: %s (%s)", + source_path, + exc, + ) + continue + out[rel_path.as_posix()] = content if content.endswith("\n") else f"{content}\n" + return out + + +def default_webapp_theme_asset_file(rel_path: str | Path) -> Optional[tuple[bytes, str]]: + """Read a default theme asset from the repository theme folder.""" + relative = Path(rel_path) + if relative.is_absolute() or len(relative.parts) < 2 or ".." in relative.parts: + return None + source_path = (DEFAULT_THEMES_SOURCE_DIR / relative).resolve() + try: + source_path.relative_to(DEFAULT_THEMES_SOURCE_DIR.resolve()) + except ValueError: + return None + try: + return source_path.read_bytes(), source_path.suffix.lower() + except OSError: + return None + + +def default_webapp_theme_descriptors() -> Dict[str, Dict[str, Any]]: + """Read default theme descriptors from repository files.""" + out: Dict[str, Dict[str, Any]] = {} + for key in DEFAULT_THEME_KEYS: + source_path = DEFAULT_THEMES_SOURCE_DIR / key / THEME_DESCRIPTOR_FILENAME + try: + raw = json.loads(source_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + logger.warning( + "Default webapp theme descriptor is missing or invalid: %s (%s)", + source_path, + exc, + ) + continue + if not isinstance(raw, dict): + continue + safe_key = _safe_theme_key(str(raw.get("key") or source_path.parent.name)) + if safe_key: + raw["key"] = safe_key + out[safe_key] = raw + return out + + +def _theme_from_descriptor(path: Path, raw: Any) -> Optional[WebappTheme]: + if not isinstance(raw, dict): + logger.warning("Ignoring theme descriptor %s: expected JSON object", path) + return None + data = dict(raw) + data["key"] = data.get("key") or ( + path.parent.name if path.name == THEME_DESCRIPTOR_FILENAME else path.stem + ) + safe_key = _safe_theme_key(str(data["key"])) + if not safe_key: + logger.warning("Ignoring theme descriptor %s: invalid theme key %r", path, data["key"]) + return None + data["key"] = safe_key + try: + return WebappTheme.model_validate(data) + except ValueError as exc: + logger.warning("Ignoring theme descriptor %s: %s", path, exc) + return None + + +def load_webapp_theme_file(path: str | Path) -> Optional[WebappTheme]: + theme_path = Path(path).expanduser() + try: + raw = json.loads(theme_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Failed to load webapp theme descriptor from %s: %s", theme_path, exc) + return None + return _theme_from_descriptor(theme_path, raw) + + +def load_webapp_theme_dir(theme_dir: str | Path) -> List[WebappTheme]: + root = Path(theme_dir).expanduser() + if not root.exists(): + return [] + themes_by_key: Dict[str, WebappTheme] = {} + for path in sorted(root.glob(f"*/{THEME_DESCRIPTOR_FILENAME}")): + if path.parent.name.startswith("_"): + continue + theme = load_webapp_theme_file(path) + if theme is None: + continue + if theme.key in themes_by_key: + logger.warning("Ignoring duplicate webapp theme key %s from %s", theme.key, path) + continue + themes_by_key[theme.key] = theme + return list(themes_by_key.values()) + + +def _config_with_synced_default_flags(config: WebappThemesConfig) -> WebappThemesConfig: + data = config.model_dump(mode="json", exclude_none=True) + default_theme = str(data.get("default_theme") or "dark") + themes = data.get("themes", []) + for theme in themes: + if isinstance(theme, dict): + theme["default"] = theme.get("key") == default_theme + + def _sort_key(item: tuple[int, Any]) -> tuple[int, int]: + idx, theme = item + if not isinstance(theme, dict): + return (len(THEME_DISPLAY_ORDER), idx) + try: + priority = THEME_DISPLAY_ORDER.index(str(theme.get("key") or "")) + except ValueError: + priority = len(THEME_DISPLAY_ORDER) + return (priority, idx) + + data["themes"] = [theme for _, theme in sorted(enumerate(themes), key=_sort_key)] + return WebappThemesConfig.model_validate(data) + + +THEME_DISPLAY_ORDER = ("dark", "light") + + +def _theme_sort_key(theme: WebappTheme, index: int) -> tuple[int, int]: + try: + priority = THEME_DISPLAY_ORDER.index(theme.key) + except ValueError: + priority = len(THEME_DISPLAY_ORDER) + return (priority, index) + + +def _sorted_themes(themes: List[WebappTheme]) -> List[WebappTheme]: + return [ + theme + for _, theme in sorted( + ((_theme_sort_key(t, i), t) for i, t in enumerate(themes)), + key=lambda pair: pair[0], + ) + ] + + +def _themes_config_from_list( + default_theme: Optional[str], + themes: List[WebappTheme], +) -> WebappThemesConfig: + keys = {theme.key for theme in themes} + descriptor_default = next( + (theme.key for theme in themes if theme.default and theme.key not in DEFAULT_THEME_KEYS), + None, + ) or next((theme.key for theme in themes if theme.default), None) + resolved_default = default_theme or descriptor_default or "dark" + if themes and resolved_default not in keys: + resolved_default = "dark" if "dark" in keys else themes[0].key + config = WebappThemesConfig(default_theme=resolved_default, themes=_sorted_themes(themes)) + return _config_with_synced_default_flags(config) + + +def _write_webapp_theme_file(path: Path, theme: WebappTheme) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + data = theme.model_dump(mode="json", exclude_none=True) + payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n" + tmp_path = path.with_suffix(f"{path.suffix}.tmp") + try: + tmp_path.write_text(payload, encoding="utf-8") + tmp_path.replace(path) + except PermissionError: + if tmp_path.exists(): + try: + tmp_path.unlink() + except OSError: + pass + path.write_text(payload, encoding="utf-8") + + +def _copy_default_theme_assets(key: str, target_dir: Path, *, overwrite: bool = False) -> None: + source_dir = DEFAULT_THEMES_SOURCE_DIR / key + if not source_dir.exists(): + return + for source_path in sorted(source_dir.rglob("*")): + if not source_path.is_file() or source_path.name == THEME_DESCRIPTOR_FILENAME: + continue + rel_path = source_path.relative_to(source_dir) + target_path = target_dir / rel_path + if target_path.exists() and not overwrite: + continue + try: + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_bytes(source_path.read_bytes()) + except OSError as exc: + logger.warning("Could not create default webapp theme asset %s: %s", target_path, exc) + + +def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool: + style_path = target_dir / "style.css" + try: + style = style_path.read_text(encoding="utf-8") + except OSError: + return True + if key == "light": + return "--success-text" not in style or ".theme-key-light.app-shell" not in style + if key == "ascii": + return ( + ".theme-key-ascii" not in style + or "ascii-spin" not in style + or "ascii-skeleton-scan" not in style + or "ascii-boot-type" not in style + or "Console-style tables" not in style + ) + if key != "windows95": + return False + required_icons = ( + "arrow-right.png", + "dashboard.png", + "megaphone.png", + "paintbrush.png", + "sliders.png", + "sparkles.png", + "tag.png", + ) + return ( + "lucide-house" not in style + or "lucide-earth" not in style + or "lucide-circle-check" not in style + or "border-radius: 0 !important" not in style + or "::-webkit-slider-thumb" not in style + or "?v=6" not in style + or any(not (target_dir / "icons" / icon).exists() for icon in required_icons) + ) + + +def ensure_default_webapp_theme_descriptor_files(theme_dir: str | Path | None) -> None: + """Seed source-controlled default theme folders into the mounted data directory.""" + if not theme_dir: + return + root = Path(theme_dir).expanduser() + try: + root.mkdir(parents=True, exist_ok=True) + except OSError as exc: + logger.warning("Could not create webapp themes directory at %s: %s", root, exc) + return + + existing_has_default = any(theme.default for theme in load_webapp_theme_dir(root)) + existing_by_key = {theme.key: theme for theme in load_webapp_theme_dir(root)} + for key, descriptor in default_webapp_theme_descriptors().items(): + theme_dir_path = _theme_dir_path(root, key) + path = _theme_file_path(root, key) + existing = existing_by_key.get(key) + source_assets_version = int(descriptor.get("assets_version") or 1) + should_sync_assets = ( + existing is not None + and key in DEFAULT_THEME_KEYS + and ( + int(existing.assets_version or 0) < source_assets_version + or _builtin_theme_assets_need_refresh(key, theme_dir_path) + ) + ) + if not path.exists(): + seed_descriptor = dict(descriptor) + if existing_has_default: + seed_descriptor["default"] = False + theme = _theme_from_descriptor(path, seed_descriptor) + if theme is not None: + try: + _write_webapp_theme_file(path, theme) + except OSError as exc: + logger.warning( + "Could not create default webapp theme descriptor %s: %s", + path, + exc, + ) + elif should_sync_assets and existing is not None: + data = existing.model_dump(mode="json", exclude_none=True) + data["assets_version"] = source_assets_version + if descriptor.get("css_file"): + data["css_file"] = descriptor["css_file"] + try: + theme = WebappTheme.model_validate(data) + _write_webapp_theme_file(path, theme) + except (OSError, ValueError) as exc: + logger.warning("Could not update default webapp theme descriptor %s: %s", path, exc) + _copy_default_theme_assets(key, theme_dir_path, overwrite=should_sync_assets) + + +def write_webapp_theme_dir( + theme_dir: str | Path, + config: WebappThemesConfig, + *, + delete_missing: bool = False, +) -> None: + """Write one theme.json descriptor per theme into WEBAPP_THEMES_DIR/.""" + root = Path(theme_dir).expanduser() + root.mkdir(parents=True, exist_ok=True) + normalized = _config_with_synced_default_flags(config) + keep_paths = set() + for theme in normalized.themes: + path = _theme_file_path(root, theme.key) + _write_webapp_theme_file(path, theme) + keep_paths.add(path.resolve()) + + if not delete_missing: + return + for path in root.glob(f"*/{THEME_DESCRIPTOR_FILENAME}"): + if path.parent.name.startswith("_") or path.resolve() in keep_paths: + continue + try: + path.unlink() + if not any(path.parent.iterdir()): + path.parent.rmdir() + except OSError as exc: + logger.warning("Could not delete removed webapp theme descriptor %s: %s", path, exc) + + +def ensure_webapp_core_themes( + config: WebappThemesConfig, primary_accent: str +) -> tuple[WebappThemesConfig, bool]: + """Keep dark, light and Windows 95 themes available without clobbering custom edits.""" + data = config.model_dump(mode="json", exclude_none=True) + themes = data.setdefault("themes", []) + by_key = {str(theme.get("key")): theme for theme in themes if isinstance(theme, dict)} + changed = False + + for builtin in builtin_webapp_themes_config(primary_accent).themes: + builtin_data = builtin.model_dump(mode="json", exclude_none=True) + existing = by_key.get(builtin.key) + if existing is None: + themes.append(builtin_data) + by_key[builtin.key] = builtin_data + changed = True + continue + + if existing.get("enabled") is False: + existing["enabled"] = True + changed = True + + if "use_primary_accent" not in existing: + existing["use_primary_accent"] = builtin_data.get("use_primary_accent", True) + changed = True + + if "use_in_admin" not in existing: + existing["use_in_admin"] = builtin_data.get("use_in_admin", True) + changed = True + + if int(existing.get("assets_version") or 0) < int(builtin_data.get("assets_version") or 1): + existing["assets_version"] = builtin_data.get("assets_version", 1) + changed = True + + if builtin.key in {"light", "windows95", "ascii"} and not existing.get("css_file"): + existing["css_file"] = builtin_data.get("css_file") + changed = True + + tokens = existing.setdefault("tokens", {}) + builtin_tokens = builtin_data.get("tokens", {}) + for token_key in ("color_scheme", "style_preset"): + if token_key in builtin_tokens and not tokens.get(token_key): + tokens[token_key] = builtin_tokens[token_key] + changed = True + + keys = {str(theme.get("key")) for theme in themes if isinstance(theme, dict)} + if themes and data.get("default_theme") not in keys: + data["default_theme"] = "dark" if "dark" in keys else str(themes[0].get("key") or "dark") + changed = True + + normalized = _config_with_synced_default_flags(WebappThemesConfig.model_validate(data)) + if normalized.model_dump(mode="json", exclude_none=True) != data: + changed = True + return normalized, changed + + +def builtin_webapp_themes_config(primary_accent: str) -> WebappThemesConfig: + """Default catalog backed by repository theme descriptor files.""" + accent = (primary_accent or "#00fe7a").strip() or "#00fe7a" + themes: List[WebappTheme] = [] + descriptors = default_webapp_theme_descriptors() + for key in DEFAULT_THEME_KEYS: + raw = descriptors.get(key) + if not raw: + continue + theme = _theme_from_descriptor( + DEFAULT_THEMES_SOURCE_DIR / key / THEME_DESCRIPTOR_FILENAME, + raw, + ) + if theme is None: + continue + if theme.key == "dark" and not theme.tokens.accent: + theme.tokens.accent = accent + themes.append(theme) + return _themes_config_from_list(None, themes) + + +def apply_webapp_theme_env_overrides( + config: WebappThemesConfig, env_default_theme: Optional[str] +) -> WebappThemesConfig: + """If WEBAPP_DEFAULT_THEME is set and matches a theme key, override the default theme.""" + raw = (env_default_theme or "").strip() + if not raw: + return config + if config.theme_by_key(raw) is None: + logger.warning("WEBAPP_DEFAULT_THEME=%r ignored: no such theme in catalog", raw) + return config + data = config.model_dump(mode="json", exclude_none=True) + data["default_theme"] = raw + return _config_with_synced_default_flags(WebappThemesConfig.model_validate(data)) + + +def resolved_webapp_themes_catalog( + *, + theme_dir: str | Path, + primary_accent: str, + env_default_theme: Optional[str], +) -> WebappThemesConfig: + """Load themes from WEBAPP_THEMES_DIR, seeding defaults when possible.""" + ensure_default_webapp_theme_descriptor_files(theme_dir) + + themes = load_webapp_theme_dir(theme_dir) + config = _themes_config_from_list(None, themes) + config, changed = ensure_webapp_core_themes(config, primary_accent) + if changed: + try: + write_webapp_theme_dir(theme_dir, config, delete_missing=False) + except OSError as exc: + logger.warning("Could not update webapp theme descriptors in %s: %s", theme_dir, exc) + return apply_webapp_theme_env_overrides(config, env_default_theme) + + +def merge_primary_accent_into_theme_tokens( + theme: WebappTheme, primary_accent: str, *, only_if_token_missing: bool = True +) -> ThemeTokens: + """Fill accent from WEBAPP_PRIMARY_COLOR when theme tokens omit accent.""" + base = theme.tokens.model_copy(deep=True) + accent = (primary_accent or "").strip() + if not accent: + return base + if only_if_token_missing and base.accent: + return base + base.accent = accent + return base + + +def public_theme_payload(theme: WebappTheme, primary_accent: str) -> Dict[str, object]: + tokens = ( + merge_primary_accent_into_theme_tokens(theme, primary_accent) + if theme.use_primary_accent + else theme.tokens + ) + payload: Dict[str, object] = { + "key": theme.key, + "names": dict(theme.names), + "enabled": bool(theme.enabled), + "use_primary_accent": bool(theme.use_primary_accent), + "use_in_admin": bool(theme.use_in_admin), + "tokens": tokens.model_dump(mode="json", exclude_none=True), + } + if theme.css_file: + payload["css_file"] = theme.css_file + return payload + + +def public_themes_catalog_payload( + config: WebappThemesConfig, primary_accent: str, *, enabled_only: bool = False +) -> Dict[str, object]: + themes = [theme for theme in config.themes if not enabled_only or theme.enabled] + return { + "default_theme": config.default_theme, + "themes": [public_theme_payload(theme, primary_accent) for theme in themes], + } 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 98% rename from db/migrator.py rename to backend/db/migrator.py index f721d60..cc7c926 100644 --- a/db/migrator.py +++ b/backend/db/migrator.py @@ -729,6 +729,21 @@ def _migration_0017_reconcile_legacy_admin_api_schema(connection: Connection) -> ) +def _migration_0022_add_indexes_for_admin_reports(connection: Connection) -> None: + connection.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_payments_status_created_at " + "ON payments (status, created_at)" + ) + ) + connection.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_message_logs_timestamp " + "ON message_logs (timestamp DESC)" + ) + ) + + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -846,6 +861,11 @@ MIGRATIONS: List[Migration] = [ description="Admin toggle for effectively unlimited main traffic limit", upgrade=_migration_0021_add_regular_unlimited_override, ), + Migration( + id="0022_add_indexes_for_admin_reports", + description="Indexes to speed up financial stats and admin log queries", + upgrade=_migration_0022_add_indexes_for_admin_reports, + ), ] 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_backend.py b/backend/main_backend.py new file mode 100644 index 0000000..80ba517 --- /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) + + +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 89% rename from requirements.txt rename to backend/requirements.txt index 9ce503e..b1aacda 100644 --- a/requirements.txt +++ b/backend/requirements.txt @@ -10,3 +10,5 @@ sqlalchemy[asyncio]==2.0.49 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..d097dc1 --- /dev/null +++ b/backend/startup_banner.py @@ -0,0 +1,155 @@ +import os +import sys +from pathlib import Path +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 +""" +_APP_ROOT = Path(__file__).resolve().parents[1] + + +def _env(name: str, default: str = "-") -> str: + return os.getenv(name) or default + + +def _build_file(name: str, default: str = "-") -> str: + try: + value = (_APP_ROOT / name).read_text(encoding="utf-8").strip() + except OSError: + return default + return value or default + + +def _build_commit() -> str: + for env_name in ("REMNAWAVE_MINISHOP_COMMIT", "GIT_COMMIT", "COMMIT_SHA"): + value = os.getenv(env_name) + if value: + return value.strip()[:40] or "-" + return _build_file(".build-commit")[:40] or "-" + + +def _build_tag() -> str: + for env_name in ("REMNAWAVE_MINISHOP_TAG", "GIT_TAG", "BUILD_TAG"): + value = os.getenv(env_name) + if value: + return value.strip()[:40] or "-" + return _build_file(".build-tag")[:40] or "-" + + +def _image_tag_display(image_tag: str) -> str: + normalized = image_tag.lower() + build_tag = _build_tag() + build_commit = _build_commit() + if normalized == "latest" and build_tag != "-": + return f"latest-{build_tag}" + if normalized == "dev": + if build_tag != "-" and build_commit != "-": + return f"dev-{build_tag}+{build_commit}" + if build_tag != "-": + return f"dev-{build_tag}" + if build_commit != "-": + return f"dev+{build_commit}" + return image_tag + + +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_display(image_tag)}", + f"commit :: {_build_commit()}", + 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..e4c4a00 --- /dev/null +++ b/deploy/compose/docker-compose-caddy.yml @@ -0,0 +1,155 @@ +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 + 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..090bb62 --- /dev/null +++ b/deploy/docker/Dockerfile @@ -0,0 +1,125 @@ +# Resolve the application version from .git at build time and emit a single +# tiny ``.build-version`` file. The .git tree is consumed in this throwaway +# stage and never copied into the runtime image — only the resulting one-line +# version string ships. This matches the runtime fallback chain in +# ``_resolve_app_version`` (REMNAWAVE_MINISHOP_VERSION env > .build-version +# file > live git > "dev+unknown") so the admin sidebar always shows a tag / +# sha even though the runtime images have no git tooling and no .git tree. +FROM alpine:3.20 AS version-builder +RUN apk add --no-cache git +WORKDIR /repo +COPY .git ./.git +RUN set -eu; \ + git config --global --add safe.directory /repo; \ + tag=$(git describe --tags --abbrev=0 2>/dev/null || true); \ + sha=$(git rev-parse --short HEAD 2>/dev/null || true); \ + dirty=$(git status --porcelain 2>/dev/null | head -c1 || true); \ + if [ -n "$tag" ] && [ -n "$sha" ]; then \ + commits_since_tag=$(git rev-list "$tag..HEAD" --count 2>/dev/null || true); \ + if [ -n "$commits_since_tag" ] && [ "$commits_since_tag" != "0" ]; then \ + version="${tag}+${commits_since_tag}.g${sha}"; \ + else \ + version="$tag"; \ + fi; \ + elif [ -n "$sha" ]; then \ + version="dev+g${sha}"; \ + else \ + version="dev+unknown"; \ + fi; \ + if [ -n "$dirty" ]; then version="${version}-dirty"; fi; \ + printf '%s' "$version" > /build-version; \ + printf '%s' "${tag:-unknown}" > /build-tag; \ + printf '%s' "${sha:-unknown}" > /build-commit + + +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 +COPY --from=version-builder /build-version /app/.build-version +COPY --from=version-builder /build-tag /app/.build-tag +COPY --from=version-builder /build-commit /app/.build-commit +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 --from=version-builder /build-tag /build-tag +COPY --from=version-builder /build-commit /build-commit +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 set -eu; \ + find /docker-entrypoint.d -type f -name '*.sh' -exec sed -i 's/\r$//' {} +; \ + HASHED=$(ls /usr/share/nginx/html/subscription_webapp.min.*.js 2>/dev/null | sort | tail -n1 | xargs -n1 basename || true); \ + JS_NAME="${HASHED:-subscription_webapp.js}"; \ + 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..53afd2d --- /dev/null +++ b/deploy/docker/caddy/Caddyfile @@ -0,0 +1,12 @@ +# Replace the example domains with your real webhook and Mini App hostnames. +app.example.com { + encode zstd gzip + + reverse_proxy backend:8080 +} + +web.example.com { + encode zstd gzip + + 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..69cb685 --- /dev/null +++ b/deploy/docker/frontend/00-startup-banner.sh @@ -0,0 +1,47 @@ +#!/bin/sh +PUBLISHED="${FRONTEND_PUBLIC:-127.0.0.1:${FRONTEND_PORT:-8082}->80}" +IMAGE_TAG_VALUE="${IMAGE_TAG:-local}" +BUILD_TAG="${REMNAWAVE_MINISHOP_TAG:-${GIT_TAG:-${BUILD_TAG:-}}}" +if [ -z "$BUILD_TAG" ] && [ -r /build-tag ]; then + BUILD_TAG="$(cat /build-tag)" +fi +BUILD_TAG="${BUILD_TAG:-unknown}" +BUILD_COMMIT="${REMNAWAVE_MINISHOP_COMMIT:-${GIT_COMMIT:-${COMMIT_SHA:-}}}" +if [ -z "$BUILD_COMMIT" ] && [ -r /build-commit ]; then + BUILD_COMMIT="$(cat /build-commit)" +fi +BUILD_COMMIT="${BUILD_COMMIT:-unknown}" +DISPLAY_IMAGE_TAG="$IMAGE_TAG_VALUE" +if [ "$IMAGE_TAG_VALUE" = "latest" ] && [ "$BUILD_TAG" != "unknown" ]; then + DISPLAY_IMAGE_TAG="latest-${BUILD_TAG}" +elif [ "$IMAGE_TAG_VALUE" = "dev" ]; then + if [ "$BUILD_TAG" != "unknown" ] && [ "$BUILD_COMMIT" != "unknown" ]; then + DISPLAY_IMAGE_TAG="dev-${BUILD_TAG}+${BUILD_COMMIT}" + elif [ "$BUILD_TAG" != "unknown" ]; then + DISPLAY_IMAGE_TAG="dev-${BUILD_TAG}" + elif [ "$BUILD_COMMIT" != "unknown" ]; then + DISPLAY_IMAGE_TAG="dev+${BUILD_COMMIT}" + fi +fi + +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 6e24843..51ef001 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -9,6 +9,7 @@ - блокировка пользователей, рассылки, промокоды и просмотр логов; - ручная синхронизация с Remnawave; - редактор разрешенных настроек приложения из manifest-файла; +- раздел **Внешний вид** для логотипа, emoji-логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем; - редактор JSON-каталога тарифов; - загрузка Internal Squads из Remnawave для выбора в тарифах. @@ -32,7 +33,7 @@ ## Настройки -Раздел **Система -> Настройки** позволяет менять приложение без редактирования `.env`, но только в пределах allowlist из `bot/app/web/admin_settings_manifest.py`. Даже администратор не может менять через UI произвольные атрибуты `Settings`. +Раздел **Система -> Настройки** позволяет менять приложение без редактирования `.env`, но только в пределах allowlist из `backend/bot/app/web/admin_settings_manifest.py`. Даже администратор не может менять через UI произвольные атрибуты `Settings`. Изменения сохраняются как overrides в базе данных и применяются поверх `.env` без перезапуска. Если значение сброшено, снова используется значение из `.env`. После сохранения публичный кеш настроек Web App сбрасывается, поэтому пользователи видят изменения при следующих запросах. @@ -46,6 +47,14 @@ Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде. +## Внешний вид + +Раздел **Внешний вид** объединяет настройки бренда и темы Web App. Логотип можно загрузить файлом или по HTTPS-ссылке; backend сохраняет файл в `data/webapp-logo/uploads` и подставляет локальный URL. Если включен emoji-логотип, картинка скрывается, а для emoji можно выбрать системный, Twemoji, Noto Color, animated Noto и другие варианты отрисовки. + +В блоке тем админка читает каталог из `WEBAPP_THEMES_DIR`, показывает встроенные и кастомные темы, позволяет выбрать текущую тему, изменить accent, включить или выключить тему для админки и настроить масштаб логотипа на главной и экране входа. Кнопка предпросмотра открывает `/home?theme_preview=` и не меняет глобальную тему до сохранения. + +Подробный формат `theme.json`, CSS/asset-роуты и пошаговый пайплайн создания новой темы описаны в [webapp-themes.md](webapp-themes.md). + ## Тарифы Раздел **Система -> Тарифы** работает с файлом `TARIFFS_CONFIG_PATH` (по умолчанию `data/tariffs.json`). При сохранении backend валидирует payload через `TariffsConfig`, пишет JSON в UTF-8 и сбрасывает кеш публичных данных Web App. @@ -68,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 f4b2f41..ff20bf3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -96,10 +96,10 @@ nano .env Если файл из `TARIFFS_CONFIG_PATH` существует, бот использует каталог тарифов. Если файла нет, применяется конфигурация из переменных `.env`. -В штатном `docker-compose.yml` том `./data:/app/data` у сервиса приложения **закомментирован по умолчанию**. Раскомментируйте блок `volumes`, чтобы админка сохраняла `data/tariffs.json`, кеш логотипа 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/webapp-logo data/webapp-emoji +mkdir -p data/themes data/webapp-logo data/webapp-emoji chown -R 10001:10001 data chmod -R u+rwX data ``` @@ -112,20 +112,18 @@ docker compose up -d --build --force-recreate Переопределения из веб-админки сохраняются в БД и применяются поверх `.env` без перезапуска. Для платежных методов кнопка отображается только если соответствующий `*_ENABLED=true` и сервис настроен. -Редактор тарифов в админке сохраняет не override в БД, а сам JSON-файл `TARIFFS_CONFIG_PATH`. Редактор настроек админки, наоборот, работает через allowlist из `bot/app/web/admin_settings_manifest.py` и сохраняет overrides в БД. Через него можно менять только заявленные в manifest параметры приложения; остальные параметры остаются в `.env`. Подробнее: [admin.md](admin.md). +Редактор тарифов в админке сохраняет не override в БД, а сам JSON-файл `TARIFFS_CONFIG_PATH`. Редактор настроек админки, наоборот, работает через allowlist из `backend/bot/app/web/admin_settings_manifest.py` и сохраняет overrides в БД. Через него можно менять только заявленные в manifest параметры приложения; остальные параметры остаются в `.env`. Подробнее: [admin.md](admin.md). ## Web App и email-вход | Переменная | Назначение | | --- | --- | | `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_PRIMARY_COLOR` | Основной цвет интерфейса. | -| `WEBAPP_LOGO_URL` | URL логотипа Web App; если пусто — показывается emoji из `WEBAPP_LOGO_EMOJI`. | -| `WEBAPP_LOGO_EMOJI` | Emoji-заглушка вместо картинки логотипа. | -| `WEBAPP_LOGO_EMOJI_FONT` | Набор/шрифт для отрисовки emoji (например `system`, `twemoji`, `noto-color-animated`). | +| `WEBAPP_THEMES_DIR` | Каталог тем Web App. По умолчанию `data/themes`; внутри ожидаются папки `/theme.json` и опциональные CSS/ассеты. | +| `WEBAPP_DEFAULT_THEME` | Опциональный override темы по ключу, например `light` или `neon`. Если пусто, используется `default` из дескрипторов тем. | | `WEBAPP_SESSION_SECRET` | HMAC-секрет сессий Web App. | | `WEBHOOK_SECRET_TOKEN` | Секретный токен, с которым Telegram шлёт обновления на вебхук. | | `WEBAPP_SESSION_TTL_SECONDS` | Время жизни сессии Web App. | @@ -146,7 +144,7 @@ docker compose up -d --build --force-recreate | `BRUTE_FORCE_LOCK_SECONDS` | Длительность временной блокировки. | | `MY_DEVICES_SECTION_ENABLED` | Показывает раздел "Мои устройства" и включает API устройств. | -Настройка домена, BotFather и callback URL описана в [webapp.md](webapp.md). +Логотип, emoji-логотип, основной accent-цвет и тема редактируются в разделе **Админка -> Внешний вид** и сохраняются как overrides в базе. Переменные `WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_URL`, `WEBAPP_LOGO_USE_EMOJI`, `WEBAPP_LOGO_EMOJI` и `WEBAPP_LOGO_EMOJI_FONT` в `.env` считаются устаревшими для первичной настройки и игнорируются при загрузке env. Настройка домена, BotFather и callback URL описана в [webapp.md](webapp.md), а создание кастомных тем - в [webapp-themes.md](webapp-themes.md). ### SMTP и вход по email @@ -231,7 +229,7 @@ docker compose up -d --build --force-recreate | `LOG_SUSPICIOUS_ACTIVITY` | Уведомлять о подозрительных попытках. | | `LOG_ADMIN_ACTIONS` | Писать в журнал админки действия пользователей из `ADMIN_IDS`. | -Часть этих переключателей доступна для правки через Web App (allowlist в `bot/app/web/admin_settings_manifest.py`), см. [admin.md](admin.md). +Часть этих переключателей доступна для правки через Web App (allowlist в `backend/bot/app/web/admin_settings_manifest.py`), см. [admin.md](admin.md). ## Миниатюры inline-режима diff --git a/docs/deployment.md b/docs/deployment.md index a4292cb..246b45a 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,295 +1,269 @@ # Развертывание -Документ описывает запуск через 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/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. +Перед запуском замените example-домены прямо в `deploy/docker/caddy/Caddyfile`. + +## Логи и проверка + +```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/webapp-emoji; touch /app/data/webapp-emoji/test && rm /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 сможет сохранять каталог тарифов, кеш `WEBAPP_LOGO_URL` в `/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 '...'`, чтобы переменные раскрылись там, а не на хосте. +- webhook-домен целиком в `backend:8080`; +- Mini App-домен целиком в `frontend:80`; +- API/auth/theme routes Mini App дальше проксируются frontend nginx в `backend:8081`. -Если написать на хосте `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 -upstream remnawave-minishop { - server remnawave-minishop:8080; -} - -map $http_upgrade $connection_upgrade { - default upgrade; - "" close; -} - -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; - } -} -``` - -Для Web App используйте отдельный upstream на `WEBAPP_SERVER_PORT`; пример есть в [webapp.md](webapp.md). - -## SSL для домена вебхуков - -Пример выпуска сертификата через `acme.sh`: - -```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 -``` - -Если 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`: +Встроенный `deploy/docker/caddy/Caddyfile` намеренно повторяет старую двухдоменную структуру: ```caddyfile -webhook.domain.com { - encode zstd gzip - reverse_proxy remnawave-minishop:{$WEB_SERVER_PORT:8080} +# Replace the example domains with your real webhook and Mini App hostnames. +app.example.com { + encode zstd gzip + + reverse_proxy backend:8080 } -app.domain.com { - encode zstd gzip - reverse_proxy remnawave-minishop:{$WEBAPP_SERVER_PORT:8081} +web.example.com { + encode zstd gzip + + reverse_proxy frontend:80 } ``` -В `.env` укажите: +Для внешнего Nginx используйте DNS-имена сервисов внутри Docker network: -```env -WEBHOOK_BASE_URL=https://webhook.domain.com -SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/ +```nginx +upstream remnawave_backend_webhooks { + server backend:8080; +} + +upstream remnawave_backend_webapp { + server backend:8081; +} + +upstream remnawave_frontend { + server frontend:80; +} ``` -Запуск: +Webhook и health-маршруты должны идти в `backend:8080`. Статические ассеты Mini App отдавайте через +`frontend:80`; frontend nginx уже проксирует `/api/*`, `/auth/*` и ассеты тем/логотипов в +`backend:8081`. + +## Newt + +Если `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 -docker compose -f docker-compose-caddy.yml up -d --build +APP_ENV_FILE=.env.staging docker compose --env-file .env.staging 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..d66402e 100644 --- a/docs/migration-to-minishop.md +++ b/docs/migration-to-minishop.md @@ -1,40 +1,151 @@ -# Миграция с `remnawave-tg-shop` на `remnawave-minishop` +# Миграция с `remnawave-tg-shop` (≤ v2.7.0) на `remnawave-minishop` (v3.4+) -Начиная с этой версии контейнеры и тома названы `remnawave-minishop*` вместо `remnawave-tg-shop*`. Старый и новый стеки используют **разные имена томов**, поэтому простой `docker compose up -d` после `git pull` создаст пустую БД. Эта инструкция описывает, как перенести данные. +## Короткий путь без смены ветки и сборки -Есть два пути: +Если вы используете только готовые Docker-образы и не собираете проект +локально, git-команды из ручного способа не нужны. Достаточно обновить +compose-файл до варианта с готовыми образами (`deploy/compose/docker-compose-remote-server.yml`) +и перенести/обновить БД. -- [Автоматический](#автоматический-способ-через-скрипт) — один скрипт, идемпотентный, проверяет состояние на каждом шаге. -- [Ручной](#ручной-способ) — команды, которые делает скрипт, если хочется понимать происходящее или выполнить выборочно. +Минимальная последовательность: + +```bash +docker compose down + +# Замените compose-файл на актуальный вариант для готовых образов. +# Если файл лежит рядом, можно запускать его явно: +IMAGE_TAG=3.4.0 docker compose -f deploy/compose/docker-compose-remote-server.yml up --no-start + +# Нужно только при переходе со старого имени volume remnawave-tg-shop-db-data. +# Если у вас уже есть remnawave-minishop-db-data, этот шаг пропустите. +docker run --rm \ + -v remnawave-tg-shop-db-data:/from:ro \ + -v remnawave-minishop-db-data:/to \ + alpine sh -c "cd /from && cp -a . /to" + +IMAGE_TAG=3.4.0 docker compose -f deploy/compose/docker-compose-remote-server.yml up -d +docker compose -f deploy/compose/docker-compose-remote-server.yml logs migrate +``` + +Сервис `migrate` сам применит недостающие схемные миграции к перенесённому +тому PostgreSQL. Новые тома `remnawave-minishop-redis-data` и +`remnawave-minishop-shop-data` переносить не нужно: они создаются пустыми. + +Этот документ описывает обновление стека, поднятого по `remnawave-tg-shop` +(включая последний релиз `v2.7.0` форка `kavore/remnawave-tg-shop`), до +текущей версии `remnawave-minishop` (v3.4+). Между этими версиями произошли +две независимые перетряски, и скрипт пытается отработать обе одной командой: + +1. **Переименование стека** (v3.1.0): контейнеры и тома `remnawave-tg-shop-*` + стали `remnawave-minishop-*`. Простой `docker compose up -d` после + `git pull` создаёт пустую БД — без переноса тома данные теряются. +2. **Разделение бота на сервисы** (v3.4.0): из одного контейнера выделены + `backend`, `worker`, `frontend`, `migrate` + новые `postgres`, `redis`. + Появились новые volumes `redis-data` и `shop-data`, новые обязательные + переменные окружения, а схема БД обновляется автоматически one-shot + сервисом `migrate`. + +После миграции `docker compose ps` должен показать как минимум: `backend`, +`worker`, `frontend`, `postgres`, `redis` (running) и `migrate` (exited 0). +Логи: `docker compose logs -f backend worker frontend`. + +Доступные пути: + +- [Автоматический](#автоматический-способ-через-скрипт) — скрипт-обёртка + останавливает старый стек, накатывает свежий код, переносит том БД, + поднимает новые сервисы. Идемпотентный. +- [Ручной](#ручной-способ) — те же шаги командами, для тех, кому нужно + понимать каждое действие или выполнить выборочно. В обоих случаях: -- старые тома **не удаляются** автоматически — это безопасный бэкап на случай отката; -- сертификаты Caddy (если используется `docker-compose-caddy.yml`) тоже переносятся, чтобы Let's Encrypt не выписывал их заново и не упереться в rate limit. + +- старые тома **не удаляются** автоматически — это безопасный бэкап на случай + отката; +- сертификаты Caddy (если используется `deploy/compose/docker-compose-caddy.yml`) + тоже переносятся, чтобы Let's Encrypt не выписывал их заново и не упереться + в rate limit; +- схема БД обновляется автоматически: при первом `docker compose up -d` сервис + `migrate` накатывает на перенесённый том все недостающие миграции (от + alembic-схемы v2.7.0 до текущей). + +## Что меняется в архитектуре + +**Контейнеры**: + +| Версия | Сервисы | +| --- | --- | +| `v2.7.0` | `remnawave-tg-shop`, `remnawave-tg-shop-db` | +| `v3.1.x–v3.3.x` | `remnawave-minishop`, `remnawave-minishop-db` | +| `v3.4+` (текущая) | `remnawave-minishop-backend`, `remnawave-minishop-worker`, `remnawave-minishop-frontend`, `remnawave-minishop-migrate`, `remnawave-minishop-postgres`, `remnawave-minishop-redis` | + +Внутри Docker-сети сервисы доступны по коротким DNS-именам (`backend`, `worker`, +`frontend`, `postgres`, `redis`), а не по полному `container_name`. Это важно +для внешнего reverse-proxy — см. раздел [Внешний reverse-proxy](#внешний-reverse-proxy) ниже. + +**Volumes**: + +| Volume | v2.7.0 | v3.4+ | Что внутри | +| --- | --- | --- | --- | +| `remnawave-minishop-db-data` | переименовать из `remnawave-tg-shop-db-data` | переносится скриптом | PostgreSQL | +| `remnawave-minishop-redis-data` | — | создаётся пустым | Redis (FSM, rate-limit, cache, очередь webhooks, distributed locks) | +| `remnawave-minishop-shop-data` | — | создаётся пустым | `/app/data`: `tariffs.json`, темы Web App, кэш логотипа/emoji | +| `remnawave-minishop-caddy-data` / `…-caddy-config` | переименовать из `remnawave-tg-shop-caddy-*` | переносится скриптом | только при Caddy-варианте | + +`redis-data` и `shop-data` стартуют пустыми — это нормально. Redis ничего +долгоживущего не хранит (всё либо FSM, либо кеш с TTL), а `data/` инициализируется +из образа при первом старте (`tariffs.json` пуст пока вы не сконфигурируете +тарифы через админ-панель). + +## Переменные окружения, которые могли исчезнуть или переехать + +Перед запуском нового стека проверьте `.env`. Ниже — только то, что точно +менялось между v2.7.0 и v3.4+: + +| Было (v2.7.0) | Стало (v3.4+) | Действие | +| --- | --- | --- | +| `TELEGRAM_WEBHOOK_SECRET` | `WEBHOOK_SECRET_TOKEN` | Переименовать. Если пусто — будет сгенерирован при старте, но тогда Telegram переустановит webhook (на это не реагирует существующий запрос). | +| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь генерируется из `BOT_TOKEN` автоматически. | +| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается автоматически, как только задан `REQUIRED_CHANNEL_ID`. | +| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars (XTR) используются напрямую. | +| `REFERRAL_ENABLED` | удалена | Реферальная программа активна по умолчанию; чтобы выключить — обнулите `REFERRAL_BONUS_DAYS_*` и `REFEREE_BONUS_DAYS_*`. | +| `POSTGRES_HOST=remnawave-tg-shop-db` | в `.env` — `remnawave-minishop-db` или пусто | Под compose значение всё равно переопределяется на сервисное имя `postgres` (см. `environment:` в compose-файлах), поэтому скрипт правит `.env` только для bare-metal сценариев. | +| `WEBHOOK_BASE_URL` | **обязательна** | Polling-режим удалён, без публичного URL бот не стартует. | +| — | `REDIS_URL=redis://redis:6379/0` | Обязательна для воркера, очередей и rate-limit. По умолчанию в compose-файлах уже задана. | +| — | `WEBAPP_SESSION_SECRET`, `WEBAPP_ENABLED`, `WEBAPP_SERVER_PORT`, `WEBAPP_THEMES_DIR`, `TARIFFS_CONFIG_PATH` | Новые настройки Web App / тарифного каталога. Безопасные дефолты есть в `.env.example`. | + +Полный референс — [docs/configuration.md](configuration.md). Скрипт миграции +эти переменные **не правит** автоматически (только `POSTGRES_HOST`), потому +что у каждой инсталляции свой шаблон `.env` с кастомными значениями. Лучше +сравнить свой `.env` с `.env.example` глазами один раз, чем получить +несовместимый шаблон автоматом. ## Автоматический способ (через скрипт) -Если helper ещё не лежит у вас локально, запускайте его прямо из `raw` из корня старого репозитория: +Если helper ещё не лежит у вас локально, запускайте его прямо из `raw` из +корня старого репозитория: ```bash bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh) ``` -> Команда выше рассчитана на `bash` / Git Bash / WSL. Если вы запускаете из PowerShell, удобнее сначала открыть Git Bash. +> Команда выше рассчитана на `bash` / Git Bash / WSL. Если вы запускаете из +> PowerShell, удобнее сначала открыть Git Bash. -Если вы уже подтянули новую версию и файл есть локально, можно запускать и так: +Если вы уже подтянули новую версию и файл есть локально, можно запускать так: ```bash bash scripts/migrate_to_minishop.sh ``` -По умолчанию скрипт работает с `docker-compose.yml` и переключается на ветку `main`. Можно переопределить через переменные окружения: +По умолчанию скрипт работает с `docker-compose.yml` и переключается на ветку +`main`. Можно переопределить через переменные окружения: | Переменная | Назначение | По умолчанию | | ----------------- | ----------------------------------------------------------------------- | ---------------------- | | `PROJECT_ROOT` | Явный путь к корню старого репозитория, если запуск не из него | текущая директория | | `COMPOSE_FILE` | Какой compose-файл стартовать в конце | `docker-compose.yml` | | `TARGET_BRANCH` | На какую ветку переключаться и подтягивать обновления | `main` | -| `GIT_REMOTE` | Какой remote использовать для `fetch`/`pull` | `origin` | +| `GIT_REMOTE` | Какой remote использовать для `fetch`/`pull` | `origin` | | `NEW_ORIGIN_URL` | Если задано и не совпадает с URL выбранного remote — он будет обновлён | (не меняется) | | `ASSUME_YES` | `1` — не задавать интерактивных вопросов | `0` | @@ -42,7 +153,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 @@ -56,25 +167,41 @@ ASSUME_YES=1 \ Что делает скрипт: -1. **Останавливает текущий стек**: проверяет известные контейнеры старой и новой схемы и останавливает их, если они запущены. -2. **Переключает `origin`**, если задана переменная `NEW_ORIGIN_URL`, иначе оставляет как есть. -3. **Подтягивает целевую ветку** (`git fetch` + `git switch` + `git pull --ff-only`). Прерывается, если в рабочем дереве есть незакоммиченные изменения. -4. **Обновляет `.env`** и правит `POSTGRES_HOST`, если он ещё указывает на старый контейнер. -5. **Подготавливает новый стек в режиме `--no-start`**, чтобы Compose сам создал тома и не ругался на уже существующий volume. -6. **Переносит тома** `remnawave-tg-shop-*` → `remnawave-minishop-*` через одноразовый `alpine`-контейнер. Если новый том уже непустой, копирование пропускается. -7. **Стартует новый стек** (`docker compose -f $COMPOSE_FILE up -d --remove-orphans`, а для локальной сборки ещё и `--build`) и печатает `docker compose ps`. +1. **Останавливает текущий стек**: ищет известные контейнеры старой схемы + (`remnawave-tg-shop`, `…-db`, `…-caddy`), переходного периода + (`remnawave-minishop`, `…-db`, `…-caddy`) и новой схемы + (`…-backend`, `…-worker`, `…-frontend`, `…-migrate`, `…-postgres`, `…-redis`) + и останавливает их, если запущены. Безопасно при повторном запуске. +2. **Переключает `origin`**, если задана переменная `NEW_ORIGIN_URL`, иначе + оставляет как есть. +3. **Подтягивает целевую ветку** (`git fetch` + `git switch` + `git pull --ff-only`). + Прерывается, если в рабочем дереве есть незакоммиченные изменения. +4. **Правит `POSTGRES_HOST` в `.env`** (только для bare-metal сценариев — в + compose это значение перебивает `environment:` блок). +5. **Подготавливает новый стек в режиме `--no-start`**, чтобы Compose сам + создал тома `db-data`, `redis-data`, `shop-data` и не ругался на уже + существующий volume. +6. **Переносит том БД** `remnawave-tg-shop-db-data` → `remnawave-minishop-db-data` + (и Caddy-тома, если применимо) через одноразовый `alpine`-контейнер. Если + новый том уже непустой — копирование пропускается. Новые volumes + `redis-data` и `shop-data` остаются пустыми (их и не должно быть в старом + стеке). +7. **Стартует новый стек** (`docker compose up -d --remove-orphans` плюс + `--build` для локальной сборки). `migrate` отработает первым, накатит + на перенесённый том все недостающие миграции (от alembic-схемы v2.7.0 до + текущей) и завершится. Затем стартуют `backend`, `worker`, `frontend`. -Скрипт идемпотентен: повторный запуск ничего не сломает, просто пропустит уже выполненные шаги. +Скрипт идемпотентен: повторный запуск ничего не сломает, просто пропустит уже +выполненные шаги. -После того как убедитесь, что бот работает и данные на месте, удалите старые тома: +После того как убедитесь, что бот работает и данные на месте, удалите старые +тома: ```bash docker volume rm remnawave-tg-shop-db-data docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true ``` -> ⚠️ Если у вас есть внешний reverse proxy (Nginx и т.п.), не забудьте поправить в его конфиге `upstream`/`proxy_pass`: имя хоста контейнера изменилось с `remnawave-tg-shop` на `remnawave-minishop`. Скрипт не трогает внешние конфиги. - ## Ручной способ 1. **Остановите старый стек и обновите код:** @@ -86,26 +213,39 @@ docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/ git pull --ff-only origin main ``` -2. **Обновите `.env`:** +2. **(Только для bare-metal без compose)** обновите `.env`, если в нём ещё + жёстко прописан старый контейнер БД: ```bash sed -i.bak 's/^POSTGRES_HOST=remnawave-tg-shop-db$/POSTGRES_HOST=remnawave-minishop-db/' .env ``` -3. **Подготовьте новый стек без запуска:** + Под `docker compose up` это не нужно: compose сам выставляет + `POSTGRES_HOST: postgres` (имя сервиса) в `environment:` и `.env`-значение + не используется. + +3. **Проверьте `.env`** на наличие переменных, которые исчезли или + переименовались — см. раздел + [Переменные окружения](#переменные-окружения-которые-могли-исчезнуть-или-переехать) + выше. Главное: `WEBHOOK_SECRET_TOKEN` (бывший `TELEGRAM_WEBHOOK_SECRET`), + обязательный `WEBHOOK_BASE_URL` и наличие `REDIS_URL` (по умолчанию задано + в compose). + +4. **Подготовьте новый стек без запуска**, чтобы Compose создал новые volumes + (`db-data`, `redis-data`, `shop-data`) и контейнеры: ```bash # Локальная сборка 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. **Перенесите том БД в новое имя:** +5. **Перенесите том БД в новое имя:** ```bash docker run --rm \ @@ -114,7 +254,12 @@ docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/ alpine sh -c "cd /from && cp -a . /to" ``` -5. **(Только для Caddy)** перенесите тома Caddy с TLS-сертификатами и состоянием ACME: + `remnawave-minishop-redis-data` и `remnawave-minishop-shop-data` — новые, + переносить нечего. Они инициализируются на лету: Redis пуст, а `data/` + наполняется при первом обращении к настройкам Web App / каталогу тарифов. + +6. **(Только для Caddy)** перенесите тома Caddy с TLS-сертификатами и + состоянием ACME: ```bash for v in caddy-data caddy-config; do @@ -125,26 +270,86 @@ docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/ done ``` -6. **Запустите новый стек:** +7. **Запустите новый стек:** ```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. **Проверьте:** + Сервис `migrate` запустится первым, обнаружит перенесённый том, + применит недостающие схемные миграции (`Base.metadata.create_all` + + последовательные миграции `0001..00NN` из `backend/db/migrator.py`) и + выйдет с кодом 0. Только после этого стартуют `backend` и `worker`. + +8. **Проверьте состояние:** ```bash docker compose ps - docker compose logs -f remnawave-minishop + docker compose logs -f backend worker frontend + docker compose logs migrate # должен закончиться "Migrator: migration 00NN applied successfully" ``` -8. **(Опционально) удалите старые тома**, когда убедитесь, что новый стек стабилен: +9. **(Опционально) удалите старые тома**, когда убедитесь, что новый стек + стабилен: ```bash docker volume rm remnawave-tg-shop-db-data docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true ``` + +## Внешний reverse-proxy + +В v2.7.0 был один upstream — `remnawave-tg-shop:8000`. В v3.4+ функциональность +разнесена по портам и сервисам: + +| Назначение | DNS-имя сервиса | Порт | +| --- | --- | --- | +| Telegram / платёжные / panel webhooks | `backend` | `8080` | +| Health-чек | `backend` | `8080` (`/healthz`) | +| Web App API (`/api/*`, `/auth/*`, ассеты тем и логотипов) | `backend` | `8081` (доступен только из Docker-сети) | +| Статический фронт Web App | `frontend` | `80` (внутри `frontend` уже проксирует `/api/*` и `/auth/*` на `backend:8081`) | + +Минимальная замена для внешнего Nginx, который раньше слал всё на один +upstream: + +```nginx +upstream remnawave_backend_webhooks { server backend:8080; } +upstream remnawave_frontend { server frontend:80; } + +server { + server_name app.domain.com; + listen 443 ssl; + http2 on; + # ssl_certificate / ssl_certificate_key — без изменений + + location /webhook/ { proxy_pass http://remnawave_backend_webhooks; } + location /healthz { proxy_pass http://remnawave_backend_webhooks; } + location / { proxy_pass http://remnawave_frontend; } +} +``` + +Полные примеры (Caddy, Newt/Pangolin) — в [docs/deployment.md](deployment.md) +и [docs/webapp.md](webapp.md). Если раньше прокси указывал на +`remnawave-tg-shop:8000` напрямую, после миграции нужно либо переключиться на +`backend:8080` / `frontend:80`, либо использовать встроенный Caddy-вариант, +который уже знает правильную маршрутизацию. + +## Если что-то пошло не так + +`migrate` упал → читайте `docker compose logs migrate`. Том БД остался +не тронут, можно откатиться, переключив compose-файл обратно на старый +коммит и подняв старый стек на старом томе `remnawave-tg-shop-db-data` +(пока вы его не удалили). + +`backend` не стартует → чаще всего `WEBHOOK_BASE_URL` пуст, либо +`WEBHOOK_SECRET_TOKEN` отличается от того, что Telegram ждёт. Поставьте +свежий секрет в `.env` и перезапустите — Telegram переустановит webhook +автоматически. + +Web App пуст / 502 → проверьте, что `frontend` живёт (`docker compose ps`), +а внешний прокси шлёт на `frontend:80`, а не на старый +`remnawave-tg-shop:8000`. diff --git a/docs/remnawave-minishop.webp b/docs/remnawave-minishop.webp new file mode 100644 index 0000000..0c10799 Binary files /dev/null and b/docs/remnawave-minishop.webp differ diff --git a/docs/webapp-themes.md b/docs/webapp-themes.md new file mode 100644 index 0000000..43b3642 --- /dev/null +++ b/docs/webapp-themes.md @@ -0,0 +1,368 @@ +![Remnawave Minishop Web App Themes](webapp-themes.webp) + +# Темы и внешний вид Web App + +Web App поддерживает файловые темы, предпросмотр и базовую настройку внешнего вида из админ-панели. Тема может быть простой цветовой схемой на JSON-токенах или полноценным скином с собственным CSS, шрифтами, иконками и графикой. + +## Что можно поменять + +Через раздел **Админка -> Внешний вид** можно: + +- выбрать глобальную тему Web App; +- изменить accent-цвет конкретной темы; +- включить или выключить применение темы в админ-панели; +- настроить масштаб логотипа на главной и экране входа; +- загрузить логотип файлом или по HTTPS-ссылке; +- включить emoji-логотип и выбрать способ его отрисовки; +- открыть предпросмотр темы через `/home?theme_preview=`. + +Через файлы темы можно менять намного больше: + +- базовые цвета Mini App и админки; +- радиусы, семейства шрифтов и размер главного логотипа; +- любые компоненты через CSS: карточки, навигацию, таблицы, модалки, кнопки, скелетоны, прогресс-бары, состояния hover/active и мобильную/desktop-верстку; +- иконки и изображения, если CSS ссылается на ассеты темы; +- стили только пользовательской части, только админки или обеих частей сразу. + +Готовые темы лежат в `backend/bot/app/web/themes`: `dark`, `light`, `windows95`, `ascii`. При первом запуске они копируются в `WEBAPP_THEMES_DIR`, по умолчанию `data/themes`. + +## Где живут темы + +Каждая тема - отдельная папка: + +```text +data/themes/ + neon/ + theme.json + style.css + icons/ + save.png +``` + +Путь настраивается переменной: + +```env +WEBAPP_THEMES_DIR=data/themes +WEBAPP_DEFAULT_THEME= +``` + +`WEBAPP_DEFAULT_THEME` опционален. Если он задан и совпадает с ключом темы, он переопределяет `default: true` в `theme.json`. Если переменная пустая, дефолт выбирается из дескрипторов тем. + +Важно: `WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_URL`, `WEBAPP_LOGO_USE_EMOJI`, `WEBAPP_LOGO_EMOJI` и `WEBAPP_LOGO_EMOJI_FONT` больше не являются рабочим способом первичной настройки через `.env`. Эти значения редактируются в админке и сохраняются как overrides в базе. Тема при этом может использовать сохраненный primary color как fallback accent. + +## Контракт `theme.json` + +Минимальная тема: + +```json +{ + "key": "neon", + "names": { + "ru": "Неон", + "en": "Neon" + }, + "enabled": true, + "default": true, + "use_primary_accent": true, + "use_in_admin": true, + "tokens": { + "color_scheme": "dark", + "bg": "#05040a", + "panel": "#11101c", + "text": "#f8f7ff", + "muted": "#b8b2d8", + "accent": "#a855f7", + "radius": "14px" + } +} +``` + +Тема с CSS: + +```json +{ + "key": "neon", + "names": { + "ru": "Неон", + "en": "Neon" + }, + "enabled": true, + "default": false, + "use_primary_accent": false, + "use_in_admin": true, + "css_file": "style.css", + "assets_version": 1, + "tokens": { + "color_scheme": "dark", + "style_preset": "none", + "accent": "#a855f7", + "bg": "#05040a", + "panel": "#11101c", + "panel_2": "#090815", + "panel_3": "#1a1830", + "border": "rgba(168, 85, 247, 0.28)", + "border_strong": "rgba(168, 85, 247, 0.48)", + "text": "#f8f7ff", + "muted": "#b8b2d8", + "dim": "#756f9b", + "danger": "#ff6b8a", + "blue": "#38bdf8", + "radius": "14px", + "font_sans": "Inter, system-ui, sans-serif", + "font_logo": "Inter, system-ui, sans-serif", + "font_mono": "\"JetBrains Mono\", \"Fira Code\", monospace", + "home_logo_scale": 120, + "admin_bg": "#05040a", + "admin_surface": "#11101c", + "admin_surface_2": "#090815", + "admin_elev": "#1a1830", + "admin_border": "rgba(168, 85, 247, 0.28)", + "admin_border_strong": "rgba(168, 85, 247, 0.48)", + "admin_text": "#f8f7ff", + "admin_muted": "#b8b2d8", + "admin_dim": "#756f9b" + } +} +``` + +Поля верхнего уровня: + +| Поле | Назначение | +| --- | --- | +| `key` | Уникальный ключ темы, 1-64 символа: латиница, цифры, `_` и `-`. Если ключ не указан, берется имя папки. | +| `names` | Локализованные названия, например `ru` и `en`. | +| `enabled` | Показывать тему пользователям. Отключенная тема не попадает в публичный каталог. | +| `default` | Делает тему выбранной по умолчанию, если `WEBAPP_DEFAULT_THEME` не задан. | +| `use_primary_accent` | Если `true`, тема может получить accent из настройки внешнего вида, когда в `tokens.accent` ничего нет. | +| `use_in_admin` | Если `false`, пользовательская часть использует тему, но админка откатывается на `dark`. | +| `css_file` | CSS-файл внутри папки темы. Может быть `style.css` или вложенный путь вроде `css/theme.css`. | +| `assets_version` | Версия ассетов. Для встроенных тем используется для обновления старых файлов в `data/themes`. | +| `tokens` | Дизайн-токены, которые превращаются в CSS-переменные на `.app-shell`. | + +## Токены + +Поддерживаемые токены: + +| Токен | CSS-переменная | Что меняет | +| --- | --- | --- | +| `color_scheme` | `color-scheme` | Нативная светлая/темная схема браузера: `dark` или `light`. | +| `style_preset` | CSS-класс пресета | Сейчас `win95`/`windows95` добавляет `theme-preset-win95`; остальные значения не дают специального класса. | +| `accent` | `--accent` | Главный акцент: активные элементы, кнопки, прогресс, фокус. Только hex `#RGB` или `#RRGGBB`. | +| `bg` | `--bg` | Основной фон приложения. | +| `panel` | `--panel` | Основные карточки и поверхности. | +| `panel_2` | `--panel-2` | Вторичные поверхности. | +| `panel_3` | `--panel-3` | Поверхности повышенной вложенности, dropdown/popover. | +| `border` | `--border` | Обычные границы. | +| `border_strong` | `--border-strong` | Усиленные границы и hover-состояния. | +| `text` | `--text` | Основной текст. | +| `muted` | `--muted` | Вторичный текст. | +| `dim` | `--dim` | Еще более тихий текст и служебные подписи. | +| `danger` | `--danger` | Ошибки и опасные действия. | +| `blue` | `--blue` | Синий вспомогательный цвет. | +| `radius` | `--radius` | Базовый радиус карточек, кнопок и контролов. | +| `font_sans` | `--font-sans` | Основной шрифт интерфейса. | +| `font_logo` | `--font-logo` | Шрифт бренда и заголовка. | +| `font_mono` | `--font-mono` | Моноширинный шрифт. | +| `home_logo_scale` | `--home-logo-scale` | Масштаб логотипа на главной и входе, от `50` до `300` процентов. | +| `admin_bg` | `--admin-bg` | Фон админ-панели. | +| `admin_surface` | `--admin-surface` | Основные карточки админки. | +| `admin_surface_2` | `--admin-surface-2` | Вторичные поверхности админки. | +| `admin_elev` | `--admin-elev` | Elevated-поверхности админки. | +| `admin_border` | `--admin-border` | Границы админки. | +| `admin_border_strong` | `--admin-border-strong` | Усиленные границы админки. | +| `admin_text` | `--admin-text` | Основной текст админки. | +| `admin_muted` | `--admin-muted` | Вторичный текст админки. | +| `admin_dim` | `--admin-dim` | Тихие подписи админки. | + +Если `css_file` не задан, интерфейс полностью строится на токенах и общих стилях. Если `css_file` задан, токены все равно применяются первыми, а CSS темы может уточнить или полностью переопределить внешний вид. + +## CSS-слой темы + +CSS темы подключается как: + +```text +/webapp-theme-css// +``` + +Например `data/themes/neon/style.css` будет доступен как `/webapp-theme-css/neon/style.css`. + +Корневой контейнер получает классы: + +```text +app-shell theme-dark theme-key-neon theme-css-style +``` + +Для светлой схемы будет `theme-light`. Класс `theme-key-` - основной якорь для CSS темы. Всегда начинайте селекторы с него, чтобы тема не задевала другие режимы: + +```css +.theme-key-neon.app-shell { + --surface-sheen: rgba(168, 85, 247, 0.12); + --shadow-soft: 0 18px 48px rgba(12, 5, 30, 0.44); +} + +.theme-key-neon .card { + border-color: color-mix(in srgb, var(--accent) 34%, var(--border)); + background: + linear-gradient(135deg, rgba(168, 85, 247, 0.14), rgba(56, 189, 248, 0.05)), + var(--panel); +} + +.theme-key-neon .bottom-nav button.active, +.theme-key-neon .admin-nav-item.active { + box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 24%, transparent); +} +``` + +CSS можно писать для пользовательской части и админки одновременно: + +```css +.theme-key-neon .period-card, +.theme-key-neon .method-card, +.theme-key-neon .option-row { + border-radius: 16px; +} + +.theme-key-neon .admin-card, +.theme-key-neon .admin-stat-card, +.theme-key-neon .admin-revenue-panel { + border-radius: 16px; +} +``` + +Ограничения: + +- CSS-файл должен быть внутри папки темы; +- размер CSS - до 512 KiB; +- путь не может содержать `..`; +- удаленные CSS, `data:` и protocol-relative URL в `css_file` не подключаются. + +## Ассеты темы + +Картинки темы кладутся рядом с `theme.json` и отдаются через: + +```text +/webapp-theme-assets// +``` + +Пример: + +```css +.theme-key-neon .btn-primary::before { + content: ""; + width: 16px; + height: 16px; + background: url("/webapp-theme-assets/neon/icons/spark.png") center / contain no-repeat; +} +``` + +Разрешены `png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`, `ico`. Один asset - до 1 MiB. Для шрифтов лучше использовать внешние источники, уже разрешенные CSP (`fonts.googleapis.com`, `fonts.gstatic.com`, `cdn.jsdelivr.net`) или системные fallback-цепочки в `font_*` токенах. + +## Пайплайн создания новой темы + +1. Выберите ключ темы. + + Ключ должен быть стабильным: по нему сохраняется выбранная тема и строятся URL ассетов. Используйте короткий slug: `neon`, `brand_dark`, `terminal-blue`. Не переименовывайте ключ после публикации без миграции файлов и сохраненных настроек. + +2. Создайте папку в `WEBAPP_THEMES_DIR`. + + В Docker по умолчанию это `data/themes`. Если включен bind mount `./data:/app/data`, убедитесь, что контейнер может писать в `data`. + + ```bash + mkdir -p data/themes/neon + ``` + +3. Скопируйте ближайшую базовую тему. + + Для обычной брендовой темы чаще всего удобнее начать с `dark` или `light`. Для глубокого CSS-скина можно взять `ascii` или `windows95` как пример того, насколько далеко можно уйти от стандартного вида. + + ```bash + cp backend/bot/app/web/themes/dark/theme.json data/themes/neon/theme.json + ``` + +4. Отредактируйте `theme.json`. + + Сначала поменяйте `key`, `names`, `default`, `use_primary_accent` и базовые токены. На этом этапе можно вообще не создавать CSS: приложение уже увидит тему как новый набор токенов. + +5. Запустите приложение и откройте админку. + + Раздел **Внешний вид** загружает `/api/admin/themes`, backend читает `WEBAPP_THEMES_DIR`, добавляет обязательные базовые темы и возвращает каталог. Нажмите **Обновить**, если папка была создана во время работы приложения. + +6. Проверьте тему через предпросмотр. + + В карточке темы нажмите **Предпросмотр** или откройте: + + ```text + https://app.domain.com/home?theme_preview=neon + ``` + + Предпросмотр не меняет глобальную тему и удобен для проверки CSS до публикации. + +7. Подберите accent и масштаб логотипа. + + В админке можно менять accent и `home_logo_scale` без ручного редактирования JSON. При сохранении backend перепишет `theme.json` в `WEBAPP_THEMES_DIR`, выставит ровно один `default` и сбросит кеш публичных настроек. + +8. Добавьте `style.css`, если токенов мало. + + Создайте файл, укажите его в `theme.json`: + + ```json + { + "css_file": "style.css" + } + ``` + + Начинайте с переопределения CSS-переменных на `.theme-key-neon.app-shell`, затем переходите к конкретным компонентам. Проверяйте минимум: главная, оплата, настройки, модалки, админский дашборд, таблица пользователей, редактор тарифов. + +9. Добавьте ассеты при необходимости. + + Положите картинки в подпапку темы и ссылайтесь на них через `/webapp-theme-assets//...`. Не используйте относительные пути вроде `url("icons/x.png")`, если CSS может быть подключен с другого URL-уровня; явный `/webapp-theme-assets/neon/icons/x.png` надежнее. + +10. Настройте поведение админки. + + Если тема сильно декоративная и мешает рабочей админке, выставьте `use_in_admin: false`. Пользователи увидят тему, а администраторы в разделе админки получат `dark` как fallback. + +11. Сделайте тему дефолтной. + + Есть два способа: + + - в админке выбрать тему и сохранить; + - указать `WEBAPP_DEFAULT_THEME=neon` в `.env`, если нужен жесткий override на уровне окружения. + +12. Зафиксируйте тему. + + Для темы, которая должна ехать вместе с проектом, добавьте ее в репозиторий в `backend/bot/app/web/themes` и при необходимости расширьте `DEFAULT_THEME_KEYS` в `backend/config/webapp_themes_config.py`. Для приватной инсталляции достаточно хранить ее в `data/themes`. + +## Насколько глубоко можно менять вид + +Уровни кастомизации: + +1. **Быстрый бренд** - токены `accent`, `bg`, `panel`, `text`, `radius`, логотип в админке. Код не нужен. +2. **Полная палитра** - все пользовательские и admin-токены, отдельные шрифты, масштаб логотипа. +3. **CSS-скин** - переопределение карточек, навигации, таблиц, модалок, progress/skeleton/toast, desktop/mobile раскладок. +4. **Почти новый UI** - тема вроде `windows95` или `ascii`: можно менять форму контролов, иконки, эффекты, таблицы и визуальный язык целиком, пока сохраняется DOM и интерактивные состояния. + +Не стоит менять через CSS смысловые состояния: скрывать ошибки, отключать фокус, перекрывать кнопки невидимыми слоями или делать `display: none` для обязательных действий оплаты и авторизации. Тема должна менять внешний вид, а не бизнес-логику. + +## Диагностика + +Если тема не появилась: + +- проверьте, что `theme.json` лежит ровно в `WEBAPP_THEMES_DIR//theme.json`; +- ключ состоит только из латиницы, цифр, `_` и `-`; +- JSON валиден; +- тема не отключена через `enabled: false`; +- в логах нет предупреждения `Ignoring theme descriptor`. + +Если CSS не применился: + +- проверьте `css_file` и URL `/webapp-theme-css//`; +- убедитесь, что файл меньше 512 KiB; +- начинайте селекторы с `.theme-key-`; +- откройте `/home?theme_preview=` в новом окне, чтобы исключить сохраненный старый выбор. + +Если ассеты не грузятся: + +- используйте путь `/webapp-theme-assets//`; +- проверьте расширение: `png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`, `ico`; +- размер каждого файла должен быть до 1 MiB; +- путь не должен содержать пробелы, кириллицу или `..`. diff --git a/docs/webapp-themes.webp b/docs/webapp-themes.webp new file mode 100644 index 0000000..acd61d4 Binary files /dev/null and b/docs/webapp-themes.webp differ diff --git a/docs/webapp.md b/docs/webapp.md index b018d64..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 @@ -24,10 +24,8 @@ WEBAPP_SERVER_HOST=0.0.0.0 WEBAPP_SERVER_PORT=8081 SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/ WEBAPP_TITLE="Моя подписка" -WEBAPP_PRIMARY_COLOR="#00fe7a" -WEBAPP_LOGO_URL= -WEBAPP_LOGO_EMOJI="🫥" -WEBAPP_LOGO_EMOJI_FONT=system +WEBAPP_THEMES_DIR=data/themes +WEBAPP_DEFAULT_THEME= WEBAPP_SESSION_SECRET= WEBHOOK_SECRET_TOKEN= WEBAPP_SESSION_TTL_SECONDS=86400 @@ -49,7 +47,9 @@ SMTP_FROM_EMAIL=no-reply@domain.com SMTP_FROM_NAME=Remnawave Minishop ``` -Если `WEBAPP_LOGO_URL` пустой, в шапке и на экране входа показывается запасной **emoji-логотип** (`WEBAPP_LOGO_EMOJI`) и при необходимости стиль отрисовки (`WEBAPP_LOGO_EMOJI_FONT`: например `system`, `noto-color`, `noto-color-animated`, `twemoji`). Если SMTP-настройки не заполнены, вход по email скрывается. +Внешний вид настраивается в админке: раздел **Внешний вид** управляет логотипом, emoji-логотипом, accent-цветом, выбранной темой и масштабом логотипа. Кастомные темы читаются из `WEBAPP_THEMES_DIR`, а `WEBAPP_DEFAULT_THEME` может принудительно выбрать тему по ключу. Подробный контракт `theme.json`, CSS/asset-роуты и пайплайн создания темы описаны в [webapp-themes.md](webapp-themes.md). + +Если SMTP-настройки не заполнены, вход по email скрывается. ## Telegram-авторизация @@ -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 92% rename from bot/app/web/frontend/src/App.svelte rename to frontend/src/App.svelte index 66b2c3a..022223c 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -40,6 +40,13 @@ import { normalizedEmail, telegramName } from "./lib/webapp/formatters.js"; import { activeTariffName, buildTariffCatalog } from "./lib/webapp/tariffs.js"; import { premiumTrafficPercent, trafficPercent } from "./lib/webapp/traffic.js"; + import { + findThemeEntry, + resolveEffectiveThemeKey, + themeCssHref, + themeEntryToInlineStyle, + themeRootClass, + } from "./lib/webapp/themeStyle.js"; /** Used-traffic percent from which top-up modals and CTAs unlock in the web app home screen */ const TRAFFIC_TOPUP_UNLOCK_PERCENT = 80; @@ -80,6 +87,7 @@ ...(MOCK ? MOCK.config : {}), ...(injectedConfig || {}), }; + const themePreviewKey = String(CFG.themePreviewKey || query.get("theme_preview") || "").trim(); const I18N = injectedI18n || {}; let telegramSdkStatus = "idle"; let telegramMiniAppInitData = ""; @@ -239,11 +247,14 @@ $: brandEmojiFont = CFG.logoEmojiFont || "system"; $: brand = normalizeBrand({ title: brandTitle, - logoUrl: CFG.logoUrl, + logoUrl: CFG.logoUseEmoji ? "" : CFG.logoUrl, emoji: brandEmoji, emojiFont: brandEmojiFont, }); - $: accent = CFG.primaryColor || "#00fe7a"; + $: faviconBrand = normalizeBrand({ + ...brand, + logoUrl: String(CFG.faviconUrl || "").trim() || brand.logoUrl, + }); $: plans = data?.plans?.length ? data.plans : DEV_MOCK.data.plans; $: methods = data?.payment_methods?.length ? data.payment_methods : []; $: appSettings = data?.settings || DEV_MOCK.data.settings; @@ -300,6 +311,30 @@ premiumTrafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT) ); $: user = data?.user || {}; + $: themesCatalog = data?.themes_catalog || + CFG.themesCatalog || { default_theme: "dark", themes: [] }; + $: previewThemeAllowed = Boolean(themePreviewKey && (!data?.user || user?.is_admin)); + $: previewThemeEntry = previewThemeAllowed + ? findThemeEntry(themesCatalog, themePreviewKey) + : null; + $: resolvedThemeKey = previewThemeEntry?.key || resolveEffectiveThemeKey(themesCatalog); + $: activeThemeEntry = findThemeEntry(themesCatalog, resolvedThemeKey); + $: darkThemeEntry = findThemeEntry(themesCatalog, "dark"); + $: effectiveThemeEntry = + screen === "admin" && activeThemeEntry?.use_in_admin === false + ? darkThemeEntry || activeThemeEntry + : activeThemeEntry; + $: shellStyle = themeEntryToInlineStyle(effectiveThemeEntry, CFG.primaryColor); + $: shellToneClass = + effectiveThemeEntry?.tokens?.color_scheme === "light" ? "theme-light" : "theme-dark"; + $: shellThemeClass = themeRootClass(effectiveThemeEntry); + $: shellThemeCssHref = themeCssHref(effectiveThemeEntry); + $: if (typeof document !== "undefined" && effectiveThemeEntry?.tokens) { + const scheme = effectiveThemeEntry.tokens.color_scheme || "dark"; + document.documentElement.style.colorScheme = scheme; + const bg = effectiveThemeEntry.tokens.bg; + if (bg) document.body.style.backgroundColor = bg; + } $: isAdmin = Boolean(user?.is_admin); $: if (screen === "admin" && !isAdmin) { screen = "settings"; @@ -346,7 +381,7 @@ : telegramLoginUnavailable ? t("wa_auth_telegram_not_configured") : ""; - $: applyFavicon(brand); + $: applyFavicon(faviconBrand); $: syncBodyScrollLock( paymentModalOpen || changeModalOpen || @@ -844,13 +879,35 @@ ); } - async function handleAdminPersistedSaved() { + function adminPayloadHasLogoChange(options = {}) { + const keys = new Set([ + ...Object.keys(options.updates || {}), + ...(Array.isArray(options.deletes) ? options.deletes : []), + ]); + return [ + "WEBAPP_LOGO_URL", + "WEBAPP_LOGO_USE_EMOJI", + "WEBAPP_LOGO_EMOJI", + "WEBAPP_LOGO_EMOJI_FONT", + "WEBAPP_FAVICON_URL", + "WEBAPP_FAVICON_USE_CUSTOM", + "WEBAPP_LOGO_FAVICON_URL", + ].some((key) => keys.has(key)); + } + + async function handleAdminPersistedSaved(options = {}) { invalidateWebappTariffOptionCaches(billingStore); try { await loadData(); } catch { // Admin save already succeeded; a later full refresh will pick up new settings or catalog. } + const shouldReloadFrontend = + options?.reloadFrontend === true || + (!options?.deferFrontendReload && adminPayloadHasLogoChange(options)); + if (shouldReloadFrontend && typeof window !== "undefined") { + window.location.reload(); + } } function selectTariff(tariff) { @@ -873,6 +930,9 @@ {brandTitle} + {#if shellThemeCssHref} + + {/if} @@ -880,7 +940,7 @@ {#if isPreviewBoard} {:else} -
+
{#if mode === "loading"}
@@ -931,8 +991,11 @@ onSectionChange={handleAdminSectionChange} onSettingsSaved={handleAdminPersistedSaved} onTariffsSaved={handleAdminPersistedSaved} + onThemesSaved={handleAdminPersistedSaved} {brandTitle} {brand} + appFaviconUrl={CFG.faviconUrl} + appFaviconUseCustom={CFG.faviconUseCustom} appVersion={CFG.appVersion} appRepositoryUrl={CFG.appRepositoryUrl} {currentLang} 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 95% rename from bot/app/web/frontend/src/admin/AdminPanel.svelte rename to frontend/src/admin/AdminPanel.svelte index 14fa264..dac7d27 100644 --- a/bot/app/web/frontend/src/admin/AdminPanel.svelte +++ b/frontend/src/admin/AdminPanel.svelte @@ -11,6 +11,7 @@ LayoutDashboard, Megaphone, Menu, + Paintbrush, Plus, RefreshCw, Save, @@ -34,6 +35,7 @@ import StatsSection from "./sections/StatsSection.svelte"; import TariffEditorModal from "./sections/TariffEditorModal.svelte"; import TariffsSection from "./sections/TariffsSection.svelte"; + import AppearanceSection from "./sections/AppearanceSection.svelte"; import UserDetailModal from "./sections/UserDetailModal.svelte"; import UsersSection from "./sections/UsersSection.svelte"; import { createAdsStore } from "../lib/admin/stores/adsStore.js"; @@ -44,6 +46,7 @@ import { createSettingsStore } from "../lib/admin/stores/settingsStore.js"; import { createStatsStore } from "../lib/admin/stores/statsStore.js"; import { createTariffsStore } from "../lib/admin/stores/tariffsStore.js"; + import { createThemesStore } from "../lib/admin/stores/themesStore.js"; import { createUsersStore } from "../lib/admin/stores/usersStore.js"; import { fmtDate, @@ -70,8 +73,11 @@ export let onSectionChange = () => {}; export let onSettingsSaved = () => {}; export let onTariffsSaved = () => {}; + export let onThemesSaved = () => {}; export let brand = {}; export let brandTitle = "/minishop"; + export let appFaviconUrl = ""; + export let appFaviconUseCustom = false; export let appVersion = "dev+local"; export let appRepositoryUrl = "https://github.com/3252a8/remnawave-minishop"; export let currentLang = "ru"; @@ -111,6 +117,7 @@ label: at("nav_system", {}, "Система"), items: [ { id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins }, + { id: "appearance", label: at("nav_appearance", {}, "Внешний вид"), icon: Paintbrush }, { id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders }, ], }, @@ -153,6 +160,10 @@ title: at("section_tariffs_title", {}, "Тарифы"), subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты"), }, + appearance: { + title: at("section_appearance_title", {}, "Внешний вид"), + subtitle: at("section_appearance_subtitle", {}, "Логотип, темы и акцентные цвета Mini App"), + }, settings: { title: at("section_settings_title", {}, "Настройки приложения"), subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"), @@ -196,6 +207,7 @@ const settingsStore = createSettingsStore({ api, onToast: flash, at }); const statsStore = createStatsStore({ api, onToast: flash, at }); const tariffsStore = createTariffsStore({ api, onToast: flash, onTariffsSaved, flash, at }); + const themesStore = createThemesStore({ api, onThemesSaved, flash, at }); const usersStore = createUsersStore({ api, onToast: flash, at }); setContext("promosStore", promosStore); @@ -207,6 +219,7 @@ setContext("settingsStore", settingsStore); setContext("usersStore", usersStore); setContext("tariffsStore", tariffsStore); + setContext("themesStore", themesStore); $: usersStore.setActive(active); $: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length; @@ -635,6 +648,17 @@ {/if} + {#if active === "appearance"} + + {/if} + {#if active === "settings"} {/if} 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/frontend/src/admin/sections/AppearanceSection.svelte b/frontend/src/admin/sections/AppearanceSection.svelte new file mode 100644 index 0000000..b856842 --- /dev/null +++ b/frontend/src/admin/sections/AppearanceSection.svelte @@ -0,0 +1,1015 @@ + + +{#if themesLoading || settingsLoading} + {at("loading", {}, "Загрузка…")} +{:else} +
+
+
+
+

{at("appearance_brand_title", {}, "Логотип")}

+ {at( + "appearance_brand_sub", + {}, + "Файл, ссылка или подтвержденный emoji-логотип" + )} +
+
+ {#if dirtyCount} + + {at("settings_dirty_count", { count: dirtyCount }, `Изменений: ${dirtyCount}`)} + + {/if} + + + {settingsSaving || themesSaving + ? at("btn_saving", {}, "Сохранение...") + : at("btn_save", {}, "Сохранить")} + +
+
+
+
+ {#if !useEmojiLogo && previewLogoUrl && !logoPreviewFailed} + { + logoPreviewFailed = true; + }} + /> + {:else if useEmojiLogo} + + {:else} + + {/if} +
+ +
+
+ + logoFileInput?.click()} + disabled={themesSaving} + > + + {at("appearance_logo_upload_file", {}, "Загрузить файл")} + +
+ + + {at("appearance_logo_upload_url", {}, "По ссылке")} + +
+
+ +
+ +
+ + setAppearanceValue("WEBAPP_LOGO_EMOJI", event.currentTarget.value)} + /> + setAppearanceValue("WEBAPP_LOGO_EMOJI_FONT", value)} + /> +
+
+
+
+ +
+
+ {#if previewFaviconUrl && !faviconPreviewFailed} + { + faviconPreviewFailed = true; + }} + /> + {:else if !useCustomFavicon && useEmojiLogo} + + {:else} + + {/if} +
+ +
+
+ + + faviconFileInput?.click()} + disabled={themesSaving} + > + + {at("appearance_favicon_upload_file", {}, "Загрузить favicon")} + +
+ + + {at("appearance_favicon_upload_url", {}, "По ссылке")} + +
+
+
+
+
+ +
+
+
+

{at("appearance_themes_title", {}, "Темы")}

+ {at( + "appearance_themes_sub", + {}, + "Глобальная тема, accent color и предпросмотр" + )} +
+
+ + + {at("btn_refresh", {}, "Обновить")} + + + + {at("btn_save", {}, "Сохранить")} + +
+
+
+ {#if !themesCatalog.themes.length} + + {at( + "themes_catalog_empty", + {}, + "Каталог пуст. Добавьте папку темы в data/themes и обновите список." + )} + + {:else} +
+ {#each themesCatalog.themes as theme (theme.key)} + {@const isCurrent = theme.key === activeKey} +
selectTheme(theme, event)} + onkeydown={(event) => handleThemeKeydown(event, theme)} + > + + + {themeTitle(theme)} + {#if isCurrent} + {at("status_current", {}, "Текущая")} + {/if} + + {theme.key} + + + + {themeDescription(theme)} + + + + +
+ previewTheme(event, theme)} + > + + {at("appearance_preview_theme", {}, "Предпросмотр")} + +
+ +
+ {/each} +
+ {/if} +
+
+
+{/if} + + 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 95% rename from bot/app/web/frontend/src/admin/sections/SettingsSection.svelte rename to frontend/src/admin/sections/SettingsSection.svelte index 893157b..a26d1a5 100644 --- a/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte +++ b/frontend/src/admin/sections/SettingsSection.svelte @@ -17,28 +17,32 @@ const settingsStore = getContext("settingsStore"); $: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore); + $: visibleSettingsSections = settingsSections.filter((section) => section.id !== "appearance"); let settingsOpenSections = []; let settingsOpenSubsections = {}; let revealedSecrets = new Set(); $: settingsAllOpen = - settingsSections.length > 0 && settingsOpenSections.length === settingsSections.length; + visibleSettingsSections.length > 0 && + settingsOpenSections.length === visibleSettingsSections.length; onMount(() => { settingsStore.loadSettings().then(() => { if ($settingsStore.settingsSections.length) { - const ids = $settingsStore.settingsSections.map((s) => s.id); + const ids = $settingsStore.settingsSections + .filter((s) => s.id !== "appearance") + .map((s) => s.id); settingsOpenSections = isCompact ? ids.slice(0, 1) : ids.slice(); } }); }); function toggleAllSections() { - if (settingsOpenSections.length === settingsSections.length) { + if (settingsOpenSections.length === visibleSettingsSections.length) { settingsOpenSections = []; } else { - settingsOpenSections = settingsSections.map((s) => s.id); + settingsOpenSections = visibleSettingsSections.map((s) => s.id); } } @@ -229,7 +233,7 @@
{/snippet} -{#if settingsLoading || !settingsSections.length} +{#if settingsLoading || !visibleSettingsSections.length} {settingsLoading ? at("loading", {}, "Загрузка…") @@ -265,7 +269,7 @@
- {#each settingsSections as section} + {#each visibleSettingsSections as section} {@const dirtyInSection = section.fields.filter((f) => Boolean(settingsDirty[f.key])).length} {@const overriddenInSection = section.fields.filter((f) => isOverridden(f)).length} 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 80% rename from bot/app/web/frontend/src/lib/admin/stores/settingsStore.js rename to frontend/src/lib/admin/stores/settingsStore.js index 448d558..e74c3e1 100644 --- a/bot/app/web/frontend/src/lib/admin/stores/settingsStore.js +++ b/frontend/src/lib/admin/stores/settingsStore.js @@ -38,13 +38,30 @@ export function createSettingsStore({ api, onToast, at }) { }); } + function setFieldValue(key, value) { + state.update((s) => { + const nextDirty = { ...s.settingsDirty }; + delete nextDirty[key]; + return { + ...s, + settingsDirty: nextDirty, + settingsSections: (s.settingsSections || []).map((section) => ({ + ...section, + fields: (section.fields || []).map((field) => + field.key === key ? { ...field, value, overridden: true } : field + ), + })), + }; + }); + } + async function saveSettings(onSettingsSaved) { let dirty = {}; state.update((s) => { dirty = s.settingsDirty; return s; }); - if (!Object.keys(dirty).length) return; + if (!Object.keys(dirty).length) return true; state.update((s) => ({ ...s, settingsSaving: true })); try { @@ -63,6 +80,7 @@ export function createSettingsStore({ api, onToast, at }) { state.update((s) => ({ ...s, settingsDirty: {} })); if (onSettingsSaved) await onSettingsSaved({ updates, deletes }); await loadSettings(); + return true; } else if (res?.errors) { const summary = Object.entries(res.errors) .map(([k, v]) => `${k}: ${v}`) @@ -71,6 +89,7 @@ export function createSettingsStore({ api, onToast, at }) { } else { onToast(res?.error || "Ошибка"); } + return false; } finally { state.update((s) => ({ ...s, settingsSaving: false })); } @@ -91,6 +110,7 @@ export function createSettingsStore({ api, onToast, at }) { loadSettings, markDirty, clearDirty, + setFieldValue, resetField, saveSettings, }; 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/frontend/src/lib/admin/stores/themesStore.js b/frontend/src/lib/admin/stores/themesStore.js new file mode 100644 index 0000000..25b7b41 --- /dev/null +++ b/frontend/src/lib/admin/stores/themesStore.js @@ -0,0 +1,280 @@ +function cloneCatalog(catalog) { + return JSON.parse(JSON.stringify(catalog || { default_theme: "dark", themes: [] })); +} + +import { writable } from "svelte/store"; + +export function createThemesStore({ api, onThemesSaved, flash, at }) { + const state = writable({ + themesCatalog: { default_theme: "dark", themes: [] }, + themesDir: "", + themesLoading: false, + themesSaving: false, + }); + + async function loadThemes() { + state.update((s) => ({ ...s, themesLoading: true })); + try { + const data = await api("/admin/themes"); + if (data?.ok) { + state.update((s) => ({ + ...s, + themesCatalog: cloneCatalog(data.catalog), + themesDir: data.themes_dir || "", + })); + } else { + flash(data?.message || data?.error || at("load_failed", {}, "Не удалось загрузить темы")); + } + } finally { + state.update((s) => ({ ...s, themesLoading: false })); + } + } + + async function saveThemes(options = {}) { + const silent = Boolean(options.silent); + let catalog = null; + state.update((s) => { + catalog = cloneCatalog(s.themesCatalog); + return { ...s, themesSaving: true }; + }); + try { + const data = await api("/admin/themes", { + method: "PUT", + body: JSON.stringify({ catalog }), + }); + if (data?.ok) { + state.update((s) => ({ + ...s, + themesCatalog: cloneCatalog(data.catalog), + themesDir: data.themes_dir || s.themesDir, + })); + if (!silent) flash(at("themes_saved", {}, "Темы сохранены")); + if (typeof onThemesSaved === "function") onThemesSaved(); + } else { + flash(data?.message || data?.error || at("themes_save_failed", {}, "Не удалось сохранить")); + } + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + + async function uploadLogoFile(file) { + if (!file) return null; + state.update((s) => ({ ...s, themesSaving: true })); + try { + const body = new FormData(); + body.append("file", file); + const data = await api("/admin/appearance/logo", { + method: "POST", + body, + }); + if (data?.ok) { + flash( + at( + "appearance_logo_uploaded_pending", + {}, + "Логотип загружен и применен." + ) + ); + return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" }; + } + flash( + data?.message || + data?.error || + at("appearance_logo_upload_failed", {}, "Не удалось загрузить логотип") + ); + return null; + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + + async function uploadLogoUrl(url) { + const sourceUrl = String(url || "").trim(); + if (!sourceUrl) return null; + state.update((s) => ({ ...s, themesSaving: true })); + try { + const data = await api("/admin/appearance/logo", { + method: "POST", + body: JSON.stringify({ url: sourceUrl }), + }); + if (data?.ok) { + flash( + at( + "appearance_logo_uploaded_pending", + {}, + "Логотип загружен и применен." + ) + ); + return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" }; + } + flash( + data?.message || + data?.error || + at("appearance_logo_upload_failed", {}, "Не удалось загрузить логотип") + ); + return null; + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + + async function uploadFaviconFile(file) { + if (!file) return null; + state.update((s) => ({ ...s, themesSaving: true })); + try { + const body = new FormData(); + body.append("file", file); + const data = await api("/admin/appearance/favicon", { + method: "POST", + body, + }); + if (data?.ok) { + flash( + at( + "appearance_favicon_uploaded_pending", + {}, + "Favicon загружена и применена." + ) + ); + return { faviconUrl: data.favicon_url || "", variants: data.variants || {} }; + } + flash( + data?.message || + data?.error || + at("appearance_favicon_upload_failed", {}, "Не удалось загрузить favicon") + ); + return null; + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + + async function uploadFaviconUrl(url) { + const sourceUrl = String(url || "").trim(); + if (!sourceUrl) return null; + state.update((s) => ({ ...s, themesSaving: true })); + try { + const data = await api("/admin/appearance/favicon", { + method: "POST", + body: JSON.stringify({ url: sourceUrl }), + }); + if (data?.ok) { + flash( + at( + "appearance_favicon_uploaded_pending", + {}, + "Favicon загружена и применена." + ) + ); + return { faviconUrl: data.favicon_url || "", variants: data.variants || {} }; + } + flash( + data?.message || + data?.error || + at("appearance_favicon_upload_failed", {}, "Не удалось загрузить favicon") + ); + return null; + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + + function setCurrentTheme(key) { + state.update((s) => ({ + ...s, + themesCatalog: { + ...s.themesCatalog, + default_theme: key, + themes: (s.themesCatalog.themes || []).map((theme) => ({ + ...theme, + default: theme.key === key, + })), + }, + })); + } + + function togglePrimaryAccent(key, enabled) { + state.update((s) => ({ + ...s, + themesCatalog: { + ...s.themesCatalog, + themes: (s.themesCatalog.themes || []).map((theme) => + theme.key === key ? { ...theme, use_primary_accent: Boolean(enabled) } : theme + ), + }, + })); + } + + function toggleAdminUse(key, enabled) { + state.update((s) => ({ + ...s, + themesCatalog: { + ...s.themesCatalog, + themes: (s.themesCatalog.themes || []).map((theme) => + theme.key === key ? { ...theme, use_in_admin: Boolean(enabled) } : theme + ), + }, + })); + } + + function setThemeAccent(key, accent) { + state.update((s) => ({ + ...s, + themesCatalog: { + ...s.themesCatalog, + themes: (s.themesCatalog.themes || []).map((theme) => + theme.key === key + ? { + ...theme, + tokens: { + ...(theme.tokens || {}), + accent: String(accent || "").trim() || null, + }, + } + : theme + ), + }, + })); + } + + function setThemeHomeLogoScale(key, scale) { + if (String(scale ?? "").trim() === "") scale = 100; + const numeric = Number(scale); + const nextScale = Number.isFinite(numeric) + ? Math.min(300, Math.max(50, Math.round(numeric))) + : 100; + state.update((s) => ({ + ...s, + themesCatalog: { + ...s.themesCatalog, + themes: (s.themesCatalog.themes || []).map((theme) => + theme.key === key + ? { + ...theme, + tokens: { + ...(theme.tokens || {}), + home_logo_scale: nextScale === 100 ? null : nextScale, + }, + } + : theme + ), + }, + })); + } + + return { + subscribe: state.subscribe, + loadThemes, + saveThemes, + setCurrentTheme, + setThemeAccent, + setThemeHomeLogoScale, + togglePrimaryAccent, + toggleAdminUse, + uploadLogoFile, + uploadLogoUrl, + uploadFaviconFile, + uploadFaviconUrl, + }; +} 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 93% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte rename to frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte index 8bd3798..1f50900 100644 --- a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte +++ b/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte @@ -24,7 +24,8 @@ function readCssColor(name, fallback) { if (typeof document === "undefined") return fallback; - const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + const scope = hostEl || document.documentElement; + const raw = getComputedStyle(scope).getPropertyValue(name).trim(); return raw || fallback; } @@ -65,7 +66,11 @@ const muted = readCssColor("--admin-muted", "#9aa7a2"); const border = readCssColor("--admin-border", "rgba(255,255,255,0.12)"); const accent = readCssColor("--accent", "#00fe7a"); - const lineStroke = readCssColor("--admin-text", "#e8f0ec"); + const lineStroke = readCssColor( + "--admin-chart-stroke", + readCssColor("--admin-text", "#e8f0ec"), + ); + const lineFill = readCssColor("--admin-chart-fill", "rgba(120, 140, 132, 0.14)"); return { width: w, @@ -94,7 +99,7 @@ stroke: lineStroke, width: 2, cap: "round", - fill: "rgba(120, 140, 132, 0.14)", + fill: lineFill, }, ], axes: [ 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 98% rename from bot/app/web/frontend/src/lib/components/ui/icons.js rename to frontend/src/lib/components/ui/icons.js index b1f704d..21f1142 100644 --- a/bot/app/web/frontend/src/lib/components/ui/icons.js +++ b/frontend/src/lib/components/ui/icons.js @@ -35,6 +35,7 @@ export { Menu, MessageSquare, MousePointerClick, + Paintbrush, Plus, QrCode, Radio, 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 98% rename from bot/app/web/frontend/src/lib/webapp/BrandMark.svelte rename to frontend/src/lib/webapp/BrandMark.svelte index 1cdec74..cabedee 100644 --- a/bot/app/web/frontend/src/lib/webapp/BrandMark.svelte +++ b/frontend/src/lib/webapp/BrandMark.svelte @@ -33,6 +33,7 @@ export let emojiFont = ""; export let size = "sm"; export let animate = false; + export let fallbackEmoji = true; let className = ""; export { className as class }; @@ -206,7 +207,7 @@ clearLogoLoadTimeout(); }} /> - {:else if useAnimatedEmoji} + {:else if fallbackEmoji && useAnimatedEmoji} - {:else} + {:else if fallbackEmoji} theme.key)); + if (themeKeys.has(mode)) { + DEV_MOCK.config.themesCatalog.default_theme = mode; + DEV_MOCK.data.themes_catalog.default_theme = mode; + for (const theme of DEV_MOCK.config.themesCatalog.themes || []) { + theme.default = theme.key === mode; + } + for (const theme of DEV_MOCK.data.themes_catalog.themes || []) { + theme.default = theme.key === mode; + } + return; + } + if (mode === "traffic") { DEV_MOCK.data.settings.traffic_mode = true; DEV_MOCK.data.settings.trial_available = false; diff --git a/bot/app/web/frontend/src/lib/webapp/publicApi.js b/frontend/src/lib/webapp/publicApi.js similarity index 85% rename from bot/app/web/frontend/src/lib/webapp/publicApi.js rename to frontend/src/lib/webapp/publicApi.js index 69c0f57..01c5ee8 100644 --- a/bot/app/web/frontend/src/lib/webapp/publicApi.js +++ b/frontend/src/lib/webapp/publicApi.js @@ -8,6 +8,8 @@ export function createApiClient({ mockApi = null, getMockContext = () => ({}), } = {}) { + const isFormDataBody = (body) => typeof FormData !== "undefined" && body instanceof FormData; + async function api(path, options = {}) { if (mockApi) return mockApi(path, options, getMockContext()); @@ -18,7 +20,9 @@ export function createApiClient({ if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) { headers["X-CSRF-Token"] = csrf; } - if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json"; + if (options.body && !headers["Content-Type"] && !isFormDataBody(options.body)) { + headers["Content-Type"] = "application/json"; + } const response = await fetch(`${apiBase}${path}`, { ...options, 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/frontend/src/lib/webapp/themeStyle.js b/frontend/src/lib/webapp/themeStyle.js new file mode 100644 index 0000000..15fe42d --- /dev/null +++ b/frontend/src/lib/webapp/themeStyle.js @@ -0,0 +1,163 @@ +/** Maps JSON theme token keys to CSS custom properties used by the Mini App shell. */ + +const TOKEN_TO_CSS_VAR = { + accent: "--accent", + bg: "--bg", + panel: "--panel", + panel_2: "--panel-2", + panel_3: "--panel-3", + border: "--border", + border_strong: "--border-strong", + text: "--text", + muted: "--muted", + dim: "--dim", + danger: "--danger", + danger_text: "--danger-text", + danger_soft: "--danger-soft", + danger_border: "--danger-border", + success: "--success", + success_text: "--success-text", + success_soft: "--success-soft", + success_border: "--success-border", + warning: "--warning", + warning_text: "--warning-text", + warning_soft: "--warning-soft", + warning_border: "--warning-border", + info: "--info", + info_text: "--info-text", + info_soft: "--info-soft", + info_border: "--info-border", + blue: "--blue", + radius: "--radius", + font_sans: "--font-sans", + font_logo: "--font-logo", + font_mono: "--font-mono", + home_logo_scale: "--home-logo-scale", + admin_bg: "--admin-bg", + admin_surface: "--admin-surface", + admin_surface_2: "--admin-surface-2", + admin_elev: "--admin-elev", + admin_border: "--admin-border", + admin_border_strong: "--admin-border-strong", + admin_text: "--admin-text", + admin_muted: "--admin-muted", + admin_dim: "--admin-dim", +}; + +export function themeTokensToInlineStyle(tokens, primaryFallback = "#00fe7a", options = {}) { + const t = tokens && typeof tokens === "object" ? tokens : {}; + const parts = []; + const useFallbackAccent = options.fallbackAccent !== false; + const accent = t.accent || (useFallbackAccent ? primaryFallback || "#00fe7a" : ""); + if (accent) parts.push(`--accent:${accent}`); + for (const [key, cssVar] of Object.entries(TOKEN_TO_CSS_VAR)) { + if (key === "accent") continue; + const value = t[key]; + if (value === undefined || value === null || value === "") continue; + if (key === "home_logo_scale") { + const scale = Number(value); + if (!Number.isFinite(scale) || scale <= 0) continue; + parts.push(`${cssVar}:${scale / 100}`); + continue; + } + parts.push(`${cssVar}:${String(value)}`); + } + return parts.join(";"); +} + +export function findThemeEntry(themesCatalog, key) { + const themes = themesCatalog?.themes || []; + return themes.find((entry) => entry && entry.key === key) || null; +} + +export function resolveEffectiveThemeKey(themesCatalog) { + const themes = themesCatalog?.themes || []; + const byKey = (k) => themes.find((entry) => entry.key === k); + const def = themesCatalog?.default_theme || themes[0]?.key || "dark"; + return byKey(def) ? def : themes[0]?.key || "dark"; +} + +export function themePresetClass(tokens) { + const preset = String(tokens?.style_preset || "") + .trim() + .toLowerCase(); + if (!preset || preset === "none") return ""; + if (preset === "win95" || preset === "windows95") return "theme-preset-win95"; + return ""; +} + +export function themeKeyClass(key) { + const safe = String(key || "") + .trim() + .toLowerCase() + .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return safe ? `theme-key-${safe}` : ""; +} + +export function themeCssClass(cssFile) { + const filename = String(cssFile || "") + .replace(/\\/g, "/") + .split("/") + .filter(Boolean) + .pop(); + const slug = String(filename || "") + .replace(/\.css$/i, "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug ? `theme-css-${slug}` : ""; +} + +export function themeRootClass(theme) { + return [ + themeKeyClass(theme?.key), + themeCssClass(theme?.css_file), + themePresetClass(theme?.tokens), + ] + .filter(Boolean) + .join(" "); +} + +export function themeEntryToInlineStyle(theme, primaryFallback = "#00fe7a") { + return themeTokensToInlineStyle(theme?.tokens, primaryFallback, { + fallbackAccent: !theme?.css_file, + }); +} + +function encodeThemeCssPath(path) { + return String(path || "") + .replace(/\\/g, "/") + .split("/") + .filter(Boolean) + .map(encodeURIComponent) + .join("/"); +} + +export function themeCssHref(theme) { + const cssFile = String(theme?.css_file || "").trim(); + if (!cssFile) return ""; + if (/^(?:https?:)?\/\//i.test(cssFile) || cssFile.startsWith("data:")) return ""; + if (cssFile.startsWith("/")) return cssFile; + const normalizedCssFile = cssFile.replace(/\\/g, "/").split("/").filter(Boolean).join("/"); + const key = String(theme?.key || "") + .trim() + .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + const themedPath = + key && normalizedCssFile.split("/")[0] !== key + ? `${key}/${normalizedCssFile}` + : normalizedCssFile; + const encoded = encodeThemeCssPath(themedPath); + return encoded ? `/webapp-theme-css/${encoded}` : ""; +} + +export function localizedThemeName(theme, lang = "en") { + const names = theme?.names || {}; + const key = String(lang || "") + .trim() + .toLowerCase(); + const base = key.split("-")[0]; + return names[key] || names[base] || names.en || theme?.key || ""; +} 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 86% rename from bot/app/web/frontend/src/styles/admin-controls.css rename to frontend/src/styles/admin-controls.css index e69029f..3f22104 100644 --- a/bot/app/web/frontend/src/styles/admin-controls.css +++ b/frontend/src/styles/admin-controls.css @@ -113,7 +113,7 @@ .admin-btn.admin-btn-primary { background: var(--accent); - color: #02110a; + color: var(--accent-contrast); border-color: color-mix(in srgb, var(--accent) 70%, #000); font-weight: 600; } @@ -129,31 +129,31 @@ } .admin-btn.admin-btn-ghost:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.04); + background: var(--surface-hover); color: var(--admin-text); } .admin-btn.admin-btn-danger { - background: color-mix(in srgb, #ff5757 80%, #000); + background: color-mix(in srgb, var(--danger) 80%, #000); color: #fff; - border-color: color-mix(in srgb, #ff5757 60%, #000); + border-color: color-mix(in srgb, var(--danger) 60%, #000); font-weight: 600; } .admin-btn.admin-btn-danger:hover:not(:disabled) { - background: #ff5757; - border-color: #ff5757; + background: var(--danger); + border-color: var(--danger); } .admin-btn.admin-btn-danger-soft { - color: #ffb4b4; + color: var(--danger-text); background: var(--admin-surface-2); - border-color: color-mix(in srgb, #ff6b6b 30%, transparent); + border-color: var(--danger-border); } .admin-btn.admin-btn-danger-soft:hover:not(:disabled) { - background: color-mix(in srgb, #ff6b6b 14%, var(--admin-surface)); - border-color: color-mix(in srgb, #ff6b6b 50%, transparent); + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 50%, transparent); } .admin-btn.admin-btn-icon { @@ -183,21 +183,21 @@ } .admin-badge.admin-badge-success { - border-color: color-mix(in srgb, var(--accent) 36%, transparent); - color: var(--accent); - background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface)); + border-color: var(--success-border); + color: var(--success-text); + background: var(--success-soft); } .admin-badge.admin-badge-danger { - border-color: color-mix(in srgb, #ff6b6b 32%, transparent); - color: #ffb4b4; - background: color-mix(in srgb, #ff6b6b 12%, var(--admin-surface)); + border-color: var(--danger-border); + color: var(--danger-text); + background: var(--danger-soft); } .admin-badge.admin-badge-warning { - border-color: color-mix(in srgb, #ffd166 32%, transparent); - color: #ffd166; - background: color-mix(in srgb, #ffd166 12%, var(--admin-surface)); + border-color: var(--warning-border); + color: var(--warning-text); + background: var(--warning-soft); } .admin-badge.admin-badge-muted { @@ -270,7 +270,7 @@ .admin-extend-control .admin-btn.admin-btn-primary { background: var(--accent); - color: #02110a; + color: var(--accent-contrast); border-color: color-mix(in srgb, var(--accent) 70%, #000); font-weight: 600; } 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 96% rename from bot/app/web/frontend/src/styles/admin.css rename to frontend/src/styles/admin.css index 7790901..df35dd5 100644 --- a/bot/app/web/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -7,17 +7,14 @@ --admin-sidebar-w: var(--desktop-rail-width); --admin-header-h: 60px; --admin-card-bg: - linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)), - var(--admin-surface); - --admin-card-shadow: - 0 18px 48px rgba(0, 0, 0, 0.22), - inset 0 1px 0 rgba(255, 255, 255, 0.05); + linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--admin-surface); + --admin-card-shadow: var(--shadow-soft), inset 0 1px 0 var(--inset-highlight); position: fixed; inset: 0; width: 100vw; height: 100dvh; - background: #02070b; + background: var(--admin-bg); color: var(--admin-text); overflow: hidden; overscroll-behavior: contain; @@ -38,9 +35,9 @@ gap: 2px; padding: 28px 14px; border-right: 1px solid var(--admin-border); - background: rgba(7, 12, 17, 0.55); + background: var(--rail-bg); backdrop-filter: blur(14px); - box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.02); + box-shadow: inset -1px 0 0 var(--admin-border); overflow-y: auto; } @@ -74,7 +71,8 @@ color: var(--accent); font-size: 14px; font-weight: 800; - letter-spacing: -0.01em; + font-family: var(--font-logo); + letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; } @@ -115,7 +113,10 @@ text-align: left; width: 100%; cursor: pointer; - transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; + transition: + background 0.12s ease, + color 0.12s ease, + border-color 0.12s ease; } .admin-nav-item > svg { @@ -124,7 +125,7 @@ } .admin-nav-item:hover { - background: rgba(255, 255, 255, 0.04); + background: var(--surface-hover); color: var(--admin-text); } @@ -169,7 +170,7 @@ gap: 8px; border: 1px solid var(--admin-border); border-radius: 10px; - background: rgba(255, 255, 255, 0.035); + background: var(--surface-muted); color: var(--admin-text); padding: 8px 10px; text-align: left; @@ -251,7 +252,7 @@ gap: 16px; padding: 0 28px; border-bottom: 1px solid var(--admin-border); - background: color-mix(in srgb, #02070b 82%, transparent); + background: color-mix(in srgb, var(--admin-bg) 82%, transparent); backdrop-filter: blur(14px); } @@ -265,7 +266,7 @@ margin: 0; font-size: 16px; font-weight: 700; - letter-spacing: -0.01em; + letter-spacing: 0; } .admin-header-title small { @@ -427,7 +428,7 @@ .admin-stat-card .admin-stat-value { font-size: 26px; font-weight: 700; - letter-spacing: -0.02em; + letter-spacing: 0; color: var(--admin-text); } @@ -457,7 +458,7 @@ font-size: 14px; font-weight: 600; color: var(--admin-text); - letter-spacing: -0.01em; + letter-spacing: 0; } .admin-dashboard-section-head small { @@ -528,7 +529,7 @@ .admin-revenue-kpi-value { font-size: 18px; font-weight: 700; - letter-spacing: -0.02em; + letter-spacing: 0; color: var(--admin-text); line-height: 1.15; } @@ -545,11 +546,11 @@ } .admin-revenue-kpi-growth.is-up { - color: color-mix(in srgb, #3ecf8e 92%, var(--admin-text)); + color: var(--success-text); } .admin-revenue-kpi-growth.is-down { - color: color-mix(in srgb, #ff6b6b 88%, var(--admin-text)); + color: var(--danger-text); } .admin-revenue-chart { @@ -606,7 +607,7 @@ border: 1px solid var(--admin-border); background: var(--admin-surface); color: var(--admin-text); - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); + box-shadow: var(--shadow-popover); } .admin-revenue-range-popover__title { @@ -946,7 +947,7 @@ .admin-panel-dash-tile-value { font-size: 22px; font-weight: 700; - letter-spacing: -0.03em; + letter-spacing: 0; line-height: 1.1; color: color-mix(in srgb, var(--accent) 22%, var(--admin-text)); font-variant-numeric: tabular-nums; @@ -1248,7 +1249,7 @@ } .admin-table tbody tr:hover { - background: rgba(255, 255, 255, 0.035); + background: var(--surface-hover); } .admin-table td.admin-cell-mono { @@ -1531,7 +1532,9 @@ font-size: 13px; resize: vertical; outline: none; - transition: border-color 0.12s ease, box-shadow 0.12s ease; + transition: + border-color 0.12s ease, + box-shadow 0.12s ease; } .admin-textarea:focus, @@ -2085,11 +2088,11 @@ } .admin-traffic-card-premium { - border-color: color-mix(in srgb, var(--accent) 30%, var(--admin-border)); + border-color: var(--info-border); } .admin-traffic-card-warning { - border-color: color-mix(in srgb, #ffb454 46%, var(--admin-border)); + border-color: var(--warning-border); } .admin-traffic-head, @@ -2131,11 +2134,11 @@ } .admin-traffic-bar-premium span { - background: linear-gradient(90deg, #66e3ff, var(--accent)); + background: linear-gradient(90deg, var(--info), color-mix(in srgb, var(--info) 64%, #ffffff)); } .admin-traffic-card-warning .admin-traffic-bar span { - background: linear-gradient(90deg, #ffb454, #ff6b6b); + background: linear-gradient(90deg, var(--warning), var(--danger)); } .admin-traffic-meta { @@ -2357,7 +2360,7 @@ z-index: 80; transform: translateX(-100%); transition: transform 0.22s ease; - box-shadow: 0 32px 80px rgba(0, 0, 0, 0.6); + box-shadow: var(--shadow-strong); } .admin-screen-wrap.is-sidebar-open .admin-sidebar { @@ -2367,7 +2370,7 @@ .admin-sidebar-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.5); + background: var(--overlay-scrim); z-index: 70; border: 0; cursor: pointer; @@ -2501,7 +2504,9 @@ padding: 10px 12px; width: 100%; cursor: pointer; - transition: background 0.12s ease, border-color 0.12s ease; + transition: + background 0.12s ease, + border-color 0.12s ease; } .settings-row.settings-row-admin:hover { @@ -2560,7 +2565,10 @@ border-radius: 7px; cursor: pointer; white-space: nowrap; - transition: background 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; + transition: + background 0.12s ease, + color 0.12s ease, + box-shadow 0.12s ease; outline: none; } @@ -2571,7 +2579,7 @@ .admin-tabs-trigger[data-state="active"] { background: var(--admin-surface); color: var(--admin-text); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); + box-shadow: var(--shadow-soft); } .admin-tabs-trigger:focus-visible { @@ -2641,13 +2649,15 @@ border: 1px solid var(--admin-border); background: var(--admin-elev); cursor: pointer; - transition: background 0.18s ease, border-color 0.18s ease; + transition: + background 0.18s ease, + border-color 0.18s ease; outline: none; } .admin-switch-root[data-state="checked"] { - background: color-mix(in srgb, var(--accent) 80%, var(--admin-elev)); - border-color: color-mix(in srgb, var(--accent) 50%, transparent); + background: color-mix(in srgb, var(--success) 82%, var(--admin-elev)); + border-color: color-mix(in srgb, var(--success) 52%, transparent); } .admin-switch-root:focus-visible { @@ -2693,7 +2703,9 @@ cursor: pointer; text-align: left; outline: none; - transition: border-color 0.12s ease, box-shadow 0.12s ease; + transition: + border-color 0.12s ease, + box-shadow 0.12s ease; } .admin-select-trigger:hover { @@ -2724,10 +2736,9 @@ border: 1px solid var(--admin-border); border-radius: 10px; background: - linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)), - var(--admin-surface); + linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--admin-surface); padding: 4px; - box-shadow: 0 16px 36px rgba(0, 0, 0, 0.45); + box-shadow: var(--shadow-popover); outline: none; display: flex; flex-direction: column; @@ -3069,9 +3080,9 @@ } .admin-danger-zone { - border: 1px solid color-mix(in srgb, #ff6b6b 32%, transparent); + border: 1px solid var(--danger-border); border-radius: 12px; - background: color-mix(in srgb, #ff6b6b 6%, var(--admin-surface)); + background: var(--danger-soft); padding: 14px 16px; display: flex; flex-direction: column; @@ -3088,13 +3099,13 @@ .admin-danger-zone-head strong { font-size: 13px; font-weight: 700; - color: #ffb4b4; + color: var(--danger-text); text-transform: uppercase; letter-spacing: 0.04em; } .admin-danger-zone-head small { - color: color-mix(in srgb, #ffb4b4 70%, var(--admin-muted)); + color: color-mix(in srgb, var(--danger-text) 70%, var(--admin-muted)); font-size: 12px; line-height: 1.45; } @@ -3202,4 +3213,4 @@ grid-area: meta; white-space: normal; } -} \ No newline at end of file +} diff --git a/bot/app/web/frontend/src/styles/base.css b/frontend/src/styles/base.css similarity index 56% rename from bot/app/web/frontend/src/styles/base.css rename to frontend/src/styles/base.css index 199d5cc..a0015cf 100644 --- a/bot/app/web/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -4,21 +4,14 @@ format("woff2"); font-display: swap; unicode-range: - U+1F1E6-1F1FF, - U+1F3F4, - U+E0062-E0063, - U+E0065, - U+E0067, - U+E006C, - U+E006E, - U+E0073-E0074, - U+E0077, - U+E007F; + U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, + U+E0077, U+E007F; } :root { --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; --font-mono: "JetBrains Mono", "Fira Code", monospace; + --font-logo: var(--font-mono); color-scheme: dark; --accent: #00fe7a; --bg: #03070b; @@ -31,8 +24,36 @@ --muted: #a9b4b0; --dim: #68736f; --danger: #ff6b6b; + --danger-text: #ffb4b4; + --danger-soft: color-mix(in srgb, var(--danger) 12%, var(--panel)); + --danger-border: color-mix(in srgb, var(--danger) 34%, var(--border)); + --success: #3ecf8e; + --success-text: #7ff0b9; + --success-soft: color-mix(in srgb, var(--success) 12%, var(--panel)); + --success-border: color-mix(in srgb, var(--success) 34%, var(--border)); + --warning: #ffd166; + --warning-text: #ffd166; + --warning-soft: color-mix(in srgb, var(--warning) 12%, var(--panel)); + --warning-border: color-mix(in srgb, var(--warning) 34%, var(--border)); + --info: #2d9cff; + --info-text: #8ed0ff; + --info-soft: color-mix(in srgb, var(--info) 12%, var(--panel)); + --info-border: color-mix(in srgb, var(--info) 34%, var(--border)); --blue: #2d9cff; --radius: 8px; + --accent-contrast: #03100a; + --surface-sheen: rgba(255, 255, 255, 0.055); + --surface-sheen-soft: rgba(255, 255, 255, 0.018); + --surface-hover: rgba(255, 255, 255, 0.04); + --surface-muted: rgba(255, 255, 255, 0.035); + --surface-subtle-border: rgba(255, 255, 255, 0.1); + --overlay-scrim: rgba(0, 0, 0, 0.56); + --nav-bg: rgba(7, 12, 17, 0.88); + --rail-bg: rgba(7, 12, 17, 0.55); + --shadow-soft: 0 18px 48px rgba(0, 0, 0, 0.22); + --shadow-strong: 0 26px 70px rgba(0, 0, 0, 0.46); + --shadow-popover: 0 20px 34px rgba(0, 0, 0, 0.42); + --inset-highlight: rgba(255, 255, 255, 0.05); /* Admin design tokens — kept on :root so portal-rendered admin surfaces (dialogs, bits-ui Select.Portal content) inherit them. */ @@ -59,6 +80,10 @@ font-family: var(--font-sans); } +.theme-light { + color-scheme: light; +} + * { box-sizing: border-box; } @@ -72,7 +97,7 @@ body, } body { - background: #02070b; + background: var(--bg, #02070b); color: var(--text); -webkit-font-smoothing: antialiased; letter-spacing: 0; @@ -86,10 +111,10 @@ input { .app-shell { min-height: 100dvh; - background: #02070b !important; + background: var(--bg, #02070b) !important; } :root { --desktop-rail-width: 252px; --desktop-page-gutter: clamp(28px, 4vw, 72px); -} \ No newline at end of file +} diff --git a/bot/app/web/frontend/src/styles/components.css b/frontend/src/styles/components.css similarity index 86% rename from bot/app/web/frontend/src/styles/components.css rename to frontend/src/styles/components.css index 91d8c8c..bc75e07 100644 --- a/bot/app/web/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -26,11 +26,9 @@ border-radius: 12px; border: 1px solid var(--admin-border); background: - linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)), + linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--admin-surface); - box-shadow: - 0 18px 48px rgba(0, 0, 0, 0.22), - inset 0 1px 0 rgba(255, 255, 255, 0.05); + box-shadow: var(--shadow-soft), inset 0 1px 0 var(--inset-highlight); position: relative; overflow: hidden; } @@ -80,7 +78,7 @@ margin: 0; font-size: 1.5rem; font-weight: 600; - letter-spacing: -0.02em; + letter-spacing: 0; color: var(--admin-text); font-variant-numeric: tabular-nums; line-height: 1.15; @@ -89,7 +87,7 @@ .admin-cn-card-title--section { font-size: 1.05rem; font-weight: 600; - letter-spacing: -0.01em; + letter-spacing: 0; } .admin-cn-card-skeleton { @@ -173,15 +171,15 @@ } .admin-cn-badge-destructive { - background: color-mix(in srgb, var(--danger) 16%, var(--admin-surface)); - border-color: color-mix(in srgb, var(--danger) 35%, var(--admin-border)); - color: color-mix(in srgb, var(--danger) 92%, var(--admin-text)); + background: var(--danger-soft); + border-color: var(--danger-border); + color: var(--danger-text); } .admin-cn-badge-success { - background: color-mix(in srgb, var(--accent) 14%, var(--admin-surface)); - border-color: color-mix(in srgb, var(--accent) 32%, var(--admin-border)); - color: color-mix(in srgb, var(--accent) 88%, var(--admin-text)); + background: var(--success-soft); + border-color: var(--success-border); + color: var(--success-text); } .admin-cn-badge-muted { @@ -227,9 +225,9 @@ display: inline-block; background: linear-gradient( 90deg, - rgba(255, 255, 255, 0.08), - rgba(255, 255, 255, 0.14), - rgba(255, 255, 255, 0.08) + color-mix(in srgb, var(--muted) 10%, transparent), + color-mix(in srgb, var(--muted) 18%, transparent), + color-mix(in srgb, var(--muted) 10%, transparent) ); background-size: 220% 100%; animation: ui-skeleton-pulse 1.2s ease-in-out infinite; @@ -293,3 +291,4 @@ background-position: -120% 0; } } + diff --git a/bot/app/web/frontend/src/styles/controls.css b/frontend/src/styles/controls.css similarity index 94% rename from bot/app/web/frontend/src/styles/controls.css rename to frontend/src/styles/controls.css index 5aa9bee..0de8184 100644 --- a/bot/app/web/frontend/src/styles/controls.css +++ b/frontend/src/styles/controls.css @@ -23,7 +23,7 @@ button:disabled { text-align: center; text-decoration: none; cursor: pointer; - box-shadow: 0 10px 26px rgba(0, 0, 0, 0.18); + box-shadow: var(--shadow-soft); transition: transform 0.16s ease, border-color 0.16s ease, @@ -34,7 +34,7 @@ button:disabled { .btn:hover:not(:disabled) { transform: translateY(-1px); - box-shadow: 0 14px 32px rgba(0, 0, 0, 0.22); + box-shadow: var(--shadow-soft); } .btn:active:not(:disabled) { @@ -45,7 +45,7 @@ button:disabled { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent), - 0 14px 32px rgba(0, 0, 0, 0.22); + var(--shadow-soft); } .btn.wide, @@ -56,7 +56,7 @@ button:disabled { .btn-primary { border-color: color-mix(in srgb, var(--accent) 72%, white); background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 76%, white)); - color: #03100a; + color: var(--accent-contrast); } .btn-secondary { @@ -111,7 +111,7 @@ button:disabled { margin-top: 12px; overflow: hidden; border-radius: 999px; - background: rgba(255, 255, 255, 0.08); + background: color-mix(in srgb, var(--muted) 18%, transparent); } .progress span { diff --git a/bot/app/web/frontend/src/styles/dialogs.css b/frontend/src/styles/dialogs.css similarity index 92% rename from bot/app/web/frontend/src/styles/dialogs.css rename to frontend/src/styles/dialogs.css index f3ae77c..40cca5c 100644 --- a/bot/app/web/frontend/src/styles/dialogs.css +++ b/frontend/src/styles/dialogs.css @@ -26,7 +26,7 @@ inset: 0; z-index: 0; border: 0; - background: rgba(0, 0, 0, 0.56); + background: var(--overlay-scrim); backdrop-filter: blur(10px); cursor: pointer; } @@ -43,9 +43,9 @@ padding: 18px; border: 1px solid var(--border); border-radius: 24px; - background: color-mix(in srgb, var(--panel) 94%, #07111a); + background: color-mix(in srgb, var(--panel) 96%, var(--bg)); color: var(--text); - box-shadow: 0 26px 70px rgba(0, 0, 0, 0.46); + box-shadow: var(--shadow-strong); } .dialog-head { @@ -92,7 +92,7 @@ .payment-submit-button { border-color: color-mix(in srgb, var(--accent) 72%, transparent); background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 94%, white), var(--accent)); - color: #031009; + color: var(--accent-contrast); box-shadow: 0 16px 38px color-mix(in srgb, var(--accent) 28%, transparent), 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent), 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 87% rename from bot/app/web/frontend/src/styles/webapp.css rename to frontend/src/styles/webapp.css index 2561e4e..62a9001 100644 --- a/bot/app/web/frontend/src/styles/webapp.css +++ b/frontend/src/styles/webapp.css @@ -12,8 +12,8 @@ a { min-height: 100dvh; margin: 0 auto; overflow-x: hidden; - padding: - max(16px, env(safe-area-inset-top)) max(var(--screen-gutter), var(--safe-inline)) max(18px, env(safe-area-inset-bottom)) max(var(--screen-gutter), var(--safe-inline)); + padding: max(16px, env(safe-area-inset-top)) max(var(--screen-gutter), var(--safe-inline)) + max(18px, env(safe-area-inset-bottom)) max(var(--screen-gutter), var(--safe-inline)); } .content { @@ -30,12 +30,18 @@ a { display: grid; min-height: 100dvh; place-items: center; - align-content: center; + align-content: safe center; gap: 14px; color: var(--muted); font-weight: 800; } +.loader .brand-mark.brand-mark-lg { + width: calc(4.125rem * var(--home-logo-scale, 1)); + height: calc(4.125rem * var(--home-logo-scale, 1)); + font-size: calc(2.875rem * var(--home-logo-scale, 1)); +} + .app-header, .preview-header, .screen-head { @@ -94,7 +100,7 @@ a { color: var(--text); font-size: 15px; font-weight: 850; - font-family: var(--font-mono); + font-family: var(--font-logo); text-overflow: ellipsis; white-space: nowrap; } @@ -162,20 +168,19 @@ a { border: 1px solid var(--border); border-radius: var(--radius); background: - linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)), - var(--panel); + linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--panel); box-shadow: - 0 18px 48px rgba(0, 0, 0, 0.22), - inset 0 1px 0 rgba(255, 255, 255, 0.05); + var(--shadow-soft), + inset 0 1px 0 var(--inset-highlight); padding: 14px; } .card-active { border-color: color-mix(in srgb, var(--accent) 76%, var(--border)); box-shadow: - 0 18px 48px rgba(0, 0, 0, 0.22), + var(--shadow-soft), 0 0 0 1px color-mix(in srgb, var(--accent) 38%, transparent), - inset 0 1px 0 rgba(255, 255, 255, 0.05); + inset 0 1px 0 var(--inset-highlight); } .card-compact { @@ -189,7 +194,11 @@ a { .status-card-inactive { border-color: color-mix(in srgb, var(--danger) 58%, var(--border)); background: - linear-gradient(135deg, color-mix(in srgb, var(--danger) 14%, rgba(255, 255, 255, 0.03)), rgba(255, 255, 255, 0.012)), + linear-gradient( + 135deg, + color-mix(in srgb, var(--danger) 14%, var(--surface-sheen-soft)), + var(--surface-sheen-soft) + ), var(--panel); } @@ -392,7 +401,7 @@ a { gap: 6px; } -.premium-server-list>div { +.premium-server-list > div { display: flex; flex-wrap: wrap; gap: 6px; @@ -401,7 +410,7 @@ a { .premium-server-list span { max-width: 100%; padding: 4px 8px; - border: 1px solid rgba(255, 255, 255, 0.1); + border: 1px solid var(--surface-subtle-border); border-radius: 999px; color: var(--text); font-size: 11px; @@ -416,7 +425,7 @@ a { padding: 12px; } -.topup-summary-card>div:not(.premium-server-list) { +.topup-summary-card > div:not(.premium-server-list) { display: grid; gap: 3px; } @@ -440,7 +449,7 @@ a { gap: 11px; } -.trial-card-head>svg { +.trial-card-head > svg { flex: 0 0 auto; color: var(--accent); } @@ -475,7 +484,7 @@ a { gap: 11px; } -.devices-summary-head>svg { +.devices-summary-head > svg { color: var(--accent); } @@ -529,7 +538,7 @@ a { place-items: center; border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border)); border-radius: var(--radius); - background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.03)); + background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted)); color: var(--accent); } @@ -565,8 +574,8 @@ a { .device-disconnect-button, .device-danger-button { - border-color: color-mix(in srgb, var(--danger) 62%, var(--border)); - color: #ffb8b8; + border-color: var(--danger-border); + color: var(--danger-text); } .devices-empty-card { @@ -604,9 +613,9 @@ a { .settings-row { border: 1px solid var(--border); border-radius: var(--radius); - background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018)); + background: linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)); color: var(--text); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045); + box-shadow: inset 0 1px 0 var(--inset-highlight); } .period-card { @@ -652,10 +661,10 @@ a { .period-card.active, .method-card.active { border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035)); + background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 50%, transparent), - inset 0 1px 0 rgba(255, 255, 255, 0.06); + inset 0 1px 0 var(--inset-highlight); } .total-card { @@ -688,11 +697,11 @@ a { gap: 12px; border: 1px solid var(--border); border-radius: var(--radius); - background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018)); + background: linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)); color: var(--text); padding: 12px; text-align: left; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045); + box-shadow: inset 0 1px 0 var(--inset-highlight); } .option-row-main, @@ -745,10 +754,10 @@ a { .option-row.active { border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035)); + background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 50%, transparent), - inset 0 1px 0 rgba(255, 255, 255, 0.06); + inset 0 1px 0 var(--inset-highlight); } .tariff-row { @@ -764,7 +773,7 @@ a { align-items: flex-start; } -.change-action-row>svg { +.change-action-row > svg { flex: 0 0 auto; margin-top: 1px; color: var(--accent); @@ -774,9 +783,9 @@ a { display: grid; gap: 6px; padding: 10px 12px; - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid var(--border); border-radius: var(--radius); - background: rgba(255, 255, 255, 0.035); + background: var(--surface-muted); } .topup-carryover-note p { @@ -798,11 +807,12 @@ a { display: block; overflow: hidden; border-radius: 999px; - background: - linear-gradient(90deg, - rgba(255, 255, 255, 0.07) 0%, - rgba(255, 255, 255, 0.14) 42%, - rgba(255, 255, 255, 0.07) 84%); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--muted) 10%, transparent) 0%, + color-mix(in srgb, var(--muted) 18%, transparent) 42%, + color-mix(in srgb, var(--muted) 10%, transparent) 84% + ); background-size: 220% 100%; animation: skeleton-shimmer 1.15s ease-in-out infinite; } @@ -893,7 +903,7 @@ a { .tariff-selected-card { min-height: 58px; - background: color-mix(in srgb, var(--accent) 7%, rgba(255, 255, 255, 0.035)); + background: color-mix(in srgb, var(--accent) 7%, var(--surface-muted)); } .tariff-action-list { @@ -909,7 +919,7 @@ a { gap: 10px; border: 1px solid var(--border); border-radius: var(--radius); - background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018)); + background: linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)); color: var(--text); padding: 12px; text-align: left; @@ -942,12 +952,12 @@ a { .tariff-action-card.active { border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035)); + background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted)); } .tariff-warning-card { justify-content: flex-start; - color: var(--warning, #ffd166); + color: var(--warning-text); } .confirm-summary-card { @@ -969,7 +979,7 @@ a { min-height: 56px; align-items: center; justify-content: center; - background: rgba(255, 255, 255, 0.035); + background: var(--surface-muted); padding: 10px; text-align: center; } @@ -1057,10 +1067,10 @@ a { .field-error-tooltip { z-index: 120; max-width: 280px; - border: 1px solid color-mix(in srgb, var(--danger) 60%, var(--border)); + border: 1px solid var(--danger-border); border-radius: 8px; - background: #2a1010; - color: #ffd8d8; + background: var(--danger-soft); + color: var(--danger-text); padding: 8px 10px; font-size: 12px; line-height: 1.3; @@ -1105,7 +1115,7 @@ a { overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); - background: rgba(0, 0, 0, 0.2); + background: color-mix(in srgb, var(--panel-2) 86%, transparent); color: var(--text); padding: 12px; font-size: 12px; @@ -1132,7 +1142,7 @@ a { gap: 14px; } -.bonus-card-head>svg { +.bonus-card-head > svg { flex: 0 0 auto; color: var(--accent); } @@ -1165,7 +1175,7 @@ a { gap: 3px; border: 1px solid var(--border); border-radius: var(--radius); - background: rgba(255, 255, 255, 0.02); + background: var(--surface-sheen-soft); padding: 10px 11px; } @@ -1193,7 +1203,7 @@ a { overflow: hidden; border: 1px solid var(--border); border-radius: 999px; - background: rgba(255, 255, 255, 0.03); + background: var(--surface-muted); } .settings-avatar img { @@ -1245,15 +1255,15 @@ a { min-height: 50px; padding: 9px 10px; text-align: left; - background: rgba(255, 255, 255, 0.03); + background: var(--surface-muted); } -.settings-row>svg:first-child { +.settings-row > svg:first-child { color: var(--text); opacity: 0.9; } -.settings-row>svg:last-child { +.settings-row > svg:last-child { color: var(--muted); } @@ -1276,26 +1286,22 @@ a { .settings-row-linked { grid-template-columns: 28px minmax(0, 1fr); - border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); - background: color-mix(in srgb, var(--accent) 11%, rgba(255, 255, 255, 0.03)); + border-color: var(--success-border); + background: var(--success-soft); } -.settings-row-linked>svg:first-child { - color: var(--accent); +.settings-row-linked > svg:first-child { + color: var(--success-text); } .settings-row-linked strong { - color: var(--accent); + color: var(--success-text); } .emoji-flag { font-family: - "Twemoji Country Flags", - "Apple Color Emoji", - "Segoe UI Emoji", - "Segoe UI Symbol", - "Noto Color Emoji", - sans-serif; + "Twemoji Country Flags", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji", sans-serif; font-variant-emoji: emoji; line-height: 1; } @@ -1325,7 +1331,7 @@ a { border-radius: 6px; } -.language-select-trigger>svg { +.language-select-trigger > svg { color: var(--muted); } @@ -1361,7 +1367,7 @@ a { border: 1px solid var(--border-strong); border-radius: var(--radius); background: var(--panel-3); - box-shadow: 0 20px 34px rgba(0, 0, 0, 0.42); + box-shadow: var(--shadow-popover); overflow: hidden; box-sizing: border-box; transform-origin: top right; @@ -1387,7 +1393,7 @@ a { } .language-select-item[data-highlighted] { - background: rgba(255, 255, 255, 0.06); + background: var(--surface-hover); } .language-select-item[data-selected] { @@ -1409,11 +1415,11 @@ a { text-overflow: ellipsis; } -.language-select-item-main>span { +.language-select-item-main > span { display: inline-block !important; } -.language-select-item-main>span:last-child { +.language-select-item-main > span:last-child { margin-left: 6px; white-space: nowrap !important; overflow: hidden; @@ -1494,8 +1500,8 @@ a { min-height: 64px; border: 1px solid var(--border); border-radius: var(--radius); - background: rgba(7, 12, 17, 0.88); - box-shadow: 0 16px 38px rgba(0, 0, 0, 0.36); + background: var(--nav-bg); + box-shadow: var(--shadow-soft); backdrop-filter: blur(16px); } @@ -1530,17 +1536,29 @@ a { .home-layout { display: grid; min-height: calc(100dvh - 34px); - grid-template-rows: minmax(0, 1fr) auto; + grid-template-rows: minmax(min-content, 1fr) auto; gap: 18px; padding-bottom: 86px; animation: section-enter 0.24s ease-out both; } .home-brand { - align-self: center; + align-self: safe center; padding-top: 0; } +.home-brand .brand-mark.brand-mark-xl { + width: calc(6rem * var(--home-logo-scale, 1)); + height: calc(6rem * var(--home-logo-scale, 1)); + font-size: calc(4.375rem * var(--home-logo-scale, 1)); +} + +.login-brand-auth .brand-mark.brand-mark-xl { + width: calc(116px * var(--home-logo-scale, 1)); + height: calc(116px * var(--home-logo-scale, 1)); + font-size: calc(84px * var(--home-logo-scale, 1)); +} + .home-bottom { display: grid; gap: 10px; @@ -1549,7 +1567,7 @@ a { .auth-screen { display: grid; - align-content: center; + align-content: safe center; } .auth-card-wrap { @@ -1568,19 +1586,13 @@ a { gap: 5px; } -.login-brand-auth .brand-mark.brand-mark-xl { - width: 116px; - height: 116px; - font-size: 84px; -} - .login-brand h1 { margin: 0; overflow-wrap: anywhere; color: var(--accent); font-size: 32px; font-weight: 900; - font-family: var(--font-mono); + font-family: var(--font-logo); line-height: 1.04; } @@ -1739,13 +1751,13 @@ a { place-items: center; border: 1px solid color-mix(in srgb, var(--accent) 74%, var(--border)); border-radius: var(--radius); - background: rgba(255, 255, 255, 0.035); + background: var(--surface-muted); font-size: 25px; font-weight: 900; } .otp-slots span.filled { - background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.04)); + background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted)); } .link-button { @@ -1760,7 +1772,7 @@ a { } .status-line { - color: var(--accent); + color: var(--success-text); font-size: 12px; line-height: 1.4; } @@ -1788,10 +1800,10 @@ a { bottom: 16px; z-index: 120; max-width: min(420px, calc(100vw - 32px)); - border: 1px solid color-mix(in srgb, var(--accent) 46%, var(--border)); + border: 1px solid var(--border-strong); border-radius: var(--radius); - background: rgba(15, 21, 27, 0.92); - box-shadow: 0 16px 42px rgba(0, 0, 0, 0.42); + background: color-mix(in srgb, var(--panel) 92%, transparent); + box-shadow: var(--shadow-popover); color: var(--text); padding: 12px 14px; font-size: 13px; @@ -1873,8 +1885,7 @@ a { border: 1px solid rgba(255, 255, 255, 0.22); border-radius: 18px; background: - linear-gradient(135deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.015)), - #071017; + linear-gradient(135deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.015)), #071017; box-shadow: 0 28px 80px rgba(0, 0, 0, 0.54), inset 0 1px 0 rgba(255, 255, 255, 0.08); @@ -2011,7 +2022,6 @@ a { } @media (prefers-reduced-motion: reduce) { - .content, .home-layout, .language-select-content, @@ -2039,7 +2049,7 @@ a { @media (min-width: 1024px) { body { - background: #02070b; + background: var(--bg); } .app-shell { @@ -2060,16 +2070,16 @@ a { border-radius: 0; background: transparent; box-shadow: none; - padding: - max(28px, env(safe-area-inset-top)) var(--desktop-page-gutter) 40px calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); + padding: max(28px, env(safe-area-inset-top)) var(--desktop-page-gutter) 40px + calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); overflow-x: visible; } /* Centre and cap the width of the actual content blocks. */ - .phone-screen>.app-header, - .phone-screen>main, - .phone-screen>.home-layout, - .phone-screen>nav.bottom-nav~* { + .phone-screen > .app-header, + .phone-screen > main, + .phone-screen > .home-layout, + .phone-screen > nav.bottom-nav ~ * { max-width: 1080px; margin-left: auto; margin-right: auto; @@ -2079,12 +2089,11 @@ a { .phone-screen.auth-screen { width: min(100%, 460px); min-height: 100dvh; - padding: - max(16px, env(safe-area-inset-top)) clamp(24px, 4vw, 16px) 16px; + padding: max(16px, env(safe-area-inset-top)) clamp(24px, 4vw, 16px) 16px; margin: 0 auto; } - .phone-screen.auth-screen~.bottom-nav, + .phone-screen.auth-screen ~ .bottom-nav, .auth-screen .bottom-nav { display: none !important; } @@ -2094,8 +2103,8 @@ a { grid-template-columns: minmax(0, 1fr); align-items: stretch; min-height: 100dvh; - padding: - max(32px, env(safe-area-inset-top)) var(--desktop-page-gutter) 42px calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); + padding: max(32px, env(safe-area-inset-top)) var(--desktop-page-gutter) 42px + calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); } /* Home: keep the phone-style vertical flow, but center it in the @@ -2113,7 +2122,7 @@ a { align-items: stretch; } - .home-layout>.home-brand { + .home-layout > .home-brand { grid-column: auto; align-self: center; justify-items: center; @@ -2132,9 +2141,9 @@ a { .home-brand .brand-mark, .home-brand .brand-mark-lg, .home-brand .brand-mark-xl { - width: clamp(180px, 18vw, 260px); - height: clamp(180px, 18vw, 260px); - font-size: clamp(124px, 13vw, 184px); + width: calc(clamp(180px, 18vw, 260px) * var(--home-logo-scale, 1)); + height: calc(clamp(180px, 18vw, 260px) * var(--home-logo-scale, 1)); + font-size: calc(clamp(124px, 13vw, 184px) * var(--home-logo-scale, 1)); } .home-bottom { @@ -2174,9 +2183,9 @@ a { border: 0 !important; border-right: 1px solid var(--border) !important; border-radius: 0 !important; - background: rgba(7, 12, 17, 0.55) !important; + background: var(--rail-bg) !important; backdrop-filter: blur(14px) !important; - box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.02) !important; + box-shadow: inset -1px 0 0 var(--border) !important; z-index: 40; } @@ -2197,22 +2206,25 @@ a { font-size: 13px !important; color: var(--muted); border: 1px solid transparent; - transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; + transition: + background 0.12s ease, + color 0.12s ease, + border-color 0.12s ease; } - .bottom-nav button>svg { + .bottom-nav button > svg { width: 20px; height: 20px; } - .bottom-nav button>span { + .bottom-nav button > span { text-align: left !important; font-size: 13px !important; font-weight: 600; } .bottom-nav button:hover { - background: rgba(255, 255, 255, 0.04); + background: var(--surface-hover); color: var(--text); } @@ -2235,7 +2247,7 @@ a { display: none !important; } - .phone-screen>main.content.with-nav { + .phone-screen > main.content.with-nav { padding-top: 0; } @@ -2310,7 +2322,8 @@ a { color: var(--accent); font-size: 14px; font-weight: 800; - letter-spacing: -0.01em; + font-family: var(--font-logo); + letter-spacing: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 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 93% rename from bot/app/web/frontend/src/webapp/PaymentDialogs.svelte rename to frontend/src/webapp/PaymentDialogs.svelte index 526b76a..60e80dd 100644 --- a/bot/app/web/frontend/src/webapp/PaymentDialogs.svelte +++ b/frontend/src/webapp/PaymentDialogs.svelte @@ -158,19 +158,17 @@ {:else} {t("wa_no_tariff_change_options")} {/if} - {:else} - {#if tariffMode} - {#if !singleTariffMode && !(subscription?.active && subscription?.tariff_key && tariffCatalog.some((t) => t.key === subscription.tariff_key))} - - {/if} - {#if hasMultipleTariffs && selectedTariff} -

- {t("wa_selected_tariff", { tariff: selectedTariff.title })} -

- {/if} + {:else if tariffMode} + {#if !singleTariffMode && !(subscription?.active && subscription?.tariff_key && tariffCatalog.some((t) => t.key === subscription.tariff_key))} + + {/if} + {#if hasMultipleTariffs && selectedTariff} +

+ {t("wa_selected_tariff", { tariff: selectedTariff.title })} +

{/if} {#if selectedTariffPlans.length}
@@ -215,8 +213,13 @@ {:else} {t("wa_no_tariff_change_options")} {/if} - {/if} - {#if !tariffMode} + {:else} +
{#each plans as plan}