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.
|
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
||||||
# Keep loopback for local proxy; add your proxy network if needed.
|
# Keep loopback for local proxy; add your proxy network if needed.
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1
|
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/deployment.md) - Docker Compose, Caddy, Nginx, Pangolin/Newt и запуск без обратного прокси.
|
||||||
- [Настройка окружения](docs/getting-started/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
|
- [Настройка окружения](docs/getting-started/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
|
||||||
- [Переменные `.env`](docs/configuration/env-vars.md) - полный справочник всех env-ключей по разделам.
|
- [Переменные `.env`](docs/configuration/env-vars.md) - полный справочник всех env-ключей по разделам.
|
||||||
|
- [Бэкапы и восстановление](docs/features/backups.md) - автоматические архивы, Telegram-отправка и restore через админку.
|
||||||
- [Тарифы](docs/features/tariffs.md) - каталог тарифов, модели на срок и по трафику, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
- [Тарифы](docs/features/tariffs.md) - каталог тарифов, модели на срок и по трафику, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
||||||
- [Админ-панель](docs/features/admin-panel.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
- [Админ-панель](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, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
||||||
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
||||||
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`.
|
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from bot.app.web.admin_api_impl import (
|
|||||||
_runtime as _runtime,
|
_runtime as _runtime,
|
||||||
ads as _ads,
|
ads as _ads,
|
||||||
auth as _auth,
|
auth as _auth,
|
||||||
|
backups as _backups,
|
||||||
broadcast as _broadcast,
|
broadcast as _broadcast,
|
||||||
common as _common,
|
common as _common,
|
||||||
logs as _logs,
|
logs as _logs,
|
||||||
@@ -36,6 +37,7 @@ _MODULES = (
|
|||||||
_broadcast,
|
_broadcast,
|
||||||
_sync,
|
_sync,
|
||||||
_ads,
|
_ads,
|
||||||
|
_backups,
|
||||||
_settings,
|
_settings,
|
||||||
_tariffs,
|
_tariffs,
|
||||||
_themes,
|
_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_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/logo", admin_appearance_logo_upload_route)
|
||||||
router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_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)
|
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
|
||||||
|
|||||||
@@ -434,6 +434,55 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"ID треда поддержки",
|
"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(
|
SettingField(
|
||||||
"SUPPORT_TICKETS_ENABLED",
|
"SUPPORT_TICKETS_ENABLED",
|
||||||
"bool",
|
"bool",
|
||||||
@@ -561,6 +610,8 @@ def coerce_value(field: SettingField, raw: Any) -> Any:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
|
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
|
||||||
|
if not field.optional:
|
||||||
|
raise ValueError(f"{field.key}: value required")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if field.type == "bool":
|
if field.type == "bool":
|
||||||
@@ -629,7 +680,8 @@ def manifest_payload() -> List[dict]:
|
|||||||
"referral": 6,
|
"referral": 6,
|
||||||
"notifications": 7,
|
"notifications": 7,
|
||||||
"support": 8,
|
"support": 8,
|
||||||
"devices": 9,
|
"backups": 9,
|
||||||
|
"devices": 10,
|
||||||
"subscription_guides": 10,
|
"subscription_guides": 10,
|
||||||
}
|
}
|
||||||
exclusive_map = {
|
exclusive_map = {
|
||||||
@@ -678,6 +730,10 @@ def manifest_payload() -> List[dict]:
|
|||||||
"optional": field.optional,
|
"optional": field.optional,
|
||||||
"secret": field.secret,
|
"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:
|
if field.key in exclusive_map:
|
||||||
item["mutually_exclusive_key"] = exclusive_map[field.key]
|
item["mutually_exclusive_key"] = exclusive_map[field.key]
|
||||||
if default_value is not None:
|
if default_value is not None:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
|||||||
app.router.add_get(
|
app.router.add_get(
|
||||||
(
|
(
|
||||||
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
|
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
|
||||||
"appearance|settings|translations|support}"
|
"appearance|settings|translations|support|backups}"
|
||||||
),
|
),
|
||||||
index_route,
|
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_sort_",
|
||||||
"admin_status_",
|
"admin_status_",
|
||||||
"admin_badge_",
|
"admin_badge_",
|
||||||
|
"admin_backups_",
|
||||||
"admin_aria_",
|
"admin_aria_",
|
||||||
"admin_search",
|
"admin_search",
|
||||||
"admin_clear",
|
"admin_clear",
|
||||||
@@ -257,6 +258,7 @@ LOCALE_GROUPS = [
|
|||||||
"audience": "internal",
|
"audience": "internal",
|
||||||
"prefixes": (
|
"prefixes": (
|
||||||
"admin_settings_field_log_",
|
"admin_settings_field_log_",
|
||||||
|
"admin_settings_field_backup_",
|
||||||
"admin_settings_field_support_",
|
"admin_settings_field_support_",
|
||||||
"admin_settings_field_subscription_notifications_",
|
"admin_settings_field_subscription_notifications_",
|
||||||
"admin_settings_field_subscription_notify_",
|
"admin_settings_field_subscription_notify_",
|
||||||
|
|||||||
@@ -263,6 +263,35 @@ async def load_overrides_from_db(settings: Settings, async_session_factory: sess
|
|||||||
return applied
|
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(
|
async def update_overrides(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
async_session_factory: sessionmaker,
|
async_session_factory: sessionmaker,
|
||||||
|
|||||||
@@ -114,6 +114,35 @@ class Settings(BaseSettings):
|
|||||||
TARIFF_WORKER_LOCK_TTL_SECONDS: int = Field(default=240)
|
TARIFF_WORKER_LOCK_TTL_SECONDS: int = Field(default=240)
|
||||||
TARIFF_WORKER_TICK_SECONDS: int = Field(default=300)
|
TARIFF_WORKER_TICK_SECONDS: int = Field(default=300)
|
||||||
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD: int = Field(default=50)
|
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_LANGUAGE: str = Field(default="ru")
|
||||||
DEFAULT_CURRENCY_SYMBOL: str = Field(default="RUB")
|
DEFAULT_CURRENCY_SYMBOL: str = Field(default="RUB")
|
||||||
@@ -947,7 +976,14 @@ class Settings(BaseSettings):
|
|||||||
return v
|
return v
|
||||||
return secrets.token_urlsafe(32)
|
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
|
@classmethod
|
||||||
def validate_optional_int_fields(cls, v):
|
def validate_optional_int_fields(cls, v):
|
||||||
"""Convert empty strings to None for optional integer fields"""
|
"""Convert empty strings to None for optional integer fields"""
|
||||||
@@ -969,6 +1005,9 @@ class Settings(BaseSettings):
|
|||||||
"SMTP_FROM_EMAIL",
|
"SMTP_FROM_EMAIL",
|
||||||
"SMTP_FROM_NAME",
|
"SMTP_FROM_NAME",
|
||||||
"SMTP_FALLBACK_PORTS",
|
"SMTP_FALLBACK_PORTS",
|
||||||
|
"BACKUP_ARCHIVE_SIGNATURE_SECRET",
|
||||||
|
"BACKUP_COMPOSE_SOURCE_DIR",
|
||||||
|
"BACKUP_COMPOSE_RESTORE_DIR",
|
||||||
mode="before",
|
mode="before",
|
||||||
)
|
)
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from bot.payment_providers.yookassa import (
|
|||||||
process_cancelled_payment,
|
process_cancelled_payment,
|
||||||
process_successful_payment,
|
process_successful_payment,
|
||||||
)
|
)
|
||||||
|
from bot.services.backup_worker import BackupWorker
|
||||||
from bot.services.locale_override_service import load_locale_overrides
|
from bot.services.locale_override_service import load_locale_overrides
|
||||||
from bot.services.tariff_worker import TariffTrafficWorker
|
from bot.services.tariff_worker import TariffTrafficWorker
|
||||||
from bot.utils.message_queue import init_queue_manager
|
from bot.utils.message_queue import init_queue_manager
|
||||||
@@ -191,6 +192,8 @@ async def main() -> None:
|
|||||||
tasks = []
|
tasks = []
|
||||||
if settings.tariffs_config:
|
if settings.tariffs_config:
|
||||||
tasks.append(asyncio.create_task(tariff_worker.run(), name="TariffTrafficWorker"))
|
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)))
|
tasks.append(asyncio.create_task(_panel_sync_loop(settings, session_factory, i18n, services)))
|
||||||
for idx in range(max(1, settings.WEBHOOK_QUEUE_CONCURRENCY)):
|
for idx in range(max(1, settings.WEBHOOK_QUEUE_CONCURRENCY)):
|
||||||
tasks.append(
|
tasks.append(
|
||||||
|
|||||||
@@ -53,6 +53,20 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
|
|
||||||
WORKDIR /app
|
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
|
RUN useradd -u 10001 -m appuser
|
||||||
|
|
||||||
COPY backend/requirements.txt backend/requirements.txt
|
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.
|
# Caddy and Docker network ranges that may set X-Forwarded-For.
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
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}
|
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- shop-data:/app/data
|
||||||
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -91,6 +92,7 @@ services:
|
|||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- shop-data:/app/data
|
||||||
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -40,3 +40,13 @@ PANEL_WEBHOOK_SECRET=change_me
|
|||||||
# Pangolin/Newt and Docker network ranges that may set X-Forwarded-For.
|
# Pangolin/Newt and Docker network ranges that may set X-Forwarded-For.
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
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}
|
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- shop-data:/app/data
|
||||||
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -91,6 +92,7 @@ services:
|
|||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- shop-data:/app/data
|
||||||
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -39,3 +39,13 @@ PANEL_WEBHOOK_SECRET=change_me
|
|||||||
# Nginx and Docker network ranges that may set X-Forwarded-For.
|
# Nginx and Docker network ranges that may set X-Forwarded-For.
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
|
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}
|
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- shop-data:/app/data
|
||||||
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -91,6 +92,7 @@ services:
|
|||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- shop-data:/app/data
|
||||||
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -39,3 +39,14 @@ PANEL_API_KEY=change_me
|
|||||||
PANEL_WEBHOOK_SECRET=change_me
|
PANEL_WEBHOOK_SECRET=change_me
|
||||||
|
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1
|
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"
|
- "${WEB_SERVER_BIND:-0.0.0.0:8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- shop-data:/app/data
|
||||||
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -93,6 +94,7 @@ services:
|
|||||||
<<: *app_environment
|
<<: *app_environment
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- shop-data:/app/data
|
||||||
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ services:
|
|||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- shop-data:/app/data
|
||||||
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
ports:
|
ports:
|
||||||
@@ -82,6 +83,7 @@ services:
|
|||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
volumes:
|
volumes:
|
||||||
- shop-data:/app/data
|
- shop-data:/app/data
|
||||||
|
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||||
networks:
|
networks:
|
||||||
- remnawave-shop
|
- remnawave-shop
|
||||||
|
|
||||||
|
|||||||
@@ -79,8 +79,11 @@ export default defineConfig({
|
|||||||
{ label: 'Подписки', slug: 'features/subscriptions' },
|
{ label: 'Подписки', slug: 'features/subscriptions' },
|
||||||
{ label: 'Тарифы', slug: 'features/tariffs' },
|
{ label: 'Тарифы', slug: 'features/tariffs' },
|
||||||
{ label: 'Веб-приложение / Mini App', slug: 'features/web-app' },
|
{ 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: 'Темы Web App', slug: 'features/webapp-themes' },
|
||||||
{ label: 'Админ-панель', slug: 'features/admin-panel' },
|
{ label: 'Админ-панель', slug: 'features/admin-panel' },
|
||||||
|
{ label: 'Бэкапы и восстановление', slug: 'features/backups' },
|
||||||
{ label: 'Поддержка пользователей / тикеты', slug: 'features/support' },
|
{ label: 'Поддержка пользователей / тикеты', slug: 'features/support' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ const descriptions = {
|
|||||||
'features/payments.md': 'Платежные провайдеры, кнопки оплаты и webhook-обработка.',
|
'features/payments.md': 'Платежные провайдеры, кнопки оплаты и webhook-обработка.',
|
||||||
'features/subscriptions.md': 'Тарифы на срок и по трафику, premium-сквады, HWID-устройства и жизненный цикл подписки.',
|
'features/subscriptions.md': 'Тарифы на срок и по трафику, premium-сквады, HWID-устройства и жизненный цикл подписки.',
|
||||||
'features/tariffs.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/webapp-themes.md': 'Кастомные темы, CSS-токены, ассеты и пайплайн создания темы.',
|
||||||
'features/admin-panel.md': 'Возможности админ-панели, управление пользователями, настройками, тарифами и поддержкой.',
|
'features/admin-panel.md': 'Возможности админ-панели, управление пользователями, настройками, тарифами и поддержкой.',
|
||||||
|
'features/backups.md': 'Автоматические бэкапы, отправка архивов в Telegram, локальное хранение и восстановление БД/compose-папки из админки.',
|
||||||
'features/support.md': 'Пользовательские тикеты, список обращений в админке, уведомления и лимиты поддержки.',
|
'features/support.md': 'Пользовательские тикеты, список обращений в админке, уведомления и лимиты поддержки.',
|
||||||
'migrations/index.md': 'Готовые сценарии миграции в Remnawave Minishop с других ботов.',
|
'migrations/index.md': 'Готовые сценарии миграции в Remnawave Minishop с других ботов.',
|
||||||
'migrations/remnawave-tg-shop.md': 'Перенос данных со старого remnawave-tg-shop на split-архитектуру 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`. |
|
| `WEBAPP_ME_CACHE_TTL_SECONDS` | TTL кеша `/api/me`. |
|
||||||
@@ -71,6 +73,25 @@
|
|||||||
| `TARIFF_WORKER_LOCK_TTL_SECONDS` | TTL Redis lock для tariff worker. |
|
| `TARIFF_WORKER_LOCK_TTL_SECONDS` | TTL Redis lock для tariff worker. |
|
||||||
| `TARIFF_WORKER_TICK_SECONDS` | Интервал tariff worker. |
|
| `TARIFF_WORKER_TICK_SECONDS` | Интервал tariff worker. |
|
||||||
| `TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD` | Порог активных подписок для bulk fetch пользователей панели. |
|
| `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-значения этих полей игнорируются при загрузке. Настраивайте их в **Админка -> Внешний вид**.
|
Часть внешнего вида (`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` и рестарта. |
|
| `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`.
|
Вход по email появляется только если заполнены `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD` и `SMTP_FROM_EMAIL`.
|
||||||
|
|
||||||
|
Практический сценарий настройки SMTP, magic link и парольного входа описан в [разделе входа по email](../features/email-login.md).
|
||||||
|
|
||||||
| Переменная | Назначение |
|
| Переменная | Назначение |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `SMTP_HOST` | SMTP host. |
|
| `SMTP_HOST` | SMTP host. |
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
- редактор разрешенных настроек приложения из manifest-файла;
|
- редактор разрешенных настроек приложения из manifest-файла;
|
||||||
- раздел **Внешний вид** для логотипа, emoji-логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем;
|
- раздел **Внешний вид** для логотипа, emoji-логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем;
|
||||||
- раздел **Инструкции подключения** для встроенной страницы установки, поведения кнопок бота и Remnawave Subscription Page config;
|
- раздел **Инструкции подключения** для встроенной страницы установки, поведения кнопок бота и Remnawave Subscription Page config;
|
||||||
|
- раздел **Бэкапы** для просмотра локальных ZIP-архивов, загрузки архива и восстановления БД/compose-папки;
|
||||||
- редактор JSON-каталога тарифов;
|
- редактор JSON-каталога тарифов;
|
||||||
- загрузка Internal Squads из Remnawave для выбора в тарифах.
|
- загрузка 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`) через модалку в админке.
|
Для каждого платежного метода в разделе провайдера доступны настройки отображения `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-бот, платежи, подписки, поддержка и другие группы.
|
Раздел **Система -> Переводы** позволяет переопределять отдельные строки из `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_NOTIFICATION_COOLDOWN_SECONDS` - пауза для Telegram/log уведомлений;
|
||||||
- `SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS` - пауза для email-уведомлений.
|
- `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, если он привязан.
|
Ответ администратора и закрытие тикета дополнительно отправляются пользователю в 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_AUTH_MAX_AGE_SECONDS=86400
|
||||||
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600
|
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_LINK=https://t.me/your_support_link
|
||||||
SUPPORT_TICKETS_ENABLED=True
|
SUPPORT_TICKETS_ENABLED=True
|
||||||
SUPPORT_TICKET_RATE_LIMIT_PER_HOUR=5
|
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).
|
Внешний вид настраивается в админке: раздел **Внешний вид** управляет логотипом, 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).
|
Тикеты поддержки включаются через `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`.
|
- [configuration/env-vars.md](../configuration/env-vars.md) - полный справочник переменных `.env`.
|
||||||
- [features/admin-panel.md](../features/admin-panel.md) - как устроены overrides и allowlist настроек.
|
- [features/admin-panel.md](../features/admin-panel.md) - как устроены overrides и allowlist настроек.
|
||||||
- [features/tariffs.md](../features/tariffs.md) - JSON-каталог тарифов и редактор тарифов.
|
- [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) - тикеты поддержки и уведомления.
|
- [Поддержка пользователей / тикеты](../features/support.md) - тикеты поддержки и уведомления.
|
||||||
- [Развертывание](deployment.md) - Docker Compose, обратный прокси, Caddy/Nginx и обновления.
|
- [Развертывание](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
|
## Резервная копия PostgreSQL
|
||||||
|
|
||||||
|
Для штатных автоматических ZIP-бэкапов, отправки в Telegram и восстановления через админку используйте раздел [бэкапы и восстановление](../features/backups.md). Команды ниже - минимальный ручной fallback для PostgreSQL.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > backup.sql
|
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.
|
- [Установка](setup.md) - базовый запуск через Compose.
|
||||||
- [Развертывание](../deployment.md) - Docker Compose, Caddy, Nginx, Pangolin/Newt и запуск без обратного прокси.
|
- [Развертывание](../deployment.md) - Docker Compose, Caddy, Nginx, Pangolin/Newt и запуск без обратного прокси.
|
||||||
- [Настройка Telegram бота](../features/web-app.md#telegram-авторизация) - Telegram OAuth и Telegram Mini App.
|
- [Настройка Telegram бота](../features/telegram-auth.md) - Telegram OAuth и Telegram Mini App.
|
||||||
- [Настройка SMTP](../features/web-app.md#вход-по-email) - Вход и регистрация по email.
|
- [Настройка 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.
|
- [Настройка Telegram бота](../features/telegram-auth.md) - Telegram OAuth и Telegram Mini App.
|
||||||
- [Настройка SMTP](../features/web-app.md#вход-по-email) - Вход и регистрация по email.
|
- [Настройка SMTP](../features/email-login.md) - Вход и регистрация по email.
|
||||||
|
|
||||||
## После первого входа
|
## После первого входа
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -8,5 +8,5 @@ Remnawave Minishop - Telegram-бот и Mini App для продажи и упр
|
|||||||
|
|
||||||
- **Продажа подписок** - тарифы на срок и по трафику, докупки трафика, HWID-устройства, [premium-сквады](features/tariffs.md#premium-сквады-и-отдельный-лимит)
|
- **Продажа подписок** - тарифы на срок и по трафику, докупки трафика, HWID-устройства, [premium-сквады](features/tariffs.md#premium-сквады-и-отдельный-лимит)
|
||||||
- **Жизненный цикл пользователей** - регистрация, пробный период, продление, синхронизация с панелью и предупреждения по трафику.
|
- **Жизненный цикл пользователей** - регистрация, пробный период, продление, синхронизация с панелью и предупреждения по трафику.
|
||||||
- **Mini App** - личный кабинет, инструкции установки, Telegram OAuth, вход по email и публичные реферальные ссылки.
|
- **Mini App** - личный кабинет, инструкции установки, [Telegram OAuth](features/telegram-auth.md), [вход по email](features/email-login.md) и публичные реферальные ссылки.
|
||||||
- **Операционные инструменты** - админка, тикеты поддержки, промокоды, рассылки, логи и настройки поверх `.env`.
|
- **Операционные инструменты** - админка, тикеты поддержки, промокоды, рассылки, логи, [бэкапы и восстановление](features/backups.md), настройки поверх `.env`.
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ docker compose logs -f backend
|
|||||||
- `/auth/telegram/start` и `/auth/telegram/callback` проходят через frontend nginx в `backend:8081`;
|
- `/auth/telegram/start` и `/auth/telegram/callback` проходят через frontend nginx в `backend:8081`;
|
||||||
- в браузере после callback нет статуса `telegram_auth=invalid_state`, `invalid_token`, `not_configured`, `unauthorized` или `failed`.
|
- в браузере после 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
|
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`
|
- `docker compose ps`
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Coins,
|
Coins,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
Database,
|
||||||
Download,
|
Download,
|
||||||
FileText,
|
FileText,
|
||||||
Globe2,
|
Globe2,
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
|
|
||||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||||
import AdsSection from "./sections/AdsSection.svelte";
|
import AdsSection from "./sections/AdsSection.svelte";
|
||||||
|
import BackupsSection from "./sections/BackupsSection.svelte";
|
||||||
import BroadcastSection from "./sections/BroadcastSection.svelte";
|
import BroadcastSection from "./sections/BroadcastSection.svelte";
|
||||||
import LogsSection from "./sections/LogsSection.svelte";
|
import LogsSection from "./sections/LogsSection.svelte";
|
||||||
import PaymentDetailModal from "./sections/PaymentDetailModal.svelte";
|
import PaymentDetailModal from "./sections/PaymentDetailModal.svelte";
|
||||||
@@ -44,6 +46,7 @@
|
|||||||
import UserDetailModal from "./sections/UserDetailModal.svelte";
|
import UserDetailModal from "./sections/UserDetailModal.svelte";
|
||||||
import UsersSection from "./sections/UsersSection.svelte";
|
import UsersSection from "./sections/UsersSection.svelte";
|
||||||
import { createAdsStore } from "../lib/admin/stores/adsStore.js";
|
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 { createBroadcastStore } from "../lib/admin/stores/broadcastStore.js";
|
||||||
import { createLogsStore } from "../lib/admin/stores/logsStore.js";
|
import { createLogsStore } from "../lib/admin/stores/logsStore.js";
|
||||||
import { createPaymentsStore } from "../lib/admin/stores/paymentsStore.js";
|
import { createPaymentsStore } from "../lib/admin/stores/paymentsStore.js";
|
||||||
@@ -133,6 +136,7 @@
|
|||||||
{ id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins },
|
{ id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins },
|
||||||
{ id: "appearance", label: at("nav_appearance", {}, "Внешний вид"), icon: Paintbrush },
|
{ id: "appearance", label: at("nav_appearance", {}, "Внешний вид"), icon: Paintbrush },
|
||||||
{ id: "translations", label: at("nav_translations", {}, "Переводы"), icon: Languages },
|
{ id: "translations", label: at("nav_translations", {}, "Переводы"), icon: Languages },
|
||||||
|
{ id: "backups", label: at("nav_backups", {}, "Бэкапы"), icon: Database },
|
||||||
{ id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders },
|
{ id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -191,6 +195,10 @@
|
|||||||
"Оверрайды строк локализации из базы данных и data/locales-overrides.json"
|
"Оверрайды строк локализации из базы данных и data/locales-overrides.json"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
backups: {
|
||||||
|
title: at("section_backups_title", {}, "Бэкапы"),
|
||||||
|
subtitle: at("section_backups_subtitle", {}, "Архивы, загрузка и восстановление БД/compose"),
|
||||||
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: at("section_settings_title", {}, "Настройки приложения"),
|
title: at("section_settings_title", {}, "Настройки приложения"),
|
||||||
subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"),
|
subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"),
|
||||||
@@ -227,6 +235,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const adsStore = createAdsStore({ api, onToast: flash, at });
|
const adsStore = createAdsStore({ api, onToast: flash, at });
|
||||||
|
const backupsStore = createBackupsStore({ api, onToast: flash, at });
|
||||||
const broadcastStore = createBroadcastStore({ api, onToast: flash, at });
|
const broadcastStore = createBroadcastStore({ api, onToast: flash, at });
|
||||||
const logsStore = createLogsStore({ api, at });
|
const logsStore = createLogsStore({ api, at });
|
||||||
const paymentsStore = createPaymentsStore({ api, onToast: flash, at });
|
const paymentsStore = createPaymentsStore({ api, onToast: flash, at });
|
||||||
@@ -241,6 +250,7 @@
|
|||||||
|
|
||||||
setContext("promosStore", promosStore);
|
setContext("promosStore", promosStore);
|
||||||
setContext("adsStore", adsStore);
|
setContext("adsStore", adsStore);
|
||||||
|
setContext("backupsStore", backupsStore);
|
||||||
setContext("broadcastStore", broadcastStore);
|
setContext("broadcastStore", broadcastStore);
|
||||||
setContext("logsStore", logsStore);
|
setContext("logsStore", logsStore);
|
||||||
setContext("paymentsStore", paymentsStore);
|
setContext("paymentsStore", paymentsStore);
|
||||||
@@ -790,6 +800,10 @@
|
|||||||
<SettingsSection {at} {onSettingsSaved} {currentLang} />
|
<SettingsSection {at} {onSettingsSaved} {currentLang} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if active === "backups"}
|
||||||
|
<BackupsSection {at} {fmtDate} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if active === "translations"}
|
{#if active === "translations"}
|
||||||
<TranslationsSection {at} {onTranslationsSaved} />
|
<TranslationsSection {at} {onTranslationsSaved} />
|
||||||
{/if}
|
{/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: "Триал",
|
trial: "Триал",
|
||||||
referral: "Реферальная программа",
|
referral: "Реферальная программа",
|
||||||
notifications: "Уведомления",
|
notifications: "Уведомления",
|
||||||
|
backups: "Бэкапы",
|
||||||
support: "Поддержка",
|
support: "Поддержка",
|
||||||
devices: "Устройства",
|
devices: "Устройства",
|
||||||
subscription_guides: "Connection guides",
|
subscription_guides: "Connection guides",
|
||||||
@@ -560,6 +561,8 @@
|
|||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
step={field.type === "float" ? "0.1" : "1"}
|
step={field.type === "float" ? "0.1" : "1"}
|
||||||
|
min={field.min ?? undefined}
|
||||||
|
max={field.max ?? undefined}
|
||||||
placeholder={fieldPlaceholderText(field)}
|
placeholder={fieldPlaceholderText(field)}
|
||||||
value={valueFor(field) ?? ""}
|
value={valueFor(field) ?? ""}
|
||||||
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
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,
|
TrendingDown,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
TriangleAlert,
|
TriangleAlert,
|
||||||
|
Upload,
|
||||||
User,
|
User,
|
||||||
UserMinus,
|
UserMinus,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
|||||||
@@ -270,6 +270,34 @@ export async function mockApi(path, options = {}, context = {}) {
|
|||||||
}
|
}
|
||||||
return out;
|
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") {
|
if (path === "/admin/stats") {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
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") {
|
if (path === "/admin/settings" && String(options.method || "GET").toUpperCase() === "PATCH") {
|
||||||
try {
|
try {
|
||||||
const body = options?.body ? JSON.parse(String(options.body)) : {};
|
const body = options?.body ? JSON.parse(String(options.body)) : {};
|
||||||
|
|||||||
@@ -871,6 +871,7 @@
|
|||||||
"admin_nav_system": "System",
|
"admin_nav_system": "System",
|
||||||
"admin_nav_tariffs": "Tariffs",
|
"admin_nav_tariffs": "Tariffs",
|
||||||
"admin_nav_themes": "Themes",
|
"admin_nav_themes": "Themes",
|
||||||
|
"admin_nav_backups": "Backups",
|
||||||
"admin_nav_settings": "Settings",
|
"admin_nav_settings": "Settings",
|
||||||
"admin_section_stats_title": "Dashboard",
|
"admin_section_stats_title": "Dashboard",
|
||||||
"admin_section_stats_subtitle": "Audience, revenue, Remnawave panel, and recent payments",
|
"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_tariffs_subtitle": "Sales catalog, periods, packages, and limits",
|
||||||
"admin_section_themes_title": "Web App themes",
|
"admin_section_themes_title": "Web App themes",
|
||||||
"admin_section_themes_subtitle": "Colors, fonts, and Mini App appearance",
|
"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_title": "App Settings",
|
||||||
"admin_section_settings_subtitle": "Overrides for .env, applied instantly",
|
"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_all": "All",
|
||||||
"admin_filter_not_banned": "Not banned",
|
"admin_filter_not_banned": "Not banned",
|
||||||
"admin_filter_banned": "Banned",
|
"admin_filter_banned": "Banned",
|
||||||
@@ -975,6 +1004,7 @@
|
|||||||
"admin_settings_section_trial": "Trial",
|
"admin_settings_section_trial": "Trial",
|
||||||
"admin_settings_section_referral": "Referral program",
|
"admin_settings_section_referral": "Referral program",
|
||||||
"admin_settings_section_notifications": "Notifications",
|
"admin_settings_section_notifications": "Notifications",
|
||||||
|
"admin_settings_section_backups": "Backups",
|
||||||
"admin_settings_section_devices": "Devices",
|
"admin_settings_section_devices": "Devices",
|
||||||
"admin_settings_section_support": "Support",
|
"admin_settings_section_support": "Support",
|
||||||
"admin_settings_subsection_common": "Common",
|
"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_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_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_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_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_hwid_device_limit_label": "User HWID Device Limit",
|
||||||
"admin_settings_field_user_traffic_limit_gb_label": "User Traffic Limit Gb",
|
"admin_settings_field_user_traffic_limit_gb_label": "User Traffic Limit Gb",
|
||||||
|
|||||||
@@ -871,6 +871,7 @@
|
|||||||
"admin_nav_system": "Система",
|
"admin_nav_system": "Система",
|
||||||
"admin_nav_tariffs": "Тарифы",
|
"admin_nav_tariffs": "Тарифы",
|
||||||
"admin_nav_themes": "Темы",
|
"admin_nav_themes": "Темы",
|
||||||
|
"admin_nav_backups": "Бэкапы",
|
||||||
"admin_nav_settings": "Настройки",
|
"admin_nav_settings": "Настройки",
|
||||||
"admin_section_stats_title": "Дашборд",
|
"admin_section_stats_title": "Дашборд",
|
||||||
"admin_section_stats_subtitle": "Аудитория, доходы, панель Remnawave и последние платежи",
|
"admin_section_stats_subtitle": "Аудитория, доходы, панель Remnawave и последние платежи",
|
||||||
@@ -890,8 +891,36 @@
|
|||||||
"admin_section_tariffs_subtitle": "Каталог продаж, периоды, пакеты и лимиты",
|
"admin_section_tariffs_subtitle": "Каталог продаж, периоды, пакеты и лимиты",
|
||||||
"admin_section_themes_title": "Темы Web App",
|
"admin_section_themes_title": "Темы Web App",
|
||||||
"admin_section_themes_subtitle": "Цвета, шрифты и темы оформления Mini App",
|
"admin_section_themes_subtitle": "Цвета, шрифты и темы оформления Mini App",
|
||||||
|
"admin_section_backups_title": "Бэкапы",
|
||||||
|
"admin_section_backups_subtitle": "Архивы, загрузка и восстановление БД/compose",
|
||||||
"admin_section_settings_title": "Настройки приложения",
|
"admin_section_settings_title": "Настройки приложения",
|
||||||
"admin_section_settings_subtitle": "Оверрайды над .env, применяются мгновенно",
|
"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_all": "Все",
|
||||||
"admin_filter_not_banned": "Не забанены",
|
"admin_filter_not_banned": "Не забанены",
|
||||||
"admin_filter_banned": "Забанены",
|
"admin_filter_banned": "Забанены",
|
||||||
@@ -975,6 +1004,7 @@
|
|||||||
"admin_settings_section_trial": "Триал",
|
"admin_settings_section_trial": "Триал",
|
||||||
"admin_settings_section_referral": "Реферальная программа",
|
"admin_settings_section_referral": "Реферальная программа",
|
||||||
"admin_settings_section_notifications": "Уведомления",
|
"admin_settings_section_notifications": "Уведомления",
|
||||||
|
"admin_settings_section_backups": "Бэкапы",
|
||||||
"admin_settings_section_devices": "Устройства",
|
"admin_settings_section_devices": "Устройства",
|
||||||
"admin_settings_section_support": "Поддержка",
|
"admin_settings_section_support": "Поддержка",
|
||||||
"admin_settings_subsection_common": "Общие",
|
"admin_settings_subsection_common": "Общие",
|
||||||
@@ -1553,6 +1583,18 @@
|
|||||||
"admin_settings_field_support_ticket_max_subject_length_description": "Максимальное количество символов в теме тикета.",
|
"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_label": "Лимит тикетов в час",
|
||||||
"admin_settings_field_support_ticket_rate_limit_per_hour_description": "Сколько новых тикетов пользователь может создать за час. 0 — без лимита.",
|
"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_my_devices_section_enabled_label": "Раздел «Мои устройства»",
|
||||||
"admin_settings_field_user_hwid_device_limit_label": "Лимит устройств по умолчанию (0 = ∞)",
|
"admin_settings_field_user_hwid_device_limit_label": "Лимит устройств по умолчанию (0 = ∞)",
|
||||||
"admin_settings_field_user_traffic_limit_gb_label": "Лимит трафика пользователя (ГБ)",
|
"admin_settings_field_user_traffic_limit_gb_label": "Лимит трафика пользователя (ГБ)",
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import json
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
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
|
from bot.middlewares.i18n import resolve_locale_key
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
@@ -33,6 +35,15 @@ SUBSCRIPTION_GUIDE_SETTINGS = (
|
|||||||
"SUBSCRIPTION_PAGE_CONFIG_JSON",
|
"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_TARIFF_SETTINGS_PAGE_KEYS = {
|
||||||
"admin_tariffs_trial_title",
|
"admin_tariffs_trial_title",
|
||||||
"admin_tariffs_trial_subtitle",
|
"admin_tariffs_trial_subtitle",
|
||||||
@@ -171,6 +182,31 @@ def test_subscription_guide_settings_i18n_keys_exist():
|
|||||||
assert field["i18n_description_key"] in messages
|
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():
|
def test_payment_provider_settings_include_webhook_metadata():
|
||||||
manifest = _manifest_by_key()
|
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"]
|
manifest["PLATEGA_CRYPTO_ADMIN_ONLY_ENABLED"]["mutually_exclusive_key"]
|
||||||
== "PLATEGA_CRYPTO_ENABLED"
|
== "PLATEGA_CRYPTO_ENABLED"
|
||||||
)
|
)
|
||||||
assert (
|
assert manifest["STARS_ADMIN_ONLY_ENABLED"]["mutually_exclusive_key"] == "STARS_ENABLED"
|
||||||
manifest["STARS_ADMIN_ONLY_ENABLED"]["mutually_exclusive_key"] == "STARS_ENABLED"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_tariff_settings_are_separated_from_payment_settings():
|
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 = [
|
forbidden_imports = [
|
||||||
node
|
node
|
||||||
for node in ast.walk(tree)
|
for node in ast.walk(tree)
|
||||||
if isinstance(node, ast.ImportFrom)
|
if isinstance(node, ast.ImportFrom) and node.module == "bot.handlers.admin.sync_admin"
|
||||||
and node.module == "bot.handlers.admin.sync_admin"
|
|
||||||
]
|
]
|
||||||
forbidden_calls = [
|
forbidden_calls = [
|
||||||
node
|
node
|
||||||
@@ -30,6 +29,22 @@ def test_backend_startup_does_not_run_panel_sync_inline():
|
|||||||
assert forbidden_calls == []
|
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):
|
def test_telegram_startup_network_error_retries_until_success_without_traceback(caplog):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,28 @@ class SettingsTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertFalse(settings.SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED)
|
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):
|
def test_subscription_purchase_description_is_localized_and_toggleable(self):
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
_env_file=None,
|
_env_file=None,
|
||||||
|
|||||||
@@ -186,6 +186,9 @@ class WebAppRouteContractTests(unittest.TestCase):
|
|||||||
("PUT", "/api/admin/themes"): "admin_themes_save_route",
|
("PUT", "/api/admin/themes"): "admin_themes_save_route",
|
||||||
("POST", "/api/admin/appearance/logo"): "admin_appearance_logo_upload_route",
|
("POST", "/api/admin/appearance/logo"): "admin_appearance_logo_upload_route",
|
||||||
("POST", "/api/admin/appearance/favicon"): "admin_appearance_favicon_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",
|
("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")
|
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):
|
def test_webapp_favicon_asset_route_is_registered(self):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
subscription_webapp.setup_subscription_webapp_routes(app)
|
subscription_webapp.setup_subscription_webapp_routes(app)
|
||||||
|
|||||||
Reference in New Issue
Block a user