feat: add backups feature

This commit is contained in:
3252a8
2026-05-27 13:53:30 +03:00
parent e90988ea5c
commit 3aede8fe95
55 changed files with 3032 additions and 70 deletions
+13
View File
@@ -70,3 +70,16 @@ FRONTEND_PORT=8082
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
# Keep loopback for local proxy; add your proxy network if needed.
TRUSTED_PROXIES=127.0.0.1,::1
# Periodic backups from the worker container. Disabled by default.
# The archive contains a pg_dump custom-format dump and a snapshot of the
# compose directory mounted read-only into /app/compose-source.
BACKUP_ENABLED=False
BACKUP_CHAT_ID=
BACKUP_INTERVAL_SECONDS=3600
BACKUP_LOCAL_RETENTION=100
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
BACKUP_ARCHIVE_SIGNATURE_SECRET=
BACKUP_COMPOSE_ENABLED=True
COMPOSE_BACKUP_SOURCE=.
COMPOSE_RESTORE_MODE=rw
+3 -1
View File
@@ -36,9 +36,11 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- [Развертывание](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 OAuth, вход по email, инструкции установки и реферальные ссылки.
- [Веб-приложение / 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`.
+2
View File
@@ -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,141 @@
# 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,
)
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_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,7 @@ 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/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)
+57 -1
View File
@@ -434,6 +434,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 +610,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 +680,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 +730,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:
+1 -1
View File
@@ -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,
)
+108
View File
@@ -0,0 +1,108 @@
import hashlib
import hmac
import json
import zipfile
from pathlib import Path
from typing import Any
from config.settings import Settings
BACKUP_APP_ID = "remnawave-minishop"
BACKUP_FILENAME_PREFIX = "remnawave-minishop-backup-"
BACKUP_FORMAT_VERSION = 1
BACKUP_MANIFEST_NAME = "manifest.json"
def backup_signature_secret(settings: Settings) -> str:
configured = str(getattr(settings, "BACKUP_ARCHIVE_SIGNATURE_SECRET", "") or "").strip()
return configured or settings.BOT_TOKEN
def canonical_manifest_payload(manifest: dict[str, Any]) -> bytes:
payload = json.loads(json.dumps(manifest, ensure_ascii=False))
archive = payload.get("archive")
if isinstance(archive, dict):
archive.pop("signature", None)
return json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def sign_manifest(manifest: dict[str, Any], settings: Settings) -> str:
return hmac.new(
backup_signature_secret(settings).encode("utf-8"),
canonical_manifest_payload(manifest),
hashlib.sha256,
).hexdigest()
def verify_manifest_signature(manifest: dict[str, Any], settings: Settings) -> bool:
archive = manifest.get("archive") if isinstance(manifest.get("archive"), dict) else {}
signature = str(archive.get("signature") or "")
if not signature:
return False
expected = sign_manifest(manifest, settings)
return hmac.compare_digest(signature, expected)
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]],
settings: Settings,
) -> None:
manifest["app"] = BACKUP_APP_ID
manifest["format_version"] = BACKUP_FORMAT_VERSION
manifest["archive"] = {
"files": file_records,
}
manifest["archive"]["signature"] = sign_manifest(manifest, settings)
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,660 @@
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,
build_file_records,
verify_manifest_signature,
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 = "remnawave-minishop-compose-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)
signature_valid = self._archive_signature_valid(manifest)
signature_required = getattr(
self.settings,
"BACKUP_ARCHIVE_SIGNATURE_REQUIRED",
True,
)
if signature_required and not signature_valid:
raise BackupArchiveError("Archive manifest signature is not valid")
if not signature_valid:
warnings.append("manifest signature is not valid")
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 = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S%z")
safe_original = self._safe_original_stem(original_filename)
archive_name = f"{BACKUP_FILENAME_PREFIX}uploaded-{stamp}-{digest}-{safe_original}.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 = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S%z")
archive_path = self.backup_dir() / f"{COMPOSE_PRE_RESTORE_PREFIX}{stamp}.zip"
excluded_dirs = self._compose_excluded_dirs()
files_count = 0
with tempfile.TemporaryDirectory(
prefix=f"{COMPOSE_PRE_RESTORE_PREFIX}{stamp}-",
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),
settings=self.settings,
)
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 _archive_signature_valid(self, manifest: dict[str, Any]) -> bool:
return verify_manifest_signature(manifest, self.settings)
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)
if getattr(self.settings, "BACKUP_ARCHIVE_SIGNATURE_REQUIRED", True):
if not self._archive_signature_valid(manifest):
raise BackupArchiveError("Archive manifest signature is not valid")
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
+419
View File
@@ -0,0 +1,419 @@
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,
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",
}
@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)
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) -> BackupResult:
result = await self.create_backup()
try:
await self.send_backup(result)
finally:
self.prune_old_backups()
return result
async def create_backup(self) -> BackupResult:
started_at = datetime.now(timezone.utc)
stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S%z")
archive_name = f"{BACKUP_FILENAME_PREFIX}{stamp}.zip"
backup_dir = Path(self.settings.BACKUP_DIR).expanduser()
backup_dir.mkdir(parents=True, exist_ok=True)
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": "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),
settings=self.settings,
)
write_manifest(staging_dir, manifest)
tmp_archive = backup_dir / f"{archive_name}.tmp"
archive_path = backup_dir / archive_name
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,
)
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.")
return 0
source_dir = Path(source_raw).expanduser()
if not source_dir.exists() or not source_dir.is_dir():
warnings.append(f"Compose source directory is unavailable: {source_dir}")
return 0
if not any((source_dir / marker).is_file() for marker in COMPOSE_MARKER_FILES):
warnings.append(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)}")
return "\n".join(lines)
@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:
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_",
@@ -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,
+40 -1
View File
@@ -114,6 +114,35 @@ 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_ARCHIVE_SIGNATURE_REQUIRED: bool = Field(default=True)
BACKUP_ARCHIVE_SIGNATURE_SECRET: Optional[str] = Field(default=None)
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")
@@ -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,9 @@ class Settings(BaseSettings):
"SMTP_FROM_EMAIL",
"SMTP_FROM_NAME",
"SMTP_FALLBACK_PORTS",
"BACKUP_ARCHIVE_SIGNATURE_SECRET",
"BACKUP_COMPOSE_SOURCE_DIR",
"BACKUP_COMPOSE_RESTORE_DIR",
mode="before",
)
@classmethod
+3
View File
@@ -23,6 +23,7 @@ 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.tariff_worker import TariffTrafficWorker
from bot.utils.message_queue import init_queue_manager
@@ -191,6 +192,8 @@ async def main() -> None:
tasks = []
if settings.tariffs_config:
tasks.append(asyncio.create_task(tariff_worker.run(), name="TariffTrafficWorker"))
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(
+14
View File
@@ -53,6 +53,20 @@ 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
+10
View File
@@ -39,3 +39,13 @@ 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
# Periodic backups from the worker container. Disabled by default.
BACKUP_ENABLED=False
BACKUP_CHAT_ID=
BACKUP_INTERVAL_SECONDS=3600
BACKUP_LOCAL_RETENTION=100
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
BACKUP_ARCHIVE_SIGNATURE_SECRET=
BACKUP_COMPOSE_ENABLED=True
COMPOSE_BACKUP_SOURCE=.
COMPOSE_RESTORE_MODE=rw
+2
View File
@@ -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:
+10
View File
@@ -40,3 +40,13 @@ 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
# Periodic backups from the worker container. Disabled by default.
BACKUP_ENABLED=False
BACKUP_CHAT_ID=
BACKUP_INTERVAL_SECONDS=3600
BACKUP_LOCAL_RETENTION=100
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
BACKUP_ARCHIVE_SIGNATURE_SECRET=
BACKUP_COMPOSE_ENABLED=True
COMPOSE_BACKUP_SOURCE=.
COMPOSE_RESTORE_MODE=rw
+2
View File
@@ -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:
+10
View File
@@ -39,3 +39,13 @@ 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
# Periodic backups from the worker container. Disabled by default.
BACKUP_ENABLED=False
BACKUP_CHAT_ID=
BACKUP_INTERVAL_SECONDS=3600
BACKUP_LOCAL_RETENTION=100
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
BACKUP_ARCHIVE_SIGNATURE_SECRET=
BACKUP_COMPOSE_ENABLED=True
COMPOSE_BACKUP_SOURCE=.
COMPOSE_RESTORE_MODE=rw
+2
View File
@@ -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:
+11
View File
@@ -39,3 +39,14 @@ PANEL_API_KEY=change_me
PANEL_WEBHOOK_SECRET=change_me
TRUSTED_PROXIES=127.0.0.1,::1
# Periodic backups from the worker container. Disabled by default.
BACKUP_ENABLED=False
BACKUP_CHAT_ID=
BACKUP_INTERVAL_SECONDS=3600
BACKUP_LOCAL_RETENTION=100
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
BACKUP_ARCHIVE_SIGNATURE_SECRET=
BACKUP_COMPOSE_ENABLED=True
COMPOSE_BACKUP_SOURCE=.
COMPOSE_RESTORE_MODE=rw
@@ -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:
+2
View File
@@ -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
+3
View File
@@ -79,8 +79,11 @@ export default defineConfig({
{ 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' },
],
},
+4 -1
View File
@@ -19,9 +19,12 @@ const descriptions = {
'features/payments.md': 'Платежные провайдеры, кнопки оплаты и webhook-обработка.',
'features/subscriptions.md': 'Тарифы на срок и по трафику, premium-сквады, HWID-устройства и жизненный цикл подписки.',
'features/tariffs.md': 'Каталог тарифов, модели на срок/по трафику, premium-сквады и HWID-устройства.',
'features/web-app.md': 'Telegram Mini App, авторизация, публичные инструкции и проксирование.',
'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.',
+25
View File
@@ -49,6 +49,8 @@
Обычно эти значения не требуют правки.
Настройки `BACKUP_*` управляют автоматическими бэкапами и восстановлением. Практический сценарий, mount compose-папки и проверки архивов описаны в [разделе про бэкапы](../features/backups.md).
| Переменная | Назначение |
| --- | --- |
| `WEBAPP_ME_CACHE_TTL_SECONDS` | TTL кеша `/api/me`. |
@@ -71,6 +73,25 @@
| `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_ARCHIVE_SIGNATURE_REQUIRED` | Требовать валидную HMAC-подпись `manifest.json` при upload/restore. По умолчанию `True`. |
| `BACKUP_ARCHIVE_SIGNATURE_SECRET` | Отдельный секрет подписи backup-архивов. Если пусто, используется `BOT_TOKEN`. |
| `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` оставляет только чтение. |
## Общие настройки
@@ -109,6 +130,8 @@
Часть внешнего вида (`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` и рестарта. |
@@ -143,6 +166,8 @@
Вход по email появляется только если заполнены `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD` и `SMTP_FROM_EMAIL`.
Практический сценарий настройки SMTP, magic link и парольного входа описан в [разделе входа по email](../features/email-login.md).
| Переменная | Назначение |
| --- | --- |
| `SMTP_HOST` | SMTP host. |
+5
View File
@@ -11,6 +11,7 @@
- редактор разрешенных настроек приложения из manifest-файла;
- раздел **Внешний вид** для логотипа, emoji-логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем;
- раздел **Инструкции подключения** для встроенной страницы установки, поведения кнопок бота и Remnawave Subscription Page config;
- раздел **Бэкапы** для просмотра локальных ZIP-архивов, загрузки архива и восстановления БД/compose-папки;
- редактор JSON-каталога тарифов;
- загрузка Internal Squads из Remnawave для выбора в тарифах.
@@ -58,6 +59,10 @@
Для каждого платежного метода в разделе провайдера доступны настройки отображения `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).
## Переводы
Раздел **Система -> Переводы** позволяет переопределять отдельные строки из `locales/ru.json` и `locales/en.json` без монтирования полного файла локализации. Строки сгруппированы по месту применения: админка, Mini App, Telegram-бот, платежи, подписки, поддержка и другие группы.
+142
View File
@@ -0,0 +1,142 @@
# Бэкапы и восстановление
Minishop умеет автоматически собирать ZIP-бэкапы в worker-контейнере, хранить последние архивы на сервере, отправлять их в Telegram и восстанавливать БД/compose-папку из админки.
## Что попадает в архив
Архив создается в `BACKUP_DIR`, по умолчанию `data/backups` внутри volume `shop-data`.
Типовой файл называется так:
```text
remnawave-minishop-backup-20260527-120000+0300.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`:
```env
BACKUP_ENABLED=True
BACKUP_CHAT_ID=-1001234567890
BACKUP_INTERVAL_SECONDS=3600
BACKUP_LOCAL_RETENTION=100
BACKUP_COMPOSE_ENABLED=True
COMPOSE_BACKUP_SOURCE=.
COMPOSE_RESTORE_MODE=rw
```
`BACKUP_INTERVAL_SECONDS=3600` запускает бэкапы ровно на границе часа: 12:00, 13:00 и т.д. Значение по умолчанию для локального хранения - 100 последних ZIP-архивов.
`BACKUP_CHAT_ID` задает чат Telegram для отправки архивов. Если он пустой, используется `LOG_CHAT_ID`. Для topic/thread можно указать `BACKUP_THREAD_ID`; если он пустой, используется `LOG_THREAD_ID`.
Каждый архив подписывается HMAC-подписью в `manifest.json` и содержит SHA-256 каждого файла. По умолчанию restore принимает только архивы с валидной подписью этого инстанса. Если нужен отдельный стабильный ключ подписи, задайте `BACKUP_ARCHIVE_SIGNATURE_SECRET`; если ключ пустой, используется `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-путь.
Если нужно запретить восстановление compose-файлов из контейнера, задайте:
```env
COMPOSE_RESTORE_MODE=ro
```
В этом режиме восстановление БД останется доступным, а восстановление compose-папки вернет понятную ошибку о недоступной записи.
## Восстановление из админки
Откройте **Система -> Бэкапы**. В разделе можно:
- выбрать архив, уже лежащий в `data/backups`;
- загрузить ZIP-архив вручную;
- отметить, что восстанавливать: `БД`, `compose-папка` или оба варианта;
- запустить восстановление после подтверждения.
БД восстанавливается через `pg_restore --clean --if-exists --no-owner --no-privileges`. На время восстановления лучше не запускать платежи, рассылки, массовую синхронизацию и ручные изменения подписок.
Compose-файлы восстанавливаются поверх текущей папки. Перед заменой backend создает pre-restore snapshot текущего compose-каталога рядом с остальными архивами:
```text
remnawave-minishop-compose-pre-restore-YYYYMMDD-HHMMSS+ZZZZ.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` и иметь поддерживаемую версию формата;
- HMAC-подпись manifest должна быть валидной, если `BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True`;
- 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 и структурно похожих архивов, которые не были созданы этим инстансом. Если вы сознательно восстанавливаете старый неподписанный архив, временно выставьте `BACKUP_ARCHIVE_SIGNATURE_REQUIRED=False`, восстановите архив и верните проверку обратно.
## Ручное восстановление БД
Если админка недоступна, можно восстановить дамп вручную:
```bash
unzip remnawave-minishop-backup-YYYYMMDD-HHMMSS+ZZZZ.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_ARCHIVE_SIGNATURE_REQUIRED` | Требовать валидную HMAC-подпись manifest при upload/restore. |
| `BACKUP_ARCHIVE_SIGNATURE_SECRET` | Отдельный секрет подписи архивов; если пустой, используется `BOT_TOKEN`. |
| `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` внутри контейнеров. |
+1 -1
View File
@@ -4,7 +4,7 @@ Minishop закрывает путь от регистрации пользов
## Для пользователей
- Регистрация через Telegram Mini App или email-код.
- Регистрация через [Telegram Mini App](telegram-auth.md) или [email-код](email-login.md).
- Просмотр подписки, срока действия, трафика и ссылки подключения.
- Покупка подписки, пакетов трафика и дополнительных устройств.
- Пробный период, промокоды и реферальные сценарии.
+108
View File
@@ -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).
+1 -1
View File
@@ -45,7 +45,7 @@
- `SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS` - пауза для Telegram/log уведомлений;
- `SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS` - пауза для email-уведомлений.
Email-уведомления администраторам включаются через `SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=True`. Письма отправляются только администраторам из `ADMIN_IDS`, у которых в базе есть email. Для отправки нужен рабочий SMTP-конфиг, как и для входа по email.
Email-уведомления администраторам включаются через `SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=True`. Письма отправляются только администраторам из `ADMIN_IDS`, у которых в базе есть email. Для отправки нужен рабочий SMTP-конфиг, как и для [входа по email](email-login.md).
Ответ администратора и закрытие тикета дополнительно отправляются пользователю в Telegram, если у него есть Telegram-аккаунт, и на email, если он привязан.
+98
View File
@@ -0,0 +1,98 @@
# Telegram-авторизация
Telegram-вход в Mini App работает двумя способами:
- внутри Telegram Mini App backend проверяет Telegram Mini Apps `initData`;
- при открытии сайта в обычном браузере используется Telegram OAuth / OpenID Connect Authorization Code Flow с PKCE, `nonce`, callback `/auth/telegram/callback` и серверной проверкой `id_token` по JWKS Telegram.
`initData` не требует отдельного OAuth-секрета, но требует корректного `BOT_TOKEN`, публичного HTTPS Mini App URL и настройки Mini Apps в BotFather. OAuth нужен для входа через кнопку Telegram вне клиента Telegram и для привязки Telegram к email-аккаунту из настроек профиля.
## Что нужно заранее
Минимальные переменные:
```ini
WEBAPP_ENABLED=True
SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/
WEBAPP_SESSION_SECRET=<stable-random-secret>
WEBAPP_AUTH_MAX_AGE_SECONDS=86400
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600
TELEGRAM_OAUTH_CLIENT_ID=<client-id-from-botfather>
TELEGRAM_OAUTH_CLIENT_SECRET=<client-secret-from-botfather>
TELEGRAM_OAUTH_REQUEST_ACCESS=write
```
`SUBSCRIPTION_MINI_APP_URL` должен быть публичным HTTPS URL именно frontend/Mini App-домена. Не добавляйте сюда `/api`, `/auth`, webhook-путь или конкретную страницу.
`WEBAPP_SESSION_SECRET` должен быть стабильным между рестартами, иначе Web App-сессии и OAuth state-cookie станут невалидными.
`WEBAPP_AUTH_MAX_AGE_SECONDS` ограничивает возраст Telegram Mini Apps `initData` и OAuth `id_token`. По умолчанию это 24 часа. Слишком маленькое значение может ломать вход на устройствах с неточными часами.
`WEBAPP_LOGIN_TOKEN_TTL_SECONDS` управляет TTL OAuth state, nonce и login-token. По умолчанию 10 минут.
`TELEGRAM_OAUTH_CLIENT_ID` можно не задавать, если client id совпадает с bot id: приложение возьмет его из префикса `BOT_TOKEN`. `TELEGRAM_OAUTH_CLIENT_SECRET` для браузерного OAuth обязателен.
`TELEGRAM_OAUTH_REQUEST_ACCESS=write` добавляет scope `telegram:bot_access`, чтобы бот мог написать пользователю после логина. Если это не нужно, оставьте переменную пустой. Также поддерживается `phone`, если вы осознанно запрашиваете телефон.
Полный справочник переменных: [Веб-приложение, внешний вид и Telegram Login](../configuration/env-vars.md#веб-приложение-внешний-вид-и-telegram-login).
## Настройка в BotFather
1. Откройте `@BotFather` -> `/mybots` -> выберите бота.
2. В `Bot Settings` -> `Domain` укажите домен Web App без протокола и пути, например `app.domain.com`.
3. В `Bot Settings` -> `Mini Apps` укажите URL, например `https://app.domain.com/`.
4. В `Bot Settings` -> `Web Login` включите OpenID Connect Login, если BotFather предлагает переключение.
5. Скопируйте client id и client secret в `TELEGRAM_OAUTH_CLIENT_ID` и `TELEGRAM_OAUTH_CLIENT_SECRET`.
6. В `Web Login` -> `Allowed URLs` добавьте:
```text
https://app.domain.com/
https://app.domain.com/auth/telegram/callback
```
После изменения `.env` перезапустите backend и frontend:
```bash
docker compose up -d --force-recreate backend frontend
```
## Проксирование
Публичный домен `SUBSCRIPTION_MINI_APP_URL` должен идти в контейнер `frontend:80`. Frontend nginx сам проксирует `/api/*` и `/auth/*` во внутренний WebApp-сервер backend на `backend:8081`.
Если используете собственный reverse proxy, не отправляйте `/auth/telegram/start` и `/auth/telegram/callback` напрямую в webhook-сервер `backend:8080`: эти маршруты принадлежат Web App API на `backend:8081` и штатно проходят через frontend.
Готовые схемы Caddy, Nginx, Newt и прямой публикации описаны в [развертывании](../getting-started/deployment.md#готовые-папки-запуска).
## Как проверить
Внутри Telegram:
1. Откройте Mini App кнопкой бота или через URL, настроенный в BotFather.
2. Проверьте, что пользователь входит без OAuth-redirect и видит личный кабинет.
3. Если вход не проходит, проверьте `SUBSCRIPTION_MINI_APP_URL`, домен BotFather и возраст `initData`.
В обычном браузере:
1. Откройте `https://app.domain.com/`.
2. Нажмите вход через Telegram.
3. Проверьте redirect на Telegram OAuth и возврат на `https://app.domain.com/auth/telegram/callback`.
4. После успешного callback пользователь должен вернуться на `/` со статусом `telegram_auth=success`, который frontend очистит из URL.
Для диагностики полезны:
```bash
curl -i https://app.domain.com/auth/telegram/start
docker compose logs -f backend frontend
```
## Частые ошибки
- `telegram_oauth_not_configured` или `telegram_auth=not_configured`: не задан `TELEGRAM_OAUTH_CLIENT_SECRET` или client id не удалось получить из `TELEGRAM_OAUTH_CLIENT_ID`/`BOT_TOKEN`.
- `Telegram OAuth nonce mismatch`: сессия/state устарели, поменялся `WEBAPP_SESSION_SECRET`, пользователь открыл старую вкладку или callback пришел с другого домена.
- `Telegram OAuth ID token is stale`: `WEBAPP_AUTH_MAX_AGE_SECONDS` слишком маленький или на сервере/клиенте сбито время.
- `Telegram OAuth callback failed`: проверьте allowed URL в BotFather и что `/auth/*` доходит до frontend/WebApp API.
- Mini App не открывается внутри Telegram: домен в BotFather должен совпадать с `SUBSCRIPTION_MINI_APP_URL`, а URL должен быть HTTPS.
Общие логи по авторизации собраны в [разделе диагностики логов](../troubleshooting/logs.md#авторизация-mini-app-и-telegram-oauth).
+8 -48
View File
@@ -38,20 +38,6 @@ WEBAPP_SESSION_TTL_SECONDS=86400
WEBAPP_AUTH_MAX_AGE_SECONDS=86400
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600
TELEGRAM_OAUTH_CLIENT_ID=<client-id-from-botfather>
TELEGRAM_OAUTH_CLIENT_SECRET=<client-secret-from-botfather>
TELEGRAM_OAUTH_REQUEST_ACCESS=write
SMTP_HOST=smtp-relay.brevo.com
SMTP_PORT=587
SMTP_FALLBACK_PORTS=2525,465
SMTP_STARTTLS=True
SMTP_USE_SSL=False
SMTP_USERNAME=<smtp-login>
SMTP_PASSWORD=<smtp-password-or-key>
SMTP_FROM_EMAIL=no-reply@domain.com
SMTP_FROM_NAME=Remnawave Minishop
SUPPORT_LINK=https://t.me/your_support_link
SUPPORT_TICKETS_ENABLED=True
SUPPORT_TICKET_RATE_LIMIT_PER_HOUR=5
@@ -83,43 +69,17 @@ SUPPORT_TICKET_RATE_LIMIT_PER_HOUR=5
Внешний вид настраивается в админке: раздел **Внешний вид** управляет логотипом, emoji-логотипом, accent-цветом, выбранной темой и масштабом логотипа. Кастомные темы читаются из `WEBAPP_THEMES_DIR`, а `WEBAPP_DEFAULT_THEME` может принудительно выбрать тему по ключу. Подробный контракт `theme.json`, CSS/asset-роуты и пайплайн создания темы описаны в [webapp-themes.md](webapp-themes.md).
Если SMTP-настройки не заполнены, вход по email скрывается.
## Авторизация
Mini App поддерживает вход через Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect вне Telegram и email-код. Подробная настройка вынесена в отдельные разделы:
- [Telegram-авторизация](telegram-auth.md) - BotFather, Mini Apps, Web Login, callback `/auth/telegram/callback`, OAuth-переменные и типичные ошибки.
- [Вход по email](email-login.md) - SMTP, одноразовые коды, magic link, парольный вход и проверки доставки писем.
Если SMTP-настройки не заполнены, вход по email скрывается. Если Telegram OAuth не настроен, вход через Telegram продолжает работать внутри Telegram Mini App через `initData`, но внешняя браузерная авторизация не сможет стартовать.
Тикеты поддержки включаются через `SUPPORT_TICKETS_ENABLED`; внешний резервный контакт задается `SUPPORT_LINK`. Полный сценарий пользователя, админа и уведомлений описан в разделе [поддержка пользователей / тикеты](support.md).
## Telegram-авторизация
Внутри Telegram Mini App пользователь авторизуется через Telegram Mini Apps `initData`. При открытии страницы вне Telegram используется Telegram OAuth / OpenID Connect Authorization Code Flow с PKCE, callback `/auth/telegram/callback`, `nonce` и серверной проверкой `id_token` по JWKS Telegram.
Настройка в BotFather:
1. Откройте `@BotFather` -> `/mybots` -> выберите бота.
2. В `Bot Settings` -> `Domain` укажите домен Web App без протокола и пути, например `app.domain.com`.
3. В `Bot Settings` -> `Mini Apps` укажите URL, например `https://app.domain.com/`.
4. В `Bot Settings` -> `Web Login` включите OpenID Connect Login, если BotFather предлагает переключение.
5. Скопируйте идентификатор клиента и секрет клиента в `TELEGRAM_OAUTH_CLIENT_ID` и `TELEGRAM_OAUTH_CLIENT_SECRET`.
6. В `Web Login` -> `Allowed URLs` добавьте:
```text
https://app.domain.com/
https://app.domain.com/auth/telegram/callback
```
`TELEGRAM_OAUTH_REQUEST_ACCESS=write` разрешает боту написать пользователю после логина. Если дополнительные разрешения не нужны, оставьте переменную пустой.
## Вход по email
Вход по email работает через одноразовый код:
1. Пользователь вводит email.
2. Бот отправляет код через SMTP.
3. Код вводится в модальном окне Web App.
4. После подтверждения создается или находится пользователь, а email можно связать с Telegram-аккаунтом.
Для Brevo обычно подходит порт `587` с STARTTLS. Если основной порт недоступен, приложение пробует порты из `SMTP_FALLBACK_PORTS`; порт `465` используется через SSL.
Полный список переменных, обязательные поля для включения входа по email и типичные ошибки подключения описаны в разделе **SMTP и вход по email** в [configuration.md](../getting-started/configuration.md).
## Проксирование
Рекомендуемая продакшен-схема - два публичных домена:
+3 -1
View File
@@ -105,6 +105,8 @@ docker compose exec backend sh -lc 'id; touch /app/data/themes/test && rm /app/d
- [configuration/env-vars.md](../configuration/env-vars.md) - полный справочник переменных `.env`.
- [features/admin-panel.md](../features/admin-panel.md) - как устроены overrides и allowlist настроек.
- [features/tariffs.md](../features/tariffs.md) - JSON-каталог тарифов и редактор тарифов.
- [Веб-приложение / Mini App](../features/web-app.md) - домен Mini App, Telegram OAuth и вход по email.
- [Веб-приложение / Mini App](../features/web-app.md) - домен Mini App, инструкции установки и проксирование.
- [Telegram-авторизация](../features/telegram-auth.md) - BotFather, Mini Apps и OAuth.
- [Вход по email](../features/email-login.md) - SMTP, коды, magic link и парольный вход.
- [Поддержка пользователей / тикеты](../features/support.md) - тикеты поддержки и уведомления.
- [Развертывание](deployment.md) - Docker Compose, обратный прокси, Caddy/Nginx и обновления.
+2
View File
@@ -356,6 +356,8 @@ docker compose exec backend sh -lc 'id; touch /app/data/themes/test && rm /app/d
## Резервная копия PostgreSQL
Для штатных автоматических ZIP-бэкапов, отправки в Telegram и восстановления через админку используйте раздел [бэкапы и восстановление](../features/backups.md). Команды ниже - минимальный ручной fallback для PostgreSQL.
```bash
docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > backup.sql
```
+2 -2
View File
@@ -14,5 +14,5 @@ Remnawave Minishop состоит из Telegram-бота, backend API, worker-п
- [Установка](setup.md) - базовый запуск через Compose.
- [Развертывание](../deployment.md) - Docker Compose, Caddy, Nginx, Pangolin/Newt и запуск без обратного прокси.
- [Настройка Telegram бота](../features/web-app.md#telegram-авторизация) - Telegram OAuth и Telegram Mini App.
- [Настройка SMTP](../features/web-app.md#вход-по-email) - Вход и регистрация по email.
- [Настройка Telegram бота](../features/telegram-auth.md) - Telegram OAuth и Telegram Mini App.
- [Настройка SMTP](../features/email-login.md) - Вход и регистрация по email.
+2 -2
View File
@@ -39,8 +39,8 @@ docker compose up -d
## Настройки для веб апп
- [Настройка Telegram бота](../features/web-app.md#telegram-авторизация) - Telegram OAuth и Telegram Mini App.
- [Настройка SMTP](../features/web-app.md#вход-по-email) - Вход и регистрация по email.
- [Настройка Telegram бота](../features/telegram-auth.md) - Telegram OAuth и Telegram Mini App.
- [Настройка SMTP](../features/email-login.md) - Вход и регистрация по email.
## После первого входа
+2 -2
View File
@@ -8,5 +8,5 @@ Remnawave Minishop - Telegram-бот и Mini App для продажи и упр
- **Продажа подписок** - тарифы на срок и по трафику, докупки трафика, HWID-устройства, [premium-сквады](features/tariffs.md#premium-сквады-и-отдельный-лимит)
- **Жизненный цикл пользователей** - регистрация, пробный период, продление, синхронизация с панелью и предупреждения по трафику.
- **Mini App** - личный кабинет, инструкции установки, Telegram OAuth, вход по email и публичные реферальные ссылки.
- **Операционные инструменты** - админка, тикеты поддержки, промокоды, рассылки, логи и настройки поверх `.env`.
- **Mini App** - личный кабинет, инструкции установки, [Telegram OAuth](features/telegram-auth.md), [вход по email](features/email-login.md) и публичные реферальные ссылки.
- **Операционные инструменты** - админка, тикеты поддержки, промокоды, рассылки, логи, [бэкапы и восстановление](features/backups.md), настройки поверх `.env`.
+1 -1
View File
@@ -92,7 +92,7 @@ docker compose logs -f backend
- `/auth/telegram/start` и `/auth/telegram/callback` проходят через frontend nginx в `backend:8081`;
- в браузере после callback нет статуса `telegram_auth=invalid_state`, `invalid_token`, `not_configured`, `unauthorized` или `failed`.
Подробности по маршрутам и настройке OAuth: [веб-приложение / Mini App](../features/web-app.md).
Подробности по маршрутам и настройке OAuth: [Telegram-авторизация](../features/telegram-auth.md).
## После изменения конфигурации
+14 -1
View File
@@ -1,6 +1,6 @@
# Обслуживание
Плановое обслуживание обычно сводится к обновлению образов, проверке миграций, логов и резервных копий PostgreSQL.
Плановое обслуживание обычно сводится к обновлению образов, проверке миграций, логов и резервных копий. Подробная инструкция по автоматическим ZIP-бэкапам и восстановлению вынесена в [бэкапы и восстановление](../features/backups.md).
## Обновление
@@ -16,6 +16,19 @@ docker compose logs -f migrate backend worker
docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > backup.sql
```
## Автоматические бэкапы
Worker может собирать ZIP-архивы с дампом PostgreSQL и snapshot compose-папки, отправлять их в Telegram и хранить последние архивы в `data/backups`. Настройка и восстановление описаны в [отдельном разделе](../features/backups.md).
После изменения backup-настроек в `.env` перезапустите backend и worker:
```bash
docker compose up -d --build backend worker
docker compose logs -f backend worker
```
Восстановление из архива доступно в админке **Система -> Бэкапы**. Там же можно загрузить ZIP вручную, выбрать `БД` и/или `compose-папка`, а backend проверит архив перед запуском. Подробности: [бэкапы и восстановление](../features/backups.md#восстановление-из-админки).
## Проверки после работ
- `docker compose ps`
+14
View File
@@ -5,6 +5,7 @@
ChevronsUpDown,
Coins,
CreditCard,
Database,
Download,
FileText,
Globe2,
@@ -29,6 +30,7 @@
import BrandMark from "$lib/webapp/BrandMark.svelte";
import AdsSection from "./sections/AdsSection.svelte";
import BackupsSection from "./sections/BackupsSection.svelte";
import BroadcastSection from "./sections/BroadcastSection.svelte";
import LogsSection from "./sections/LogsSection.svelte";
import PaymentDetailModal from "./sections/PaymentDetailModal.svelte";
@@ -44,6 +46,7 @@
import UserDetailModal from "./sections/UserDetailModal.svelte";
import UsersSection from "./sections/UsersSection.svelte";
import { createAdsStore } from "../lib/admin/stores/adsStore.js";
import { createBackupsStore } from "../lib/admin/stores/backupsStore.js";
import { createBroadcastStore } from "../lib/admin/stores/broadcastStore.js";
import { createLogsStore } from "../lib/admin/stores/logsStore.js";
import { createPaymentsStore } from "../lib/admin/stores/paymentsStore.js";
@@ -133,6 +136,7 @@
{ id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins },
{ id: "appearance", label: at("nav_appearance", {}, "Внешний вид"), icon: Paintbrush },
{ id: "translations", label: at("nav_translations", {}, "Переводы"), icon: Languages },
{ id: "backups", label: at("nav_backups", {}, "Бэкапы"), icon: Database },
{ id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders },
],
},
@@ -191,6 +195,10 @@
"Оверрайды строк локализации из базы данных и data/locales-overrides.json"
),
},
backups: {
title: at("section_backups_title", {}, "Бэкапы"),
subtitle: at("section_backups_subtitle", {}, "Архивы, загрузка и восстановление БД/compose"),
},
settings: {
title: at("section_settings_title", {}, "Настройки приложения"),
subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"),
@@ -227,6 +235,7 @@
}
const adsStore = createAdsStore({ api, onToast: flash, at });
const backupsStore = createBackupsStore({ api, onToast: flash, at });
const broadcastStore = createBroadcastStore({ api, onToast: flash, at });
const logsStore = createLogsStore({ api, at });
const paymentsStore = createPaymentsStore({ api, onToast: flash, at });
@@ -241,6 +250,7 @@
setContext("promosStore", promosStore);
setContext("adsStore", adsStore);
setContext("backupsStore", backupsStore);
setContext("broadcastStore", broadcastStore);
setContext("logsStore", logsStore);
setContext("paymentsStore", paymentsStore);
@@ -790,6 +800,10 @@
<SettingsSection {at} {onSettingsSaved} {currentLang} />
{/if}
{#if active === "backups"}
<BackupsSection {at} {fmtDate} />
{/if}
{#if active === "translations"}
<TranslationsSection {at} {onTranslationsSaved} />
{/if}
@@ -0,0 +1,351 @@
<script>
import { getContext, onMount } from "svelte";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
import {
CheckCircle2,
Database,
RefreshCw,
Server,
TriangleAlert,
Upload,
} from "$components/ui/icons.js";
export let at = (key) => key;
export let fmtDate = (value) => value;
const backupsStore = getContext("backupsStore");
let selectedName = "";
let restoreDatabase = true;
let restoreCompose = false;
let fileInput = null;
$: ({
archives,
backupDir,
backupsLoading,
backupsUploading,
backupsRestoring,
lastRestore,
} = $backupsStore);
$: if (!selectedName && archives?.length) selectedName = archives[0].name;
$: if (selectedName && archives?.length && !archives.some((item) => item.name === selectedName)) {
selectedName = archives[0].name;
}
$: selectedArchive = (archives || []).find((item) => item.name === selectedName) || null;
$: if (selectedArchive && restoreDatabase && !selectedArchive.has_database) restoreDatabase = false;
$: if (selectedArchive && restoreCompose && !selectedArchive.has_compose) restoreCompose = false;
$: if (selectedArchive && !restoreDatabase && !restoreCompose) {
if (selectedArchive.has_database) restoreDatabase = true;
else if (selectedArchive.has_compose) restoreCompose = true;
}
$: canRestore = Boolean(selectedArchive && (restoreDatabase || restoreCompose) && !backupsRestoring);
$: backupHeaders = [
"",
at("backups_col_archive", {}, "Архив"),
at("backups_col_created", {}, "Создан"),
at("backups_col_size", {}, "Размер"),
at("backups_col_contents", {}, "Состав"),
at("backups_col_warnings", {}, "Предупреждения"),
];
function formatSize(sizeBytes) {
const units = ["B", "KB", "MB", "GB"];
let value = Number(sizeBytes || 0);
let unit = units[0];
for (unit of units) {
if (value < 1024 || unit === "GB") break;
value /= 1024;
}
return unit === "B" ? `${Math.round(value)} ${unit}` : `${value.toFixed(1)} ${unit}`;
}
function archiveDate(archive) {
return archive?.created_at_local || archive?.created_at || archive?.modified_at || "";
}
function selectedComponentsText() {
const parts = [];
if (restoreDatabase) parts.push(at("backups_target_database", {}, "БД"));
if (restoreCompose) parts.push(at("backups_target_compose", {}, "compose-папку"));
return parts.join(" + ");
}
async function uploadSelectedFile(event) {
const file = event?.currentTarget?.files?.[0];
if (!file) return;
const archive = await backupsStore.uploadArchive(file);
if (archive?.name) selectedName = archive.name;
event.currentTarget.value = "";
}
async function restoreSelected() {
if (!canRestore) return;
const confirmText = at(
"backups_restore_confirm",
{ name: selectedName, components: selectedComponentsText() },
`Запустить восстановление из ${selectedName}?`
);
if (typeof window !== "undefined" && !window.confirm(confirmText)) return;
const ok = await backupsStore.restoreArchive({
archiveName: selectedName,
restoreDatabase,
restoreCompose,
});
if (ok) await backupsStore.loadArchives();
}
onMount(() => {
backupsStore.loadArchives();
});
</script>
<div class="backups-layout">
<div class="admin-toolbar admin-toolbar-card backups-toolbar">
<div class="backups-toolbar-main">
<AdminButton onclick={() => backupsStore.loadArchives()} disabled={backupsLoading}>
<RefreshCw size={14} />
{at("btn_refresh", {}, "Обновить")}
</AdminButton>
<AdminButton onclick={() => fileInput?.click()} disabled={backupsUploading}>
<Upload size={14} />
{backupsUploading
? at("backups_uploading", {}, "Загрузка...")
: at("backups_upload", {}, "Загрузить архив")}
</AdminButton>
<input
bind:this={fileInput}
class="backups-file-input"
type="file"
accept=".zip,application/zip"
on:change={uploadSelectedFile}
/>
</div>
<div class="admin-toolbar-summary">
<span class="admin-toolbar-field-label">{at("backups_dir", {}, "Каталог")}</span>
<strong class="backups-dir">{backupDir || "data/backups"}</strong>
</div>
</div>
<article class="admin-card backups-restore-card">
<header class="admin-card-head">
<div>
<h3>{at("backups_restore_title", {}, "Восстановление")}</h3>
{#if selectedArchive}
<small class="backups-selected-name">{selectedArchive.name}</small>
{/if}
</div>
{#if lastRestore}
<AdminBadge variant="success">
<CheckCircle2 size={12} />
{at("backups_last_restore_done", {}, "Готово")}
</AdminBadge>
{/if}
</header>
<div class="admin-card-body backups-restore-body">
<label class="backups-check" class:is-disabled={!selectedArchive?.has_database}>
<input
type="checkbox"
bind:checked={restoreDatabase}
disabled={!selectedArchive?.has_database || backupsRestoring}
/>
<Database size={16} />
<span>{at("backups_target_database", {}, "БД")}</span>
</label>
<label class="backups-check" class:is-disabled={!selectedArchive?.has_compose}>
<input
type="checkbox"
bind:checked={restoreCompose}
disabled={!selectedArchive?.has_compose || backupsRestoring}
/>
<Server size={16} />
<span>{at("backups_target_compose", {}, "compose-папка")}</span>
</label>
<AdminButton variant="danger" onclick={restoreSelected} disabled={!canRestore}>
<RefreshCw size={14} />
{backupsRestoring
? at("backups_restoring", {}, "Восстановление...")
: at("backups_restore_run", {}, "Запустить")}
</AdminButton>
</div>
{#if lastRestore?.compose_pre_restore_archive}
<div class="backups-restore-note">
{at(
"backups_pre_restore_snapshot",
{ path: lastRestore.compose_pre_restore_archive },
"Текущая compose-папка сохранена перед заменой."
)}
</div>
{/if}
</article>
<div class="admin-table-wrap">
{#if backupsLoading}
<AdminTableSkeleton
headers={backupHeaders}
rows={6}
widths={["36px", "minmax(220px, 1fr)", "150px", "80px", "150px", "120px"]}
/>
{:else if !archives?.length}
<AdminEmptyState tone="card">
<span class="admin-muted">{at("backups_empty", {}, "Архивов пока нет")}</span>
</AdminEmptyState>
{:else}
<AdminTable class="backups-table">
<thead>
<tr>
<th aria-label={at("select", {}, "Выбрать")}></th>
<th>{at("backups_col_archive", {}, "Архив")}</th>
<th>{at("backups_col_created", {}, "Создан")}</th>
<th>{at("backups_col_size", {}, "Размер")}</th>
<th>{at("backups_col_contents", {}, "Состав")}</th>
<th>{at("backups_col_warnings", {}, "Предупреждения")}</th>
</tr>
</thead>
<tbody>
{#each archives as archive (archive.name)}
<tr class:is-selected={archive.name === selectedName}>
<td data-label={at("select", {}, "Выбрать")}>
<input
type="radio"
name="backup-archive"
value={archive.name}
checked={archive.name === selectedName}
on:change={() => (selectedName = archive.name)}
aria-label={archive.name}
/>
</td>
<td class="admin-cell-wrap backups-name" data-label={at("backups_col_archive", {}, "Архив")}>
{archive.name}
</td>
<td data-label={at("backups_col_created", {}, "Создан")}>{fmtDate(archiveDate(archive))}</td>
<td data-label={at("backups_col_size", {}, "Размер")}>{formatSize(archive.size_bytes)}</td>
<td data-label={at("backups_col_contents", {}, "Состав")}>
<span class="backups-badges">
{#if archive.has_database}
<AdminBadge variant="success">{at("backups_badge_db", {}, "БД")}</AdminBadge>
{/if}
{#if archive.has_compose}
<AdminBadge variant="muted">
{at("backups_badge_compose", {}, "Compose")}
</AdminBadge>
{/if}
</span>
</td>
<td data-label={at("backups_col_warnings", {}, "Предупреждения")}>
{#if archive.warnings?.length}
<AdminBadge variant="warning">
<TriangleAlert size={12} />
{archive.warnings.length}
</AdminBadge>
{:else}
<span class="admin-muted">-</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</AdminTable>
{/if}
</div>
</div>
<style>
.backups-layout {
display: grid;
gap: 12px;
}
.backups-toolbar-main {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.backups-file-input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.backups-dir,
.backups-selected-name,
.backups-name {
font-family: var(--font-mono);
word-break: break-word;
}
.backups-dir {
max-width: min(420px, 70vw);
overflow: hidden;
text-overflow: ellipsis;
}
.backups-restore-body {
display: grid;
grid-template-columns: repeat(2, minmax(160px, 1fr)) auto;
gap: 10px;
align-items: center;
}
.backups-check {
display: flex;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 8px 10px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: var(--admin-surface-2);
color: var(--admin-text);
font-size: 13px;
}
.backups-check input {
width: 16px;
height: 16px;
margin: 0;
}
.backups-check.is-disabled {
opacity: 0.55;
}
.backups-restore-note {
border-top: 1px solid var(--admin-border);
padding: 10px 14px;
color: var(--admin-muted);
font-size: 12px;
}
:global(.backups-table tbody tr.is-selected) {
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.backups-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
@media (max-width: 760px) {
.backups-restore-body {
grid-template-columns: minmax(0, 1fr);
}
:global(.backups-restore-body .admin-btn) {
width: 100%;
}
}
</style>
@@ -339,6 +339,7 @@
trial: "Триал",
referral: "Реферальная программа",
notifications: "Уведомления",
backups: "Бэкапы",
support: "Поддержка",
devices: "Устройства",
subscription_guides: "Connection guides",
@@ -560,6 +561,8 @@
class="input"
type="number"
step={field.type === "float" ? "0.1" : "1"}
min={field.min ?? undefined}
max={field.max ?? undefined}
placeholder={fieldPlaceholderText(field)}
value={valueFor(field) ?? ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
@@ -0,0 +1,93 @@
import { writable } from "svelte/store";
export function createBackupsStore({ api, onToast, at }) {
const state = writable({
archives: [],
backupDir: "",
backupsLoading: false,
backupsUploading: false,
backupsRestoring: false,
lastRestore: null,
});
async function loadArchives() {
state.update((s) => ({ ...s, backupsLoading: true }));
try {
const data = await api("/admin/backups");
if (data?.ok) {
state.update((s) => ({
...s,
archives: data.archives || [],
backupDir: data.backup_dir || "",
}));
} else {
onToast(data?.message || data?.error || at("backups_load_failed", {}, "Не удалось загрузить бэкапы"));
}
} finally {
state.update((s) => ({ ...s, backupsLoading: false }));
}
}
async function uploadArchive(file) {
if (!file) return null;
state.update((s) => ({ ...s, backupsUploading: true }));
try {
const body = new FormData();
body.append("file", file);
const data = await api("/admin/backups/upload", {
method: "POST",
body,
});
if (data?.ok) {
onToast(at("backups_upload_done", {}, "Архив загружен"));
await loadArchives();
return data.archive || null;
}
onToast(data?.message || data?.error || at("backups_upload_failed", {}, "Не удалось загрузить архив"));
return null;
} finally {
state.update((s) => ({ ...s, backupsUploading: false }));
}
}
async function restoreArchive({ archiveName, restoreDatabase, restoreCompose }) {
const archive_name = String(archiveName || "").trim();
if (!archive_name) {
onToast(at("backups_select_archive", {}, "Выберите архив"));
return false;
}
if (!restoreDatabase && !restoreCompose) {
onToast(at("backups_select_target", {}, "Выберите, что восстановить"));
return false;
}
state.update((s) => ({ ...s, backupsRestoring: true, lastRestore: null }));
try {
const data = await api("/admin/backups/restore", {
method: "POST",
body: JSON.stringify({
archive_name,
restore_database: Boolean(restoreDatabase),
restore_compose: Boolean(restoreCompose),
confirm: true,
}),
});
if (data?.ok) {
state.update((s) => ({ ...s, lastRestore: data.result || null }));
onToast(at("backups_restore_done", {}, "Восстановление завершено"));
return true;
}
onToast(data?.message || data?.error || at("backups_restore_failed", {}, "Не удалось восстановить"));
return false;
} finally {
state.update((s) => ({ ...s, backupsRestoring: false }));
}
}
return {
subscribe: state.subscribe,
loadArchives,
uploadArchive,
restoreArchive,
};
}
+1
View File
@@ -63,6 +63,7 @@ export {
TrendingDown,
TrendingUp,
TriangleAlert,
Upload,
User,
UserMinus,
UserPlus,
+63
View File
@@ -270,6 +270,34 @@ export async function mockApi(path, options = {}, context = {}) {
}
return out;
})();
const mockBackups = [
{
name: "remnawave-minishop-backup-20260527-120000+0300.zip",
size_bytes: 184320,
modified_at: "2026-05-27T09:00:00Z",
created_at: "2026-05-27T09:00:00Z",
created_at_local: "2026-05-27T12:00:00+03:00",
has_database: true,
has_compose: true,
database_name: "remnawave_minishop",
compose_files_count: 6,
warnings: [],
manifest: {},
},
{
name: "remnawave-minishop-backup-20260527-110000+0300.zip",
size_bytes: 153600,
modified_at: "2026-05-27T08:00:00Z",
created_at: "2026-05-27T08:00:00Z",
created_at_local: "2026-05-27T11:00:00+03:00",
has_database: true,
has_compose: false,
database_name: "remnawave_minishop",
compose_files_count: 0,
warnings: ["Compose source directory is unavailable"],
manifest: {},
},
];
if (path === "/admin/stats") {
return {
ok: true,
@@ -440,6 +468,41 @@ export async function mockApi(path, options = {}, context = {}) {
},
};
}
if (path === "/admin/backups") {
return {
ok: true,
backup_dir: "data/backups",
archives: clone(mockBackups),
};
}
if (path === "/admin/backups/upload") {
return {
ok: true,
archive: {
...mockBackups[0],
name: `remnawave-minishop-backup-uploaded-${Date.now()}.zip`,
modified_at: new Date().toISOString(),
created_at: new Date().toISOString(),
created_at_local: new Date().toISOString(),
},
};
}
if (path === "/admin/backups/restore") {
return {
ok: true,
result: {
archive_name: mockBackups[0].name,
started_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
database_restored: true,
compose_files_restored: 6,
compose_target_dir: "/app/compose-source",
compose_pre_restore_archive:
"data/backups/remnawave-minishop-compose-pre-restore-20260527-121500+0300.zip",
warnings: [],
},
};
}
if (path === "/admin/settings" && String(options.method || "GET").toUpperCase() === "PATCH") {
try {
const body = options?.body ? JSON.parse(String(options.body)) : {};
+42
View File
@@ -871,6 +871,7 @@
"admin_nav_system": "System",
"admin_nav_tariffs": "Tariffs",
"admin_nav_themes": "Themes",
"admin_nav_backups": "Backups",
"admin_nav_settings": "Settings",
"admin_section_stats_title": "Dashboard",
"admin_section_stats_subtitle": "Audience, revenue, Remnawave panel, and recent payments",
@@ -890,8 +891,36 @@
"admin_section_tariffs_subtitle": "Sales catalog, periods, packages, and limits",
"admin_section_themes_title": "Web App themes",
"admin_section_themes_subtitle": "Colors, fonts, and Mini App appearance",
"admin_section_backups_title": "Backups",
"admin_section_backups_subtitle": "Archives, uploads, and database/compose restore",
"admin_section_settings_title": "App Settings",
"admin_section_settings_subtitle": "Overrides for .env, applied instantly",
"admin_backups_load_failed": "Failed to load backups",
"admin_backups_upload_done": "Archive uploaded",
"admin_backups_upload_failed": "Failed to upload archive",
"admin_backups_select_archive": "Select an archive",
"admin_backups_select_target": "Select what to restore",
"admin_backups_restore_done": "Restore completed",
"admin_backups_restore_failed": "Restore failed",
"admin_backups_col_archive": "Archive",
"admin_backups_col_created": "Created",
"admin_backups_col_size": "Size",
"admin_backups_col_contents": "Contents",
"admin_backups_col_warnings": "Warnings",
"admin_backups_target_database": "Database",
"admin_backups_target_compose": "compose folder",
"admin_backups_restore_confirm": "Start restore from {name}: {components}?",
"admin_backups_uploading": "Uploading...",
"admin_backups_upload": "Upload archive",
"admin_backups_dir": "Directory",
"admin_backups_restore_title": "Restore",
"admin_backups_last_restore_done": "Done",
"admin_backups_restoring": "Restoring...",
"admin_backups_restore_run": "Start",
"admin_backups_pre_restore_snapshot": "Current compose folder was saved before replacement: {path}",
"admin_backups_empty": "No archives yet",
"admin_backups_badge_db": "DB",
"admin_backups_badge_compose": "Compose",
"admin_filter_all": "All",
"admin_filter_not_banned": "Not banned",
"admin_filter_banned": "Banned",
@@ -975,6 +1004,7 @@
"admin_settings_section_trial": "Trial",
"admin_settings_section_referral": "Referral program",
"admin_settings_section_notifications": "Notifications",
"admin_settings_section_backups": "Backups",
"admin_settings_section_devices": "Devices",
"admin_settings_section_support": "Support",
"admin_settings_subsection_common": "Common",
@@ -1553,6 +1583,18 @@
"admin_settings_field_support_ticket_max_subject_length_description": "Maximum number of characters in a ticket subject.",
"admin_settings_field_support_ticket_rate_limit_per_hour_label": "Ticket limit per hour",
"admin_settings_field_support_ticket_rate_limit_per_hour_description": "How many new tickets a user can create per hour. 0 means unlimited.",
"admin_settings_field_backup_enabled_label": "Backups enabled",
"admin_settings_field_backup_enabled_description": "The worker periodically builds a ZIP archive and sends it to Telegram.",
"admin_settings_field_backup_chat_id_label": "Backup chat ID",
"admin_settings_field_backup_chat_id_description": "Where ZIP archives are sent. Falls back to LOG_CHAT_ID when empty.",
"admin_settings_field_backup_thread_id_label": "Backup thread ID",
"admin_settings_field_backup_thread_id_description": "Optional topic/thread ID. Falls back to LOG_THREAD_ID when empty.",
"admin_settings_field_backup_interval_seconds_label": "Backup period (sec.)",
"admin_settings_field_backup_interval_seconds_description": "Default is 3600: run on the hour boundary (12:00, 13:00, etc.).",
"admin_settings_field_backup_local_retention_label": "Archives to keep",
"admin_settings_field_backup_local_retention_description": "How many latest ZIP archives to keep in data/backups on the server.",
"admin_settings_field_backup_compose_enabled_label": "Include compose folder",
"admin_settings_field_backup_compose_enabled_description": "Adds a /app/compose-source snapshot. If the folder is not mounted, the DB backup is still created.",
"admin_settings_field_my_devices_section_enabled_label": "My Devices Section Enabled",
"admin_settings_field_user_hwid_device_limit_label": "User HWID Device Limit",
"admin_settings_field_user_traffic_limit_gb_label": "User Traffic Limit Gb",
+42
View File
@@ -871,6 +871,7 @@
"admin_nav_system": "Система",
"admin_nav_tariffs": "Тарифы",
"admin_nav_themes": "Темы",
"admin_nav_backups": "Бэкапы",
"admin_nav_settings": "Настройки",
"admin_section_stats_title": "Дашборд",
"admin_section_stats_subtitle": "Аудитория, доходы, панель Remnawave и последние платежи",
@@ -890,8 +891,36 @@
"admin_section_tariffs_subtitle": "Каталог продаж, периоды, пакеты и лимиты",
"admin_section_themes_title": "Темы Web App",
"admin_section_themes_subtitle": "Цвета, шрифты и темы оформления Mini App",
"admin_section_backups_title": "Бэкапы",
"admin_section_backups_subtitle": "Архивы, загрузка и восстановление БД/compose",
"admin_section_settings_title": "Настройки приложения",
"admin_section_settings_subtitle": "Оверрайды над .env, применяются мгновенно",
"admin_backups_load_failed": "Не удалось загрузить бэкапы",
"admin_backups_upload_done": "Архив загружен",
"admin_backups_upload_failed": "Не удалось загрузить архив",
"admin_backups_select_archive": "Выберите архив",
"admin_backups_select_target": "Выберите, что восстановить",
"admin_backups_restore_done": "Восстановление завершено",
"admin_backups_restore_failed": "Не удалось восстановить",
"admin_backups_col_archive": "Архив",
"admin_backups_col_created": "Создан",
"admin_backups_col_size": "Размер",
"admin_backups_col_contents": "Состав",
"admin_backups_col_warnings": "Предупреждения",
"admin_backups_target_database": "БД",
"admin_backups_target_compose": "compose-папка",
"admin_backups_restore_confirm": "Запустить восстановление из {name}: {components}?",
"admin_backups_uploading": "Загрузка...",
"admin_backups_upload": "Загрузить архив",
"admin_backups_dir": "Каталог",
"admin_backups_restore_title": "Восстановление",
"admin_backups_last_restore_done": "Готово",
"admin_backups_restoring": "Восстановление...",
"admin_backups_restore_run": "Запустить",
"admin_backups_pre_restore_snapshot": "Текущая compose-папка сохранена перед заменой: {path}",
"admin_backups_empty": "Архивов пока нет",
"admin_backups_badge_db": "БД",
"admin_backups_badge_compose": "Compose",
"admin_filter_all": "Все",
"admin_filter_not_banned": "Не забанены",
"admin_filter_banned": "Забанены",
@@ -975,6 +1004,7 @@
"admin_settings_section_trial": "Триал",
"admin_settings_section_referral": "Реферальная программа",
"admin_settings_section_notifications": "Уведомления",
"admin_settings_section_backups": "Бэкапы",
"admin_settings_section_devices": "Устройства",
"admin_settings_section_support": "Поддержка",
"admin_settings_subsection_common": "Общие",
@@ -1553,6 +1583,18 @@
"admin_settings_field_support_ticket_max_subject_length_description": "Максимальное количество символов в теме тикета.",
"admin_settings_field_support_ticket_rate_limit_per_hour_label": "Лимит тикетов в час",
"admin_settings_field_support_ticket_rate_limit_per_hour_description": "Сколько новых тикетов пользователь может создать за час. 0 — без лимита.",
"admin_settings_field_backup_enabled_label": "Бэкапы включены",
"admin_settings_field_backup_enabled_description": "Worker будет периодически собирать ZIP-архив и отправлять его в Telegram.",
"admin_settings_field_backup_chat_id_label": "ID чата для бэкапов",
"admin_settings_field_backup_chat_id_description": "Куда отправлять ZIP-архивы. Если пусто, используется LOG_CHAT_ID.",
"admin_settings_field_backup_thread_id_label": "ID треда для бэкапов",
"admin_settings_field_backup_thread_id_description": "Необязательный topic/thread ID. Если пусто, используется LOG_THREAD_ID.",
"admin_settings_field_backup_interval_seconds_label": "Период бэкапов (сек.)",
"admin_settings_field_backup_interval_seconds_description": "По умолчанию 3600: запуск на границе часа (12:00, 13:00 и т.д.).",
"admin_settings_field_backup_local_retention_label": "Сколько архивов хранить",
"admin_settings_field_backup_local_retention_description": "Сколько последних ZIP-архивов оставлять в data/backups на сервере.",
"admin_settings_field_backup_compose_enabled_label": "Добавлять compose-папку",
"admin_settings_field_backup_compose_enabled_description": "Добавляет snapshot /app/compose-source. Если папка не смонтирована, бэкап БД все равно будет создан.",
"admin_settings_field_my_devices_section_enabled_label": "Раздел «Мои устройства»",
"admin_settings_field_user_hwid_device_limit_label": "Лимит устройств по умолчанию (0 = ∞)",
"admin_settings_field_user_traffic_limit_gb_label": "Лимит трафика пользователя (ГБ)",
+38 -4
View File
@@ -2,7 +2,9 @@ import json
import re
from pathlib import Path
from bot.app.web.admin_settings_manifest import manifest_payload
import pytest
from bot.app.web.admin_settings_manifest import coerce_value, get_field_by_key, manifest_payload
from bot.middlewares.i18n import resolve_locale_key
REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -33,6 +35,15 @@ SUBSCRIPTION_GUIDE_SETTINGS = (
"SUBSCRIPTION_PAGE_CONFIG_JSON",
)
BACKUP_SETTINGS = (
"BACKUP_ENABLED",
"BACKUP_CHAT_ID",
"BACKUP_THREAD_ID",
"BACKUP_INTERVAL_SECONDS",
"BACKUP_LOCAL_RETENTION",
"BACKUP_COMPOSE_ENABLED",
)
ADMIN_TARIFF_SETTINGS_PAGE_KEYS = {
"admin_tariffs_trial_title",
"admin_tariffs_trial_subtitle",
@@ -171,6 +182,31 @@ def test_subscription_guide_settings_i18n_keys_exist():
assert field["i18n_description_key"] in messages
def test_backup_settings_i18n_keys_exist():
manifest = _manifest_by_key()
assert manifest["BACKUP_ENABLED"]["section"] == "backups"
assert manifest["BACKUP_ENABLED"]["section_order"] == 9
assert manifest["BACKUP_INTERVAL_SECONDS"]["min"] == 60
assert manifest["BACKUP_INTERVAL_SECONDS"]["optional"] is False
assert manifest["BACKUP_LOCAL_RETENTION"]["min"] == 1
assert manifest["BACKUP_LOCAL_RETENTION"]["optional"] is False
for language in ("ru", "en"):
messages = _locale(language)
assert "admin_settings_section_backups" in messages
for setting_key in BACKUP_SETTINGS:
field = manifest[setting_key]
assert field["i18n_label_key"] in messages
assert field["i18n_description_key"] in messages
def test_backup_required_numeric_settings_reject_empty_values():
with pytest.raises(ValueError):
coerce_value(get_field_by_key("BACKUP_INTERVAL_SECONDS"), "")
def test_payment_provider_settings_include_webhook_metadata():
manifest = _manifest_by_key()
@@ -190,9 +226,7 @@ def test_payment_provider_admin_only_toggles_are_mutually_exclusive():
manifest["PLATEGA_CRYPTO_ADMIN_ONLY_ENABLED"]["mutually_exclusive_key"]
== "PLATEGA_CRYPTO_ENABLED"
)
assert (
manifest["STARS_ADMIN_ONLY_ENABLED"]["mutually_exclusive_key"] == "STARS_ENABLED"
)
assert manifest["STARS_ADMIN_ONLY_ENABLED"]["mutually_exclusive_key"] == "STARS_ENABLED"
def test_legacy_tariff_settings_are_separated_from_payment_settings():
+199
View File
@@ -0,0 +1,199 @@
import asyncio
import tempfile
import zipfile
from pathlib import Path
import pytest
from bot.services.backup_archive import (
attach_archive_integrity,
build_file_records,
write_manifest,
write_zip_from_directory,
)
from bot.services.backup_restore_service import (
BackupArchiveError,
BackupRestoreService,
)
from bot.services.backup_worker import BACKUP_FILENAME_PREFIX
from config.settings import Settings
def _settings(tmp_path: Path, compose_dir: Path, **overrides) -> Settings:
values = {
"BOT_TOKEN": "token",
"POSTGRES_USER": "app_user",
"POSTGRES_PASSWORD": "app_password",
"POSTGRES_DB": "shop",
"BACKUP_DIR": str(tmp_path / "backups"),
"BACKUP_COMPOSE_SOURCE_DIR": str(compose_dir),
"_env_file": None,
}
values.update(overrides)
return Settings(**values)
def _write_backup_archive(
settings: Settings,
path: Path,
*,
include_db=True,
include_compose=True,
unsafe=False,
) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=path.parent) as tmp:
staging_dir = Path(tmp)
if include_db:
dump_dir = staging_dir / "database"
dump_dir.mkdir(parents=True)
(dump_dir / "shop.dump").write_bytes(b"fake dump")
if include_compose:
compose_dir = staging_dir / "compose"
compose_dir.mkdir(parents=True)
(compose_dir / "docker-compose.yml").write_text("services: {}\n", encoding="utf-8")
(compose_dir / ".env").write_text("POSTGRES_PASSWORD=secret\n", encoding="utf-8")
manifest = {
"app": "remnawave-minishop",
"format_version": 1,
"type": "test",
"created_at": "2026-05-27T09:00:00+00:00",
"postgres": {"database": "shop", "included": include_db},
"compose": {"included": include_compose, "files_count": 2 if include_compose else 0},
"warnings": [],
}
attach_archive_integrity(
manifest,
file_records=build_file_records(staging_dir),
settings=settings,
)
write_manifest(staging_dir, manifest)
write_zip_from_directory(staging_dir, path)
if unsafe:
# Add a malicious member after signing; validation must reject before restore.
with zipfile.ZipFile(path, "a") as archive:
archive.writestr("compose/../evil.txt", "nope")
def test_backup_restore_service_lists_archives_with_contents(tmp_path):
compose_dir = tmp_path / "compose"
compose_dir.mkdir()
settings = _settings(tmp_path, compose_dir)
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
_write_backup_archive(settings, archive_path)
archives = BackupRestoreService(settings).list_archives()
assert [item.name for item in archives] == [archive_path.name]
assert archives[0].has_database is True
assert archives[0].has_compose is True
assert archives[0].database_name == "shop"
assert archives[0].compose_files_count == 2
def test_backup_restore_service_rejects_path_traversal_archive_name(tmp_path):
settings = _settings(tmp_path, tmp_path / "compose")
service = BackupRestoreService(settings)
with pytest.raises(BackupArchiveError):
service.archive_path_for_name("../backup.zip")
def test_backup_restore_service_restores_compose_and_snapshots_current(tmp_path):
compose_dir = tmp_path / "compose"
compose_dir.mkdir()
(compose_dir / "docker-compose.yml").write_text("old: true\n", encoding="utf-8")
settings = _settings(tmp_path, compose_dir)
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
_write_backup_archive(settings, archive_path, include_db=False)
service = BackupRestoreService(settings)
result = service.restore_archive_sync(
archive_path.name,
restore_database=False,
restore_compose=True,
)
assert result.database_restored is False
assert result.compose_files_restored == 2
assert (compose_dir / "docker-compose.yml").read_text(encoding="utf-8") == "services: {}\n"
assert (compose_dir / ".env").read_text(encoding="utf-8") == "POSTGRES_PASSWORD=secret\n"
assert result.compose_pre_restore_archive
assert Path(result.compose_pre_restore_archive).is_file()
snapshot = service.inspect_archive(Path(result.compose_pre_restore_archive))
assert snapshot.has_compose is True
assert snapshot.compose_files_count == 1
def test_backup_restore_service_prevents_zip_slip_in_compose_restore(tmp_path):
compose_dir = tmp_path / "compose"
compose_dir.mkdir()
settings = _settings(tmp_path, compose_dir)
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
_write_backup_archive(settings, archive_path, include_db=False, unsafe=True)
with pytest.raises(BackupArchiveError):
BackupRestoreService(settings).restore_archive_sync(
archive_path.name,
restore_database=False,
restore_compose=True,
)
assert not (tmp_path / "evil.txt").exists()
def test_backup_restore_service_runs_pg_restore_for_dump(tmp_path):
compose_dir = tmp_path / "compose"
compose_dir.mkdir()
settings = _settings(tmp_path, compose_dir)
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
_write_backup_archive(settings, archive_path, include_compose=False)
service = BackupRestoreService(settings)
restored_payloads = []
def fake_pg_restore(dump_path: Path) -> None:
restored_payloads.append(dump_path.read_bytes())
service._run_pg_restore = fake_pg_restore
result = asyncio.run(
service.restore_archive(
archive_path.name,
restore_database=True,
restore_compose=False,
)
)
assert result.database_restored is True
assert restored_payloads == [b"fake dump"]
def test_backup_restore_service_validates_uploaded_zip(tmp_path):
compose_dir = tmp_path / "compose"
compose_dir.mkdir()
settings = _settings(tmp_path, compose_dir)
temp_path = tmp_path / "not-a-backup.zip"
temp_path.write_text("not zip", encoding="utf-8")
with pytest.raises(BackupArchiveError):
BackupRestoreService(settings).import_uploaded_archive(temp_path, "backup.zip")
def test_backup_restore_service_rejects_tampered_archive(tmp_path):
compose_dir = tmp_path / "compose"
compose_dir.mkdir()
settings = _settings(tmp_path, compose_dir)
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
_write_backup_archive(settings, archive_path, include_compose=False)
tampered_path = archive_path.with_name("tampered.zip")
with zipfile.ZipFile(archive_path) as source, zipfile.ZipFile(tampered_path, "w") as target:
for member in source.infolist():
payload = source.read(member.filename)
if member.filename == "database/shop.dump":
payload = b"not the signed dump"
target.writestr(member, payload)
with pytest.raises(BackupArchiveError):
BackupRestoreService(settings).import_uploaded_archive(tampered_path, "tampered.zip")
+168
View File
@@ -0,0 +1,168 @@
import asyncio
import json
import os
import zipfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
from bot.services.backup_worker import BACKUP_FILENAME_PREFIX, BackupWorker
from bot.services.settings_override_service import refresh_overrides_from_db
from config.settings import Settings
class _FakeBot:
def __init__(self):
self.send_document = AsyncMock()
self.send_message = AsyncMock()
class _FakePgDumpBackupWorker(BackupWorker):
def _run_pg_dump(self, dump_path: Path) -> None:
dump_path.write_bytes(b"fake custom pg dump")
class _FakeSession:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
class _FakeSessionFactory:
def __call__(self):
return _FakeSession()
def _settings(tmp_path: Path, compose_dir: Path, **overrides) -> Settings:
values = {
"BOT_TOKEN": "token",
"POSTGRES_USER": "app_user",
"POSTGRES_PASSWORD": "app_password",
"POSTGRES_DB": "shop",
"BACKUP_DIR": str(tmp_path / "backups"),
"BACKUP_COMPOSE_SOURCE_DIR": str(compose_dir),
"BACKUP_CHAT_ID": 123,
"BACKUP_LOCAL_RETENTION": 1,
"_env_file": None,
}
values.update(overrides)
return Settings(**values)
def test_backup_worker_creates_archive_with_db_dump_and_compose_snapshot(tmp_path):
compose_dir = tmp_path / "compose"
compose_dir.mkdir()
(compose_dir / "docker-compose.yml").write_text("services: {}\n", encoding="utf-8")
(compose_dir / ".env").write_text("POSTGRES_PASSWORD=secret\n", encoding="utf-8")
(compose_dir / "Caddyfile").write_text("example.com\n", encoding="utf-8")
(compose_dir / "node_modules").mkdir()
(compose_dir / "node_modules" / "ignored.txt").write_text("ignored", encoding="utf-8")
settings = _settings(tmp_path, compose_dir)
backup_dir = Path(settings.BACKUP_DIR)
backup_dir.mkdir(parents=True)
old_archive = backup_dir / f"{BACKUP_FILENAME_PREFIX}old.zip"
old_archive.write_text("old", encoding="utf-8")
os.utime(old_archive, (1, 1))
bot = _FakeBot()
worker = _FakePgDumpBackupWorker(settings, bot)
result = asyncio.run(worker.create_and_send_backup())
assert result.archive_path.is_file()
assert result.db_dump_included is True
assert result.compose_files_count == 3
assert not old_archive.exists()
bot.send_document.assert_awaited_once()
send_kwargs = bot.send_document.await_args.kwargs
assert send_kwargs["chat_id"] == 123
assert "Database dump: yes" in send_kwargs["caption"]
with zipfile.ZipFile(result.archive_path) as archive:
names = set(archive.namelist())
manifest = json.loads(archive.read("manifest.json").decode("utf-8"))
assert "database/shop.dump" in names
assert "compose/docker-compose.yml" in names
assert "compose/.env" in names
assert "compose/Caddyfile" in names
assert all("node_modules" not in name for name in names)
assert manifest["postgres"]["database"] == "shop"
assert manifest["compose"]["files_count"] == 3
def test_backup_worker_falls_back_to_log_chat_and_thread(tmp_path):
compose_dir = tmp_path / "compose"
compose_dir.mkdir()
settings = _settings(
tmp_path,
compose_dir,
BACKUP_CHAT_ID="",
BACKUP_THREAD_ID="",
LOG_CHAT_ID=-100123,
LOG_THREAD_ID=77,
BACKUP_POSTGRES_DUMP_ENABLED=False,
BACKUP_COMPOSE_ENABLED=False,
)
bot = _FakeBot()
worker = _FakePgDumpBackupWorker(settings, bot)
result = asyncio.run(worker.create_and_send_backup())
assert result.db_dump_included is False
bot.send_document.assert_awaited_once()
send_kwargs = bot.send_document.await_args.kwargs
assert send_kwargs["chat_id"] == -100123
assert send_kwargs["message_thread_id"] == 77
def test_backup_worker_does_not_fail_when_compose_source_is_not_mounted(tmp_path):
missing_compose_dir = tmp_path / "missing-compose"
settings = _settings(
tmp_path,
missing_compose_dir,
BACKUP_POSTGRES_DUMP_ENABLED=True,
BACKUP_COMPOSE_ENABLED=True,
)
bot = _FakeBot()
worker = _FakePgDumpBackupWorker(settings, bot)
result = asyncio.run(worker.create_and_send_backup())
assert result.archive_path.is_file()
assert result.db_dump_included is True
assert result.compose_files_count == 0
assert any("Compose source directory is unavailable" in item for item in result.warnings)
with zipfile.ZipFile(result.archive_path) as archive:
names = set(archive.namelist())
assert "database/shop.dump" in names
def test_backup_settings_refresh_restores_env_default_when_override_is_deleted(monkeypatch):
from bot.services import settings_override_service
settings = SimpleNamespace(BACKUP_ENABLED=True)
monkeypatch.setattr(
settings_override_service.app_settings_dal,
"get_all_overrides",
AsyncMock(return_value={}),
)
monkeypatch.setattr(
settings_override_service,
"Settings",
lambda: SimpleNamespace(BACKUP_ENABLED=False),
)
applied = asyncio.run(
refresh_overrides_from_db(
settings,
_FakeSessionFactory(),
keys={"BACKUP_ENABLED"},
)
)
assert applied == 0
assert settings.BACKUP_ENABLED is False
+17 -2
View File
@@ -15,8 +15,7 @@ def test_backend_startup_does_not_run_panel_sync_inline():
forbidden_imports = [
node
for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom)
and node.module == "bot.handlers.admin.sync_admin"
if isinstance(node, ast.ImportFrom) and node.module == "bot.handlers.admin.sync_admin"
]
forbidden_calls = [
node
@@ -30,6 +29,22 @@ def test_backend_startup_does_not_run_panel_sync_inline():
assert forbidden_calls == []
def test_worker_starts_backup_task_without_enabled_guard():
source = Path("backend/main_worker.py").read_text(encoding="utf-8")
tree = ast.parse(source)
guarded_backup_tasks = [
node
for node in ast.walk(tree)
if isinstance(node, ast.If)
and isinstance(node.test, ast.Attribute)
and node.test.attr == "BACKUP_ENABLED"
]
assert "BackupWorker" in source
assert guarded_backup_tasks == []
def test_telegram_startup_network_error_retries_until_success_without_traceback(caplog):
calls = []
+22
View File
@@ -190,6 +190,28 @@ class SettingsTests(unittest.TestCase):
self.assertFalse(settings.SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED)
def test_backup_defaults_are_safe_and_blank_targets_use_log_fallback(self):
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
BACKUP_CHAT_ID="",
BACKUP_THREAD_ID="",
)
self.assertFalse(settings.BACKUP_ENABLED)
self.assertEqual(settings.BACKUP_INTERVAL_SECONDS, 3600)
self.assertEqual(settings.BACKUP_DIR, "data/backups")
self.assertEqual(settings.BACKUP_LOCAL_RETENTION, 100)
self.assertIsNone(settings.BACKUP_CHAT_ID)
self.assertIsNone(settings.BACKUP_THREAD_ID)
self.assertEqual(settings.BACKUP_COMPOSE_SOURCE_DIR, "/app/compose-source")
self.assertIsNone(settings.BACKUP_COMPOSE_RESTORE_DIR)
self.assertEqual(settings.BACKUP_PG_RESTORE_PATH, "pg_restore")
self.assertTrue(settings.BACKUP_ARCHIVE_SIGNATURE_REQUIRED)
self.assertIsNone(settings.BACKUP_ARCHIVE_SIGNATURE_SECRET)
def test_subscription_purchase_description_is_localized_and_toggleable(self):
settings = Settings(
_env_file=None,
+12
View File
@@ -186,6 +186,9 @@ class WebAppRouteContractTests(unittest.TestCase):
("PUT", "/api/admin/themes"): "admin_themes_save_route",
("POST", "/api/admin/appearance/logo"): "admin_appearance_logo_upload_route",
("POST", "/api/admin/appearance/favicon"): "admin_appearance_favicon_upload_route",
("GET", "/api/admin/backups"): "admin_backups_list_route",
("POST", "/api/admin/backups/upload"): "admin_backups_upload_route",
("POST", "/api/admin/backups/restore"): "admin_backups_restore_route",
("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route",
}
@@ -219,6 +222,15 @@ class WebAppRouteContractTests(unittest.TestCase):
self.assertEqual(match_info.handler.__name__, "index_route")
def test_admin_backups_page_route_is_registered(self):
app = web.Application()
subscription_webapp.setup_subscription_webapp_routes(app)
request = make_mocked_request("GET", "/admin/backups", app=app)
match_info = asyncio.run(app.router.resolve(request))
self.assertEqual(match_info.handler.__name__, "index_route")
def test_webapp_favicon_asset_route_is_registered(self):
app = web.Application()
subscription_webapp.setup_subscription_webapp_routes(app)