Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5892a72575 | ||
|
|
d299a3c524 | ||
|
|
1c1ef06e29 | ||
|
|
bea62d75a9 | ||
|
|
e998b9ddd1 | ||
|
|
2f192447ce | ||
|
|
e89ed870dc | ||
|
|
6a722bfa17 | ||
|
|
a96aaa763f | ||
|
|
bbe05d7f57 | ||
|
|
da28b69461 | ||
|
|
cd469ae2bb | ||
|
|
9af49453f8 | ||
|
|
e543665704 | ||
|
|
39c0696e53 | ||
|
|
c6cc4ab963 | ||
|
|
4dfe71f392 | ||
|
|
cf05c6580d | ||
|
|
d544ebd879 | ||
|
|
7e0e8ff319 | ||
|
|
613a9860a0 | ||
|
|
92f0277dad | ||
|
|
749693078b | ||
|
|
b566a34725 | ||
|
|
3ab6c18a76 | ||
|
|
52458eb223 | ||
|
|
790946d89e | ||
|
|
30c5d9ae11 | ||
|
|
12e60629fe | ||
|
|
6c0d2932c3 | ||
|
|
ce19c7e2d1 | ||
|
|
93c9dde572 | ||
|
|
d46324cb6a | ||
|
|
f153ca5bf4 | ||
|
|
62e950f5c4 | ||
|
|
2b8bcd10b8 | ||
|
|
4c2ee19957 | ||
|
|
8217d13cd6 | ||
|
|
a2a887b898 | ||
|
|
1620de9a01 | ||
|
|
b6c6887842 | ||
|
|
ef4b493e65 | ||
|
|
50dd1951c4 | ||
|
|
b3894e53e4 | ||
|
|
ded044b4c0 | ||
|
|
bce78c4f28 | ||
|
|
75586d8883 | ||
|
|
f2fc335221 | ||
|
|
1c9e55d797 | ||
|
|
25056602d8 | ||
|
|
bd2e67059f | ||
|
|
fe34edfe73 | ||
|
|
4706be53ab | ||
|
|
4bd547f06a | ||
|
|
0250264fa0 | ||
|
|
3aede8fe95 | ||
|
|
e90988ea5c | ||
|
|
69d3400310 | ||
|
|
6a44da1f31 | ||
|
|
77c8785ec0 | ||
|
|
f2c0a9f6d5 | ||
|
|
f77a6ea46d | ||
|
|
a92ad32b23 | ||
|
|
a6af8f8415 | ||
|
|
da9db7b7f8 | ||
|
|
11048a6ed8 | ||
|
|
c3381bdd31 | ||
|
|
53f1cec401 | ||
|
|
bf46a15446 | ||
|
|
0df52d0235 | ||
|
|
0c167c8f09 | ||
|
|
804ccdabec | ||
|
|
5833b18052 |
@@ -13,6 +13,10 @@ scratch/
|
||||
*.local.*
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
docs-site/node_modules/
|
||||
docs-site/.astro/
|
||||
docs-site/dist/
|
||||
docs-site/src/content/docs/
|
||||
deploy/compose/docker-compose-dev.yml
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
@@ -31,10 +35,38 @@ deploy/compose/*.yml
|
||||
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
|
||||
bot/app/web/templates/subscription_webapp.*.css
|
||||
bot/app/web/templates/subscription_webapp.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp.*.css.br
|
||||
bot/app/web/templates/subscription_webapp.*.css.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.css
|
||||
bot/app/web/templates/subscription_webapp_admin.js
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.css
|
||||
backend/bot/app/web/templates/subscription_webapp.js
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.css
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.js
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
**/__pycache__/
|
||||
|
||||
@@ -12,6 +12,12 @@ scratch/
|
||||
node_modules/
|
||||
.git/
|
||||
|
||||
# Documentation site build artifacts
|
||||
docs-site/.astro/
|
||||
docs-site/dist/
|
||||
docs-site/public/demo/runtime/
|
||||
docs-site/src/content/docs/
|
||||
|
||||
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
@@ -29,6 +35,11 @@ bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.css
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.js
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.css
|
||||
backend/bot/app/web/templates/subscription_webapp.js
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||
@@ -45,6 +56,11 @@ backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.css
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.js
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
|
||||
tmp
|
||||
.claude
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками панели [Remnawave](https://docs.rw/). Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
|
||||
|
||||
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
|
||||
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека и других ботов используйте [раздел миграций](docs/migrations/index.md).
|
||||
|
||||
## Возможности
|
||||
|
||||
@@ -26,21 +26,25 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
||||
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
|
||||
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
|
||||
- блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
|
||||
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
|
||||
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональный JSON-override и переключатель поведения кнопок бота;
|
||||
- редактор JSON-каталога тарифов с моделями на срок/по трафику, Internal Squads, premium-сквадами и HWID-пакетами;
|
||||
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональное JSON-переопределение и переключатель поведения кнопок бота;
|
||||
- ручная синхронизация пользователей и подписок с панелью.
|
||||
|
||||
## Документация
|
||||
|
||||
- [Настройка окружения](docs/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
|
||||
- [Переменные `.env`](docs/env-vars.md) - полный справочник всех env-ключей по разделам.
|
||||
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
||||
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
||||
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход, инструкции установки и реферальные ссылки.
|
||||
- [Поддержка](docs/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
||||
- [Темы 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) - перенос данных из прежнего стека.
|
||||
- [Входная страница документации](docs/index.md) - маршрут по установке, настройке, платежам, админке и диагностике.
|
||||
- [Развертывание](docs/getting-started/deployment.md) - Docker Compose, Caddy, Nginx, Pangolin/Newt и запуск без обратного прокси.
|
||||
- [Настройка окружения](docs/getting-started/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
|
||||
- [Переменные `.env`](docs/configuration/env-vars.md) - полный справочник всех env-ключей по разделам.
|
||||
- [Бэкапы и восстановление](docs/features/backups.md) - автоматические архивы, Telegram-отправка и restore через админку.
|
||||
- [Тарифы](docs/features/tariffs.md) - каталог тарифов, модели на срок и по трафику, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
||||
- [Админ-панель](docs/features/admin-panel.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
||||
- [Веб-приложение / Mini App](docs/features/web-app.md) - отдельный порт, домен, инструкции установки и реферальные ссылки.
|
||||
- [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина.
|
||||
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
||||
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
||||
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`.
|
||||
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) - готовый сценарий для legacy-стека.
|
||||
|
||||
## Совместимость
|
||||
|
||||
@@ -88,9 +92,9 @@ docker compose logs -f backend worker frontend
|
||||
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
||||
- остальные настройки удобнее задать в Web App админке.
|
||||
|
||||
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/env-vars.md](docs/env-vars.md).
|
||||
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/configuration/env-vars.md](docs/configuration/env-vars.md).
|
||||
|
||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
|
||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md).
|
||||
|
||||
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
|
||||
|
||||
@@ -110,7 +114,7 @@ docker compose up -d --build
|
||||
# Логи приложения
|
||||
docker compose logs -f backend worker frontend
|
||||
|
||||
# Готовые production-примеры
|
||||
# Рекомендуемый продакшен-вариант с Caddy
|
||||
cd deploy/examples/caddy # или nginx, newt, no-proxy
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
@@ -120,13 +124,24 @@ docker compose up -d
|
||||
IMAGE_TAG=3.1.0 docker compose up -d
|
||||
```
|
||||
|
||||
Для production-запуска удобнее брать готовые папки из [`deploy/examples`](deploy/examples): там отдельно собраны варианты для Caddy, Nginx, Newt/Pangolin и прямой публикации портов без reverse proxy. В каждой папке рядом лежат `docker-compose.yml`, `.env.example`, README и нужный proxy-конфиг.
|
||||
Для продакшен-запуска удобнее брать готовые папки из [`deploy/examples`](deploy/examples), а читать каноничные инструкции в [docs/getting-started/deployment.md](docs/getting-started/deployment.md). Предпочтительный вариант для обычного публичного сервера - Caddy: он сам выпускает и продлевает HTTPS-сертификаты. В папках рядом с compose лежат только конфиги и короткие ссылки на документацию.
|
||||
|
||||
GHCR image names for releases:
|
||||
Имена образов для релизов:
|
||||
|
||||
- `ghcr.io/3252a8/remnawave-minishop-backend`
|
||||
- `ghcr.io/3252a8/remnawave-minishop-worker`
|
||||
- `ghcr.io/3252a8/remnawave-minishop-frontend`
|
||||
- `docker.io/3252a8/remnawave-minishop-backend`
|
||||
- `docker.io/3252a8/remnawave-minishop-worker`
|
||||
- `docker.io/3252a8/remnawave-minishop-frontend`
|
||||
|
||||
Сборка и публикация сразу в GHCR и Docker Hub:
|
||||
|
||||
```bash
|
||||
docker login ghcr.io
|
||||
docker login docker.io
|
||||
IMAGE_TAG=v3.4.3 bash scripts/docker-build-push-images.sh
|
||||
```
|
||||
|
||||
## Поддержать проект
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from bot.app.web.admin_api_impl import (
|
||||
_runtime as _runtime,
|
||||
ads as _ads,
|
||||
auth as _auth,
|
||||
backups as _backups,
|
||||
broadcast as _broadcast,
|
||||
common as _common,
|
||||
logs as _logs,
|
||||
@@ -36,6 +37,7 @@ _MODULES = (
|
||||
_broadcast,
|
||||
_sync,
|
||||
_ads,
|
||||
_backups,
|
||||
_settings,
|
||||
_tariffs,
|
||||
_themes,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
import secrets
|
||||
import subprocess
|
||||
|
||||
from bot.infra.redis import redis_lock
|
||||
from bot.services.backup_restore_service import (
|
||||
BACKUP_UPLOAD_MAX_BYTES,
|
||||
BackupArchiveError,
|
||||
BackupArchiveInfo,
|
||||
BackupRestoreError,
|
||||
BackupRestoreService,
|
||||
)
|
||||
from bot.services.backup_worker import BackupWorker
|
||||
|
||||
|
||||
def _backup_archive_payload(archive) -> Dict[str, Any]:
|
||||
return archive.to_payload()
|
||||
|
||||
|
||||
async def _read_uploaded_backup_file(request: web.Request) -> BackupArchiveInfo:
|
||||
settings: Settings = request.app["settings"]
|
||||
service = BackupRestoreService(settings)
|
||||
backup_dir = service.backup_dir()
|
||||
temp_path: Optional[Path] = None
|
||||
|
||||
reader = await request.multipart()
|
||||
try:
|
||||
async for part in reader:
|
||||
if part.name != "file":
|
||||
continue
|
||||
|
||||
original_filename = part.filename or "backup.zip"
|
||||
temp_path = backup_dir / f".upload-{secrets.token_urlsafe(12)}.zip.tmp"
|
||||
size = 0
|
||||
with temp_path.open("wb") as handle:
|
||||
while True:
|
||||
chunk = await part.read_chunk(size=1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > BACKUP_UPLOAD_MAX_BYTES:
|
||||
raise BackupArchiveError("Backup archive is too large")
|
||||
handle.write(chunk)
|
||||
if size <= 0:
|
||||
raise BackupArchiveError("Uploaded archive is empty")
|
||||
archive = service.import_uploaded_archive(temp_path, original_filename)
|
||||
temp_path = None
|
||||
return archive
|
||||
finally:
|
||||
if temp_path is not None and temp_path.exists():
|
||||
try:
|
||||
temp_path.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove temporary backup upload %s", temp_path)
|
||||
|
||||
raise BackupArchiveError("file field is required")
|
||||
|
||||
|
||||
async def admin_backups_list_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
try:
|
||||
service = BackupRestoreService(settings)
|
||||
archives = service.list_archives()
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to list backup archives")
|
||||
return _error(500, "backup_list_failed", str(exc))
|
||||
return _ok(
|
||||
{
|
||||
"backup_dir": str(service.backup_dir()),
|
||||
"archives": [_backup_archive_payload(archive) for archive in archives],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_backups_upload_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
content_type = (request.headers.get("Content-Type") or "").lower()
|
||||
if not content_type.startswith("multipart/form-data"):
|
||||
return _error(400, "invalid_backup_archive", "multipart file upload is required")
|
||||
try:
|
||||
archive = await _read_uploaded_backup_file(request)
|
||||
except BackupArchiveError as exc:
|
||||
return _error(400, "invalid_backup_archive", str(exc))
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to save uploaded backup archive")
|
||||
return _error(500, "backup_upload_failed", str(exc))
|
||||
return _ok({"archive": _backup_archive_payload(archive)})
|
||||
|
||||
|
||||
async def admin_backups_create_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
bot = request.app["bot"]
|
||||
session_factory = request.app.get("async_session_factory")
|
||||
worker = BackupWorker(settings, bot, session_factory=session_factory)
|
||||
|
||||
ttl_seconds = max(
|
||||
60,
|
||||
int(
|
||||
max(
|
||||
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
|
||||
getattr(settings, "BACKUP_PG_DUMP_TIMEOUT_SECONDS", 1800) or 1800,
|
||||
)
|
||||
),
|
||||
)
|
||||
try:
|
||||
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
|
||||
if not acquired:
|
||||
return _error(409, "backup_create_busy", "Backup or restore is already running")
|
||||
await worker.refresh_settings()
|
||||
result = await worker.create_and_send_backup(backup_type="manual")
|
||||
archive = BackupRestoreService(settings).inspect_archive(result.archive_path)
|
||||
except BackupArchiveError as exc:
|
||||
return _error(400, "invalid_backup_archive", str(exc))
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError, TimeoutError) as exc:
|
||||
logger.exception("Manual backup creation failed")
|
||||
return _error(500, "backup_create_failed", str(exc))
|
||||
except Exception as exc:
|
||||
logger.exception("Manual backup creation failed")
|
||||
return _error(500, "backup_create_failed", str(exc))
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"result": result.to_payload(),
|
||||
"archive": _backup_archive_payload(archive),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_backups_restore_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
|
||||
archive_name = str(payload.get("archive_name") or "").strip()
|
||||
restore_database = bool(payload.get("restore_database"))
|
||||
restore_compose = bool(payload.get("restore_compose"))
|
||||
confirm = bool(payload.get("confirm"))
|
||||
if not confirm:
|
||||
return _error(400, "restore_confirmation_required")
|
||||
|
||||
service = BackupRestoreService(settings)
|
||||
ttl_seconds = max(
|
||||
60,
|
||||
int(
|
||||
max(
|
||||
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
|
||||
getattr(settings, "BACKUP_PG_RESTORE_TIMEOUT_SECONDS", 1800) or 1800,
|
||||
)
|
||||
),
|
||||
)
|
||||
try:
|
||||
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
|
||||
if not acquired:
|
||||
return _error(409, "backup_restore_busy", "Backup or restore is already running")
|
||||
result = await service.restore_archive(
|
||||
archive_name,
|
||||
restore_database=restore_database,
|
||||
restore_compose=restore_compose,
|
||||
)
|
||||
except BackupArchiveError as exc:
|
||||
return _error(400, "invalid_backup_archive", str(exc))
|
||||
except BackupRestoreError as exc:
|
||||
logger.exception("Backup restore failed")
|
||||
return _error(500, "backup_restore_failed", str(exc))
|
||||
except (OSError, subprocess.SubprocessError, TimeoutError) as exc:
|
||||
logger.exception("Backup restore failed")
|
||||
return _error(500, "backup_restore_failed", str(exc))
|
||||
|
||||
if result.database_restored:
|
||||
try:
|
||||
from db import database_setup
|
||||
|
||||
if database_setup.async_engine is not None:
|
||||
await database_setup.async_engine.dispose()
|
||||
except Exception:
|
||||
logger.exception("Failed to dispose DB engine after backup restore")
|
||||
|
||||
return _ok({"result": result.to_payload()})
|
||||
@@ -75,4 +75,8 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
router.add_put("/api/admin/themes", admin_themes_save_route)
|
||||
router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route)
|
||||
router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_upload_route)
|
||||
router.add_get("/api/admin/backups", admin_backups_list_route)
|
||||
router.add_post("/api/admin/backups/create", admin_backups_create_route)
|
||||
router.add_post("/api/admin/backups/upload", admin_backups_upload_route)
|
||||
router.add_post("/api/admin/backups/restore", admin_backups_restore_route)
|
||||
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
|
||||
|
||||
@@ -11,6 +11,7 @@ from bot.services.locale_override_service import (
|
||||
update_locale_overrides,
|
||||
)
|
||||
|
||||
|
||||
def _locale_languages(
|
||||
i18n: JsonI18n,
|
||||
overrides: Optional[List[Dict[str, Any]]] = None,
|
||||
|
||||
@@ -283,12 +283,20 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
subsection="common",
|
||||
),
|
||||
# ─── Trial ─────────────────────────────────────────────────────
|
||||
SettingField("TRIAL_ENABLED", "bool", "pricing", "Триал включён", subsection="trial"),
|
||||
SettingField(
|
||||
"TRIAL_ENABLED",
|
||||
"bool",
|
||||
"pricing",
|
||||
"Триал включён",
|
||||
optional=False,
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
"TRIAL_DURATION_DAYS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Длительность триала (дней)",
|
||||
optional=False,
|
||||
min=0,
|
||||
subsection="trial",
|
||||
),
|
||||
@@ -297,6 +305,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"float",
|
||||
"pricing",
|
||||
"Лимит трафика триала (ГБ)",
|
||||
optional=False,
|
||||
min=0,
|
||||
subsection="trial",
|
||||
),
|
||||
@@ -305,6 +314,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"string",
|
||||
"pricing",
|
||||
"Стратегия сброса трафика триала",
|
||||
optional=False,
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
@@ -399,6 +409,14 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"За сколько дней предупреждать",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFY_HOURS_BEFORE",
|
||||
"int",
|
||||
"notifications",
|
||||
"За сколько часов предупреждать",
|
||||
min=0,
|
||||
max=23,
|
||||
),
|
||||
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
|
||||
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
|
||||
SettingField("LOG_SUPPORT", "bool", "notifications", "Логировать тикеты поддержки"),
|
||||
@@ -434,6 +452,55 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"ID треда поддержки",
|
||||
"Тред лог-чата для уведомлений о тикетах поддержки.",
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_ENABLED",
|
||||
"bool",
|
||||
"backups",
|
||||
"Бэкапы включены",
|
||||
"Worker будет периодически собирать ZIP-архив и отправлять его в Telegram.",
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_CHAT_ID",
|
||||
"int",
|
||||
"backups",
|
||||
"ID чата для бэкапов",
|
||||
"Куда отправлять ZIP-архивы. Если пусто, используется LOG_CHAT_ID.",
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_THREAD_ID",
|
||||
"int",
|
||||
"backups",
|
||||
"ID треда для бэкапов",
|
||||
"Необязательный topic/thread ID. Если пусто, используется LOG_THREAD_ID.",
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_INTERVAL_SECONDS",
|
||||
"int",
|
||||
"backups",
|
||||
"Период бэкапов (сек.)",
|
||||
"По умолчанию 3600: запуск на границе часа (12:00, 13:00 и т.д.).",
|
||||
optional=False,
|
||||
min=60,
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_LOCAL_RETENTION",
|
||||
"int",
|
||||
"backups",
|
||||
"Сколько архивов хранить",
|
||||
"Сколько последних ZIP-архивов оставлять в data/backups на сервере.",
|
||||
optional=False,
|
||||
min=1,
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_COMPOSE_ENABLED",
|
||||
"bool",
|
||||
"backups",
|
||||
"Добавлять compose-папку",
|
||||
(
|
||||
"Добавляет snapshot /app/compose-source. Если папка не смонтирована, "
|
||||
"бэкап БД все равно будет создан."
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_TICKETS_ENABLED",
|
||||
"bool",
|
||||
@@ -561,6 +628,8 @@ def coerce_value(field: SettingField, raw: Any) -> Any:
|
||||
return text
|
||||
|
||||
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
|
||||
if not field.optional:
|
||||
raise ValueError(f"{field.key}: value required")
|
||||
return None
|
||||
|
||||
if field.type == "bool":
|
||||
@@ -629,7 +698,8 @@ def manifest_payload() -> List[dict]:
|
||||
"referral": 6,
|
||||
"notifications": 7,
|
||||
"support": 8,
|
||||
"devices": 9,
|
||||
"backups": 9,
|
||||
"devices": 10,
|
||||
"subscription_guides": 10,
|
||||
}
|
||||
exclusive_map = {
|
||||
@@ -678,6 +748,10 @@ def manifest_payload() -> List[dict]:
|
||||
"optional": field.optional,
|
||||
"secret": field.secret,
|
||||
}
|
||||
if field.min is not None:
|
||||
item["min"] = field.min
|
||||
if field.max is not None:
|
||||
item["max"] = field.max
|
||||
if field.key in exclusive_map:
|
||||
item["mutually_exclusive_key"] = exclusive_map[field.key]
|
||||
if default_value is not None:
|
||||
|
||||
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 738 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -71,6 +71,12 @@ 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_DEFAULT_BRAND_DIR = ASSET_DIR / "default-brand"
|
||||
WEBAPP_DEFAULT_LOGO_FILE = WEBAPP_DEFAULT_BRAND_DIR / "default-logo.webp"
|
||||
WEBAPP_DEFAULT_LOGO_PATH = "/webapp-default-logo.webp"
|
||||
WEBAPP_DEFAULT_FAVICON_DIGEST = "19b2a242e5b7bc2d"
|
||||
WEBAPP_DEFAULT_FAVICON_DIR = WEBAPP_DEFAULT_BRAND_DIR / "favicons" / WEBAPP_DEFAULT_FAVICON_DIGEST
|
||||
WEBAPP_DEFAULT_FAVICON_URL = f"{WEBAPP_FAVICON_PATH}/{WEBAPP_DEFAULT_FAVICON_DIGEST}/icon-180.png"
|
||||
WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji"
|
||||
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
||||
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
||||
|
||||
@@ -482,6 +482,7 @@ async def account_avatar_route(request: web.Request) -> web.Response:
|
||||
|
||||
async def account_language_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
language_payload, validation_error = _validate_model_payload(WebAppLanguagePayload, payload)
|
||||
if validation_error:
|
||||
|
||||
@@ -205,7 +205,7 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
|
||||
|
||||
raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip()
|
||||
if not raw_logo_url:
|
||||
return ""
|
||||
return WEBAPP_DEFAULT_LOGO_PATH
|
||||
|
||||
parsed_logo_url = urlsplit(raw_logo_url)
|
||||
if parsed_logo_url.scheme == "https":
|
||||
@@ -215,7 +215,7 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
|
||||
return raw_logo_url
|
||||
if raw_logo_url.startswith("/"):
|
||||
return raw_logo_url
|
||||
return ""
|
||||
return WEBAPP_DEFAULT_LOGO_PATH
|
||||
|
||||
|
||||
def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str:
|
||||
@@ -227,7 +227,9 @@ def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str:
|
||||
resolved = _resolve_webapp_asset_url(raw_logo_favicon_url)
|
||||
if resolved:
|
||||
return resolved
|
||||
return logo_url or ""
|
||||
if logo_url and logo_url != WEBAPP_DEFAULT_LOGO_PATH:
|
||||
return logo_url
|
||||
return WEBAPP_DEFAULT_FAVICON_URL
|
||||
|
||||
|
||||
def _resolve_webapp_asset_url(raw_url: str) -> str:
|
||||
@@ -364,6 +366,16 @@ async def webapp_uploaded_logo_route(request: web.Request) -> web.Response:
|
||||
return _uploaded_webapp_logo_response(filename)
|
||||
|
||||
|
||||
async def webapp_default_logo_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
if not settings.WEBAPP_ENABLED:
|
||||
raise web.HTTPNotFound(text="webapp_disabled")
|
||||
|
||||
response = _webapp_default_brand_file_response(WEBAPP_DEFAULT_LOGO_FILE, "image/webp")
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return response
|
||||
|
||||
|
||||
async def webapp_favicon_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
if not settings.WEBAPP_ENABLED:
|
||||
@@ -451,6 +463,9 @@ def _webapp_favicon_file_response(digest: str, filename: str) -> web.Response:
|
||||
):
|
||||
raise web.HTTPNotFound(text="webapp_favicon_not_found")
|
||||
|
||||
if digest == WEBAPP_DEFAULT_FAVICON_DIGEST:
|
||||
return _webapp_default_favicon_file_response(filename)
|
||||
|
||||
root = WEBAPP_FAVICON_DIR.expanduser().resolve()
|
||||
path = (root / digest / filename).resolve()
|
||||
try:
|
||||
@@ -477,6 +492,29 @@ def _webapp_favicon_file_response(digest: str, filename: str) -> web.Response:
|
||||
return response
|
||||
|
||||
|
||||
def _webapp_default_favicon_file_response(filename: str) -> web.Response:
|
||||
path = WEBAPP_DEFAULT_FAVICON_DIR / filename
|
||||
content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower())
|
||||
if not content_type:
|
||||
raise web.HTTPNotFound(text="webapp_favicon_not_found")
|
||||
|
||||
response = _webapp_default_brand_file_response(path, content_type)
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return response
|
||||
|
||||
|
||||
def _webapp_default_brand_file_response(path: Path, content_type: str) -> web.Response:
|
||||
try:
|
||||
body = _read_template_binary_cached(path)
|
||||
except OSError:
|
||||
raise web.HTTPNotFound(text="webapp_default_brand_not_found") from None
|
||||
|
||||
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
raise web.HTTPNotFound(text="webapp_default_brand_not_found")
|
||||
|
||||
return web.Response(body=body, content_type=content_type)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -1203,7 +1203,7 @@ def _apply_telegram_profile_to_user(
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
language_code = _normalize_language(
|
||||
telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE
|
||||
user.language_code or telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
user.telegram_id = int(telegram_user["id"])
|
||||
@@ -1251,8 +1251,8 @@ async def _link_telegram_to_user(
|
||||
|
||||
if not existing_telegram_user and int(current_user.user_id) < 0:
|
||||
language_code = _normalize_language(
|
||||
telegram_user.get("language_code")
|
||||
or current_user.language_code
|
||||
current_user.language_code
|
||||
or telegram_user.get("language_code")
|
||||
or settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
target_user, _ = await user_dal.create_user(
|
||||
@@ -1400,20 +1400,19 @@ async def _ensure_user_from_telegram(
|
||||
referral_param: Optional[str] = None,
|
||||
) -> User:
|
||||
user_id = int(telegram_user["id"])
|
||||
language_code = _normalize_language(
|
||||
telegram_language_code = _normalize_language(
|
||||
telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
update_data = {
|
||||
profile_data = {
|
||||
"telegram_id": user_id,
|
||||
"username": sanitize_username(telegram_user.get("username")),
|
||||
"first_name": sanitize_display_name(telegram_user.get("first_name")),
|
||||
"last_name": sanitize_display_name(telegram_user.get("last_name")),
|
||||
"language_code": language_code,
|
||||
}
|
||||
telegram_photo_url = _telegram_photo_url_value(telegram_user)
|
||||
if telegram_photo_url:
|
||||
update_data["telegram_photo_url"] = telegram_photo_url
|
||||
profile_data["telegram_photo_url"] = telegram_photo_url
|
||||
|
||||
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
|
||||
if not db_user:
|
||||
@@ -1428,7 +1427,8 @@ async def _ensure_user_from_telegram(
|
||||
session,
|
||||
{
|
||||
"user_id": user_id,
|
||||
**update_data,
|
||||
**profile_data,
|
||||
"language_code": telegram_language_code,
|
||||
"referred_by_id": referred_by_id,
|
||||
"registration_date": datetime.now(timezone.utc),
|
||||
},
|
||||
@@ -1436,6 +1436,10 @@ async def _ensure_user_from_telegram(
|
||||
setattr(db_user, "_webapp_created", bool(created))
|
||||
return db_user
|
||||
|
||||
update_data = {
|
||||
**profile_data,
|
||||
"language_code": _normalize_language(db_user.language_code or telegram_language_code),
|
||||
}
|
||||
changed = {key: value for key, value in update_data.items() if getattr(db_user, key) != value}
|
||||
if changed:
|
||||
db_user = await user_dal.update_user(session, db_user.user_id, changed) or db_user
|
||||
|
||||
@@ -4,6 +4,42 @@ from ._runtime import * # noqa: F403,F405
|
||||
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
||||
|
||||
|
||||
def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _billing_datetime_text(value: Optional[Any]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||
text = str(value)
|
||||
try:
|
||||
normalized = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||
except Exception:
|
||||
return text
|
||||
|
||||
|
||||
def _parse_positive_int_units(value: Any) -> Optional[int]:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not number.is_integer():
|
||||
return None
|
||||
integer = int(number)
|
||||
return integer if integer > 0 else None
|
||||
|
||||
|
||||
async def apply_promo_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
payload = await _read_json(request)
|
||||
@@ -64,6 +100,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
return validation_error
|
||||
method = str(payment_payload.method or "").strip().lower()
|
||||
settings: Settings = request.app["settings"]
|
||||
subscription_service: SubscriptionService = request.app["subscription_service"]
|
||||
cached = _get_cached_webapp_settings(request)
|
||||
tariffs_config = settings.tariffs_config
|
||||
traffic_mode = bool(settings.traffic_sale_mode)
|
||||
@@ -86,15 +123,12 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
return _json_error(400, "invalid_plan", "Tariff is not available")
|
||||
if tariff.billing_model != "period":
|
||||
return _json_error(400, "invalid_plan", "Device top-up is not available")
|
||||
try:
|
||||
device_count = int(
|
||||
float(
|
||||
payment_payload.device_count
|
||||
if payment_payload.device_count is not None
|
||||
else payment_payload.months
|
||||
)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
device_count = _parse_positive_int_units(
|
||||
payment_payload.device_count
|
||||
if payment_payload.device_count is not None
|
||||
else payment_payload.months
|
||||
)
|
||||
if device_count is None:
|
||||
return _json_error(400, "invalid_plan", "Invalid device package")
|
||||
if not tariff.hwid_device_packages:
|
||||
return _json_error(400, "invalid_plan", "Device package is not available")
|
||||
@@ -613,12 +647,14 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
tariff = config.require(sub.tariff_key)
|
||||
if tariff.billing_model != "period":
|
||||
return _json_error(
|
||||
400, "device_topup_unavailable", "Device top-up is not available"
|
||||
)
|
||||
return _json_error(400, "device_topup_unavailable", "Device top-up is not available")
|
||||
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
renewal_available = bool(active and active.get("device_topup_renewal_available"))
|
||||
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") if active else None
|
||||
extra_hwid_valid_until_text = (
|
||||
active.get("extra_hwid_devices_valid_until_text") if active else None
|
||||
) or _billing_datetime_text(extra_hwid_valid_until)
|
||||
packages = tariff.hwid_device_packages
|
||||
rub_counts = {int(package.count) for package in (packages.rub if packages else [])}
|
||||
stars_counts = {int(package.count) for package in (packages.stars if packages else [])}
|
||||
@@ -663,16 +699,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"title": f"+{count}",
|
||||
"subtitle": tariff.name(lang),
|
||||
"valid_from": (
|
||||
(rub_quote or stars_quote)["valid_from"].isoformat()
|
||||
if (rub_quote or stars_quote).get("valid_from")
|
||||
else None
|
||||
),
|
||||
"valid_until": (
|
||||
(rub_quote or stars_quote)["valid_until"].isoformat()
|
||||
if (rub_quote or stars_quote).get("valid_until")
|
||||
else None
|
||||
),
|
||||
"valid_from": _billing_iso_datetime((rub_quote or stars_quote).get("valid_from")),
|
||||
"valid_until": _billing_iso_datetime((rub_quote or stars_quote).get("valid_until")),
|
||||
"proration_ratio": float((rub_quote or stars_quote).get("proration_ratio") or 0),
|
||||
}
|
||||
if stars_quote and int(stars_quote.get("price") or 0) > 0:
|
||||
@@ -687,14 +715,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
"extra_hwid_devices": int(active.get("extra_hwid_devices") or 0)
|
||||
if active
|
||||
else int(sub.extra_hwid_devices or 0),
|
||||
"extra_hwid_devices_valid_until": active.get("extra_hwid_devices_valid_until")
|
||||
if active
|
||||
else None,
|
||||
"extra_hwid_devices_valid_until_text": active.get(
|
||||
"extra_hwid_devices_valid_until_text"
|
||||
)
|
||||
if active
|
||||
else None,
|
||||
"extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until),
|
||||
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text,
|
||||
"renewal_available": renewal_available,
|
||||
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0)
|
||||
if active and renewal_available
|
||||
@@ -953,9 +975,7 @@ async def _create_subscription_payment(
|
||||
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
|
||||
if hwid_quote
|
||||
else None,
|
||||
hwid_proration_ratio=hwid_quote.get("proration_ratio")
|
||||
if hwid_quote
|
||||
else None,
|
||||
hwid_proration_ratio=hwid_quote.get("proration_ratio") if hwid_quote else None,
|
||||
hwid_full_price=hwid_quote.get("full_price") if hwid_quote else None,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -193,6 +193,15 @@ def _format_device_datetime(value: Any) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _serialize_device_datetime(value: Any) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]:
|
||||
hwid = str(device.get("hwid") or "").strip()
|
||||
model = str(device.get("deviceModel") or "").strip()
|
||||
@@ -208,7 +217,7 @@ def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]:
|
||||
"os_version": os_version,
|
||||
"platform_label": platform_label,
|
||||
"user_agent": user_agent,
|
||||
"created_at": device.get("createdAt"),
|
||||
"created_at": _serialize_device_datetime(device.get("createdAt")),
|
||||
"created_at_text": _format_device_datetime(device.get("createdAt")),
|
||||
"hwid_short": _shorten_hwid_for_display(hwid),
|
||||
"token": _device_hwid_token(hwid) if hwid else "",
|
||||
|
||||
@@ -19,7 +19,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get(
|
||||
(
|
||||
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
|
||||
"appearance|settings|translations|support}"
|
||||
"appearance|settings|translations|support|backups}"
|
||||
),
|
||||
index_route,
|
||||
)
|
||||
@@ -35,6 +35,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/apple-touch-icon-precomposed.png", webapp_current_favicon_route)
|
||||
app.router.add_get("/icon-192.png", webapp_current_favicon_route)
|
||||
app.router.add_get("/icon-512.png", webapp_current_favicon_route)
|
||||
app.router.add_get(WEBAPP_DEFAULT_LOGO_PATH, webapp_default_logo_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_.-]+}}",
|
||||
|
||||
@@ -611,14 +611,12 @@ def _serialize_tariff_change_target(
|
||||
"mode": "recalc_days",
|
||||
"kind": "free",
|
||||
"title": "recalc_days",
|
||||
"days_after": int(options.get("recalc_days") or 0),
|
||||
"remaining_days": int(options.get("remaining_days") or 0),
|
||||
"converted_hwid_value_rub": float(
|
||||
options.get("converted_hwid_value_rub") or 0
|
||||
),
|
||||
"converted_hwid_days": int(options.get("converted_hwid_days") or 0),
|
||||
}
|
||||
)
|
||||
"days_after": int(options.get("recalc_days") or 0),
|
||||
"remaining_days": int(options.get("remaining_days") or 0),
|
||||
"converted_hwid_value_rub": float(options.get("converted_hwid_value_rub") or 0),
|
||||
"converted_hwid_days": int(options.get("converted_hwid_days") or 0),
|
||||
}
|
||||
)
|
||||
paid_diff = float(options.get("paid_diff_rub") or 0)
|
||||
if paid_diff > 0:
|
||||
actions.append(
|
||||
@@ -638,9 +636,7 @@ def _serialize_tariff_change_target(
|
||||
"title": "convert_days_to_gb",
|
||||
"converted_gb": float(options.get("converted_gb") or 0),
|
||||
"remaining_days": int(options.get("remaining_days") or 0),
|
||||
"converted_hwid_value_rub": float(
|
||||
options.get("converted_hwid_value_rub") or 0
|
||||
),
|
||||
"converted_hwid_value_rub": float(options.get("converted_hwid_value_rub") or 0),
|
||||
"converted_hwid_gb": float(options.get("converted_hwid_gb") or 0),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1203,15 +1203,13 @@ async def _perform_sync_impl(
|
||||
merge_panel_patches = int(merge_result.get("panel_patches", 0))
|
||||
if merge_panel_patches:
|
||||
panel_patch_count += merge_panel_patches
|
||||
panel_patch_reason_counts[
|
||||
"duplicate_panel_merge_extend"
|
||||
] += merge_panel_patches
|
||||
panel_patch_reason_counts["duplicate_panel_merge_extend"] += (
|
||||
merge_panel_patches
|
||||
)
|
||||
if merge_result["resolved"]:
|
||||
users_updated += 1
|
||||
users_uuid_updated += 1
|
||||
local_update_reason_counts.update(
|
||||
["duplicate_panel_identity_resolved"]
|
||||
)
|
||||
local_update_reason_counts.update(["duplicate_panel_identity_resolved"])
|
||||
panel_uuids_by_telegram_id.get(telegram_id_from_panel, set()).discard(
|
||||
str(panel_uuid)
|
||||
)
|
||||
@@ -1322,9 +1320,7 @@ async def _perform_sync_impl(
|
||||
current_panel_user=panel_user_for_identity,
|
||||
reasons=panel_reasons,
|
||||
panel_view=(
|
||||
"list"
|
||||
if missing_identity_fields_match
|
||||
else "full_fetch"
|
||||
"list" if missing_identity_fields_match else "full_fetch"
|
||||
),
|
||||
)
|
||||
panel_patch_count += 1
|
||||
|
||||
@@ -543,9 +543,7 @@ async def hwid_devices_list_callback(
|
||||
renewal=renewal_available,
|
||||
)
|
||||
text_key = (
|
||||
"select_hwid_device_renewal_package"
|
||||
if renewal_available
|
||||
else "select_hwid_device_package"
|
||||
"select_hwid_device_renewal_package" if renewal_available else "select_hwid_device_package"
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
|
||||
@@ -160,10 +160,7 @@ def locale_language_flag(code: object) -> str:
|
||||
|
||||
|
||||
def sort_locale_language_codes(codes: Iterable[object]) -> List[str]:
|
||||
normalized = {
|
||||
normalize_locale_language_code(code, prefer_known_base=False)
|
||||
for code in codes
|
||||
}
|
||||
normalized = {normalize_locale_language_code(code, prefer_known_base=False) for code in codes}
|
||||
normalized = {code for code in normalized if code and is_valid_locale_language_code(code)}
|
||||
preferred = [code for code in DEFAULT_LANGUAGE_ORDER if code in normalized]
|
||||
rest = sorted(code for code in normalized if code not in DEFAULT_LANGUAGE_ORDER)
|
||||
|
||||
@@ -270,6 +270,14 @@ class CryptoPayService:
|
||||
referral_service: ReferralService = app["referral_service"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment:
|
||||
logging.error("CryptoPay webhook: payment %s not found.", payment_db_id)
|
||||
return
|
||||
if payment.status == "succeeded":
|
||||
logging.info("CryptoPay webhook: payment %s already succeeded.", payment_db_id)
|
||||
return
|
||||
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
|
||||
@@ -49,6 +49,7 @@ from .shared import (
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
post_json_request,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
@@ -382,10 +383,10 @@ class FreeKassaService(HttpClientMixin):
|
||||
)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
months = payment.purchased_gb or payment.subscription_duration_months or 1
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
months = payment_units_for_activation(payment, sale_mode)
|
||||
|
||||
success_prefix: Optional[str] = None
|
||||
if provider_payment_id:
|
||||
|
||||
@@ -49,6 +49,7 @@ from .shared import (
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
)
|
||||
@@ -469,10 +470,10 @@ class HeleketService(HttpClientMixin):
|
||||
)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
payment_units = payment_units_for_activation(payment, sale_mode)
|
||||
|
||||
outcome = await finalize_successful_payment(
|
||||
PaymentSuccessRequest(
|
||||
|
||||
@@ -45,6 +45,7 @@ from .shared import (
|
||||
payment_failed,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
post_json_request,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
@@ -303,10 +304,10 @@ class PlategaService(HttpClientMixin):
|
||||
if payment.status == "succeeded" and status == "CONFIRMED":
|
||||
return web.Response(text="ok")
|
||||
|
||||
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
payment_months = payment_units_for_activation(payment, sale_mode)
|
||||
|
||||
if status == "CONFIRMED":
|
||||
if amount_raw is not None:
|
||||
|
||||
@@ -46,6 +46,7 @@ from .shared import (
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
post_json_request,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
@@ -270,12 +271,19 @@ class SeverPayService(HttpClientMixin):
|
||||
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
|
||||
|
||||
resolved_provider_id = provider_payment_id or str(payment.payment_id)
|
||||
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
payment_months = payment_units_for_activation(payment, sale_mode)
|
||||
|
||||
if status == "success":
|
||||
if payment.status == "succeeded":
|
||||
logging.info(
|
||||
"SeverPay webhook: payment %s already succeeded.",
|
||||
payment.payment_id,
|
||||
)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
|
||||
@@ -36,10 +36,12 @@ from .common import (
|
||||
json_error,
|
||||
make_translator,
|
||||
mark_payment_failed_creation,
|
||||
parse_positive_int_units,
|
||||
payment_failed,
|
||||
payment_link_response,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
sale_mode_base,
|
||||
sale_mode_is_hwid_devices,
|
||||
sale_mode_is_traffic,
|
||||
@@ -103,6 +105,7 @@ __all__ = [
|
||||
"lookup_payment_by_order_or_provider_id",
|
||||
"make_translator",
|
||||
"mark_payment_failed_creation",
|
||||
"parse_positive_int_units",
|
||||
"notify_admins_payment_received",
|
||||
"notify_callback_parse_error",
|
||||
"notify_payment_gateway_failure",
|
||||
@@ -114,6 +117,7 @@ __all__ = [
|
||||
"payment_link_message_text",
|
||||
"payment_link_response",
|
||||
"payment_record_amounts",
|
||||
"payment_units_for_activation",
|
||||
"payment_unavailable",
|
||||
"post_json_request",
|
||||
"quote_hwid_callback_parts",
|
||||
|
||||
@@ -20,6 +20,7 @@ from .common import (
|
||||
build_payment_description,
|
||||
format_human_units,
|
||||
mark_payment_failed_creation,
|
||||
parse_positive_int_units,
|
||||
sale_mode_base,
|
||||
sale_mode_is_hwid_devices,
|
||||
sale_mode_tariff_key,
|
||||
@@ -124,10 +125,13 @@ async def quote_hwid_callback_parts(
|
||||
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
|
||||
if not sale_mode_is_hwid_devices(parts.sale_mode):
|
||||
return parts, None
|
||||
device_count = parse_positive_int_units(parts.months)
|
||||
if device_count is None:
|
||||
return None, None
|
||||
quote = await subscription_service.quote_hwid_device_topup(
|
||||
session,
|
||||
user_id=user_id,
|
||||
device_count=int(parts.months),
|
||||
device_count=device_count,
|
||||
tariff_key=sale_mode_tariff_key(parts.sale_mode),
|
||||
renewal=sale_mode_base(parts.sale_mode) == "hwid_devices_renewal",
|
||||
currency=currency,
|
||||
@@ -135,7 +139,7 @@ async def quote_hwid_callback_parts(
|
||||
if not quote:
|
||||
return None, None
|
||||
quoted_parts = PaymentCallbackParts(
|
||||
months=parts.months,
|
||||
months=device_count,
|
||||
price=float(quote.get("price") or 0),
|
||||
sale_mode=parts.sale_mode,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from aiohttp import web
|
||||
@@ -36,6 +36,20 @@ def decimal_amounts_equal(left: Any, right: Any, places: int = 2) -> bool:
|
||||
return format_decimal_amount(left, places) == format_decimal_amount(right, places)
|
||||
|
||||
|
||||
def parse_positive_int_units(value: Any) -> Optional[int]:
|
||||
"""Return a positive integer only when the input represents whole units exactly."""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
decimal_value = Decimal(str(value).strip())
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
if not decimal_value.is_finite() or decimal_value != decimal_value.to_integral_value():
|
||||
return None
|
||||
integer_value = int(decimal_value)
|
||||
return integer_value if integer_value > 0 else None
|
||||
|
||||
|
||||
def format_human_units(value: Any) -> str:
|
||||
"""Render numeric units the way the UI expects: integers w/o decimals, floats with %g."""
|
||||
numeric = float(value)
|
||||
@@ -164,6 +178,24 @@ def payment_record_amounts(
|
||||
)
|
||||
|
||||
|
||||
def payment_units_for_activation(payment: Any, sale_mode: str) -> Any:
|
||||
"""Resolve purchased units from a payment record for webhook activation."""
|
||||
base = sale_mode_base(sale_mode)
|
||||
if sale_mode_is_traffic(base):
|
||||
return (
|
||||
getattr(payment, "purchased_gb", None)
|
||||
or getattr(payment, "subscription_duration_months", None)
|
||||
or 1
|
||||
)
|
||||
if sale_mode_is_hwid_devices(base):
|
||||
return (
|
||||
getattr(payment, "purchased_hwid_devices", None)
|
||||
or getattr(payment, "subscription_duration_months", None)
|
||||
or 1
|
||||
)
|
||||
return getattr(payment, "subscription_duration_months", None) or 1
|
||||
|
||||
|
||||
def json_error(status: int, code: str, message: str) -> web.Response:
|
||||
return web.json_response({"ok": False, "error": code, "message": message}, status=status)
|
||||
|
||||
|
||||
@@ -143,6 +143,14 @@ class StarsService:
|
||||
i18n_data: dict,
|
||||
sale_mode: str = "subscription",
|
||||
) -> None:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment:
|
||||
logging.error("Stars: payment %s not found.", payment_db_id)
|
||||
return
|
||||
if payment.status == "succeeded":
|
||||
logging.info("Stars: payment %s already succeeded.", payment_db_id)
|
||||
return
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
@@ -162,9 +170,6 @@ class StarsService:
|
||||
else int(message.from_user.id)
|
||||
)
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment:
|
||||
logging.error("Stars: payment %s vanished after status update.", payment_db_id)
|
||||
return
|
||||
|
||||
await finalize_successful_payment(
|
||||
PaymentSuccessRequest(
|
||||
|
||||
@@ -52,6 +52,7 @@ from .shared import (
|
||||
payment_link_response,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
post_json_request,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
@@ -531,10 +532,10 @@ class WataService(HttpClientMixin):
|
||||
)
|
||||
return None
|
||||
|
||||
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
payment_units = payment_units_for_activation(payment, sale_mode)
|
||||
outcome = await finalize_successful_payment(
|
||||
PaymentSuccessRequest(
|
||||
bot=self.bot,
|
||||
|
||||
@@ -63,6 +63,7 @@ from .shared import (
|
||||
make_translator,
|
||||
mark_payment_failed_creation,
|
||||
notify_admins_payment_received,
|
||||
parse_positive_int_units,
|
||||
payment_failed,
|
||||
payment_link_response,
|
||||
payment_record_amounts,
|
||||
@@ -144,10 +145,12 @@ class YooKassaService:
|
||||
bot_username_for_default_return: Optional[str] = None,
|
||||
settings_obj: Optional[Settings] = None,
|
||||
config: Optional[YooKassaConfig] = None,
|
||||
subscription_service: Optional[SubscriptionService] = None,
|
||||
):
|
||||
|
||||
self.settings = settings_obj
|
||||
self.config = config or YooKassaConfig()
|
||||
self.subscription_service = subscription_service
|
||||
self._bot_username_for_default_return = bot_username_for_default_return
|
||||
self._configured_return_url_override = configured_return_url
|
||||
self._sdk_configured_for = (
|
||||
@@ -169,9 +172,7 @@ class YooKassaService:
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
if not (
|
||||
provider_runtime_enabled(self.config)
|
||||
and self.config.SHOP_ID
|
||||
and self.config.SECRET_KEY
|
||||
provider_runtime_enabled(self.config) and self.config.SHOP_ID and self.config.SECRET_KEY
|
||||
):
|
||||
return False
|
||||
self._ensure_sdk_configured()
|
||||
@@ -444,11 +445,17 @@ def _resolve_yookassa_activation_amounts(
|
||||
traffic_amount_gb = (
|
||||
float(traffic_gb_raw) if _metadata_value_present(traffic_gb_raw) else subscription_months
|
||||
)
|
||||
hwid_devices_count = (
|
||||
int(float(hwid_devices_raw))
|
||||
if _metadata_value_present(hwid_devices_raw)
|
||||
else (int(subscription_months) if _is_hwid_device_sale_base(sale_mode_base) else 0)
|
||||
)
|
||||
hwid_devices_count = 0
|
||||
if _metadata_value_present(hwid_devices_raw):
|
||||
parsed_hwid_devices = parse_positive_int_units(hwid_devices_raw)
|
||||
if parsed_hwid_devices is None:
|
||||
raise ValueError("Invalid HWID device count")
|
||||
hwid_devices_count = parsed_hwid_devices
|
||||
elif _is_hwid_device_sale_base(sale_mode_base):
|
||||
parsed_hwid_devices = parse_positive_int_units(subscription_months_raw)
|
||||
if parsed_hwid_devices is None:
|
||||
raise ValueError("Invalid HWID device count")
|
||||
hwid_devices_count = parsed_hwid_devices
|
||||
|
||||
if sale_mode_base == "subscription":
|
||||
months_for_activation = int(subscription_months)
|
||||
@@ -1327,9 +1334,7 @@ async def _initiate_yk_payment(
|
||||
"purchased_gb": float(months)
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else None,
|
||||
"purchased_hwid_devices": int(months)
|
||||
if sale_base in HWID_DEVICE_SALE_BASES
|
||||
else None,
|
||||
"purchased_hwid_devices": int(months) if sale_base in HWID_DEVICE_SALE_BASES else None,
|
||||
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
|
||||
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
|
||||
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
|
||||
@@ -2684,6 +2689,7 @@ def create_service(ctx: ServiceFactoryContext) -> YooKassaService:
|
||||
bot_username_for_default_return=ctx.bot_username_for_default_return,
|
||||
settings_obj=ctx.settings,
|
||||
config=config,
|
||||
subscription_service=ctx.subscription_service,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import hashlib
|
||||
import json
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
BACKUP_APP_ID = "remnawave-minishop"
|
||||
BACKUP_FILENAME_PREFIX = "minishop-"
|
||||
BACKUP_FORMAT_VERSION = 1
|
||||
BACKUP_MANIFEST_NAME = "manifest.json"
|
||||
|
||||
|
||||
def backup_filename_timestamp() -> str:
|
||||
return datetime.now().astimezone().strftime("%Y%m%d-%H-%M")
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def build_file_records(source_dir: Path) -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
for path in sorted(source_dir.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
relative = path.relative_to(source_dir).as_posix()
|
||||
if relative == BACKUP_MANIFEST_NAME:
|
||||
continue
|
||||
stat = path.stat()
|
||||
records.append(
|
||||
{
|
||||
"path": relative,
|
||||
"size_bytes": int(stat.st_size),
|
||||
"sha256": file_sha256(path),
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def attach_archive_integrity(
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
file_records: list[dict[str, Any]],
|
||||
) -> None:
|
||||
manifest["app"] = BACKUP_APP_ID
|
||||
manifest["format_version"] = BACKUP_FORMAT_VERSION
|
||||
manifest["archive"] = {
|
||||
"files": file_records,
|
||||
}
|
||||
|
||||
|
||||
def write_manifest(source_dir: Path, manifest: dict[str, Any]) -> None:
|
||||
(source_dir / BACKUP_MANIFEST_NAME).write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def write_zip_from_directory(source_dir: Path, archive_path: Path) -> None:
|
||||
with zipfile.ZipFile(
|
||||
archive_path,
|
||||
mode="w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=6,
|
||||
) as archive:
|
||||
for path in sorted(source_dir.rglob("*")):
|
||||
if path.is_file():
|
||||
archive.write(path, path.relative_to(source_dir).as_posix())
|
||||
@@ -0,0 +1,642 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Optional
|
||||
|
||||
from bot.services.backup_archive import (
|
||||
BACKUP_APP_ID,
|
||||
BACKUP_FILENAME_PREFIX,
|
||||
BACKUP_FORMAT_VERSION,
|
||||
BACKUP_MANIFEST_NAME,
|
||||
attach_archive_integrity,
|
||||
backup_filename_timestamp,
|
||||
build_file_records,
|
||||
write_manifest,
|
||||
write_zip_from_directory,
|
||||
)
|
||||
from bot.services.backup_worker import (
|
||||
DEFAULT_COMPOSE_EXCLUDED_DIRS,
|
||||
)
|
||||
from config.settings import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BACKUP_UPLOAD_MAX_BYTES = 2 * 1024 * 1024 * 1024
|
||||
BACKUP_MAX_MEMBERS = 20_000
|
||||
BACKUP_MAX_MEMBER_BYTES = 4 * 1024 * 1024 * 1024
|
||||
BACKUP_MAX_UNCOMPRESSED_BYTES = 16 * 1024 * 1024 * 1024
|
||||
BACKUP_MAX_COMPOSE_BYTES = 1024 * 1024 * 1024
|
||||
BACKUP_MAX_COMPOSE_MEMBER_BYTES = 256 * 1024 * 1024
|
||||
BACKUP_MAX_COMPRESSION_RATIO = 200
|
||||
BACKUP_ZIP_BOMB_MIN_BYTES = 100 * 1024 * 1024
|
||||
COMPOSE_PRE_RESTORE_PREFIX = "minishop-pre-restore-"
|
||||
SAFE_ARCHIVE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.@+-]{0,220}\.zip$")
|
||||
|
||||
|
||||
class BackupArchiveError(ValueError):
|
||||
"""The selected archive cannot be used for restore."""
|
||||
|
||||
|
||||
class BackupRestoreError(RuntimeError):
|
||||
"""Restore command failed after archive validation."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackupArchiveInfo:
|
||||
name: str
|
||||
path: Path
|
||||
size_bytes: int
|
||||
modified_at: datetime
|
||||
created_at: Optional[str] = None
|
||||
created_at_local: Optional[str] = None
|
||||
has_database: bool = False
|
||||
has_compose: bool = False
|
||||
database_name: Optional[str] = None
|
||||
compose_files_count: int = 0
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
manifest: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"size_bytes": self.size_bytes,
|
||||
"modified_at": self.modified_at.isoformat(),
|
||||
"created_at": self.created_at,
|
||||
"created_at_local": self.created_at_local,
|
||||
"has_database": self.has_database,
|
||||
"has_compose": self.has_compose,
|
||||
"database_name": self.database_name,
|
||||
"compose_files_count": self.compose_files_count,
|
||||
"warnings": self.warnings,
|
||||
"manifest": self.manifest,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackupRestoreResult:
|
||||
archive_name: str
|
||||
started_at: datetime
|
||||
completed_at: datetime
|
||||
database_restored: bool = False
|
||||
compose_files_restored: int = 0
|
||||
compose_target_dir: Optional[str] = None
|
||||
compose_pre_restore_archive: Optional[str] = None
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"archive_name": self.archive_name,
|
||||
"started_at": self.started_at.isoformat(),
|
||||
"completed_at": self.completed_at.isoformat(),
|
||||
"database_restored": self.database_restored,
|
||||
"compose_files_restored": self.compose_files_restored,
|
||||
"compose_target_dir": self.compose_target_dir,
|
||||
"compose_pre_restore_archive": self.compose_pre_restore_archive,
|
||||
"warnings": self.warnings,
|
||||
}
|
||||
|
||||
|
||||
class BackupRestoreService:
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
|
||||
def backup_dir(self) -> Path:
|
||||
path = Path(self.settings.BACKUP_DIR).expanduser()
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
def list_archives(self) -> list[BackupArchiveInfo]:
|
||||
backup_dir = self.backup_dir()
|
||||
archives = []
|
||||
for path in backup_dir.glob("*.zip"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
archives.append(self.inspect_archive(path))
|
||||
except BackupArchiveError as exc:
|
||||
logger.warning("Skipping invalid backup archive %s: %s", path, exc)
|
||||
return sorted(archives, key=lambda item: item.modified_at, reverse=True)
|
||||
|
||||
def archive_path_for_name(self, archive_name: str) -> Path:
|
||||
raw_name = str(archive_name or "").strip()
|
||||
safe_name = Path(raw_name).name
|
||||
if not raw_name or safe_name != raw_name or not SAFE_ARCHIVE_NAME_RE.fullmatch(safe_name):
|
||||
raise BackupArchiveError("Invalid archive name")
|
||||
|
||||
backup_dir = self.backup_dir().resolve()
|
||||
archive_path = (backup_dir / safe_name).resolve()
|
||||
try:
|
||||
archive_path.relative_to(backup_dir)
|
||||
except ValueError as exc:
|
||||
raise BackupArchiveError("Archive path escapes backup directory") from exc
|
||||
if not archive_path.is_file():
|
||||
raise BackupArchiveError("Archive does not exist")
|
||||
return archive_path
|
||||
|
||||
def inspect_archive(self, archive_path: Path) -> BackupArchiveInfo:
|
||||
if not zipfile.is_zipfile(archive_path):
|
||||
raise BackupArchiveError("Archive is not a valid ZIP file")
|
||||
|
||||
stat = archive_path.stat()
|
||||
warnings: list[str] = []
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
self._validate_zip_members(archive.infolist())
|
||||
manifest = self._read_manifest(archive)
|
||||
has_database = self._find_database_dump_member(archive) is not None
|
||||
compose_members = self._compose_file_members(archive)
|
||||
|
||||
manifest_warnings = manifest.get("warnings")
|
||||
if isinstance(manifest_warnings, list):
|
||||
warnings.extend(str(item) for item in manifest_warnings if item)
|
||||
|
||||
postgres = manifest.get("postgres") if isinstance(manifest.get("postgres"), dict) else {}
|
||||
compose = manifest.get("compose") if isinstance(manifest.get("compose"), dict) else {}
|
||||
return BackupArchiveInfo(
|
||||
name=archive_path.name,
|
||||
path=archive_path,
|
||||
size_bytes=int(stat.st_size),
|
||||
modified_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||
created_at=str(manifest.get("created_at") or "") or None,
|
||||
created_at_local=str(manifest.get("created_at_local") or "") or None,
|
||||
has_database=has_database,
|
||||
has_compose=bool(compose_members),
|
||||
database_name=str(postgres.get("database") or "") or None,
|
||||
compose_files_count=int(compose.get("files_count") or len(compose_members)),
|
||||
warnings=warnings,
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
def import_uploaded_archive(
|
||||
self,
|
||||
temp_path: Path,
|
||||
original_filename: str = "",
|
||||
) -> BackupArchiveInfo:
|
||||
self._validate_archive_for_restore(temp_path)
|
||||
digest = self._file_digest(temp_path)
|
||||
stamp = backup_filename_timestamp()
|
||||
archive_name = f"{BACKUP_FILENAME_PREFIX}uploaded-{stamp}-{digest}.zip"
|
||||
target_path = self._unique_archive_path(archive_name)
|
||||
temp_path.replace(target_path)
|
||||
return self.inspect_archive(target_path)
|
||||
|
||||
async def restore_archive(
|
||||
self,
|
||||
archive_name: str,
|
||||
*,
|
||||
restore_database: bool,
|
||||
restore_compose: bool,
|
||||
) -> BackupRestoreResult:
|
||||
return await asyncio.to_thread(
|
||||
self.restore_archive_sync,
|
||||
archive_name,
|
||||
restore_database=restore_database,
|
||||
restore_compose=restore_compose,
|
||||
)
|
||||
|
||||
def restore_archive_sync(
|
||||
self,
|
||||
archive_name: str,
|
||||
*,
|
||||
restore_database: bool,
|
||||
restore_compose: bool,
|
||||
) -> BackupRestoreResult:
|
||||
if not restore_database and not restore_compose:
|
||||
raise BackupArchiveError("Select at least one restore target")
|
||||
|
||||
archive_path = self.archive_path_for_name(archive_name)
|
||||
self._validate_archive_for_restore(archive_path)
|
||||
started_at = datetime.now(timezone.utc)
|
||||
warnings: list[str] = []
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix=f"restore-{archive_path.stem}-",
|
||||
dir=self.backup_dir(),
|
||||
) as tmp:
|
||||
temp_dir = Path(tmp)
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
self._validate_zip_members(archive.infolist())
|
||||
db_member = self._find_database_dump_member(archive) if restore_database else None
|
||||
compose_members = self._compose_file_members(archive) if restore_compose else []
|
||||
|
||||
if restore_database and db_member is None:
|
||||
raise BackupArchiveError("Archive does not contain a database dump")
|
||||
if restore_compose and not compose_members:
|
||||
raise BackupArchiveError("Archive does not contain compose files")
|
||||
|
||||
compose_target_dir: Optional[Path] = None
|
||||
compose_pre_restore_archive: Optional[Path] = None
|
||||
if restore_compose:
|
||||
compose_target_dir = self._compose_restore_target_dir()
|
||||
self._assert_compose_target_writable(compose_target_dir)
|
||||
compose_pre_restore_archive = self._snapshot_current_compose(compose_target_dir)
|
||||
|
||||
database_restored = False
|
||||
if db_member is not None:
|
||||
dump_path = self._extract_database_dump(archive, db_member, temp_dir)
|
||||
self._run_pg_restore(dump_path)
|
||||
database_restored = True
|
||||
|
||||
compose_files_restored = 0
|
||||
if compose_target_dir is not None:
|
||||
compose_files_restored = self._restore_compose_members(
|
||||
archive,
|
||||
compose_members,
|
||||
compose_target_dir,
|
||||
)
|
||||
|
||||
return BackupRestoreResult(
|
||||
archive_name=archive_path.name,
|
||||
started_at=started_at,
|
||||
completed_at=datetime.now(timezone.utc),
|
||||
database_restored=database_restored,
|
||||
compose_files_restored=compose_files_restored,
|
||||
compose_target_dir=str(compose_target_dir) if compose_target_dir else None,
|
||||
compose_pre_restore_archive=str(compose_pre_restore_archive)
|
||||
if compose_pre_restore_archive
|
||||
else None,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
def _run_pg_restore(self, dump_path: Path) -> None:
|
||||
pg_restore_path = str(getattr(self.settings, "BACKUP_PG_RESTORE_PATH", "pg_restore") or "")
|
||||
pg_restore_path = pg_restore_path or "pg_restore"
|
||||
if shutil.which(pg_restore_path) is None and Path(pg_restore_path).name == pg_restore_path:
|
||||
raise BackupRestoreError(
|
||||
"pg_restore executable was not found. Rebuild the backend image with "
|
||||
"PostgreSQL client tools."
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PGPASSWORD"] = self.settings.POSTGRES_PASSWORD
|
||||
command = [
|
||||
pg_restore_path,
|
||||
"-h",
|
||||
self.settings.POSTGRES_HOST,
|
||||
"-p",
|
||||
str(self.settings.POSTGRES_PORT),
|
||||
"-U",
|
||||
self.settings.POSTGRES_USER,
|
||||
"-d",
|
||||
self.settings.POSTGRES_DB,
|
||||
"--clean",
|
||||
"--if-exists",
|
||||
"--no-owner",
|
||||
"--no-privileges",
|
||||
str(dump_path),
|
||||
]
|
||||
timeout = max(
|
||||
30,
|
||||
int(
|
||||
getattr(
|
||||
self.settings,
|
||||
"BACKUP_PG_RESTORE_TIMEOUT_SECONDS",
|
||||
self.settings.BACKUP_PG_DUMP_TIMEOUT_SECONDS,
|
||||
)
|
||||
or 1800
|
||||
),
|
||||
)
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = (result.stderr or result.stdout or "").strip()
|
||||
raise BackupRestoreError(
|
||||
f"pg_restore failed with exit code {result.returncode}: {stderr[:500]}"
|
||||
)
|
||||
|
||||
def _compose_restore_target_dir(self) -> Path:
|
||||
target_raw = (
|
||||
getattr(self.settings, "BACKUP_COMPOSE_RESTORE_DIR", None)
|
||||
or self.settings.BACKUP_COMPOSE_SOURCE_DIR
|
||||
or ""
|
||||
)
|
||||
if not str(target_raw).strip():
|
||||
raise BackupArchiveError("Compose restore directory is not configured")
|
||||
return Path(str(target_raw)).expanduser()
|
||||
|
||||
def _assert_compose_target_writable(self, target_dir: Path) -> None:
|
||||
if not target_dir.exists() or not target_dir.is_dir():
|
||||
raise BackupArchiveError(
|
||||
f"Compose restore directory is unavailable: {target_dir}. "
|
||||
"Mount the compose folder into the backend container."
|
||||
)
|
||||
probe = target_dir / f".restore-write-test-{os.getpid()}"
|
||||
try:
|
||||
probe.write_text("", encoding="utf-8")
|
||||
probe.unlink()
|
||||
except OSError as exc:
|
||||
raise BackupArchiveError(
|
||||
f"Compose restore directory is not writable: {target_dir}"
|
||||
) from exc
|
||||
|
||||
def _snapshot_current_compose(self, target_dir: Path) -> Optional[Path]:
|
||||
stamp = backup_filename_timestamp()
|
||||
archive_path = self._unique_archive_path(f"{COMPOSE_PRE_RESTORE_PREFIX}{stamp}.zip")
|
||||
excluded_dirs = self._compose_excluded_dirs()
|
||||
files_count = 0
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix=f"{archive_path.stem}-",
|
||||
dir=self.backup_dir(),
|
||||
) as tmp:
|
||||
staging_dir = Path(tmp)
|
||||
compose_dir = staging_dir / "compose"
|
||||
for path in sorted(target_dir.rglob("*")):
|
||||
relative = path.relative_to(target_dir)
|
||||
if any(part in excluded_dirs for part in relative.parts):
|
||||
continue
|
||||
if path.is_dir() or path.is_symlink():
|
||||
continue
|
||||
destination = compose_dir / relative
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, destination)
|
||||
files_count += 1
|
||||
if files_count <= 0:
|
||||
return None
|
||||
|
||||
completed_at = datetime.now(timezone.utc)
|
||||
manifest = {
|
||||
"app": BACKUP_APP_ID,
|
||||
"format_version": BACKUP_FORMAT_VERSION,
|
||||
"type": "compose-pre-restore",
|
||||
"created_at": completed_at.isoformat(),
|
||||
"created_at_local": completed_at.astimezone().isoformat(),
|
||||
"postgres": {
|
||||
"database": self.settings.POSTGRES_DB,
|
||||
"included": False,
|
||||
},
|
||||
"compose": {
|
||||
"source_dir": str(target_dir),
|
||||
"included": True,
|
||||
"files_count": files_count,
|
||||
},
|
||||
"warnings": [],
|
||||
}
|
||||
attach_archive_integrity(
|
||||
manifest,
|
||||
file_records=build_file_records(staging_dir),
|
||||
)
|
||||
write_manifest(staging_dir, manifest)
|
||||
tmp_archive = archive_path.with_name(f"{archive_path.name}.tmp")
|
||||
try:
|
||||
write_zip_from_directory(staging_dir, tmp_archive)
|
||||
tmp_archive.replace(archive_path)
|
||||
finally:
|
||||
if tmp_archive.exists():
|
||||
try:
|
||||
tmp_archive.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove temporary snapshot %s", tmp_archive)
|
||||
return archive_path
|
||||
|
||||
def _restore_compose_members(
|
||||
self,
|
||||
archive: zipfile.ZipFile,
|
||||
members: list[zipfile.ZipInfo],
|
||||
target_dir: Path,
|
||||
) -> int:
|
||||
target_root = target_dir.resolve()
|
||||
restored = 0
|
||||
for member in members:
|
||||
relative = PurePosixPath(member.filename).relative_to("compose")
|
||||
destination = target_root.joinpath(*relative.parts).resolve()
|
||||
try:
|
||||
destination.relative_to(target_root)
|
||||
except ValueError as exc:
|
||||
raise BackupArchiveError(
|
||||
f"Unsafe compose archive member: {member.filename}"
|
||||
) from exc
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_destination = destination.with_name(
|
||||
f".{destination.name}.restore-{os.getpid()}.tmp"
|
||||
)
|
||||
try:
|
||||
with archive.open(member) as source, temp_destination.open("wb") as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
temp_destination.replace(destination)
|
||||
finally:
|
||||
if temp_destination.exists():
|
||||
try:
|
||||
temp_destination.unlink()
|
||||
except OSError:
|
||||
logger.warning(
|
||||
"Failed to remove temporary restore file %s",
|
||||
temp_destination,
|
||||
)
|
||||
restored += 1
|
||||
return restored
|
||||
|
||||
def _extract_database_dump(
|
||||
self,
|
||||
archive: zipfile.ZipFile,
|
||||
member: zipfile.ZipInfo,
|
||||
temp_dir: Path,
|
||||
) -> Path:
|
||||
dump_dir = temp_dir / "database"
|
||||
dump_dir.mkdir(parents=True, exist_ok=True)
|
||||
dump_path = dump_dir / Path(member.filename).name
|
||||
with archive.open(member) as source, dump_path.open("wb") as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
return dump_path
|
||||
|
||||
def _find_database_dump_member(self, archive: zipfile.ZipFile) -> Optional[zipfile.ZipInfo]:
|
||||
candidates = [
|
||||
item
|
||||
for item in archive.infolist()
|
||||
if not item.is_dir()
|
||||
and item.filename.startswith("database/")
|
||||
and PurePosixPath(item.filename).suffix.lower() in {".dump", ".backup"}
|
||||
]
|
||||
return sorted(candidates, key=lambda item: item.filename)[0] if candidates else None
|
||||
|
||||
def _compose_file_members(self, archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]:
|
||||
members = [
|
||||
item
|
||||
for item in archive.infolist()
|
||||
if not item.is_dir() and item.filename.startswith("compose/")
|
||||
]
|
||||
self._validate_compose_members(members)
|
||||
return members
|
||||
|
||||
def _validate_zip_members(self, members: list[zipfile.ZipInfo]) -> None:
|
||||
if len(members) > BACKUP_MAX_MEMBERS:
|
||||
raise BackupArchiveError("Archive contains too many files")
|
||||
|
||||
seen: set[str] = set()
|
||||
total_size = 0
|
||||
for member in members:
|
||||
filename = member.filename
|
||||
if "\\" in filename or "\x00" in filename:
|
||||
raise BackupArchiveError(f"Unsafe archive member path: {filename}")
|
||||
path = PurePosixPath(member.filename)
|
||||
if (
|
||||
not path.parts
|
||||
or path.is_absolute()
|
||||
or ".." in path.parts
|
||||
or any(part in {"", "."} for part in path.parts)
|
||||
):
|
||||
raise BackupArchiveError(f"Unsafe archive member path: {member.filename}")
|
||||
if member.is_dir():
|
||||
continue
|
||||
if filename in seen:
|
||||
raise BackupArchiveError(f"Duplicate archive member path: {filename}")
|
||||
seen.add(filename)
|
||||
if member.file_size > BACKUP_MAX_MEMBER_BYTES:
|
||||
raise BackupArchiveError(f"Archive member is too large: {filename}")
|
||||
total_size += int(member.file_size)
|
||||
if total_size > BACKUP_MAX_UNCOMPRESSED_BYTES:
|
||||
raise BackupArchiveError("Archive uncompressed size is too large")
|
||||
compressed = max(1, int(member.compress_size or 1))
|
||||
ratio = int(member.file_size) / compressed
|
||||
if (
|
||||
member.file_size >= BACKUP_ZIP_BOMB_MIN_BYTES
|
||||
and ratio > BACKUP_MAX_COMPRESSION_RATIO
|
||||
):
|
||||
raise BackupArchiveError(
|
||||
f"Archive member compression ratio is too high: {filename}"
|
||||
)
|
||||
|
||||
def _validate_compose_members(self, members: list[zipfile.ZipInfo]) -> None:
|
||||
total_size = 0
|
||||
for member in members:
|
||||
if member.file_size > BACKUP_MAX_COMPOSE_MEMBER_BYTES:
|
||||
raise BackupArchiveError(f"Compose archive member is too large: {member.filename}")
|
||||
total_size += int(member.file_size)
|
||||
if total_size > BACKUP_MAX_COMPOSE_BYTES:
|
||||
raise BackupArchiveError("Compose archive contents are too large")
|
||||
|
||||
def _read_manifest(self, archive: zipfile.ZipFile) -> dict[str, Any]:
|
||||
if BACKUP_MANIFEST_NAME not in archive.namelist():
|
||||
raise BackupArchiveError("Archive does not contain manifest.json")
|
||||
try:
|
||||
manifest = json.loads(archive.read(BACKUP_MANIFEST_NAME).decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise BackupArchiveError("manifest.json is not valid JSON") from exc
|
||||
if not isinstance(manifest, dict):
|
||||
raise BackupArchiveError("manifest.json must contain an object")
|
||||
if manifest.get("app") != BACKUP_APP_ID:
|
||||
raise BackupArchiveError("Archive manifest belongs to another application")
|
||||
try:
|
||||
format_version = int(manifest.get("format_version") or 0)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise BackupArchiveError("Archive manifest format is not supported") from exc
|
||||
if format_version != BACKUP_FORMAT_VERSION:
|
||||
raise BackupArchiveError("Archive manifest format is not supported")
|
||||
return manifest
|
||||
|
||||
def _validate_archive_for_restore(self, archive_path: Path) -> None:
|
||||
if not zipfile.is_zipfile(archive_path):
|
||||
raise BackupArchiveError("Archive is not a valid ZIP file")
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
self._validate_zip_members(archive.infolist())
|
||||
manifest = self._read_manifest(archive)
|
||||
self._validate_archive_integrity(archive, manifest)
|
||||
|
||||
def _validate_archive_integrity(
|
||||
self,
|
||||
archive: zipfile.ZipFile,
|
||||
manifest: dict[str, Any],
|
||||
) -> None:
|
||||
archive_manifest = (
|
||||
manifest.get("archive") if isinstance(manifest.get("archive"), dict) else {}
|
||||
)
|
||||
file_records = archive_manifest.get("files")
|
||||
if not isinstance(file_records, list):
|
||||
raise BackupArchiveError("Archive manifest does not contain file checksums")
|
||||
|
||||
expected: dict[str, dict[str, Any]] = {}
|
||||
for record in file_records:
|
||||
if not isinstance(record, dict):
|
||||
raise BackupArchiveError("Archive manifest contains invalid file record")
|
||||
filename = str(record.get("path") or "")
|
||||
if not filename:
|
||||
raise BackupArchiveError("Archive manifest contains empty file path")
|
||||
if filename in expected:
|
||||
raise BackupArchiveError(
|
||||
f"Archive manifest contains duplicate file path: {filename}"
|
||||
)
|
||||
expected[filename] = record
|
||||
|
||||
actual = {
|
||||
item.filename
|
||||
for item in archive.infolist()
|
||||
if not item.is_dir() and item.filename != BACKUP_MANIFEST_NAME
|
||||
}
|
||||
if actual != set(expected):
|
||||
raise BackupArchiveError("Archive contents do not match manifest")
|
||||
|
||||
for info in archive.infolist():
|
||||
if info.is_dir() or info.filename == BACKUP_MANIFEST_NAME:
|
||||
continue
|
||||
record = expected[info.filename]
|
||||
try:
|
||||
expected_size = int(record.get("size_bytes") or -1)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise BackupArchiveError(
|
||||
f"Archive manifest size is invalid: {info.filename}"
|
||||
) from exc
|
||||
expected_hash = str(record.get("sha256") or "")
|
||||
if expected_size != int(info.file_size):
|
||||
raise BackupArchiveError(
|
||||
f"Archive member size does not match manifest: {info.filename}"
|
||||
)
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", expected_hash):
|
||||
raise BackupArchiveError(f"Archive manifest checksum is invalid: {info.filename}")
|
||||
digest = hashlib.sha256()
|
||||
with archive.open(info) as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
if not hmac.compare_digest(digest.hexdigest(), expected_hash):
|
||||
raise BackupArchiveError(
|
||||
f"Archive member checksum does not match manifest: {info.filename}"
|
||||
)
|
||||
|
||||
def _compose_excluded_dirs(self) -> set[str]:
|
||||
configured = self._split_csv(self.settings.BACKUP_COMPOSE_EXCLUDE_DIRS)
|
||||
return DEFAULT_COMPOSE_EXCLUDED_DIRS | set(configured)
|
||||
|
||||
@staticmethod
|
||||
def _split_csv(value: Optional[str]) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
@staticmethod
|
||||
def _file_digest(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()[:16]
|
||||
|
||||
@staticmethod
|
||||
def _safe_original_stem(filename: str) -> str:
|
||||
stem = Path(str(filename or "backup")).stem
|
||||
safe = re.sub(r"[^A-Za-z0-9_.+-]+", "-", stem).strip(".-")
|
||||
return (safe or "backup")[:72]
|
||||
|
||||
def _unique_archive_path(self, archive_name: str) -> Path:
|
||||
backup_dir = self.backup_dir()
|
||||
stem = Path(archive_name).stem
|
||||
suffix = Path(archive_name).suffix
|
||||
candidate = backup_dir / archive_name
|
||||
counter = 2
|
||||
while candidate.exists():
|
||||
candidate = backup_dir / f"{stem}-{counter}{suffix}"
|
||||
counter += 1
|
||||
return candidate
|
||||
@@ -0,0 +1,479 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
from bot.infra.redis import redis_lock
|
||||
from bot.services.backup_archive import (
|
||||
BACKUP_APP_ID,
|
||||
BACKUP_FILENAME_PREFIX,
|
||||
BACKUP_FORMAT_VERSION,
|
||||
attach_archive_integrity,
|
||||
backup_filename_timestamp,
|
||||
build_file_records,
|
||||
write_manifest,
|
||||
write_zip_from_directory,
|
||||
)
|
||||
from config.settings import Settings
|
||||
|
||||
COMPOSE_MARKER_FILES = {
|
||||
"compose.yaml",
|
||||
"compose.yml",
|
||||
"docker-compose.yaml",
|
||||
"docker-compose.yml",
|
||||
}
|
||||
DEFAULT_COMPOSE_EXCLUDED_DIRS = {
|
||||
".git",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
"__pycache__",
|
||||
"backups",
|
||||
"node_modules",
|
||||
"postgres-data",
|
||||
"redis-data",
|
||||
"shop-data",
|
||||
}
|
||||
BACKUP_RUNTIME_SETTING_KEYS = {
|
||||
"BACKUP_ENABLED",
|
||||
"BACKUP_CHAT_ID",
|
||||
"BACKUP_THREAD_ID",
|
||||
"BACKUP_INTERVAL_SECONDS",
|
||||
"BACKUP_LOCAL_RETENTION",
|
||||
"BACKUP_POSTGRES_DUMP_ENABLED",
|
||||
"BACKUP_PG_DUMP_PATH",
|
||||
"BACKUP_PG_DUMP_TIMEOUT_SECONDS",
|
||||
"BACKUP_COMPOSE_ENABLED",
|
||||
"BACKUP_COMPOSE_SOURCE_DIR",
|
||||
"BACKUP_COMPOSE_EXCLUDE_DIRS",
|
||||
}
|
||||
TELEGRAM_DOCUMENT_CAPTION_LIMIT = 1024
|
||||
TELEGRAM_WARNING_DETAIL_LIMIT = 6
|
||||
TELEGRAM_WARNING_LINE_LIMIT = 220
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackupResult:
|
||||
archive_path: Path
|
||||
started_at: datetime
|
||||
completed_at: datetime
|
||||
db_dump_included: bool
|
||||
compose_files_count: int
|
||||
size_bytes: int
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
def to_payload(self) -> dict[str, object]:
|
||||
return {
|
||||
"archive_name": self.archive_path.name,
|
||||
"archive_path": str(self.archive_path),
|
||||
"started_at": self.started_at.isoformat(),
|
||||
"completed_at": self.completed_at.isoformat(),
|
||||
"db_dump_included": self.db_dump_included,
|
||||
"compose_files_count": self.compose_files_count,
|
||||
"size_bytes": self.size_bytes,
|
||||
"warnings": self.warnings,
|
||||
}
|
||||
|
||||
|
||||
class BackupWorker:
|
||||
SETTINGS_REFRESH_SECONDS = 60
|
||||
|
||||
def __init__(self, settings: Settings, bot: Bot, session_factory=None):
|
||||
self.settings = settings
|
||||
self.bot = bot
|
||||
self.session_factory = session_factory
|
||||
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
await self._refresh_settings()
|
||||
if not self.settings.BACKUP_ENABLED:
|
||||
await asyncio.sleep(self.SETTINGS_REFRESH_SECONDS)
|
||||
continue
|
||||
|
||||
interval = self._interval_seconds()
|
||||
delay_seconds = self._seconds_until_next_slot(interval)
|
||||
if delay_seconds > 0:
|
||||
should_run = await self._sleep_until_next_slot(delay_seconds, interval)
|
||||
if not should_run:
|
||||
continue
|
||||
|
||||
await self._refresh_settings()
|
||||
if not self.settings.BACKUP_ENABLED:
|
||||
continue
|
||||
|
||||
try:
|
||||
ttl_seconds = max(
|
||||
60,
|
||||
int(getattr(self.settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200),
|
||||
)
|
||||
async with redis_lock(
|
||||
self.settings,
|
||||
"backup-worker",
|
||||
ttl_seconds=ttl_seconds,
|
||||
) as acquired:
|
||||
if acquired:
|
||||
started = time.monotonic()
|
||||
result = await self.create_and_send_backup()
|
||||
logging.info(
|
||||
"metric worker_tick_duration_seconds=%.3f worker=backup size_bytes=%s",
|
||||
time.monotonic() - started,
|
||||
result.size_bytes,
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
"Backup worker tick skipped because another worker holds the lock"
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.exception("Backup worker tick failed")
|
||||
await self._notify_failure(exc)
|
||||
|
||||
async def create_and_send_backup(self, *, backup_type: str = "scheduled") -> BackupResult:
|
||||
result = await self.create_backup(backup_type=backup_type)
|
||||
try:
|
||||
await self.send_backup(result)
|
||||
finally:
|
||||
self.prune_old_backups()
|
||||
return result
|
||||
|
||||
async def create_backup(self, *, backup_type: str = "scheduled") -> BackupResult:
|
||||
started_at = datetime.now(timezone.utc)
|
||||
stamp = backup_filename_timestamp()
|
||||
archive_name = f"{BACKUP_FILENAME_PREFIX}{stamp}.zip"
|
||||
backup_dir = Path(self.settings.BACKUP_DIR).expanduser()
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive_path = self._unique_archive_path(backup_dir / archive_name)
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix=f"{BACKUP_FILENAME_PREFIX}{stamp}-",
|
||||
dir=backup_dir,
|
||||
) as tmp:
|
||||
staging_dir = Path(tmp)
|
||||
warnings: list[str] = []
|
||||
db_dump_included = False
|
||||
compose_files_count = 0
|
||||
|
||||
if self.settings.BACKUP_POSTGRES_DUMP_ENABLED:
|
||||
dump_dir = staging_dir / "database"
|
||||
dump_dir.mkdir(parents=True, exist_ok=True)
|
||||
dump_path = dump_dir / f"{self.settings.POSTGRES_DB}.dump"
|
||||
await self._dump_database(dump_path)
|
||||
db_dump_included = True
|
||||
|
||||
if self.settings.BACKUP_COMPOSE_ENABLED:
|
||||
compose_files_count = self._stage_compose_source(staging_dir / "compose", warnings)
|
||||
|
||||
completed_at = datetime.now(timezone.utc)
|
||||
manifest = {
|
||||
"app": BACKUP_APP_ID,
|
||||
"format_version": BACKUP_FORMAT_VERSION,
|
||||
"type": str(backup_type or "scheduled"),
|
||||
"created_at": completed_at.isoformat(),
|
||||
"created_at_local": completed_at.astimezone().isoformat(),
|
||||
"postgres": {
|
||||
"host": self.settings.POSTGRES_HOST,
|
||||
"port": self.settings.POSTGRES_PORT,
|
||||
"database": self.settings.POSTGRES_DB,
|
||||
"user": self.settings.POSTGRES_USER,
|
||||
"dump_format": "pg_dump custom",
|
||||
"included": db_dump_included,
|
||||
},
|
||||
"compose": {
|
||||
"source_dir": self.settings.BACKUP_COMPOSE_SOURCE_DIR,
|
||||
"included": compose_files_count > 0,
|
||||
"files_count": compose_files_count,
|
||||
},
|
||||
"warnings": warnings,
|
||||
}
|
||||
attach_archive_integrity(
|
||||
manifest,
|
||||
file_records=build_file_records(staging_dir),
|
||||
)
|
||||
write_manifest(staging_dir, manifest)
|
||||
|
||||
tmp_archive = archive_path.with_name(f"{archive_path.name}.tmp")
|
||||
write_zip_from_directory(staging_dir, tmp_archive)
|
||||
tmp_archive.replace(archive_path)
|
||||
|
||||
return BackupResult(
|
||||
archive_path=archive_path,
|
||||
started_at=started_at,
|
||||
completed_at=completed_at,
|
||||
db_dump_included=db_dump_included,
|
||||
compose_files_count=compose_files_count,
|
||||
size_bytes=archive_path.stat().st_size,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
def _unique_archive_path(self, archive_path: Path) -> Path:
|
||||
if not archive_path.exists():
|
||||
return archive_path
|
||||
for index in range(2, 1000):
|
||||
candidate = archive_path.with_name(f"{archive_path.stem}-{index}{archive_path.suffix}")
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
raise RuntimeError("Could not allocate a unique backup archive filename")
|
||||
|
||||
async def _dump_database(self, dump_path: Path) -> None:
|
||||
await asyncio.to_thread(self._run_pg_dump, dump_path)
|
||||
|
||||
def _run_pg_dump(self, dump_path: Path) -> None:
|
||||
pg_dump_path = str(self.settings.BACKUP_PG_DUMP_PATH or "pg_dump")
|
||||
if shutil.which(pg_dump_path) is None and Path(pg_dump_path).name == pg_dump_path:
|
||||
raise RuntimeError(
|
||||
"pg_dump executable was not found. Rebuild the worker image with "
|
||||
"PostgreSQL client tools."
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PGPASSWORD"] = self.settings.POSTGRES_PASSWORD
|
||||
command = [
|
||||
pg_dump_path,
|
||||
"-h",
|
||||
self.settings.POSTGRES_HOST,
|
||||
"-p",
|
||||
str(self.settings.POSTGRES_PORT),
|
||||
"-U",
|
||||
self.settings.POSTGRES_USER,
|
||||
"-d",
|
||||
self.settings.POSTGRES_DB,
|
||||
"--format=custom",
|
||||
"--no-owner",
|
||||
"--no-privileges",
|
||||
"--file",
|
||||
str(dump_path),
|
||||
]
|
||||
timeout = max(30, int(self.settings.BACKUP_PG_DUMP_TIMEOUT_SECONDS or 1800))
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = (result.stderr or result.stdout or "").strip()
|
||||
raise RuntimeError(f"pg_dump failed with exit code {result.returncode}: {stderr[:500]}")
|
||||
|
||||
def _stage_compose_source(self, target_dir: Path, warnings: list[str]) -> int:
|
||||
source_raw = (self.settings.BACKUP_COMPOSE_SOURCE_DIR or "").strip()
|
||||
if not source_raw:
|
||||
warnings.append(
|
||||
"Compose source directory is not configured. Set "
|
||||
"BACKUP_COMPOSE_SOURCE_DIR or mount the compose folder into the backup container."
|
||||
)
|
||||
return 0
|
||||
|
||||
source_dir = Path(source_raw).expanduser()
|
||||
if not source_dir.exists() or not source_dir.is_dir():
|
||||
warnings.append(
|
||||
"If manual backup includes compose but scheduled backup does not, recreate "
|
||||
"the worker service with the compose-source mount. Compose source directory "
|
||||
f"is unavailable in this container: {source_dir}"
|
||||
)
|
||||
return 0
|
||||
|
||||
if not any((source_dir / marker).is_file() for marker in COMPOSE_MARKER_FILES):
|
||||
warnings.append(
|
||||
"Check that COMPOSE_BACKUP_SOURCE points to the folder with docker-compose.yml. "
|
||||
f"Compose source directory has no compose file marker: {source_dir}"
|
||||
)
|
||||
|
||||
excluded_dirs = self._compose_excluded_dirs()
|
||||
files_count = 0
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for path in source_dir.rglob("*"):
|
||||
relative = path.relative_to(source_dir)
|
||||
if any(part in excluded_dirs for part in relative.parts):
|
||||
continue
|
||||
if path.is_dir() or path.is_symlink():
|
||||
continue
|
||||
if path.name.startswith(f"{BACKUP_FILENAME_PREFIX}") and path.suffix == ".zip":
|
||||
continue
|
||||
destination = target_dir / relative
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(path, destination)
|
||||
files_count += 1
|
||||
except OSError as exc:
|
||||
warnings.append(f"Skipped compose file {relative.as_posix()}: {exc}")
|
||||
|
||||
return files_count
|
||||
|
||||
def _compose_excluded_dirs(self) -> set[str]:
|
||||
configured = self._split_csv(self.settings.BACKUP_COMPOSE_EXCLUDE_DIRS)
|
||||
return DEFAULT_COMPOSE_EXCLUDED_DIRS | set(configured)
|
||||
|
||||
@staticmethod
|
||||
def _split_csv(value: Optional[str]) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
async def send_backup(self, result: BackupResult) -> None:
|
||||
chat_id = self._target_chat_id()
|
||||
if chat_id is None:
|
||||
logging.warning(
|
||||
"Backup archive created at %s but BACKUP_CHAT_ID/LOG_CHAT_ID is not configured",
|
||||
result.archive_path,
|
||||
)
|
||||
return
|
||||
|
||||
kwargs = {
|
||||
"chat_id": chat_id,
|
||||
"document": FSInputFile(result.archive_path),
|
||||
"caption": self._caption(result),
|
||||
}
|
||||
thread_id = self._target_thread_id()
|
||||
if thread_id is not None:
|
||||
kwargs["message_thread_id"] = thread_id
|
||||
await self.bot.send_document(**kwargs)
|
||||
|
||||
def prune_old_backups(self) -> None:
|
||||
retention = int(getattr(self.settings, "BACKUP_LOCAL_RETENTION", 3) or 0)
|
||||
if retention <= 0:
|
||||
return
|
||||
|
||||
backup_dir = Path(self.settings.BACKUP_DIR).expanduser()
|
||||
archives = sorted(
|
||||
backup_dir.glob(f"{BACKUP_FILENAME_PREFIX}*.zip"),
|
||||
key=lambda path: path.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
for archive in archives[retention:]:
|
||||
try:
|
||||
archive.unlink()
|
||||
except OSError:
|
||||
logging.exception("Failed to delete old backup archive %s", archive)
|
||||
|
||||
def _target_chat_id(self) -> Optional[int]:
|
||||
return self.settings.BACKUP_CHAT_ID or self.settings.LOG_CHAT_ID
|
||||
|
||||
def _target_thread_id(self) -> Optional[int]:
|
||||
return self.settings.BACKUP_THREAD_ID or self.settings.LOG_THREAD_ID
|
||||
|
||||
def _caption(self, result: BackupResult) -> str:
|
||||
completed_at = result.completed_at.astimezone()
|
||||
lines = [
|
||||
"Remnawave Minishop backup",
|
||||
f"Created: {completed_at.strftime('%Y-%m-%d %H:%M:%S %Z')}",
|
||||
f"Database dump: {'yes' if result.db_dump_included else 'no'}",
|
||||
f"Compose files: {result.compose_files_count}",
|
||||
f"Archive size: {self._human_size(result.size_bytes)}",
|
||||
]
|
||||
if result.warnings:
|
||||
lines.append(f"Warnings ({len(result.warnings)}):")
|
||||
for index, warning in enumerate(
|
||||
result.warnings[:TELEGRAM_WARNING_DETAIL_LIMIT],
|
||||
start=1,
|
||||
):
|
||||
lines.append(f"{index}. {self._caption_warning(warning)}")
|
||||
hidden_count = len(result.warnings) - TELEGRAM_WARNING_DETAIL_LIMIT
|
||||
if hidden_count > 0:
|
||||
lines.append(f"... and {hidden_count} more warning(s)")
|
||||
return self._fit_caption(lines)
|
||||
|
||||
@staticmethod
|
||||
def _caption_warning(warning: str) -> str:
|
||||
text = " ".join(str(warning or "").split())
|
||||
if len(text) <= TELEGRAM_WARNING_LINE_LIMIT:
|
||||
return text
|
||||
return f"{text[: TELEGRAM_WARNING_LINE_LIMIT - 1].rstrip()}..."
|
||||
|
||||
@staticmethod
|
||||
def _fit_caption(lines: list[str]) -> str:
|
||||
caption = "\n".join(lines)
|
||||
if len(caption) <= TELEGRAM_DOCUMENT_CAPTION_LIMIT:
|
||||
return caption
|
||||
suffix = "\n... caption truncated"
|
||||
return f"{caption[: TELEGRAM_DOCUMENT_CAPTION_LIMIT - len(suffix)].rstrip()}{suffix}"
|
||||
|
||||
@staticmethod
|
||||
def _human_size(size_bytes: int) -> str:
|
||||
units: Iterable[str] = ("B", "KB", "MB", "GB")
|
||||
size = float(size_bytes)
|
||||
unit = "B"
|
||||
for unit in units:
|
||||
if size < 1024 or unit == "GB":
|
||||
break
|
||||
size /= 1024
|
||||
if unit == "B":
|
||||
return f"{int(size)} {unit}"
|
||||
return f"{size:.1f} {unit}"
|
||||
|
||||
async def refresh_settings(self) -> None:
|
||||
await self._refresh_settings()
|
||||
|
||||
async def _refresh_settings(self) -> None:
|
||||
if self.session_factory is None:
|
||||
return
|
||||
try:
|
||||
from bot.services.settings_override_service import refresh_overrides_from_db
|
||||
|
||||
await refresh_overrides_from_db(
|
||||
self.settings,
|
||||
self.session_factory,
|
||||
keys=BACKUP_RUNTIME_SETTING_KEYS,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to refresh backup settings from DB")
|
||||
|
||||
def _interval_seconds(self) -> int:
|
||||
try:
|
||||
interval = int(self.settings.BACKUP_INTERVAL_SECONDS or 0)
|
||||
except (TypeError, ValueError):
|
||||
interval = 0
|
||||
return max(60, interval)
|
||||
|
||||
def _seconds_until_next_slot(self, interval_seconds: int) -> float:
|
||||
now = datetime.now().astimezone()
|
||||
if interval_seconds <= 0:
|
||||
return 0.0
|
||||
if interval_seconds <= 24 * 60 * 60:
|
||||
midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
elapsed = (now - midnight).total_seconds()
|
||||
remainder = elapsed % interval_seconds
|
||||
else:
|
||||
remainder = time.time() % interval_seconds
|
||||
if remainder < 0.5:
|
||||
return 0.0
|
||||
return max(0.0, interval_seconds - remainder)
|
||||
|
||||
async def _sleep_until_next_slot(self, delay_seconds: float, interval_seconds: int) -> bool:
|
||||
deadline = datetime.now(timezone.utc) + timedelta(seconds=delay_seconds)
|
||||
while True:
|
||||
remaining = (deadline - datetime.now(timezone.utc)).total_seconds()
|
||||
if remaining <= 0:
|
||||
return True
|
||||
await asyncio.sleep(min(remaining, self.SETTINGS_REFRESH_SECONDS))
|
||||
await self._refresh_settings()
|
||||
if not self.settings.BACKUP_ENABLED:
|
||||
return False
|
||||
if self._interval_seconds() != interval_seconds:
|
||||
return False
|
||||
|
||||
async def _notify_failure(self, exc: Exception) -> None:
|
||||
chat_id = self._target_chat_id()
|
||||
if chat_id is None:
|
||||
return
|
||||
kwargs = {
|
||||
"chat_id": chat_id,
|
||||
"text": f"Remnawave Minishop backup failed: {type(exc).__name__}. Check worker logs.",
|
||||
}
|
||||
thread_id = self._target_thread_id()
|
||||
if thread_id is not None:
|
||||
kwargs["message_thread_id"] = thread_id
|
||||
try:
|
||||
await self.bot.send_message(**kwargs)
|
||||
except Exception:
|
||||
logging.exception("Failed to send backup failure notification")
|
||||
@@ -53,6 +53,7 @@ LOCALE_GROUPS = [
|
||||
"admin_sort_",
|
||||
"admin_status_",
|
||||
"admin_badge_",
|
||||
"admin_backups_",
|
||||
"admin_aria_",
|
||||
"admin_search",
|
||||
"admin_clear",
|
||||
@@ -257,6 +258,7 @@ LOCALE_GROUPS = [
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_settings_field_log_",
|
||||
"admin_settings_field_backup_",
|
||||
"admin_settings_field_support_",
|
||||
"admin_settings_field_subscription_notifications_",
|
||||
"admin_settings_field_subscription_notify_",
|
||||
|
||||
@@ -68,9 +68,7 @@ class PanelWebhookService:
|
||||
text = _(message_key, **kwargs)
|
||||
if extra_text:
|
||||
text = f"{text}\n\n{extra_text}"
|
||||
await self.bot.send_message(
|
||||
user_id, text, reply_markup=reply_markup
|
||||
)
|
||||
await self.bot.send_message(user_id, text, reply_markup=reply_markup)
|
||||
except Exception:
|
||||
logging.exception("Failed to send notification to %s", user_id)
|
||||
|
||||
|
||||
@@ -263,6 +263,35 @@ async def load_overrides_from_db(settings: Settings, async_session_factory: sess
|
||||
return applied
|
||||
|
||||
|
||||
async def refresh_overrides_from_db(
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
*,
|
||||
keys: Optional[set[str]] = None,
|
||||
) -> int:
|
||||
"""Refresh already-known runtime overrides without startup restore side effects."""
|
||||
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
overrides = await app_settings_dal.get_all_overrides(session)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not refresh setting overrides from DB: %s", exc)
|
||||
return 0
|
||||
if keys is not None:
|
||||
try:
|
||||
env_only = Settings()
|
||||
for key in keys:
|
||||
if key in overrides:
|
||||
continue
|
||||
attr_name = _resolve_attribute_name(env_only, key)
|
||||
if attr_name and hasattr(env_only, attr_name):
|
||||
setattr(settings, attr_name, getattr(env_only, attr_name))
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to restore env defaults while refreshing overrides: %s", exc)
|
||||
overrides = {key: value for key, value in overrides.items() if key in keys}
|
||||
return apply_overrides(settings, overrides)
|
||||
|
||||
|
||||
async def update_overrides(
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.utils.text_decorations import html_decoration as hd
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload, sessionmaker
|
||||
|
||||
from bot.infra.redis import redis_lock
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from config.settings import Settings
|
||||
from db.dal import subscription_dal
|
||||
from db.models import Subscription
|
||||
|
||||
SUBSCRIPTION_NOTIFICATION_LOCK = "subscription-notification-worker"
|
||||
DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS = 300
|
||||
EXPIRED_NOTIFICATION_WINDOW = timedelta(hours=24)
|
||||
EXPIRED_AFTER_NOTIFICATION_WINDOW = timedelta(hours=48)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionNotificationStage:
|
||||
key: str
|
||||
message_key: str
|
||||
hours_before: Optional[int] = None
|
||||
|
||||
|
||||
class SubscriptionNotificationWorker:
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
session_factory: sessionmaker,
|
||||
bot: Bot,
|
||||
i18n: JsonI18n,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.session_factory = session_factory
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
self.panel_service = panel_service
|
||||
self.subscription_service = subscription_service
|
||||
self._stopped = asyncio.Event()
|
||||
|
||||
async def run(self) -> None:
|
||||
while not self._stopped.is_set():
|
||||
try:
|
||||
async with redis_lock(
|
||||
self.settings,
|
||||
SUBSCRIPTION_NOTIFICATION_LOCK,
|
||||
ttl_seconds=max(60, self._tick_seconds() - 10),
|
||||
) as acquired:
|
||||
if not acquired:
|
||||
logging.info(
|
||||
"SubscriptionNotificationWorker tick skipped: Redis lock is held"
|
||||
)
|
||||
else:
|
||||
started = time.monotonic()
|
||||
async with self.session_factory() as session:
|
||||
await self.expiry_tick(session)
|
||||
await self.trial_traffic_tick(session)
|
||||
await session.commit()
|
||||
logging.info(
|
||||
"metric worker_tick_duration_seconds=%.3f "
|
||||
"worker=subscription_notification",
|
||||
time.monotonic() - started,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("SubscriptionNotificationWorker tick failed")
|
||||
try:
|
||||
await asyncio.wait_for(self._stopped.wait(), timeout=self._tick_seconds())
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stopped.set()
|
||||
|
||||
def _tick_seconds(self) -> int:
|
||||
return int(
|
||||
getattr(
|
||||
self.settings,
|
||||
"SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS",
|
||||
DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS,
|
||||
)
|
||||
or DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS
|
||||
)
|
||||
|
||||
async def expiry_tick(self, session: AsyncSession) -> None:
|
||||
if not getattr(self.settings, "SUBSCRIPTION_NOTIFICATIONS_ENABLED", True):
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
lower = now - EXPIRED_AFTER_NOTIFICATION_WINDOW
|
||||
upper = now + self._max_before_window()
|
||||
result = await session.execute(
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.skip_notifications == False,
|
||||
Subscription.end_date >= lower,
|
||||
Subscription.end_date <= upper,
|
||||
)
|
||||
.options(selectinload(Subscription.user))
|
||||
.order_by(Subscription.end_date.asc())
|
||||
)
|
||||
for sub in result.scalars().all():
|
||||
stage = self.stage_for_subscription(sub, now)
|
||||
if stage is None:
|
||||
continue
|
||||
if await subscription_dal.has_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
stage.key,
|
||||
):
|
||||
continue
|
||||
if not await self._send_expiry_notification(sub, stage):
|
||||
continue
|
||||
await subscription_dal.record_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
stage.key,
|
||||
sent_at=now,
|
||||
)
|
||||
|
||||
def stage_for_subscription(
|
||||
self,
|
||||
sub: Subscription,
|
||||
now: datetime,
|
||||
) -> Optional[SubscriptionNotificationStage]:
|
||||
end_date = self._as_utc(getattr(sub, "end_date", None))
|
||||
if end_date is None:
|
||||
return None
|
||||
|
||||
seconds_left = (end_date - now).total_seconds()
|
||||
if seconds_left > 0:
|
||||
hours_before = int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_HOURS_BEFORE", 0) or 0)
|
||||
if 0 < hours_before <= 23 and seconds_left <= hours_before * 3600:
|
||||
return SubscriptionNotificationStage(
|
||||
key=f"before_{hours_before}h",
|
||||
message_key="subscription_hours_notification",
|
||||
hours_before=hours_before,
|
||||
)
|
||||
|
||||
days_before_limit = max(
|
||||
0,
|
||||
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0),
|
||||
)
|
||||
day_stages = (
|
||||
(1, "subscription_24h_notification"),
|
||||
(2, "subscription_48h_notification"),
|
||||
(3, "subscription_72h_notification"),
|
||||
)
|
||||
for days_before, message_key in day_stages:
|
||||
if days_before > days_before_limit:
|
||||
continue
|
||||
if seconds_left <= days_before * 24 * 3600:
|
||||
return SubscriptionNotificationStage(
|
||||
key=f"before_{days_before}d",
|
||||
message_key=message_key,
|
||||
)
|
||||
return None
|
||||
|
||||
expired_for = now - end_date
|
||||
if (
|
||||
getattr(self.settings, "SUBSCRIPTION_NOTIFY_ON_EXPIRE", True)
|
||||
and expired_for <= EXPIRED_NOTIFICATION_WINDOW
|
||||
):
|
||||
return SubscriptionNotificationStage(
|
||||
key="expired",
|
||||
message_key="subscription_expired_notification",
|
||||
)
|
||||
if (
|
||||
getattr(self.settings, "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", True)
|
||||
and EXPIRED_NOTIFICATION_WINDOW < expired_for <= EXPIRED_AFTER_NOTIFICATION_WINDOW
|
||||
):
|
||||
return SubscriptionNotificationStage(
|
||||
key="expired_24h_after",
|
||||
message_key="subscription_expired_yesterday_notification",
|
||||
)
|
||||
return None
|
||||
|
||||
async def trial_traffic_tick(self, session: AsyncSession) -> None:
|
||||
if not getattr(self.settings, "SUBSCRIPTION_NOTIFICATIONS_ENABLED", True):
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await session.execute(
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.skip_notifications == False,
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > now,
|
||||
Subscription.traffic_limit_bytes.is_not(None),
|
||||
Subscription.traffic_limit_bytes > 0,
|
||||
or_(
|
||||
Subscription.provider == "trial",
|
||||
Subscription.status_from_panel == "TRIAL",
|
||||
Subscription.duration_months == 0,
|
||||
),
|
||||
)
|
||||
.options(selectinload(Subscription.user))
|
||||
.order_by(Subscription.end_date.asc())
|
||||
)
|
||||
for sub in result.scalars().all():
|
||||
if await subscription_dal.has_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
"trial_traffic_depleted",
|
||||
):
|
||||
continue
|
||||
|
||||
used = int(getattr(sub, "traffic_used_bytes", 0) or 0)
|
||||
limit = int(getattr(sub, "traffic_limit_bytes", 0) or 0)
|
||||
panel_data = await self._panel_user(sub)
|
||||
if panel_data:
|
||||
panel_used, panel_limit, _ = (
|
||||
self.subscription_service._extract_panel_traffic_details(panel_data)
|
||||
)
|
||||
if panel_used is not None:
|
||||
used = int(panel_used)
|
||||
sub.traffic_used_bytes = used
|
||||
if panel_limit is not None:
|
||||
limit = int(panel_limit)
|
||||
sub.traffic_limit_bytes = limit
|
||||
panel_status = str(panel_data.get("status") or "").upper()
|
||||
if panel_status:
|
||||
sub.status_from_panel = panel_status
|
||||
|
||||
if limit <= 0 or used < limit:
|
||||
continue
|
||||
if not await self._send_trial_traffic_depleted(sub, used=used, limit=limit):
|
||||
continue
|
||||
await subscription_dal.record_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
"trial_traffic_depleted",
|
||||
sent_at=now,
|
||||
)
|
||||
|
||||
async def _panel_user(self, sub: Subscription) -> Optional[dict]:
|
||||
panel_uuid = str(getattr(sub, "panel_user_uuid", "") or "").strip()
|
||||
if not panel_uuid:
|
||||
return None
|
||||
try:
|
||||
data = await self.panel_service.get_user_by_uuid(panel_uuid, log_response=False)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"SubscriptionNotificationWorker: failed to fetch panel user %s",
|
||||
panel_uuid,
|
||||
)
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
async def _send_expiry_notification(
|
||||
self,
|
||||
sub: Subscription,
|
||||
stage: SubscriptionNotificationStage,
|
||||
) -> bool:
|
||||
user_id = int(getattr(sub, "user_id", 0) or 0)
|
||||
if user_id <= 0:
|
||||
return False
|
||||
user = getattr(sub, "user", None)
|
||||
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
|
||||
user_name = getattr(user, "first_name", None) or f"User {user_id}"
|
||||
end_date = self._as_utc(getattr(sub, "end_date", None))
|
||||
end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
|
||||
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
|
||||
kwargs = {"user_name": user_name, "end_date": end_date_text}
|
||||
if stage.hours_before is not None:
|
||||
kwargs["hours"] = stage.hours_before
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
translate(stage.message_key, **kwargs),
|
||||
reply_markup=get_subscribe_only_markup(lang, self.i18n),
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send subscription notification %s to user %s",
|
||||
stage.key,
|
||||
user_id,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_trial_traffic_depleted(
|
||||
self,
|
||||
sub: Subscription,
|
||||
*,
|
||||
used: int,
|
||||
limit: int,
|
||||
) -> bool:
|
||||
user_id = int(getattr(sub, "user_id", 0) or 0)
|
||||
if user_id <= 0:
|
||||
return False
|
||||
user = getattr(sub, "user", None)
|
||||
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
|
||||
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
|
||||
remaining = max(0, limit - used)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
translate(
|
||||
"trial_traffic_depleted_notification",
|
||||
used=hd.quote(self._fmt_bytes(used)),
|
||||
remaining=hd.quote(self._fmt_bytes(remaining)),
|
||||
limit_total=hd.quote(self._fmt_bytes(limit)),
|
||||
),
|
||||
reply_markup=get_subscribe_only_markup(lang, self.i18n),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logging.exception("Failed to send trial traffic depleted warning to user %s", user_id)
|
||||
return False
|
||||
|
||||
def _max_before_window(self) -> timedelta:
|
||||
days_before = max(0, int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0))
|
||||
hours_before = max(
|
||||
0,
|
||||
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_HOURS_BEFORE", 0) or 0),
|
||||
)
|
||||
return max(timedelta(days=min(days_before, 3)), timedelta(hours=hours_before))
|
||||
|
||||
@staticmethod
|
||||
def _as_utc(value: Optional[datetime]) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
@staticmethod
|
||||
def _fmt_bytes(value: int) -> str:
|
||||
size = float(max(0, int(value or 0)))
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if size < 1024 or unit == "TB":
|
||||
return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B"
|
||||
size /= 1024
|
||||
return f"{size:.1f} TB"
|
||||
@@ -87,9 +87,9 @@ class HwidDeviceMixin:
|
||||
full_price = float(package.price_for_period(period_months))
|
||||
period_start = self._as_aware_utc(getattr(sub, "start_date", None))
|
||||
period_end = self._as_aware_utc(getattr(sub, "end_date", None)) or valid_until
|
||||
if not period_start or period_start >= period_end:
|
||||
period_start = valid_from
|
||||
period_end = valid_until
|
||||
inferred_period_start = add_months(period_end, -period_months)
|
||||
if not period_start or period_start >= period_end or period_start < inferred_period_start:
|
||||
period_start = inferred_period_start
|
||||
|
||||
basis_seconds = max(1.0, (period_end - period_start).total_seconds())
|
||||
billable_start = max(now, valid_from)
|
||||
|
||||
@@ -416,9 +416,7 @@ class SubscriptionLifecycleMixin:
|
||||
"Failed to recalculate active HWID devices for renewal of user %s",
|
||||
user_id,
|
||||
)
|
||||
extra_hwid_devices = int(
|
||||
getattr(current_active_sub, "extra_hwid_devices", 0) or 0
|
||||
)
|
||||
extra_hwid_devices = int(getattr(current_active_sub, "extra_hwid_devices", 0) or 0)
|
||||
premium_topup_balance_bytes = int(
|
||||
getattr(current_active_sub, "premium_topup_balance_bytes", 0) or 0
|
||||
)
|
||||
@@ -813,9 +811,7 @@ class SubscriptionLifecycleMixin:
|
||||
subscription_id=local_active_sub.subscription_id,
|
||||
at=datetime.now(timezone.utc),
|
||||
)
|
||||
active_extra_hwid_devices = int(
|
||||
hwid_entitlement_summary.get("active_devices") or 0
|
||||
)
|
||||
active_extra_hwid_devices = int(hwid_entitlement_summary.get("active_devices") or 0)
|
||||
if active_extra_hwid_devices != int(local_active_sub.extra_hwid_devices or 0):
|
||||
await subscription_dal.update_subscription(
|
||||
session,
|
||||
@@ -901,9 +897,7 @@ class SubscriptionLifecycleMixin:
|
||||
else None,
|
||||
"extra_hwid_devices": active_extra_hwid_devices,
|
||||
"extra_hwid_devices_valid_until": hwid_entitlement_summary.get("active_until"),
|
||||
"extra_hwid_devices_next_valid_from": hwid_entitlement_summary.get(
|
||||
"next_valid_from"
|
||||
),
|
||||
"extra_hwid_devices_next_valid_from": hwid_entitlement_summary.get("next_valid_from"),
|
||||
"user_bot_username": db_user.username,
|
||||
"is_panel_data": True,
|
||||
"max_devices": hwid_limit,
|
||||
|
||||
@@ -441,9 +441,7 @@ class TariffMixin:
|
||||
|
||||
if options.get("mode") == "period_to_period":
|
||||
target_monthly = float(options.get("target_monthly_rub") or 0)
|
||||
hwid_days = (
|
||||
math.floor((value_rub / target_monthly) * 30) if target_monthly > 0 else 0
|
||||
)
|
||||
hwid_days = math.floor((value_rub / target_monthly) * 30) if target_monthly > 0 else 0
|
||||
options["converted_hwid_days"] = max(0, hwid_days)
|
||||
options["recalc_days"] = int(options.get("recalc_days") or 0) + max(0, hwid_days)
|
||||
options["paid_diff_rub"] = max(
|
||||
|
||||
@@ -54,9 +54,7 @@ class TrafficMixin:
|
||||
|
||||
purchase_bytes = self.gb_to_bytes(traffic_gb)
|
||||
extra_hwid_devices = (
|
||||
await self._active_hwid_extra_devices_for_sub(session, active_sub)
|
||||
if active_sub
|
||||
else 0
|
||||
await self._active_hwid_extra_devices_for_sub(session, active_sub) if active_sub else 0
|
||||
)
|
||||
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
|
||||
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
|
||||
|
||||
@@ -62,6 +62,7 @@ class TrialSubscriptionMixin:
|
||||
"status_from_panel": "TRIAL",
|
||||
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
||||
"auto_renew_enabled": False,
|
||||
"provider": "trial",
|
||||
}
|
||||
try:
|
||||
await subscription_dal.upsert_subscription(session, trial_sub_data)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
@@ -134,6 +135,16 @@ class SupportService:
|
||||
email_auth_service=self.email_auth_service,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _schedule_notification(coro, error_message: str, *error_args: Any) -> None:
|
||||
async def _runner():
|
||||
try:
|
||||
await coro
|
||||
except Exception:
|
||||
logger.exception(error_message, *error_args)
|
||||
|
||||
asyncio.create_task(_runner(), name="support-notification")
|
||||
|
||||
async def _ensure_user_allowed(self, session, user_id: int) -> User:
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not user or user.is_banned or not self.settings.SUPPORT_TICKETS_ENABLED:
|
||||
@@ -218,8 +229,8 @@ class SupportService:
|
||||
await session.commit()
|
||||
|
||||
if notification_decision.send_telegram or notification_decision.send_email:
|
||||
try:
|
||||
await self.notification_service.notify_support_user_reply(
|
||||
self._schedule_notification(
|
||||
self.notification_service.notify_support_user_reply(
|
||||
ticket,
|
||||
message,
|
||||
user,
|
||||
@@ -227,9 +238,10 @@ class SupportService:
|
||||
unread_count=int(ticket.unread_admin_count or 0),
|
||||
send_telegram=notification_decision.send_telegram,
|
||||
send_email=notification_decision.send_email,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to notify about support user reply %s", ticket_id)
|
||||
),
|
||||
"Failed to notify about support user reply %s",
|
||||
ticket_id,
|
||||
)
|
||||
return ticket, message
|
||||
|
||||
async def reply_as_admin(
|
||||
@@ -275,10 +287,11 @@ class SupportService:
|
||||
await session.commit()
|
||||
|
||||
if user and not is_internal_note:
|
||||
try:
|
||||
await self.notification_service.notify_support_admin_reply(ticket, message, user)
|
||||
except Exception:
|
||||
logger.exception("Failed to notify user about support admin reply %s", ticket_id)
|
||||
self._schedule_notification(
|
||||
self.notification_service.notify_support_admin_reply(ticket, message, user),
|
||||
"Failed to notify user about support admin reply %s",
|
||||
ticket_id,
|
||||
)
|
||||
return ticket, message
|
||||
|
||||
async def change_status(self, admin_id: int, ticket_id: int, status: str) -> SupportTicket:
|
||||
|
||||
@@ -795,8 +795,7 @@ class TariffTrafficWorker:
|
||||
if not cached_at:
|
||||
return False
|
||||
return (
|
||||
time.monotonic() - float(cached_at)
|
||||
< TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS
|
||||
time.monotonic() - float(cached_at) < TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS
|
||||
)
|
||||
|
||||
def _remember_premium_squad_match(self, cache_key: tuple[str, tuple[str, ...]]) -> None:
|
||||
|
||||
@@ -114,6 +114,33 @@ class Settings(BaseSettings):
|
||||
TARIFF_WORKER_LOCK_TTL_SECONDS: int = Field(default=240)
|
||||
TARIFF_WORKER_TICK_SECONDS: int = Field(default=300)
|
||||
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD: int = Field(default=50)
|
||||
BACKUP_ENABLED: bool = Field(
|
||||
default=False,
|
||||
description="Run periodic backup jobs from the worker container.",
|
||||
)
|
||||
BACKUP_INTERVAL_SECONDS: int = Field(default=60 * 60)
|
||||
BACKUP_LOCK_TTL_SECONDS: int = Field(default=2 * 60 * 60)
|
||||
BACKUP_DIR: str = Field(default="data/backups")
|
||||
BACKUP_LOCAL_RETENTION: int = Field(default=100)
|
||||
BACKUP_CHAT_ID: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Telegram chat ID for backup archives. Falls back to LOG_CHAT_ID.",
|
||||
)
|
||||
BACKUP_THREAD_ID: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Telegram topic/thread ID for backup archives. Falls back to LOG_THREAD_ID.",
|
||||
)
|
||||
BACKUP_POSTGRES_DUMP_ENABLED: bool = Field(default=True)
|
||||
BACKUP_PG_DUMP_PATH: str = Field(default="pg_dump")
|
||||
BACKUP_PG_DUMP_TIMEOUT_SECONDS: int = Field(default=30 * 60)
|
||||
BACKUP_PG_RESTORE_PATH: str = Field(default="pg_restore")
|
||||
BACKUP_PG_RESTORE_TIMEOUT_SECONDS: int = Field(default=30 * 60)
|
||||
BACKUP_COMPOSE_ENABLED: bool = Field(default=True)
|
||||
BACKUP_COMPOSE_SOURCE_DIR: Optional[str] = Field(default="/app/compose-source")
|
||||
BACKUP_COMPOSE_RESTORE_DIR: Optional[str] = Field(default=None)
|
||||
BACKUP_COMPOSE_EXCLUDE_DIRS: str = Field(
|
||||
default=".git,node_modules,__pycache__,.pytest_cache,.ruff_cache,postgres-data,redis-data,shop-data,backups"
|
||||
)
|
||||
|
||||
DEFAULT_LANGUAGE: str = Field(default="ru")
|
||||
DEFAULT_CURRENCY_SYMBOL: str = Field(default="RUB")
|
||||
@@ -216,6 +243,8 @@ class Settings(BaseSettings):
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3)
|
||||
SUBSCRIPTION_NOTIFY_HOURS_BEFORE: int = Field(default=3)
|
||||
SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS: int = Field(default=300)
|
||||
|
||||
REFERRAL_BONUS_DAYS_INVITER_1_MONTH: Optional[int] = Field(
|
||||
default=3, alias="REFERRAL_BONUS_DAYS_1_MONTH"
|
||||
@@ -303,7 +332,7 @@ class Settings(BaseSettings):
|
||||
)
|
||||
WEBAPP_SERVER_HOST: str = Field(default="0.0.0.0")
|
||||
WEBAPP_SERVER_PORT: int = Field(default=8081)
|
||||
WEBAPP_TITLE: str = Field(default="Моя подписка")
|
||||
WEBAPP_TITLE: str = Field(default="/minishop")
|
||||
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
|
||||
WEBAPP_THEMES_DIR: str = Field(
|
||||
default="data/themes",
|
||||
@@ -947,7 +976,14 @@ class Settings(BaseSettings):
|
||||
return v
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
@field_validator("LOG_CHAT_ID", "LOG_THREAD_ID", "LOG_SUPPORT_THREAD_ID", mode="before")
|
||||
@field_validator(
|
||||
"LOG_CHAT_ID",
|
||||
"LOG_THREAD_ID",
|
||||
"LOG_SUPPORT_THREAD_ID",
|
||||
"BACKUP_CHAT_ID",
|
||||
"BACKUP_THREAD_ID",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def validate_optional_int_fields(cls, v):
|
||||
"""Convert empty strings to None for optional integer fields"""
|
||||
@@ -969,6 +1005,8 @@ class Settings(BaseSettings):
|
||||
"SMTP_FROM_EMAIL",
|
||||
"SMTP_FROM_NAME",
|
||||
"SMTP_FALLBACK_PORTS",
|
||||
"BACKUP_COMPOSE_SOURCE_DIR",
|
||||
"BACKUP_COMPOSE_RESTORE_DIR",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from db.models import Subscription
|
||||
from db.models import Subscription, SubscriptionNotification
|
||||
|
||||
INSTALL_SHARE_TOKEN_BYTES = 16
|
||||
|
||||
@@ -318,6 +318,45 @@ async def update_subscription_notification_time(
|
||||
)
|
||||
|
||||
|
||||
async def has_subscription_notification(
|
||||
session: AsyncSession,
|
||||
subscription_id: int,
|
||||
notification_key: str,
|
||||
) -> bool:
|
||||
stmt = (
|
||||
select(SubscriptionNotification.notification_id)
|
||||
.where(
|
||||
SubscriptionNotification.subscription_id == subscription_id,
|
||||
SubscriptionNotification.notification_key == notification_key,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def record_subscription_notification(
|
||||
session: AsyncSession,
|
||||
subscription_id: int,
|
||||
notification_key: str,
|
||||
*,
|
||||
sent_at: Optional[datetime] = None,
|
||||
) -> None:
|
||||
if sent_at is None:
|
||||
sent_at = datetime.now(timezone.utc)
|
||||
existing = await has_subscription_notification(session, subscription_id, notification_key)
|
||||
if existing:
|
||||
return
|
||||
session.add(
|
||||
SubscriptionNotification(
|
||||
subscription_id=subscription_id,
|
||||
notification_key=notification_key,
|
||||
sent_at=sent_at,
|
||||
)
|
||||
)
|
||||
await update_subscription_notification_time(session, subscription_id, sent_at)
|
||||
|
||||
|
||||
async def find_subscription_for_notification_update(
|
||||
session: AsyncSession, user_id: int, subscription_end_date_to_match: datetime
|
||||
) -> Optional[Subscription]:
|
||||
|
||||
@@ -41,9 +41,7 @@ async def sum_traffic_topups(
|
||||
if created_at_gte is not None:
|
||||
conditions.append(TrafficTopup.created_at >= created_at_gte)
|
||||
result = await session.execute(
|
||||
select(func.coalesce(func.sum(TrafficTopup.purchased_bytes), 0)).where(
|
||||
and_(*conditions)
|
||||
)
|
||||
select(func.coalesce(func.sum(TrafficTopup.purchased_bytes), 0)).where(and_(*conditions))
|
||||
)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from ..models import (
|
||||
Payment,
|
||||
PromoCodeActivation,
|
||||
Subscription,
|
||||
SubscriptionNotification,
|
||||
SupportTicket,
|
||||
SupportTicketMessage,
|
||||
TariffChange,
|
||||
@@ -779,6 +780,11 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
|
||||
await session.execute(
|
||||
delete(TrafficWarning).where(TrafficWarning.subscription_id.in_(subscription_ids))
|
||||
)
|
||||
await session.execute(
|
||||
delete(SubscriptionNotification).where(
|
||||
SubscriptionNotification.subscription_id.in_(subscription_ids)
|
||||
)
|
||||
)
|
||||
await session.execute(
|
||||
delete(SupportTicketMessage).where(SupportTicketMessage.ticket_id.in_(support_ticket_ids))
|
||||
)
|
||||
|
||||
@@ -925,9 +925,7 @@ def _migration_0029_add_hwid_device_purchase_validity(connection: Connection) ->
|
||||
if "hwid_device_purchases" not in table_names or "subscriptions" not in table_names:
|
||||
return
|
||||
|
||||
columns: Set[str] = {
|
||||
col["name"] for col in inspector.get_columns("hwid_device_purchases")
|
||||
}
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("hwid_device_purchases")}
|
||||
if "valid_from" not in columns:
|
||||
connection.execute(
|
||||
text("ALTER TABLE hwid_device_purchases ADD COLUMN valid_from TIMESTAMPTZ")
|
||||
@@ -1002,9 +1000,7 @@ def _migration_0030_add_hwid_pricing_metadata(connection: Connection) -> None:
|
||||
connection.execute(text(f"ALTER TABLE payments ADD COLUMN {column} {ddl_type}"))
|
||||
|
||||
if "tariff_changes" in table_names:
|
||||
change_columns: Set[str] = {
|
||||
col["name"] for col in inspector.get_columns("tariff_changes")
|
||||
}
|
||||
change_columns: Set[str] = {col["name"] for col in inspector.get_columns("tariff_changes")}
|
||||
change_additions = {
|
||||
"converted_hwid_value_rub": "NUMERIC",
|
||||
"converted_hwid_days": "INTEGER",
|
||||
@@ -1016,6 +1012,41 @@ def _migration_0030_add_hwid_pricing_metadata(connection: Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _migration_0031_add_subscription_notifications(connection: Connection) -> None:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS subscription_notifications (
|
||||
notification_id SERIAL PRIMARY KEY,
|
||||
subscription_id INTEGER NOT NULL REFERENCES subscriptions(subscription_id),
|
||||
notification_key VARCHAR(64) NOT NULL,
|
||||
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_subscription_notification_key UNIQUE (
|
||||
subscription_id,
|
||||
notification_key
|
||||
)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_subscription_notifications_subscription_id
|
||||
ON subscription_notifications (subscription_id)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_subscription_notifications_notification_key
|
||||
ON subscription_notifications (notification_key)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
MIGRATIONS: List[Migration] = [
|
||||
Migration(
|
||||
id="0001_add_channel_subscription_fields",
|
||||
@@ -1178,6 +1209,11 @@ MIGRATIONS: List[Migration] = [
|
||||
description="Persist quoted HWID top-up pricing windows and conversion audit",
|
||||
upgrade=_migration_0030_add_hwid_pricing_metadata,
|
||||
),
|
||||
Migration(
|
||||
id="0031_add_subscription_notifications",
|
||||
description="Track sent subscription notification stages",
|
||||
upgrade=_migration_0031_add_subscription_notifications,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -277,6 +277,26 @@ class TrafficWarning(Base):
|
||||
subscription = relationship("Subscription")
|
||||
|
||||
|
||||
class SubscriptionNotification(Base):
|
||||
__tablename__ = "subscription_notifications"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"subscription_id",
|
||||
"notification_key",
|
||||
name="uq_subscription_notification_key",
|
||||
),
|
||||
)
|
||||
|
||||
notification_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
subscription_id = Column(
|
||||
Integer, ForeignKey("subscriptions.subscription_id"), nullable=False, index=True
|
||||
)
|
||||
notification_key = Column(String(64), nullable=False, index=True)
|
||||
sent_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
subscription = relationship("Subscription")
|
||||
|
||||
|
||||
class TariffChange(Base):
|
||||
__tablename__ = "tariff_changes"
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ from bot.payment_providers.yookassa import (
|
||||
process_cancelled_payment,
|
||||
process_successful_payment,
|
||||
)
|
||||
from bot.services.backup_worker import BackupWorker
|
||||
from bot.services.locale_override_service import load_locale_overrides
|
||||
from bot.services.subscription_notification_worker import SubscriptionNotificationWorker
|
||||
from bot.services.tariff_worker import TariffTrafficWorker
|
||||
from bot.utils.message_queue import init_queue_manager
|
||||
from config.settings import get_settings
|
||||
@@ -191,6 +193,22 @@ async def main() -> None:
|
||||
tasks = []
|
||||
if settings.tariffs_config:
|
||||
tasks.append(asyncio.create_task(tariff_worker.run(), name="TariffTrafficWorker"))
|
||||
subscription_notification_worker = SubscriptionNotificationWorker(
|
||||
settings,
|
||||
session_factory,
|
||||
bot,
|
||||
i18n,
|
||||
services["panel_service"],
|
||||
services["subscription_service"],
|
||||
)
|
||||
tasks.append(
|
||||
asyncio.create_task(
|
||||
subscription_notification_worker.run(),
|
||||
name="SubscriptionNotificationWorker",
|
||||
)
|
||||
)
|
||||
backup_worker = BackupWorker(settings, bot, session_factory=session_factory)
|
||||
tasks.append(asyncio.create_task(backup_worker.run(), name="BackupWorker"))
|
||||
tasks.append(asyncio.create_task(_panel_sync_loop(settings, session_factory, i18n, services)))
|
||||
for idx in range(max(1, settings.WEBHOOK_QUEUE_CONCURRENCY)):
|
||||
tasks.append(
|
||||
|
||||
@@ -53,21 +53,35 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends ca-certificates curl gnupg; \
|
||||
. /etc/os-release; \
|
||||
install -d /usr/share/postgresql-common/pgdg; \
|
||||
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||
| gpg --dearmor -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg; \
|
||||
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg] https://apt.postgresql.org/pub/repos/apt ${VERSION_CODENAME}-pgdg main" \
|
||||
> /etc/apt/sources.list.d/pgdg.list; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends postgresql-client-17; \
|
||||
apt-get purge -y --auto-remove curl gnupg; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -u 10001 -m appuser
|
||||
|
||||
COPY backend/requirements.txt backend/requirements.txt
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --no-cache-dir -r backend/requirements.txt
|
||||
|
||||
COPY backend ./backend
|
||||
COPY locales ./locales
|
||||
COPY data ./data
|
||||
COPY --from=version-builder /build-version /app/.build-version
|
||||
COPY --from=version-builder /build-tag /app/.build-tag
|
||||
COPY --from=version-builder /build-commit /app/.build-commit
|
||||
COPY backend ./backend
|
||||
RUN mkdir -p /app/logs /app/data \
|
||||
&& if [ ! -f /app/data/locales-overrides.json ]; then printf '{}\n' > /app/data/locales-overrides.json; fi \
|
||||
&& chown -R appuser:appuser /app/logs /app/data
|
||||
COPY --from=version-builder /build-version /app/.build-version
|
||||
COPY --from=version-builder /build-tag /app/.build-tag
|
||||
COPY --from=version-builder /build-commit /app/.build-commit
|
||||
|
||||
USER appuser
|
||||
|
||||
@@ -102,7 +116,7 @@ RUN --mount=type=cache,target=/root/.npm \
|
||||
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
|
||||
COPY frontend ./
|
||||
COPY backend/bot/app/web/templates ../backend/bot/app/web/templates
|
||||
RUN mkdir -p ../backend/bot/app/web/templates
|
||||
|
||||
RUN npm run build:webapp
|
||||
|
||||
@@ -116,8 +130,6 @@ LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minis
|
||||
|
||||
COPY deploy/docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY deploy/docker/frontend/00-startup-banner.sh /docker-entrypoint.d/00-startup-banner.sh
|
||||
COPY --from=version-builder /build-tag /build-tag
|
||||
COPY --from=version-builder /build-commit /build-commit
|
||||
COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/
|
||||
@@ -169,5 +181,7 @@ RUN set -eu; \
|
||||
-e '/subscription_webapp.js" defer/d' \
|
||||
/usr/share/nginx/html/index.html; \
|
||||
chmod +x /docker-entrypoint.d/00-startup-banner.sh
|
||||
COPY --from=version-builder /build-tag /build-tag
|
||||
COPY --from=version-builder /build-commit /build-commit
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -59,6 +59,15 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /webapp-default-logo.webp {
|
||||
proxy_pass http://backend:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/(favicon\.ico|apple-touch-icon(?:-precomposed)?\.png|icon-(?:192|512)\.png)$ {
|
||||
proxy_pass http://backend:8081;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -1,33 +1,12 @@
|
||||
# Готовые варианты запуска
|
||||
# Примеры Docker Compose
|
||||
|
||||
В этой папке лежат самодостаточные compose-примеры. Каждый вариант запускается из своей директории обычной командой:
|
||||
Каноничная документация по вариантам запуска живет в [docs/getting-started/deployment.md](../../docs/getting-started/deployment.md).
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
После старта полезно проверить:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f backend worker frontend
|
||||
```
|
||||
|
||||
## Какой вариант выбрать
|
||||
|
||||
| Папка | Когда использовать | Что править |
|
||||
| --- | --- | --- |
|
||||
| [`caddy`](caddy) | Нужен самый простой публичный HTTPS с автоматическими сертификатами Let's Encrypt. | `.env`; при нестандартной схеме можно поправить `Caddyfile`. |
|
||||
| [`nginx`](nginx) | Уже используете Nginx и готовы положить TLS-сертификаты рядом с примером. | `.env`, `nginx.conf.template`, файлы в `ssl/`. |
|
||||
| [`newt`](newt) | Публикуете сервисы через Pangolin/Newt без входящих портов на сервере приложения. | `.env` и ресурсы в панели Pangolin. |
|
||||
| [`no-proxy`](no-proxy) | Нужно напрямую открыть порты backend/frontend или проверить стек без reverse proxy. | `.env`. |
|
||||
|
||||
Для всех вариантов нужны два публичных URL:
|
||||
|
||||
- webhook/backend URL для Telegram, платежных систем и Remnawave webhooks;
|
||||
- Mini App/frontend URL для Telegram Mini App и Web App.
|
||||
|
||||
Обычно это два домена, например `webhooks.example.com` и `app.example.com`.
|
||||
Эта папка хранит только рабочие compose-примеры и конфиги. Подробное описание не дублируется здесь, чтобы сайт документации и навигация из README использовали один источник.
|
||||
|
||||
| Папка | Документация |
|
||||
| --- | --- |
|
||||
| `caddy` | [Развертывание с Caddy](../../docs/getting-started/deployment.md#caddy-рекомендуемый-вариант) |
|
||||
| `nginx` | [Развертывание с Nginx](../../docs/getting-started/deployment.md#nginx) |
|
||||
| `newt` | [Развертывание через Pangolin / Newt](../../docs/getting-started/deployment.md#pangolin--newt) |
|
||||
| `no-proxy` | [Запуск без обратного прокси](../../docs/getting-started/deployment.md#без-обратного-прокси) |
|
||||
|
||||
@@ -38,4 +38,3 @@ PANEL_WEBHOOK_SECRET=change_me
|
||||
|
||||
# Caddy and Docker network ranges that may set X-Forwarded-For.
|
||||
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
||||
|
||||
|
||||
@@ -1,31 +1,5 @@
|
||||
# Запуск с Caddy
|
||||
# Caddy
|
||||
|
||||
Caddy сам выпускает и продлевает HTTPS-сертификаты. На сервере должны быть открыты входящие `80/tcp` и `443/tcp`, а DNS-записи `WEBHOOK_HOST` и `MINIAPP_HOST` должны смотреть на этот сервер.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Минимально поменяйте в `.env`:
|
||||
|
||||
- `WEBHOOK_HOST` и `MINIAPP_HOST`;
|
||||
- `BOT_TOKEN`, `ADMIN_IDS`;
|
||||
- `POSTGRES_PASSWORD`;
|
||||
- `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN`;
|
||||
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET`.
|
||||
|
||||
`Caddyfile` лежит рядом и использует домены из `.env`. Если нужна нестандартная логика Caddy, правьте его и перезапускайте:
|
||||
|
||||
```bash
|
||||
docker compose up -d --force-recreate caddy
|
||||
```
|
||||
|
||||
Проверка:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f caddy backend worker frontend
|
||||
```
|
||||
Каноничная инструкция: [docs/getting-started/deployment.md](../../../docs/getting-started/deployment.md#caddy-рекомендуемый-вариант).
|
||||
|
||||
Файлы этого примера остаются рядом: `docker-compose.yml`, `.env.example` и `Caddyfile`.
|
||||
|
||||
@@ -67,6 +67,7 @@ services:
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
@@ -91,6 +92,7 @@ services:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
|
||||
@@ -39,4 +39,3 @@ PANEL_WEBHOOK_SECRET=change_me
|
||||
|
||||
# Pangolin/Newt and Docker network ranges that may set X-Forwarded-For.
|
||||
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
||||
|
||||
|
||||
@@ -1,33 +1,5 @@
|
||||
# Запуск через Newt / Pangolin
|
||||
# Pangolin / Newt
|
||||
|
||||
Этот вариант не открывает входящие порты на сервере приложения. Newt подключается к Pangolin, а публичные домены настраиваются ресурсами в панели Pangolin.
|
||||
Каноничная инструкция: [docs/getting-started/deployment.md](../../../docs/getting-started/deployment.md#pangolin--newt).
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
В `.env` заполните:
|
||||
|
||||
- `WEBHOOK_HOST` и `MINIAPP_HOST` - публичные домены ресурсов в Pangolin;
|
||||
- `PANGOLIN_ENDPOINT`, `NEWT_ID`, `NEWT_SECRET` - значения из настроек site/client в Pangolin;
|
||||
- обычные переменные приложения: `BOT_TOKEN`, `ADMIN_IDS`, `POSTGRES_PASSWORD`, секреты и доступ к Remnawave.
|
||||
|
||||
Официальная инструкция Pangolin по установке Newt site: <https://docs.pangolin.net/manage/sites/install-site>.
|
||||
|
||||
В Pangolin создайте два HTTP-ресурса для этого Newt site:
|
||||
|
||||
| Публичный домен | Upstream |
|
||||
| --- | --- |
|
||||
| `https://webhooks.example.com` | `http://backend:8080` |
|
||||
| `https://app.example.com` | `http://frontend:80` |
|
||||
|
||||
Домены в Pangolin должны совпадать с `WEBHOOK_HOST` и `MINIAPP_HOST`.
|
||||
|
||||
Проверка:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f newt backend worker frontend
|
||||
```
|
||||
Файлы этого примера остаются рядом: `docker-compose.yml` и `.env.example`.
|
||||
|
||||
@@ -67,6 +67,7 @@ services:
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
@@ -91,6 +92,7 @@ services:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
|
||||
@@ -38,4 +38,3 @@ PANEL_WEBHOOK_SECRET=change_me
|
||||
|
||||
# Nginx and Docker network ranges that may set X-Forwarded-For.
|
||||
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
||||
|
||||
|
||||
@@ -1,42 +1,5 @@
|
||||
# Запуск с Nginx
|
||||
# Nginx
|
||||
|
||||
Этот пример поднимает Nginx в той же Docker-сети, что и приложение:
|
||||
|
||||
- `WEBHOOK_HOST` проксируется в `backend:8080`;
|
||||
- `MINIAPP_HOST` проксируется в `frontend:80`;
|
||||
- `frontend` сам проксирует внутренние `/api`, `/auth` и ассеты тем в `backend:8081`.
|
||||
|
||||
## Подготовка
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Положите TLS-сертификаты в `ssl/`:
|
||||
|
||||
```text
|
||||
ssl/
|
||||
webhooks.example.com/
|
||||
fullchain.pem
|
||||
privkey.pem
|
||||
app.example.com/
|
||||
fullchain.pem
|
||||
privkey.pem
|
||||
```
|
||||
|
||||
Имена папок должны совпадать с `WEBHOOK_HOST` и `MINIAPP_HOST` в `.env`.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose logs -f nginx backend worker frontend
|
||||
```
|
||||
|
||||
Если нужно поменять заголовки, лимиты или TLS-настройки, правьте `nginx.conf.template` рядом с compose и перезапускайте Nginx:
|
||||
|
||||
```bash
|
||||
docker compose up -d --force-recreate nginx
|
||||
```
|
||||
Каноничная инструкция: [docs/getting-started/deployment.md](../../../docs/getting-started/deployment.md#nginx).
|
||||
|
||||
Файлы этого примера остаются рядом: `docker-compose.yml`, `.env.example`, `nginx.conf.template` и папка `ssl/` для сертификатов.
|
||||
|
||||
@@ -67,6 +67,7 @@ services:
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
@@ -91,6 +92,7 @@ services:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# TLS-сертификаты для Nginx
|
||||
# TLS certificates
|
||||
|
||||
Положите сюда сертификаты для доменов из `.env`.
|
||||
Каноничная инструкция по Nginx: [docs/getting-started/deployment.md](../../../../docs/getting-started/deployment.md#nginx).
|
||||
|
||||
Пример структуры:
|
||||
Кладите сертификаты в подпапки, совпадающие с `WEBHOOK_HOST` и `MINIAPP_HOST`:
|
||||
|
||||
```text
|
||||
ssl/
|
||||
@@ -13,6 +13,3 @@ ssl/
|
||||
fullchain.pem
|
||||
privkey.pem
|
||||
```
|
||||
|
||||
Если используете wildcard-сертификат, можно положить одинаковые `fullchain.pem` и `privkey.pem` в обе папки.
|
||||
|
||||
|
||||
@@ -1,23 +1,5 @@
|
||||
# Запуск без reverse proxy
|
||||
# Без обратного прокси
|
||||
|
||||
Этот вариант напрямую публикует два HTTP-порта:
|
||||
|
||||
- backend/webhooks: `WEB_SERVER_BIND`, по умолчанию `0.0.0.0:8080`;
|
||||
- frontend/Mini App: `FRONTEND_BIND`, по умолчанию `0.0.0.0:8082`.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Важно: контейнеры приложения сами не выпускают TLS-сертификаты. Для реального Telegram webhook и Mini App публичные URL должны быть HTTPS. Используйте этот вариант для локальной проверки, внутренней сети или когда HTTPS уже завершается внешней платформой и дальше трафик приходит на эти порты.
|
||||
|
||||
Проверка локально:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8080/healthz
|
||||
curl http://127.0.0.1:8082/health
|
||||
docker compose logs -f backend worker frontend
|
||||
```
|
||||
Каноничная инструкция: [docs/getting-started/deployment.md](../../../docs/getting-started/deployment.md#без-обратного-прокси).
|
||||
|
||||
Файлы этого примера остаются рядом: `docker-compose.yml` и `.env.example`.
|
||||
|
||||
@@ -69,6 +69,7 @@ services:
|
||||
- "${WEB_SERVER_BIND:-0.0.0.0:8080}:8080"
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
@@ -93,6 +94,7 @@ services:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
|
||||
@@ -48,6 +48,7 @@ services:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||
networks:
|
||||
- remnawave-shop
|
||||
ports:
|
||||
@@ -82,6 +83,7 @@ services:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||
networks:
|
||||
- remnawave-shop
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import starlight from '@astrojs/starlight';
|
||||
import starlightThemeNova from 'starlight-theme-nova';
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://minishop.minidoc.cc',
|
||||
integrations: [
|
||||
starlight({
|
||||
title: 'minishop',
|
||||
favicon: '/favicon.png',
|
||||
description:
|
||||
'Документация по настройке, развертыванию и эксплуатации Remnawave Minishop.',
|
||||
plugins: [
|
||||
starlightThemeNova({
|
||||
nav: [
|
||||
{ label: 'Демо', href: '/demo/home' },
|
||||
{ label: 'Установка', href: '/getting-started/setup/' },
|
||||
{ label: 'GitHub', href: 'https://github.com/3252a8/remnawave-minishop' },
|
||||
{ label: 'Telegram', href: 'https://t.me/remnawave_minishop' }
|
||||
],
|
||||
}),
|
||||
],
|
||||
customCss: ['./src/styles/custom.css'],
|
||||
components: {
|
||||
Header: './src/components/Header.astro',
|
||||
},
|
||||
lastUpdated: false,
|
||||
locales: {
|
||||
root: {
|
||||
label: 'Русский',
|
||||
lang: 'ru',
|
||||
},
|
||||
},
|
||||
head: [
|
||||
{
|
||||
tag: 'link',
|
||||
attrs: {
|
||||
rel: 'icon',
|
||||
href: '/favicon.webp',
|
||||
type: 'image/webp',
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: 'meta',
|
||||
attrs: {
|
||||
name: 'theme-color',
|
||||
content: '#00fe7a',
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: 'meta',
|
||||
attrs: {
|
||||
property: 'og:site_name',
|
||||
content: 'Remnawave Minishop Docs',
|
||||
},
|
||||
},
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
label: 'Начало',
|
||||
items: [
|
||||
{ label: 'Обзор', slug: 'getting-started/overview' },
|
||||
{ label: 'Демо-режим', slug: 'getting-started/demo' },
|
||||
{ label: 'Установка', slug: 'getting-started/setup' },
|
||||
{ label: 'Развертывание', slug: 'getting-started/deployment' },
|
||||
{ label: 'Настройка окружения', slug: 'getting-started/configuration' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Конфигурация',
|
||||
items: [
|
||||
{ label: 'Переменные окружения', slug: 'configuration/env-vars' },
|
||||
{ label: 'Безопасность', slug: 'configuration/security' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Возможности',
|
||||
items: [
|
||||
{ label: 'Основные', slug: 'features/core' },
|
||||
{ label: 'Платежи', slug: 'features/payments' },
|
||||
{ label: 'Подписки', slug: 'features/subscriptions' },
|
||||
{ label: 'Тарифы', slug: 'features/tariffs' },
|
||||
{ label: 'Веб-приложение / Mini App', slug: 'features/web-app' },
|
||||
{ label: 'Telegram-авторизация', slug: 'features/telegram-auth' },
|
||||
{ label: 'Вход по email', slug: 'features/email-login' },
|
||||
{ label: 'Темы Web App', slug: 'features/webapp-themes' },
|
||||
{ label: 'Админ-панель', slug: 'features/admin-panel' },
|
||||
{ label: 'Бэкапы и восстановление', slug: 'features/backups' },
|
||||
{ label: 'Поддержка пользователей / тикеты', slug: 'features/support' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Миграции',
|
||||
items: [
|
||||
{ label: 'Обзор миграций', slug: 'migrations' },
|
||||
{ label: 'remnawave-tg-shop', slug: 'migrations/remnawave-tg-shop' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Справка',
|
||||
items: [
|
||||
{ label: 'Проблемы', slug: 'troubleshooting/issues' },
|
||||
{ label: 'Логи', slug: 'troubleshooting/logs' },
|
||||
{ label: 'Обслуживание', slug: 'troubleshooting/maintenance' },
|
||||
{ label: 'Архитектура', slug: 'reference/architecture' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"sync:docs": "node ./scripts/sync-docs.mjs",
|
||||
"build:demo": "node ./scripts/build-demo-runtime.mjs",
|
||||
"dev": "npm run sync:docs && npm run build:demo && astro dev",
|
||||
"build": "npm run sync:docs && npm run build:demo && astro build && node ./scripts/materialize-demo-routes.mjs",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/starlight": "^0.39.2",
|
||||
"astro": "^6.3.7",
|
||||
"starlight-theme-nova": "^0.11.11",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# Cloudflare Pages rewrites for the static docs demo SPA.
|
||||
# Keep these scoped to app routes so runtime JS/CSS/assets are served directly.
|
||||
/demo/runtime /demo/runtime/app.html 200
|
||||
/demo/runtime/ /demo/runtime/app.html 200
|
||||
/demo/runtime/home /demo/runtime/app.html 200
|
||||
/demo/runtime/home/* /demo/runtime/app.html 200
|
||||
/demo/runtime/install /demo/runtime/app.html 200
|
||||
/demo/runtime/install/* /demo/runtime/app.html 200
|
||||
/demo/runtime/trial /demo/runtime/app.html 200
|
||||
/demo/runtime/trial/* /demo/runtime/app.html 200
|
||||
/demo/runtime/invite /demo/runtime/app.html 200
|
||||
/demo/runtime/invite/* /demo/runtime/app.html 200
|
||||
/demo/runtime/devices /demo/runtime/app.html 200
|
||||
/demo/runtime/devices/* /demo/runtime/app.html 200
|
||||
/demo/runtime/support /demo/runtime/app.html 200
|
||||
/demo/runtime/support/* /demo/runtime/app.html 200
|
||||
/demo/runtime/settings /demo/runtime/app.html 200
|
||||
/demo/runtime/settings/* /demo/runtime/app.html 200
|
||||
/demo/runtime/login /demo/runtime/app.html 200
|
||||
/demo/runtime/login/* /demo/runtime/app.html 200
|
||||
/demo/runtime/admin /demo/runtime/app.html 200
|
||||
/demo/runtime/admin/* /demo/runtime/app.html 200
|
||||
@@ -0,0 +1,204 @@
|
||||
const frame = document.getElementById("demo-frame");
|
||||
const runtimeBase = "/demo/runtime";
|
||||
const demoBase = "/demo";
|
||||
const defaultMock = "tariffs";
|
||||
const stateMocks = new Set([
|
||||
"tariffs",
|
||||
"depleted",
|
||||
"no-subscription",
|
||||
"trial",
|
||||
"devices",
|
||||
"auth",
|
||||
]);
|
||||
const routeMocks = new Set([...stateMocks, "guides", "install"]);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const normalizePath = (value) => {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return "/home";
|
||||
const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
|
||||
return withSlash.replace(/\/{2,}/g, "/").replace(/\/+$/, "") || "/home";
|
||||
};
|
||||
|
||||
const normalizeRouteMock = (value) => {
|
||||
const mock = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return routeMocks.has(mock) ? mock : defaultMock;
|
||||
};
|
||||
|
||||
const normalizeStateMock = (value) => {
|
||||
const mock = normalizeRouteMock(value);
|
||||
return stateMocks.has(mock) ? mock : defaultMock;
|
||||
};
|
||||
|
||||
const routeFromPublicPath = () => {
|
||||
const pathname = window.location.pathname.replace(/\/+$/, "") || "/";
|
||||
const lowerPathname = pathname.toLowerCase();
|
||||
if (lowerPathname === demoBase) return "";
|
||||
if (!lowerPathname.startsWith(`${demoBase}/`)) return "";
|
||||
|
||||
const publicRoute = pathname.slice(demoBase.length);
|
||||
if (!publicRoute || publicRoute.toLowerCase().startsWith("/runtime"))
|
||||
return "";
|
||||
return normalizePath(publicRoute);
|
||||
};
|
||||
|
||||
const routeFromParams = () => {
|
||||
const publicRoute = routeFromPublicPath();
|
||||
if (publicRoute) return publicRoute;
|
||||
|
||||
const explicitPath = params.get("path");
|
||||
if (explicitPath) return normalizePath(explicitPath);
|
||||
|
||||
const screen = String(params.get("screen") || "home")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (screen === "admin") {
|
||||
const adminSection = String(params.get("admin_section") || "stats")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return `/admin/${adminSection || "stats"}`;
|
||||
}
|
||||
if (
|
||||
[
|
||||
"home",
|
||||
"install",
|
||||
"trial",
|
||||
"invite",
|
||||
"devices",
|
||||
"support",
|
||||
"settings",
|
||||
"login",
|
||||
].includes(screen)
|
||||
) {
|
||||
return `/${screen}`;
|
||||
}
|
||||
return "/home";
|
||||
};
|
||||
|
||||
let initialRoute = routeFromParams();
|
||||
const mockForRoute = (route) => {
|
||||
const normalized = normalizePath(route);
|
||||
if (normalized === "/devices") return "devices";
|
||||
if (normalized === "/login" || normalized.startsWith("/login/"))
|
||||
return "auth";
|
||||
return "";
|
||||
};
|
||||
const initialMock = params.has("mock")
|
||||
? normalizeRouteMock(params.get("mock"))
|
||||
: normalizeRouteMock(mockForRoute(initialRoute) || defaultMock);
|
||||
if (initialMock === "trial" && initialRoute === "/trial") {
|
||||
initialRoute = "/home";
|
||||
const normalizedUrl = new URL(window.location.href);
|
||||
normalizedUrl.pathname = `${demoBase}/home`;
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${normalizedUrl.pathname}${normalizedUrl.search}${normalizedUrl.hash}`,
|
||||
);
|
||||
}
|
||||
params.set("mock", initialMock);
|
||||
params.delete("path");
|
||||
params.delete("screen");
|
||||
params.delete("admin_section");
|
||||
params.set("path", initialRoute);
|
||||
frame.src = `${runtimeBase}/app.html?${params.toString()}${window.location.hash || ""}`;
|
||||
|
||||
const routeFromRuntimeUrl = (url) => {
|
||||
if (url.origin !== window.location.origin) return "";
|
||||
if (!url.pathname.toLowerCase().startsWith(runtimeBase.toLowerCase()))
|
||||
return "";
|
||||
const runtimePath = normalizePath(
|
||||
url.pathname.slice(runtimeBase.length) || "/home",
|
||||
);
|
||||
if (runtimePath === "/app.html") {
|
||||
return normalizePath(url.searchParams.get("path") || "/home");
|
||||
}
|
||||
return runtimePath;
|
||||
};
|
||||
|
||||
const materializedRouteFromRuntime = (route) => {
|
||||
const normalized = normalizePath(route);
|
||||
if (/^\/admin\/users\/-?\d+$/i.test(normalized)) return "/admin/users";
|
||||
if (/^\/admin\/payments\/\d+$/i.test(normalized)) return "/admin/payments";
|
||||
if (/^\/admin\/payments\/users\/-?\d+$/i.test(normalized))
|
||||
return "/admin/payments";
|
||||
if (/^\/admin\/support\/\d+$/i.test(normalized)) return "/admin/support";
|
||||
if (/^\/support\/\d+$/i.test(normalized)) return "/support";
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const publicPathFromRoute = (route) =>
|
||||
`${demoBase}${materializedRouteFromRuntime(route)}`;
|
||||
const routeForStateMock = (mock) => {
|
||||
if (mock === "devices") return "/devices";
|
||||
if (mock === "auth") return "/login";
|
||||
return "/home";
|
||||
};
|
||||
const runtimeSrc = (route, searchParams = new URLSearchParams()) => {
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
nextParams.delete("screen");
|
||||
nextParams.delete("admin_section");
|
||||
nextParams.set("path", normalizePath(route));
|
||||
return `${runtimeBase}/app.html?${nextParams.toString()}${window.location.hash || ""}`;
|
||||
};
|
||||
const topbar = document.querySelector(".demo-topbar");
|
||||
const toggle = document.querySelector(".demo-topbar__toggle");
|
||||
const hide = document.querySelector(".demo-topbar__hide");
|
||||
const stateSelect = document.querySelector(".demo-topbar__state-select");
|
||||
|
||||
const syncParentUrlFromFrame = () => {
|
||||
try {
|
||||
const frameUrl = new URL(frame.contentWindow.location.href);
|
||||
const route = routeFromRuntimeUrl(frameUrl);
|
||||
if (!route) return;
|
||||
|
||||
const nextUrl = new URL(window.location.href);
|
||||
nextUrl.pathname = publicPathFromRoute(route);
|
||||
nextUrl.searchParams.delete("path");
|
||||
|
||||
const mock = normalizeRouteMock(
|
||||
frameUrl.searchParams.get("mock") || params.get("mock"),
|
||||
);
|
||||
if (mock === defaultMock) nextUrl.searchParams.delete("mock");
|
||||
else nextUrl.searchParams.set("mock", mock);
|
||||
if (stateSelect) stateSelect.value = normalizeStateMock(mock);
|
||||
|
||||
nextUrl.searchParams.delete("screen");
|
||||
nextUrl.searchParams.delete("admin_section");
|
||||
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
|
||||
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
if (nextPath !== currentPath)
|
||||
window.history.replaceState(null, "", nextPath);
|
||||
} catch (_error) {
|
||||
// The iframe is same-origin in docs builds; this keeps local oddities harmless.
|
||||
}
|
||||
};
|
||||
|
||||
frame.addEventListener("load", syncParentUrlFromFrame);
|
||||
window.setInterval(syncParentUrlFromFrame, 750);
|
||||
|
||||
const setCollapsed = (collapsed) => {
|
||||
topbar?.toggleAttribute("data-collapsed", collapsed);
|
||||
toggle?.setAttribute("aria-expanded", String(!collapsed));
|
||||
};
|
||||
|
||||
toggle?.addEventListener("click", () => setCollapsed(false));
|
||||
hide?.addEventListener("click", () => setCollapsed(true));
|
||||
if (stateSelect) stateSelect.value = normalizeStateMock(params.get("mock"));
|
||||
stateSelect?.addEventListener("change", () => {
|
||||
const mock = normalizeStateMock(stateSelect.value);
|
||||
const nextParams = new URLSearchParams(window.location.search);
|
||||
nextParams.delete("path");
|
||||
nextParams.delete("screen");
|
||||
nextParams.delete("admin_section");
|
||||
if (mock === defaultMock) nextParams.delete("mock");
|
||||
else nextParams.set("mock", mock);
|
||||
|
||||
const query = nextParams.toString();
|
||||
const stateRoute = routeForStateMock(mock);
|
||||
const publicUrl = `${demoBase}${stateRoute}${query ? `?${query}` : ""}`;
|
||||
window.history.replaceState(null, "", publicUrl);
|
||||
frame.src = runtimeSrc(stateRoute, nextParams);
|
||||
});
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,219 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
access,
|
||||
copyFile,
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const siteRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const repoRoot = path.resolve(siteRoot, "..");
|
||||
const frontendRoot = path.join(repoRoot, "frontend");
|
||||
const runtimeDir = path.join(siteRoot, "public", "demo", "runtime");
|
||||
const templatesDir = path.join(
|
||||
repoRoot,
|
||||
"backend",
|
||||
"bot",
|
||||
"app",
|
||||
"web",
|
||||
"templates",
|
||||
);
|
||||
const themesDir = path.join(repoRoot, "backend", "bot", "app", "web", "themes");
|
||||
const localesDir = path.join(repoRoot, "locales");
|
||||
const runtimeBase = "/demo/runtime";
|
||||
const installGuidesConfigUrl =
|
||||
"https://raw.githubusercontent.com/legiz-ru/my-remnawave/main/sub-page/subpage-config/multiapp.json";
|
||||
const installGuidesConfigRetries = 3;
|
||||
const isWindows = process.platform === "win32";
|
||||
const npmExecPath = process.env.npm_execpath || "";
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
...options,
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(
|
||||
new Error(`${command} ${args.join(" ")} exited with code ${code}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function runNpm(args) {
|
||||
if (npmExecPath) {
|
||||
return run(process.execPath, [npmExecPath, ...args]);
|
||||
}
|
||||
return run(isWindows ? "npm.cmd" : "npm", args, { shell: isWindows });
|
||||
}
|
||||
|
||||
async function pathExists(targetPath) {
|
||||
try {
|
||||
await access(targetPath);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureFrontendDependencies() {
|
||||
const viteBin = isWindows
|
||||
? path.join(frontendRoot, "node_modules", ".bin", "vite.cmd")
|
||||
: path.join(frontendRoot, "node_modules", ".bin", "vite");
|
||||
if (await pathExists(viteBin)) return;
|
||||
await runNpm(["--prefix", frontendRoot, "ci"]);
|
||||
}
|
||||
|
||||
async function copyDirectory(sourceDir, targetDir, transform = null) {
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
const entries = await readdir(sourceDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.join(sourceDir, entry.name);
|
||||
const targetPath = path.join(targetDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await copyDirectory(sourcePath, targetPath, transform);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
if (transform) {
|
||||
const handled = await transform(sourcePath, targetPath);
|
||||
if (handled) continue;
|
||||
}
|
||||
await mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await copyFile(sourcePath, targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyThemeFile(sourcePath, targetPath) {
|
||||
if (path.extname(sourcePath).toLowerCase() !== ".css") return false;
|
||||
const css = await readFile(sourcePath, "utf8");
|
||||
const rewritten = css.replace(
|
||||
/\/webapp-theme-assets\//g,
|
||||
`${runtimeBase}/themes/`,
|
||||
);
|
||||
await mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, rewritten, "utf8");
|
||||
return true;
|
||||
}
|
||||
|
||||
async function copyRuntimeAsset(name) {
|
||||
await copyFile(path.join(templatesDir, name), path.join(runtimeDir, name));
|
||||
}
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function jsonScriptPayload(value) {
|
||||
return JSON.stringify(value).replace(/</g, "\\u003c");
|
||||
}
|
||||
|
||||
async function demoI18nPayload() {
|
||||
const [ru, en] = await Promise.all([
|
||||
readFile(path.join(localesDir, "ru.json"), "utf8"),
|
||||
readFile(path.join(localesDir, "en.json"), "utf8"),
|
||||
]);
|
||||
return jsonScriptPayload({ ru: JSON.parse(ru), en: JSON.parse(en) });
|
||||
}
|
||||
|
||||
async function installGuidesConfigPayload() {
|
||||
let lastError;
|
||||
|
||||
for (let attempt = 1; attempt <= installGuidesConfigRetries; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(installGuidesConfigUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Unable to download demo install guides config (${response.status} ${response.statusText})`,
|
||||
);
|
||||
}
|
||||
const config = await response.json();
|
||||
return `${JSON.stringify(config, null, 2)}\n`;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt === installGuidesConfigRetries) break;
|
||||
await wait(500 * attempt);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function appHtml() {
|
||||
const i18n = await demoI18nPayload();
|
||||
return `<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#03070b" />
|
||||
<title>remnawave-minishop demo</title>
|
||||
<link
|
||||
id="app-favicon"
|
||||
rel="icon"
|
||||
href="${runtimeBase}/default-brand/favicons/19b2a242e5b7bc2d/icon-180.png"
|
||||
sizes="180x180"
|
||||
/>
|
||||
<link rel="stylesheet" href="${runtimeBase}/subscription_webapp_docs_demo.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<div class="app-boot-fallback" role="status" aria-label="Loading demo"></div>
|
||||
</main>
|
||||
<script id="i18n" type="application/json">${i18n}</script>
|
||||
<script src="${runtimeBase}/subscription_webapp_docs_demo.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
await ensureFrontendDependencies();
|
||||
await runNpm(["--prefix", frontendRoot, "run", "build:docs-demo"]);
|
||||
|
||||
await rm(runtimeDir, { recursive: true, force: true });
|
||||
await mkdir(runtimeDir, { recursive: true });
|
||||
|
||||
const html = await appHtml();
|
||||
|
||||
await Promise.all([
|
||||
copyRuntimeAsset("subscription_webapp_docs_demo.js"),
|
||||
copyRuntimeAsset("subscription_webapp_docs_demo.css"),
|
||||
copyRuntimeAsset("subscription_webapp_admin.js"),
|
||||
copyRuntimeAsset("subscription_webapp_admin.css"),
|
||||
copyDirectory(
|
||||
path.join(templatesDir, "default-brand"),
|
||||
path.join(runtimeDir, "default-brand"),
|
||||
),
|
||||
copyDirectory(themesDir, path.join(runtimeDir, "themes"), copyThemeFile),
|
||||
writeFile(path.join(runtimeDir, "app.html"), html, "utf8"),
|
||||
installGuidesConfigPayload().then((payload) =>
|
||||
writeFile(
|
||||
path.join(runtimeDir, "subscription-guides-config.json"),
|
||||
payload,
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
console.log(
|
||||
`Built static docs demo runtime at ${path.relative(repoRoot, runtimeDir)}`,
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
import { copyFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const siteRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const distRoot = path.join(siteRoot, "dist");
|
||||
|
||||
const userRoutes = [
|
||||
"home",
|
||||
"install",
|
||||
"trial",
|
||||
"invite",
|
||||
"devices",
|
||||
"support",
|
||||
"settings",
|
||||
"login",
|
||||
"login/password",
|
||||
];
|
||||
|
||||
const adminRoutes = [
|
||||
"stats",
|
||||
"users",
|
||||
"payments",
|
||||
"promos",
|
||||
"ads",
|
||||
"broadcast",
|
||||
"logs",
|
||||
"support",
|
||||
"tariffs",
|
||||
"appearance",
|
||||
"translations",
|
||||
"backups",
|
||||
"settings",
|
||||
];
|
||||
|
||||
const demoRoutes = [
|
||||
...userRoutes.map((route) => `demo/${route}`),
|
||||
"demo/admin",
|
||||
...adminRoutes.map((route) => `demo/admin/${route}`),
|
||||
];
|
||||
|
||||
const runtimeRoutes = [
|
||||
...userRoutes.map((route) => `demo/runtime/${route}`),
|
||||
"demo/runtime/admin",
|
||||
...adminRoutes.map((route) => `demo/runtime/admin/${route}`),
|
||||
];
|
||||
|
||||
async function copyHtml(source, route) {
|
||||
const targetDir = path.join(distRoot, route);
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
await copyFile(source, path.join(targetDir, "index.html"));
|
||||
}
|
||||
|
||||
const demoShell = path.join(distRoot, "demo", "index.html");
|
||||
const runtimeApp = path.join(distRoot, "demo", "runtime", "app.html");
|
||||
|
||||
for (const route of demoRoutes) {
|
||||
await copyHtml(demoShell, route);
|
||||
}
|
||||
|
||||
for (const route of runtimeRoutes) {
|
||||
await copyHtml(runtimeApp, route);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Materialized ${demoRoutes.length} public demo routes and ${runtimeRoutes.length} runtime routes`,
|
||||
);
|
||||
@@ -0,0 +1,193 @@
|
||||
import { copyFile, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const siteRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||
const repoRoot = path.resolve(siteRoot, '..');
|
||||
const sourceDir = path.join(repoRoot, 'docs');
|
||||
const outputDir = path.join(siteRoot, 'src', 'content', 'docs');
|
||||
|
||||
const descriptions = {
|
||||
'getting-started/demo.md': 'Как устроен статический демо-режим Remnawave Minishop и почему он собирается только вместе с документацией.',
|
||||
'index.md': 'Документация по запуску, настройке и сопровождению Telegram Mini App для Remnawave.',
|
||||
'getting-started/overview.md': 'Что входит в Remnawave Minishop и как связаны бот, Mini App, backend, worker и Remnawave Panel.',
|
||||
'getting-started/setup.md': 'Минимальный путь запуска Remnawave Minishop через Docker Compose.',
|
||||
'getting-started/configuration.md': 'Минимальный .env, bootstrap-секреты и настройка через Web App админку.',
|
||||
'getting-started/deployment.md': 'Docker Compose, обратный прокси, TLS, образы, обновления и резервные копии.',
|
||||
'configuration/security.md': 'Секреты, публичные URL, доступ администраторов и базовые меры защиты Minishop.',
|
||||
'configuration/env-vars.md': 'Полный справочник переменных окружения Remnawave Minishop.',
|
||||
'features/core.md': 'Пользовательские и админские сценарии Remnawave Minishop.',
|
||||
'features/payments.md': 'Платежные провайдеры, кнопки оплаты и webhook-обработка.',
|
||||
'features/subscriptions.md': 'Тарифы на срок и по трафику, premium-сквады, HWID-устройства и жизненный цикл подписки.',
|
||||
'features/tariffs.md': 'Каталог тарифов, модели на срок/по трафику, premium-сквады и HWID-устройства.',
|
||||
'features/web-app.md': 'Telegram Mini App, публичные инструкции, проксирование и реферальные ссылки.',
|
||||
'features/telegram-auth.md': 'Telegram Mini Apps initData, Telegram OAuth, BotFather и настройка входа через Telegram.',
|
||||
'features/email-login.md': 'SMTP, одноразовые коды, magic link, парольный вход и привязка email-аккаунтов.',
|
||||
'features/webapp-themes.md': 'Кастомные темы, CSS-токены, ассеты и пайплайн создания темы.',
|
||||
'features/admin-panel.md': 'Возможности админ-панели, управление пользователями, настройками, тарифами и поддержкой.',
|
||||
'features/backups.md': 'Автоматические бэкапы, отправка архивов в Telegram, локальное хранение и восстановление БД/compose-папки из админки.',
|
||||
'features/support.md': 'Пользовательские тикеты, список обращений в админке, уведомления и лимиты поддержки.',
|
||||
'migrations/index.md': 'Готовые сценарии миграции в Remnawave Minishop с других ботов.',
|
||||
'migrations/remnawave-tg-shop.md': 'Перенос данных со старого remnawave-tg-shop на split-архитектуру Minishop.',
|
||||
'troubleshooting/issues.md': 'Короткие чеклисты для частых проблем запуска, вебхуков, Mini App и платежей.',
|
||||
'troubleshooting/logs.md': 'Какие логи смотреть при диагностике backend, worker, frontend, миграций и вебхуков.',
|
||||
'troubleshooting/maintenance.md': 'Обновления, миграции, резервные копии и проверки продакшен-стека.',
|
||||
'architecture.md': 'Краткая архитектура backend, frontend, worker и инфраструктурных сервисов.',
|
||||
};
|
||||
|
||||
const imageExtensions = new Set(['.avif', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp']);
|
||||
|
||||
function yamlString(value) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function toPosix(relativePath) {
|
||||
return relativePath.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function outputRelativePath(sourceRelativePath) {
|
||||
if (sourceRelativePath === 'index.md') {
|
||||
return 'index.md';
|
||||
}
|
||||
if (!sourceRelativePath.includes('/')) {
|
||||
return `reference/${sourceRelativePath}`;
|
||||
}
|
||||
return sourceRelativePath;
|
||||
}
|
||||
|
||||
function pagePathForSource(sourceRelativePath, hash = '') {
|
||||
const output = outputRelativePath(sourceRelativePath).replace(/\.md$/i, '');
|
||||
const route = output === 'index' ? '/' : `/${output.replace(/\/index$/u, '')}/`;
|
||||
return `${route}${hash}`;
|
||||
}
|
||||
|
||||
function titleForRelativePath(relativePath) {
|
||||
const baseName = path.posix.basename(relativePath, '.md');
|
||||
return baseName;
|
||||
}
|
||||
|
||||
function extractTitle(relativePath, content) {
|
||||
const match = content.match(/^#\s+(.+?)\s*$/m);
|
||||
return match?.[1] ?? titleForRelativePath(relativePath);
|
||||
}
|
||||
|
||||
function stripFirstHeading(content) {
|
||||
return content.replace(/^#\s+.+?\s*\r?\n+/, '');
|
||||
}
|
||||
|
||||
function rewriteMarkdownLinks(markdown, sourceRelativePath) {
|
||||
const sourceDirectory = path.posix.dirname(sourceRelativePath);
|
||||
return markdown.replace(/\]\((?!https?:\/\/|mailto:|tel:|\/|#)([^)\s]+\.md)(#[^)]+)?\)/g, (match, target, hash = '') => {
|
||||
const resolvedTarget = path.posix.normalize(path.posix.join(sourceDirectory, target));
|
||||
return `](${pagePathForSource(resolvedTarget, hash)})`;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeCodeFences(markdown) {
|
||||
return markdown
|
||||
.replace(/^```env\s*$/gim, '```ini')
|
||||
.replace(/^```caddyfile\s*$/gim, '```txt');
|
||||
}
|
||||
|
||||
function extraFrontmatter(sourceRelativePath) {
|
||||
if (sourceRelativePath !== 'index.md') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'template: splash',
|
||||
'hero:',
|
||||
' tagline: "Telegram-бот и Mini App для продажи подписок Remnawave: платежи, тарифы, админка, поддержка и инструкции подключения."',
|
||||
' image:',
|
||||
' html: \'<img class="minishop-hero-screenshot" src="/remnawave-minishop.webp" alt="Интерфейс Remnawave Minishop" width="1920" height="1080" loading="eager" decoding="async" />\'',
|
||||
' actions:',
|
||||
' - text: "Демо"',
|
||||
' link: /demo/home',
|
||||
' icon: right-arrow',
|
||||
' - text: "Установка"',
|
||||
' link: /getting-started/setup/',
|
||||
' icon: setting',
|
||||
' variant: minimal',
|
||||
];
|
||||
}
|
||||
|
||||
function frontmatter({ title, description, sourceRelativePath }) {
|
||||
const editPath = sourceRelativePath
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
const editUrl = `https://github.com/3252a8/remnawave-minishop/edit/main/docs/${editPath}`;
|
||||
return [
|
||||
'---',
|
||||
`title: ${yamlString(title)}`,
|
||||
`description: ${yamlString(description)}`,
|
||||
`editUrl: ${yamlString(editUrl)}`,
|
||||
...extraFrontmatter(sourceRelativePath),
|
||||
'---',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function walk(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(absolutePath)));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile()) {
|
||||
files.push(absolutePath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function syncMarkdown(files) {
|
||||
for (const sourcePath of files.filter((file) => file.endsWith('.md'))) {
|
||||
const sourceRelativePath = toPosix(path.relative(sourceDir, sourcePath));
|
||||
const outputRelative = outputRelativePath(sourceRelativePath);
|
||||
const outputPath = path.join(outputDir, ...outputRelative.split('/'));
|
||||
const content = await readFile(sourcePath, 'utf8');
|
||||
const title = extractTitle(sourceRelativePath, content);
|
||||
const body = normalizeCodeFences(
|
||||
rewriteMarkdownLinks(stripFirstHeading(content).trimStart(), sourceRelativePath),
|
||||
);
|
||||
const output = frontmatter({
|
||||
title,
|
||||
description: descriptions[sourceRelativePath] ?? title,
|
||||
sourceRelativePath,
|
||||
});
|
||||
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, `${output}${body}\n`, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAssets(files) {
|
||||
for (const sourcePath of files.filter((file) => imageExtensions.has(path.extname(file).toLowerCase()))) {
|
||||
const sourceRelativePath = toPosix(path.relative(sourceDir, sourcePath));
|
||||
const outputRelative = !sourceRelativePath.includes('/')
|
||||
? sourceRelativePath
|
||||
: sourceRelativePath;
|
||||
const outputPath = path.join(outputDir, ...outputRelative.split('/'));
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await copyFile(sourcePath, outputPath);
|
||||
|
||||
if (!sourceRelativePath.includes('/')) {
|
||||
const referenceOutputPath = path.join(outputDir, 'reference', sourceRelativePath);
|
||||
await mkdir(path.dirname(referenceOutputPath), { recursive: true });
|
||||
await copyFile(sourcePath, referenceOutputPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const files = await walk(sourceDir);
|
||||
await syncMarkdown(files);
|
||||
await syncAssets(files);
|
||||
|
||||
console.log(`Synced documentation from ${path.relative(repoRoot, sourceDir)} to ${path.relative(repoRoot, outputDir)}`);
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
import type { StarlightRouteData } from '@astrojs/starlight/route-data'
|
||||
import { Icon } from '@astrojs/starlight/components'
|
||||
import LanguageSelect from 'virtual:starlight/components/LanguageSelect'
|
||||
import Search from 'virtual:starlight/components/Search'
|
||||
import SiteTitle from 'virtual:starlight/components/SiteTitle'
|
||||
import SocialIcons from 'virtual:starlight/components/SocialIcons'
|
||||
import ThemeSelect from 'virtual:starlight/components/ThemeSelect'
|
||||
import config from 'virtual:starlight/user-config'
|
||||
|
||||
import MobileMenuToggle from 'starlight-theme-nova/components/MobileMenuToggle.astro'
|
||||
import options from 'virtual:starlight-theme-nova/user-config'
|
||||
|
||||
const { hasSidebar } = Astro.locals.starlightRoute
|
||||
const nav = options.nav ?? []
|
||||
const route = Astro.locals.starlightRoute
|
||||
|
||||
function getI18nText(
|
||||
value: string | Record<string, string>,
|
||||
route: StarlightRouteData,
|
||||
): string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
const { lang, locale } = route
|
||||
|
||||
if (value[lang]) {
|
||||
return value[lang]
|
||||
}
|
||||
|
||||
if (config.defaultLocale.lang === lang || config.defaultLocale.locale === locale) {
|
||||
if (value.root) {
|
||||
return value.root
|
||||
}
|
||||
}
|
||||
|
||||
if (locale && value[locale]) {
|
||||
return value[locale]
|
||||
}
|
||||
|
||||
throw new Error(`Unable to find the translation for language "${lang}".`)
|
||||
}
|
||||
|
||||
function navIcon(href: string) {
|
||||
if (href.includes('github.com/3252a8/remnawave-minishop')) {
|
||||
return 'github'
|
||||
}
|
||||
if (href.includes('t.me/remnawave_minishop')) {
|
||||
return 'telegram'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
<div class="box-border flex h-full items-center gap-2">
|
||||
<div
|
||||
class:list={[
|
||||
'-m-3 overflow-clip p-3',
|
||||
'flex min-w-0',
|
||||
]}
|
||||
>
|
||||
<SiteTitle />
|
||||
</div>
|
||||
<nav
|
||||
class="hidden flex-1 flex-row gap-4 overflow-x-auto py-3 pr-4 pl-4 text-sm font-medium md:flex xl:gap-6 xl:pl-6"
|
||||
>
|
||||
{
|
||||
nav.map((item) => {
|
||||
const href = getI18nText(item.href, route)
|
||||
const label = getI18nText(item.label, route)
|
||||
const icon = navIcon(href)
|
||||
|
||||
return (
|
||||
<a
|
||||
class:list={[
|
||||
'-m-1.5 rounded-md p-1.5 text-(--sl-color-gray-3) no-underline hover:text-(--sl-color-white) focus-visible:outline-offset-0',
|
||||
icon && 'minishop-header-link inline-flex items-center',
|
||||
]}
|
||||
href={href}
|
||||
>
|
||||
{icon && <Icon name={icon} size="1rem" color="currentColor" />}
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
)
|
||||
})
|
||||
}
|
||||
</nav>
|
||||
<div class="minishop-header-search flex md:max-w-60 md:flex-1 print:hidden">
|
||||
<Search />
|
||||
</div>
|
||||
{
|
||||
!hasSidebar && nav.length > 0 && (
|
||||
<details class="minishop-mobile-nav md:hidden print:hidden">
|
||||
<summary aria-label="Menu">
|
||||
<span aria-hidden="true" />
|
||||
</summary>
|
||||
<div class="minishop-mobile-nav-panel">
|
||||
{nav.map((item) => {
|
||||
const href = getI18nText(item.href, route)
|
||||
const label = getI18nText(item.label, route)
|
||||
const icon = navIcon(href)
|
||||
|
||||
return (
|
||||
<a
|
||||
class:list={[
|
||||
'minishop-mobile-nav-link',
|
||||
icon && 'minishop-header-link inline-flex items-center',
|
||||
]}
|
||||
href={href}
|
||||
>
|
||||
{icon && <Icon name={icon} size="1rem" color="currentColor" />}
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
<div class="hidden items-center gap-2 md:flex print:hidden">
|
||||
<SocialIcons />
|
||||
<LanguageSelect />
|
||||
<ThemeSelect />
|
||||
</div>
|
||||
{
|
||||
hasSidebar && (
|
||||
<div class="flex items-center gap-2 md:hidden print:hidden">
|
||||
<MobileMenuToggle />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { docsLoader } from '@astrojs/starlight/loaders';
|
||||
import { docsSchema } from '@astrojs/starlight/schema';
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||
};
|
||||
@@ -0,0 +1,317 @@
|
||||
---
|
||||
const defaultDemoSrc = '/demo/runtime/app.html?path=/home&mock=tariffs';
|
||||
const docsHref = '/getting-started/demo/';
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05080f" />
|
||||
<link rel="icon" href="/favicon.png" sizes="any" />
|
||||
<link rel="icon" href="/favicon.webp" type="image/webp" />
|
||||
<title>remnawave-minishop demo</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
background: #05080f;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
background: #05080f;
|
||||
}
|
||||
|
||||
.demo-topbar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 3.25rem;
|
||||
padding: max(0.7rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right))
|
||||
0.7rem max(1rem, env(safe-area-inset-left));
|
||||
border-bottom: 1px solid rgb(148 163 184 / 20%);
|
||||
background: rgb(5 8 15 / 94%);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.demo-topbar__toggle,
|
||||
.demo-topbar__hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.demo-topbar__panel {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.demo-topbar__title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.demo-topbar__title strong {
|
||||
display: block;
|
||||
color: #00fe7a;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.demo-topbar__state {
|
||||
display: flex;
|
||||
flex: 0 1 24rem;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.demo-topbar__state-label {
|
||||
flex: 0 0 auto;
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.demo-topbar__state-select {
|
||||
min-width: 13.25rem;
|
||||
max-width: 100%;
|
||||
border: 1px solid rgb(148 163 184 / 28%);
|
||||
border-radius: 7px;
|
||||
padding: 0.42rem 1.9rem 0.42rem 0.65rem;
|
||||
background: rgb(15 23 42 / 82%);
|
||||
color: #f8fafc;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.demo-topbar__state-select:focus-visible {
|
||||
outline: 2px solid rgb(0 254 122 / 72%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.demo-topbar__back {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgb(0 254 122 / 45%);
|
||||
border-radius: 7px;
|
||||
padding: 0.48rem 0.72rem;
|
||||
color: #f8fafc;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.demo-topbar__back:hover {
|
||||
border-color: #00fe7a;
|
||||
color: #00fe7a;
|
||||
}
|
||||
|
||||
.demo-topbar__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.demo-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #05080f;
|
||||
}
|
||||
|
||||
@media (max-width: 42rem) {
|
||||
body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.demo-topbar {
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
top: max(0.55rem, env(safe-area-inset-top));
|
||||
right: max(0.55rem, env(safe-area-inset-right));
|
||||
display: block;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.demo-topbar__toggle {
|
||||
display: inline-grid;
|
||||
min-width: 2.75rem;
|
||||
height: 2.25rem;
|
||||
place-items: center;
|
||||
pointer-events: auto;
|
||||
border: 1px solid rgb(0 254 122 / 35%);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.7rem;
|
||||
background: rgb(5 8 15 / 86%);
|
||||
box-shadow: 0 12px 34px rgb(0 0 0 / 28%);
|
||||
color: #f8fafc;
|
||||
backdrop-filter: blur(12px);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.demo-topbar:not([data-collapsed]) .demo-topbar__toggle,
|
||||
.demo-topbar[data-collapsed] .demo-topbar__panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.demo-topbar__panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
width: min(20rem, calc(100vw - 1.1rem));
|
||||
padding: 0.38rem;
|
||||
pointer-events: auto;
|
||||
border: 1px solid rgb(148 163 184 / 22%);
|
||||
border-radius: 10px;
|
||||
background: rgb(5 8 15 / 90%);
|
||||
box-shadow: 0 16px 44px rgb(0 0 0 / 34%);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.demo-topbar__title {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-topbar__title strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.78rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.demo-topbar__state {
|
||||
flex: 1 1 100%;
|
||||
order: 3;
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.demo-topbar__state-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.demo-topbar__state-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.demo-topbar__actions {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.demo-topbar__back {
|
||||
display: inline-flex;
|
||||
border-color: rgb(0 254 122 / 35%);
|
||||
padding: 0.42rem 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.demo-topbar__hide {
|
||||
display: inline-grid;
|
||||
width: 1.95rem;
|
||||
height: 1.95rem;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(148 163 184 / 26%);
|
||||
border-radius: 7px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: #cbd5e1;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.demo-frame {
|
||||
height: 100dvh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="demo-topbar" data-collapsed>
|
||||
<button
|
||||
class="demo-topbar__toggle"
|
||||
type="button"
|
||||
aria-controls="demo-topbar-panel"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Демо
|
||||
</button>
|
||||
<div class="demo-topbar__panel" id="demo-topbar-panel">
|
||||
<div class="demo-topbar__title">
|
||||
<strong>remnawave-minishop demo</strong>
|
||||
</div>
|
||||
<label class="demo-topbar__state">
|
||||
<span class="demo-topbar__state-label">Состояние</span>
|
||||
<select class="demo-topbar__state-select" aria-label="Состояние личного кабинета">
|
||||
<option value="tariffs">По умолчанию</option>
|
||||
<option value="depleted">Трафик закончился</option>
|
||||
<option value="no-subscription">Нет подписки</option>
|
||||
<option value="trial">Доступна пробная подписка</option>
|
||||
<option value="devices">Лимит и докупка устройств</option>
|
||||
<option value="auth">Вход и регистрация</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="demo-topbar__actions">
|
||||
<a class="demo-topbar__back" href={docsHref}>К документации</a>
|
||||
<button class="demo-topbar__hide" type="button" aria-label="Скрыть панель">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<iframe
|
||||
id="demo-frame"
|
||||
class="demo-frame"
|
||||
title="remnawave-minishop static demo"
|
||||
src={defaultDemoSrc}
|
||||
loading="eager"
|
||||
></iframe>
|
||||
<script is:inline src="/demo/demo-shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
import DemoShell from '../demo.astro';
|
||||
|
||||
export function getStaticPaths() {
|
||||
const userRoutes = [
|
||||
'home',
|
||||
'install',
|
||||
'trial',
|
||||
'invite',
|
||||
'devices',
|
||||
'support',
|
||||
'settings',
|
||||
'login',
|
||||
'login/password',
|
||||
];
|
||||
const adminRoutes = [
|
||||
'stats',
|
||||
'users',
|
||||
'payments',
|
||||
'promos',
|
||||
'ads',
|
||||
'broadcast',
|
||||
'logs',
|
||||
'support',
|
||||
'tariffs',
|
||||
'appearance',
|
||||
'translations',
|
||||
'backups',
|
||||
'settings',
|
||||
];
|
||||
|
||||
return [
|
||||
...userRoutes.map((path) => ({ params: { path } })),
|
||||
{ params: { path: 'admin' } },
|
||||
...adminRoutes.map((section) => ({ params: { path: `admin/${section}` } })),
|
||||
];
|
||||
}
|
||||
---
|
||||
|
||||
<DemoShell />
|
||||
@@ -0,0 +1,207 @@
|
||||
@layer starlight, nova, minishop;
|
||||
|
||||
@layer minishop {
|
||||
.site-title,
|
||||
.site-title span {
|
||||
color: #00fe7a;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.site-title:hover,
|
||||
.site-title:hover span {
|
||||
color: #00fe7a;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] .site-title,
|
||||
:root[data-theme='light'] .site-title span {
|
||||
color: #007a3d;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] .site-title:hover,
|
||||
:root[data-theme='light'] .site-title:hover span {
|
||||
color: #006f36;
|
||||
}
|
||||
|
||||
.minishop-header-link {
|
||||
column-gap: 0.625rem;
|
||||
}
|
||||
|
||||
.minishop-header-link svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.minishop-header-search {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.minishop-header-search {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.minishop-mobile-nav {
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.minishop-mobile-nav summary {
|
||||
display: grid;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
place-items: center;
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 7px;
|
||||
color: var(--sl-color-gray-2);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.minishop-mobile-nav summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.minishop-mobile-nav summary span,
|
||||
.minishop-mobile-nav summary span::before,
|
||||
.minishop-mobile-nav summary span::after {
|
||||
display: block;
|
||||
width: 1rem;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.minishop-mobile-nav summary span {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.minishop-mobile-nav summary span::before,
|
||||
.minishop-mobile-nav summary span::after {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.minishop-mobile-nav summary span::before {
|
||||
top: -0.34rem;
|
||||
}
|
||||
|
||||
.minishop-mobile-nav summary span::after {
|
||||
top: 0.34rem;
|
||||
}
|
||||
|
||||
.minishop-mobile-nav[open] summary {
|
||||
border-color: #00fe7a;
|
||||
color: #00fe7a;
|
||||
}
|
||||
|
||||
.minishop-mobile-nav-panel {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: calc(100% + 0.55rem);
|
||||
right: 0;
|
||||
display: grid;
|
||||
min-width: min(17rem, calc(100vw - 2rem));
|
||||
gap: 0.25rem;
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
background: var(--sl-color-black);
|
||||
box-shadow: 0 18px 60px rgb(0 0 0 / 32%);
|
||||
}
|
||||
|
||||
.minishop-mobile-nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
border-radius: 6px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
color: var(--sl-color-gray-2);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.minishop-mobile-nav-link:hover,
|
||||
.minishop-mobile-nav-link:focus-visible {
|
||||
color: var(--sl-color-white);
|
||||
background: var(--sl-color-gray-6);
|
||||
}
|
||||
|
||||
:root[data-theme='light'] .minishop-mobile-nav-panel {
|
||||
background: var(--sl-color-white);
|
||||
box-shadow: 0 18px 60px rgb(15 23 42 / 16%);
|
||||
}
|
||||
|
||||
.hero {
|
||||
gap: clamp(2rem, 6vw, 5rem);
|
||||
padding-block: clamp(3.5rem, 10vw, 6.5rem);
|
||||
}
|
||||
|
||||
.hero > .hero-html:has(.minishop-hero-screenshot) {
|
||||
width: min(96vw, 72rem);
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.hero img {
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 24px 70px rgb(15 23 42 / 18%);
|
||||
}
|
||||
|
||||
.minishop-hero-screenshot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .hero img {
|
||||
box-shadow: 0 24px 70px rgb(0 0 0 / 36%);
|
||||
}
|
||||
|
||||
@media (min-width: 50rem) {
|
||||
.hero:has(.minishop-hero-screenshot) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: clamp(2rem, 4vw, 4rem);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero > .hero-html:has(.minishop-hero-screenshot) {
|
||||
order: 2;
|
||||
width: min(100%, 90rem);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.hero:has(.minishop-hero-screenshot) .stack {
|
||||
align-items: center;
|
||||
max-width: 64rem;
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero:has(.minishop-hero-screenshot) .copy {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero:has(.minishop-hero-screenshot) .actions {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.sl-markdown-content :is(h2, h3) {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.sl-markdown-content table {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.sl-markdown-content code:not(:where(pre *)) {
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 5px;
|
||||
padding: 0.08rem 0.28rem;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,48 +1,42 @@
|
||||
# Project Architecture
|
||||
# Архитектура проекта
|
||||
|
||||
The repository is split by runtime responsibility:
|
||||
Репозиторий разделен по зонам ответственности рантайма:
|
||||
|
||||
```text
|
||||
backend/ Python application code
|
||||
bot/ Telegram bot, aiohttp APIs, webhooks, services
|
||||
config/ Pydantic settings and tariff/theme config loaders
|
||||
db/ SQLAlchemy models, DAL, migrations
|
||||
main_backend.py aiohttp backend entrypoint
|
||||
main_worker.py background worker entrypoint
|
||||
main_migrate.py one-shot migration entrypoint
|
||||
requirements.txt Python runtime dependencies
|
||||
backend/ Python-код приложения
|
||||
bot/ Telegram-бот, aiohttp API, вебхуки, сервисы
|
||||
config/ Pydantic-настройки и загрузчики тарифов/тем
|
||||
db/ SQLAlchemy-модели, DAL, миграции
|
||||
main_backend.py точка входа aiohttp backend
|
||||
main_worker.py точка входа фонового worker
|
||||
main_migrate.py одноразовый запуск миграций
|
||||
requirements.txt Python-зависимости рантайма
|
||||
|
||||
frontend/ Svelte/Vite Mini App and admin UI
|
||||
src/ Svelte source code
|
||||
scripts/ frontend build helpers
|
||||
package.json Node scripts and dependencies
|
||||
frontend/ Svelte/Vite Mini App и админка
|
||||
src/ исходный код Svelte
|
||||
scripts/ вспомогательные скрипты сборки frontend
|
||||
package.json Node-скрипты и зависимости
|
||||
|
||||
deploy/
|
||||
docker/ Dockerfile, nginx and caddy runtime config
|
||||
compose/ legacy/alternate compose examples
|
||||
docker/ Dockerfile, nginx- и caddy-конфиги рантайма
|
||||
examples/ готовые Docker Compose примеры запуска
|
||||
|
||||
data/ runtime data mounted in containers
|
||||
locales/ bot and Web App translations
|
||||
tests/ Python test suite
|
||||
data/ данные рантайма, монтируемые в контейнеры
|
||||
locales/ переводы бота и Web App
|
||||
tests/ Python-тесты
|
||||
```
|
||||
|
||||
The default `docker-compose.yml` stays in the repository root so `docker compose up` remains the
|
||||
simple production path. It builds three application images from `deploy/docker/Dockerfile`:
|
||||
Основной `docker-compose.yml` находится в корне репозитория, чтобы `docker compose up` оставался простым продакшен-путем. Он собирает три прикладных образа из `deploy/docker/Dockerfile`:
|
||||
|
||||
- `backend`: aiohttp APIs and webhooks only.
|
||||
- `worker`: tariff traffic worker, panel sync, webhook queue consumers.
|
||||
- `frontend`: static Svelte assets served by nginx.
|
||||
- `backend`: aiohttp API и вебхуки.
|
||||
- `worker`: worker тарифов, синхронизация с панелью, обработчики очередей вебхуков.
|
||||
- `frontend`: статические Svelte-ассеты, которые отдает nginx.
|
||||
|
||||
The `migrate` service is a one-shot container based on the backend image. It is part of the
|
||||
default Compose dependency graph: Postgres and Redis become healthy, `migrate` applies
|
||||
`Base.metadata.create_all` and pending `schema_migrations`, then `backend` and `worker` start
|
||||
only after `migrate` exits successfully. This keeps migrations automatic for `docker compose up`
|
||||
without running them inside every backend replica.
|
||||
Сервис `migrate` - одноразовый контейнер на базе backend-образа. Он входит в стандартный Compose-граф: Postgres и Redis переходят в healthy-состояние, `migrate` применяет `Base.metadata.create_all` и ожидающие `schema_migrations`, а затем `backend` и `worker` стартуют только после успешного завершения `migrate`. Так миграции остаются автоматическими для `docker compose up`, но не запускаются внутри каждой backend-реплики.
|
||||
|
||||
Python imports intentionally remain `bot.*`, `config.*`, and `db.*`. Runtime containers set
|
||||
`PYTHONPATH=/app/backend`; local tests use the same layout through `pytest.ini`.
|
||||
Python-импорты намеренно остаются в пространствах `bot.*`, `config.*` и `db.*`. Контейнеры рантайма выставляют `PYTHONPATH=/app/backend`; локальные тесты используют такую же раскладку через `pytest.ini`.
|
||||
|
||||
Common commands:
|
||||
Основные команды:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Переменные окружения
|
||||
|
||||
`.env` нужен прежде всего для bootstrap: токен бота, доступ к базе, публичный webhook URL и стабильные секреты. После первого входа большая часть продуктовых настроек меняется в Web App админке и сохраняется в БД как override поверх `.env`.
|
||||
`.env` нужен прежде всего для bootstrap: токен бота, доступ к базе, публичный URL вебхуков и стабильные секреты. После первого входа большая часть продуктовых настроек меняется в Web App админке и сохраняется в БД как переопределения поверх `.env`.
|
||||
|
||||
Рекомендуемый порядок:
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
| --- | --- | --- |
|
||||
| `BOT_TOKEN` | Только `.env` | Токен Telegram-бота. |
|
||||
| `ADMIN_IDS` | Только `.env` | Telegram ID администраторов через запятую. Нужен для первого входа в админку. |
|
||||
| `WEBHOOK_BASE_URL` | `.env` | Публичный URL backend/webhook-домена. Используется для Telegram, платежных и Remnawave webhook URL. |
|
||||
| `WEBHOOK_BASE_URL` | `.env` | Публичный URL backend/webhook-домена. Используется для URL вебхуков Telegram, платежных провайдеров и Remnawave. |
|
||||
| `POSTGRES_USER` | `.env` / Compose | Пользователь PostgreSQL. |
|
||||
| `POSTGRES_PASSWORD` | `.env` / Compose | Пароль PostgreSQL. |
|
||||
| `POSTGRES_DB` | `.env` / Compose | Имя базы PostgreSQL. |
|
||||
| `WEBAPP_ENABLED` | `.env` / админка | Включает Web App и админку. Держите `True` для первого запуска; если выключить, вернуть доступ можно только через `.env` и рестарт. |
|
||||
| `WEBAPP_SESSION_SECRET` | `.env` | Стабильный HMAC-секрет сессий Web App. Если пустой, генерируется на процесс, но сессии сбросятся после рестарта. |
|
||||
| `WEBHOOK_SECRET_TOKEN` | `.env` | Секрет Telegram webhook. Если пустой, генерируется на процесс. |
|
||||
| `WEBHOOK_SECRET_TOKEN` | `.env` | Секрет вебхука Telegram. Если пустой, генерируется на процесс. |
|
||||
|
||||
## Инфраструктура и Compose
|
||||
|
||||
@@ -29,10 +29,10 @@
|
||||
| `APP_ENV_FILE` | CLI/Compose | Путь к env-файлу вместо `.env`. |
|
||||
| `IMAGE_TAG` | CLI/Compose | Тег Docker-образов. |
|
||||
| `FRONTEND_PORT` | `.env` / Compose | Хостовый порт frontend nginx. По умолчанию `8082`. |
|
||||
| `WEB_SERVER_HOST` | `.env` | Внутренний host backend webhook server. Обычно `0.0.0.0`. |
|
||||
| `WEB_SERVER_PORT` | `.env` / Compose | Хостовый порт backend webhook server. По умолчанию `8080`. |
|
||||
| `WEBAPP_SERVER_HOST` | `.env` | Внутренний host Web App API server. Обычно `0.0.0.0`. |
|
||||
| `WEBAPP_SERVER_PORT` | `.env` | Внутренний порт Web App API server. По умолчанию `8081`. |
|
||||
| `WEB_SERVER_HOST` | `.env` | Внутренний хост backend-сервера вебхуков. Обычно `0.0.0.0`. |
|
||||
| `WEB_SERVER_PORT` | `.env` / Compose | Хостовый порт backend-сервера вебхуков. По умолчанию `8080`. |
|
||||
| `WEBAPP_SERVER_HOST` | `.env` | Внутренний хост Web App API-сервера. Обычно `0.0.0.0`. |
|
||||
| `WEBAPP_SERVER_PORT` | `.env` | Внутренний порт Web App API-сервера. По умолчанию `8081`. |
|
||||
| `POSTGRES_HOST` | Compose | Host PostgreSQL. В штатном Compose задается как `postgres`. |
|
||||
| `POSTGRES_PORT` | `.env` | Порт PostgreSQL. |
|
||||
| `DB_POOL_SIZE` | `.env` | Размер async SQLAlchemy pool. |
|
||||
@@ -41,7 +41,7 @@
|
||||
| `DB_POOL_RECYCLE_SECONDS` | `.env` | Период recycling DB-соединений. |
|
||||
| `REDIS_URL` | Compose | Redis для FSM, кеша, rate-limit, очередей и locks. В Compose задается автоматически. |
|
||||
| `REDIS_KEY_PREFIX` | `.env` | Префикс Redis-ключей. |
|
||||
| `TRUSTED_PROXIES` | `.env` | IP/CIDR reverse proxy, которым доверяется `X-Forwarded-For`. |
|
||||
| `TRUSTED_PROXIES` | `.env` | IP/CIDR обратных прокси, которым доверяется `X-Forwarded-For`. |
|
||||
| `HTTP_BIND` / `HTTPS_BIND` | Caddy Compose | Адреса публикации Caddy-варианта. |
|
||||
| `NEWT_ID` / `NEWT_SECRET` | Dev Compose | Доступы Newt в dev-compose. |
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
|
||||
Обычно эти значения не требуют правки.
|
||||
|
||||
Настройки `BACKUP_*` управляют автоматическими бэкапами и восстановлением. Практический сценарий, mount compose-папки и проверки архивов описаны в [разделе про бэкапы](../features/backups.md).
|
||||
|
||||
| Переменная | Назначение |
|
||||
| --- | --- |
|
||||
| `WEBAPP_ME_CACHE_TTL_SECONDS` | TTL кеша `/api/me`. |
|
||||
@@ -71,6 +73,23 @@
|
||||
| `TARIFF_WORKER_LOCK_TTL_SECONDS` | TTL Redis lock для tariff worker. |
|
||||
| `TARIFF_WORKER_TICK_SECONDS` | Интервал tariff worker. |
|
||||
| `TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD` | Порог активных подписок для bulk fetch пользователей панели. |
|
||||
| `BACKUP_ENABLED` | Включает периодические бэкапы в worker-контейнере. По умолчанию `False`. |
|
||||
| `BACKUP_INTERVAL_SECONDS` | Интервал между бэкапами. По умолчанию `3600`; запуск выравнивается на границу часа: 12:00, 13:00 и т.д. |
|
||||
| `BACKUP_CHAT_ID` | Telegram chat ID для архивов. Если пусто, используется `LOG_CHAT_ID`. |
|
||||
| `BACKUP_THREAD_ID` | Topic/thread ID для архивов. Если пусто, используется `LOG_THREAD_ID`. |
|
||||
| `BACKUP_DIR` | Локальная папка архивов внутри контейнера. По умолчанию `data/backups` в volume `shop-data`. |
|
||||
| `BACKUP_LOCAL_RETENTION` | Сколько локальных ZIP-архивов хранить после отправки. По умолчанию `100`. |
|
||||
| `BACKUP_POSTGRES_DUMP_ENABLED` | Добавлять в архив `pg_dump` базы PostgreSQL. |
|
||||
| `BACKUP_PG_DUMP_PATH` | Путь к `pg_dump` внутри worker-контейнера. |
|
||||
| `BACKUP_PG_DUMP_TIMEOUT_SECONDS` | Таймаут выполнения `pg_dump`. |
|
||||
| `BACKUP_PG_RESTORE_PATH` | Путь к `pg_restore` внутри backend-контейнера для восстановления из админки. |
|
||||
| `BACKUP_PG_RESTORE_TIMEOUT_SECONDS` | Таймаут выполнения `pg_restore`. |
|
||||
| `BACKUP_COMPOSE_ENABLED` | Добавлять snapshot compose-каталога в архив. Если mount отсутствует, бэкап БД не падает. |
|
||||
| `BACKUP_COMPOSE_SOURCE_DIR` | Путь внутри контейнера к compose-каталогу. В стандартном compose это `/app/compose-source`. |
|
||||
| `BACKUP_COMPOSE_RESTORE_DIR` | Куда восстанавливать compose-файлы. Если пусто, используется `BACKUP_COMPOSE_SOURCE_DIR`. |
|
||||
| `BACKUP_COMPOSE_EXCLUDE_DIRS` | Имена директорий, которые не попадают в compose snapshot. |
|
||||
| `COMPOSE_BACKUP_SOURCE` | Host-путь, который Docker Compose монтирует как `/app/compose-source`. По умолчанию текущая папка compose-файла. |
|
||||
| `COMPOSE_RESTORE_MODE` | Режим mount для backend: `rw` позволяет восстанавливать compose-папку из админки, `ro` оставляет только чтение. |
|
||||
|
||||
## Общие настройки
|
||||
|
||||
@@ -105,29 +124,31 @@
|
||||
| `USER_TRAFFIC_STRATEGY` | Legacy-стратегия лимита трафика. |
|
||||
| `USER_HWID_DEVICE_LIMIT` | Legacy-лимит HWID-устройств по умолчанию. |
|
||||
|
||||
## Web App, внешний вид и Telegram Login
|
||||
## Веб-приложение, внешний вид и Telegram Login
|
||||
|
||||
Часть внешнего вида (`WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_*`, `WEBAPP_FAVICON_*`) сохранена для совместимости, но env-значения этих полей игнорируются при загрузке. Настраивайте их в **Админка -> Внешний вид**.
|
||||
|
||||
Практическая настройка Mini App вынесена в [веб-приложение](../features/web-app.md), а вход через Telegram - в [Telegram-авторизацию](../features/telegram-auth.md).
|
||||
|
||||
| Переменная | Где менять | Назначение |
|
||||
| --- | --- | --- |
|
||||
| `WEBAPP_ENABLED` | `.env` / админка | Включает Web App. Если `False`, пользовательский Web App и админка недоступны до включения через `.env` и рестарта. |
|
||||
| `SUBSCRIPTION_MINI_APP_URL` | `.env` / админка | Публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`. Используется в Telegram-кнопках, referral-ссылках, email-входе и BotFather Mini App settings. Не указывайте здесь `/api` или webhook-пути. |
|
||||
| `SUBSCRIPTION_MINI_APP_URL` | `.env` / админка | Публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`. Используется в Telegram-кнопках, реферальных ссылках, входе по email и настройках BotFather Mini App. Не указывайте здесь `/api` или webhook-пути. |
|
||||
| `SUBSCRIPTION_GUIDES_ENABLED` | `.env` / админка | Включает встроенные инструкции установки в Web App. По умолчанию `True`; если конфиг недоступен или невалиден, кнопка подключения открывает обычную финальную ссылку подписки. |
|
||||
| `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED` | `.env` / админка | Включает открытие Mini App `/install` из кнопок бота и показ публичной ссылки инструкции `/s/<token>`. По умолчанию `True`; если выключить, бот ведет на финальную Remnawave Subscription Page. |
|
||||
| `SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED` | `.env` / админка | Читать Remnawave Subscription Page config из панели для встроенных инструкций. По умолчанию `True`, чтобы не дублировать настройку страницы подписки в приложении. |
|
||||
| `SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED` | `.env` / админка | Включает использование JSON из поля `SUBSCRIPTION_PAGE_CONFIG_JSON` вместо конфига панели. По умолчанию `False`. |
|
||||
| `SUBSCRIPTION_PAGE_CONFIG_PATH` | `.env` / админка | Fallback-путь к локальному Remnawave Subscription Page v1 JSON config, если конфиг панели выключен или недоступен. По умолчанию `data/subpage-config/multiapp.json`; файл не создается автоматически. |
|
||||
| `SUBSCRIPTION_PAGE_CONFIG_JSON` | Админка | Опциональный JSON-override Remnawave Subscription Page v1. Применяется только при включенном `SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED`; backend валидирует JSON при сохранении. |
|
||||
| `SUBSCRIPTION_PAGE_CONFIG_PATH` | `.env` / админка | Резервный путь к локальному JSON-конфигу Remnawave Subscription Page v1, если конфиг панели выключен или недоступен. По умолчанию `data/subpage-config/multiapp.json`; файл не создается автоматически. |
|
||||
| `SUBSCRIPTION_PAGE_CONFIG_JSON` | Админка | Опциональное JSON-переопределение Remnawave Subscription Page v1. Применяется только при включенном `SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED`; backend валидирует JSON при сохранении. |
|
||||
| `WEBAPP_TITLE` | Админка | Заголовок Web App. |
|
||||
| `WEBAPP_THEMES_DIR` | `.env` | Каталог кастомных тем. |
|
||||
| `WEBAPP_DEFAULT_THEME` | `.env` / админка | Ключ темы по умолчанию. |
|
||||
| `WEBAPP_SESSION_TTL_SECONDS` | `.env` | Время жизни Web App-сессии. |
|
||||
| `WEBAPP_AUTH_MAX_AGE_SECONDS` | `.env` | Максимальный возраст Telegram Mini Apps `initData`. |
|
||||
| `WEBAPP_LOGIN_TOKEN_TTL_SECONDS` | `.env` | TTL ссылки внешнего логина. |
|
||||
| `TELEGRAM_OAUTH_CLIENT_ID` | `.env` | Client ID Telegram OAuth / OpenID Connect. Если пусто, берется bot ID из `BOT_TOKEN`. |
|
||||
| `TELEGRAM_OAUTH_CLIENT_SECRET` | `.env` | Client Secret Telegram OAuth / OpenID Connect. |
|
||||
| `TELEGRAM_OAUTH_REQUEST_ACCESS` | `.env` | Дополнительные permissions, например `write`. |
|
||||
| `TELEGRAM_OAUTH_CLIENT_ID` | `.env` | Идентификатор клиента Telegram OAuth / OpenID Connect. Если пусто, берется bot ID из `BOT_TOKEN`. |
|
||||
| `TELEGRAM_OAUTH_CLIENT_SECRET` | `.env` | Секрет клиента Telegram OAuth / OpenID Connect. |
|
||||
| `TELEGRAM_OAUTH_REQUEST_ACCESS` | `.env` | Дополнительные разрешения, например `write`. |
|
||||
| `WEBAPP_PRIMARY_COLOR` | Админка | Устаревшее env-поле, игнорируется. |
|
||||
| `WEBAPP_LOGO_URL` | Админка | Устаревшее env-поле, игнорируется. |
|
||||
| `WEBAPP_LOGO_USE_EMOJI` | Админка | Устаревшее env-поле, игнорируется. |
|
||||
@@ -139,9 +160,11 @@
|
||||
|
||||
Инструкции установки совместимы с Remnawave Subscription Page v1 config: `version`, `locales`, `brandingSettings`, `uiConfig`, `baseSettings`, `baseTranslations`, `svgLibrary` и `platforms`. Текстовые поля рендерятся как текст, а SVG из `svgLibrary` проходит санитарную проверку перед отдачей в Web App.
|
||||
|
||||
## SMTP и email-вход
|
||||
## SMTP и вход по email
|
||||
|
||||
Email-вход появляется только если заполнены `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD` и `SMTP_FROM_EMAIL`.
|
||||
Вход по email появляется только если заполнены `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD` и `SMTP_FROM_EMAIL`.
|
||||
|
||||
Практический сценарий настройки SMTP, magic link и парольного входа описан в [разделе входа по email](../features/email-login.md).
|
||||
|
||||
| Переменная | Назначение |
|
||||
| --- | --- |
|
||||
@@ -150,7 +173,7 @@ Email-вход появляется только если заполнены `SM
|
||||
| `SMTP_FALLBACK_PORTS` | Резервные порты через запятую. |
|
||||
| `SMTP_TIMEOUT_SECONDS` | Таймаут SMTP-попытки. |
|
||||
| `SMTP_USERNAME` | SMTP login. |
|
||||
| `SMTP_PASSWORD` | SMTP password/API key. |
|
||||
| `SMTP_PASSWORD` | SMTP-пароль или API-ключ. |
|
||||
| `SMTP_FROM_EMAIL` | Подтвержденный адрес отправителя. |
|
||||
| `SMTP_FROM_NAME` | Имя отправителя. |
|
||||
| `SMTP_STARTTLS` | Использовать STARTTLS. |
|
||||
@@ -164,7 +187,7 @@ Email-вход появляется только если заполнены `SM
|
||||
|
||||
## Платежи
|
||||
|
||||
Все включатели, секреты и presentation-настройки провайдеров доступны в админке: **Система -> Настройки -> Платежи**.
|
||||
Все включатели, секреты и настройки отображения провайдеров доступны в админке: **Система -> Настройки -> Платежи**.
|
||||
|
||||
| Переменная | Назначение |
|
||||
| --- | --- |
|
||||
@@ -185,7 +208,7 @@ Email-вход появляется только если заполнены `SM
|
||||
| `CRYPTOPAY_ENABLED` | Включает CryptoPay. |
|
||||
| `HELEKET_ENABLED` | Включает Heleket. |
|
||||
|
||||
Конкретные presentation-ключи:
|
||||
Конкретные ключи отображения:
|
||||
|
||||
```text
|
||||
PAYMENT_YOOKASSA_WEBAPP_LABEL_RU
|
||||
@@ -249,7 +272,7 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
|
||||
| Переменная | Назначение |
|
||||
| --- | --- |
|
||||
| `YOOKASSA_SHOP_ID` | ID магазина. |
|
||||
| `YOOKASSA_SECRET_KEY` | Secret key. |
|
||||
| `YOOKASSA_SECRET_KEY` | Секретный ключ. |
|
||||
| `YOOKASSA_RETURN_URL` | URL возврата после оплаты. |
|
||||
| `YOOKASSA_DEFAULT_RECEIPT_EMAIL` | Email для чеков по умолчанию. |
|
||||
| `YOOKASSA_VAT_CODE` | Код НДС. |
|
||||
@@ -261,22 +284,22 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
|
||||
| Переменная | Назначение |
|
||||
| --- | --- |
|
||||
| `FREEKASSA_MERCHANT_ID` | ID магазина. |
|
||||
| `FREEKASSA_API_KEY` | API key. |
|
||||
| `FREEKASSA_API_KEY` | API-ключ. |
|
||||
| `FREEKASSA_SECOND_SECRET` | Секрет уведомлений. |
|
||||
| `FREEKASSA_PAYMENT_IP` | Публичный IP сервера для запроса оплаты. |
|
||||
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты. |
|
||||
| `FREEKASSA_TRUSTED_IPS` | IP-allowlist webhook-источников. |
|
||||
| `FREEKASSA_TRUSTED_IPS` | Список доверенных IP webhook-источников. |
|
||||
|
||||
### Platega
|
||||
|
||||
| Переменная | Назначение |
|
||||
| --- | --- |
|
||||
| `PLATEGA_BASE_URL` | Базовый URL API. |
|
||||
| `PLATEGA_MERCHANT_ID` | Merchant ID. |
|
||||
| `PLATEGA_SECRET` | API secret. |
|
||||
| `PLATEGA_PAYMENT_METHOD` | Legacy/fallback method ID. |
|
||||
| `PLATEGA_SBP_METHOD` | Method ID для СБП. |
|
||||
| `PLATEGA_CRYPTO_METHOD` | Method ID для крипто. |
|
||||
| `PLATEGA_MERCHANT_ID` | ID мерчанта. |
|
||||
| `PLATEGA_SECRET` | Секрет API. |
|
||||
| `PLATEGA_PAYMENT_METHOD` | Устаревший/резервный ID метода оплаты. |
|
||||
| `PLATEGA_SBP_METHOD` | ID метода оплаты для СБП. |
|
||||
| `PLATEGA_CRYPTO_METHOD` | ID метода оплаты для крипто. |
|
||||
| `PLATEGA_RETURN_URL` | URL успешного возврата. |
|
||||
| `PLATEGA_FAILED_URL` | URL неуспешного возврата. |
|
||||
|
||||
@@ -286,7 +309,7 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
|
||||
| --- | --- |
|
||||
| `SEVERPAY_BASE_URL` | Базовый URL API. |
|
||||
| `SEVERPAY_MID` | Merchant MID. |
|
||||
| `SEVERPAY_TOKEN` | API token/secret. |
|
||||
| `SEVERPAY_TOKEN` | API-токен или секрет. |
|
||||
| `SEVERPAY_RETURN_URL` | URL возврата. |
|
||||
| `SEVERPAY_LIFETIME_MINUTES` | Время жизни платежной ссылки. |
|
||||
|
||||
@@ -295,19 +318,19 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
|
||||
| Переменная | Назначение |
|
||||
| --- | --- |
|
||||
| `WATA_BASE_URL` | Базовый URL API. |
|
||||
| `WATA_API_TOKEN` | Bearer token. |
|
||||
| `WATA_API_TOKEN` | Bearer-токен. |
|
||||
| `WATA_RETURN_URL` | URL успешного возврата. |
|
||||
| `WATA_FAILED_URL` | URL неуспешного возврата. |
|
||||
| `WATA_LINK_TTL_MINUTES` | TTL платежной ссылки в минутах (по умолчанию 15, минимум 15, максимум 43200). |
|
||||
| `WATA_WEBHOOK_VERIFY_SIGNATURE` | Проверять `X-Signature`. |
|
||||
| `WATA_PUBLIC_KEY` | Cached public key; если пусто, загружается из API. |
|
||||
| `WATA_TRUSTED_IPS` | IP-allowlist webhook-источников. |
|
||||
| `WATA_PUBLIC_KEY` | Закешированный публичный ключ; если пусто, загружается из API. |
|
||||
| `WATA_TRUSTED_IPS` | Список доверенных IP webhook-источников. |
|
||||
|
||||
### CryptoPay
|
||||
|
||||
| Переменная | Назначение |
|
||||
| --- | --- |
|
||||
| `CRYPTOPAY_TOKEN` | API token CryptoPay. |
|
||||
| `CRYPTOPAY_TOKEN` | API-токен CryptoPay. |
|
||||
| `CRYPTOPAY_NETWORK` | `mainnet` или `testnet`. |
|
||||
| `CRYPTOPAY_CURRENCY_TYPE` | `fiat` или `crypto`. |
|
||||
| `CRYPTOPAY_ASSET` | Актив, например `RUB`, `USDT`, `BTC`. |
|
||||
@@ -318,7 +341,7 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
|
||||
| --- | --- |
|
||||
| `HELEKET_BASE_URL` | Базовый URL API. |
|
||||
| `HELEKET_MERCHANT_ID` | UUID мерчанта. |
|
||||
| `HELEKET_API_KEY` | Payment API key. |
|
||||
| `HELEKET_API_KEY` | Ключ платежного API. |
|
||||
| `HELEKET_CURRENCY` | Валюта инвойса. |
|
||||
| `HELEKET_TO_CURRENCY` | Целевая криптовалюта для конвертации. |
|
||||
| `HELEKET_NETWORK` | Сеть, например `tron`, `bsc`, `eth`. |
|
||||
@@ -326,7 +349,7 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
|
||||
| `HELEKET_SUCCESS_URL` | URL после успешной оплаты. |
|
||||
| `HELEKET_LIFETIME_SECONDS` | TTL инвойса: 300..43200. |
|
||||
| `HELEKET_VERIFY_WEBHOOK_SIGNATURE` | Проверять подпись webhook. |
|
||||
| `HELEKET_TRUSTED_IPS` | IP-allowlist webhook-источников. |
|
||||
| `HELEKET_TRUSTED_IPS` | Список доверенных IP webhook-источников. |
|
||||
|
||||
## Тарифы и legacy-цены
|
||||
|
||||
@@ -345,7 +368,7 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
|
||||
| `TRAFFIC_PACKAGES` | Legacy-пакеты трафика RUB, формат `10:199,50:799`. |
|
||||
| `STARS_TRAFFIC_PACKAGES` | Legacy-пакеты трафика Stars. |
|
||||
|
||||
## Trial, referral и уведомления
|
||||
## Пробный период, рефералы и уведомления
|
||||
|
||||
Эти настройки доступны в админке.
|
||||
|
||||
@@ -365,10 +388,12 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
|
||||
| `SUBSCRIPTION_NOTIFY_ON_EXPIRE` | Уведомлять в день окончания. |
|
||||
| `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE` | Уведомлять после окончания. |
|
||||
| `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` | За сколько дней предупреждать. |
|
||||
| `SUBSCRIPTION_NOTIFY_HOURS_BEFORE` | За сколько часов предупреждать дополнительно. |
|
||||
| `SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS` | Период локальной проверки уведомлений. |
|
||||
|
||||
## Поддержка
|
||||
|
||||
Подробный сценарий описан в [support.md](support.md).
|
||||
Подробный сценарий описан в разделе [поддержка пользователей / тикеты](../features/support.md).
|
||||
|
||||
| Переменная | Назначение |
|
||||
| --- | --- |
|
||||
@@ -377,8 +402,8 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
|
||||
| `SUPPORT_TICKET_MAX_BODY_LENGTH` | Максимальная длина сообщения. |
|
||||
| `SUPPORT_TICKET_MAX_SUBJECT_LENGTH` | Максимальная длина темы. |
|
||||
| `SUPPORT_TICKET_RATE_LIMIT_PER_HOUR` | Лимит новых тикетов в час. |
|
||||
| `SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS` | Cooldown Telegram/log уведомлений. |
|
||||
| `SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS` | Cooldown email-уведомлений. |
|
||||
| `SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS` | Пауза между Telegram/log уведомлениями. |
|
||||
| `SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS` | Пауза между email-уведомлениями. |
|
||||
|
||||
## Логирование
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Безопасность
|
||||
|
||||
Безопасность Minishop в первую очередь держится на стабильных секретах, корректном разделении публичных доменов и ограниченном доступе к админке.
|
||||
|
||||
## Секреты
|
||||
|
||||
- `WEBAPP_SESSION_SECRET` должен быть постоянным между рестартами, иначе Web App-сессии станут невалидными.
|
||||
- `WEBHOOK_SECRET_TOKEN` защищает вебхук Telegram.
|
||||
- `PANEL_WEBHOOK_SECRET` проверяет входящие события Remnawave Panel.
|
||||
- Платежные токены и webhook-секреты храните в `.env` или настройках админки с учетом доступа к серверу.
|
||||
|
||||
Сгенерировать секрет можно так:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## Доступ администраторов
|
||||
|
||||
- `ADMIN_IDS` задает Telegram ID администраторов.
|
||||
- Админка доступна только пользователям из `ADMIN_IDS` при входе через Telegram.
|
||||
- Email-only аккаунты не получают админский доступ.
|
||||
|
||||
## Публичные URL
|
||||
|
||||
- `WEBHOOK_BASE_URL` должен вести на backend-сервер вебхуков.
|
||||
- `SUBSCRIPTION_MINI_APP_URL` должен вести на frontend/Mini App-домен.
|
||||
- Не добавляйте `/api`, `/auth` или webhook-пути в `SUBSCRIPTION_MINI_APP_URL`.
|
||||
|
||||
## Дополнительно
|
||||
|
||||
- Используйте HTTPS на всех публичных доменах.
|
||||
- Ограничивайте доступ к серверу и `.env`.
|
||||
- Следите за логами платежных вебхуков и вебхуков панели.
|
||||
- После ротации секретов перезапускайте соответствующие сервисы и проверяйте вебхуки.
|
||||
|
||||
См. также [переменные окружения](env-vars.md) и [развертывание](../getting-started/deployment.md).
|
||||
@@ -11,6 +11,7 @@
|
||||
- редактор разрешенных настроек приложения из manifest-файла;
|
||||
- раздел **Внешний вид** для логотипа, emoji-логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем;
|
||||
- раздел **Инструкции подключения** для встроенной страницы установки, поведения кнопок бота и Remnawave Subscription Page config;
|
||||
- раздел **Бэкапы** для просмотра локальных ZIP-архивов, загрузки архива и восстановления БД/compose-папки;
|
||||
- редактор JSON-каталога тарифов;
|
||||
- загрузка Internal Squads из Remnawave для выбора в тарифах.
|
||||
|
||||
@@ -18,6 +19,13 @@
|
||||
|
||||
Раздел **Пользователи** — таблица с **пагинацией по 25 записей**. Строка поиска ищет по внутреннему числовому ID, Telegram ID, фрагменту `@username`, имени или email; применение — кнопка «Найти» или клавиша Enter в поле поиска.
|
||||
|
||||
Из этого же раздела администратор управляет основными пользовательскими операциями:
|
||||
|
||||
- просматривает подписку, статус, трафик, premium-лимиты, платежи и служебные события;
|
||||
- блокирует пользователя;
|
||||
- запускает ручную синхронизацию с Remnawave Panel;
|
||||
- открывает тикеты поддержки и отвечает пользователю.
|
||||
|
||||
**Фильтры:**
|
||||
|
||||
- состояние аккаунта: все / не забанены / забанены;
|
||||
@@ -42,14 +50,18 @@
|
||||
|
||||
- общие параметры: язык, валюта, ссылки поддержки, документы, обязательный канал, Remnawave-доступы и поведение `/start`;
|
||||
- внешний вид и доступность Web App: название, цвет, логотип, emoji-логотип и `WEBAPP_ENABLED`;
|
||||
- инструкции подключения: `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED`, чтение конфига из Remnawave Panel, JSON-override и fallback-путь к файлу;
|
||||
- инструкции подключения: `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED`, чтение конфига из Remnawave Panel, JSON-переопределение и резервный путь к файлу;
|
||||
- legacy-цены без JSON-каталога: периоды подписки, RUB/Stars цены и пакеты трафика;
|
||||
- платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Stars, а также текст и иконки кнопок оплаты;
|
||||
- пробный период, реферальные бонусы, уведомления, логирование, поддержка, раздел устройств, лимит устройств и legacy-лимиты трафика.
|
||||
|
||||
Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде.
|
||||
|
||||
Для каждого платежного метода в разделе провайдера доступны presentation-настройки `PAYMENT_<METHOD>_WEBAPP_LABEL_RU`, `PAYMENT_<METHOD>_WEBAPP_LABEL_EN`, `PAYMENT_<METHOD>_WEBAPP_ICON`, `PAYMENT_<METHOD>_TELEGRAM_LABEL_RU`, `PAYMENT_<METHOD>_TELEGRAM_LABEL_EN` и `PAYMENT_<METHOD>_TELEGRAM_EMOJI`. Пустое значение возвращает мультиязычный дефолт из модуля платежного провайдера. Иконка Web App выбирается из уже подключённых lucide-иконок (`frontend/src/lib/components/ui/icons.js`) через модалку в админке.
|
||||
Для каждого платежного метода в разделе провайдера доступны настройки отображения `PAYMENT_<METHOD>_WEBAPP_LABEL_RU`, `PAYMENT_<METHOD>_WEBAPP_LABEL_EN`, `PAYMENT_<METHOD>_WEBAPP_ICON`, `PAYMENT_<METHOD>_TELEGRAM_LABEL_RU`, `PAYMENT_<METHOD>_TELEGRAM_LABEL_EN` и `PAYMENT_<METHOD>_TELEGRAM_EMOJI`. Пустое значение возвращает мультиязычное значение по умолчанию из модуля платежного провайдера. Иконка Web App выбирается из уже подключенных lucide-иконок (`frontend/src/lib/components/ui/icons.js`) через модалку в админке.
|
||||
|
||||
## Бэкапы
|
||||
|
||||
Раздел **Система -> Бэкапы** показывает архивы из `data/backups`, принимает upload ZIP-файла и запускает восстановление БД/compose-папки с предварительной проверкой архива. Подробная эксплуатационная инструкция: [бэкапы и восстановление](backups.md).
|
||||
|
||||
## Переводы
|
||||
|
||||
@@ -72,13 +84,13 @@
|
||||
}
|
||||
```
|
||||
|
||||
### Инструкции подключения
|
||||
## Инструкции подключения
|
||||
|
||||
Секция **Система -> Настройки -> Инструкции подключения** управляет встроенным экраном установки. `SUBSCRIPTION_GUIDES_ENABLED` включает `/install` в личном кабинете, а `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED` заставляет кнопки подключения в Telegram-боте открывать Mini App вместо финальной Remnawave Subscription Page. Оба переключателя включены по умолчанию.
|
||||
|
||||
По умолчанию Minishop читает Remnawave Subscription Page config из панели (`SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED=True`). Это основной режим, потому что один и тот же конфиг используется и в панели, и во встроенной инструкции. JSON-поле `SUBSCRIPTION_PAGE_CONFIG_JSON` применяется только когда явно включен `SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED`; иначе оно может храниться в админке, но не влияет на пользователей. `SUBSCRIPTION_PAGE_CONFIG_PATH` остается fallback-путем к локальному v1 JSON-файлу, если конфиг панели отключен или недоступен.
|
||||
По умолчанию Minishop читает конфиг Remnawave Subscription Page из панели (`SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED=True`). Это основной режим, потому что один и тот же конфиг используется и в панели, и во встроенной инструкции. JSON-поле `SUBSCRIPTION_PAGE_CONFIG_JSON` применяется только когда явно включен `SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED`; иначе оно может храниться в админке, но не влияет на пользователей. `SUBSCRIPTION_PAGE_CONFIG_PATH` остается резервным путем к локальному v1 JSON-файлу, если конфиг панели отключен или недоступен.
|
||||
|
||||
При сохранении backend валидирует JSON-override как Remnawave Subscription Page v1 config. Ошибки показываются как обычные validation errors настроек, а если рабочий конфиг недоступен, пользовательская кнопка подключения откатывается к старой финальной ссылке подписки.
|
||||
При сохранении backend валидирует JSON-переопределение как конфиг Remnawave Subscription Page v1. Ошибки показываются как обычные ошибки валидации настроек, а если рабочий конфиг недоступен, пользовательская кнопка подключения откатывается к старой финальной ссылке подписки.
|
||||
|
||||
## Поддержка
|
||||
|
||||
@@ -86,7 +98,7 @@
|
||||
|
||||
В карточке тикета администратор видит диалог, пользовательский контекст и действия: ответить пользователю, оставить внутреннюю заметку, изменить статус, приоритет, категорию или исполнителя, закрыть тикет и перейти в карточку пользователя. Внутренние заметки не показываются пользователю.
|
||||
|
||||
Счетчик непрочитанных обращений отображается в навигации админки. Уведомления о новых тикетах и ответах пользователя настраиваются через `LOG_SUPPORT`, `LOG_SUPPORT_THREAD_ID` и параметры `SUPPORT_*`. Подробности: [support.md](support.md).
|
||||
Счетчик непрочитанных обращений отображается в навигации админки. Уведомления о новых тикетах и ответах пользователя настраиваются через `LOG_SUPPORT`, `LOG_SUPPORT_THREAD_ID` и параметры `SUPPORT_*`. Подробности: [поддержка пользователей / тикеты](support.md).
|
||||
|
||||
## Внешний вид
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# Бэкапы и восстановление
|
||||
|
||||
Minishop умеет автоматически собирать ZIP-бэкапы в worker-контейнере, хранить последние архивы на сервере, отправлять их в Telegram и восстанавливать БД/compose-папку из админки.
|
||||
|
||||
## Что попадает в архив
|
||||
|
||||
Архив создается в `BACKUP_DIR`, по умолчанию `data/backups` внутри volume `shop-data`.
|
||||
|
||||
Типовой файл называется так:
|
||||
|
||||
```text
|
||||
minishop-20260527-12-00.zip
|
||||
```
|
||||
|
||||
Внутри:
|
||||
|
||||
- `database/<POSTGRES_DB>.dump` - `pg_dump` в custom format для `pg_restore`;
|
||||
- `compose/` - snapshot папки с `docker-compose.yml`, `.env` и соседними конфигами;
|
||||
- `manifest.json` - дата создания, сведения о БД, compose snapshot и предупреждения.
|
||||
|
||||
Если compose-папка не смонтирована или недоступна, worker не роняет весь бэкап: архив будет создан с дампом БД и предупреждением в `manifest.json`.
|
||||
|
||||
## Настройка
|
||||
|
||||
Основные параметры доступны в админке: **Система -> Настройки -> Бэкапы**.
|
||||
|
||||
Минимальный `.env`, если `LOG_CHAT_ID` уже задан и подходит для бэкапов:
|
||||
|
||||
```env
|
||||
BACKUP_ENABLED=True
|
||||
```
|
||||
|
||||
Если бэкапы нужно отправлять в отдельный чат или topic/thread, добавьте только нужные переменные:
|
||||
|
||||
```env
|
||||
BACKUP_CHAT_ID=-1001234567890
|
||||
BACKUP_THREAD_ID=123
|
||||
```
|
||||
|
||||
Остальные backup-переменные обычно не нужны в `.env`: `BACKUP_INTERVAL_SECONDS=3600` запускает бэкапы ровно на границе часа 12:00, 13:00 и т.д.; `BACKUP_LOCAL_RETENTION=100` хранит 100 последних ZIP-архивов; `BACKUP_COMPOSE_ENABLED=True`, `COMPOSE_BACKUP_SOURCE=.` и `COMPOSE_RESTORE_MODE=rw` уже совпадают со стандартным compose-сценарием.
|
||||
|
||||
`BACKUP_CHAT_ID` задает чат Telegram для отправки архивов. Если он пустой, используется `LOG_CHAT_ID`. Для topic/thread можно указать `BACKUP_THREAD_ID`; если он пустой, используется `LOG_THREAD_ID`.
|
||||
|
||||
Каждый архив содержит `manifest.json` с SHA-256 и размером каждого файла. Это позволяет проверить, что архив не поврежден и его содержимое не отличается от manifest.
|
||||
|
||||
Архив не привязан к текущему инстансу, `BOT_TOKEN` или серверу. Его можно загрузить и восстановить на другом сервере, если формат архива поддерживается и проверки целостности проходят.
|
||||
|
||||
## Mount compose-папки
|
||||
|
||||
В стандартных compose-файлах есть два mount:
|
||||
|
||||
- `worker`: `${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro` - только читает папку для создания snapshot;
|
||||
- `backend`: `${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}` - читает список архивов и может восстановить compose-папку из админки.
|
||||
|
||||
`COMPOSE_BACKUP_SOURCE=.` означает папку рядом с текущим `docker-compose.yml`. Если compose лежит в другом месте, укажите абсолютный host-путь.
|
||||
|
||||
Ручное создание бэкапа из админки выполняется в `backend`-контейнере, а автоматический backup по расписанию - в `worker`-контейнере. Оба контейнера должны видеть `/app/compose-source`. Если ручной backup содержит compose-папку, а автоматический нет, пересоздайте worker после обновления compose:
|
||||
|
||||
```bash
|
||||
docker compose up -d --force-recreate worker
|
||||
docker compose exec worker ls -la /app/compose-source
|
||||
```
|
||||
|
||||
Если нужно запретить восстановление compose-файлов из контейнера, задайте:
|
||||
|
||||
```env
|
||||
COMPOSE_RESTORE_MODE=ro
|
||||
```
|
||||
|
||||
В этом режиме восстановление БД останется доступным, а восстановление compose-папки вернет понятную ошибку о недоступной записи.
|
||||
|
||||
## Восстановление из админки
|
||||
|
||||
Откройте **Система -> Бэкапы**. В разделе можно:
|
||||
|
||||
- создать новый backup вручную, не дожидаясь следующего запуска по расписанию;
|
||||
- выбрать архив, уже лежащий в `data/backups`;
|
||||
- загрузить ZIP-архив вручную;
|
||||
- отметить, что восстанавливать: `БД`, `compose-папка` или оба варианта;
|
||||
- запустить восстановление после подтверждения.
|
||||
|
||||
Ручное создание использует тот же механизм, что и расписание: делает `pg_dump`, добавляет compose snapshot, сохраняет ZIP в `BACKUP_DIR`, отправляет архив в Telegram и применяет локальный retention. На время ручного запуска используется общий Redis lock, поэтому он не пересечется с плановым backup или restore.
|
||||
|
||||
БД восстанавливается через `pg_restore --clean --if-exists --no-owner --no-privileges`. На время восстановления лучше не запускать платежи, рассылки, массовую синхронизацию и ручные изменения подписок.
|
||||
|
||||
Compose-файлы восстанавливаются поверх текущей папки. Перед заменой backend создает pre-restore snapshot текущего compose-каталога рядом с остальными архивами:
|
||||
|
||||
```text
|
||||
minishop-pre-restore-YYYYMMDD-HH-MM.zip
|
||||
```
|
||||
|
||||
После восстановления compose-папки перезапустите нужные сервисы, чтобы изменения `docker-compose.yml`, `.env`, Caddyfile/Nginx-конфигов и других файлов реально применились:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build backend worker
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Если менялись proxy-конфиги, перезапустите соответствующий сервис (`caddy`, `nginx`, `newt`).
|
||||
|
||||
## Проверка архива перед восстановлением
|
||||
|
||||
Backend валидирует архив до восстановления:
|
||||
|
||||
- файл должен быть валидным ZIP;
|
||||
- `manifest.json` должен принадлежать `remnawave-minishop` и иметь поддерживаемую версию формата;
|
||||
- SHA-256 и размер каждого файла должны совпадать с manifest;
|
||||
- выбранный server-side файл должен лежать внутри `BACKUP_DIR`, путь вида `../backup.zip` отклоняется;
|
||||
- пути внутри ZIP не могут быть абсолютными, содержать `..`, `\`, пустые сегменты или дубли;
|
||||
- архивы с подозрительно большим числом файлов, размером или zip-bomb compression ratio отклоняются;
|
||||
- для восстановления БД нужен `database/*.dump` или `database/*.backup`;
|
||||
- для восстановления compose нужны файлы внутри `compose/`;
|
||||
- compose restore стартует только если целевая папка существует и доступна на запись;
|
||||
- backup/restore защищены одним Redis lock, чтобы две операции не выполнялись одновременно.
|
||||
|
||||
Это защищает от случайной загрузки мусорного файла, zip-slip-архивов и поврежденных ZIP. Проверка специально не привязана к секретам инстанса, чтобы архивы можно было использовать для переноса между серверами. Это не проверка доверенного источника: не восстанавливайте архивы, происхождение которых вы не контролируете.
|
||||
|
||||
## Перенос на другой сервер
|
||||
|
||||
Для переноса БД между инстансами:
|
||||
|
||||
1. Создайте backup на старом сервере или возьмите ZIP из Telegram.
|
||||
2. На новом сервере загрузите архив в **Система -> Бэкапы**.
|
||||
3. Выберите `БД`; `compose-папку` включайте только если хотите перенести `.env`, `docker-compose.yml` и proxy-конфиги.
|
||||
4. Запустите restore и после восстановления выполните миграции/healthcheck.
|
||||
|
||||
Если переносите compose-папку, проверьте домены, токены, `WEBHOOK_BASE_URL`, `SUBSCRIPTION_MINI_APP_URL`, bind-порты и volume/mount пути: на новом сервере они могут отличаться.
|
||||
|
||||
## Ручное восстановление БД
|
||||
|
||||
Если админка недоступна, можно восстановить дамп вручную:
|
||||
|
||||
```bash
|
||||
unzip minishop-YYYYMMDD-HH-MM.zip -d restore
|
||||
docker compose cp restore/database/remnawave_minishop.dump postgres:/tmp/remnawave_minishop.dump
|
||||
docker compose stop backend worker
|
||||
docker compose exec postgres sh -c 'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges /tmp/remnawave_minishop.dump'
|
||||
docker compose up -d backend worker
|
||||
```
|
||||
|
||||
После ручного восстановления проверьте миграции и healthcheck:
|
||||
|
||||
```bash
|
||||
docker compose run --rm migrate
|
||||
docker compose ps
|
||||
docker compose logs -f backend worker
|
||||
```
|
||||
|
||||
## Переменные
|
||||
|
||||
Полный справочник лежит в [переменных окружения](../configuration/env-vars.md#кеши-rate-limits-и-worker). Основные ключи:
|
||||
|
||||
| Переменная | Назначение |
|
||||
| --- | --- |
|
||||
| `BACKUP_ENABLED` | Включает периодические бэкапы. |
|
||||
| `BACKUP_CHAT_ID` / `BACKUP_THREAD_ID` | Куда отправлять архивы в Telegram. |
|
||||
| `BACKUP_INTERVAL_SECONDS` | Периодичность, по умолчанию `3600`. |
|
||||
| `BACKUP_LOCAL_RETENTION` | Сколько последних архивов хранить на сервере. |
|
||||
| `BACKUP_DIR` | Каталог ZIP-архивов. |
|
||||
| `BACKUP_COMPOSE_ENABLED` | Добавлять compose snapshot. |
|
||||
| `COMPOSE_BACKUP_SOURCE` | Host-путь compose-папки для mount в контейнеры. |
|
||||
| `COMPOSE_RESTORE_MODE` | `rw` для восстановления compose из админки, `ro` для запрета записи. |
|
||||
| `BACKUP_PG_DUMP_PATH` / `BACKUP_PG_RESTORE_PATH` | Пути к `pg_dump` и `pg_restore` внутри контейнеров. |
|
||||
@@ -0,0 +1,22 @@
|
||||
# Основные возможности
|
||||
|
||||
Minishop закрывает путь от регистрации пользователя до оплаты, продления, поддержки и сопровождения подписки.
|
||||
|
||||
## Для пользователей
|
||||
|
||||
- Регистрация через [Telegram Mini App](telegram-auth.md) или [email-код](email-login.md).
|
||||
- Просмотр подписки, срока действия, трафика и ссылки подключения.
|
||||
- Покупка подписки, пакетов трафика и дополнительных устройств.
|
||||
- Пробный период, промокоды и реферальные сценарии.
|
||||
- Тикеты поддержки внутри Mini App.
|
||||
- Встроенные инструкции установки и публичные ссылки `/s/<token>`.
|
||||
|
||||
## Для администраторов
|
||||
|
||||
- Поиск и управление пользователями.
|
||||
- Настройка платежей, тарифов, внешнего вида и поддержки.
|
||||
- Рассылки, промокоды и логи действий.
|
||||
- Ручная синхронизация с Remnawave Panel.
|
||||
- Редактор JSON-каталога тарифов.
|
||||
|
||||
Подробности: [админ-панель](admin-panel.md), [Mini App](web-app.md) и [поддержка пользователей / тикеты](support.md).
|
||||
@@ -0,0 +1,108 @@
|
||||
# Вход по email
|
||||
|
||||
Email-вход позволяет пользователю зарегистрироваться или войти в Mini App без Telegram. Пользователь вводит email, получает одноразовый код и может подтвердить вход кодом или magic link из письма. После входа email можно связать с Telegram-аккаунтом в настройках профиля.
|
||||
|
||||
Аккаунты только с email не получают права администратора: админка проверяет Telegram ID из `ADMIN_IDS`.
|
||||
|
||||
## Когда форма появляется
|
||||
|
||||
Кнопка входа по email показывается только если заполнены все обязательные SMTP-настройки:
|
||||
|
||||
```ini
|
||||
SMTP_HOST=smtp-relay.brevo.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=<smtp-login>
|
||||
SMTP_PASSWORD=<smtp-password-or-api-key>
|
||||
SMTP_FROM_EMAIL=no-reply@domain.com
|
||||
```
|
||||
|
||||
Если хотя бы одно из этих полей пустое, backend вернет `email_auth_enabled=false` в bootstrap, а frontend скроет email-login.
|
||||
|
||||
Для magic link также нужен корректный `SUBSCRIPTION_MINI_APP_URL`, потому что ссылка в письме строится на его основе.
|
||||
|
||||
## SMTP-настройка
|
||||
|
||||
Типовой пример:
|
||||
|
||||
```ini
|
||||
SMTP_HOST=smtp-relay.brevo.com
|
||||
SMTP_PORT=587
|
||||
SMTP_FALLBACK_PORTS=2525,465
|
||||
SMTP_TIMEOUT_SECONDS=30
|
||||
SMTP_STARTTLS=True
|
||||
SMTP_USE_SSL=False
|
||||
SMTP_USERNAME=<smtp-login>
|
||||
SMTP_PASSWORD=<smtp-password-or-api-key>
|
||||
SMTP_FROM_EMAIL=no-reply@domain.com
|
||||
SMTP_FROM_NAME=Remnawave Minishop
|
||||
|
||||
EMAIL_CODE_TTL_SECONDS=600
|
||||
EMAIL_CODE_RESEND_SECONDS=60
|
||||
EMAIL_CODE_MAX_ATTEMPTS=5
|
||||
BRUTE_FORCE_MAX_FAILURES=5
|
||||
BRUTE_FORCE_WINDOW_SECONDS=900
|
||||
BRUTE_FORCE_LOCK_SECONDS=900
|
||||
```
|
||||
|
||||
Для Brevo обычно подходит порт `587` с STARTTLS. Если основной порт недоступен, приложение пробует порты из `SMTP_FALLBACK_PORTS`; порт `465` используется через SSL wrapper автоматически.
|
||||
|
||||
`SMTP_FROM_EMAIL` должен быть подтвержден у SMTP-провайдера, иначе письмо часто отклоняется или попадает в спам. `SMTP_FROM_NAME` можно оставить пустым, тогда используется название Web App.
|
||||
|
||||
Полный справочник переменных: [SMTP и вход по email](../configuration/env-vars.md#smtp-и-вход-по-email).
|
||||
|
||||
## Как работает вход
|
||||
|
||||
1. Пользователь вводит email в форме входа.
|
||||
2. Backend проверяет rate limit и создает одноразовый код.
|
||||
3. Письмо отправляется через SMTP. Если `SUBSCRIPTION_MINI_APP_URL` валиден, в письме также есть magic link.
|
||||
4. Пользователь вводит код в Mini App или открывает magic link.
|
||||
5. Backend создает нового email-пользователя или находит существующего по email.
|
||||
6. Если в URL был referral-параметр, он применяется к новой или существующей записи.
|
||||
7. Пользователь получает Web App-сессию.
|
||||
|
||||
Коды хранятся в базе в хешированном виде, устаревают по `EMAIL_CODE_TTL_SECONDS`, повторная отправка ограничена `EMAIL_CODE_RESEND_SECONDS`, а количество попыток ввода ограничено `EMAIL_CODE_MAX_ATTEMPTS` и общими brute-force настройками.
|
||||
|
||||
## Парольный вход
|
||||
|
||||
После подтверждения email пользователь может задать пароль в настройках профиля. Пароль хранится как PBKDF2-SHA256 hash с солью.
|
||||
|
||||
После установки пароля доступен путь:
|
||||
|
||||
```text
|
||||
https://app.domain.com/login/password
|
||||
```
|
||||
|
||||
Если парольный вход не удался, frontend предлагает fallback на обычный email-код. Установка или изменение пароля тоже подтверждается email-кодом.
|
||||
|
||||
## Привязка аккаунтов
|
||||
|
||||
В настройках профиля пользователь может:
|
||||
|
||||
- привязать email к Telegram-аккаунту через код;
|
||||
- привязать Telegram к email-аккаунту через Telegram Mini Apps `initData` или Telegram OAuth;
|
||||
- задать или сменить пароль для email-входа.
|
||||
|
||||
Если email уже принадлежит другой записи, backend выполняет безопасное объединение по существующим правилам аккаунтов и инвалидирует старые Web App-кеши.
|
||||
|
||||
## Проверка после настройки
|
||||
|
||||
1. Перезапустите backend/frontend после изменения `.env`.
|
||||
2. Откройте `https://app.domain.com/` вне Telegram.
|
||||
3. Убедитесь, что форма email-входа видна.
|
||||
4. Запросите код на тестовый адрес.
|
||||
5. Проверьте письмо, magic link и ручной ввод 6-значного кода.
|
||||
6. Проверьте логи backend, если письмо не пришло:
|
||||
|
||||
```bash
|
||||
docker compose logs -f backend
|
||||
```
|
||||
|
||||
## Частые ошибки
|
||||
|
||||
- Форма email не видна: не заполнены `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD` или `SMTP_FROM_EMAIL`.
|
||||
- Письмо не отправляется: проверьте порт, STARTTLS/SSL режим, SMTP login/API key и подтверждение отправителя.
|
||||
- Magic link ведет не туда: исправьте `SUBSCRIPTION_MINI_APP_URL`, он должен быть публичным HTTPS URL Mini App без `/api` и `/auth`.
|
||||
- Код сразу устаревает: проверьте `EMAIL_CODE_TTL_SECONDS` и время на сервере.
|
||||
- Пользователь получает `rate_limited`: подождите `EMAIL_CODE_RESEND_SECONDS` или проверьте brute-force настройки.
|
||||
|
||||
Email-уведомления поддержки и платежей используют тот же SMTP-контур. Сценарий поддержки описан в [разделе тикетов](support.md).
|
||||
@@ -0,0 +1,152 @@
|
||||
# Платежи
|
||||
|
||||
Платежные методы включаются настройками и отображаются пользователю как кнопки оплаты в Mini App и Telegram-сценариях. Настройки можно задавать через `.env` или через админку, если параметр есть в allowlist настроек.
|
||||
|
||||
## Типовой порядок настройки
|
||||
|
||||
1. Включите нужный провайдер в админке или через `.env`.
|
||||
2. Заполните публичные параметры, секреты и URL возврата.
|
||||
3. Настройте URL вебхука у провайдера, если это требуется.
|
||||
4. Проверьте порядок методов в `PAYMENT_METHODS_ORDER`.
|
||||
5. Проверьте подписи и иконки кнопок оплаты.
|
||||
6. Выполните тестовый платеж и проверьте логи `backend`.
|
||||
|
||||
Общие ссылки:
|
||||
|
||||
- [Справочник `.env`](../configuration/env-vars.md) содержит все ключи провайдеров.
|
||||
- [Админ-панель](admin-panel.md) описывает UI-настройки платежей.
|
||||
- [Тарифы](tariffs.md) описывают цены, Telegram Stars и сценарии покупки.
|
||||
- [Логи](../troubleshooting/logs.md) помогают проверить webhook и создание платежных ссылок.
|
||||
|
||||
## YooKassa
|
||||
|
||||
YooKassa используется для рублевых оплат и может участвовать в сценариях автопродления period-подписок.
|
||||
|
||||
Что настроить:
|
||||
|
||||
- включение провайдера: `YOOKASSA_ENABLED`;
|
||||
- идентификаторы и секреты магазина;
|
||||
- URL вебхука на backend-домен;
|
||||
- отображение кнопки оплаты и порядок платежных методов.
|
||||
|
||||
Справочник переменных: [YooKassa](../configuration/env-vars.md#yookassa).
|
||||
|
||||
## FreeKassa
|
||||
|
||||
FreeKassa подключается как отдельный платежный метод и обрабатывает входящие webhook-события через `backend`.
|
||||
|
||||
Что настроить:
|
||||
|
||||
- включение провайдера: `FREEKASSA_ENABLED`;
|
||||
- ID магазина, API/secret-ключи и настройки подписи;
|
||||
- список доверенных IP, если используется;
|
||||
- публичный URL вебхука на `WEBHOOK_BASE_URL`.
|
||||
|
||||
Справочник переменных: [FreeKassa](../configuration/env-vars.md#freekassa).
|
||||
|
||||
## Platega
|
||||
|
||||
Platega подключается как отдельный платежный провайдер, но внутри Minishop может дать несколько кнопок: основную устаревшую кнопку, СБП/карту и крипто-кнопку. Общие параметры мерчанта задаются один раз, а ID методов оплаты и подписи кнопок настраиваются отдельно.
|
||||
|
||||
Что включить:
|
||||
|
||||
- `PLATEGA_ENABLED` - общий флаг провайдера;
|
||||
- `PLATEGA_SBP_ENABLED` - отдельная кнопка СБП/карта;
|
||||
- `PLATEGA_CRYPTO_ENABLED` - отдельная crypto-кнопка Platega;
|
||||
- `PLATEGA_PAYMENT_METHOD` - устаревший/резервный ID метода оплаты для старых callback-запросов и старых установок.
|
||||
|
||||
Что настроить:
|
||||
|
||||
1. Укажите `PLATEGA_BASE_URL`, `PLATEGA_MERCHANT_ID` и `PLATEGA_SECRET`.
|
||||
2. Заполните `PLATEGA_SBP_METHOD` и/или `PLATEGA_CRYPTO_METHOD`, если используете отдельные кнопки.
|
||||
3. Проверьте `PLATEGA_RETURN_URL` и `PLATEGA_FAILED_URL`.
|
||||
4. Настройте тексты и иконки кнопок через `PAYMENT_PLATEGA_SBP_*` и `PAYMENT_PLATEGA_CRYPTO_*`.
|
||||
5. Добавьте нужные методы в `PAYMENT_METHODS_ORDER`.
|
||||
|
||||
Справочник переменных: [Platega](../configuration/env-vars.md#platega).
|
||||
|
||||
## SeverPay
|
||||
|
||||
SeverPay подключается как отдельный платежный метод с собственным MID, token и сроком жизни платежной ссылки.
|
||||
|
||||
Что настроить:
|
||||
|
||||
1. Включите `SEVERPAY_ENABLED`.
|
||||
2. Укажите `SEVERPAY_BASE_URL`.
|
||||
3. Заполните `SEVERPAY_MID` и `SEVERPAY_TOKEN`.
|
||||
4. Настройте `SEVERPAY_RETURN_URL`.
|
||||
5. При необходимости задайте `SEVERPAY_LIFETIME_MINUTES`.
|
||||
6. Добавьте `severpay` в `PAYMENT_METHODS_ORDER`.
|
||||
|
||||
Справочник переменных: [SeverPay](../configuration/env-vars.md#severpay).
|
||||
|
||||
## Wata
|
||||
|
||||
Wata подключается как отдельный провайдер с bearer token, платежными ссылками и опциональной проверкой подписи webhook.
|
||||
|
||||
Что настроить:
|
||||
|
||||
1. Включите `WATA_ENABLED`.
|
||||
2. Укажите `WATA_BASE_URL` и `WATA_API_TOKEN`.
|
||||
3. Проверьте `WATA_RETURN_URL` и `WATA_FAILED_URL`.
|
||||
4. Настройте `WATA_LINK_TTL_MINUTES`: минимум 15 минут, максимум 43200.
|
||||
5. Если включаете проверку подписи, задайте `WATA_WEBHOOK_VERIFY_SIGNATURE` и при необходимости `WATA_PUBLIC_KEY`.
|
||||
6. Для дополнительной защиты заполните `WATA_TRUSTED_IPS`.
|
||||
7. Добавьте `wata` в `PAYMENT_METHODS_ORDER`.
|
||||
|
||||
Справочник переменных: [Wata](../configuration/env-vars.md#wata).
|
||||
|
||||
## CryptoPay
|
||||
|
||||
CryptoPay используется для криптовалютных платежей через отдельный токен и сеть Crypto Bot API.
|
||||
|
||||
Что настроить:
|
||||
|
||||
1. Включите `CRYPTOPAY_ENABLED`.
|
||||
2. Укажите `CRYPTOPAY_TOKEN`.
|
||||
3. Выберите `CRYPTOPAY_NETWORK`: `mainnet` или `testnet`.
|
||||
4. Задайте `CRYPTOPAY_CURRENCY_TYPE`: `fiat` или `crypto`.
|
||||
5. Проверьте `CRYPTOPAY_ASSET`, например `RUB`, `USDT` или `BTC`.
|
||||
6. Добавьте `cryptopay` в `PAYMENT_METHODS_ORDER`.
|
||||
|
||||
Для тестов используйте соответствующую сеть: testnet-токен не должен попадать в mainnet-настройки. Если сумма или asset выглядят неверно, проверьте сочетание `CRYPTOPAY_CURRENCY_TYPE` и `CRYPTOPAY_ASSET`.
|
||||
|
||||
Справочник переменных: [CryptoPay](../configuration/env-vars.md#cryptopay).
|
||||
|
||||
## Heleket
|
||||
|
||||
Heleket используется для крипто-инвойсов с отдельными merchant ID, ключом платежного API, валютой инвойса и настройками проверки webhook.
|
||||
|
||||
Что настроить:
|
||||
|
||||
1. Включите `HELEKET_ENABLED`.
|
||||
2. Укажите `HELEKET_BASE_URL`, `HELEKET_MERCHANT_ID` и `HELEKET_API_KEY`.
|
||||
3. Настройте `HELEKET_CURRENCY`.
|
||||
4. При необходимости задайте `HELEKET_TO_CURRENCY` и `HELEKET_NETWORK`.
|
||||
5. Проверьте `HELEKET_RETURN_URL` и `HELEKET_SUCCESS_URL`.
|
||||
6. Настройте `HELEKET_LIFETIME_SECONDS`: допустимый диапазон 300..43200.
|
||||
7. Если включаете проверку webhook, задайте `HELEKET_VERIFY_WEBHOOK_SIGNATURE`.
|
||||
8. Для IP-фильтрации заполните `HELEKET_TRUSTED_IPS`.
|
||||
9. Добавьте `heleket` в `PAYMENT_METHODS_ORDER`.
|
||||
|
||||
Справочник переменных: [Heleket](../configuration/env-vars.md#heleket).
|
||||
|
||||
## Telegram Stars
|
||||
|
||||
Telegram Stars используются напрямую и поддерживаются в legacy-ценах и JSON-каталоге тарифов.
|
||||
|
||||
Где применяются Stars:
|
||||
|
||||
- цены периодов подписки;
|
||||
- пакеты трафика;
|
||||
- premium-докупки;
|
||||
- HWID-докупки, если они включены в каталоге тарифов.
|
||||
|
||||
Что проверить:
|
||||
|
||||
- `STARS_ENABLED`;
|
||||
- Stars-цены в legacy-настройках или JSON-каталоге;
|
||||
- корректное округление цены до целого количества Stars;
|
||||
- сценарии смены тарифа: XTR/Stars-докупки не конвертируются без явного курса.
|
||||
|
||||
См. также [переменные платежей](../configuration/env-vars.md#платежи) и [тарифы](tariffs.md).
|
||||