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}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {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)