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:
2026-04-02 14:13:42 +03:00
parent 6e51164f8e
commit 6b2211353d
13 changed files with 2191 additions and 2 deletions
+65
View File
@@ -16,6 +16,7 @@
"cmdTemplateConfigs": "Cmd Templates",
"users": "Users",
"settings": "Settings",
"backup": "Backup",
"logout": "Logout",
"notification": "Notification",
"commands": "Commands",
@@ -1025,6 +1026,8 @@
"criteria": "Criteria",
"persons": "Persons",
"addPerson": "Add person...",
"excludePersons": "Exclude persons",
"addExcludePerson": "Add person to exclude...",
"searchQuery": "Smart Search Query",
"searchQueryPlaceholder": "e.g. sunset, beach, birthday...",
"assetType": "Asset type",
@@ -1053,5 +1056,67 @@
"triggerManual": "manual",
"triggerDryRun": "dry-run",
"triggerScheduled": "scheduled"
},
"backup": {
"title": "Backup & Restore",
"description": "Export and import your configuration, or set up automatic backups",
"export": "Export Configuration",
"exportDescription": "Download your configuration as a JSON file. Select which categories to include.",
"import": "Import Configuration",
"importDescription": "Upload a previously exported backup file to restore configuration.",
"categories": "Categories",
"selectAll": "Select all",
"deselectAll": "Deselect all",
"catProviders": "Providers",
"catTelegramBots": "Telegram Bots",
"catMatrixBots": "Matrix Bots",
"catEmailBots": "Email Bots",
"catTargets": "Targets",
"catTrackingConfigs": "Tracking Configs",
"catTemplateConfigs": "Template Configs",
"catCommandConfigs": "Command Configs",
"catCommandTemplateConfigs": "Cmd Template Configs",
"catNotificationTrackers": "Notif. Trackers",
"catCommandTrackers": "Cmd Trackers",
"catActions": "Actions",
"catAppSettings": "App Settings",
"secretsMode": "Secrets",
"secretsExclude": "Exclude secrets (safe)",
"secretsMasked": "Mask secrets (for review)",
"secretsInclude": "Include secrets (plaintext)",
"secretsWarningExport": "Warning: The export file will contain sensitive data (API keys, tokens, passwords) in plaintext.",
"exportBtn": "Export",
"exportSuccess": "Configuration exported",
"validateBtn": "Validate",
"validating": "Validating...",
"validationPassed": "Validation passed",
"validationFailed": "Validation failed",
"entities": "Entities",
"conflictMode": "Conflict resolution",
"conflictSkip": "Skip existing (keep current)",
"conflictRename": "Rename duplicates (add suffix)",
"conflictOverwrite": "Overwrite existing (replace)",
"importBtn": "Import",
"importing": "Importing...",
"importSuccess": "Configuration imported",
"importResults": "Import Results",
"resultCreated": "Created",
"resultSkipped": "Skipped",
"resultOverwritten": "Overwritten",
"resultErrors": "Errors",
"confirmExportTitle": "Export with secrets?",
"confirmExportMessage": "The exported file will contain all secrets (API keys, bot tokens, passwords) in plaintext. Only use this for secure transfers or trusted storage.",
"confirmImportTitle": "Import configuration?",
"confirmImportMessage": "This will create new entities in your database. Make sure you have validated the backup file first.",
"scheduled": "Scheduled Backups",
"enableScheduled": "Enable automatic backups",
"interval": "Interval",
"hours": "hours",
"retention": "Keep last",
"scheduleSaved": "Backup schedule saved",
"savedFiles": "Saved Backups",
"noFiles": "No backup files yet.",
"download": "Download",
"fileDeleted": "Backup file deleted"
}
}
+65
View File
@@ -16,6 +16,7 @@
"cmdTemplateConfigs": "Шаблоны команд",
"users": "Пользователи",
"settings": "Настройки",
"backup": "Бэкап",
"logout": "Выход",
"notification": "Уведомления",
"commands": "Команды",
@@ -1025,6 +1026,8 @@
"criteria": "Критерии",
"persons": "Люди",
"addPerson": "Добавить человека...",
"excludePersons": "Исключить людей",
"addExcludePerson": "Добавить человека для исключения...",
"searchQuery": "Умный поиск",
"searchQueryPlaceholder": "напр. закат, пляж, день рождения...",
"assetType": "Тип файла",
@@ -1053,5 +1056,67 @@
"triggerManual": "вручную",
"triggerDryRun": "пробный",
"triggerScheduled": "по расписанию"
},
"backup": {
"title": "Резервное копирование",
"description": "Экспорт и импорт конфигурации, настройка автоматических бэкапов",
"export": "Экспорт конфигурации",
"exportDescription": "Скачать конфигурацию в формате JSON. Выберите категории для включения.",
"import": "Импорт конфигурации",
"importDescription": "Загрузить ранее экспортированный файл бэкапа для восстановления.",
"categories": "Категории",
"selectAll": "Выбрать все",
"deselectAll": "Снять все",
"catProviders": "Провайдеры",
"catTelegramBots": "Telegram боты",
"catMatrixBots": "Matrix боты",
"catEmailBots": "Email боты",
"catTargets": "Цели",
"catTrackingConfigs": "Конфиги отслеживания",
"catTemplateConfigs": "Конфиги шаблонов",
"catCommandConfigs": "Конфиги команд",
"catCommandTemplateConfigs": "Шаблоны команд",
"catNotificationTrackers": "Трекеры уведомлений",
"catCommandTrackers": "Трекеры команд",
"catActions": "Действия",
"catAppSettings": "Настройки приложения",
"secretsMode": "Секреты",
"secretsExclude": "Исключить секреты (безопасно)",
"secretsMasked": "Маскировать секреты (для проверки)",
"secretsInclude": "Включить секреты (открытый текст)",
"secretsWarningExport": "Внимание: файл экспорта будет содержать конфиденциальные данные (API-ключи, токены, пароли) в открытом виде.",
"exportBtn": "Экспорт",
"exportSuccess": "Конфигурация экспортирована",
"validateBtn": "Проверить",
"validating": "Проверка...",
"validationPassed": "Проверка пройдена",
"validationFailed": "Проверка не пройдена",
"entities": "Сущности",
"conflictMode": "Разрешение конфликтов",
"conflictSkip": "Пропустить существующие (оставить текущие)",
"conflictRename": "Переименовать дубликаты (добавить суффикс)",
"conflictOverwrite": "Перезаписать существующие (заменить)",
"importBtn": "Импорт",
"importing": "Импорт...",
"importSuccess": "Конфигурация импортирована",
"importResults": "Результаты импорта",
"resultCreated": "Создано",
"resultSkipped": "Пропущено",
"resultOverwritten": "Перезаписано",
"resultErrors": "Ошибки",
"confirmExportTitle": "Экспорт с секретами?",
"confirmExportMessage": "Экспортированный файл будет содержать все секреты (API-ключи, токены ботов, пароли) в открытом виде. Используйте только для безопасной передачи.",
"confirmImportTitle": "Импортировать конфигурацию?",
"confirmImportMessage": "Это создаст новые сущности в базе данных. Убедитесь, что файл бэкапа прошёл проверку.",
"scheduled": "Автоматические бэкапы",
"enableScheduled": "Включить автоматическое резервное копирование",
"interval": "Интервал",
"hours": "часов",
"retention": "Хранить последних",
"scheduleSaved": "Расписание бэкапов сохранено",
"savedFiles": "Сохранённые бэкапы",
"noFiles": "Файлов бэкапа пока нет.",
"download": "Скачать",
"fileDeleted": "Файл бэкапа удалён"
}
}
+2
View File
@@ -193,6 +193,7 @@
key: 'nav.settings', icon: 'mdiCogOutline',
children: [
{ href: '/settings', key: 'nav.common', icon: 'mdiCogOutline' },
{ href: '/settings/backup', key: 'nav.backup', icon: 'mdiBackupRestore' },
{ href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' },
],
},
@@ -236,6 +237,7 @@
{ href: '/command-template-configs', key: 'nav.templates', icon: 'mdiCodeBracesBox' },
...(auth.isAdmin ? [
{ href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' },
{ href: '/settings/backup', key: 'nav.backup', icon: 'mdiBackupRestore' },
{ href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' },
] : []),
]);
+14 -2
View File
@@ -31,7 +31,7 @@
let newRule = $state({
name: '',
rule_config: {
criteria: { person_ids: [] as string[], person_names: [] as string[], query: '', asset_type: 'all', date_from: '', date_to: '', favorite_only: false },
criteria: { person_ids: [] as string[], person_names: [] as string[], exclude_person_ids: [] as string[], exclude_person_names: [] as string[], query: '', asset_type: 'all', date_from: '', date_to: '', favorite_only: false },
target_album_ids: [] as string[], target_album_names: [] as string[],
target_album_id: '', target_album_name: '',
create_album_if_missing: false, create_album_name: '',
@@ -111,7 +111,7 @@
newRule = {
name: '',
rule_config: {
criteria: { person_ids: [], person_names: [], query: '', asset_type: 'all', date_from: '', date_to: '', favorite_only: false },
criteria: { person_ids: [], person_names: [], exclude_person_ids: [] as string[], exclude_person_names: [] as string[], query: '', asset_type: 'all', date_from: '', date_to: '', favorite_only: false },
target_album_ids: [] as string[], target_album_names: [] as string[],
target_album_id: '', target_album_name: '',
create_album_if_missing: false, create_album_name: '',
@@ -228,6 +228,18 @@
ruleConfig.criteria.person_names = ids.map(id => people.find(p => p.id === id)?.name || id);
}} />
</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}
<!-- 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"},
"description": "Display names (UI only)",
},
"exclude_person_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Immich person UUIDs — assets with these persons are excluded",
},
"exclude_person_names": {
"type": "array",
"items": {"type": "string"},
"description": "Display names for excluded persons (UI only)",
},
"query": {
"type": "string",
"description": "Smart search query (CLIP)",
@@ -255,6 +255,18 @@ class ImmichActionExecutor(ActionExecutor):
seen.add(aid)
result.append(aid)
# Exclude assets belonging to excluded persons
exclude_person_ids = criteria.get("exclude_person_ids", [])
if exclude_person_ids:
excluded_asset_ids: set[str] = set()
for pid in exclude_person_ids:
assets = await self._client.get_person_assets_all(pid)
for asset in assets:
aid = asset.get("id", "")
if aid:
excluded_asset_ids.add(aid)
result = [aid for aid in result if aid not in excluded_asset_ids]
return result
def _matches_filters(
+1
View File
@@ -20,6 +20,7 @@ dependencies = [
"pydantic-settings>=2.0",
"slowapi>=0.1.9",
"cachetools>=5.3",
"python-multipart>=0.0.9",
]
[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 .api.webhooks import router as webhooks_router
from .api.webhook_logs import router as webhook_logs_router
from .api.backup import router as backup_router
@asynccontextmanager
@@ -143,6 +144,7 @@ app.include_router(command_template_configs_router)
app.include_router(webhook_router)
app.include_router(webhooks_router)
app.include_router(webhook_logs_router)
app.include_router(backup_router)
@app.get("/api/health")
@@ -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
start_sync_scheduler()
# Load scheduled backup job if enabled
await _load_backup_job()
def _schedule_event_cleanup() -> None:
"""Schedule a daily job to delete EventLog entries older than 90 days."""
@@ -321,3 +324,103 @@ async def _run_action(action_id: int) -> None:
await run_action(action_id, trigger="scheduled")
except Exception as e:
_LOGGER.error("Error running action %d: %s", action_id, e)
# ---------------------------------------------------------------------------
# Scheduled backup
# ---------------------------------------------------------------------------
_BACKUP_JOB_ID = "scheduled_backup"
async def _load_backup_job() -> None:
"""Load scheduled backup job from settings if enabled."""
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession as _AS
from ..database.engine import get_engine
from ..database.models import AppSetting
engine = get_engine()
async with _AS(engine) as session:
enabled_row = await session.get(AppSetting, "backup_scheduled_enabled")
interval_row = await session.get(AppSetting, "backup_scheduled_interval_hours")
enabled = enabled_row and enabled_row.value == "true"
if not enabled:
return
interval_hours = int(interval_row.value) if interval_row and interval_row.value else 24
scheduler = get_scheduler()
scheduler.add_job(
_run_scheduled_backup,
"interval",
hours=interval_hours,
id=_BACKUP_JOB_ID,
replace_existing=True,
max_instances=1,
)
_LOGGER.info("Scheduled backup every %dh", interval_hours)
async def schedule_backup(interval_hours: int = 24) -> None:
"""Add or update the scheduled backup job."""
scheduler = get_scheduler()
if scheduler.get_job(_BACKUP_JOB_ID):
scheduler.remove_job(_BACKUP_JOB_ID)
scheduler.add_job(
_run_scheduled_backup,
"interval",
hours=interval_hours,
id=_BACKUP_JOB_ID,
replace_existing=True,
max_instances=1,
)
_LOGGER.info("Scheduled backup every %dh", interval_hours)
async def unschedule_backup() -> None:
"""Remove the scheduled backup job."""
scheduler = get_scheduler()
if scheduler.get_job(_BACKUP_JOB_ID):
scheduler.remove_job(_BACKUP_JOB_ID)
_LOGGER.info("Unscheduled backup job")
async def _run_scheduled_backup() -> None:
"""Run a scheduled backup (called by APScheduler)."""
from sqlmodel.ext.asyncio.session import AsyncSession as _AS
from ..database.engine import get_engine
from ..database.models import AppSetting, User
from ..config import settings as app_config
from .backup_schema import SecretsMode
from .backup_service import export_backup_to_file, cleanup_old_backups
try:
engine = get_engine()
async with _AS(engine) as session:
# Read settings
secrets_row = await session.get(AppSetting, "backup_secrets_mode")
retention_row = await session.get(AppSetting, "backup_retention_count")
secrets_mode = SecretsMode(secrets_row.value) if secrets_row and secrets_row.value else SecretsMode.EXCLUDE
retention = int(retention_row.value) if retention_row and retention_row.value else 5
# Find admin user (first admin) for ownership context
from sqlmodel import select
admin_result = await session.exec(
select(User).where(User.role == "admin")
)
admin = admin_result.first()
if not admin:
_LOGGER.warning("No admin user found, skipping scheduled backup")
return
backup_dir = app_config.data_dir / "backups"
await export_backup_to_file(session, admin.id, backup_dir, secrets_mode)
# Cleanup outside the session
cleanup_old_backups(backup_dir, keep=retention)
except Exception as e:
_LOGGER.error("Scheduled backup failed: %s", e)