Merge branch 'dev'

This commit is contained in:
3252a8
2026-05-17 22:23:14 +03:00
386 changed files with 13241 additions and 1177 deletions
+6 -6
View File
@@ -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__/
+17 -5
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
*.sh text eol=lf
deploy/docker/frontend/*.sh text eol=lf
+4 -1
View File
@@ -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
-10
View File
@@ -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}
}
-96
View File
@@ -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"]
+18 -9
View File
@@ -1,5 +1,7 @@
# Remnawave Minishop
![Remnawave Minishop](docs/remnawave-minishop.webp)
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
@@ -31,6 +33,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки.
- [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
@@ -40,13 +43,13 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
## Стек
Сборка и runtime задаются **Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **requirements.txt** и **package.json**.
Сборка и runtime задаются **deploy/docker/Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **backend/requirements.txt** и **frontend/package.json**.
| Слой | Технологии |
| --- | --- |
| Backend | Python **3.12**, [aiogram](https://docs.aiogram.dev/) 3.x (Telegram), **aiohttp** (HTTP и Web App), **SQLAlchemy** 2 async, **asyncpg**, **Pydantic** / pydantic-settings, **httpx**, платёжные SDK (в т.ч. YooKassa, aiocryptopay), **PyJWT** |
| Данные | **PostgreSQL** **17** (сервис `remnawave-minishop-db` в Compose) |
| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `bot/app/web/templates/` |
| Данные | **PostgreSQL** **17** (сервис `postgres` в Compose) и **Redis** **7** (сервис `redis`) |
| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `backend/bot/app/web/templates/` |
Локальная разработка без Docker возможна при установленных Python 3.12, PostgreSQL и (для пересборки фронта) Node 22; типичный сценарий — всё через Compose.
@@ -65,7 +68,7 @@ cd remnawave-minishop
cp .env.example .env
nano .env
docker compose up -d --build
docker compose logs -f remnawave-minishop
docker compose logs -f backend worker frontend
```
Минимально заполните в `.env`:
@@ -80,10 +83,10 @@ docker compose logs -f remnawave-minishop
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, кеша логотипа Web App и animated emoji:
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
```bash
mkdir -p data/webapp-logo data/webapp-emoji
mkdir -p data/themes data/webapp-logo data/webapp-emoji
chown -R 10001:10001 data
chmod -R u+rwX data
```
@@ -95,15 +98,21 @@ chmod -R u+rwX data
docker compose up -d --build
# Логи приложения
docker compose logs -f remnawave-minishop
docker compose logs -f backend worker frontend
# Запуск с Caddy
docker compose -f docker-compose-caddy.yml up -d --build
docker compose -f deploy/compose/docker-compose-caddy.yml up -d
# Запуск из готового образа
IMAGE_TAG=3.1.0 docker compose -f docker-compose-remote-server.yml up -d
IMAGE_TAG=3.1.0 docker compose -f deploy/compose/docker-compose-remote-server.yml up -d
```
GHCR image names for releases:
- `ghcr.io/3252a8/remnawave-minishop-backend`
- `ghcr.io/3252a8/remnawave-minishop-worker`
- `ghcr.io/3252a8/remnawave-minishop-frontend`
## Поддержка
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
+12
View File
@@ -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>
+953
View File
@@ -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"
}
}
+131
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

File diff suppressed because it is too large Load Diff
@@ -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,

Some files were not shown because too many files have changed in this diff Show More