diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 292825e..2fd4f1f 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -16,6 +16,7 @@ "cmdTemplateConfigs": "Cmd Templates", "users": "Users", "settings": "Settings", + "backup": "Backup", "logout": "Logout", "notification": "Notification", "commands": "Commands", @@ -1025,6 +1026,8 @@ "criteria": "Criteria", "persons": "Persons", "addPerson": "Add person...", + "excludePersons": "Exclude persons", + "addExcludePerson": "Add person to exclude...", "searchQuery": "Smart Search Query", "searchQueryPlaceholder": "e.g. sunset, beach, birthday...", "assetType": "Asset type", @@ -1053,5 +1056,67 @@ "triggerManual": "manual", "triggerDryRun": "dry-run", "triggerScheduled": "scheduled" + }, + "backup": { + "title": "Backup & Restore", + "description": "Export and import your configuration, or set up automatic backups", + "export": "Export Configuration", + "exportDescription": "Download your configuration as a JSON file. Select which categories to include.", + "import": "Import Configuration", + "importDescription": "Upload a previously exported backup file to restore configuration.", + "categories": "Categories", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "catProviders": "Providers", + "catTelegramBots": "Telegram Bots", + "catMatrixBots": "Matrix Bots", + "catEmailBots": "Email Bots", + "catTargets": "Targets", + "catTrackingConfigs": "Tracking Configs", + "catTemplateConfigs": "Template Configs", + "catCommandConfigs": "Command Configs", + "catCommandTemplateConfigs": "Cmd Template Configs", + "catNotificationTrackers": "Notif. Trackers", + "catCommandTrackers": "Cmd Trackers", + "catActions": "Actions", + "catAppSettings": "App Settings", + "secretsMode": "Secrets", + "secretsExclude": "Exclude secrets (safe)", + "secretsMasked": "Mask secrets (for review)", + "secretsInclude": "Include secrets (plaintext)", + "secretsWarningExport": "Warning: The export file will contain sensitive data (API keys, tokens, passwords) in plaintext.", + "exportBtn": "Export", + "exportSuccess": "Configuration exported", + "validateBtn": "Validate", + "validating": "Validating...", + "validationPassed": "Validation passed", + "validationFailed": "Validation failed", + "entities": "Entities", + "conflictMode": "Conflict resolution", + "conflictSkip": "Skip existing (keep current)", + "conflictRename": "Rename duplicates (add suffix)", + "conflictOverwrite": "Overwrite existing (replace)", + "importBtn": "Import", + "importing": "Importing...", + "importSuccess": "Configuration imported", + "importResults": "Import Results", + "resultCreated": "Created", + "resultSkipped": "Skipped", + "resultOverwritten": "Overwritten", + "resultErrors": "Errors", + "confirmExportTitle": "Export with secrets?", + "confirmExportMessage": "The exported file will contain all secrets (API keys, bot tokens, passwords) in plaintext. Only use this for secure transfers or trusted storage.", + "confirmImportTitle": "Import configuration?", + "confirmImportMessage": "This will create new entities in your database. Make sure you have validated the backup file first.", + "scheduled": "Scheduled Backups", + "enableScheduled": "Enable automatic backups", + "interval": "Interval", + "hours": "hours", + "retention": "Keep last", + "scheduleSaved": "Backup schedule saved", + "savedFiles": "Saved Backups", + "noFiles": "No backup files yet.", + "download": "Download", + "fileDeleted": "Backup file deleted" } } \ No newline at end of file diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 3429f8d..94b6b49 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -16,6 +16,7 @@ "cmdTemplateConfigs": "Шаблоны команд", "users": "Пользователи", "settings": "Настройки", + "backup": "Бэкап", "logout": "Выход", "notification": "Уведомления", "commands": "Команды", @@ -1025,6 +1026,8 @@ "criteria": "Критерии", "persons": "Люди", "addPerson": "Добавить человека...", + "excludePersons": "Исключить людей", + "addExcludePerson": "Добавить человека для исключения...", "searchQuery": "Умный поиск", "searchQueryPlaceholder": "напр. закат, пляж, день рождения...", "assetType": "Тип файла", @@ -1053,5 +1056,67 @@ "triggerManual": "вручную", "triggerDryRun": "пробный", "triggerScheduled": "по расписанию" + }, + "backup": { + "title": "Резервное копирование", + "description": "Экспорт и импорт конфигурации, настройка автоматических бэкапов", + "export": "Экспорт конфигурации", + "exportDescription": "Скачать конфигурацию в формате JSON. Выберите категории для включения.", + "import": "Импорт конфигурации", + "importDescription": "Загрузить ранее экспортированный файл бэкапа для восстановления.", + "categories": "Категории", + "selectAll": "Выбрать все", + "deselectAll": "Снять все", + "catProviders": "Провайдеры", + "catTelegramBots": "Telegram боты", + "catMatrixBots": "Matrix боты", + "catEmailBots": "Email боты", + "catTargets": "Цели", + "catTrackingConfigs": "Конфиги отслеживания", + "catTemplateConfigs": "Конфиги шаблонов", + "catCommandConfigs": "Конфиги команд", + "catCommandTemplateConfigs": "Шаблоны команд", + "catNotificationTrackers": "Трекеры уведомлений", + "catCommandTrackers": "Трекеры команд", + "catActions": "Действия", + "catAppSettings": "Настройки приложения", + "secretsMode": "Секреты", + "secretsExclude": "Исключить секреты (безопасно)", + "secretsMasked": "Маскировать секреты (для проверки)", + "secretsInclude": "Включить секреты (открытый текст)", + "secretsWarningExport": "Внимание: файл экспорта будет содержать конфиденциальные данные (API-ключи, токены, пароли) в открытом виде.", + "exportBtn": "Экспорт", + "exportSuccess": "Конфигурация экспортирована", + "validateBtn": "Проверить", + "validating": "Проверка...", + "validationPassed": "Проверка пройдена", + "validationFailed": "Проверка не пройдена", + "entities": "Сущности", + "conflictMode": "Разрешение конфликтов", + "conflictSkip": "Пропустить существующие (оставить текущие)", + "conflictRename": "Переименовать дубликаты (добавить суффикс)", + "conflictOverwrite": "Перезаписать существующие (заменить)", + "importBtn": "Импорт", + "importing": "Импорт...", + "importSuccess": "Конфигурация импортирована", + "importResults": "Результаты импорта", + "resultCreated": "Создано", + "resultSkipped": "Пропущено", + "resultOverwritten": "Перезаписано", + "resultErrors": "Ошибки", + "confirmExportTitle": "Экспорт с секретами?", + "confirmExportMessage": "Экспортированный файл будет содержать все секреты (API-ключи, токены ботов, пароли) в открытом виде. Используйте только для безопасной передачи.", + "confirmImportTitle": "Импортировать конфигурацию?", + "confirmImportMessage": "Это создаст новые сущности в базе данных. Убедитесь, что файл бэкапа прошёл проверку.", + "scheduled": "Автоматические бэкапы", + "enableScheduled": "Включить автоматическое резервное копирование", + "interval": "Интервал", + "hours": "часов", + "retention": "Хранить последних", + "scheduleSaved": "Расписание бэкапов сохранено", + "savedFiles": "Сохранённые бэкапы", + "noFiles": "Файлов бэкапа пока нет.", + "download": "Скачать", + "fileDeleted": "Файл бэкапа удалён" } } \ No newline at end of file diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index c4cf5a8..5dd5e46 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -193,6 +193,7 @@ key: 'nav.settings', icon: 'mdiCogOutline', children: [ { href: '/settings', key: 'nav.common', icon: 'mdiCogOutline' }, + { href: '/settings/backup', key: 'nav.backup', icon: 'mdiBackupRestore' }, { href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' }, ], }, @@ -236,6 +237,7 @@ { href: '/command-template-configs', key: 'nav.templates', icon: 'mdiCodeBracesBox' }, ...(auth.isAdmin ? [ { href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' }, + { href: '/settings/backup', key: 'nav.backup', icon: 'mdiBackupRestore' }, { href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' }, ] : []), ]); diff --git a/frontend/src/routes/actions/RuleEditor.svelte b/frontend/src/routes/actions/RuleEditor.svelte index a93cca4..94bdac5 100644 --- a/frontend/src/routes/actions/RuleEditor.svelte +++ b/frontend/src/routes/actions/RuleEditor.svelte @@ -31,7 +31,7 @@ let newRule = $state({ name: '', rule_config: { - criteria: { person_ids: [] as string[], person_names: [] as string[], query: '', asset_type: 'all', date_from: '', date_to: '', favorite_only: false }, + criteria: { person_ids: [] as string[], person_names: [] as string[], exclude_person_ids: [] as string[], exclude_person_names: [] as string[], query: '', asset_type: 'all', date_from: '', date_to: '', favorite_only: false }, target_album_ids: [] as string[], target_album_names: [] as string[], target_album_id: '', target_album_name: '', create_album_if_missing: false, create_album_name: '', @@ -111,7 +111,7 @@ newRule = { name: '', rule_config: { - criteria: { person_ids: [], person_names: [], query: '', asset_type: 'all', date_from: '', date_to: '', favorite_only: false }, + criteria: { person_ids: [], person_names: [], exclude_person_ids: [] as string[], exclude_person_names: [] as string[], query: '', asset_type: 'all', date_from: '', date_to: '', favorite_only: false }, target_album_ids: [] as string[], target_album_names: [] as string[], target_album_id: '', target_album_name: '', create_album_if_missing: false, create_album_name: '', @@ -228,6 +228,18 @@ ruleConfig.criteria.person_names = ids.map(id => people.find(p => p.id === id)?.name || id); }} /> + + +
+ + { + ruleConfig.criteria.exclude_person_names = ids.map(id => people.find(p => p.id === id)?.name || id); + }} /> +
{/if} diff --git a/frontend/src/routes/settings/backup/+page.svelte b/frontend/src/routes/settings/backup/+page.svelte new file mode 100644 index 0000000..c87dcbc --- /dev/null +++ b/frontend/src/routes/settings/backup/+page.svelte @@ -0,0 +1,570 @@ + + + + +{#if !loaded} + +{:else} + +
+ + + +

+ + {t('backup.export')} +

+

{t('backup.exportDescription')}

+ + +
+
+ + +
+
+ {#each categories as cat} + + {/each} +
+
+ + +
+ +
+ + + +
+ {#if exportSecrets === 'include'} +
+ + {t('backup.secretsWarningExport')} +
+ {/if} +
+ + +
+ + + +

+ + {t('backup.import')} +

+

{t('backup.importDescription')}

+ + +
+ +
+ + {#if importFile} + +
+ +
+ + {#if validationResult} +
+
+ {#if validationResult.valid} + + {t('backup.validationPassed')} + {:else} + + {t('backup.validationFailed')} + {/if} +
+ {#if Object.keys(validationResult.entity_counts || {}).length} +
+ {t('backup.entities')}: + {#each Object.entries(validationResult.entity_counts) as [cat, count]} + {cat}: {count} + {/each} +
+ {/if} + {#each validationResult.warnings || [] as w} +
+ + {w} +
+ {/each} + {#each validationResult.errors || [] as e} +
+ + {e} +
+ {/each} +
+ {/if} + + +
+ +
+ + + +
+
+ + + + {#if importResult} +
+
{t('backup.importResults')}
+
+
{t('backup.resultCreated')}: {importResult.created}
+
{t('backup.resultSkipped')}: {importResult.skipped}
+
{t('backup.resultOverwritten')}: {importResult.overwritten}
+ {#if importResult.errors?.length} +
{t('backup.resultErrors')}: {importResult.errors.length}
+ {#each importResult.errors as e} +
{e}
+ {/each} + {/if} + {#if importResult.warnings?.length} + {#each importResult.warnings as w} +
{w}
+ {/each} + {/if} +
+
+ {/if} + {/if} +
+ + + +

+ + {t('backup.scheduled')} +

+ +
+ + + {#if scheduledSettings.backup_scheduled_enabled === 'true'} +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} +
+ +
+ +
+
+ + + +
+

+ + {t('backup.savedFiles')} +

+ +
+ + {#if backupFiles.length === 0} +

{t('backup.noFiles')}

+ {:else} +
+ {#each backupFiles as file} +
+
+ + {file.filename} + ({formatSize(file.size)}) +
+
+ + +
+
+ {/each} +
+ {/if} +
+
+{/if} + + + confirmExportOpen = false} +/> + + + confirmImportOpen = false} +/> + + + deleteFile(confirmDeleteFile)} + oncancel={() => confirmDeleteFile = ''} +/> diff --git a/packages/core/src/notify_bridge_core/providers/actions.py b/packages/core/src/notify_bridge_core/providers/actions.py index 116f58c..0acf6e6 100644 --- a/packages/core/src/notify_bridge_core/providers/actions.py +++ b/packages/core/src/notify_bridge_core/providers/actions.py @@ -77,6 +77,16 @@ IMMICH_AUTO_ORGANIZE = ActionTypeDefinition( "items": {"type": "string"}, "description": "Display names (UI only)", }, + "exclude_person_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Immich person UUIDs — assets with these persons are excluded", + }, + "exclude_person_names": { + "type": "array", + "items": {"type": "string"}, + "description": "Display names for excluded persons (UI only)", + }, "query": { "type": "string", "description": "Smart search query (CLIP)", diff --git a/packages/core/src/notify_bridge_core/providers/immich/action_executor.py b/packages/core/src/notify_bridge_core/providers/immich/action_executor.py index 9df6d65..c46c634 100644 --- a/packages/core/src/notify_bridge_core/providers/immich/action_executor.py +++ b/packages/core/src/notify_bridge_core/providers/immich/action_executor.py @@ -255,6 +255,18 @@ class ImmichActionExecutor(ActionExecutor): seen.add(aid) result.append(aid) + # Exclude assets belonging to excluded persons + exclude_person_ids = criteria.get("exclude_person_ids", []) + if exclude_person_ids: + excluded_asset_ids: set[str] = set() + for pid in exclude_person_ids: + assets = await self._client.get_person_assets_all(pid) + for asset in assets: + aid = asset.get("id", "") + if aid: + excluded_asset_ids.add(aid) + result = [aid for aid in result if aid not in excluded_asset_ids] + return result def _matches_filters( diff --git a/packages/server/pyproject.toml b/packages/server/pyproject.toml index 53b0fa8..6f8cf6a 100644 --- a/packages/server/pyproject.toml +++ b/packages/server/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "pydantic-settings>=2.0", "slowapi>=0.1.9", "cachetools>=5.3", + "python-multipart>=0.0.9", ] [project.optional-dependencies] diff --git a/packages/server/src/notify_bridge_server/api/backup.py b/packages/server/src/notify_bridge_server/api/backup.py new file mode 100644 index 0000000..aed4ec1 --- /dev/null +++ b/packages/server/src/notify_bridge_server/api/backup.py @@ -0,0 +1,223 @@ +"""Configuration backup/restore API (admin only).""" + +import json +import logging +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query +from fastapi.responses import JSONResponse +from sqlmodel.ext.asyncio.session import AsyncSession + +from ..auth.dependencies import require_admin +from ..config import settings as app_config +from ..database.engine import get_session +from ..database.models import AppSetting, User +from ..services.backup_schema import ( + ALL_CATEGORIES, BackupCategory, BackupFile, ConflictMode, SecretsMode, +) +from ..services.backup_service import ( + cleanup_old_backups, export_backup, import_backup, list_backup_files, + validate_backup, +) + +_LOGGER = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/backup", tags=["backup"]) + +MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB + + +def _backup_dir(): + return app_config.data_dir / "backups" + + +# --------------------------------------------------------------------------- +# Export +# --------------------------------------------------------------------------- + +@router.get("/export") +async def export_config( + secrets_mode: SecretsMode = Query(default=SecretsMode.EXCLUDE), + categories: str = Query(default=""), + user: User = Depends(require_admin), + session: AsyncSession = Depends(get_session), +): + """Export configuration as a downloadable JSON file.""" + cats = None + if categories: + try: + cats = [BackupCategory(c.strip()) for c in categories.split(",") if c.strip()] + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid category: {e}") + + backup = await export_backup(session, user.id, cats, secrets_mode) + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S") + filename = f"notify-bridge-backup-{ts}.json" + + return JSONResponse( + content=backup.model_dump(), + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +# --------------------------------------------------------------------------- +# Validate +# --------------------------------------------------------------------------- + +@router.post("/validate") +async def validate_config( + file: UploadFile = File(...), + user: User = Depends(require_admin), +): + """Validate a backup file without importing.""" + content = await file.read() + if len(content) > MAX_UPLOAD_SIZE: + raise HTTPException(status_code=400, detail="File too large (max 10 MB)") + + try: + raw = json.loads(content) + except json.JSONDecodeError as e: + raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}") + + result = validate_backup(raw) + return result.model_dump() + + +# --------------------------------------------------------------------------- +# Import +# --------------------------------------------------------------------------- + +@router.post("/import") +async def import_config( + file: UploadFile = File(...), + conflict_mode: ConflictMode = Query(default=ConflictMode.SKIP), + user: User = Depends(require_admin), + session: AsyncSession = Depends(get_session), +): + """Import configuration from a backup file.""" + content = await file.read() + if len(content) > MAX_UPLOAD_SIZE: + raise HTTPException(status_code=400, detail="File too large (max 10 MB)") + + try: + raw = json.loads(content) + except json.JSONDecodeError as e: + raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}") + + # Validate first + validation = validate_backup(raw) + if not validation.valid: + raise HTTPException(status_code=400, detail=f"Invalid backup: {'; '.join(validation.errors)}") + + backup = BackupFile.model_validate(raw) + result = await import_backup(session, user.id, backup, conflict_mode) + return result.model_dump() + + +# --------------------------------------------------------------------------- +# Scheduled backup settings +# --------------------------------------------------------------------------- + +_BACKUP_SETTING_KEYS = { + "backup_scheduled_enabled": "false", + "backup_scheduled_interval_hours": "24", + "backup_secrets_mode": "exclude", + "backup_retention_count": "5", +} + + +@router.get("/scheduled") +async def get_scheduled_settings( + user: User = Depends(require_admin), + session: AsyncSession = Depends(get_session), +): + """Get scheduled backup settings.""" + result = {} + for key, default in _BACKUP_SETTING_KEYS.items(): + row = await session.get(AppSetting, key) + result[key] = row.value if row and row.value else default + return result + + +@router.put("/scheduled") +async def update_scheduled_settings( + body: dict, + user: User = Depends(require_admin), + session: AsyncSession = Depends(get_session), +): + """Update scheduled backup settings and reschedule.""" + for key, default in _BACKUP_SETTING_KEYS.items(): + value = body.get(key) + if value is None: + continue + row = await session.get(AppSetting, key) + if row: + row.value = str(value) + else: + row = AppSetting(key=key, value=str(value)) + session.add(row) + await session.commit() + + # Reschedule backup job + from ..services.scheduler import schedule_backup + enabled = body.get("backup_scheduled_enabled", "false") == "true" + interval_hours = int(body.get("backup_scheduled_interval_hours", "24")) + if enabled: + await schedule_backup(interval_hours) + else: + from ..services.scheduler import unschedule_backup + await unschedule_backup() + + # Return updated settings + result = {} + for key, default in _BACKUP_SETTING_KEYS.items(): + row = await session.get(AppSetting, key) + result[key] = row.value if row and row.value else default + return result + + +# --------------------------------------------------------------------------- +# Backup file management +# --------------------------------------------------------------------------- + +@router.get("/files") +async def get_backup_files( + user: User = Depends(require_admin), +): + """List saved backup files.""" + return list_backup_files(_backup_dir()) + + +@router.get("/files/{filename}") +async def download_backup_file( + filename: str, + user: User = Depends(require_admin), +): + """Download a specific backup file.""" + filepath = _backup_dir() / filename + if not filepath.is_file() or not filename.startswith("backup-"): + raise HTTPException(status_code=404, detail="Backup file not found") + + try: + content = json.loads(filepath.read_text(encoding="utf-8")) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to read backup: {e}") + + return JSONResponse( + content=content, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.delete("/files/{filename}") +async def delete_backup_file( + filename: str, + user: User = Depends(require_admin), +): + """Delete a specific backup file.""" + filepath = _backup_dir() / filename + if not filepath.is_file() or not filename.startswith("backup-"): + raise HTTPException(status_code=404, detail="Backup file not found") + + filepath.unlink() + return {"deleted": filename} diff --git a/packages/server/src/notify_bridge_server/main.py b/packages/server/src/notify_bridge_server/main.py index 8304e42..054db10 100644 --- a/packages/server/src/notify_bridge_server/main.py +++ b/packages/server/src/notify_bridge_server/main.py @@ -44,6 +44,7 @@ from .api.action_types import router as action_types_router from .commands.webhook import router as webhook_router, set_webhook_secret from .api.webhooks import router as webhooks_router from .api.webhook_logs import router as webhook_logs_router +from .api.backup import router as backup_router @asynccontextmanager @@ -143,6 +144,7 @@ app.include_router(command_template_configs_router) app.include_router(webhook_router) app.include_router(webhooks_router) app.include_router(webhook_logs_router) +app.include_router(backup_router) @app.get("/api/health") diff --git a/packages/server/src/notify_bridge_server/services/backup_schema.py b/packages/server/src/notify_bridge_server/services/backup_schema.py new file mode 100644 index 0000000..e3443e8 --- /dev/null +++ b/packages/server/src/notify_bridge_server/services/backup_schema.py @@ -0,0 +1,269 @@ +"""Pydantic models for the configuration backup/restore file format.""" + +from __future__ import annotations + +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +class SecretsMode(str, Enum): + EXCLUDE = "exclude" + MASKED = "masked" + INCLUDE = "include" + + +class ConflictMode(str, Enum): + SKIP = "skip" + RENAME = "rename" + OVERWRITE = "overwrite" + + +class BackupCategory(str, Enum): + PROVIDERS = "providers" + TELEGRAM_BOTS = "telegram_bots" + MATRIX_BOTS = "matrix_bots" + EMAIL_BOTS = "email_bots" + TARGETS = "targets" + TRACKING_CONFIGS = "tracking_configs" + TEMPLATE_CONFIGS = "template_configs" + COMMAND_CONFIGS = "command_configs" + COMMAND_TEMPLATE_CONFIGS = "command_template_configs" + NOTIFICATION_TRACKERS = "notification_trackers" + COMMAND_TRACKERS = "command_trackers" + ACTIONS = "actions" + APP_SETTINGS = "app_settings" + + +ALL_CATEGORIES = list(BackupCategory) + +# Secret fields in provider config dicts +PROVIDER_SECRET_FIELDS = frozenset( + ("api_key", "api_token", "webhook_secret", "password", + "client_secret", "refresh_token") +) + + +# ---------- nested child models ---------- + +class ReceiverData(BaseModel): + name: str = "" + config: dict[str, Any] = {} + receiver_key: str = "" + locale: str = "" + enabled: bool = True + + +class TargetData(BaseModel): + id: int + type: str + name: str + icon: str = "" + config: dict[str, Any] = {} + chat_action: str | None = "typing" + receivers: list[ReceiverData] = [] + + +class TemplateSlotData(BaseModel): + slot_name: str + locale: str = "en" + template: str = "" + + +class TemplateConfigData(BaseModel): + id: int + provider_type: str + name: str + description: str = "" + icon: str = "" + locale: str = "" + date_format: str = "%d.%m.%Y, %H:%M UTC" + date_only_format: str = "%d.%m.%Y" + slots: list[TemplateSlotData] = [] + + +class CommandTemplateSlotData(BaseModel): + slot_name: str + locale: str = "en" + template: str = "" + + +class CommandTemplateConfigData(BaseModel): + id: int + provider_type: str + name: str + description: str = "" + icon: str = "" + locale: str = "" + slots: list[CommandTemplateSlotData] = [] + + +class TrackerTargetData(BaseModel): + target_id: int + tracking_config_id: int | None = None + template_config_id: int | None = None + enabled: bool = True + quiet_hours_start: str | None = None + quiet_hours_end: str | None = None + + +class NotificationTrackerData(BaseModel): + id: int + provider_id: int + name: str + icon: str = "" + collection_ids: list[str] = [] + filters: dict[str, Any] = {} + scan_interval: int = 60 + batch_duration: int = 0 + default_tracking_config_id: int | None = None + default_template_config_id: int | None = None + enabled: bool = True + targets: list[TrackerTargetData] = [] + + +class CommandTrackerListenerData(BaseModel): + listener_type: str + listener_id: int + + +class CommandTrackerData(BaseModel): + id: int + provider_id: int + command_config_id: int + name: str + icon: str = "" + enabled: bool = True + listeners: list[CommandTrackerListenerData] = [] + + +class ActionRuleData(BaseModel): + name: str = "" + rule_config: dict[str, Any] = {} + enabled: bool = True + order: int = 0 + + +class ActionData(BaseModel): + id: int + provider_id: int + name: str + icon: str = "" + action_type: str + config: dict[str, Any] = {} + schedule_type: str = "interval" + schedule_interval: int = 3600 + schedule_cron: str = "" + enabled: bool = False + rules: list[ActionRuleData] = [] + + +class ProviderData(BaseModel): + id: int + type: str + name: str + icon: str = "" + config: dict[str, Any] = {} + + +class TelegramBotData(BaseModel): + id: int + name: str + token: str = "" + icon: str = "" + bot_username: str = "" + update_mode: str = "polling" + + +class MatrixBotData(BaseModel): + id: int + name: str + icon: str = "" + homeserver_url: str = "" + access_token: str = "" + display_name: str = "" + + +class EmailBotData(BaseModel): + id: int + name: str + icon: str = "" + email: str = "" + smtp_host: str = "" + smtp_port: int = 587 + smtp_username: str = "" + smtp_password: str = "" + smtp_use_tls: bool = True + + +class TrackingConfigData(BaseModel): + id: int + provider_type: str + name: str + icon: str = "" + # All the boolean / int / str tracking fields are captured generically + fields: dict[str, Any] = {} + + +class CommandConfigData(BaseModel): + id: int + provider_type: str + name: str + icon: str = "" + enabled_commands: list[str] = [] + response_mode: str = "media" + default_count: int = 5 + rate_limits: dict[str, Any] = {} + command_template_config_id: int | None = None + + +class AppSettingData(BaseModel): + key: str + value: str = "" + + +# ---------- top-level backup envelope ---------- + +class BackupData(BaseModel): + providers: list[ProviderData] = [] + telegram_bots: list[TelegramBotData] = [] + matrix_bots: list[MatrixBotData] = [] + email_bots: list[EmailBotData] = [] + targets: list[TargetData] = [] + tracking_configs: list[TrackingConfigData] = [] + template_configs: list[TemplateConfigData] = [] + command_configs: list[CommandConfigData] = [] + command_template_configs: list[CommandTemplateConfigData] = [] + notification_trackers: list[NotificationTrackerData] = [] + command_trackers: list[CommandTrackerData] = [] + actions: list[ActionData] = [] + app_settings: list[AppSettingData] = [] + + +class BackupFile(BaseModel): + format: str = "notify-bridge-backup" + version: int = 1 + created_at: str = "" + app_version: str = "" + secrets_mode: SecretsMode = SecretsMode.EXCLUDE + categories: list[str] = [] + data: BackupData = Field(default_factory=BackupData) + + +# ---------- import result ---------- + +class ImportResult(BaseModel): + created: int = 0 + skipped: int = 0 + overwritten: int = 0 + errors: list[str] = [] + warnings: list[str] = [] + + +class ValidateResult(BaseModel): + valid: bool = True + version: int = 0 + entity_counts: dict[str, int] = {} + warnings: list[str] = [] + errors: list[str] = [] diff --git a/packages/server/src/notify_bridge_server/services/backup_service.py b/packages/server/src/notify_bridge_server/services/backup_service.py new file mode 100644 index 0000000..0db6fd9 --- /dev/null +++ b/packages/server/src/notify_bridge_server/services/backup_service.py @@ -0,0 +1,855 @@ +"""Configuration backup/restore service — export and import logic.""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from ..database.models import ( + Action, ActionRule, AppSetting, CommandConfig, CommandTemplateConfig, + CommandTemplateSlot, CommandTracker, CommandTrackerListener, EmailBot, + MatrixBot, NotificationTarget, NotificationTracker, + NotificationTrackerTarget, ServiceProvider, TargetReceiver, + TemplateConfig, TemplateSlot, TelegramBot, TrackingConfig, +) +from .backup_schema import ( + ALL_CATEGORIES, ActionData, ActionRuleData, AppSettingData, BackupCategory, + BackupData, BackupFile, CommandConfigData, CommandTemplateConfigData, + CommandTemplateSlotData, CommandTrackerData, CommandTrackerListenerData, + ConflictMode, EmailBotData, ImportResult, MatrixBotData, + NotificationTrackerData, PROVIDER_SECRET_FIELDS, ProviderData, + ReceiverData, SecretsMode, TargetData, TemplateConfigData, + TemplateSlotData, TelegramBotData, TrackerTargetData, + TrackingConfigData, ValidateResult, +) + +_LOGGER = logging.getLogger(__name__) + +# Fields to skip when serializing TrackingConfig into the generic `fields` dict +_TRACKING_SKIP = frozenset(("id", "user_id", "provider_type", "name", "icon", "created_at")) + + +# --------------------------------------------------------------------------- +# Export +# --------------------------------------------------------------------------- + +def _mask_secret(value: str) -> str: + return f"***{value[-4:]}" if len(value) > 4 else "***" + + +def _apply_secrets_provider(config: dict[str, Any], mode: SecretsMode) -> dict[str, Any]: + """Return a copy of provider config with secrets handled per mode.""" + result = dict(config) + for key in PROVIDER_SECRET_FIELDS: + if key in result and result[key]: + if mode == SecretsMode.EXCLUDE: + result[key] = "" + elif mode == SecretsMode.MASKED: + result[key] = _mask_secret(result[key]) + return result + + +def _tracking_config_fields(tc: TrackingConfig) -> dict[str, Any]: + """Extract all tracking config fields (booleans, ints, strings) as a dict.""" + data = {} + for field_name in tc.model_fields: + if field_name in _TRACKING_SKIP: + continue + data[field_name] = getattr(tc, field_name) + return data + + +async def export_backup( + session: AsyncSession, + user_id: int, + categories: list[BackupCategory] | None = None, + secrets_mode: SecretsMode = SecretsMode.EXCLUDE, +) -> BackupFile: + """Export user configuration as a BackupFile.""" + cats = set(categories or ALL_CATEGORIES) + data = BackupData() + + # -- Providers -- + if BackupCategory.PROVIDERS in cats: + result = await session.exec( + select(ServiceProvider).where(ServiceProvider.user_id == user_id) + ) + for p in result.all(): + data.providers.append(ProviderData( + id=p.id, + type=p.type, + name=p.name, + icon=p.icon, + config=_apply_secrets_provider(p.config, secrets_mode), + )) + + # -- Telegram Bots -- + if BackupCategory.TELEGRAM_BOTS in cats: + result = await session.exec( + select(TelegramBot).where(TelegramBot.user_id == user_id) + ) + for b in result.all(): + token = b.token + if secrets_mode == SecretsMode.EXCLUDE: + token = "" + elif secrets_mode == SecretsMode.MASKED: + token = _mask_secret(token) + data.telegram_bots.append(TelegramBotData( + id=b.id, name=b.name, token=token, icon=b.icon, + bot_username=b.bot_username, update_mode=b.update_mode, + )) + + # -- Matrix Bots -- + if BackupCategory.MATRIX_BOTS in cats: + result = await session.exec( + select(MatrixBot).where(MatrixBot.user_id == user_id) + ) + for b in result.all(): + access_token = b.access_token + if secrets_mode == SecretsMode.EXCLUDE: + access_token = "" + elif secrets_mode == SecretsMode.MASKED: + access_token = _mask_secret(access_token) + data.matrix_bots.append(MatrixBotData( + id=b.id, name=b.name, icon=b.icon, + homeserver_url=b.homeserver_url, access_token=access_token, + display_name=b.display_name, + )) + + # -- Email Bots -- + if BackupCategory.EMAIL_BOTS in cats: + result = await session.exec( + select(EmailBot).where(EmailBot.user_id == user_id) + ) + for b in result.all(): + smtp_password = b.smtp_password + if secrets_mode == SecretsMode.EXCLUDE: + smtp_password = "" + elif secrets_mode == SecretsMode.MASKED: + smtp_password = _mask_secret(smtp_password) if smtp_password else "" + data.email_bots.append(EmailBotData( + id=b.id, name=b.name, icon=b.icon, email=b.email, + smtp_host=b.smtp_host, smtp_port=b.smtp_port, + smtp_username=b.smtp_username, smtp_password=smtp_password, + smtp_use_tls=b.smtp_use_tls, + )) + + # -- Targets + Receivers -- + if BackupCategory.TARGETS in cats: + result = await session.exec( + select(NotificationTarget).where(NotificationTarget.user_id == user_id) + ) + for tgt in result.all(): + recv_result = await session.exec( + select(TargetReceiver).where(TargetReceiver.target_id == tgt.id) + ) + receivers = [ + ReceiverData( + name=r.name, config=r.config, receiver_key=r.receiver_key, + locale=r.locale, enabled=r.enabled, + ) + for r in recv_result.all() + ] + data.targets.append(TargetData( + id=tgt.id, type=tgt.type, name=tgt.name, icon=tgt.icon, + config=tgt.config, chat_action=tgt.chat_action, + receivers=receivers, + )) + + # -- Tracking Configs -- + if BackupCategory.TRACKING_CONFIGS in cats: + result = await session.exec( + select(TrackingConfig).where(TrackingConfig.user_id == user_id) + ) + for tc in result.all(): + data.tracking_configs.append(TrackingConfigData( + id=tc.id, provider_type=tc.provider_type, name=tc.name, + icon=tc.icon, fields=_tracking_config_fields(tc), + )) + + # -- Template Configs + Slots (user-owned only) -- + if BackupCategory.TEMPLATE_CONFIGS in cats: + result = await session.exec( + select(TemplateConfig).where(TemplateConfig.user_id == user_id) + ) + for tc in result.all(): + slot_result = await session.exec( + select(TemplateSlot).where(TemplateSlot.config_id == tc.id) + ) + slots = [ + TemplateSlotData( + slot_name=s.slot_name, locale=s.locale, template=s.template, + ) + for s in slot_result.all() + ] + data.template_configs.append(TemplateConfigData( + id=tc.id, provider_type=tc.provider_type, name=tc.name, + description=tc.description, icon=tc.icon, locale=tc.locale, + date_format=tc.date_format, date_only_format=tc.date_only_format, + slots=slots, + )) + + # -- Command Template Configs + Slots (user-owned only) -- + if BackupCategory.COMMAND_TEMPLATE_CONFIGS in cats: + result = await session.exec( + select(CommandTemplateConfig).where(CommandTemplateConfig.user_id == user_id) + ) + for ctc in result.all(): + slot_result = await session.exec( + select(CommandTemplateSlot).where(CommandTemplateSlot.config_id == ctc.id) + ) + slots = [ + CommandTemplateSlotData( + slot_name=s.slot_name, locale=s.locale, template=s.template, + ) + for s in slot_result.all() + ] + data.command_template_configs.append(CommandTemplateConfigData( + id=ctc.id, provider_type=ctc.provider_type, name=ctc.name, + description=ctc.description, icon=ctc.icon, locale=ctc.locale, + slots=slots, + )) + + # -- Command Configs -- + if BackupCategory.COMMAND_CONFIGS in cats: + result = await session.exec( + select(CommandConfig).where(CommandConfig.user_id == user_id) + ) + for cc in result.all(): + data.command_configs.append(CommandConfigData( + id=cc.id, provider_type=cc.provider_type, name=cc.name, + icon=cc.icon, enabled_commands=cc.enabled_commands, + response_mode=cc.response_mode, default_count=cc.default_count, + rate_limits=cc.rate_limits, + command_template_config_id=cc.command_template_config_id, + )) + + # -- Notification Trackers + Tracker-Targets -- + if BackupCategory.NOTIFICATION_TRACKERS in cats: + result = await session.exec( + select(NotificationTracker).where(NotificationTracker.user_id == user_id) + ) + for nt in result.all(): + tt_result = await session.exec( + select(NotificationTrackerTarget).where( + NotificationTrackerTarget.tracker_id == nt.id + ) + ) + targets = [ + TrackerTargetData( + target_id=tt.target_id, + tracking_config_id=tt.tracking_config_id, + template_config_id=tt.template_config_id, + enabled=tt.enabled, + quiet_hours_start=tt.quiet_hours_start, + quiet_hours_end=tt.quiet_hours_end, + ) + for tt in tt_result.all() + ] + data.notification_trackers.append(NotificationTrackerData( + id=nt.id, provider_id=nt.provider_id, name=nt.name, + icon=nt.icon, collection_ids=nt.collection_ids, + filters=nt.filters, scan_interval=nt.scan_interval, + batch_duration=nt.batch_duration, + default_tracking_config_id=nt.default_tracking_config_id, + default_template_config_id=nt.default_template_config_id, + enabled=nt.enabled, targets=targets, + )) + + # -- Command Trackers + Listeners -- + if BackupCategory.COMMAND_TRACKERS in cats: + result = await session.exec( + select(CommandTracker).where(CommandTracker.user_id == user_id) + ) + for ct in result.all(): + lis_result = await session.exec( + select(CommandTrackerListener).where( + CommandTrackerListener.command_tracker_id == ct.id + ) + ) + listeners = [ + CommandTrackerListenerData( + listener_type=l.listener_type, listener_id=l.listener_id, + ) + for l in lis_result.all() + ] + data.command_trackers.append(CommandTrackerData( + id=ct.id, provider_id=ct.provider_id, + command_config_id=ct.command_config_id, name=ct.name, + icon=ct.icon, enabled=ct.enabled, listeners=listeners, + )) + + # -- Actions + Rules -- + if BackupCategory.ACTIONS in cats: + result = await session.exec( + select(Action).where(Action.user_id == user_id) + ) + for a in result.all(): + rule_result = await session.exec( + select(ActionRule).where(ActionRule.action_id == a.id) + ) + rules = [ + ActionRuleData( + name=r.name, rule_config=r.rule_config, + enabled=r.enabled, order=r.order, + ) + for r in rule_result.all() + ] + data.actions.append(ActionData( + id=a.id, provider_id=a.provider_id, name=a.name, + icon=a.icon, action_type=a.action_type, config=a.config, + schedule_type=a.schedule_type, + schedule_interval=a.schedule_interval, + schedule_cron=a.schedule_cron, enabled=a.enabled, + rules=rules, + )) + + # -- App Settings -- + if BackupCategory.APP_SETTINGS in cats: + result = await session.exec(select(AppSetting)) + for s in result.all(): + value = s.value + if s.key == "telegram_webhook_secret" and value: + if secrets_mode == SecretsMode.EXCLUDE: + value = "" + elif secrets_mode == SecretsMode.MASKED: + value = _mask_secret(value) + data.app_settings.append(AppSettingData(key=s.key, value=value)) + + return BackupFile( + format="notify-bridge-backup", + version=1, + created_at=datetime.now(timezone.utc).isoformat(), + app_version="1.0.0", + secrets_mode=secrets_mode, + categories=[c.value for c in cats], + data=data, + ) + + +# --------------------------------------------------------------------------- +# Export to file (for scheduled backups) +# --------------------------------------------------------------------------- + +async def export_backup_to_file( + session: AsyncSession, + user_id: int, + backup_dir: Path, + secrets_mode: SecretsMode = SecretsMode.EXCLUDE, +) -> Path: + """Export backup and write to a file in backup_dir. Returns the file path.""" + backup_dir.mkdir(parents=True, exist_ok=True) + backup = await export_backup(session, user_id, secrets_mode=secrets_mode) + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S") + filename = f"backup-{ts}.json" + filepath = backup_dir / filename + filepath.write_text( + json.dumps(backup.model_dump(), indent=2, ensure_ascii=False), + encoding="utf-8", + ) + _LOGGER.info("Scheduled backup saved: %s", filepath) + return filepath + + +def cleanup_old_backups(backup_dir: Path, keep: int = 5) -> list[str]: + """Delete oldest backup files exceeding `keep` count. Returns deleted filenames.""" + if not backup_dir.is_dir(): + return [] + files = sorted(backup_dir.glob("backup-*.json"), key=lambda f: f.name, reverse=True) + deleted = [] + for old in files[keep:]: + old.unlink() + deleted.append(old.name) + if deleted: + _LOGGER.info("Cleaned up %d old backup(s): %s", len(deleted), deleted) + return deleted + + +def list_backup_files(backup_dir: Path) -> list[dict[str, Any]]: + """List backup files in the directory with metadata.""" + if not backup_dir.is_dir(): + return [] + files = sorted(backup_dir.glob("backup-*.json"), key=lambda f: f.name, reverse=True) + result = [] + for f in files: + stat = f.stat() + result.append({ + "filename": f.name, + "size": stat.st_size, + "created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + }) + return result + + +# --------------------------------------------------------------------------- +# Validate +# --------------------------------------------------------------------------- + +def validate_backup(raw: dict[str, Any]) -> ValidateResult: + """Validate a backup file dict without importing. Returns summary.""" + warnings: list[str] = [] + errors: list[str] = [] + + fmt = raw.get("format") + if fmt != "notify-bridge-backup": + errors.append(f"Unknown format: {fmt}") + return ValidateResult(valid=False, errors=errors) + + version = raw.get("version", 0) + if version > 1: + errors.append(f"Unsupported backup version: {version} (max supported: 1)") + return ValidateResult(valid=False, version=version, errors=errors) + + secrets_mode = raw.get("secrets_mode", "exclude") + if secrets_mode in ("exclude", "masked"): + warnings.append( + f"Backup was exported with secrets_mode={secrets_mode}. " + "Imported entities will have empty/placeholder secrets that need manual update." + ) + + try: + backup = BackupFile.model_validate(raw) + except Exception as e: + errors.append(f"Schema validation failed: {e}") + return ValidateResult(valid=False, version=version, errors=errors) + + counts: dict[str, int] = {} + d = backup.data + for cat in ("providers", "telegram_bots", "matrix_bots", "email_bots", + "targets", "tracking_configs", "template_configs", + "command_configs", "command_template_configs", + "notification_trackers", "command_trackers", "actions", + "app_settings"): + items = getattr(d, cat, []) + if items: + counts[cat] = len(items) + + return ValidateResult( + valid=True, version=version, + entity_counts=counts, warnings=warnings, errors=errors, + ) + + +# --------------------------------------------------------------------------- +# Import +# --------------------------------------------------------------------------- + +async def import_backup( + session: AsyncSession, + user_id: int, + backup: BackupFile, + conflict_mode: ConflictMode = ConflictMode.SKIP, +) -> ImportResult: + """Import a backup file into the database. Atomic — rolls back on error.""" + result = ImportResult() + # Maps: category -> {old_id: new_id} + id_map: dict[str, dict[int, int]] = {} + d = backup.data + + try: + # 1. App Settings (simple upsert) + for s in d.app_settings: + existing = await session.get(AppSetting, s.key) + if existing: + if conflict_mode == ConflictMode.SKIP: + result.skipped += 1 + continue + elif conflict_mode == ConflictMode.OVERWRITE: + existing.value = s.value + session.add(existing) + result.overwritten += 1 + else: # rename — not applicable for settings, just skip + result.skipped += 1 + continue + else: + session.add(AppSetting(key=s.key, value=s.value)) + result.created += 1 + + # 2. Telegram Bots + id_map["telegram_bots"] = {} + for b in d.telegram_bots: + name = await _resolve_name( + session, TelegramBot, b.name, user_id, conflict_mode, result, + ) + if name is None: + continue + new_bot = TelegramBot( + user_id=user_id, name=name, token=b.token, icon=b.icon, + bot_username=b.bot_username, update_mode=b.update_mode, + ) + session.add(new_bot) + await session.flush() + id_map["telegram_bots"][b.id] = new_bot.id + + # 3. Matrix Bots + id_map["matrix_bots"] = {} + for b in d.matrix_bots: + name = await _resolve_name( + session, MatrixBot, b.name, user_id, conflict_mode, result, + ) + if name is None: + continue + new_bot = MatrixBot( + user_id=user_id, name=name, icon=b.icon, + homeserver_url=b.homeserver_url, access_token=b.access_token, + display_name=b.display_name, + ) + session.add(new_bot) + await session.flush() + id_map["matrix_bots"][b.id] = new_bot.id + + # 4. Email Bots + id_map["email_bots"] = {} + for b in d.email_bots: + name = await _resolve_name( + session, EmailBot, b.name, user_id, conflict_mode, result, + ) + if name is None: + continue + new_bot = EmailBot( + user_id=user_id, name=name, icon=b.icon, email=b.email, + smtp_host=b.smtp_host, smtp_port=b.smtp_port, + smtp_username=b.smtp_username, smtp_password=b.smtp_password, + smtp_use_tls=b.smtp_use_tls, + ) + session.add(new_bot) + await session.flush() + id_map["email_bots"][b.id] = new_bot.id + + # 5. Providers + id_map["providers"] = {} + for p in d.providers: + name = await _resolve_name( + session, ServiceProvider, p.name, user_id, conflict_mode, result, + ) + if name is None: + continue + new_p = ServiceProvider( + user_id=user_id, type=p.type, name=name, + icon=p.icon, config=p.config, + ) + session.add(new_p) + await session.flush() + id_map["providers"][p.id] = new_p.id + + # 6. Tracking Configs + id_map["tracking_configs"] = {} + for tc in d.tracking_configs: + name = await _resolve_name( + session, TrackingConfig, tc.name, user_id, conflict_mode, result, + ) + if name is None: + continue + new_tc = TrackingConfig( + user_id=user_id, provider_type=tc.provider_type, + name=name, icon=tc.icon, + ) + # Apply all tracked fields + for field_name, value in tc.fields.items(): + if hasattr(new_tc, field_name): + setattr(new_tc, field_name, value) + session.add(new_tc) + await session.flush() + id_map["tracking_configs"][tc.id] = new_tc.id + + # 7. Template Configs + Slots + id_map["template_configs"] = {} + for tc in d.template_configs: + name = await _resolve_name_template( + session, TemplateConfig, tc.name, user_id, conflict_mode, result, + ) + if name is None: + continue + new_tc = TemplateConfig( + user_id=user_id, provider_type=tc.provider_type, + name=name, description=tc.description, icon=tc.icon, + locale=tc.locale, date_format=tc.date_format, + date_only_format=tc.date_only_format, + ) + session.add(new_tc) + await session.flush() + id_map["template_configs"][tc.id] = new_tc.id + for s in tc.slots: + session.add(TemplateSlot( + config_id=new_tc.id, slot_name=s.slot_name, + locale=s.locale, template=s.template, + )) + result.created += len(tc.slots) + + # 8. Command Template Configs + Slots + id_map["command_template_configs"] = {} + for ctc in d.command_template_configs: + name = await _resolve_name_template( + session, CommandTemplateConfig, ctc.name, user_id, conflict_mode, result, + ) + if name is None: + continue + new_ctc = CommandTemplateConfig( + user_id=user_id, provider_type=ctc.provider_type, + name=name, description=ctc.description, icon=ctc.icon, + locale=ctc.locale, + ) + session.add(new_ctc) + await session.flush() + id_map["command_template_configs"][ctc.id] = new_ctc.id + for s in ctc.slots: + session.add(CommandTemplateSlot( + config_id=new_ctc.id, slot_name=s.slot_name, + locale=s.locale, template=s.template, + )) + result.created += len(ctc.slots) + + # 9. Command Configs + id_map["command_configs"] = {} + for cc in d.command_configs: + name = await _resolve_name( + session, CommandConfig, cc.name, user_id, conflict_mode, result, + ) + if name is None: + continue + ctc_id = _map_id(id_map, "command_template_configs", cc.command_template_config_id) + new_cc = CommandConfig( + user_id=user_id, provider_type=cc.provider_type, + name=name, icon=cc.icon, + enabled_commands=cc.enabled_commands, + response_mode=cc.response_mode, + default_count=cc.default_count, + rate_limits=cc.rate_limits, + command_template_config_id=ctc_id, + ) + session.add(new_cc) + await session.flush() + id_map["command_configs"][cc.id] = new_cc.id + + # 10. Targets + Receivers + id_map["targets"] = {} + for tgt in d.targets: + name = await _resolve_name( + session, NotificationTarget, tgt.name, user_id, conflict_mode, result, + ) + if name is None: + continue + new_tgt = NotificationTarget( + user_id=user_id, type=tgt.type, name=name, + icon=tgt.icon, config=tgt.config, + chat_action=tgt.chat_action, + ) + session.add(new_tgt) + await session.flush() + id_map["targets"][tgt.id] = new_tgt.id + for r in tgt.receivers: + session.add(TargetReceiver( + target_id=new_tgt.id, name=r.name, config=r.config, + receiver_key=r.receiver_key, locale=r.locale, + enabled=r.enabled, + )) + result.created += len(tgt.receivers) + + # 11. Notification Trackers + Tracker-Targets + for nt in d.notification_trackers: + provider_id = _map_id(id_map, "providers", nt.provider_id) + if provider_id is None: + result.warnings.append( + f"Skipped tracker '{nt.name}': provider {nt.provider_id} not found" + ) + result.skipped += 1 + continue + name = await _resolve_name( + session, NotificationTracker, nt.name, user_id, conflict_mode, result, + ) + if name is None: + continue + new_nt = NotificationTracker( + user_id=user_id, provider_id=provider_id, + name=name, icon=nt.icon, collection_ids=nt.collection_ids, + filters=nt.filters, scan_interval=nt.scan_interval, + batch_duration=nt.batch_duration, + default_tracking_config_id=_map_id(id_map, "tracking_configs", nt.default_tracking_config_id), + default_template_config_id=_map_id(id_map, "template_configs", nt.default_template_config_id), + enabled=nt.enabled, + ) + session.add(new_nt) + await session.flush() + for tt in nt.targets: + target_id = _map_id(id_map, "targets", tt.target_id) + if target_id is None: + result.warnings.append( + f"Skipped tracker-target link in '{nt.name}': target {tt.target_id} not found" + ) + continue + session.add(NotificationTrackerTarget( + tracker_id=new_nt.id, + target_id=target_id, + tracking_config_id=_map_id(id_map, "tracking_configs", tt.tracking_config_id), + template_config_id=_map_id(id_map, "template_configs", tt.template_config_id), + enabled=tt.enabled, + quiet_hours_start=tt.quiet_hours_start, + quiet_hours_end=tt.quiet_hours_end, + )) + result.created += 1 + + # 12. Command Trackers + Listeners + for ct in d.command_trackers: + provider_id = _map_id(id_map, "providers", ct.provider_id) + if provider_id is None: + result.warnings.append( + f"Skipped command tracker '{ct.name}': provider {ct.provider_id} not found" + ) + result.skipped += 1 + continue + cc_id = _map_id(id_map, "command_configs", ct.command_config_id) + if cc_id is None: + result.warnings.append( + f"Skipped command tracker '{ct.name}': command config {ct.command_config_id} not found" + ) + result.skipped += 1 + continue + name = await _resolve_name( + session, CommandTracker, ct.name, user_id, conflict_mode, result, + ) + if name is None: + continue + new_ct = CommandTracker( + user_id=user_id, provider_id=provider_id, + command_config_id=cc_id, name=name, icon=ct.icon, + enabled=ct.enabled, + ) + session.add(new_ct) + await session.flush() + for lis in ct.listeners: + # Map listener_id based on listener_type + mapped_listener_id = lis.listener_id + if lis.listener_type == "telegram_bot": + mapped_listener_id = _map_id(id_map, "telegram_bots", lis.listener_id) or lis.listener_id + session.add(CommandTrackerListener( + command_tracker_id=new_ct.id, + listener_type=lis.listener_type, + listener_id=mapped_listener_id, + )) + result.created += 1 + + # 13. Actions + Rules + for a in d.actions: + provider_id = _map_id(id_map, "providers", a.provider_id) + if provider_id is None: + result.warnings.append( + f"Skipped action '{a.name}': provider {a.provider_id} not found" + ) + result.skipped += 1 + continue + name = await _resolve_name( + session, Action, a.name, user_id, conflict_mode, result, + ) + if name is None: + continue + new_a = Action( + user_id=user_id, provider_id=provider_id, name=name, + icon=a.icon, action_type=a.action_type, config=a.config, + schedule_type=a.schedule_type, + schedule_interval=a.schedule_interval, + schedule_cron=a.schedule_cron, enabled=False, # always import disabled + ) + session.add(new_a) + await session.flush() + for r in a.rules: + session.add(ActionRule( + action_id=new_a.id, name=r.name, + rule_config=r.rule_config, enabled=r.enabled, + order=r.order, + )) + result.created += len(a.rules) + + await session.commit() + except Exception as e: + await session.rollback() + _LOGGER.error("Backup import failed: %s", e) + result.errors.append(f"Import failed: {e}") + + return result + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _map_id( + id_map: dict[str, dict[int, int]], + category: str, + old_id: int | None, +) -> int | None: + """Resolve an old ID to a new ID via the id_map. Returns None if not found.""" + if old_id is None: + return None + return id_map.get(category, {}).get(old_id) + + +async def _resolve_name( + session: AsyncSession, + model: type, + name: str, + user_id: int, + conflict_mode: ConflictMode, + result: ImportResult, +) -> str | None: + """Check for name conflict and return the resolved name, or None to skip.""" + existing = await session.exec( + select(model).where( + model.name == name, + model.user_id == user_id, + ) + ) + found = existing.first() + if found is None: + result.created += 1 + return name + + if conflict_mode == ConflictMode.SKIP: + result.skipped += 1 + return None + elif conflict_mode == ConflictMode.RENAME: + result.created += 1 + return f"{name} (imported)" + else: # OVERWRITE — delete existing, create new + await session.delete(found) + await session.flush() + result.overwritten += 1 + return name + + +async def _resolve_name_template( + session: AsyncSession, + model: type, + name: str, + user_id: int, + conflict_mode: ConflictMode, + result: ImportResult, +) -> str | None: + """Like _resolve_name but for template models where user_id can be 0 for system.""" + existing = await session.exec( + select(model).where( + model.name == name, + model.user_id == user_id, + ) + ) + found = existing.first() + if found is None: + result.created += 1 + return name + + if conflict_mode == ConflictMode.SKIP: + result.skipped += 1 + return None + elif conflict_mode == ConflictMode.RENAME: + result.created += 1 + return f"{name} (imported)" + else: + await session.delete(found) + await session.flush() + result.overwritten += 1 + return name diff --git a/packages/server/src/notify_bridge_server/services/scheduler.py b/packages/server/src/notify_bridge_server/services/scheduler.py index 876a1e0..afcd695 100644 --- a/packages/server/src/notify_bridge_server/services/scheduler.py +++ b/packages/server/src/notify_bridge_server/services/scheduler.py @@ -38,6 +38,9 @@ async def start_scheduler() -> None: from .command_sync import start_sync_scheduler start_sync_scheduler() + # Load scheduled backup job if enabled + await _load_backup_job() + def _schedule_event_cleanup() -> None: """Schedule a daily job to delete EventLog entries older than 90 days.""" @@ -321,3 +324,103 @@ async def _run_action(action_id: int) -> None: await run_action(action_id, trigger="scheduled") except Exception as e: _LOGGER.error("Error running action %d: %s", action_id, e) + + +# --------------------------------------------------------------------------- +# Scheduled backup +# --------------------------------------------------------------------------- + +_BACKUP_JOB_ID = "scheduled_backup" + + +async def _load_backup_job() -> None: + """Load scheduled backup job from settings if enabled.""" + from sqlmodel import select + from sqlmodel.ext.asyncio.session import AsyncSession as _AS + from ..database.engine import get_engine + from ..database.models import AppSetting + + engine = get_engine() + async with _AS(engine) as session: + enabled_row = await session.get(AppSetting, "backup_scheduled_enabled") + interval_row = await session.get(AppSetting, "backup_scheduled_interval_hours") + + enabled = enabled_row and enabled_row.value == "true" + if not enabled: + return + + interval_hours = int(interval_row.value) if interval_row and interval_row.value else 24 + scheduler = get_scheduler() + scheduler.add_job( + _run_scheduled_backup, + "interval", + hours=interval_hours, + id=_BACKUP_JOB_ID, + replace_existing=True, + max_instances=1, + ) + _LOGGER.info("Scheduled backup every %dh", interval_hours) + + +async def schedule_backup(interval_hours: int = 24) -> None: + """Add or update the scheduled backup job.""" + scheduler = get_scheduler() + if scheduler.get_job(_BACKUP_JOB_ID): + scheduler.remove_job(_BACKUP_JOB_ID) + + scheduler.add_job( + _run_scheduled_backup, + "interval", + hours=interval_hours, + id=_BACKUP_JOB_ID, + replace_existing=True, + max_instances=1, + ) + _LOGGER.info("Scheduled backup every %dh", interval_hours) + + +async def unschedule_backup() -> None: + """Remove the scheduled backup job.""" + scheduler = get_scheduler() + if scheduler.get_job(_BACKUP_JOB_ID): + scheduler.remove_job(_BACKUP_JOB_ID) + _LOGGER.info("Unscheduled backup job") + + +async def _run_scheduled_backup() -> None: + """Run a scheduled backup (called by APScheduler).""" + from sqlmodel.ext.asyncio.session import AsyncSession as _AS + from ..database.engine import get_engine + from ..database.models import AppSetting, User + from ..config import settings as app_config + from .backup_schema import SecretsMode + from .backup_service import export_backup_to_file, cleanup_old_backups + + try: + engine = get_engine() + async with _AS(engine) as session: + # Read settings + secrets_row = await session.get(AppSetting, "backup_secrets_mode") + retention_row = await session.get(AppSetting, "backup_retention_count") + + secrets_mode = SecretsMode(secrets_row.value) if secrets_row and secrets_row.value else SecretsMode.EXCLUDE + retention = int(retention_row.value) if retention_row and retention_row.value else 5 + + # Find admin user (first admin) for ownership context + from sqlmodel import select + admin_result = await session.exec( + select(User).where(User.role == "admin") + ) + admin = admin_result.first() + if not admin: + _LOGGER.warning("No admin user found, skipping scheduled backup") + return + + backup_dir = app_config.data_dir / "backups" + await export_backup_to_file(session, admin.id, backup_dir, secrets_mode) + + # Cleanup outside the session + cleanup_old_backups(backup_dir, keep=retention) + + except Exception as e: + _LOGGER.error("Scheduled backup failed: %s", e)