Merge branch 'dev'
@@ -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__/
|
||||
|
||||
@@ -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: <key>/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: "<GB>:<price>", comma-separated
|
||||
STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars
|
||||
TARIFFS_CONFIG_PATH=data/tariffs.json # Optional Tariffs 2.0 JSON config. If missing, legacy .env pricing is used.
|
||||
NEWT_ID= # Optional: local docker-compose-dev.yml Newt tunnel id
|
||||
NEWT_SECRET= # Optional: local docker-compose-dev.yml Newt tunnel secret
|
||||
TARIFF_TRAFFIC_WARNING_LEVELS=85,90,95 # Tariffs 2.0 traffic warning levels, percent used
|
||||
|
||||
# Subscription Notifications
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
*.sh text eol=lf
|
||||
deploy/docker/frontend/*.sh text eol=lf
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# Replace the example domains below with your real webhook and Mini App hostnames.
|
||||
webhook.domain.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy remnawave-minishop:{$WEB_SERVER_PORT:8080}
|
||||
}
|
||||
|
||||
app.domain.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy remnawave-minishop:{$WEBAPP_SERVER_PORT:8081}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
FROM python:3.12-slim AS python-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
FROM node:22-slim AS webapp-builder
|
||||
|
||||
WORKDIR /webapp
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
|
||||
COPY bot/app/web/frontend ./bot/app/web/frontend
|
||||
COPY bot/app/web/templates ./bot/app/web/templates
|
||||
COPY scripts/build_subscription_webapp_js.mjs ./scripts/build_subscription_webapp_js.mjs
|
||||
|
||||
RUN npm run build:webapp
|
||||
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG APP_VERSION=""
|
||||
ARG APP_REVISION=""
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \
|
||||
org.opencontainers.image.version="${APP_VERSION}" \
|
||||
org.opencontainers.image.revision="${APP_REVISION}"
|
||||
|
||||
RUN useradd -u 10001 -m appuser
|
||||
|
||||
COPY --from=python-builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends git
|
||||
|
||||
COPY . .
|
||||
|
||||
# Replace template assets with freshly built ones
|
||||
RUN rm -f bot/app/web/templates/subscription_webapp.css \
|
||||
bot/app/web/templates/subscription_webapp.js \
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.css \
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.js \
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.min.*.js \
|
||||
bot/app/web/templates/
|
||||
|
||||
RUN set -eux; \
|
||||
if [ -n "$APP_VERSION" ]; then \
|
||||
printf '%s\n' "$APP_VERSION" > .build-version; \
|
||||
elif [ -d .git ]; then \
|
||||
tag="$(git describe --tags --abbrev=0 2>/dev/null || true)"; \
|
||||
sha="$(git rev-parse --short HEAD 2>/dev/null || true)"; \
|
||||
dirty=""; \
|
||||
if ! git diff --quiet --ignore-submodules HEAD 2>/dev/null; then dirty="-dirty"; fi; \
|
||||
if [ -n "$tag" ] && [ -n "$sha" ]; then \
|
||||
count="$(git rev-list "${tag}..HEAD" --count 2>/dev/null || true)"; \
|
||||
if [ -n "$count" ] && [ "$count" != "0" ]; then \
|
||||
printf '%s+%s.g%s%s\n' "$tag" "$count" "$sha" "$dirty" > .build-version; \
|
||||
else \
|
||||
printf '%s%s\n' "$tag" "$dirty" > .build-version; \
|
||||
fi; \
|
||||
elif [ -n "$sha" ]; then \
|
||||
printf 'dev+g%s%s\n' "$sha" "$dirty" > .build-version; \
|
||||
else \
|
||||
printf 'dev+container\n' > .build-version; \
|
||||
fi; \
|
||||
else \
|
||||
printf 'dev+container\n' > .build-version; \
|
||||
fi; \
|
||||
if [ -n "$APP_REVISION" ]; then \
|
||||
printf '%s\n' "$APP_REVISION" > .build-revision; \
|
||||
elif [ -d .git ]; then \
|
||||
git rev-parse HEAD > .build-revision 2>/dev/null || printf 'unknown\n' > .build-revision; \
|
||||
else \
|
||||
printf 'unknown\n' > .build-revision; \
|
||||
fi; \
|
||||
apt-get purge -y --auto-remove git; \
|
||||
rm -rf .git /root/.cache
|
||||
|
||||
RUN mkdir -p /app/logs /app/data && chown -R appuser:appuser /app/logs /app/data
|
||||
|
||||
USER appuser
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
@@ -1,5 +1,7 @@
|
||||
# Remnawave Minishop
|
||||
|
||||

|
||||
|
||||
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
|
||||
|
||||
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
|
||||
@@ -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)`
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
level = getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO)
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
stream=sys.stdout,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
@@ -6,6 +6,11 @@ from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
try:
|
||||
from aiogram.fsm.storage.redis import RedisStorage
|
||||
except ModuleNotFoundError: # pragma: no cover - dependency is installed in Docker image
|
||||
RedisStorage = None # type: ignore[assignment]
|
||||
|
||||
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
|
||||
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
||||
from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
||||
@@ -18,7 +23,11 @@ from config.settings import Settings
|
||||
def build_dispatcher(
|
||||
settings: Settings, async_session_factory: sessionmaker
|
||||
) -> tuple[Dispatcher, Bot, Dict]:
|
||||
storage = MemoryStorage()
|
||||
storage = (
|
||||
RedisStorage.from_url(settings.REDIS_URL)
|
||||
if settings.REDIS_URL and RedisStorage is not None
|
||||
else MemoryStorage()
|
||||
)
|
||||
default_props = DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=default_props)
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
@@ -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 ""
|
||||
@@ -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)
|
||||
@@ -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)})
|
||||
@@ -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")
|
||||
@@ -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"<svg") or b"<svg" in head:
|
||||
return ".svg"
|
||||
if content_type == "image/x-icon" or suffix == ".ico":
|
||||
if body.startswith(b"\x00\x00\x01\x00"):
|
||||
return ".ico"
|
||||
return suffix if suffix in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES else None
|
||||
|
||||
|
||||
def _write_uploaded_logo(body: bytes, content_type: str = "", filename: str = "") -> str:
|
||||
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
raise ValueError("logo must be a non-empty image up to 2 MiB")
|
||||
ext = _detect_logo_extension(body, content_type, filename)
|
||||
if ext not in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES:
|
||||
raise ValueError("unsupported image type")
|
||||
digest = hashlib.sha256(body).hexdigest()[:16]
|
||||
safe_name = f"logo-{digest}{ext}"
|
||||
WEBAPP_UPLOADED_LOGO_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(WEBAPP_UPLOADED_LOGO_DIR / safe_name).write_bytes(body)
|
||||
return f"{WEBAPP_UPLOADED_LOGO_PATH}/{safe_name}"
|
||||
|
||||
|
||||
def _uploaded_logo_filename(url: str) -> Optional[str]:
|
||||
parsed = urlsplit(str(url or ""))
|
||||
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
|
||||
prefix = f"{WEBAPP_UPLOADED_LOGO_PATH}/"
|
||||
if not path.startswith(prefix):
|
||||
return None
|
||||
filename = path.removeprefix(prefix)
|
||||
if re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename):
|
||||
return filename
|
||||
return None
|
||||
|
||||
|
||||
def _favicon_digest(url: str) -> Optional[str]:
|
||||
parsed = urlsplit(str(url or ""))
|
||||
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
|
||||
match = re.fullmatch(
|
||||
rf"{re.escape(WEBAPP_FAVICON_PATH)}/([0-9a-f]{{16}})/(?:[A-Za-z0-9_.-]+)",
|
||||
path,
|
||||
)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _emoji_to_codepoints(value: str) -> str:
|
||||
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
|
||||
|
||||
|
||||
def prune_unused_appearance_assets(settings: Settings) -> None:
|
||||
keep_logos = {
|
||||
filename
|
||||
for filename in [
|
||||
_uploaded_logo_filename(getattr(settings, "WEBAPP_LOGO_URL", "")),
|
||||
]
|
||||
if filename
|
||||
}
|
||||
keep_favicons = {
|
||||
digest
|
||||
for digest in [
|
||||
_favicon_digest(getattr(settings, "WEBAPP_FAVICON_URL", "")),
|
||||
_favicon_digest(getattr(settings, "WEBAPP_LOGO_FAVICON_URL", "")),
|
||||
]
|
||||
if digest
|
||||
}
|
||||
keep_emoji_prefixes = set()
|
||||
if (
|
||||
getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False)
|
||||
and str(getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") or "").strip()
|
||||
== "noto-color-animated"
|
||||
):
|
||||
codepoints = _emoji_to_codepoints(getattr(settings, "WEBAPP_LOGO_EMOJI", ""))
|
||||
if codepoints:
|
||||
keep_emoji_prefixes.add(f"{codepoints}.512.")
|
||||
|
||||
for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"):
|
||||
if path.is_file() and path.name not in keep_logos:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp logo %s", path, exc_info=True)
|
||||
|
||||
for path in WEBAPP_FAVICON_DIR.glob("*"):
|
||||
if (
|
||||
path.is_dir()
|
||||
and re.fullmatch(r"[0-9a-f]{16}", path.name)
|
||||
and path.name not in keep_favicons
|
||||
):
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
|
||||
|
||||
for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"):
|
||||
if path.is_file() and not any(
|
||||
path.name.startswith(prefix) for prefix in keep_emoji_prefixes
|
||||
):
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp emoji asset %s", path, exc_info=True)
|
||||
|
||||
|
||||
async def _persist_appearance_upload(
|
||||
request: web.Request,
|
||||
updates: Dict[str, Any],
|
||||
actor_id: int,
|
||||
) -> bool:
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
result = await update_overrides(
|
||||
settings,
|
||||
async_session_factory,
|
||||
updates=updates,
|
||||
deletes=[],
|
||||
actor_id=actor_id,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
logger.warning("Failed to persist uploaded appearance asset settings: %s", result)
|
||||
return False
|
||||
|
||||
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),
|
||||
}
|
||||
)
|
||||
@@ -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 месяц"),
|
||||
@@ -9,7 +9,6 @@
|
||||
<link id="app-favicon" rel="icon" href="data:," sizes="any">
|
||||
<title>/minishop</title>
|
||||
<link rel="stylesheet" href="/subscription_webapp.css">
|
||||
<link rel="preload" id="logo-preload" href="" as="image" fetchpriority="high" crossorigin="anonymous">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"key": "dark",
|
||||
"names": {
|
||||
"ru": "Темная",
|
||||
"en": "Dark"
|
||||
},
|
||||
"enabled": true,
|
||||
"default": true,
|
||||
"use_primary_accent": true,
|
||||
"use_in_admin": true,
|
||||
"assets_version": 1,
|
||||
"tokens": {
|
||||
"color_scheme": "dark",
|
||||
"bg": "#03070b",
|
||||
"panel": "#111820",
|
||||
"panel_2": "#0b1118",
|
||||
"panel_3": "#17212b",
|
||||
"border": "rgba(255, 255, 255, 0.12)",
|
||||
"border_strong": "rgba(255, 255, 255, 0.2)",
|
||||
"text": "#f2f7f4",
|
||||
"muted": "#a9b4b0",
|
||||
"dim": "#68736f",
|
||||
"danger": "#ff6b6b",
|
||||
"blue": "#2d9cff",
|
||||
"radius": "8px"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 340 B |
|
After Width: | Height: | Size: 375 B |
|
After Width: | Height: | Size: 424 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 356 B |
|
After Width: | Height: | Size: 419 B |
|
After Width: | Height: | Size: 372 B |
|
After Width: | Height: | Size: 388 B |
|
After Width: | Height: | Size: 378 B |
|
After Width: | Height: | Size: 424 B |
|
After Width: | Height: | Size: 636 B |
|
After Width: | Height: | Size: 364 B |
|
After Width: | Height: | Size: 390 B |
|
After Width: | Height: | Size: 385 B |
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 356 B |
|
After Width: | Height: | Size: 393 B |
|
After Width: | Height: | Size: 474 B |
|
After Width: | Height: | Size: 395 B |
|
After Width: | Height: | Size: 461 B |
|
After Width: | Height: | Size: 327 B |
|
After Width: | Height: | Size: 411 B |
|
After Width: | Height: | Size: 395 B |
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 393 B |
|
After Width: | Height: | Size: 422 B |
|
After Width: | Height: | Size: 478 B |
|
After Width: | Height: | Size: 500 B |
|
After Width: | Height: | Size: 589 B |
|
After Width: | Height: | Size: 392 B |
|
After Width: | Height: | Size: 392 B |
|
After Width: | Height: | Size: 384 B |
|
After Width: | Height: | Size: 419 B |
|
After Width: | Height: | Size: 403 B |
|
After Width: | Height: | Size: 371 B |
|
After Width: | Height: | Size: 344 B |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"key": "windows95",
|
||||
"names": {
|
||||
"ru": "Windows 95",
|
||||
"en": "Windows 95"
|
||||
},
|
||||
"enabled": true,
|
||||
"default": false,
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 6,
|
||||
"tokens": {
|
||||
"color_scheme": "light",
|
||||
"style_preset": "win95"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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_CONFIG_SCRIPT -->"
|
||||
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
||||
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->"
|
||||
@@ -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
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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("</head>", f"{initial_theme_markup}\n</head>", 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'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
|
||||
)
|
||||
favicon_markup = _favicon_head_markup(cached["favicon_url"])
|
||||
if favicon_markup:
|
||||
html = html.replace(
|
||||
'<link id="app-favicon" rel="icon" href="data:," sizes="any">',
|
||||
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(
|
||||
'<link rel="preload" id="logo-preload" href="" as="image" fetchpriority="high" crossorigin="anonymous">', # noqa: E501
|
||||
f'<link rel="preload" href="{brand_asset_url}" as="image" fetchpriority="high" crossorigin="anonymous">', # noqa: E501
|
||||
)
|
||||
else:
|
||||
html = html.replace(
|
||||
'<link rel="preload" id="logo-preload" href="" as="image" fetchpriority="high" crossorigin="anonymous">', # noqa: E501
|
||||
"",
|
||||
"</head>",
|
||||
(
|
||||
f'<link rel="preload" href="{brand_asset_url}" '
|
||||
'as="image" fetchpriority="high">\n</head>'
|
||||
),
|
||||
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'<style id="webapp-initial-theme" nonce="{nonce}">' + "".join(css_rules) + "</style>"
|
||||
)
|
||||
href = _theme_css_href_for_html(theme)
|
||||
if not href:
|
||||
return style_tag
|
||||
stylesheet = (
|
||||
f'<link rel="stylesheet" href="{html.escape(href, quote=True)}" '
|
||||
f'data-initial-theme-css="{html.escape(str(theme.key), quote=True)}">'
|
||||
)
|
||||
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'<link id="app-favicon" rel="icon" href="{escaped_href}" sizes="any">\n'
|
||||
f'<link rel="{rel}" href="{escaped_href}">'
|
||||
)
|
||||
|
||||
digest = match.group(1)
|
||||
base = f"{WEBAPP_FAVICON_PATH}/{digest}"
|
||||
return "\n".join(
|
||||
[
|
||||
(
|
||||
f'<link id="app-favicon" rel="icon" type="image/png" sizes="32x32" '
|
||||
f'href="{base}/icon-32.png">'
|
||||
),
|
||||
f'<link rel="icon" type="image/x-icon" sizes="any" href="{base}/favicon.ico">',
|
||||
f'<link rel="icon" type="image/png" sizes="16x16" href="{base}/icon-16.png">',
|
||||
f'<link rel="icon" type="image/png" sizes="192x192" href="{base}/icon-192.png">',
|
||||
f'<link rel="apple-touch-icon" sizes="180x180" href="{base}/apple-touch-icon.png">',
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str:
|
||||
start = html.find(start_marker)
|
||||
if start == -1:
|
||||
@@ -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)
|
||||
@@ -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,
|
||||