feat: add backups feature
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -67,6 +67,7 @@ services:
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
@@ -91,6 +92,7 @@ services:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
|
||||
@@ -39,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
|
||||
|
||||
@@ -67,6 +67,7 @@ services:
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
@@ -91,6 +92,7 @@ services:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
|
||||
@@ -39,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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-бот, платежи, подписки, поддержка и другие группы.
|
||||
|
||||
@@ -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` внутри контейнеров. |
|
||||
@@ -4,7 +4,7 @@ Minishop закрывает путь от регистрации пользов
|
||||
|
||||
## Для пользователей
|
||||
|
||||
- Регистрация через Telegram Mini App или email-код.
|
||||
- Регистрация через [Telegram Mini App](telegram-auth.md) или [email-код](email-login.md).
|
||||
- Просмотр подписки, срока действия, трафика и ссылки подключения.
|
||||
- Покупка подписки, пакетов трафика и дополнительных устройств.
|
||||
- Пробный период, промокоды и реферальные сценарии.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Вход по email
|
||||
|
||||
Email-вход позволяет пользователю зарегистрироваться или войти в Mini App без Telegram. Пользователь вводит email, получает одноразовый код и может подтвердить вход кодом или magic link из письма. После входа email можно связать с Telegram-аккаунтом в настройках профиля.
|
||||
|
||||
Аккаунты только с email не получают права администратора: админка проверяет Telegram ID из `ADMIN_IDS`.
|
||||
|
||||
## Когда форма появляется
|
||||
|
||||
Кнопка входа по email показывается только если заполнены все обязательные SMTP-настройки:
|
||||
|
||||
```ini
|
||||
SMTP_HOST=smtp-relay.brevo.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=<smtp-login>
|
||||
SMTP_PASSWORD=<smtp-password-or-api-key>
|
||||
SMTP_FROM_EMAIL=no-reply@domain.com
|
||||
```
|
||||
|
||||
Если хотя бы одно из этих полей пустое, backend вернет `email_auth_enabled=false` в bootstrap, а frontend скроет email-login.
|
||||
|
||||
Для magic link также нужен корректный `SUBSCRIPTION_MINI_APP_URL`, потому что ссылка в письме строится на его основе.
|
||||
|
||||
## SMTP-настройка
|
||||
|
||||
Типовой пример:
|
||||
|
||||
```ini
|
||||
SMTP_HOST=smtp-relay.brevo.com
|
||||
SMTP_PORT=587
|
||||
SMTP_FALLBACK_PORTS=2525,465
|
||||
SMTP_TIMEOUT_SECONDS=30
|
||||
SMTP_STARTTLS=True
|
||||
SMTP_USE_SSL=False
|
||||
SMTP_USERNAME=<smtp-login>
|
||||
SMTP_PASSWORD=<smtp-password-or-api-key>
|
||||
SMTP_FROM_EMAIL=no-reply@domain.com
|
||||
SMTP_FROM_NAME=Remnawave Minishop
|
||||
|
||||
EMAIL_CODE_TTL_SECONDS=600
|
||||
EMAIL_CODE_RESEND_SECONDS=60
|
||||
EMAIL_CODE_MAX_ATTEMPTS=5
|
||||
BRUTE_FORCE_MAX_FAILURES=5
|
||||
BRUTE_FORCE_WINDOW_SECONDS=900
|
||||
BRUTE_FORCE_LOCK_SECONDS=900
|
||||
```
|
||||
|
||||
Для Brevo обычно подходит порт `587` с STARTTLS. Если основной порт недоступен, приложение пробует порты из `SMTP_FALLBACK_PORTS`; порт `465` используется через SSL wrapper автоматически.
|
||||
|
||||
`SMTP_FROM_EMAIL` должен быть подтвержден у SMTP-провайдера, иначе письмо часто отклоняется или попадает в спам. `SMTP_FROM_NAME` можно оставить пустым, тогда используется название Web App.
|
||||
|
||||
Полный справочник переменных: [SMTP и вход по email](../configuration/env-vars.md#smtp-и-вход-по-email).
|
||||
|
||||
## Как работает вход
|
||||
|
||||
1. Пользователь вводит email в форме входа.
|
||||
2. Backend проверяет rate limit и создает одноразовый код.
|
||||
3. Письмо отправляется через SMTP. Если `SUBSCRIPTION_MINI_APP_URL` валиден, в письме также есть magic link.
|
||||
4. Пользователь вводит код в Mini App или открывает magic link.
|
||||
5. Backend создает нового email-пользователя или находит существующего по email.
|
||||
6. Если в URL был referral-параметр, он применяется к новой или существующей записи.
|
||||
7. Пользователь получает Web App-сессию.
|
||||
|
||||
Коды хранятся в базе в хешированном виде, устаревают по `EMAIL_CODE_TTL_SECONDS`, повторная отправка ограничена `EMAIL_CODE_RESEND_SECONDS`, а количество попыток ввода ограничено `EMAIL_CODE_MAX_ATTEMPTS` и общими brute-force настройками.
|
||||
|
||||
## Парольный вход
|
||||
|
||||
После подтверждения email пользователь может задать пароль в настройках профиля. Пароль хранится как PBKDF2-SHA256 hash с солью.
|
||||
|
||||
После установки пароля доступен путь:
|
||||
|
||||
```text
|
||||
https://app.domain.com/login/password
|
||||
```
|
||||
|
||||
Если парольный вход не удался, frontend предлагает fallback на обычный email-код. Установка или изменение пароля тоже подтверждается email-кодом.
|
||||
|
||||
## Привязка аккаунтов
|
||||
|
||||
В настройках профиля пользователь может:
|
||||
|
||||
- привязать email к Telegram-аккаунту через код;
|
||||
- привязать Telegram к email-аккаунту через Telegram Mini Apps `initData` или Telegram OAuth;
|
||||
- задать или сменить пароль для email-входа.
|
||||
|
||||
Если email уже принадлежит другой записи, backend выполняет безопасное объединение по существующим правилам аккаунтов и инвалидирует старые Web App-кеши.
|
||||
|
||||
## Проверка после настройки
|
||||
|
||||
1. Перезапустите backend/frontend после изменения `.env`.
|
||||
2. Откройте `https://app.domain.com/` вне Telegram.
|
||||
3. Убедитесь, что форма email-входа видна.
|
||||
4. Запросите код на тестовый адрес.
|
||||
5. Проверьте письмо, magic link и ручной ввод 6-значного кода.
|
||||
6. Проверьте логи backend, если письмо не пришло:
|
||||
|
||||
```bash
|
||||
docker compose logs -f backend
|
||||
```
|
||||
|
||||
## Частые ошибки
|
||||
|
||||
- Форма email не видна: не заполнены `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD` или `SMTP_FROM_EMAIL`.
|
||||
- Письмо не отправляется: проверьте порт, STARTTLS/SSL режим, SMTP login/API key и подтверждение отправителя.
|
||||
- Magic link ведет не туда: исправьте `SUBSCRIPTION_MINI_APP_URL`, он должен быть публичным HTTPS URL Mini App без `/api` и `/auth`.
|
||||
- Код сразу устаревает: проверьте `EMAIL_CODE_TTL_SECONDS` и время на сервере.
|
||||
- Пользователь получает `rate_limited`: подождите `EMAIL_CODE_RESEND_SECONDS` или проверьте brute-force настройки.
|
||||
|
||||
Email-уведомления поддержки и платежей используют тот же SMTP-контур. Сценарий поддержки описан в [разделе тикетов](support.md).
|
||||
@@ -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, если он привязан.
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
|
||||
## Проксирование
|
||||
|
||||
Рекомендуемая продакшен-схема - два публичных домена:
|
||||
|
||||
@@ -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 и обновления.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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).
|
||||
|
||||
## После изменения конфигурации
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export {
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
TriangleAlert,
|
||||
Upload,
|
||||
User,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
|
||||
@@ -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)) : {};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Лимит трафика пользователя (ГБ)",
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user