feat: person excludes for auto-organize rules, backup & restore system
Add person exclude criteria to Immich auto-organize — assets containing excluded persons are filtered out after candidate gathering. Also adds full backup/restore system with export, import, scheduled backups, and retention management.
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
"cmdTemplateConfigs": "Cmd Templates",
|
"cmdTemplateConfigs": "Cmd Templates",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
"backup": "Backup",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"notification": "Notification",
|
"notification": "Notification",
|
||||||
"commands": "Commands",
|
"commands": "Commands",
|
||||||
@@ -1025,6 +1026,8 @@
|
|||||||
"criteria": "Criteria",
|
"criteria": "Criteria",
|
||||||
"persons": "Persons",
|
"persons": "Persons",
|
||||||
"addPerson": "Add person...",
|
"addPerson": "Add person...",
|
||||||
|
"excludePersons": "Exclude persons",
|
||||||
|
"addExcludePerson": "Add person to exclude...",
|
||||||
"searchQuery": "Smart Search Query",
|
"searchQuery": "Smart Search Query",
|
||||||
"searchQueryPlaceholder": "e.g. sunset, beach, birthday...",
|
"searchQueryPlaceholder": "e.g. sunset, beach, birthday...",
|
||||||
"assetType": "Asset type",
|
"assetType": "Asset type",
|
||||||
@@ -1053,5 +1056,67 @@
|
|||||||
"triggerManual": "manual",
|
"triggerManual": "manual",
|
||||||
"triggerDryRun": "dry-run",
|
"triggerDryRun": "dry-run",
|
||||||
"triggerScheduled": "scheduled"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"cmdTemplateConfigs": "Шаблоны команд",
|
"cmdTemplateConfigs": "Шаблоны команд",
|
||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
|
"backup": "Бэкап",
|
||||||
"logout": "Выход",
|
"logout": "Выход",
|
||||||
"notification": "Уведомления",
|
"notification": "Уведомления",
|
||||||
"commands": "Команды",
|
"commands": "Команды",
|
||||||
@@ -1025,6 +1026,8 @@
|
|||||||
"criteria": "Критерии",
|
"criteria": "Критерии",
|
||||||
"persons": "Люди",
|
"persons": "Люди",
|
||||||
"addPerson": "Добавить человека...",
|
"addPerson": "Добавить человека...",
|
||||||
|
"excludePersons": "Исключить людей",
|
||||||
|
"addExcludePerson": "Добавить человека для исключения...",
|
||||||
"searchQuery": "Умный поиск",
|
"searchQuery": "Умный поиск",
|
||||||
"searchQueryPlaceholder": "напр. закат, пляж, день рождения...",
|
"searchQueryPlaceholder": "напр. закат, пляж, день рождения...",
|
||||||
"assetType": "Тип файла",
|
"assetType": "Тип файла",
|
||||||
@@ -1053,5 +1056,67 @@
|
|||||||
"triggerManual": "вручную",
|
"triggerManual": "вручную",
|
||||||
"triggerDryRun": "пробный",
|
"triggerDryRun": "пробный",
|
||||||
"triggerScheduled": "по расписанию"
|
"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": "Файл бэкапа удалён"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,6 +193,7 @@
|
|||||||
key: 'nav.settings', icon: 'mdiCogOutline',
|
key: 'nav.settings', icon: 'mdiCogOutline',
|
||||||
children: [
|
children: [
|
||||||
{ href: '/settings', key: 'nav.common', icon: 'mdiCogOutline' },
|
{ href: '/settings', key: 'nav.common', icon: 'mdiCogOutline' },
|
||||||
|
{ href: '/settings/backup', key: 'nav.backup', icon: 'mdiBackupRestore' },
|
||||||
{ href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' },
|
{ href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -236,6 +237,7 @@
|
|||||||
{ href: '/command-template-configs', key: 'nav.templates', icon: 'mdiCodeBracesBox' },
|
{ href: '/command-template-configs', key: 'nav.templates', icon: 'mdiCodeBracesBox' },
|
||||||
...(auth.isAdmin ? [
|
...(auth.isAdmin ? [
|
||||||
{ href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' },
|
{ href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' },
|
||||||
|
{ href: '/settings/backup', key: 'nav.backup', icon: 'mdiBackupRestore' },
|
||||||
{ href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' },
|
{ href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' },
|
||||||
] : []),
|
] : []),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
let newRule = $state({
|
let newRule = $state({
|
||||||
name: '',
|
name: '',
|
||||||
rule_config: {
|
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_ids: [] as string[], target_album_names: [] as string[],
|
||||||
target_album_id: '', target_album_name: '',
|
target_album_id: '', target_album_name: '',
|
||||||
create_album_if_missing: false, create_album_name: '',
|
create_album_if_missing: false, create_album_name: '',
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
newRule = {
|
newRule = {
|
||||||
name: '',
|
name: '',
|
||||||
rule_config: {
|
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_ids: [] as string[], target_album_names: [] as string[],
|
||||||
target_album_id: '', target_album_name: '',
|
target_album_id: '', target_album_name: '',
|
||||||
create_album_if_missing: false, create_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.person_names = ids.map(id => people.find(p => p.id === id)?.name || id);
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Person excludes -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium mb-1">{t('actions.excludePersons')}</label>
|
||||||
|
<MultiEntitySelect items={personItems}
|
||||||
|
bind:values={ruleConfig.criteria.exclude_person_ids}
|
||||||
|
placeholder={t('actions.addExcludePerson')}
|
||||||
|
size="sm"
|
||||||
|
onchange={(ids) => {
|
||||||
|
ruleConfig.criteria.exclude_person_names = ids.map(id => people.find(p => p.id === id)?.name || id);
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Smart search query -->
|
<!-- Smart search query -->
|
||||||
|
|||||||
@@ -0,0 +1,570 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
|
import Card from '$lib/components/Card.svelte';
|
||||||
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Hint from '$lib/components/Hint.svelte';
|
||||||
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
|
|
||||||
|
// --- Export state ---
|
||||||
|
let exportSecrets = $state('exclude');
|
||||||
|
let exporting = $state(false);
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ key: 'providers', label: 'backup.catProviders' },
|
||||||
|
{ key: 'telegram_bots', label: 'backup.catTelegramBots' },
|
||||||
|
{ key: 'matrix_bots', label: 'backup.catMatrixBots' },
|
||||||
|
{ key: 'email_bots', label: 'backup.catEmailBots' },
|
||||||
|
{ key: 'targets', label: 'backup.catTargets' },
|
||||||
|
{ key: 'tracking_configs', label: 'backup.catTrackingConfigs' },
|
||||||
|
{ key: 'template_configs', label: 'backup.catTemplateConfigs' },
|
||||||
|
{ key: 'command_configs', label: 'backup.catCommandConfigs' },
|
||||||
|
{ key: 'command_template_configs', label: 'backup.catCommandTemplateConfigs' },
|
||||||
|
{ key: 'notification_trackers', label: 'backup.catNotificationTrackers' },
|
||||||
|
{ key: 'command_trackers', label: 'backup.catCommandTrackers' },
|
||||||
|
{ key: 'actions', label: 'backup.catActions' },
|
||||||
|
{ key: 'app_settings', label: 'backup.catAppSettings' },
|
||||||
|
];
|
||||||
|
let selectedCategories = $state<Record<string, boolean>>(
|
||||||
|
Object.fromEntries(categories.map(c => [c.key, true]))
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Import state ---
|
||||||
|
let importFile: File | null = $state(null);
|
||||||
|
let importConflict = $state('skip');
|
||||||
|
let importing = $state(false);
|
||||||
|
let validating = $state(false);
|
||||||
|
let validationResult: any = $state(null);
|
||||||
|
let importResult: any = $state(null);
|
||||||
|
let confirmImportOpen = $state(false);
|
||||||
|
let confirmExportOpen = $state(false);
|
||||||
|
|
||||||
|
// --- Scheduled backup state ---
|
||||||
|
let loaded = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let scheduledSettings = $state({
|
||||||
|
backup_scheduled_enabled: 'false',
|
||||||
|
backup_scheduled_interval_hours: '24',
|
||||||
|
backup_secrets_mode: 'exclude',
|
||||||
|
backup_retention_count: '5',
|
||||||
|
});
|
||||||
|
let savingSchedule = $state(false);
|
||||||
|
|
||||||
|
// --- Backup files ---
|
||||||
|
let backupFiles = $state<any[]>([]);
|
||||||
|
let loadingFiles = $state(false);
|
||||||
|
let confirmDeleteFile = $state('');
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const [settings, files] = await Promise.all([
|
||||||
|
api('/backup/scheduled'),
|
||||||
|
api('/backup/files'),
|
||||||
|
]);
|
||||||
|
scheduledSettings = settings;
|
||||||
|
backupFiles = files;
|
||||||
|
} catch (err: any) {
|
||||||
|
error = err.message;
|
||||||
|
snackError(err.message);
|
||||||
|
} finally {
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Export ---
|
||||||
|
async function doExport() {
|
||||||
|
if (exportSecrets === 'include') {
|
||||||
|
confirmExportOpen = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await performExport();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performExport() {
|
||||||
|
confirmExportOpen = false;
|
||||||
|
exporting = true;
|
||||||
|
try {
|
||||||
|
const cats = Object.entries(selectedCategories)
|
||||||
|
.filter(([_, v]) => v)
|
||||||
|
.map(([k]) => k)
|
||||||
|
.join(',');
|
||||||
|
const data = await api(`/backup/export?secrets_mode=${exportSecrets}&categories=${cats}`);
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||||
|
a.download = `notify-bridge-backup-${ts}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
snackSuccess(t('backup.exportSuccess'));
|
||||||
|
} catch (err: any) {
|
||||||
|
snackError(err.message);
|
||||||
|
} finally {
|
||||||
|
exporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Validate ---
|
||||||
|
async function validateFile() {
|
||||||
|
if (!importFile) return;
|
||||||
|
validating = true;
|
||||||
|
validationResult = null;
|
||||||
|
importResult = null;
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', importFile);
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
const res = await fetch('/api/backup/validate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||||
|
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
validationResult = await res.json();
|
||||||
|
} catch (err: any) {
|
||||||
|
snackError(err.message);
|
||||||
|
} finally {
|
||||||
|
validating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Import ---
|
||||||
|
async function doImport() {
|
||||||
|
confirmImportOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performImport() {
|
||||||
|
confirmImportOpen = false;
|
||||||
|
if (!importFile) return;
|
||||||
|
importing = true;
|
||||||
|
importResult = null;
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', importFile);
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
const res = await fetch(`/api/backup/import?conflict_mode=${importConflict}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||||
|
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
importResult = await res.json();
|
||||||
|
snackSuccess(t('backup.importSuccess'));
|
||||||
|
} catch (err: any) {
|
||||||
|
snackError(err.message);
|
||||||
|
} finally {
|
||||||
|
importing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scheduled settings ---
|
||||||
|
async function saveSchedule() {
|
||||||
|
savingSchedule = true;
|
||||||
|
try {
|
||||||
|
scheduledSettings = await api('/backup/scheduled', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(scheduledSettings),
|
||||||
|
});
|
||||||
|
snackSuccess(t('backup.scheduleSaved'));
|
||||||
|
} catch (err: any) {
|
||||||
|
snackError(err.message);
|
||||||
|
} finally {
|
||||||
|
savingSchedule = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- File management ---
|
||||||
|
async function refreshFiles() {
|
||||||
|
loadingFiles = true;
|
||||||
|
try {
|
||||||
|
backupFiles = await api('/backup/files');
|
||||||
|
} catch (err: any) {
|
||||||
|
snackError(err.message);
|
||||||
|
} finally {
|
||||||
|
loadingFiles = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(filename: string) {
|
||||||
|
try {
|
||||||
|
const data = await api(`/backup/files/${filename}`);
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err: any) {
|
||||||
|
snackError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFile(filename: string) {
|
||||||
|
try {
|
||||||
|
await api(`/backup/files/${filename}`, { method: 'DELETE' });
|
||||||
|
snackSuccess(t('backup.fileDeleted'));
|
||||||
|
confirmDeleteFile = '';
|
||||||
|
await refreshFiles();
|
||||||
|
} catch (err: any) {
|
||||||
|
snackError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
if (input.files?.length) {
|
||||||
|
importFile = input.files[0];
|
||||||
|
validationResult = null;
|
||||||
|
importResult = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let allSelected = $derived(Object.values(selectedCategories).every(v => v));
|
||||||
|
let noneSelected = $derived(Object.values(selectedCategories).every(v => !v));
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
const newVal = !allSelected;
|
||||||
|
for (const key of Object.keys(selectedCategories)) {
|
||||||
|
selectedCategories[key] = newVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader title={t('backup.title')} description={t('backup.description')} />
|
||||||
|
|
||||||
|
{#if !loaded}
|
||||||
|
<Loading />
|
||||||
|
{:else}
|
||||||
|
<ErrorBanner message={error} />
|
||||||
|
<div class="space-y-6">
|
||||||
|
|
||||||
|
<!-- Export Section -->
|
||||||
|
<Card>
|
||||||
|
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<MdiIcon name="mdiDatabaseExport" size={18} />
|
||||||
|
{t('backup.export')}
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.exportDescription')}</p>
|
||||||
|
|
||||||
|
<!-- Categories -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<label class="text-xs font-medium">{t('backup.categories')}</label>
|
||||||
|
<button class="text-xs underline" style="color: var(--color-primary);" onclick={toggleAll}>
|
||||||
|
{allSelected ? t('backup.deselectAll') : t('backup.selectAll')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
|
||||||
|
{#each categories as cat}
|
||||||
|
<label class="flex items-center gap-1.5 text-xs">
|
||||||
|
<input type="checkbox" bind:checked={selectedCategories[cat.key]} />
|
||||||
|
{t(cat.label)}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Secrets mode -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs font-medium mb-2">{t('backup.secretsMode')}</label>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="flex items-center gap-1.5 text-xs">
|
||||||
|
<input type="radio" bind:group={exportSecrets} value="exclude" />
|
||||||
|
{t('backup.secretsExclude')}
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1.5 text-xs">
|
||||||
|
<input type="radio" bind:group={exportSecrets} value="masked" />
|
||||||
|
{t('backup.secretsMasked')}
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1.5 text-xs">
|
||||||
|
<input type="radio" bind:group={exportSecrets} value="include" />
|
||||||
|
{t('backup.secretsInclude')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{#if exportSecrets === 'include'}
|
||||||
|
<div class="mt-2 p-2 rounded-md text-xs flex items-center gap-2"
|
||||||
|
style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
||||||
|
<MdiIcon name="mdiAlert" size={14} />
|
||||||
|
{t('backup.secretsWarningExport')}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onclick={doExport} disabled={exporting || noneSelected}>
|
||||||
|
{#if exporting}
|
||||||
|
<MdiIcon name="mdiLoading" size={14} />
|
||||||
|
{:else}
|
||||||
|
<MdiIcon name="mdiDownload" size={14} />
|
||||||
|
{/if}
|
||||||
|
{exporting ? t('common.loading') : t('backup.exportBtn')}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Import Section -->
|
||||||
|
<Card>
|
||||||
|
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<MdiIcon name="mdiDatabaseImport" size={18} />
|
||||||
|
{t('backup.import')}
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.importDescription')}</p>
|
||||||
|
|
||||||
|
<!-- File picker -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<input type="file" accept=".json" onchange={handleFileSelect}
|
||||||
|
class="text-xs file:mr-2 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-medium file:cursor-pointer"
|
||||||
|
style="file:background: var(--color-muted); file:color: var(--color-foreground);" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if importFile}
|
||||||
|
<!-- Validate -->
|
||||||
|
<div class="mb-4 flex items-center gap-2">
|
||||||
|
<Button variant="secondary" onclick={validateFile} disabled={validating}>
|
||||||
|
{#if validating}
|
||||||
|
<MdiIcon name="mdiLoading" size={14} />
|
||||||
|
{:else}
|
||||||
|
<MdiIcon name="mdiCheckCircleOutline" size={14} />
|
||||||
|
{/if}
|
||||||
|
{validating ? t('backup.validating') : t('backup.validateBtn')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if validationResult}
|
||||||
|
<div class="mb-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
|
||||||
|
<div class="flex items-center gap-2 mb-2 font-medium">
|
||||||
|
{#if validationResult.valid}
|
||||||
|
<MdiIcon name="mdiCheckCircle" size={14} class="text-green-600" />
|
||||||
|
<span style="color: var(--color-success-fg, green);">{t('backup.validationPassed')}</span>
|
||||||
|
{:else}
|
||||||
|
<MdiIcon name="mdiCloseCircle" size={14} />
|
||||||
|
<span style="color: var(--color-error-fg);">{t('backup.validationFailed')}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if Object.keys(validationResult.entity_counts || {}).length}
|
||||||
|
<div class="mb-2">
|
||||||
|
<span class="font-medium">{t('backup.entities')}:</span>
|
||||||
|
{#each Object.entries(validationResult.entity_counts) as [cat, count]}
|
||||||
|
<span class="inline-block mr-2">{cat}: {count}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#each validationResult.warnings || [] as w}
|
||||||
|
<div class="flex items-start gap-1 mt-1" style="color: var(--color-warning-fg, orange);">
|
||||||
|
<MdiIcon name="mdiAlert" size={12} />
|
||||||
|
<span>{w}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#each validationResult.errors || [] as e}
|
||||||
|
<div class="flex items-start gap-1 mt-1" style="color: var(--color-error-fg);">
|
||||||
|
<MdiIcon name="mdiAlertCircle" size={12} />
|
||||||
|
<span>{e}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Conflict mode -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs font-medium mb-2">{t('backup.conflictMode')}</label>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="flex items-center gap-1.5 text-xs">
|
||||||
|
<input type="radio" bind:group={importConflict} value="skip" />
|
||||||
|
{t('backup.conflictSkip')}
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1.5 text-xs">
|
||||||
|
<input type="radio" bind:group={importConflict} value="rename" />
|
||||||
|
{t('backup.conflictRename')}
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1.5 text-xs">
|
||||||
|
<input type="radio" bind:group={importConflict} value="overwrite" />
|
||||||
|
{t('backup.conflictOverwrite')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onclick={doImport}
|
||||||
|
disabled={importing || !validationResult?.valid}>
|
||||||
|
{#if importing}
|
||||||
|
<MdiIcon name="mdiLoading" size={14} />
|
||||||
|
{:else}
|
||||||
|
<MdiIcon name="mdiUpload" size={14} />
|
||||||
|
{/if}
|
||||||
|
{importing ? t('backup.importing') : t('backup.importBtn')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{#if importResult}
|
||||||
|
<div class="mt-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
|
||||||
|
<div class="font-medium mb-1">{t('backup.importResults')}</div>
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<div>{t('backup.resultCreated')}: {importResult.created}</div>
|
||||||
|
<div>{t('backup.resultSkipped')}: {importResult.skipped}</div>
|
||||||
|
<div>{t('backup.resultOverwritten')}: {importResult.overwritten}</div>
|
||||||
|
{#if importResult.errors?.length}
|
||||||
|
<div style="color: var(--color-error-fg);">{t('backup.resultErrors')}: {importResult.errors.length}</div>
|
||||||
|
{#each importResult.errors as e}
|
||||||
|
<div class="ml-2" style="color: var(--color-error-fg);">{e}</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{#if importResult.warnings?.length}
|
||||||
|
{#each importResult.warnings as w}
|
||||||
|
<div class="ml-2" style="color: var(--color-warning-fg, orange);">{w}</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Scheduled Backups Section -->
|
||||||
|
<Card>
|
||||||
|
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<MdiIcon name="mdiClockOutline" size={18} />
|
||||||
|
{t('backup.scheduled')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="flex items-center gap-2 text-xs">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked={scheduledSettings.backup_scheduled_enabled === 'true'}
|
||||||
|
onchange={() => scheduledSettings.backup_scheduled_enabled =
|
||||||
|
scheduledSettings.backup_scheduled_enabled === 'true' ? 'false' : 'true'} />
|
||||||
|
<span class="font-medium">{t('backup.enableScheduled')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if scheduledSettings.backup_scheduled_enabled === 'true'}
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium mb-1">{t('backup.interval')}</label>
|
||||||
|
<select bind:value={scheduledSettings.backup_scheduled_interval_hours}
|
||||||
|
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||||
|
<option value="6">6 {t('backup.hours')}</option>
|
||||||
|
<option value="12">12 {t('backup.hours')}</option>
|
||||||
|
<option value="24">24 {t('backup.hours')}</option>
|
||||||
|
<option value="48">48 {t('backup.hours')}</option>
|
||||||
|
<option value="72">72 {t('backup.hours')}</option>
|
||||||
|
<option value="168">168 {t('backup.hours')} (7d)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium mb-1">{t('backup.secretsMode')}</label>
|
||||||
|
<select bind:value={scheduledSettings.backup_secrets_mode}
|
||||||
|
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||||
|
<option value="exclude">{t('backup.secretsExclude')}</option>
|
||||||
|
<option value="masked">{t('backup.secretsMasked')}</option>
|
||||||
|
<option value="include">{t('backup.secretsInclude')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium mb-1">{t('backup.retention')}</label>
|
||||||
|
<select bind:value={scheduledSettings.backup_retention_count}
|
||||||
|
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||||
|
<option value="3">3</option>
|
||||||
|
<option value="5">5</option>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="20">20</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<Button onclick={saveSchedule} disabled={savingSchedule}>
|
||||||
|
{savingSchedule ? t('common.loading') : t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Saved Backup Files -->
|
||||||
|
<Card>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-sm font-semibold flex items-center gap-2">
|
||||||
|
<MdiIcon name="mdiFolder" size={18} />
|
||||||
|
{t('backup.savedFiles')}
|
||||||
|
</h3>
|
||||||
|
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
|
||||||
|
<MdiIcon name="mdiRefresh" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if backupFiles.length === 0}
|
||||||
|
<p class="text-xs" style="color: var(--color-muted-foreground);">{t('backup.noFiles')}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each backupFiles as file}
|
||||||
|
<div class="flex items-center justify-between p-2 rounded-md border text-xs"
|
||||||
|
style="border-color: var(--color-border);">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<MdiIcon name="mdiFileDocument" size={14} />
|
||||||
|
<span class="font-mono">{file.filename}</span>
|
||||||
|
<span style="color: var(--color-muted-foreground);">({formatSize(file.size)})</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button onclick={() => downloadFile(file.filename)}
|
||||||
|
class="p-1 rounded hover:bg-[var(--color-muted)]" title={t('backup.download')}>
|
||||||
|
<MdiIcon name="mdiDownload" size={14} />
|
||||||
|
</button>
|
||||||
|
<button onclick={() => confirmDeleteFile = file.filename}
|
||||||
|
class="p-1 rounded hover:bg-[var(--color-muted)]" title={t('common.delete')}
|
||||||
|
style="color: var(--color-error-fg);">
|
||||||
|
<MdiIcon name="mdiDelete" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Confirm plaintext export -->
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirmExportOpen}
|
||||||
|
title={t('backup.confirmExportTitle')}
|
||||||
|
message={t('backup.confirmExportMessage')}
|
||||||
|
confirmLabel={t('backup.exportBtn')}
|
||||||
|
confirmIcon="mdiDownload"
|
||||||
|
onconfirm={performExport}
|
||||||
|
oncancel={() => confirmExportOpen = false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Confirm import -->
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirmImportOpen}
|
||||||
|
title={t('backup.confirmImportTitle')}
|
||||||
|
message={t('backup.confirmImportMessage')}
|
||||||
|
confirmLabel={t('backup.importBtn')}
|
||||||
|
confirmIcon="mdiUpload"
|
||||||
|
onconfirm={performImport}
|
||||||
|
oncancel={() => confirmImportOpen = false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Confirm delete file -->
|
||||||
|
<ConfirmModal
|
||||||
|
open={!!confirmDeleteFile}
|
||||||
|
title={t('common.delete')}
|
||||||
|
message={confirmDeleteFile}
|
||||||
|
onconfirm={() => deleteFile(confirmDeleteFile)}
|
||||||
|
oncancel={() => confirmDeleteFile = ''}
|
||||||
|
/>
|
||||||
@@ -77,6 +77,16 @@ IMMICH_AUTO_ORGANIZE = ActionTypeDefinition(
|
|||||||
"items": {"type": "string"},
|
"items": {"type": "string"},
|
||||||
"description": "Display names (UI only)",
|
"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": {
|
"query": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Smart search query (CLIP)",
|
"description": "Smart search query (CLIP)",
|
||||||
|
|||||||
@@ -255,6 +255,18 @@ class ImmichActionExecutor(ActionExecutor):
|
|||||||
seen.add(aid)
|
seen.add(aid)
|
||||||
result.append(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
|
return result
|
||||||
|
|
||||||
def _matches_filters(
|
def _matches_filters(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ dependencies = [
|
|||||||
"pydantic-settings>=2.0",
|
"pydantic-settings>=2.0",
|
||||||
"slowapi>=0.1.9",
|
"slowapi>=0.1.9",
|
||||||
"cachetools>=5.3",
|
"cachetools>=5.3",
|
||||||
|
"python-multipart>=0.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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 .commands.webhook import router as webhook_router, set_webhook_secret
|
||||||
from .api.webhooks import router as webhooks_router
|
from .api.webhooks import router as webhooks_router
|
||||||
from .api.webhook_logs import router as webhook_logs_router
|
from .api.webhook_logs import router as webhook_logs_router
|
||||||
|
from .api.backup import router as backup_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -143,6 +144,7 @@ app.include_router(command_template_configs_router)
|
|||||||
app.include_router(webhook_router)
|
app.include_router(webhook_router)
|
||||||
app.include_router(webhooks_router)
|
app.include_router(webhooks_router)
|
||||||
app.include_router(webhook_logs_router)
|
app.include_router(webhook_logs_router)
|
||||||
|
app.include_router(backup_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -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] = []
|
||||||
@@ -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
|
||||||
@@ -38,6 +38,9 @@ async def start_scheduler() -> None:
|
|||||||
from .command_sync import start_sync_scheduler
|
from .command_sync import start_sync_scheduler
|
||||||
start_sync_scheduler()
|
start_sync_scheduler()
|
||||||
|
|
||||||
|
# Load scheduled backup job if enabled
|
||||||
|
await _load_backup_job()
|
||||||
|
|
||||||
|
|
||||||
def _schedule_event_cleanup() -> None:
|
def _schedule_event_cleanup() -> None:
|
||||||
"""Schedule a daily job to delete EventLog entries older than 90 days."""
|
"""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")
|
await run_action(action_id, trigger="scheduled")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.error("Error running action %d: %s", action_id, 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user