From 925aaed54cdb5f75b62657f6667c43b67704cad3 Mon Sep 17 00:00:00 2001 From: Paul Makles Date: Thu, 29 Oct 2026 18:14:01 +0000 Subject: [PATCH] feat: backups (#32268) Signed-off-by: izzy Co-authored-by: Zack Pollard (cherry picked from commit 177b57b689be293bad96b8092e473dc03e068e9c) --- .../server/futo-backups.e2e-spec.ts | 384 ++ .../web/database-backups.e2e-spec.ts | 1 + .../maintenance/web/futo-backups.e2e-spec.ts | 173 + e2e/src/specs/server/api/server.e2e-spec.ts | 1 + e2e/src/ui/mock-network/base-network.ts | 1 + e2e/src/utils.ts | 3 + i18n/en.json | 18 + mise.lock | 39 + mise.toml | 1 + mobile/lib/utils/openapi_patching.dart | 2 +- open-api/immich-openapi-specs.json | 4166 ++++++++++++++++- packages/sdk/src/fetch-client.ts | 1089 ++++- pnpm-lock.yaml | 607 ++- pnpm-workspace.yaml | 2 + server/Dockerfile | 4 +- server/Dockerfile.dev | 1 + server/package.json | 1 + server/src/app.module.ts | 73 +- server/src/constants.ts | 1 + server/src/controllers/index.spec.ts | 2 +- server/src/dtos/config.dto.ts | 2 + server/src/dtos/maintenance.dto.ts | 3 + server/src/dtos/server.dto.ts | 1 + server/src/enum.ts | 3 + .../src/maintenance/maintenance-auth.guard.ts | 10 +- .../maintenance-health.repository.spec.ts | 110 + .../maintenance-health.repository.ts | 66 +- .../maintenance-worker.service.spec.ts | 32 +- .../maintenance/maintenance-worker.service.ts | 94 +- server/src/middleware/auth.guard.spec.ts | 22 +- server/src/middleware/auth.guard.ts | 8 +- server/src/middleware/websocket.adapter.ts | 6 +- server/src/repositories/event.repository.ts | 4 + .../src/repositories/websocket.repository.ts | 3 + .../services/database-backup.service.spec.ts | 6 + .../src/services/database-backup.service.ts | 4 +- server/src/services/futo-backups.service.ts | 102 + server/src/services/index.ts | 2 + server/src/services/library.service.spec.ts | 3 + server/src/services/library.service.ts | 5 + .../src/services/notification.service.spec.ts | 21 + server/src/services/notification.service.ts | 15 + server/src/services/server.service.spec.ts | 1 + server/src/services/server.service.ts | 15 +- server/src/services/storage.service.ts | 21 +- .../services/system-config.service.spec.ts | 1 + server/src/utils/storage.ts | 49 + server/src/workers/api.ts | 4 + server/src/workers/maintenance.ts | 2 + .../specs/services/library.service.spec.ts | 11 +- web/package.json | 1 + .../backups/HideBackupsReminder.svelte | 9 + .../components/layouts/AdminPageLayout.svelte | 6 + .../maintenance/MaintenanceBackupCard.svelte | 114 + .../MaintenanceBackupEntry.spec.ts | 20 +- .../maintenance/MaintenanceBackupEntry.svelte | 109 +- .../maintenance/MaintenanceBackupsList.svelte | 9 +- .../purchasing/PurchaseContent.svelte | 5 +- .../side-bar/BottomInfo.svelte | 11 +- .../side-bar/StorageSpace.svelte | 2 +- .../lib/managers/language-manager.svelte.ts | 9 +- web/src/lib/route.ts | 6 + .../lib/services/database-backups.service.ts | 27 + web/src/lib/stores/preferences.store.ts | 2 + web/src/routes/+layout.ts | 6 + web/src/routes/admin/backups/+page.svelte | 57 + web/src/routes/admin/backups/+page.ts | 29 + .../admin/backups/attempts/+page.svelte | 20 + .../routes/admin/backups/attempts/+page.ts | 14 + .../admin/backups/settings/+page.svelte | 20 + .../routes/admin/backups/settings/+page.ts | 14 + .../admin/backups/snapshots/+page.svelte | 20 + .../routes/admin/backups/snapshots/+page.ts | 14 + .../system-settings/BackupSettings.svelte | 2 +- web/src/routes/link/+page.ts | 5 + web/src/routes/maintenance/+page.svelte | 129 +- .../maintenance/MaintenanceRestoreFlow.svelte | 33 +- .../RestoreFlowAutoSelectBackup.svelte | 68 + .../RestoreFlowDetectInstall.svelte | 6 +- .../maintenance/RestoreFlowIntro.svelte | 24 + web/vite.config.ts | 5 + 81 files changed, 7712 insertions(+), 279 deletions(-) create mode 100644 e2e/src/specs/maintenance/server/futo-backups.e2e-spec.ts create mode 100644 e2e/src/specs/maintenance/web/futo-backups.e2e-spec.ts create mode 100644 server/src/maintenance/maintenance-health.repository.spec.ts create mode 100644 server/src/services/futo-backups.service.ts create mode 100644 server/src/utils/storage.ts create mode 100644 web/src/lib/components/backups/HideBackupsReminder.svelte create mode 100644 web/src/lib/components/maintenance/MaintenanceBackupCard.svelte create mode 100644 web/src/routes/admin/backups/+page.svelte create mode 100644 web/src/routes/admin/backups/+page.ts create mode 100644 web/src/routes/admin/backups/attempts/+page.svelte create mode 100644 web/src/routes/admin/backups/attempts/+page.ts create mode 100644 web/src/routes/admin/backups/settings/+page.svelte create mode 100644 web/src/routes/admin/backups/settings/+page.ts create mode 100644 web/src/routes/admin/backups/snapshots/+page.svelte create mode 100644 web/src/routes/admin/backups/snapshots/+page.ts create mode 100644 web/src/routes/maintenance/RestoreFlowAutoSelectBackup.svelte create mode 100644 web/src/routes/maintenance/RestoreFlowIntro.svelte diff --git a/e2e/src/specs/maintenance/server/futo-backups.e2e-spec.ts b/e2e/src/specs/maintenance/server/futo-backups.e2e-spec.ts new file mode 100644 index 000000000..3e5a6d115 --- /dev/null +++ b/e2e/src/specs/maintenance/server/futo-backups.e2e-spec.ts @@ -0,0 +1,384 @@ +import * as sdk from '@immich/sdk'; +import { LoginResponseDto, StorageFolder } from '@immich/sdk'; +import { io, Socket } from 'socket.io-client'; +import { createUserDto } from 'src/fixtures'; +import { app, asBearerAuth, baseUrl, utils } from 'src/utils'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('/futo-backups', () => { + let admin: LoginResponseDto; + let nonAdmin: LoginResponseDto; + let requestOpts: any; + let filename: string; + + let socket: Socket; + let libraryId: string; + + beforeAll(async () => { + utils.initSdk(); + + await utils.resetDatabase(); + admin = await utils.adminSetup(); + nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); + + requestOpts = { headers: asBearerAuth(admin.accessToken) }; + + await utils.resetBackups(admin.accessToken); + await sdk.futoBackupsResetOrchestrator(requestOpts); + + socket = io(baseUrl, { + path: '/api/futo-backups/socket.io', + transports: ['websocket'], + extraHeaders: asBearerAuth(admin.accessToken), + }); + + socket.onAny(console.info); + }); + + afterAll(async () => { + socket.close(); + + // trigger update for orchestrator module config + if (libraryId) { + await utils.deleteLibrary(admin.accessToken, libraryId); + } + }); + + const finishedJobStatuses = new Set(['complete', 'warn', 'failed', 'cancelled', 'incomplete']); + + const waitForMessage = (type: string, matches: (payload: any) => boolean = () => true) => { + return new Promise((resolve) => { + const listener = (msg: string) => { + const payload = JSON.parse(msg); + if (payload.type !== type || !matches(payload)) { + return; + } + + resolve(payload); + socket.offAny(listener); + }; + + socket.onAny(listener); + }); + }; + + describe('Orchestration Module', async () => { + it('works', async () => { + await expect(sdk.futoBackupsOnboardingStatus(requestOpts)).resolves.toEqual( + expect.objectContaining({ + hasOnboardedKey: false, + hasBackend: false, + hasBackup: false, + hasSchedule: false, + hasSkippedExtraConfig: false, + }), + ); + }); + + it('is inaccessible without admin', async () => { + await expect(sdk.futoBackupsOnboardingStatus({ headers: asBearerAuth(nonAdmin.accessToken) })).rejects.toEqual( + expect.objectContaining({ data: { message: expect.any(String) } }), + ); + }); + + it('is inaccessible without logging in', async () => { + await expect(sdk.futoBackupsOnboardingStatus()).rejects.toEqual( + expect.objectContaining({ data: { message: 'Authentication required' } }), + ); + }); + }); + + describe('Local Backup', { concurrent: false }, async () => { + beforeAll(async () => { + await sdk.futoBackupsImportRecoveryKey( + { + importRecoveryKeyRequest: { + recoveryKey: '0'.repeat(64), + }, + }, + requestOpts, + ); + }); + + it('configures a local backend', { concurrent: false }, async () => { + await utils.mkFolder('/local-backend'); + + await sdk.futoBackupsCreateLocalBackend( + { + createLocalBackendRequestDto: { + path: '/local-backend', + }, + }, + requestOpts, + ); + }); + + it('configures Immich backup', { concurrent: false }, async () => { + const event = waitForMessage('IntegrationUpdate'); + + await sdk.futoBackupsConfigureImmichIntegration( + { + configureImmichIntegrationRequestDto: { + name: 'Immich', + worm: false, + cron: '0 3 * * *', + backupConfiguration: true, + dataFolders: [StorageFolder.Backups, StorageFolder.Upload], + libraries: ['all'], + }, + }, + requestOpts, + ); + + await event; + + await expect(sdk.futoBackupsGetIntegrations(requestOpts)).resolves.toEqual( + expect.objectContaining({ + immichIntegration: expect.objectContaining({ + configuration: { + backupConfiguration: true, + dataFolders: ['backups', 'upload'], + libraries: ['all'], + }, + }), + immichState: { + dataFolders: expect.arrayContaining(Object.values(StorageFolder)), + dataPath: '/data', + // may have stale libraries from other test runs + libraries: expect.any(Array), + }, + }), + ); + }); + + it('updates configuration', { concurrent: false }, async () => { + await utils.mkFolder('/test'); + + ({ id: libraryId } = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + name: 'My Library', + importPaths: ['/test'], + })); + + await expect(sdk.futoBackupsGetIntegrations(requestOpts)).resolves.toEqual( + expect.objectContaining({ + immichIntegration: expect.any(Object), + immichState: expect.objectContaining({ + libraries: expect.arrayContaining([ + expect.objectContaining({ + name: 'My Library', + importPaths: ['/test'], + }), + ]), + }), + }), + ); + }); + + it('creates a snapshot', { concurrent: false }, async () => { + const event = waitForMessage('JobUpdate', ({ job }) => finishedJobStatuses.has(job?.status)); + + const { repositories } = await sdk.futoBackupsGetRepositories(requestOpts); + const { id } = repositories[0]; + + filename = await utils.createBackup(admin.accessToken); + + await sdk.futoBackupsCreateBackup({ id }, requestOpts); + await event; + + const { snapshots } = await sdk.futoBackupsGetSnapshots({ id }, requestOpts); + const { id: snapshotId } = snapshots[0]; + + await expect(sdk.futoBackupsGetSnapshotListing({ id, snapshot: snapshotId }, requestOpts)).resolves + .toMatchInlineSnapshot(` + { + "items": [ + { + "isDirectory": true, + "path": "/data", + }, + { + "isDirectory": true, + "path": "/test", + }, + ], + "parent": "/", + "path": "/", + } + `); + + await expect(sdk.futoBackupsGetSnapshotListing({ id, snapshot: snapshotId, path: '/data' }, requestOpts)).resolves + .toMatchInlineSnapshot(` + { + "items": [ + { + "isDirectory": true, + "path": "/data/backups", + }, + { + "isDirectory": true, + "path": "/data/upload", + }, + ], + "parent": "/", + "path": "/data", + } + `); + + await expect( + sdk.futoBackupsGetSnapshotListing({ id, snapshot: snapshotId, path: '/data/backups' }, requestOpts), + ).resolves.toEqual( + expect.objectContaining({ + items: expect.arrayContaining([ + { isDirectory: false, path: '/data/backups/.immich' }, + { isDirectory: false, path: `/data/backups/${filename}` }, + { isDirectory: true, path: '/data/backups/yucca' }, + ]), + parent: '/data', + path: '/data/backups', + }), + ); + }); + }); + + describe('Restore Local Backup', { concurrent: false }, async () => { + let cookie: string; + + beforeAll(async () => { + await sdk.futoBackupsResetOrchestrator(requestOpts); + await utils.resetDatabase(); + socket.disconnect(); + await utils.disconnectDatabase(); + }); + + afterAll(async () => { + await utils.connectDatabase(); + }); + + it('restores backup', { timeout: 60_000, concurrent: false }, async () => { + const { status, headers } = await request(app).post('/admin/database-backups/start-restore').send(); + expect(status).toBe(201); + cookie = headers['set-cookie'][0].split(';', 1)[0]; + + await expect + .poll( + async () => { + const { status, body } = await request(app).get('/server/config'); + expect(status).toBe(200); + return body.maintenanceMode; + }, + { + interval: 500, + timeout: 10_000, + }, + ) + .toBeTruthy(); + + const maintenanceRequestOpts = { + headers: { + cookie, + }, + }; + + await expect(sdk.futoBackupsGetSchedules(maintenanceRequestOpts)).resolves.toEqual({ schedules: [] }); + + await sdk.futoBackupsImportRecoveryKey( + { + importRecoveryKeyRequest: { + recoveryKey: '0'.repeat(64), + }, + }, + maintenanceRequestOpts, + ); + + const { + backend: { id: backendId }, + } = await sdk.futoBackupsCreateLocalBackend( + { + createLocalBackendRequestDto: { + path: '/local-backend', + }, + }, + maintenanceRequestOpts, + ); + + const { repositories } = await sdk.futoBackupsInspectRepositories({}, maintenanceRequestOpts); + const { id: repositoryId, snapshots } = repositories[0]; + const { id: snapshotId } = (snapshots ?? [])[0]; + + socket = io(baseUrl, { + path: '/api/futo-backups/socket.io', + transports: ['websocket'], + extraHeaders: { + cookie, + }, + }); + + const event = waitForMessage('JobUpdate', ({ job }) => finishedJobStatuses.has(job?.status)); + await sdk.futoBackupsRestoreFromPoint( + { + id: repositoryId, + snapshot: snapshotId, + backend: backendId, + repositorySnapshotRestoreFromPointRequestDto: { + yuccaConfig: '/data/yucca', + include: ['/data'], + }, + }, + maintenanceRequestOpts, + ); + + await event; + socket.disconnect(); + + const { status: restoreStatus } = await request(app).post('/admin/maintenance').set('Cookie', cookie).send({ + action: 'restore_database', + restoreBackupFilename: filename, + }); + + expect(restoreStatus).toBe(201); + + await expect + .poll( + async () => { + const { status, body } = await request(app).get('/server/config'); + expect(status).toBe(200); + return body.maintenanceMode; + }, + { + interval: 500, + timeout: 10_000, + }, + ) + .toBeTruthy(); + + const { status: status2, body } = await request(app).get('/admin/maintenance/status'); + expect(status2).toBe(200); + expect(body).toEqual( + expect.objectContaining({ + active: true, + action: 'restore_database', + }), + ); + + await expect + .poll( + async () => { + const { status, body } = await request(app).get('/server/config'); + expect(status).toBe(200); + return body.maintenanceMode; + }, + { + interval: 500, + timeout: 60_000, + }, + ) + .toBeFalsy(); + + await expect(sdk.futoBackupsGetSchedules(requestOpts)).resolves.toEqual({ + schedules: expect.arrayContaining([expect.objectContaining({ id: expect.any(String) })]), + }); + }); + }); +}); diff --git a/e2e/src/specs/maintenance/web/database-backups.e2e-spec.ts b/e2e/src/specs/maintenance/web/database-backups.e2e-spec.ts index efcf2ef27..650334360 100644 --- a/e2e/src/specs/maintenance/web/database-backups.e2e-spec.ts +++ b/e2e/src/specs/maintenance/web/database-backups.e2e-spec.ts @@ -95,6 +95,7 @@ test.describe('Database Backups', () => { await page.waitForURL('/maintenance**'); } + await page.getByRole('button', { name: 'Database Backup' }).click(); await page.getByRole('button', { name: 'Next' }).click(); await page.getByRole('button', { name: 'Restore', exact: true }).click(); await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click(); diff --git a/e2e/src/specs/maintenance/web/futo-backups.e2e-spec.ts b/e2e/src/specs/maintenance/web/futo-backups.e2e-spec.ts new file mode 100644 index 000000000..590b111a7 --- /dev/null +++ b/e2e/src/specs/maintenance/web/futo-backups.e2e-spec.ts @@ -0,0 +1,173 @@ +import * as sdk from '@immich/sdk'; +import { LoginResponseDto, StorageFolder, getServerFeatures } from '@immich/sdk'; +import { expect, test } from '@playwright/test'; +import { io, type Socket } from 'socket.io-client'; +import { asBearerAuth, baseUrl, utils } from 'src/utils'; + +test.describe.configure({ mode: 'serial' }); + +test.describe('FUTO Backups', () => { + let admin: LoginResponseDto; + let socket: Socket; + + const finishedJobStatuses = new Set(['complete', 'warn', 'failed', 'cancelled', 'incomplete']); + + const waitForJobEnd = () => + new Promise((resolve) => { + const listener = (msg: string) => { + try { + const payload = JSON.parse(msg); + if (payload.type === 'JobUpdate' && finishedJobStatuses.has(payload.job?.status)) { + socket.offAny(listener); + resolve(); + } + } catch { + // no-op + } + }; + socket.onAny(listener); + }); + + test.beforeAll(async () => { + utils.initSdk(); + await utils.resetDatabase(); + admin = await utils.adminSetup(); + + const headers = asBearerAuth(admin.accessToken); + await sdk.futoBackupsResetOrchestrator({ headers }); + await sdk.futoBackupsImportRecoveryKey({ importRecoveryKeyRequest: { recoveryKey: '0'.repeat(64) } }, { headers }); + await sdk.futoBackupsConfirmRecoveryKey({ headers }); + await sdk.futoBackupsEnableTelemetry({ headers }); + await utils.mkFolder('/local-backend'); + + socket = io(baseUrl, { + path: '/api/futo-backups/socket.io', + transports: ['websocket'], + extraHeaders: headers, + forceNew: true, + }); + await new Promise((resolve) => socket.on('connect', () => resolve())); + }); + + test.afterAll(async () => { + socket?.close(); + }); + + test('the backups link opens the upsell before backups are configured', async ({ context, page }) => { + const headers = asBearerAuth(admin.accessToken); + await utils.setAuthCookies(context, admin.accessToken); + + await expect(getServerFeatures({ headers })).resolves.toMatchObject({ backups: false }); + + await page.goto('/link?target=backups'); + await page.waitForURL('/admin/backups'); + + await expect(page.getByRole('heading', { name: 'FUTO Backups', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Get Started' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Set up Backups' })).toBeVisible(); + await expect(getServerFeatures({ headers })).resolves.toMatchObject({ backups: true }); + }); + + test('the backups link opens the dashboard once backups are configured', async ({ context, page }) => { + test.setTimeout(60_000); + const headers = asBearerAuth(admin.accessToken); + await utils.setAuthCookies(context, admin.accessToken); + + await sdk.futoBackupsCreateLocalBackend({ createLocalBackendRequestDto: { path: '/local-backend' } }, { headers }); + await sdk.futoBackupsConfigureImmichIntegration( + { + configureImmichIntegrationRequestDto: { + name: 'Immich', + worm: false, + cron: '0 3 * * *', + backupConfiguration: true, + dataFolders: [StorageFolder.Backups, StorageFolder.Upload], + libraries: ['all'], + }, + }, + { headers }, + ); + + const { repositories } = await sdk.futoBackupsGetRepositories({ headers }); + const backupEnd = waitForJobEnd(); + await sdk.futoBackupsCreateBackup({ id: repositories[0].id }, { headers }); + await backupEnd; + + await page.goto('/link?target=backups'); + await page.waitForURL('/admin/backups'); + + await expect(page.getByText('Your library backup')).toBeVisible(); + await expect(page.getByText('Last backup successful')).toBeVisible(); + }); + + test('manually triggers a backup and waits for completion', async ({ context, page }) => { + test.setTimeout(60_000); + await utils.setAuthCookies(context, admin.accessToken); + + await page.goto('/admin/backups'); + await page.getByRole('button', { name: 'Back up now', exact: true }).click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('heading', { name: 'Backing up your library' })).toBeVisible(); + await expect(dialog.getByRole('heading', { name: 'Backup complete' })).toBeVisible({ timeout: 45_000 }); + }); + + test('resets immich and restores from the local FUTO Backups backup', async ({ context, page }) => { + test.setTimeout(120_000); + await utils.setAuthCookies(context, admin.accessToken); + + await utils.resetBackups(admin.accessToken); + await utils.createBackup(admin.accessToken); + + await sdk.futoBackupsResetOrchestrator({ headers: asBearerAuth(admin.accessToken) }); + await utils.resetDatabase(); + + await page.goto('/'); + await page.getByRole('button', { name: 'Restore from backup' }).click(); + + try { + await page.waitForURL('/maintenance**'); + } catch { + await page.goto('/maintenance'); + await page.waitForURL('/maintenance**'); + } + + await page.getByRole('button', { name: 'FUTO Backups' }).click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog.filter({ hasText: 'Telemetry required for closed beta' })).toBeVisible(); + await dialog.getByRole('button', { name: 'Continue' }).click(); + + await expect(dialog.filter({ hasText: 'Import recovery key' })).toBeVisible(); + await dialog.getByLabel('Recovery Key').fill('0'.repeat(64)); + await dialog.getByRole('button', { name: 'Save' }).click(); + + await expect(dialog.filter({ hasText: 'Where would you like to restore from?' })).toBeVisible(); + await dialog.getByText('Local Storage').click(); + + await expect(dialog.filter({ hasText: 'Create local backend' })).toBeVisible(); + await dialog.getByLabel('Path').fill('/local-backend'); + await dialog.getByRole('button', { name: 'Save' }).click(); + + await expect(dialog.filter({ hasText: 'Select Backup' })).toBeVisible(); + await dialog + .getByRole('button', { name: /Last backup/ }) + .first() + .click(); + + await expect(dialog.filter({ hasText: /Restore from/ })).toBeVisible(); + await dialog.getByRole('button', { name: 'Restore' }).first().click(); + + await expect(dialog.filter({ hasText: 'Confirm restore from snapshot' })).toBeVisible(); + await dialog.getByRole('button', { name: 'Restore', exact: true }).click(); + + await expect(dialog.filter({ hasText: 'Restoring' })).toBeVisible(); + await expect(dialog.filter({ hasText: 'Restoring' })).toBeHidden({ timeout: 60_000 }); + + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click(); + + await page.waitForURL('/maintenance?**'); + await page.waitForURL('/photos', { timeout: 90_000 }); + }); +}); diff --git a/e2e/src/specs/server/api/server.e2e-spec.ts b/e2e/src/specs/server/api/server.e2e-spec.ts index 48d2b2c6d..bc301c4d4 100644 --- a/e2e/src/specs/server/api/server.e2e-spec.ts +++ b/e2e/src/specs/server/api/server.e2e-spec.ts @@ -98,6 +98,7 @@ describe('/server', () => { configFile: false, duplicateDetection: false, facialRecognition: false, + backups: false, map: true, reverseGeocoding: true, importFaces: false, diff --git a/e2e/src/ui/mock-network/base-network.ts b/e2e/src/ui/mock-network/base-network.ts index 3c761ba37..65d2e4889 100644 --- a/e2e/src/ui/mock-network/base-network.ts +++ b/e2e/src/ui/mock-network/base-network.ts @@ -143,6 +143,7 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI smartSearch: false, facialRecognition: false, duplicateDetection: false, + backups: false, map: true, reverseGeocoding: true, importFaces: false, diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 02cce0983..6f32a6423 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -31,6 +31,7 @@ import { createUserAdmin, deleteAssets, deleteDatabaseBackup, + deleteLibrary, getAssetInfo, getConfig, getConfigDefaults, @@ -466,6 +467,8 @@ export const utils = { updateLibrary: (accessToken: string, id: string, dto: UpdateLibraryDto) => updateLibrary({ id, updateLibraryDto: dto }, { headers: asBearerAuth(accessToken) }), + deleteLibrary: (accessToken: string, id: string) => deleteLibrary({ id }, { headers: asBearerAuth(accessToken) }), + createPartner: (accessToken: string, id: string) => createPartner({ partnerCreateDto: { sharedWithId: id } }, { headers: asBearerAuth(accessToken) }), diff --git a/i18n/en.json b/i18n/en.json index db95e6a02..09fb7aaca 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -62,6 +62,12 @@ "backup_onboarding_title": "Backups", "backup_settings": "Database Dump Settings", "backup_settings_description": "Manage database dump settings.", + "backups": "Backups", + "backups_attempts": "Backup attempts", + "backups_hide_reminder": "Hide backups reminder", + "backups_hide_reminder_description": "If you already manage backups outside of Immich, you can hide the backup setup reminder.", + "backups_settings": "Backup settings", + "backups_snapshots": "Snapshots", "cleared_jobs": "Cleared jobs for: {job}", "config_set_by_file": "Config is currently set by a config file", "confirm_delete_library": "Are you sure you want to delete {library} library?", @@ -1023,6 +1029,7 @@ "failed_to_keep_this_delete_others": "Failed to keep this asset and delete the other assets", "failed_to_load_asset": "Failed to load asset", "failed_to_load_assets": "Failed to load assets", + "failed_to_load_database_backups": "Failed to load database backups", "failed_to_load_notifications": "Failed to load notifications", "failed_to_load_people": "Failed to load people", "failed_to_remove_product_key": "Failed to remove product key", @@ -1401,10 +1408,12 @@ "main_branch_warning": "You're using a development version; we strongly recommend using a release version!", "main_menu": "Main menu", "maintenance_action_restore": "Restoring Database", + "maintenance_action_rollback": "Restoring from Backup", "maintenance_description": "Immich has been put into maintenance mode.", "maintenance_end": "End maintenance mode", "maintenance_logged_in_as": "Currently logged in as {user}", "maintenance_restore_from_backup": "Restore From Backup", + "maintenance_restore_latest_backup_description": "We'll restore your database from the most recent backup. You can also pick a different one.", "maintenance_restore_library": "Restore Your Library", "maintenance_restore_library_confirm": "If this looks correct, continue to restoring a backup!", "maintenance_restore_library_description": "Restoring Database", @@ -1417,6 +1426,13 @@ "maintenance_restore_library_hint_regenerate_later": "You can regenerate these later in settings", "maintenance_restore_library_hint_storage_template_missing_files": "Using storage template? You may be missing files", "maintenance_restore_library_loading": "Loading integrity checks and heuristics…", + "maintenance_restore_loading_backups": "Loading backups…", + "maintenance_restore_no_backups": "There are no database backups.", + "maintenance_restore_select_another": "Select another backup", + "maintenance_restore_source_database_backup": "Database Backup", + "maintenance_restore_source_futo_backups": "FUTO Backups", + "maintenance_restore_source_title": "Where would you like to restore from?", + "maintenance_restore_upload_backup": "Upload a backup", "maintenance_task_backup": "Creating a backup of the existing database…", "maintenance_task_migrations": "Running database migrations…", "maintenance_task_restore": "Restoring the chosen backup…", @@ -1789,6 +1805,8 @@ "purchase_panel_title": "Support the project", "purchase_per_server": "Per server", "purchase_per_user": "Per user", + "purchase_product_key_description": "Product keys support Immich development and give you a supporter badge.", + "purchase_product_key_excludes_backups": "Product keys do not include FUTO Backups.", "purchase_remove_product_key": "Remove Product Key", "purchase_remove_product_key_prompt": "Are you sure you want to remove the product key?", "purchase_remove_server_product_key": "Remove Server product key", diff --git a/mise.lock b/mise.lock index ec3f7cfe4..33d681e6f 100644 --- a/mise.lock +++ b/mise.lock @@ -297,6 +297,45 @@ url = "https://github.com/pnpm/pnpm/releases/download/v12.15.0/pnpm-win32-x64.zi url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/662173351" provenance = "github-attestations" +[[tools.restic]] +version = "0.19.0" +backend = "aqua:restic/restic" + +[tools.restic."platforms.linux-arm64"] +checksum = "sha256:e522ce6bf748d753fee8093e8ec59359972cf5b6bc65fc7c7cf38ae952351d91" +url = "https://github.com/restic/restic/releases/download/v0.19.0/restic_0.19.0_linux_arm64.bz2" +url_api = "https://api.github.com/repos/restic/restic/releases/assets/442897888" + +[tools.restic."platforms.linux-arm64-musl"] +checksum = "sha256:e522ce6bf748d753fee8093e8ec59359972cf5b6bc65fc7c7cf38ae952351d91" +url = "https://github.com/restic/restic/releases/download/v0.19.0/restic_0.19.0_linux_arm64.bz2" +url_api = "https://api.github.com/repos/restic/restic/releases/assets/442897888" + +[tools.restic."platforms.linux-x64"] +checksum = "sha256:13176fe6d89d4357947a2cd107218ab2873a5f9d8e1ac2d4cd1c8e07e6839c21" +url = "https://github.com/restic/restic/releases/download/v0.19.0/restic_0.19.0_linux_amd64.bz2" +url_api = "https://api.github.com/repos/restic/restic/releases/assets/442897993" + +[tools.restic."platforms.linux-x64-musl"] +checksum = "sha256:13176fe6d89d4357947a2cd107218ab2873a5f9d8e1ac2d4cd1c8e07e6839c21" +url = "https://github.com/restic/restic/releases/download/v0.19.0/restic_0.19.0_linux_amd64.bz2" +url_api = "https://api.github.com/repos/restic/restic/releases/assets/442897993" + +[tools.restic."platforms.macos-arm64"] +checksum = "sha256:1475397bf759ef4be16a77b19dec650bdbfec00d2cacd82005553411cdd37997" +url = "https://github.com/restic/restic/releases/download/v0.19.0/restic_0.19.0_darwin_arm64.bz2" +url_api = "https://api.github.com/repos/restic/restic/releases/assets/442898395" + +[tools.restic."platforms.macos-x64"] +checksum = "sha256:c9d9a71234bc0955fdba6da93cc9375f8793ec1e1cbce77a91014d536a969148" +url = "https://github.com/restic/restic/releases/download/v0.19.0/restic_0.19.0_darwin_amd64.bz2" +url_api = "https://api.github.com/repos/restic/restic/releases/assets/442898462" + +[tools.restic."platforms.windows-x64"] +checksum = "sha256:6fa4219a70b1b5d1c429bb106a7f97f3d2a5aab74494db2e490b625edc486d8f" +url = "https://github.com/restic/restic/releases/download/v0.19.0/restic_0.19.0_windows_amd64.zip" +url_api = "https://api.github.com/repos/restic/restic/releases/assets/442897166" + [[tools.terragrunt]] version = "1.2.0" backend = "aqua:gruntwork-io/terragrunt" diff --git a/mise.toml b/mise.toml index c97083291..c26ceb282 100644 --- a/mise.toml +++ b/mise.toml @@ -24,6 +24,7 @@ opentofu = "1.13.2" "github:extism/cli" = "1.6.3" "github:webassembly/binaryen" = "version_124" "github:extism/js-pdk" = "v1.7.0" +restic = "0.19.0" java = "21.0.2" [tools."github:jellyfin/jellyfin-ffmpeg"] diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 95a87d60a..5b4f00e0c 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -50,7 +50,7 @@ final Map> openApiPatches = { 'LoginResponseDto': {'isOnboarded': false}, 'SyncUserV1': {'profileChangedAt': _now, 'hasProfileImage': false}, 'SyncAssetV1': {'isEdited': false}, - 'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false}, + 'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false, 'backups': false}, 'SearchAssetResponseDto': {'nextCursor': null}, 'MemoriesResponse': {'duration': 5, 'sidebarWeb': false}, 'PeopleResponse': {'updateStrategy': 'everyone'}, diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index eaeeefe46..5dc0b6d03 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7229,6 +7229,2632 @@ "x-immich-state": "Stable" } }, + "/futo-backups/auth/oidc/device": { + "get": { + "description": "Connect an existing FUTO Backups account to this server.", + "operationId": "futoBackupsConnectDeviceFlow", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceFlowEventDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Connect a FUTO Backups account", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/auth/session": { + "post": { + "description": "Use existing session token to create a session on your device.", + "operationId": "futoBackupsCreateSession", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSessionRequestDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Create session", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/auth/session/device": { + "get": { + "description": "Log into standalone backups app using the associated cloud account.", + "operationId": "futoBackupsSessionDeviceFlow", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceFlowEventDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Sign in with FUTO Backups account", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/auth/ticket": { + "post": { + "description": "Create a ticket to perform a protected repository action.", + "operationId": "futoBackupsCreateTicket", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketCreateRequestDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketCreateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Create a ticket", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/backend": { + "get": { + "description": "List the storage backends configured on this server.", + "operationId": "futoBackupsGetBackends", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List backends", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/backend/local": { + "post": { + "description": "Add a storage backend that writes to a local path on this server.", + "operationId": "futoBackupsCreateLocalBackend", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateLocalBackendRequestDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Create a local backend", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/config": { + "get": { + "description": "Retrieve the current backups orchestrator configuration.", + "operationId": "futoBackupsGetConfig", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get backups orchestrator configuration", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + }, + "put": { + "description": "Update the backups orchestrator configuration.", + "operationId": "futoBackupsUpdateConfig", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigUpdateRequestDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Update backups orchestrator configuration", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/debug/reset": { + "post": { + "description": "Delete all orchestrator state. Only available in development mode.", + "operationId": "futoBackupsResetOrchestrator", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Reset the backups orchestrator", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/fs": { + "get": { + "description": "List the files and folders at a path on this server.", + "operationId": "futoBackupsGetFileListing", + "parameters": [ + { + "name": "path", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FilesystemListingResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List files", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/integrations": { + "get": { + "description": "Retrieve the configuration and state of each integration.", + "operationId": "futoBackupsGetIntegrations", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntegrationsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get backup integrations", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/integrations/immich": { + "post": { + "description": "Set up backups for the Immich backup integration.", + "operationId": "futoBackupsConfigureImmichIntegration", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigureImmichIntegrationRequestDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigureImmichIntegrationResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Configure the Immich backup integration", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/integrations/immich/database-dump": { + "post": { + "description": "Configure the Immich database dumps included in backups.", + "operationId": "futoBackupsConfigureImmichDatabaseDump", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigureImmichDatabaseDumpRequestDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Configure Immich database dumps", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/integrations/immich/database-dump/ignore-warning": { + "post": { + "description": "Stop warning that Immich database dumps are not configured.", + "operationId": "futoBackupsIgnoreImmichDatabaseDumpWarning", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Ignore the Immich database dump warning", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/integrations/immich/rollback": { + "post": { + "description": "Put Immich into maintenance mode and restore it from a snapshot.", + "operationId": "futoBackupsStartImmichRollback", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImmichRollbackRequestDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Roll Immich back to a snapshot", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/integrations/immich/status": { + "get": { + "description": "Retrieve the backup status of the Immich integration.", + "operationId": "futoBackupsGetImmichBackupStatus", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImmichBackupStatusDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get Immich backup status", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/jobs": { + "get": { + "description": "List the jobs for a repository, newest first.", + "operationId": "futoBackupsGetJobs", + "parameters": [ + { + "name": "repositoryId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobListResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List jobs", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/jobs/{id}": { + "delete": { + "description": "Cancel a queued job, or stop it if it is already running.", + "operationId": "futoBackupsCancelJob", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Cancel a job", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + }, + "get": { + "description": "Get the status of a job.", + "operationId": "futoBackupsGetJob", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get a job", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/jobs/{id}/download": { + "get": { + "description": "Download the full log of a job as JSON lines.", + "operationId": "futoBackupsDownloadJobLog", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/jsonl": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Download a job log", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/jobs/{id}/support-log/download": { + "get": { + "description": "Download the verbose restic output recorded while the job ran in support mode, as JSON lines.", + "operationId": "futoBackupsDownloadJobSupportLog", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/jsonl": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Download a job support log", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/onboarding": { + "get": { + "description": "Retrieve which onboarding steps this server has completed.", + "operationId": "futoBackupsOnboardingStatus", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingStatusResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get backup onboarding status", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/onboarding/recovery-key": { + "get": { + "description": "Retrieve the recovery key that encrypts the backups of this server.", + "operationId": "futoBackupsCurrentRecoveryKey", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentRecoveryKeyResponse" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get the backup recovery key", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + }, + "post": { + "description": "Record that the user has saved the recovery key.", + "operationId": "futoBackupsConfirmRecoveryKey", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Confirm the backup recovery key", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + }, + "put": { + "description": "Replace the recovery key with one from an earlier installation.", + "operationId": "futoBackupsImportRecoveryKey", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportRecoveryKeyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Import a backup recovery key", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/onboarding/report-error": { + "post": { + "description": "Send the current startup error to telemetry.", + "operationId": "futoBackupsReportError", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Report a startup error", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/onboarding/skip": { + "post": { + "description": "Skip the optional configuration steps of onboarding.", + "operationId": "futoBackupsSkipOnboardingExtraConfig", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Skip optional backup onboarding", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/onboarding/telemetry": { + "post": { + "description": "Record that the user consents to sending telemetry.", + "operationId": "futoBackupsEnableTelemetry", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Enable FUTO Backups telemetry", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/repository": { + "get": { + "description": "List the backup repositories known to this server.", + "operationId": "futoBackupsGetRepositories", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryListResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List backups", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + }, + "post": { + "description": "Create a backup repository on a backend.", + "operationId": "futoBackupsCreateRepository", + "parameters": [ + { + "name": "backend", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryCreateRequestDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryCreateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Create a new backup", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/repository/inspect": { + "get": { + "description": "List repositories on a backend that are not configured on this server.", + "operationId": "futoBackupsInspectRepositories", + "parameters": [ + { + "name": "backend", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryInspectResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List remote backups", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/repository/{id}": { + "delete": { + "description": "Remove a repository from this server.", + "operationId": "futoBackupsDeleteRepository", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Remove a backup", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + }, + "patch": { + "description": "Update the settings of a repository.", + "operationId": "futoBackupsUpdateRepository", + "parameters": [ + { + "name": "backend", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryUpdateRequestDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryUpdateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Update a backup", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + }, + "post": { + "description": "Queue a backup to a repository and return the ID of its job.", + "operationId": "futoBackupsCreateBackup", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Start a backup", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/repository/{id}/backend": { + "put": { + "description": "Create a new empty repository on the selected backend and redirect future backups to it. Existing snapshots are not migrated.", + "operationId": "futoBackupsReconfigureRepositoryPrimaryBackend", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryPrimaryBackendReconfigureRequestDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryCreateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Change the primary backend", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/repository/{id}/import": { + "get": { + "description": "Check that an existing repository on a backend can be opened with the recovery key.", + "operationId": "futoBackupsCheckImportRepository", + "parameters": [ + { + "name": "backend", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryCheckImportResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Check a repository import", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + }, + "post": { + "description": "Add an existing repository on a backend to this server.", + "operationId": "futoBackupsImportRepository", + "parameters": [ + { + "name": "backend", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryCreateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Import a repository", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/repository/{id}/snapshots": { + "get": { + "description": "List the snapshots in a repository.", + "operationId": "futoBackupsGetSnapshots", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSnapshotsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List snapshots", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/repository/{id}/snapshots/prune": { + "post": { + "description": "Queue the removal of snapshots outside the retention policy and return the ID of its job.", + "operationId": "futoBackupsPruneRepository", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Prune a repository", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/repository/{id}/snapshots/{snapshot}": { + "delete": { + "description": "Remove a snapshot from a repository.", + "operationId": "futoBackupsForgetSnapshot", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "snapshot", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSnapshotsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Delete a snapshot", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + }, + "post": { + "description": "Queue a restore of files from a snapshot and return the ID of its job.", + "operationId": "futoBackupsRestoreSnapshot", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "snapshot", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySnapshotRestoreRequestDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Restore a snapshot", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/repository/{id}/snapshots/{snapshot}/listing": { + "get": { + "description": "List the files and folders at a path in a snapshot.", + "operationId": "futoBackupsGetSnapshotListing", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "path", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "snapshot", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FilesystemListingResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List snapshot files", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/repository/{id}/snapshots/{snapshot}/restore-from-point": { + "post": { + "description": "Restore the orchestrator to a previous state, wiping the current state.", + "operationId": "futoBackupsRestoreFromPoint", + "parameters": [ + { + "name": "backend", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "snapshot", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySnapshotRestoreFromPointRequestDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Restore from snapshot", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/repository/{id}/sync-deletion": { + "post": { + "description": "Remove a repository from this server once it has been deleted from its backend.", + "operationId": "futoBackupsSyncRepositoryDeletion", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySyncDeletionResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Sync a remote deletion", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/schedule": { + "get": { + "description": "List the backup schedules.", + "operationId": "futoBackupsGetSchedules", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleListResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List schedules", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + }, + "post": { + "description": "Create a backup schedule for one or more repositories.", + "operationId": "futoBackupsCreateSchedule", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleCreateRequestDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleCreateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Create a schedule", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/schedule/{id}": { + "delete": { + "description": "Delete a backup schedule.", + "operationId": "futoBackupsRemoveSchedule", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Delete a schedule", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + }, + "patch": { + "description": "Update a backup schedule.", + "operationId": "futoBackupsUpdateSchedule", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleUpdateRequestDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleUpdateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Update a schedule", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, + "/futo-backups/support-mode": { + "get": { + "description": "Retrieve how many upcoming runs use verbose restic logging, and whether they stream to FUTO.", + "operationId": "futoBackupsGetSupportMode", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SupportModeResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get support mode", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + }, + "put": { + "description": "Set how many upcoming runs use verbose restic logging, and whether they stream to FUTO.", + "operationId": "futoBackupsUpdateSupportMode", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SupportModeUpdateRequestDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SupportModeResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Update support mode", + "tags": [ + "Backups" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + }, + { + "version": "v3.4.0", + "state": "Internal" + } + ], + "x-immich-state": "Internal" + } + }, "/jobs": { "get": { "deprecated": true, @@ -18655,6 +21281,10 @@ "name": "Authentication (admin)", "description": "Administrative endpoints related to authentication." }, + { + "name": "Backups", + "description": "FUTO Backups." + }, { "name": "Cluster groups", "description": "A cluster group is a set of users whose faces are clustered together, so that a person can be shared between them." @@ -18940,6 +21570,10 @@ }, "AdminConfigBackupsDto": { "properties": { + "beta": { + "description": "Whether the backups feature is enabled", + "type": "boolean" + }, "database": { "$ref": "#/components/schemas/AdminConfigDatabaseBackupDto" } @@ -21991,6 +24625,92 @@ }, "type": "object" }, + "BackendDto": { + "properties": { + "description": { + "type": "string" + }, + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isOnline": { + "type": "boolean" + }, + "type": { + "$ref": "#/components/schemas/BackendType" + } + }, + "required": [ + "description", + "id", + "isOnline", + "type" + ], + "type": "object" + }, + "BackendResponseDto": { + "properties": { + "backend": { + "$ref": "#/components/schemas/BackendDto" + } + }, + "required": [ + "backend" + ], + "type": "object" + }, + "BackendType": { + "enum": [ + "yucca", + "local", + "s3" + ], + "type": "string" + }, + "BackendsResponseDto": { + "properties": { + "backends": { + "items": { + "$ref": "#/components/schemas/BackendDto" + }, + "type": "array" + } + }, + "required": [ + "backends" + ], + "type": "object" + }, + "BackupStatus": { + "enum": [ + "incomplete", + "complete", + "warn", + "failed", + "cancelled" + ], + "type": "string" + }, + "BandwidthDto": { + "properties": { + "bytesPerSec": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "quietHours": { + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d-([01]\\d|2[0-3]):[0-5]\\d$", + "type": "string" + } + }, + "required": [ + "bytesPerSec" + ], + "type": "object" + }, "BoolFilter": { "properties": { "eq": { @@ -22002,6 +24722,14 @@ ], "type": "object" }, + "BootstrapStatus": { + "enum": [ + "not-ready", + "ready", + "error" + ], + "type": "string" + }, "BulkIdErrorReason": { "description": "Error reason", "enum": [ @@ -22226,6 +24954,104 @@ ], "type": "string" }, + "ConfigResponseDto": { + "properties": { + "bandwidth": { + "$ref": "#/components/schemas/BandwidthDto" + } + }, + "required": [ + "bandwidth" + ], + "type": "object" + }, + "ConfigUpdateRequestDto": { + "properties": { + "bandwidth": { + "$ref": "#/components/schemas/BandwidthDto" + } + }, + "required": [ + "bandwidth" + ], + "type": "object" + }, + "ConfigureImmichDatabaseDumpRequestDto": { + "properties": { + "enabled": { + "type": "boolean" + }, + "keepLastAmount": { + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "ConfigureImmichIntegrationRequestDto": { + "properties": { + "backupConfiguration": { + "type": "boolean" + }, + "cron": { + "type": "string" + }, + "dataFolders": { + "items": { + "type": "string" + }, + "type": "array" + }, + "libraries": { + "description": "IDs of the external libraries to back up. Use [\"all\"] to back up every library, including ones added later.", + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "paused": { + "type": "boolean" + }, + "repositoryId": { + "type": "string" + }, + "retentionPolicy": { + "allOf": [ + { + "$ref": "#/components/schemas/RetentionPolicyDto" + } + ], + "nullable": true + }, + "worm": { + "type": "boolean" + } + }, + "required": [ + "backupConfiguration", + "cron", + "dataFolders", + "libraries", + "name", + "worm" + ], + "type": "object" + }, + "ConfigureImmichIntegrationResponseDto": { + "properties": { + "repositoryId": { + "type": "string" + } + }, + "required": [ + "repositoryId" + ], + "type": "object" + }, "ContributorCountResponseDto": { "properties": { "assetCount": { @@ -22326,6 +25152,17 @@ ], "type": "object" }, + "CreateLocalBackendRequestDto": { + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, "CreateProfileImageDto": { "properties": { "file": { @@ -22366,6 +25203,17 @@ ], "type": "object" }, + "CreateSessionRequestDto": { + "properties": { + "token": { + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, "CropParameters": { "properties": { "height": { @@ -22401,6 +25249,17 @@ ], "type": "object" }, + "CurrentRecoveryKeyResponse": { + "properties": { + "recoveryKey": { + "type": "string" + } + }, + "required": [ + "recoveryKey" + ], + "type": "object" + }, "DatabaseBackupDeleteDto": { "properties": { "backups": { @@ -22549,6 +25408,49 @@ }, "type": "object" }, + "DeviceFlowEventDto": { + "properties": { + "backendId": { + "type": "string" + }, + "reason": { + "$ref": "#/components/schemas/DeviceFlowFailureReason" + }, + "token": { + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/DeviceFlowEventType" + }, + "userCode": { + "type": "string" + }, + "verificationUri": { + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "DeviceFlowEventType": { + "enum": [ + "START", + "SUCCESS", + "FAILURE" + ], + "type": "string" + }, + "DeviceFlowFailureReason": { + "enum": [ + "NOT_CONNECTED", + "DEVICE_FLOW_FAILED", + "WRONG_ACCOUNT", + "UNKNOWN" + ], + "type": "string" + }, "DownloadArchiveDto": { "properties": { "archiveName": { @@ -23024,6 +25926,43 @@ ], "type": "object" }, + "FilesystemListingItemDto": { + "properties": { + "isDirectory": { + "type": "boolean" + }, + "path": { + "type": "string" + } + }, + "required": [ + "isDirectory", + "path" + ], + "type": "object" + }, + "FilesystemListingResponseDto": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/FilesystemListingItemDto" + }, + "type": "array" + }, + "parent": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "items", + "parent", + "path" + ], + "type": "object" + }, "FoldersResponse": { "properties": { "enabled": { @@ -23138,6 +26077,236 @@ ], "type": "string" }, + "ImmichBackupStatusDto": { + "properties": { + "backend": { + "$ref": "#/components/schemas/BackendDto" + }, + "databaseDump": { + "$ref": "#/components/schemas/ImmichDatabaseDumpConfigDto" + }, + "databaseDumpWarningIgnored": { + "type": "boolean" + }, + "integration": { + "$ref": "#/components/schemas/ImmichIntegrationDto" + }, + "latestBackupJob": { + "$ref": "#/components/schemas/JobDto" + }, + "repository": { + "$ref": "#/components/schemas/LocalRepositoryDto" + }, + "schedule": { + "$ref": "#/components/schemas/ScheduleDto" + } + }, + "type": "object" + }, + "ImmichDatabaseDumpConfigDto": { + "properties": { + "enabled": { + "type": "boolean" + }, + "keepLastAmount": { + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "enabled", + "keepLastAmount" + ], + "type": "object" + }, + "ImmichIntegrationConfigurationDto": { + "properties": { + "backupConfiguration": { + "type": "boolean" + }, + "dataFolders": { + "items": { + "type": "string" + }, + "type": "array" + }, + "libraries": { + "description": "IDs of the external libraries to back up. Use [\"all\"] to back up every library, including ones added later.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "backupConfiguration", + "dataFolders", + "libraries" + ], + "type": "object" + }, + "ImmichIntegrationDto": { + "properties": { + "configuration": { + "$ref": "#/components/schemas/ImmichIntegrationConfigurationDto" + }, + "id": { + "type": "string" + }, + "scheduleId": { + "type": "string" + } + }, + "required": [ + "configuration", + "id", + "scheduleId" + ], + "type": "object" + }, + "ImmichLibraryDto": { + "properties": { + "exclusionPatterns": { + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "importPaths": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + } + }, + "required": [ + "exclusionPatterns", + "id", + "importPaths", + "name" + ], + "type": "object" + }, + "ImmichRollbackRequestDto": { + "properties": { + "backupFileName": { + "type": "string" + }, + "repositoryId": { + "type": "string" + }, + "snapshotId": { + "type": "string" + } + }, + "required": [ + "repositoryId", + "snapshotId" + ], + "type": "object" + }, + "ImmichStateDto": { + "properties": { + "dataFolders": { + "items": { + "type": "string" + }, + "type": "array" + }, + "dataPath": { + "type": "string" + }, + "libraries": { + "items": { + "$ref": "#/components/schemas/ImmichLibraryDto" + }, + "type": "array" + } + }, + "required": [ + "dataFolders", + "dataPath", + "libraries" + ], + "type": "object" + }, + "ImportRecoveryKeyRequest": { + "properties": { + "recoveryKey": { + "type": "string" + } + }, + "required": [ + "recoveryKey" + ], + "type": "object" + }, + "InspectedLocalRepositoryDto": { + "properties": { + "backends": { + "$ref": "#/components/schemas/RepositoryBackendsDto" + }, + "configuration": { + "$ref": "#/components/schemas/RepositoryConfigurationDto" + }, + "id": { + "type": "string" + }, + "meter": { + "$ref": "#/components/schemas/RepositoryMeterDto" + }, + "metrics": { + "$ref": "#/components/schemas/RepositoryMetricsDto" + }, + "name": { + "type": "string" + }, + "siteCode": { + "nullable": true, + "type": "string" + }, + "snapshots": { + "items": { + "$ref": "#/components/schemas/SnapshotDto" + }, + "type": "array" + }, + "storageClusterCode": { + "nullable": true, + "type": "string" + }, + "worm": { + "type": "boolean" + } + }, + "required": [ + "id", + "metrics", + "name", + "siteCode", + "storageClusterCode", + "worm" + ], + "type": "object" + }, + "IntegrationsResponseDto": { + "properties": { + "immichIntegration": { + "$ref": "#/components/schemas/ImmichIntegrationDto" + }, + "immichState": { + "$ref": "#/components/schemas/ImmichStateDto" + } + }, + "type": "object" + }, "IntegrityReport": { "description": "Integrity report type", "enum": [ @@ -23220,6 +26389,59 @@ ], "type": "object" }, + "JobDto": { + "properties": { + "end": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "$ref": "#/components/schemas/YuccaJobName" + }, + "outcome": { + "$ref": "#/components/schemas/JobOutcomeDto" + }, + "progress": { + "$ref": "#/components/schemas/JobProgressDto" + }, + "queuedAt": { + "type": "string" + }, + "repositoryId": { + "type": "string" + }, + "start": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/YuccaJobStatus" + } + }, + "required": [ + "id", + "name", + "queuedAt", + "repositoryId", + "status" + ], + "type": "object" + }, + "JobListResponseDto": { + "properties": { + "jobs": { + "items": { + "$ref": "#/components/schemas/JobDto" + }, + "type": "array" + } + }, + "required": [ + "jobs" + ], + "type": "object" + }, "JobName": { "description": "Job name", "enum": [ @@ -23293,6 +26515,201 @@ ], "type": "string" }, + "JobOutcomeDto": { + "properties": { + "errors": { + "items": { + "type": "string" + }, + "type": "array" + }, + "summary": { + "$ref": "#/components/schemas/JobSummaryDto" + }, + "support": { + "$ref": "#/components/schemas/JobSupportDto" + } + }, + "required": [ + "errors" + ], + "type": "object" + }, + "JobProgressDto": { + "properties": { + "bytesDone": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "currentFiles": { + "items": { + "type": "string" + }, + "type": "array" + }, + "estimatedEndAt": { + "type": "string" + }, + "percentDone": { + "format": "double", + "type": "number" + }, + "scanning": { + "type": "boolean" + }, + "totalBytes": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + } + }, + "required": [ + "currentFiles", + "percentDone", + "scanning" + ], + "type": "object" + }, + "JobResponseDto": { + "properties": { + "jobId": { + "type": "string" + } + }, + "required": [ + "jobId" + ], + "type": "object" + }, + "JobSummaryDto": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "backup" + ] + }, + "filesNew": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "filesChanged": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalFilesProcessed": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalBytesProcessed": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "snapshotId": { + "type": "string" + } + }, + "required": [ + "type", + "filesNew", + "filesChanged", + "totalFilesProcessed", + "totalBytesProcessed" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "restore" + ] + }, + "filesRestored": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "filesSkipped": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "bytesRestored": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "filesRestored", + "filesSkipped", + "bytesRestored" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "prune" + ] + }, + "kept": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "removed": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "kept", + "removed" + ] + } + ] + }, + "JobSupportDto": { + "properties": { + "droppedLines": { + "description": "Log lines that could not be streamed in time", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "localDroppedLines": { + "description": "Log lines that could not be written to the local support log", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "sessionId": { + "description": "Support code to share with FUTO when the logs were streamed", + "type": "string" + } + }, + "required": [ + "droppedLines", + "localDroppedLines" + ], + "type": "object" + }, "LibraryResponseDto": { "properties": { "assetCount": { @@ -23423,6 +26840,62 @@ "LicenseResponseDto": { "$ref": "#/components/schemas/UserLicense" }, + "ListSnapshotsResponseDto": { + "properties": { + "snapshots": { + "items": { + "$ref": "#/components/schemas/SnapshotDto" + }, + "type": "array" + } + }, + "required": [ + "snapshots" + ], + "type": "object" + }, + "LocalRepositoryDto": { + "properties": { + "backends": { + "$ref": "#/components/schemas/RepositoryBackendsDto" + }, + "configuration": { + "$ref": "#/components/schemas/RepositoryConfigurationDto" + }, + "id": { + "type": "string" + }, + "meter": { + "$ref": "#/components/schemas/RepositoryMeterDto" + }, + "metrics": { + "$ref": "#/components/schemas/RepositoryMetricsDto" + }, + "name": { + "type": "string" + }, + "siteCode": { + "nullable": true, + "type": "string" + }, + "storageClusterCode": { + "nullable": true, + "type": "string" + }, + "worm": { + "type": "boolean" + } + }, + "required": [ + "id", + "metrics", + "name", + "siteCode", + "storageClusterCode", + "worm" + ], + "type": "object" + }, "LogLevel": { "description": "Log level", "enum": [ @@ -23530,7 +27003,8 @@ "start", "end", "select_database_restore", - "restore_database" + "restore_database", + "rollback" ], "type": "string" }, @@ -23608,6 +27082,10 @@ "error": { "type": "string" }, + "futoBackupsJobId": { + "description": "FUTO Backups job ID", + "type": "string" + }, "progress": { "maximum": 9007199254740991, "minimum": -9007199254740991, @@ -25288,6 +28766,52 @@ ], "type": "object" }, + "OnboardingStatusResponseDto": { + "properties": { + "error": { + "type": "string" + }, + "hasBackend": { + "type": "boolean" + }, + "hasBackup": { + "type": "boolean" + }, + "hasOnboardedKey": { + "type": "boolean" + }, + "hasSchedule": { + "type": "boolean" + }, + "hasSkippedExtraConfig": { + "type": "boolean" + }, + "hasTelemetry": { + "$ref": "#/components/schemas/TelemetryLevel" + }, + "isAuthenticated": { + "type": "boolean" + }, + "requiresAuthentication": { + "type": "boolean" + }, + "status": { + "$ref": "#/components/schemas/BootstrapStatus" + } + }, + "required": [ + "hasBackend", + "hasBackup", + "hasOnboardedKey", + "hasSchedule", + "hasSkippedExtraConfig", + "hasTelemetry", + "isAuthenticated", + "requiresAuthentication", + "status" + ], + "type": "object" + }, "PartnerCreateDto": { "properties": { "sharedWithId": { @@ -27774,6 +31298,321 @@ ], "type": "string" }, + "RepositoryBackendDto": { + "properties": { + "id": { + "type": "string" + }, + "online": { + "type": "boolean" + }, + "type": { + "$ref": "#/components/schemas/BackendType" + } + }, + "required": [ + "id", + "online", + "type" + ], + "type": "object" + }, + "RepositoryBackendsDto": { + "properties": { + "primary": { + "$ref": "#/components/schemas/RepositoryBackendDto" + }, + "secondary": { + "items": { + "$ref": "#/components/schemas/RepositoryBackendDto" + }, + "type": "array" + } + }, + "required": [ + "primary", + "secondary" + ], + "type": "object" + }, + "RepositoryCheckImportResponseDto": { + "properties": { + "readable": { + "type": "boolean" + } + }, + "required": [ + "readable" + ], + "type": "object" + }, + "RepositoryConfigurationDto": { + "properties": { + "paths": { + "items": { + "type": "string" + }, + "type": "array" + }, + "retentionPolicy": { + "allOf": [ + { + "$ref": "#/components/schemas/RetentionPolicyDto" + } + ], + "nullable": true + } + }, + "required": [ + "paths" + ], + "type": "object" + }, + "RepositoryCreateRequestDto": { + "properties": { + "name": { + "type": "string" + }, + "paths": { + "items": { + "type": "string" + }, + "type": "array" + }, + "retentionPolicy": { + "allOf": [ + { + "$ref": "#/components/schemas/RetentionPolicyDto" + } + ], + "nullable": true + }, + "site": { + "description": "Internal site code from environment metadata", + "type": "string" + }, + "worm": { + "type": "boolean" + } + }, + "required": [ + "name", + "worm" + ], + "type": "object" + }, + "RepositoryCreateResponseDto": { + "properties": { + "repository": { + "$ref": "#/components/schemas/LocalRepositoryDto" + } + }, + "required": [ + "repository" + ], + "type": "object" + }, + "RepositoryInspectResponseDto": { + "properties": { + "repositories": { + "items": { + "$ref": "#/components/schemas/InspectedLocalRepositoryDto" + }, + "type": "array" + } + }, + "required": [ + "repositories" + ], + "type": "object" + }, + "RepositoryListResponseDto": { + "properties": { + "repositories": { + "items": { + "$ref": "#/components/schemas/LocalRepositoryDto" + }, + "type": "array" + } + }, + "required": [ + "repositories" + ], + "type": "object" + }, + "RepositoryMeterDto": { + "properties": { + "lastUpdated": { + "nullable": true, + "type": "string" + }, + "objectCount": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "sizeBytes": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + } + }, + "required": [ + "objectCount", + "sizeBytes" + ], + "type": "object" + }, + "RepositoryMetricsDto": { + "properties": { + "lastBackup": { + "nullable": true, + "type": "string" + }, + "lastBackupDuration": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "nullable": true, + "type": "integer" + }, + "lastBackupStatus": { + "allOf": [ + { + "$ref": "#/components/schemas/BackupStatus" + } + ], + "nullable": true + }, + "lastStarted": { + "nullable": true, + "type": "string" + }, + "sizeBytes": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + } + }, + "required": [ + "sizeBytes" + ], + "type": "object" + }, + "RepositoryPrimaryBackendReconfigureRequestDto": { + "properties": { + "backendId": { + "type": "string" + } + }, + "required": [ + "backendId" + ], + "type": "object" + }, + "RepositorySnapshotRestoreFromPointRequestDto": { + "properties": { + "include": { + "items": { + "type": "string" + }, + "type": "array" + }, + "yuccaConfig": { + "type": "string" + } + }, + "type": "object" + }, + "RepositorySnapshotRestoreRequestDto": { + "properties": { + "include": { + "items": { + "type": "string" + }, + "type": "array" + }, + "target": { + "type": "string" + } + }, + "type": "object" + }, + "RepositorySyncDeletionResponseDto": { + "properties": { + "deleted": { + "description": "Whether the repository was deleted remotely and removed from this server", + "type": "boolean" + } + }, + "required": [ + "deleted" + ], + "type": "object" + }, + "RepositoryUpdateRequestDto": { + "properties": { + "name": { + "type": "string" + }, + "paths": { + "items": { + "type": "string" + }, + "type": "array" + }, + "retentionPolicy": { + "allOf": [ + { + "$ref": "#/components/schemas/RetentionPolicyDto" + } + ], + "nullable": true + }, + "worm": { + "type": "boolean" + } + }, + "type": "object" + }, + "RepositoryUpdateResponseDto": { + "properties": { + "repository": { + "$ref": "#/components/schemas/LocalRepositoryDto" + } + }, + "required": [ + "repository" + ], + "type": "object" + }, + "RetentionPolicyDto": { + "properties": { + "keepLast": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "keepWithin": { + "type": "string" + }, + "keepWithinDaily": { + "type": "string" + }, + "keepWithinHourly": { + "type": "string" + }, + "keepWithinMonthly": { + "type": "string" + }, + "keepWithinWeekly": { + "type": "string" + }, + "keepWithinYearly": { + "type": "string" + } + }, + "type": "object" + }, "ReverseGeocodingStateResponseDto": { "properties": { "lastImportFileName": { @@ -27807,6 +31646,124 @@ ], "type": "object" }, + "ScheduleCreateRequestDto": { + "properties": { + "cron": { + "type": "string" + }, + "name": { + "type": "string" + }, + "paused": { + "default": false, + "type": "boolean" + }, + "repositories": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "cron", + "name", + "repositories" + ], + "type": "object" + }, + "ScheduleCreateResponseDto": { + "properties": { + "schedule": { + "$ref": "#/components/schemas/ScheduleDto" + } + }, + "required": [ + "schedule" + ], + "type": "object" + }, + "ScheduleDto": { + "properties": { + "cron": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lastFinished": { + "type": "string" + }, + "lastRun": { + "type": "string" + }, + "name": { + "type": "string" + }, + "paused": { + "type": "boolean" + }, + "repositories": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "cron", + "id", + "name", + "paused", + "repositories" + ], + "type": "object" + }, + "ScheduleListResponseDto": { + "properties": { + "schedules": { + "items": { + "$ref": "#/components/schemas/ScheduleDto" + }, + "type": "array" + } + }, + "required": [ + "schedules" + ], + "type": "object" + }, + "ScheduleUpdateRequestDto": { + "properties": { + "cron": { + "type": "string" + }, + "name": { + "type": "string" + }, + "paused": { + "type": "boolean" + }, + "repositories": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "ScheduleUpdateResponseDto": { + "properties": { + "schedule": { + "$ref": "#/components/schemas/ScheduleDto" + } + }, + "required": [ + "schedule" + ], + "type": "object" + }, "SearchAlbumResponseDto": { "properties": { "count": { @@ -28478,6 +32435,10 @@ }, "ServerFeaturesDto": { "properties": { + "backups": { + "description": "Whether the backups feature is enabled", + "type": "boolean" + }, "configFile": { "description": "Whether config file is available", "type": "boolean" @@ -28544,6 +32505,7 @@ } }, "required": [ + "backups", "configFile", "duplicateDetection", "email", @@ -28952,6 +32914,14 @@ "restoreBackupFilename": { "description": "Restore backup filename", "type": "string" + }, + "rollbackRepositoryId": { + "description": "Rollback repository ID", + "type": "string" + }, + "rollbackSnapshotId": { + "description": "Rollback snapshot ID", + "type": "string" } }, "required": [ @@ -29920,6 +33890,80 @@ }, "type": "object" }, + "SnapshotDto": { + "properties": { + "id": { + "type": "string" + }, + "paths": { + "items": { + "type": "string" + }, + "type": "array" + }, + "summary": { + "$ref": "#/components/schemas/SnapshotSummaryDto" + }, + "tags": { + "items": { + "type": "string" + }, + "type": "array" + }, + "time": { + "type": "string" + } + }, + "required": [ + "id", + "paths", + "time" + ], + "type": "object" + }, + "SnapshotSummaryDto": { + "properties": { + "dataAdded": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "filesChanged": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "filesNew": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "filesUnmodified": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "totalBytes": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "totalFiles": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + } + }, + "required": [ + "dataAdded", + "filesChanged", + "filesNew", + "filesUnmodified", + "totalBytes", + "totalFiles" + ], + "type": "object" + }, "SourceType": { "description": "Face detection source type", "enum": [ @@ -30740,6 +34784,59 @@ ], "type": "object" }, + "SupportModeResponseDto": { + "properties": { + "includePaths": { + "description": "Send file paths as-is instead of redacted placeholders", + "type": "boolean" + }, + "remainingRuns": { + "description": "How many more backup, cleanup, restore and delete runs use verbose restic logging", + "maximum": 20, + "minimum": 0, + "type": "integer" + }, + "streamToFuto": { + "description": "Stream those runs’ logs to FUTO; requires the support-log-streaming feature", + "type": "boolean" + }, + "streamingAvailable": { + "description": "Whether this account may stream support logs to FUTO", + "type": "boolean" + } + }, + "required": [ + "includePaths", + "remainingRuns", + "streamToFuto", + "streamingAvailable" + ], + "type": "object" + }, + "SupportModeUpdateRequestDto": { + "properties": { + "includePaths": { + "description": "Send file paths as-is instead of redacted placeholders", + "type": "boolean" + }, + "remainingRuns": { + "description": "How many more backup, cleanup, restore and delete runs use verbose restic logging", + "maximum": 20, + "minimum": 0, + "type": "integer" + }, + "streamToFuto": { + "description": "Stream those runs’ logs to FUTO; requires the support-log-streaming feature", + "type": "boolean" + } + }, + "required": [ + "includePaths", + "remainingRuns", + "streamToFuto" + ], + "type": "object" + }, "SyncAckDeleteDto": { "properties": { "types": { @@ -32979,6 +37076,13 @@ }, "type": "object" }, + "TelemetryLevel": { + "enum": [ + "full", + "none" + ], + "type": "string" + }, "TemplateDto": { "properties": { "template": { @@ -33020,6 +37124,43 @@ ], "type": "object" }, + "TicketAction": { + "enum": [ + "repository.delete", + "repository.disable-worm" + ], + "type": "string" + }, + "TicketCreateRequestDto": { + "properties": { + "action": { + "$ref": "#/components/schemas/TicketAction" + }, + "repositoryId": { + "description": "Repository the ticket is bound to", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + }, + "required": [ + "action", + "repositoryId" + ], + "type": "object" + }, + "TicketCreateResponseDto": { + "properties": { + "redirectTo": { + "description": "Identity provider URL the browser must be sent to", + "type": "string" + } + }, + "required": [ + "redirectTo" + ], + "type": "object" + }, "TimeBucketAssetResponseDto": { "properties": { "city": { @@ -34737,6 +38878,29 @@ } }, "type": "object" + }, + "YuccaJobName": { + "enum": [ + "repository-backup", + "repository-prune", + "repository-restore", + "repository-restore-from-point", + "repository-snapshot-forget", + "repository-sync-metrics" + ], + "type": "string" + }, + "YuccaJobStatus": { + "enum": [ + "queued", + "running", + "complete", + "warn", + "failed", + "cancelled", + "incomplete" + ], + "type": "string" } } } diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index bc68fcdc4..bf2edd1d3 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -63,6 +63,8 @@ export type AdminConfigDatabaseBackupDto = { keepLastAmount: number; }; export type AdminConfigBackupsDto = { + /** Whether the backups feature is enabled */ + beta?: boolean; database: AdminConfigDatabaseBackupDto; }; export type AdminConfigFFmpegRealtimeDto = { @@ -478,6 +480,10 @@ export type SetMaintenanceModeDto = { action: MaintenanceAction; /** Restore backup filename */ restoreBackupFilename?: string; + /** Rollback repository ID */ + rollbackRepositoryId?: string; + /** Rollback snapshot ID */ + rollbackSnapshotId?: string; }; export type MaintenanceDetectInstallStorageFolderDto = { /** Number of files in the folder */ @@ -503,6 +509,8 @@ export type MaintenanceStatusResponseDto = { action: MaintenanceAction; active: boolean; error?: string; + /** FUTO Backups job ID */ + futoBackupsJobId?: string; progress?: number; task?: string; }; @@ -1764,6 +1772,364 @@ export type FaceDto = { /** Face ID */ id: string; }; +export type DeviceFlowEventDto = { + backendId?: string; + reason?: DeviceFlowFailureReason; + token?: string; + "type": DeviceFlowEventType; + userCode?: string; + verificationUri?: string; +}; +export type CreateSessionRequestDto = { + token: string; +}; +export type TicketCreateRequestDto = { + action: TicketAction; + /** Repository the ticket is bound to */ + repositoryId: string; +}; +export type TicketCreateResponseDto = { + /** Identity provider URL the browser must be sent to */ + redirectTo: string; +}; +export type BackendDto = { + description: string; + error?: string; + id: string; + isOnline: boolean; + "type": BackendType; +}; +export type BackendsResponseDto = { + backends: BackendDto[]; +}; +export type CreateLocalBackendRequestDto = { + path: string; +}; +export type BackendResponseDto = { + backend: BackendDto; +}; +export type BandwidthDto = { + bytesPerSec: number; + quietHours?: string; +}; +export type ConfigResponseDto = { + bandwidth: BandwidthDto; +}; +export type ConfigUpdateRequestDto = { + bandwidth: BandwidthDto; +}; +export type FilesystemListingItemDto = { + isDirectory: boolean; + path: string; +}; +export type FilesystemListingResponseDto = { + items: FilesystemListingItemDto[]; + parent: string; + path: string; +}; +export type ImmichIntegrationConfigurationDto = { + backupConfiguration: boolean; + dataFolders: string[]; + /** IDs of the external libraries to back up. Use ["all"] to back up every library, including ones added later. */ + libraries: string[]; +}; +export type ImmichIntegrationDto = { + configuration: ImmichIntegrationConfigurationDto; + id: string; + scheduleId: string; +}; +export type ImmichLibraryDto = { + exclusionPatterns: string[]; + id: string; + importPaths: string[]; + name: string; +}; +export type ImmichStateDto = { + dataFolders: string[]; + dataPath: string; + libraries: ImmichLibraryDto[]; +}; +export type IntegrationsResponseDto = { + immichIntegration?: ImmichIntegrationDto; + immichState?: ImmichStateDto; +}; +export type RetentionPolicyDto = { + keepLast?: number; + keepWithin?: string; + keepWithinDaily?: string; + keepWithinHourly?: string; + keepWithinMonthly?: string; + keepWithinWeekly?: string; + keepWithinYearly?: string; +}; +export type ConfigureImmichIntegrationRequestDto = { + backupConfiguration: boolean; + cron: string; + dataFolders: string[]; + /** IDs of the external libraries to back up. Use ["all"] to back up every library, including ones added later. */ + libraries: string[]; + name: string; + paused?: boolean; + repositoryId?: string; + retentionPolicy?: (RetentionPolicyDto) | null; + worm: boolean; +}; +export type ConfigureImmichIntegrationResponseDto = { + repositoryId: string; +}; +export type ConfigureImmichDatabaseDumpRequestDto = { + enabled?: boolean; + keepLastAmount?: number; +}; +export type ImmichRollbackRequestDto = { + backupFileName?: string; + repositoryId: string; + snapshotId: string; +}; +export type ImmichDatabaseDumpConfigDto = { + enabled: boolean; + keepLastAmount: number; +}; +export type JobSummaryDto = { + "type": Type; + filesNew: number; + filesChanged: number; + totalFilesProcessed: number; + totalBytesProcessed: number; + snapshotId?: string; +} | { + "type": Type2; + filesRestored: number; + filesSkipped: number; + bytesRestored: number; +} | { + "type": Type3; + kept: number; + removed: number; +}; +export type JobSupportDto = { + /** Log lines that could not be streamed in time */ + droppedLines: number; + /** Log lines that could not be written to the local support log */ + localDroppedLines: number; + /** Support code to share with FUTO when the logs were streamed */ + sessionId?: string; +}; +export type JobOutcomeDto = { + errors: string[]; + summary?: JobSummaryDto; + support?: JobSupportDto; +}; +export type JobProgressDto = { + bytesDone?: number; + currentFiles: string[]; + estimatedEndAt?: string; + percentDone: number; + scanning: boolean; + totalBytes?: number; +}; +export type JobDto = { + end?: string; + id: string; + name: YuccaJobName; + outcome?: JobOutcomeDto; + progress?: JobProgressDto; + queuedAt: string; + repositoryId: string; + start?: string; + status: YuccaJobStatus; +}; +export type RepositoryBackendDto = { + id: string; + online: boolean; + "type": BackendType; +}; +export type RepositoryBackendsDto = { + primary: RepositoryBackendDto; + secondary: RepositoryBackendDto[]; +}; +export type RepositoryConfigurationDto = { + paths: string[]; + retentionPolicy?: (RetentionPolicyDto) | null; +}; +export type RepositoryMeterDto = { + lastUpdated?: string | null; + objectCount: number; + sizeBytes: number; +}; +export type RepositoryMetricsDto = { + lastBackup?: string | null; + lastBackupDuration?: number | null; + lastBackupStatus?: (BackupStatus) | null; + lastStarted?: string | null; + sizeBytes: number; +}; +export type LocalRepositoryDto = { + backends?: RepositoryBackendsDto; + configuration?: RepositoryConfigurationDto; + id: string; + meter?: RepositoryMeterDto; + metrics: RepositoryMetricsDto; + name: string; + siteCode: string | null; + storageClusterCode: string | null; + worm: boolean; +}; +export type ScheduleDto = { + cron: string; + id: string; + lastFinished?: string; + lastRun?: string; + name: string; + paused: boolean; + repositories: string[]; +}; +export type ImmichBackupStatusDto = { + backend?: BackendDto; + databaseDump?: ImmichDatabaseDumpConfigDto; + databaseDumpWarningIgnored?: boolean; + integration?: ImmichIntegrationDto; + latestBackupJob?: JobDto; + repository?: LocalRepositoryDto; + schedule?: ScheduleDto; +}; +export type JobListResponseDto = { + jobs: JobDto[]; +}; +export type OnboardingStatusResponseDto = { + error?: string; + hasBackend: boolean; + hasBackup: boolean; + hasOnboardedKey: boolean; + hasSchedule: boolean; + hasSkippedExtraConfig: boolean; + hasTelemetry: TelemetryLevel; + isAuthenticated: boolean; + requiresAuthentication: boolean; + status: BootstrapStatus; +}; +export type CurrentRecoveryKeyResponse = { + recoveryKey: string; +}; +export type ImportRecoveryKeyRequest = { + recoveryKey: string; +}; +export type RepositoryListResponseDto = { + repositories: LocalRepositoryDto[]; +}; +export type RepositoryCreateRequestDto = { + name: string; + paths?: string[]; + retentionPolicy?: (RetentionPolicyDto) | null; + /** Internal site code from environment metadata */ + site?: string; + worm: boolean; +}; +export type RepositoryCreateResponseDto = { + repository: LocalRepositoryDto; +}; +export type SnapshotSummaryDto = { + dataAdded: number; + filesChanged: number; + filesNew: number; + filesUnmodified: number; + totalBytes: number; + totalFiles: number; +}; +export type SnapshotDto = { + id: string; + paths: string[]; + summary?: SnapshotSummaryDto; + tags?: string[]; + time: string; +}; +export type InspectedLocalRepositoryDto = { + backends?: RepositoryBackendsDto; + configuration?: RepositoryConfigurationDto; + id: string; + meter?: RepositoryMeterDto; + metrics: RepositoryMetricsDto; + name: string; + siteCode: string | null; + snapshots?: SnapshotDto[]; + storageClusterCode: string | null; + worm: boolean; +}; +export type RepositoryInspectResponseDto = { + repositories: InspectedLocalRepositoryDto[]; +}; +export type RepositoryUpdateRequestDto = { + name?: string; + paths?: string[]; + retentionPolicy?: (RetentionPolicyDto) | null; + worm?: boolean; +}; +export type RepositoryUpdateResponseDto = { + repository: LocalRepositoryDto; +}; +export type JobResponseDto = { + jobId: string; +}; +export type RepositoryPrimaryBackendReconfigureRequestDto = { + backendId: string; +}; +export type RepositoryCheckImportResponseDto = { + readable: boolean; +}; +export type ListSnapshotsResponseDto = { + snapshots: SnapshotDto[]; +}; +export type RepositorySnapshotRestoreRequestDto = { + include?: string[]; + target?: string; +}; +export type RepositorySnapshotRestoreFromPointRequestDto = { + include?: string[]; + yuccaConfig?: string; +}; +export type RepositorySyncDeletionResponseDto = { + /** Whether the repository was deleted remotely and removed from this server */ + deleted: boolean; +}; +export type ScheduleListResponseDto = { + schedules: ScheduleDto[]; +}; +export type ScheduleCreateRequestDto = { + cron: string; + name: string; + paused?: boolean; + repositories: string[]; +}; +export type ScheduleCreateResponseDto = { + schedule: ScheduleDto; +}; +export type ScheduleUpdateRequestDto = { + cron?: string; + name?: string; + paused?: boolean; + repositories?: string[]; +}; +export type ScheduleUpdateResponseDto = { + schedule: ScheduleDto; +}; +export type SupportModeResponseDto = { + /** Send file paths as-is instead of redacted placeholders */ + includePaths: boolean; + /** How many more backup, cleanup, restore and delete runs use verbose restic logging */ + remainingRuns: number; + /** Stream those runs’ logs to FUTO; requires the support-log-streaming feature */ + streamToFuto: boolean; + /** Whether this account may stream support logs to FUTO */ + streamingAvailable: boolean; +}; +export type SupportModeUpdateRequestDto = { + /** Send file paths as-is instead of redacted placeholders */ + includePaths: boolean; + /** How many more backup, cleanup, restore and delete runs use verbose restic logging */ + remainingRuns: number; + /** Stream those runs’ logs to FUTO; requires the support-log-streaming feature */ + streamToFuto: boolean; +}; export type QueueStatisticsDto = { /** Number of active jobs */ active: number; @@ -2885,6 +3251,8 @@ export type ServerConfigDto = { userDeleteDelay: number; }; export type ServerFeaturesDto = { + /** Whether the backups feature is enabled */ + backups: boolean; /** Whether config file is available */ configFile: boolean; /** Whether duplicate detection is enabled */ @@ -5617,6 +5985,662 @@ export function reassignFacesById({ id, faceDto }: { body: faceDto }))); } +/** + * Connect a FUTO Backups account + */ +export function futoBackupsConnectDeviceFlow(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: DeviceFlowEventDto; + }>("/futo-backups/auth/oidc/device", { + ...opts + })); +} +/** + * Create session + */ +export function futoBackupsCreateSession({ createSessionRequestDto }: { + createSessionRequestDto: CreateSessionRequestDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/futo-backups/auth/session", oazapfts.json({ + ...opts, + method: "POST", + body: createSessionRequestDto + }))); +} +/** + * Sign in with FUTO Backups account + */ +export function futoBackupsSessionDeviceFlow(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: DeviceFlowEventDto; + }>("/futo-backups/auth/session/device", { + ...opts + })); +} +/** + * Create a ticket + */ +export function futoBackupsCreateTicket({ ticketCreateRequestDto }: { + ticketCreateRequestDto: TicketCreateRequestDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: TicketCreateResponseDto; + }>("/futo-backups/auth/ticket", oazapfts.json({ + ...opts, + method: "POST", + body: ticketCreateRequestDto + }))); +} +/** + * List backends + */ +export function futoBackupsGetBackends(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: BackendsResponseDto; + }>("/futo-backups/backend", { + ...opts + })); +} +/** + * Create a local backend + */ +export function futoBackupsCreateLocalBackend({ createLocalBackendRequestDto }: { + createLocalBackendRequestDto: CreateLocalBackendRequestDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: BackendResponseDto; + }>("/futo-backups/backend/local", oazapfts.json({ + ...opts, + method: "POST", + body: createLocalBackendRequestDto + }))); +} +/** + * Get backups orchestrator configuration + */ +export function futoBackupsGetConfig(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ConfigResponseDto; + }>("/futo-backups/config", { + ...opts + })); +} +/** + * Update backups orchestrator configuration + */ +export function futoBackupsUpdateConfig({ configUpdateRequestDto }: { + configUpdateRequestDto: ConfigUpdateRequestDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ConfigResponseDto; + }>("/futo-backups/config", oazapfts.json({ + ...opts, + method: "PUT", + body: configUpdateRequestDto + }))); +} +/** + * Reset the backups orchestrator + */ +export function futoBackupsResetOrchestrator(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/futo-backups/debug/reset", { + ...opts, + method: "POST" + })); +} +/** + * List files + */ +export function futoBackupsGetFileListing({ path }: { + path?: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: FilesystemListingResponseDto; + }>(`/futo-backups/fs${QS.query(QS.explode({ + path + }))}`, { + ...opts + })); +} +/** + * Get backup integrations + */ +export function futoBackupsGetIntegrations(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: IntegrationsResponseDto; + }>("/futo-backups/integrations", { + ...opts + })); +} +/** + * Configure the Immich backup integration + */ +export function futoBackupsConfigureImmichIntegration({ configureImmichIntegrationRequestDto }: { + configureImmichIntegrationRequestDto: ConfigureImmichIntegrationRequestDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: ConfigureImmichIntegrationResponseDto; + }>("/futo-backups/integrations/immich", oazapfts.json({ + ...opts, + method: "POST", + body: configureImmichIntegrationRequestDto + }))); +} +/** + * Configure Immich database dumps + */ +export function futoBackupsConfigureImmichDatabaseDump({ configureImmichDatabaseDumpRequestDto }: { + configureImmichDatabaseDumpRequestDto: ConfigureImmichDatabaseDumpRequestDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/futo-backups/integrations/immich/database-dump", oazapfts.json({ + ...opts, + method: "POST", + body: configureImmichDatabaseDumpRequestDto + }))); +} +/** + * Ignore the Immich database dump warning + */ +export function futoBackupsIgnoreImmichDatabaseDumpWarning(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/futo-backups/integrations/immich/database-dump/ignore-warning", { + ...opts, + method: "POST" + })); +} +/** + * Roll Immich back to a snapshot + */ +export function futoBackupsStartImmichRollback({ immichRollbackRequestDto }: { + immichRollbackRequestDto: ImmichRollbackRequestDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/futo-backups/integrations/immich/rollback", oazapfts.json({ + ...opts, + method: "POST", + body: immichRollbackRequestDto + }))); +} +/** + * Get Immich backup status + */ +export function futoBackupsGetImmichBackupStatus(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ImmichBackupStatusDto; + }>("/futo-backups/integrations/immich/status", { + ...opts + })); +} +/** + * List jobs + */ +export function futoBackupsGetJobs({ repositoryId }: { + repositoryId: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: JobListResponseDto; + }>(`/futo-backups/jobs${QS.query(QS.explode({ + repositoryId + }))}`, { + ...opts + })); +} +/** + * Cancel a job + */ +export function futoBackupsCancelJob({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/futo-backups/jobs/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +/** + * Get a job + */ +export function futoBackupsGetJob({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: JobDto; + }>(`/futo-backups/jobs/${encodeURIComponent(id)}`, { + ...opts + })); +} +/** + * Download a job log + */ +export function futoBackupsDownloadJobLog({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchBlob<{ + status: 200; + data: Blob; + }>(`/futo-backups/jobs/${encodeURIComponent(id)}/download`, { + ...opts + })); +} +/** + * Download a job support log + */ +export function futoBackupsDownloadJobSupportLog({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchBlob<{ + status: 200; + data: Blob; + }>(`/futo-backups/jobs/${encodeURIComponent(id)}/support-log/download`, { + ...opts + })); +} +/** + * Get backup onboarding status + */ +export function futoBackupsOnboardingStatus(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: OnboardingStatusResponseDto; + }>("/futo-backups/onboarding", { + ...opts + })); +} +/** + * Get the backup recovery key + */ +export function futoBackupsCurrentRecoveryKey(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: CurrentRecoveryKeyResponse; + }>("/futo-backups/onboarding/recovery-key", { + ...opts + })); +} +/** + * Confirm the backup recovery key + */ +export function futoBackupsConfirmRecoveryKey(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/futo-backups/onboarding/recovery-key", { + ...opts, + method: "POST" + })); +} +/** + * Import a backup recovery key + */ +export function futoBackupsImportRecoveryKey({ importRecoveryKeyRequest }: { + importRecoveryKeyRequest: ImportRecoveryKeyRequest; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/futo-backups/onboarding/recovery-key", oazapfts.json({ + ...opts, + method: "PUT", + body: importRecoveryKeyRequest + }))); +} +/** + * Report a startup error + */ +export function futoBackupsReportError(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/futo-backups/onboarding/report-error", { + ...opts, + method: "POST" + })); +} +/** + * Skip optional backup onboarding + */ +export function futoBackupsSkipOnboardingExtraConfig(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/futo-backups/onboarding/skip", { + ...opts, + method: "POST" + })); +} +/** + * Enable FUTO Backups telemetry + */ +export function futoBackupsEnableTelemetry(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/futo-backups/onboarding/telemetry", { + ...opts, + method: "POST" + })); +} +/** + * List backups + */ +export function futoBackupsGetRepositories(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: RepositoryListResponseDto; + }>("/futo-backups/repository", { + ...opts + })); +} +/** + * Create a new backup + */ +export function futoBackupsCreateRepository({ backend, repositoryCreateRequestDto }: { + backend?: string; + repositoryCreateRequestDto: RepositoryCreateRequestDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: RepositoryCreateResponseDto; + }>(`/futo-backups/repository${QS.query(QS.explode({ + backend + }))}`, oazapfts.json({ + ...opts, + method: "POST", + body: repositoryCreateRequestDto + }))); +} +/** + * List remote backups + */ +export function futoBackupsInspectRepositories({ backend }: { + backend?: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: RepositoryInspectResponseDto; + }>(`/futo-backups/repository/inspect${QS.query(QS.explode({ + backend + }))}`, { + ...opts + })); +} +/** + * Remove a backup + */ +export function futoBackupsDeleteRepository({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/futo-backups/repository/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +/** + * Update a backup + */ +export function futoBackupsUpdateRepository({ backend, id, repositoryUpdateRequestDto }: { + backend?: string; + id: string; + repositoryUpdateRequestDto: RepositoryUpdateRequestDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: RepositoryUpdateResponseDto; + }>(`/futo-backups/repository/${encodeURIComponent(id)}${QS.query(QS.explode({ + backend + }))}`, oazapfts.json({ + ...opts, + method: "PATCH", + body: repositoryUpdateRequestDto + }))); +} +/** + * Start a backup + */ +export function futoBackupsCreateBackup({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: JobResponseDto; + }>(`/futo-backups/repository/${encodeURIComponent(id)}`, { + ...opts, + method: "POST" + })); +} +/** + * Change the primary backend + */ +export function futoBackupsReconfigureRepositoryPrimaryBackend({ id, repositoryPrimaryBackendReconfigureRequestDto }: { + id: string; + repositoryPrimaryBackendReconfigureRequestDto: RepositoryPrimaryBackendReconfigureRequestDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: RepositoryCreateResponseDto; + }>(`/futo-backups/repository/${encodeURIComponent(id)}/backend`, oazapfts.json({ + ...opts, + method: "PUT", + body: repositoryPrimaryBackendReconfigureRequestDto + }))); +} +/** + * Check a repository import + */ +export function futoBackupsCheckImportRepository({ backend, id }: { + backend: string; + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: RepositoryCheckImportResponseDto; + }>(`/futo-backups/repository/${encodeURIComponent(id)}/import${QS.query(QS.explode({ + backend + }))}`, { + ...opts + })); +} +/** + * Import a repository + */ +export function futoBackupsImportRepository({ backend, id }: { + backend: string; + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: RepositoryCreateResponseDto; + }>(`/futo-backups/repository/${encodeURIComponent(id)}/import${QS.query(QS.explode({ + backend + }))}`, { + ...opts, + method: "POST" + })); +} +/** + * List snapshots + */ +export function futoBackupsGetSnapshots({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ListSnapshotsResponseDto; + }>(`/futo-backups/repository/${encodeURIComponent(id)}/snapshots`, { + ...opts + })); +} +/** + * Prune a repository + */ +export function futoBackupsPruneRepository({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: JobResponseDto; + }>(`/futo-backups/repository/${encodeURIComponent(id)}/snapshots/prune`, { + ...opts, + method: "POST" + })); +} +/** + * Delete a snapshot + */ +export function futoBackupsForgetSnapshot({ id, snapshot }: { + id: string; + snapshot: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ListSnapshotsResponseDto; + }>(`/futo-backups/repository/${encodeURIComponent(id)}/snapshots/${encodeURIComponent(snapshot)}`, { + ...opts, + method: "DELETE" + })); +} +/** + * Restore a snapshot + */ +export function futoBackupsRestoreSnapshot({ id, snapshot, repositorySnapshotRestoreRequestDto }: { + id: string; + snapshot: string; + repositorySnapshotRestoreRequestDto: RepositorySnapshotRestoreRequestDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: JobResponseDto; + }>(`/futo-backups/repository/${encodeURIComponent(id)}/snapshots/${encodeURIComponent(snapshot)}`, oazapfts.json({ + ...opts, + method: "POST", + body: repositorySnapshotRestoreRequestDto + }))); +} +/** + * List snapshot files + */ +export function futoBackupsGetSnapshotListing({ id, path, snapshot }: { + id: string; + path?: string; + snapshot: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: FilesystemListingResponseDto; + }>(`/futo-backups/repository/${encodeURIComponent(id)}/snapshots/${encodeURIComponent(snapshot)}/listing${QS.query(QS.explode({ + path + }))}`, { + ...opts + })); +} +/** + * Restore from snapshot + */ +export function futoBackupsRestoreFromPoint({ backend, id, snapshot, repositorySnapshotRestoreFromPointRequestDto }: { + backend: string; + id: string; + snapshot: string; + repositorySnapshotRestoreFromPointRequestDto: RepositorySnapshotRestoreFromPointRequestDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: JobResponseDto; + }>(`/futo-backups/repository/${encodeURIComponent(id)}/snapshots/${encodeURIComponent(snapshot)}/restore-from-point${QS.query(QS.explode({ + backend + }))}`, oazapfts.json({ + ...opts, + method: "POST", + body: repositorySnapshotRestoreFromPointRequestDto + }))); +} +/** + * Sync a remote deletion + */ +export function futoBackupsSyncRepositoryDeletion({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: RepositorySyncDeletionResponseDto; + }>(`/futo-backups/repository/${encodeURIComponent(id)}/sync-deletion`, { + ...opts, + method: "POST" + })); +} +/** + * List schedules + */ +export function futoBackupsGetSchedules(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ScheduleListResponseDto; + }>("/futo-backups/schedule", { + ...opts + })); +} +/** + * Create a schedule + */ +export function futoBackupsCreateSchedule({ scheduleCreateRequestDto }: { + scheduleCreateRequestDto: ScheduleCreateRequestDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: ScheduleCreateResponseDto; + }>("/futo-backups/schedule", oazapfts.json({ + ...opts, + method: "POST", + body: scheduleCreateRequestDto + }))); +} +/** + * Delete a schedule + */ +export function futoBackupsRemoveSchedule({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/futo-backups/schedule/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +/** + * Update a schedule + */ +export function futoBackupsUpdateSchedule({ id, scheduleUpdateRequestDto }: { + id: string; + scheduleUpdateRequestDto: ScheduleUpdateRequestDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ScheduleUpdateResponseDto; + }>(`/futo-backups/schedule/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "PATCH", + body: scheduleUpdateRequestDto + }))); +} +/** + * Get support mode + */ +export function futoBackupsGetSupportMode(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SupportModeResponseDto; + }>("/futo-backups/support-mode", { + ...opts + })); +} +/** + * Update support mode + */ +export function futoBackupsUpdateSupportMode({ supportModeUpdateRequestDto }: { + supportModeUpdateRequestDto: SupportModeUpdateRequestDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SupportModeResponseDto; + }>("/futo-backups/support-mode", oazapfts.json({ + ...opts, + method: "PUT", + body: supportModeUpdateRequestDto + }))); +} /** * Retrieve queue counts and status */ @@ -8242,7 +9266,8 @@ export enum MaintenanceAction { Start = "start", End = "end", SelectDatabaseRestore = "select_database_restore", - RestoreDatabase = "restore_database" + RestoreDatabase = "restore_database", + Rollback = "rollback" } export enum StorageFolder { EncodedVideo = "encoded-video", @@ -8532,6 +9557,68 @@ export enum SourceType { Exif = "exif", Manual = "manual" } +export enum DeviceFlowFailureReason { + NotConnected = "NOT_CONNECTED", + DeviceFlowFailed = "DEVICE_FLOW_FAILED", + WrongAccount = "WRONG_ACCOUNT", + Unknown = "UNKNOWN" +} +export enum DeviceFlowEventType { + Start = "START", + Success = "SUCCESS", + Failure = "FAILURE" +} +export enum TicketAction { + RepositoryDelete = "repository.delete", + RepositoryDisableWorm = "repository.disable-worm" +} +export enum BackendType { + Yucca = "yucca", + Local = "local", + S3 = "s3" +} +export enum YuccaJobName { + RepositoryBackup = "repository-backup", + RepositoryPrune = "repository-prune", + RepositoryRestore = "repository-restore", + RepositoryRestoreFromPoint = "repository-restore-from-point", + RepositorySnapshotForget = "repository-snapshot-forget", + RepositorySyncMetrics = "repository-sync-metrics" +} +export enum Type { + Backup = "backup" +} +export enum Type2 { + Restore = "restore" +} +export enum Type3 { + Prune = "prune" +} +export enum YuccaJobStatus { + Queued = "queued", + Running = "running", + Complete = "complete", + Warn = "warn", + Failed = "failed", + Cancelled = "cancelled", + Incomplete = "incomplete" +} +export enum BackupStatus { + Incomplete = "incomplete", + Complete = "complete", + Warn = "warn", + Failed = "failed", + Cancelled = "cancelled" +} +export enum TelemetryLevel { + Full = "full", + None = "none" +} +export enum BootstrapStatus { + NotReady = "not-ready", + Ready = "ready", + Error = "error" +} export enum ManualJobName { PersonCleanup = "person-cleanup", TagCleanup = "tag-cleanup", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af6895a86..28904ecd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -565,6 +565,9 @@ importers: '@extism/extism': specifier: 2.0.0-rc13 version: 2.0.0-rc13 + '@futo-org/backups-orchestrator-api': + specifier: 1.11.5 + version: 1.11.5(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(@nestjs/platform-socket.io@12.1.0)(@nestjs/schedule@12.0.2(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0))(@nestjs/swagger@12.0.2(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2))(@nestjs/websockets@12.1.0)(reflect-metadata@0.2.2)(supports-color@8.1.1) '@immich/plugin-sdk': specifier: workspace:* version: link:../packages/plugin-sdk @@ -953,6 +956,9 @@ importers: '@formatjs/icu-messageformat-parser': specifier: ^3.0.0 version: 3.5.20 + '@futo-org/backups-orchestrator-ui': + specifier: 1.11.5 + version: 1.11.5(@immich/ui@0.91.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(svelte@5.57.2(@typescript-eslint/types@8.70.1))(zod@4.4.3))(@lingui/cli@6.11.0(esbuild@0.28.2)(rolldown@1.2.9)(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(supports-color@8.1.1)(svelte@5.57.2(@typescript-eslint/types@8.70.1)) '@immich/justified-layout-wasm': specifier: ^0.4.3 version: 0.4.3 @@ -2935,6 +2941,29 @@ packages: resolution: {integrity: sha512-v0BLa0eqg7ubvVWeNSHVBs8fWH/GJicERZoJaxJ3FE/lj67VSqzoMg9pzfZVOfLMX10y0pGwQAuxoRVVH2patg==} engines: {node: '>=6'} + '@futo-org/backups-api-client@1.12.1': + resolution: {integrity: sha512-y1AtOftdZnFpoU7YRteD6U0qip4zWOg/JuiplGGGotdBnNMipOCSDj7ttU2huWRIEfZRosgAPiUi/lt/Bns/Xg==} + + '@futo-org/backups-orchestrator-api@1.11.5': + resolution: {integrity: sha512-YcIdWrgoyYx8L1vu2Gwtb+k1lQL32xQOK65JAAYMdZ7uEb+B/uEwHQOJWgCoxH6exwvEVtWPbvj3Mkn84r5pTQ==} + peerDependencies: + '@nestjs/common': ^12.0.0 + '@nestjs/core': ^12.0.0 + '@nestjs/platform-socket.io': ^12.0.0 + '@nestjs/schedule': ^12.0.0 + '@nestjs/swagger': ^12.0.0 + '@nestjs/websockets': ^12.0.0 + + '@futo-org/backups-orchestrator-ui@1.11.5': + resolution: {integrity: sha512-sfUfsJCeR7HEfmbLu/eAACAtY27ALxLPlZ9BYW9MnnQ9BXQHGBnM+hZ0pGs/YwtYuNsAeoq5kEbIadVN5sX6Gw==} + peerDependencies: + '@immich/ui': ^0.90.0 + svelte: ^5.0.0 + + '@futo-org/restic-wrapper@1.7.0': + resolution: {integrity: sha512-z27pVTOZA7h755RcZi7MCcCNh6o9lqmk55lRsAxV2gVWBQWfuPpdWVrpRI5Or3IgbYR4B82t7ICHBgtgcWvYrg==} + engines: {node: '>=20'} + '@golevelup/nestjs-discovery@7.0.3': resolution: {integrity: sha512-wBlSxU1wgBS+x4adwgp2qklXKCC9sufn8zrrSJ1WSCEHb48OBMrn0FskU862CFPwyotJaElRrirHTICAOsl1Mg==} peerDependencies: @@ -3527,6 +3556,133 @@ packages: '@lezer/lr@1.4.10': resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + '@lingui/babel-plugin-extract-messages@6.11.0': + resolution: {integrity: sha512-gKXMoLOJf91nVm959bn5AJCY5PdNXZjXP3ltKahhe1UjCZCMVQI8CTmt0bzEH8MlUm7/zXqqvME8dIAQTiNFYw==} + engines: {node: '>=22.19.0'} + + '@lingui/babel-plugin-lingui-macro@6.11.0': + resolution: {integrity: sha512-cFy6lJ8ZnvUowUuNoB5KSPUyT03lL9YNsFqeg7EbqHbzwcsT0uitIGc8pZ96+mwNq8RaACgd5amHcRfr18aAUQ==} + engines: {node: '>=22.19.0'} + peerDependencies: + '@babel/core': ^7.20.12 || ^8.0.0 + peerDependenciesMeta: + '@babel/core': + optional: true + + '@lingui/cli@6.11.0': + resolution: {integrity: sha512-R5qiCOAsV6STn+O3bWF+ttngLuh7Q+wf+83J34D0xDN/5pPAnYiek26rqGFwcpM9nAESzCEM0IoQsutpGCRLvg==} + engines: {node: '>=22.19.0'} + hasBin: true + peerDependencies: + esbuild: ^0.28.1 + rolldown: ^1.0.0 + peerDependenciesMeta: + esbuild: + optional: true + rolldown: + optional: true + + '@lingui/conf@6.11.0': + resolution: {integrity: sha512-2TYTmbXkecuUxFU0DaPrRF8slUCzEd+G3Wh0RXws/uP2RkakvrcMx+m+ZjdVHypT/3VyjIGsCfDpFSnlLppo+g==} + engines: {node: '>=22.19.0'} + + '@lingui/core@5.9.5': + resolution: {integrity: sha512-Y+iZq9NqnqZOqHNgPomUFP21KH/zs4oTTizWoz0AKAkBbq9T9yb1DSz/ugtBRjF1YLtKMF9tq28v3thMHANSiQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@lingui/babel-plugin-lingui-macro': 5.9.5 + babel-plugin-macros: 2 || 3 + peerDependenciesMeta: + '@lingui/babel-plugin-lingui-macro': + optional: true + babel-plugin-macros: + optional: true + + '@lingui/core@6.11.0': + resolution: {integrity: sha512-JQLQeUCu0MWxHbBjQbrRdoPQqD15tKLFpXMaXII60TsWnfeNhcqBVhL1c2U9VStIjlc3c9S2sMaieawahyMkwQ==} + engines: {node: '>=22.19.0'} + peerDependencies: + babel-plugin-macros: 2 || 3 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + '@lingui/format-po@6.11.0': + resolution: {integrity: sha512-XID3qLdPlxvM2nx+5cWtmAaCsxYFGBNe5xA5XHE4KRF0nG98BKN50M4577ntG3+cK+6G+0b5US4WLe1sdH65vw==} + engines: {node: '>=22.19.0'} + + '@lingui/message-utils@5.9.5': + resolution: {integrity: sha512-t3dNbjb1dWkvcpXGMXIEyBDO3l4B8J2ColZXi0NTG1ioAj+sDfFxFB8fepVgd3JAk+AwARlOLvF14oS0mAdgpw==} + engines: {node: '>=20.0.0'} + + '@lingui/message-utils@6.11.0': + resolution: {integrity: sha512-pRRELzVBQyXKhdFzhOi98hCfRDCHBhQmmCxraxrSkuthO+YD+4Xurap+9LiejAQqj1D2HAUxC+pEc7I9MPXfzQ==} + engines: {node: '>=22.19.0'} + + '@lingui/native-tools-android-arm64@0.2.0': + resolution: {integrity: sha512-8iBKJbVT83n6MAmeVCJDWaPqm69AHV8mjnY0hklH0SPP2V3rx8V4tLG7WLktg9qO++mbreYGmaHzIowwRwTgKQ==} + engines: {node: '>=22.19.0'} + cpu: [arm64] + os: [android] + + '@lingui/native-tools-darwin-arm64@0.2.0': + resolution: {integrity: sha512-NuJmNQjMOnMMvQgyUuN95/yER6Pspujav4xUm08WE3fKPTOZPIkB2TmOpBM2cVXhuDcsv5g+3x3tsnkwcd65DQ==} + engines: {node: '>=22.19.0'} + cpu: [arm64] + os: [darwin] + + '@lingui/native-tools-darwin-x64@0.2.0': + resolution: {integrity: sha512-n4G3GLcHKw5R56UzkKNFnm1ju/9nxBtPE40g+U+sUL4XUo6tPMX4J8b4yIE1dXYY6i5g7jQJm9Kvr/lIxibzOg==} + engines: {node: '>=22.19.0'} + cpu: [x64] + os: [darwin] + + '@lingui/native-tools-linux-arm64-gnu@0.2.0': + resolution: {integrity: sha512-vmb7cuTr/vyfn5kgyO/0JkCuru9r8DFAkgJLTUsbG12NL0ti/PN+lJeflplUFEcRiWZryvCJ6UFNtMw65WX9GQ==} + engines: {node: '>=22.19.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@lingui/native-tools-linux-arm64-musl@0.2.0': + resolution: {integrity: sha512-Y3gEixa7EFIozkFVMp2KKDWn5aGYzxCFPBgEB1EtVXHj+VyPewc/Oo7xvXORpqD9u4vZQzuOYQJh6G6UVL9wnQ==} + engines: {node: '>=22.19.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@lingui/native-tools-linux-x64-gnu@0.2.0': + resolution: {integrity: sha512-5gmB+mQt/DtenAm8KV2b2RihXZwce1SjF+E/lytQWGWfe/6JHl+kqBWOmrc3uhuB76xInyRw3ui4gDut5oAQYA==} + engines: {node: '>=22.19.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@lingui/native-tools-linux-x64-musl@0.2.0': + resolution: {integrity: sha512-Y6E+9ZgW5y+8OyzpEdCJc+waRhNHv2i0h4ulU6xX7/GOOPnb3QpmRvOd6aCXkg6HwOy5SNJ/Rm8drQRJKei6qg==} + engines: {node: '>=22.19.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@lingui/native-tools-win32-arm64-msvc@0.2.0': + resolution: {integrity: sha512-I/XzCL7eZaT4BSDMVKc+FBhCGPxecf1E0aR2YQshz9KMwmuubOfpqBw6ZjEEaggvZWO8cg+G0FP3v8U01m3JvQ==} + engines: {node: '>=22.19.0'} + cpu: [arm64] + os: [win32] + + '@lingui/native-tools-win32-x64-msvc@0.2.0': + resolution: {integrity: sha512-Po3MvSbHqzy8LhGDcCKrhQOOZL3PSBIct/fj5DpEkiP4+OrUocYbwX62AaV67ne/vzUTpYkEjALjq7ymSD14aA==} + engines: {node: '>=22.19.0'} + cpu: [x64] + os: [win32] + + '@lingui/native-tools@0.2.0': + resolution: {integrity: sha512-60zML3mPXI6bdMSTGO9r1cL0DWD1n7ajkOPGdaxl3oiHZc2Y9huEeftwtNYEvPXFLrDV+qt/F5mCqZEj1PFeQg==} + engines: {node: '>=22.19.0'} + peerDependencies: + '@lingui/conf': ^6.0.1 + '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} @@ -3567,6 +3723,9 @@ packages: '@marijn/find-cluster-break@1.0.3': resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==} + '@material-symbols/svg-400@0.48.2': + resolution: {integrity: sha512-JJXRvRoHhMbrSFQIkiUwjFkCnurH0Yg+cGMCn9xS0PRtLXovIAQCz7NltNn/teQ9VRYmbzZB75Uw6Hx0umu5Bg==} + '@mdi/js@7.4.47': resolution: {integrity: sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==} @@ -3594,6 +3753,12 @@ packages: '@mermaid-js/parser@1.2.0': resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} + '@messageformat/date-skeleton@1.1.0': + resolution: {integrity: sha512-rmGAfB1tIPER+gh3p/RgA+PVeRE/gxuQ2w4snFWPF5xtb5mbWR7Cbw7wCOftcUypbD6HVoxrVdyyghPm3WzP5A==} + + '@messageformat/parser@5.2.0': + resolution: {integrity: sha512-sc3hNZ0JtC32GhL5nUgPbh2ilcwji4G+Vmj/vhMd8hZaH//NDOvkfwKK1mFnRWG5h2DpksN8xr/HB5/+vMg3iw==} + '@microsoft/tsdoc@0.17.0': resolution: {integrity: sha512-p68VexhnH7ojf3U27RdryUDqnKJgKaWUbCL96vJR7N0mqDuhKG5OIECk3sCPPdynxjk+yoVhRkScqtv5KQjzsw==} @@ -3712,6 +3877,18 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/event-emitter@12.0.1': + resolution: {integrity: sha512-7MbpgNcxUcDlqcYY/qgZm1H7MfK3uGcRsd8lEjj9ZYvI9qTl6kdkkykpxZ6YjBRH3wlZK5dFLAei8h5qpsHzOw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@nestjs/common': ^11.0.0 || ^12.0.0 + '@nestjs/core': ^11.0.0 || ^12.0.0 + + '@nestjs/jwt@12.0.3': + resolution: {integrity: sha512-Gr/lpj1pu5Lfn8b+6WeZZzj4J+Ggm1d4ImgrYLhiWahpJHoc/hhfQ9h9AAVVQ9vM+VskPrlngMu0RZwBIRsx3g==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 + '@nestjs/mapped-types@12.0.0': resolution: {integrity: sha512-aX7lxZcqXldtnSr0bgT2ZPQvpTpIwBMW1GMX29o4s3q02vmq6JoTYuPW0GwYXa8BiFMRX7WbGZVDA703pNqOMQ==} engines: {node: '>=20.19.0'} @@ -6319,6 +6496,10 @@ packages: resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} engines: {node: '>= 18'} + better-sqlite3@12.11.1: + resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + bidi-js@1.1.0: resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} @@ -6329,6 +6510,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bits-ui@2.18.1: resolution: {integrity: sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==} engines: {node: '>=20'} @@ -6745,6 +6929,10 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@15.0.0: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} @@ -6875,6 +7063,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cookie@2.0.1: resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} engines: {node: '>=22'} @@ -6949,10 +7141,17 @@ packages: resolution: {integrity: sha512-JfAYMY0LZaWQTuict8PhtF/ksSnq8xefSDmQUnnoqvkPYkU/y3UP0DTWzJKFBz0P1IXsDG8q0vY3zHkoFZOhvw==} engines: {node: '>=18'} + cron-validate@1.5.3: + resolution: {integrity: sha512-jcu8g/3wZL8OBr4MkEcbeIdLpM8pp5Y6UoOlRktcJG3WjgpifijR0s26Yac7ywR0gC2ABtevOsz5mlD3l3gzwA==} + cron@4.4.0: resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} engines: {node: '>=18.x'} + cronstrue@3.38.0: + resolution: {integrity: sha512-i10xR7fS4AH9LKwmt3gYdt/5lSsB2qFGSNfC8aeyYybxo48DCMz+nUrSVI4kUj8yo+SgTbMJqp/BWEem9avlcA==} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -7921,6 +8120,9 @@ packages: estree-util-visit@2.0.0: resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + estree-walker-ts@1.1.0: + resolution: {integrity: sha512-qZDD6u86YMB15jps0ZYDE7fxJl2EZsxspagUcLg6K3k/WKRkGGpXM/5kk5a2UvX5+yv6viQ1jYYr1De4IiBx/w==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -7946,10 +8148,16 @@ packages: event-emitter@0.3.5: resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} + event-iterator@2.0.0: + resolution: {integrity: sha512-KGft0ldl31BZVV//jj+IAIGCxkvvUkkON+ScH6zfoX+l+omX6001ggyRSpI0Io2Hlro0ThXotswCtfzS8UkIiQ==} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} @@ -7960,6 +8168,14 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-client@1.2.0: + resolution: {integrity: sha512-kDI75RSzO3TwyG/K9w1ap8XwqSPcwi6jaMkNulfVeZmSeUM49U8kUzk1s+vKNt0tGrXgK47i+620Yasn1ccFiw==} + engines: {node: '>=18.0.0'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -8094,6 +8310,9 @@ packages: resolution: {integrity: sha512-sEB0oStmeLGtpmSAD88RDNuLsvhv4iC3u0lE25CUvfCULvj49xoQ3l3K525Vn+ZzsZ6DOLajQCV6RmhwwhqiJg==} engines: {node: '>=22'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -9075,10 +9294,18 @@ packages: javascript-natural-sort@0.7.1: resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-util@29.7.0: resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} @@ -9109,6 +9336,9 @@ packages: jose@6.2.12: resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + js-sha256@0.10.1: + resolution: {integrity: sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -9249,6 +9479,10 @@ packages: postgres: optional: true + kysely@0.28.17: + resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==} + engines: {node: '>=20.0.0'} + kysely@0.29.6: resolution: {integrity: sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==} engines: {node: '>=22.0.0'} @@ -10659,6 +10893,10 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} + pofile-ts@4.0.3: + resolution: {integrity: sha512-sz1pnjgEfPyZ+QvaeX3NtCmbYnEvG01LZRLoN/uXoLtPZtxCIH5IctL7yXXc0fFyk/fqV6K8g3hlNfr6IJwupA==} + engines: {node: '>=20'} + point-in-polygon-hao@1.2.4: resolution: {integrity: sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==} @@ -11203,6 +11441,10 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + pretty-time@1.1.0: resolution: {integrity: sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==} engines: {node: '>=4'} @@ -11241,6 +11483,9 @@ packages: resolution: {integrity: sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==} engines: {node: '>=18'} + property-expr@2.0.6: + resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} + property-information@5.6.0: resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==} @@ -11261,6 +11506,11 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pseudolocale@3.1.0: + resolution: {integrity: sha512-jBidehQu5WO8zbFUod0ETJYoVPxZ0ixsdI9B8ilkE0eA+YTydxHA7ujNdU4YhTexD5yH5Griv9r5CuXx+nwFUA==} + engines: {node: '>=24.0.0'} + hasBin: true + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -11375,6 +11625,9 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-json-view-lite@2.5.0: resolution: {integrity: sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==} engines: {node: '>=18'} @@ -11959,6 +12212,10 @@ packages: resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} engines: {node: '>=10.2.0'} + socket.io@4.8.4: + resolution: {integrity: sha512-9ZV9zg8vN8hO3Y25DhR7nUkJ1VDnBhq5O/JrXhFT9s6+F0gqytSASTHVfCFc2mJXaTfARroErplXlzFWwETZqQ==} + engines: {node: '>=10.2.0'} + sockjs@0.3.24: resolution: {integrity: sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==} @@ -12225,6 +12482,13 @@ packages: svelte-highlight@7.11.0: resolution: {integrity: sha512-0C8qDLIc825IWRyLqj9XNu762mV4hXtkKhnY8tyXreyakIyRYYUfgxJUwulFQa4mSQ/kra+jJDvKeoem8T4xYA==} + svelte-i18n-lingui@0.2.2: + resolution: {integrity: sha512-HxVsn56T4qt6ScNhaMBvkSv1Wy962NZUXjmXe7SnHZzXrkHgK2Is6zwa3knaXP5u2t/mSf/cTemOcEFTBBpnww==} + peerDependencies: + '@lingui/cli': '>=4' + '@lingui/core': '>=4' + svelte: '>=4' + svelte-i18n@4.0.1: resolution: {integrity: sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==} engines: {node: '>= 16'} @@ -12471,6 +12735,9 @@ packages: resolution: {integrity: sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==} engines: {node: '>=0.12'} + tiny-case@1.0.3: + resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + tiny-glob@0.2.9: resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} @@ -12500,6 +12767,10 @@ packages: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} + tinypool@2.2.0: + resolution: {integrity: sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==} + engines: {node: ^20.0.0 || >=22.0.0} + tinyqueue@3.0.0: resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} @@ -12533,6 +12804,9 @@ packages: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} + toposort@2.0.2: + resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -13400,6 +13674,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + yup@1.7.1: + resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==} + zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} @@ -16060,6 +16337,70 @@ snapshots: dependencies: '@fortawesome/fontawesome-common-types': 7.3.1 + '@futo-org/backups-api-client@1.12.1': + dependencies: + '@oazapfts/runtime': 1.2.0 + + '@futo-org/backups-orchestrator-api@1.11.5(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(@nestjs/platform-socket.io@12.1.0)(@nestjs/schedule@12.0.2(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0))(@nestjs/swagger@12.0.2(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2))(@nestjs/websockets@12.1.0)(reflect-metadata@0.2.2)(supports-color@8.1.1)': + dependencies: + '@futo-org/backups-api-client': 1.12.1 + '@futo-org/restic-wrapper': 1.7.0 + '@nestjs/common': 12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/core': 12.1.0(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@12.1.0)(@nestjs/websockets@12.1.0)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/event-emitter': 12.0.1(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0) + '@nestjs/jwt': 12.0.3(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)) + '@nestjs/platform-socket.io': 12.1.0(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/websockets@12.1.0)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/schedule': 12.0.2(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0) + '@nestjs/swagger': 12.0.2(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2) + '@nestjs/websockets': 12.1.0(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(@nestjs/platform-socket.io@12.1.0)(reflect-metadata@0.2.2)(rxjs@7.8.2) + better-sqlite3: 12.11.1 + cookie: 1.1.1 + cron: 4.4.0 + event-iterator: 2.0.0 + eventsource-client: 1.2.0 + express: 5.2.1(supports-color@8.1.1) + kysely: 0.28.17 + nestjs-kysely: 3.1.4(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(kysely@0.28.17)(reflect-metadata@0.2.2) + nestjs-zod: 5.5.0(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/swagger@12.0.2(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.4.3) + openid-client: 6.8.8 + rxjs: 7.8.2 + socket.io: 4.8.4(supports-color@8.1.1) + zod: 4.4.3 + transitivePeerDependencies: + - bufferutil + - reflect-metadata + - supports-color + - utf-8-validate + + '@futo-org/backups-orchestrator-ui@1.11.5(@immich/ui@0.91.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(svelte@5.57.2(@typescript-eslint/types@8.70.1))(zod@4.4.3))(@lingui/cli@6.11.0(esbuild@0.28.2)(rolldown@1.2.9)(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(supports-color@8.1.1)(svelte@5.57.2(@typescript-eslint/types@8.70.1))': + dependencies: + '@futo-org/backups-api-client': 1.12.1 + '@immich/ui': 0.91.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(svelte@5.57.2(@typescript-eslint/types@8.70.1))(zod@4.4.3) + '@lingui/core': 5.9.5 + '@material-symbols/svg-400': 0.48.2 + '@mdi/js': 7.4.47 + '@oazapfts/runtime': 1.2.0 + '@tanstack/svelte-query': 6.3.2(svelte@5.57.2(@typescript-eslint/types@8.70.1)) + cron-validate: 1.5.3 + cronstrue: 3.38.0 + lodash.debounce: 4.0.8 + luxon: 3.7.2 + socket.io-client: 4.8.3(supports-color@8.1.1) + svelte: 5.57.2(@typescript-eslint/types@8.70.1) + svelte-i18n-lingui: 0.2.2(@lingui/cli@6.11.0(esbuild@0.28.2)(rolldown@1.2.9)(supports-color@8.1.1))(@lingui/core@5.9.5)(@typescript/typescript6@6.0.2)(supports-color@8.1.1)(svelte@5.57.2(@typescript-eslint/types@8.70.1)) + transitivePeerDependencies: + - '@lingui/babel-plugin-lingui-macro' + - '@lingui/cli' + - babel-plugin-macros + - bufferutil + - supports-color + - typescript + - utf-8-validate + + '@futo-org/restic-wrapper@1.7.0': + dependencies: + zod: 4.4.3 + '@golevelup/nestjs-discovery@7.0.3(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)': dependencies: '@nestjs/common': 12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) @@ -16623,6 +16964,126 @@ snapshots: dependencies: '@lezer/common': 1.5.2 + '@lingui/babel-plugin-extract-messages@6.11.0': + dependencies: + '@lingui/conf': 6.11.0 + + '@lingui/babel-plugin-lingui-macro@6.11.0(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@lingui/conf': 6.11.0 + '@lingui/message-utils': 6.11.0 + optionalDependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + + '@lingui/cli@6.11.0(esbuild@0.28.2)(rolldown@1.2.9)(supports-color@8.1.1)': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@lingui/babel-plugin-extract-messages': 6.11.0 + '@lingui/babel-plugin-lingui-macro': 6.11.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@lingui/conf': 6.11.0 + '@lingui/core': 6.11.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@lingui/format-po': 6.11.0 + '@lingui/message-utils': 6.11.0 + '@lingui/native-tools': 0.2.0(@lingui/conf@6.11.0) + chokidar: 5.0.0 + cli-table3: 0.6.5 + commander: 14.0.3 + jiti: 2.7.0 + micromatch: 4.0.8 + ms: 2.1.3 + normalize-path: 3.0.0 + ora: 9.4.1 + pseudolocale: 3.1.0 + source-map: 0.7.6 + tinypool: 2.2.0 + optionalDependencies: + esbuild: 0.28.2 + rolldown: 1.2.9 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + '@lingui/conf@6.11.0': + dependencies: + jest-validate: 29.7.0 + jiti: 2.7.0 + lilconfig: 3.1.3 + normalize-path: 3.0.0 + + '@lingui/core@5.9.5': + dependencies: + '@babel/runtime': 7.29.7 + '@lingui/message-utils': 5.9.5 + + '@lingui/core@6.11.0(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@lingui/babel-plugin-lingui-macro': 6.11.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@lingui/message-utils': 6.11.0 + transitivePeerDependencies: + - '@babel/core' + + '@lingui/format-po@6.11.0': + dependencies: + '@lingui/conf': 6.11.0 + '@lingui/message-utils': 6.11.0 + pofile-ts: 4.0.3 + + '@lingui/message-utils@5.9.5': + dependencies: + '@messageformat/parser': 5.2.0 + js-sha256: 0.10.1 + + '@lingui/message-utils@6.11.0': + dependencies: + '@messageformat/date-skeleton': 1.1.0 + '@messageformat/parser': 5.2.0 + '@noble/hashes': 2.4.0 + + '@lingui/native-tools-android-arm64@0.2.0': + optional: true + + '@lingui/native-tools-darwin-arm64@0.2.0': + optional: true + + '@lingui/native-tools-darwin-x64@0.2.0': + optional: true + + '@lingui/native-tools-linux-arm64-gnu@0.2.0': + optional: true + + '@lingui/native-tools-linux-arm64-musl@0.2.0': + optional: true + + '@lingui/native-tools-linux-x64-gnu@0.2.0': + optional: true + + '@lingui/native-tools-linux-x64-musl@0.2.0': + optional: true + + '@lingui/native-tools-win32-arm64-msvc@0.2.0': + optional: true + + '@lingui/native-tools-win32-x64-msvc@0.2.0': + optional: true + + '@lingui/native-tools@0.2.0(@lingui/conf@6.11.0)': + dependencies: + '@lingui/conf': 6.11.0 + '@swc/types': 0.1.28 + optionalDependencies: + '@lingui/native-tools-android-arm64': 0.2.0 + '@lingui/native-tools-darwin-arm64': 0.2.0 + '@lingui/native-tools-darwin-x64': 0.2.0 + '@lingui/native-tools-linux-arm64-gnu': 0.2.0 + '@lingui/native-tools-linux-arm64-musl': 0.2.0 + '@lingui/native-tools-linux-x64-gnu': 0.2.0 + '@lingui/native-tools-linux-x64-musl': 0.2.0 + '@lingui/native-tools-win32-arm64-msvc': 0.2.0 + '@lingui/native-tools-win32-x64-msvc': 0.2.0 + '@lukeed/csprng@1.1.0': {} '@mapbox/jsonlint-lines-primitives@2.0.3': {} @@ -16679,6 +17140,8 @@ snapshots: '@marijn/find-cluster-break@1.0.3': {} + '@material-symbols/svg-400@0.48.2': {} + '@mdi/js@7.4.47': {} '@mdi/react@1.6.1': @@ -16731,6 +17194,12 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 + '@messageformat/date-skeleton@1.1.0': {} + + '@messageformat/parser@5.2.0': + dependencies: + moo: 0.5.3 + '@microsoft/tsdoc@0.17.0': {} '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': @@ -16828,6 +17297,18 @@ snapshots: '@nestjs/platform-express': 12.1.0(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(supports-color@8.1.1) '@nestjs/websockets': 12.1.0(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(@nestjs/platform-socket.io@12.1.0)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/event-emitter@12.0.1(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)': + dependencies: + '@nestjs/common': 12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/core': 12.1.0(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@12.1.0)(@nestjs/websockets@12.1.0)(reflect-metadata@0.2.2)(rxjs@7.8.2) + eventemitter2: 6.4.9 + + '@nestjs/jwt@12.0.3(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))': + dependencies: + '@nestjs/common': 12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.3 + '@nestjs/mapped-types@12.0.0(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(reflect-metadata@0.2.2)': dependencies: '@nestjs/common': 12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) @@ -19515,6 +19996,11 @@ snapshots: node-gyp: 13.0.0 node-gyp-build: 4.8.4 + better-sqlite3@12.11.1: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + bidi-js@1.1.0: dependencies: require-from-string: 2.0.2 @@ -19523,6 +20009,10 @@ snapshots: binary-extensions@2.3.0: {} + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(svelte@5.57.2(@typescript-eslint/types@8.70.1)): dependencies: '@floating-ui/core': 1.8.0 @@ -19954,6 +20444,8 @@ snapshots: commander@13.1.0: {} + commander@14.0.3: {} + commander@15.0.0: {} commander@2.20.3: {} @@ -20081,6 +20573,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.1.1: {} + cookie@2.0.1: {} cookiejar@2.1.4: {} @@ -20156,11 +20650,17 @@ snapshots: dependencies: luxon: 3.7.2 + cron-validate@1.5.3: + dependencies: + yup: 1.7.1 + cron@4.4.0: dependencies: '@types/luxon': 3.7.5 luxon: 3.7.2 + cronstrue@3.38.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -21375,6 +21875,8 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/unist': 3.0.3 + estree-walker-ts@1.1.0: {} + estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -21397,8 +21899,12 @@ snapshots: d: 1.0.2 es5-ext: 0.10.64 + event-iterator@2.0.0: {} + event-target-shim@5.0.1: {} + eventemitter2@6.4.9: {} + eventemitter3@4.0.7: {} events-universal@1.0.1: @@ -21409,6 +21915,12 @@ snapshots: events@3.3.0: {} + eventsource-client@1.2.0: + dependencies: + eventsource-parser: 3.1.1 + + eventsource-parser@3.1.1: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -21437,8 +21949,7 @@ snapshots: optionalDependencies: exiftool-vendored.pl: 13.59.3 - expand-template@2.0.3: - optional: true + expand-template@2.0.3: {} expect-type@1.4.0: {} @@ -21618,6 +22129,8 @@ snapshots: transitivePeerDependencies: - supports-color + file-uri-to-path@1.0.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -21864,8 +22377,7 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - github-from-package@0.0.0: - optional: true + github-from-package@0.0.0: {} github-slugger@1.5.0: {} @@ -22778,6 +23290,8 @@ snapshots: javascript-natural-sort@0.7.1: {} + jest-get-type@29.6.3: {} + jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 @@ -22787,6 +23301,15 @@ snapshots: graceful-fs: 4.2.11 picomatch: 2.3.2 + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + jest-worker@27.5.1: dependencies: '@types/node': 24.19.2 @@ -22818,6 +23341,8 @@ snapshots: jose@6.2.12: {} + js-sha256@0.10.1: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -23011,6 +23536,8 @@ snapshots: optionalDependencies: postgres: 3.4.9 + kysely@0.28.17: {} + kysely@0.29.6: {} latest-version@7.0.0: @@ -24084,8 +24611,7 @@ snapshots: nanoid@3.3.18: {} - napi-build-utils@2.0.0: - optional: true + napi-build-utils@2.0.0: {} napi-postinstall@0.3.4: {} @@ -24129,6 +24655,14 @@ snapshots: reflect-metadata: 0.2.2 rxjs: 7.8.2 + nestjs-kysely@3.1.4(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(kysely@0.28.17)(reflect-metadata@0.2.2): + dependencies: + '@nestjs/common': 12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/core': 12.1.0(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@12.1.0)(@nestjs/websockets@12.1.0)(reflect-metadata@0.2.2)(rxjs@7.8.2) + kysely: 0.28.17 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + nestjs-kysely@3.1.4(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(kysely@0.29.6)(reflect-metadata@0.2.2): dependencies: '@nestjs/common': 12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) @@ -24165,7 +24699,6 @@ snapshots: node-abi@3.92.0: dependencies: semver: 7.8.5 - optional: true node-abort-controller@3.1.1: {} @@ -24665,6 +25198,8 @@ snapshots: pngjs@7.0.0: {} + pofile-ts@4.0.3: {} + point-in-polygon-hao@1.2.4: dependencies: robust-predicates: 3.0.3 @@ -25193,7 +25728,6 @@ snapshots: simple-get: 4.0.1 tar-fs: 2.1.5 tunnel-agent: 0.6.0 - optional: true prelude-ls@1.2.1: {} @@ -25228,6 +25762,12 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + pretty-time@1.1.0: {} prism-react-renderer@2.4.1(react@19.3.0): @@ -25268,6 +25808,8 @@ snapshots: transitivePeerDependencies: - supports-color + property-expr@2.0.6: {} + property-information@5.6.0: dependencies: xtend: 4.0.2 @@ -25297,6 +25839,10 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pseudolocale@3.1.0: + dependencies: + commander: 13.1.0 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -25427,6 +25973,8 @@ snapshots: react-is@17.0.2: {} + react-is@18.3.1: {} + react-json-view-lite@2.5.0(react@19.3.0): dependencies: react: 19.3.0 @@ -26195,15 +26743,13 @@ snapshots: signal-exit@4.1.0: {} - simple-concat@1.0.1: - optional: true + simple-concat@1.0.1: {} simple-get@4.0.1: dependencies: decompress-response: 6.0.0 once: 1.4.0 simple-concat: 1.0.1 - optional: true simple-icons@16.32.0: {} @@ -26284,6 +26830,19 @@ snapshots: - supports-color - utf-8-validate + socket.io@4.8.4(supports-color@8.1.1): + dependencies: + accepts: 1.3.8 + cors: 2.8.6 + debug: 4.4.3(supports-color@8.1.1) + engine.io: 6.6.8(supports-color@8.1.1) + socket.io-adapter: 2.5.7(supports-color@8.1.1) + socket.io-parser: 4.2.6(supports-color@8.1.1) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + sockjs@0.3.24: dependencies: faye-websocket: 0.11.4 @@ -26594,6 +27153,18 @@ snapshots: dependencies: highlight.js: 11.11.1 + svelte-i18n-lingui@0.2.2(@lingui/cli@6.11.0(esbuild@0.28.2)(rolldown@1.2.9)(supports-color@8.1.1))(@lingui/core@5.9.5)(@typescript/typescript6@6.0.2)(supports-color@8.1.1)(svelte@5.57.2(@typescript-eslint/types@8.70.1)): + dependencies: + '@lingui/cli': 6.11.0(esbuild@0.28.2)(rolldown@1.2.9)(supports-color@8.1.1) + '@lingui/core': 5.9.5 + '@typescript-eslint/typescript-estree': 8.72.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) + estree-walker-ts: 1.1.0 + js-sha256: 0.10.1 + svelte: 5.57.2(@typescript-eslint/types@8.70.1) + transitivePeerDependencies: + - supports-color + - typescript + svelte-i18n@4.0.1(svelte@5.57.2(@typescript-eslint/types@8.70.1)): dependencies: cli-color: 2.0.4 @@ -26910,6 +27481,8 @@ snapshots: es5-ext: 0.10.64 next-tick: 1.1.0 + tiny-case@1.0.3: {} + tiny-glob@0.2.9: dependencies: globalyzer: 0.1.0 @@ -26932,6 +27505,8 @@ snapshots: tinypool@1.1.1: {} + tinypool@2.2.0: {} + tinyqueue@3.0.0: {} tinyrainbow@3.2.0: {} @@ -26963,6 +27538,8 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + toposort@2.0.2: {} + totalist@3.0.1: {} tough-cookie@5.1.2: @@ -27043,7 +27620,6 @@ snapshots: tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 - optional: true tweetnacl@0.14.5: {} @@ -27980,6 +28556,13 @@ snapshots: yoctocolors@2.1.2: {} + yup@1.7.1: + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 + zimmerframe@1.1.4: {} zip-stream@6.0.1: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index da106172a..092d2079f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,7 @@ allowBuilds: '@scarf/scarf': false '@swc/core': false bcrypt: true + better-sqlite3: true canvas: false core-js: false cpu-features: false @@ -75,3 +76,4 @@ minimumReleaseAgeExclude: - '@immich/ui' - '@immich/sql-tools@0.6.3 || 0.6.6' - '@nestjs/cli@12.0.1' + - '@futo-org/*' diff --git a/server/Dockerfile b/server/Dockerfile index 970f5f3b5..ff94a94c9 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -78,7 +78,6 @@ ENV MISE_DATA_DIR=/buildcache/mise ENV MISE_DISABLE_TOOLS=flutter RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ mise install --locked - COPY ./packages/plugin-core ./packages/plugin-core/ # Build plugins @@ -119,6 +118,9 @@ ENV IMMICH_SOURCE_REF=${BUILD_SOURCE_REF} ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT} ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT} +COPY --from=ghcr.io/futo-org/backups/restic-proxy:v1.11.2@sha256:a88252d884d22a55749f45dd39a2458c6391ca3e4d3b0d9e0c2246786cf923be /usr/local/bin/restic-proxy /usr/local/bin/restic-proxy +COPY --from=ghcr.io/futo-org/restic:sha-a24cc22@sha256:7b5cdba9cc8690ecc528e239e6f8b2cfb9ef3644895cafd0e0fa9003bcded1f6 /usr/bin/restic /usr/local/bin/restic + VOLUME /data EXPOSE 2283 ENTRYPOINT ["tini", "--", "/bin/bash", "-c"] diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index 4b9d0c4b8..0bd2a74dd 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -3,6 +3,7 @@ FROM ghcr.io/immich-app/base-server-dev:202610291416@sha256:5f4f9f77059e1cd01829 COPY --from=ghcr.io/jdx/mise:2026.10.20@sha256:a3023a390ce9d79343741039d35a36aa41e4fe8198464bf8cc9ffd23ae081a24 /usr/local/bin/mise /usr/local/bin/mise +COPY --from=ghcr.io/immich-app/futo-backups-restic-proxy:commit-6297cf30a3caf4101e1ca74247bb822ab1f56ee0 /usr/local/bin/restic-proxy /usr/local/bin/restic-proxy RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \ echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \ diff --git a/server/package.json b/server/package.json index 8389bc5ea..9794ff10f 100644 --- a/server/package.json +++ b/server/package.json @@ -40,6 +40,7 @@ }, "dependencies": { "@extism/extism": "2.0.0-rc13", + "@futo-org/backups-orchestrator-api": "1.11.5", "@immich/plugin-sdk": "workspace:*", "@immich/sql-tools": "^0.6.6", "@nestjs/bullmq": "^12.0.0", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 83bf21108..9b6480238 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -1,21 +1,26 @@ +import { OrchestrationApiModule, controllers as futoBackupsControllers } from '@futo-org/backups-orchestrator-api'; import { BullModule } from '@nestjs/bullmq'; -import { Inject, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Inject, Module, OnModuleDestroy, OnModuleInit, applyDecorators, forwardRef } from '@nestjs/common'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; +import { ApiExtension, ApiTags } from '@nestjs/swagger'; import { ClsModule } from 'nestjs-cls'; import { KyselyModule } from 'nestjs-kysely'; import { OpenTelemetryModule } from 'nestjs-otel'; import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; import { commandsAndQuestions } from 'src/commands/index.js'; import { IWorker } from 'src/constants.js'; import { controllers } from 'src/controllers/index.js'; -import { ImmichWorker } from 'src/enum.js'; +import { HistoryBuilder } from 'src/decorators.js'; +import { ApiTag, ImmichEnvironment, ImmichWorker } from 'src/enum.js'; import { MaintenanceAuthGuard } from 'src/maintenance/maintenance-auth.guard.js'; import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository.js'; import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository.js'; import { MaintenanceWorkerController } from 'src/maintenance/maintenance-worker.controller.js'; import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service.js'; -import { AuthGuard } from 'src/middleware/auth.guard.js'; +import { AuthGuard, Authenticated } from 'src/middleware/auth.guard.js'; import { ErrorInterceptor } from 'src/middleware/error.interceptor.js'; import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor.js'; import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter.js'; @@ -39,6 +44,7 @@ import { services } from 'src/services/index.js'; import { QueueService } from 'src/services/queue.service.js'; import { getKyselyConfig } from 'src/utils/database.js'; import { configureUserAgent } from 'src/utils/fetch.js'; +import { detectMediaLocation, getBackupsStatePath } from 'src/utils/storage.js'; const common = [...repositories, ...services, GlobalExceptionFilter]; @@ -53,7 +59,21 @@ const commonMiddleware = [ const apiMiddleware = [FileUploadInterceptor, ...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }]; const configRepository = new ConfigRepository(); -const { bull, cls, database, otel } = configRepository.getEnv(); +const { bull, cls, database, environment, otel, storage } = configRepository.getEnv(); + +const isFutoBackupsDevelopmentMode = environment !== ImmichEnvironment.Production; +const futoBackupsStatePath = getBackupsStatePath(storage.mediaLocation); +const futoBackupsCachePath = join(detectMediaLocation(storage.mediaLocation, existsSync), 'restic-cache'); + +const futoBackupsHistory = new HistoryBuilder().added('v3.4.0').internal('v3.4.0').getExtensions(); + +for (const controller of futoBackupsControllers) { + applyDecorators( + Authenticated({ admin: true }), + ApiTags(ApiTag.Backups), + ...Object.entries(futoBackupsHistory).map(([key, value]) => ApiExtension(key, value)), + )(controller); +} const commonImports = [ ClsModule.forRoot(cls.config), @@ -103,14 +123,54 @@ export class BaseModule implements OnModuleInit, OnModuleDestroy { } @Module({ - imports: [...bullImports, ...commonImports, ScheduleModule.forRoot()], + imports: [ + ...bullImports, + ...commonImports, + ScheduleModule.forRoot(), + OrchestrationApiModule.forRootAsync({ + imports: [forwardRef(() => ApiModule)], + inject: [AuthService], + useFactory: (authService: AuthService) => ({ + statePath: futoBackupsStatePath, + cachePath: futoBackupsCachePath, + requireWsAuth: true, + requireLock: true, + developmentMode: isFutoBackupsDevelopmentMode, + authenticate: (client) => + authService.authenticate({ + headers: client.request.headers, + queryParams: {}, + metadata: { adminRoute: true, sharedLinkRoute: false, uri: '/api/futo-backups/socket.io' }, + }), + }), + }), + ], controllers: [...controllers], providers: [...common, ...apiMiddleware, { provide: IWorker, useValue: ImmichWorker.Api }], + exports: [AuthService, WebsocketRepository], }) export class ApiModule extends BaseModule {} @Module({ - imports: [...commonImports], + imports: [ + ...commonImports, + OrchestrationApiModule.forRootAsync({ + imports: [forwardRef(() => MaintenanceModule)], + inject: [MaintenanceWorkerService], + useFactory: (maintenanceWorkerService: MaintenanceWorkerService) => ({ + statePath: futoBackupsStatePath, + cachePath: futoBackupsCachePath, + externalBaseUrl: 'https://my.immich.app', + requireWsAuth: true, + requireLock: true, + developmentMode: isFutoBackupsDevelopmentMode, + authenticate: async (client) => { + await maintenanceWorkerService.authenticate(client.request.headers); + return { user: { isAdmin: true } }; + }, + }), + }), + ], controllers: [MaintenanceWorkerController], providers: [ ConfigRepository, @@ -129,6 +189,7 @@ export class ApiModule extends BaseModule {} { provide: APP_GUARD, useClass: MaintenanceAuthGuard }, { provide: IWorker, useValue: ImmichWorker.Maintenance }, ], + exports: [MaintenanceWorkerService, MaintenanceWebsocketRepository], }) export class MaintenanceModule { constructor( diff --git a/server/src/constants.ts b/server/src/constants.ts index a0b19d754..f589ad12b 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -153,6 +153,7 @@ export const endpointTags: Record = { [ApiTag.AssetFiles]: 'An asset file is a file associated with an asset, including edited versions, thumbnails, etc.', [ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.', [ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.', + [ApiTag.Backups]: 'FUTO Backups.', [ApiTag.ClusterGroups]: 'A cluster group is a set of users whose faces are clustered together, so that a person can be shared between them.', [ApiTag.ConfigUser]: 'The system configuration properties that are visible to logged in users.', diff --git a/server/src/controllers/index.spec.ts b/server/src/controllers/index.spec.ts index e156e01b0..2817fa825 100644 --- a/server/src/controllers/index.spec.ts +++ b/server/src/controllers/index.spec.ts @@ -92,7 +92,7 @@ const getRoutes = () => { id: `${method} ${path}`, label: `${Controller.name}.${name} (${method} /${path})`, path, - auth: getAuthenticatedOptions(reflector, handler), + auth: getAuthenticatedOptions(reflector, [handler]), }; }); }); diff --git a/server/src/dtos/config.dto.ts b/server/src/dtos/config.dto.ts index 63620b7d0..8cd22e537 100644 --- a/server/src/dtos/config.dto.ts +++ b/server/src/dtos/config.dto.ts @@ -163,6 +163,7 @@ const AdminConfigSchemaWithVisibility = z .object({ backup: z .object({ + beta: configBool.default(false).optional().describe('Whether the backups feature is enabled'), database: z .object({ enabled: configBool.describe('Enabled'), @@ -584,6 +585,7 @@ export function mapPublicConfig(config: SystemConfig): PublicConfigDto { export const defaults = Object.freeze({ backup: { + beta: false, database: { enabled: true, cronExpression: CronExpression.EVERY_DAY_AT_2AM, diff --git a/server/src/dtos/maintenance.dto.ts b/server/src/dtos/maintenance.dto.ts index 3b9ffc9b7..4bb7f35e1 100644 --- a/server/src/dtos/maintenance.dto.ts +++ b/server/src/dtos/maintenance.dto.ts @@ -6,6 +6,8 @@ const SetMaintenanceModeSchema = z .object({ action: MaintenanceActionSchema, restoreBackupFilename: z.string().optional().describe('Restore backup filename'), + rollbackRepositoryId: z.string().optional().describe('Rollback repository ID'), + rollbackSnapshotId: z.string().optional().describe('Rollback snapshot ID'), }) .refine( (data) => data.action !== MaintenanceAction.RestoreDatabase || (data.restoreBackupFilename?.length ?? 0) > 0, @@ -32,6 +34,7 @@ const MaintenanceStatusResponseSchema = z progress: z.int().optional(), task: z.string().optional(), error: z.string().optional(), + futoBackupsJobId: z.string().optional().describe('FUTO Backups job ID'), }) .meta({ id: 'MaintenanceStatusResponseDto' }); diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index 2e4e9f99e..6b2ef44c7 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -142,6 +142,7 @@ const ServerFeaturesSchema = z configFile: z.boolean().describe('Whether config file is available'), facialRecognition: z.boolean().describe('Whether facial recognition is enabled'), map: z.boolean().describe('Whether map feature is enabled'), + backups: z.boolean().describe('Whether the backups feature is enabled'), trash: z.boolean().describe('Whether trash feature is enabled'), reverseGeocoding: z.boolean().describe('Whether reverse geocoding is enabled'), importFaces: z.boolean().describe('Whether face import is enabled'), diff --git a/server/src/enum.ts b/server/src/enum.ts index 4f69ddf25..6231d63c3 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -1031,6 +1031,7 @@ export enum DatabaseLock { VersionCheck = 800, HlsSessionCleanup = 850, FacialRecognition = 900, + FutoBackupsModuleConfig = 926, DuplicateDetection = 1000, } @@ -1039,6 +1040,7 @@ export enum MaintenanceAction { End = 'end', SelectDatabaseRestore = 'select_database_restore', RestoreDatabase = 'restore_database', + Rollback = 'rollback', } export const MaintenanceActionSchema = z @@ -1263,6 +1265,7 @@ export enum ApiTag { AuthenticationAdmin = 'Authentication (admin)', Assets = 'Assets', AssetFiles = 'Asset files', + Backups = 'Backups', ConfigUser = 'Config (user)', ConfigAdmin = 'Config (admin)', ConfigPublic = 'Config (public)', diff --git a/server/src/maintenance/maintenance-auth.guard.ts b/server/src/maintenance/maintenance-auth.guard.ts index 8533b171c..31c836338 100644 --- a/server/src/maintenance/maintenance-auth.guard.ts +++ b/server/src/maintenance/maintenance-auth.guard.ts @@ -1,4 +1,5 @@ -import { CanActivate, ExecutionContext, Injectable, SetMetadata, applyDecorators } from '@nestjs/common'; +import { controllers as futoBackupsControllers } from '@futo-org/backups-orchestrator-api'; +import { CanActivate, ExecutionContext, Injectable, SetMetadata, Type, applyDecorators } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Request } from 'express'; import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto.js'; @@ -26,16 +27,19 @@ export class MaintenanceAuthGuard implements CanActivate { } async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const targets = [context.getHandler()]; const options = this.reflector.getAllAndOverride<{ _emptyObject: never } | undefined>( MetadataKey.AuthRoute, targets, ); - if (!options) { + + const isFutoBackupsRoute = (futoBackupsControllers as Type[]).includes(context.getClass()); + if (!options && !isFutoBackupsRoute) { return true; } - const request = context.switchToHttp().getRequest(); request.auth = await this.service.authenticate(request.headers); return true; diff --git a/server/src/maintenance/maintenance-health.repository.spec.ts b/server/src/maintenance/maintenance-health.repository.spec.ts new file mode 100644 index 000000000..9fd9b0e1b --- /dev/null +++ b/server/src/maintenance/maintenance-health.repository.spec.ts @@ -0,0 +1,110 @@ +import { ChildProcess, fork } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository.js'; + +vi.mock('node:child_process', () => ({ fork: vi.fn() })); + +const createWorker = () => { + const worker = Object.assign(new EventEmitter(), { kill: vi.fn() }); + worker.kill.mockImplementation((signal: NodeJS.Signals) => { + worker.emit('exit', null, signal); + return true; + }); + return worker; +}; + +const refused = () => Promise.reject(new TypeError('fetch failed')); +const respond = (ok: boolean) => Promise.resolve({ ok } as Response); + +describe(MaintenanceHealthRepository.name, () => { + let sut: MaintenanceHealthRepository; + let worker: ReturnType; + let fetchMock: ReturnType>; + + beforeEach(() => { + vi.useFakeTimers(); + sut = new MaintenanceHealthRepository(); + worker = createWorker(); + vi.mocked(fork).mockReturnValue(worker as unknown as ChildProcess); + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + describe('checkApiHealth', () => { + it('should start the api worker on the health check address', async () => { + fetchMock.mockImplementation(() => respond(true)); + + const promise = sut.checkApiHealth(); + await vi.advanceTimersByTimeAsync(1000); + await promise; + + expect(fork).toHaveBeenCalledWith( + expect.stringMatching(/workers\/api\.js$/), + [], + expect.objectContaining({ + env: expect.objectContaining({ IMMICH_HOST: '127.0.0.1', IMMICH_PORT: '33001' }), + }), + ); + }); + + it('should resolve once the api worker answers the ping endpoint', async () => { + fetchMock + .mockImplementationOnce(refused) + .mockImplementationOnce(refused) + .mockImplementation(() => respond(true)); + + const promise = sut.checkApiHealth(); + await vi.advanceTimersByTimeAsync(3000); + + await expect(promise).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:33001/api/server/ping', expect.anything()); + expect(worker.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('should keep polling while the ping endpoint returns an error', async () => { + fetchMock.mockImplementationOnce(() => respond(false)).mockImplementation(() => respond(true)); + + const promise = sut.checkApiHealth(); + await vi.advanceTimersByTimeAsync(2000); + + await expect(promise).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('should reject when the api worker exits before answering', async () => { + fetchMock.mockImplementation(refused); + + const promise = sut.checkApiHealth(); + worker.emit('exit', 1, null); + + await expect(promise).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Server health check failed, server exited with 1]`, + ); + await vi.advanceTimersByTimeAsync(5000); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('should reject and stop the api worker when it does not answer in time', async () => { + fetchMock.mockImplementation(refused); + + const promise = sut.checkApiHealth(); + const assertion = expect(promise).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Server health check failed, took too long to start.]`, + ); + await vi.advanceTimersByTimeAsync(180_000); + + await assertion; + expect(worker.kill).toHaveBeenCalledWith('SIGTERM'); + + const calls = fetchMock.mock.calls.length; + await vi.advanceTimersByTimeAsync(5000); + expect(fetchMock).toHaveBeenCalledTimes(calls); + }); + }); +}); diff --git a/server/src/maintenance/maintenance-health.repository.ts b/server/src/maintenance/maintenance-health.repository.ts index 07e0ad824..2e386d942 100644 --- a/server/src/maintenance/maintenance-health.repository.ts +++ b/server/src/maintenance/maintenance-health.repository.ts @@ -1,7 +1,11 @@ import { Injectable } from '@nestjs/common'; import { fork } from 'node:child_process'; import { dirname, join } from 'node:path'; -import { IMMICH_SERVER_START } from 'src/constants.js'; + +const HEALTH_CHECK_HOST = '127.0.0.1'; +const HEALTH_CHECK_PORT = '33001'; +const HEALTH_CHECK_TIMEOUT = 180_000; +const PING_INTERVAL = 1000; @Injectable() export class MaintenanceHealthRepository { @@ -14,42 +18,54 @@ export class MaintenanceHealthRepository { execArgv: process.execArgv.filter((arg) => !arg.startsWith('--inspect')), env: { ...process.env, - IMMICH_HOST: '127.0.0.1', - IMMICH_PORT: '33001', + IMMICH_HOST: HEALTH_CHECK_HOST, + IMMICH_PORT: HEALTH_CHECK_PORT, }, - stdio: ['ignore', 'pipe', 'ignore', 'ipc'], + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], }); - let output = ''; + let settled = false; + let pingTimer: NodeJS.Timeout | undefined; - worker.stdout?.on('data', (data) => { - if (worker.exitCode !== null) { + function settle(error?: Error) { + if (settled) { return; } - output += data; - - if (!output.includes(IMMICH_SERVER_START)) { - return; - } - - resolve(); + settled = true; + clearTimeout(pingTimer); + clearTimeout(deadline); worker.kill('SIGTERM'); - }); + + if (error) { + reject(error); + } else { + resolve(); + } + } + + async function ping() { + const response = await fetch(`http://${HEALTH_CHECK_HOST}:${HEALTH_CHECK_PORT}/api/server/ping`, { + signal: AbortSignal.timeout(PING_INTERVAL * 5), + }).catch(() => null); + + if (response?.ok) { + settle(); + } else if (!settled) { + pingTimer = setTimeout(() => void ping(), PING_INTERVAL); + } + } worker.on('exit', (code, signal) => - reject(new Error(`Server health check failed, server exited with ${signal ?? code}`)), + settle(new Error(`Server health check failed, server exited with ${signal ?? code}`)), ); - worker.on('error', (error) => reject(new Error(`Server health check failed, process threw: ${error}`))); + worker.on('error', (error) => settle(new Error(`Server health check failed, process threw: ${error}`))); - setTimeout(() => { - if (worker.exitCode !== null) { - return; - } - - reject(new Error('Server health check failed, took too long to start.')); - worker.kill('SIGTERM'); - }, 180_000); + const deadline = setTimeout( + () => settle(new Error('Server health check failed, took too long to start.')), + HEALTH_CHECK_TIMEOUT, + ); + pingTimer = setTimeout(() => void ping(), PING_INTERVAL); }); } } diff --git a/server/src/maintenance/maintenance-worker.service.spec.ts b/server/src/maintenance/maintenance-worker.service.spec.ts index 9d131be08..34cb09600 100644 --- a/server/src/maintenance/maintenance-worker.service.spec.ts +++ b/server/src/maintenance/maintenance-worker.service.spec.ts @@ -1,6 +1,6 @@ import { UnauthorizedException } from '@nestjs/common'; import { SignJWT } from 'jose'; -import { MaintenanceAction, SystemMetadataKey } from 'src/enum.js'; +import { DatabaseLock, MaintenanceAction, SystemMetadataKey } from 'src/enum.js'; import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository.js'; import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository.js'; import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service.js'; @@ -10,12 +10,14 @@ import { AutoMocked, ServiceMocks, automock, getMocks } from 'test/utils.js'; describe(MaintenanceWorkerService.name, () => { let sut: MaintenanceWorkerService; let mocks: ServiceMocks; + let moduleRefMock: { get: ReturnType }; let maintenanceWebsocketRepositoryMock: AutoMocked; let maintenanceHealthRepositoryMock: AutoMocked; let databaseBackupServiceMock: AutoMocked; beforeEach(() => { mocks = getMocks(); + moduleRefMock = { get: vitest.fn() }; maintenanceWebsocketRepositoryMock = automock(MaintenanceWebsocketRepository, { args: [mocks.logger], strict: false, @@ -50,6 +52,7 @@ describe(MaintenanceWorkerService.name, () => { mocks.process, mocks.database as never, databaseBackupServiceMock, + moduleRefMock as never, ); sut.mock({ @@ -58,6 +61,33 @@ describe(MaintenanceWorkerService.name, () => { }); }); + describe('init', () => { + beforeEach(() => { + mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' }); + }); + + it('takes the backups SDK lock so its jobs run during maintenance', async () => { + const futoBackups = { acquireLock: vitest.fn() }; + moduleRefMock.get.mockReturnValue(futoBackups); + mocks.database.tryLock.mockResolvedValue(true); + + await sut.init(); + + expect(mocks.database.tryLock).toHaveBeenCalledWith(DatabaseLock.FutoBackupsModuleConfig); + expect(futoBackups.acquireLock).toHaveBeenCalled(); + }); + + it('leaves the backups SDK lock to whoever already holds it', async () => { + const futoBackups = { acquireLock: vitest.fn() }; + moduleRefMock.get.mockReturnValue(futoBackups); + mocks.database.tryLock.mockResolvedValue(false); + + await sut.init(); + + expect(futoBackups.acquireLock).not.toHaveBeenCalled(); + }); + }); + it('should work', () => { expect(sut).toBeDefined(); }); diff --git a/server/src/maintenance/maintenance-worker.service.ts b/server/src/maintenance/maintenance-worker.service.ts index 6e51ac61a..87f0ca371 100644 --- a/server/src/maintenance/maintenance-worker.service.ts +++ b/server/src/maintenance/maintenance-worker.service.ts @@ -1,9 +1,12 @@ +import { YuccaService } from '@futo-org/backups-orchestrator-api'; import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { parseCookie } from 'cookie'; import { NextFunction, Request, Response } from 'express'; import { jwtVerify } from 'jose'; import { readFileSync } from 'node:fs'; import { IncomingHttpHeaders } from 'node:http'; +import { basename } from 'node:path'; import type { MaintenanceModeState } from 'src/types.js'; import { serverVersion } from 'src/constants.js'; import { StorageCore } from 'src/cores/storage.core.js'; @@ -32,6 +35,7 @@ import { type VersionService as _VersionService } from 'src/services/version.ser import { getConfig } from 'src/utils/config.js'; import { createMaintenanceLoginUrl, detectPriorInstall } from 'src/utils/maintenance.js'; import { getExternalDomain } from 'src/utils/misc.js'; +import { detectMediaLocation } from 'src/utils/storage.js'; /** This service is available inside of maintenance mode to manage maintenance mode */ @Injectable() @@ -53,6 +57,7 @@ export class MaintenanceWorkerService { private processRepository: ProcessRepository, private databaseRepository: DatabaseRepository, private databaseBackupService: DatabaseBackupService, + private moduleRef: ModuleRef, ) { this.logger.setContext(this.constructor.name); } @@ -80,6 +85,10 @@ export class MaintenanceWorkerService { await this.logSecret(); + if (await this.databaseRepository.tryLock(DatabaseLock.FutoBackupsModuleConfig)) { + this.moduleRef.get(YuccaService, { strict: false }).acquireLock(); + } + if (state.action) { void this.runAction(state.action); } @@ -146,28 +155,9 @@ export class MaintenanceWorkerService { }; } - /** {@link _StorageService.detectMediaLocation} */ detectMediaLocation(): string { const envData = this.configRepository.getEnv(); - if (envData.storage.mediaLocation) { - return envData.storage.mediaLocation; - } - - const targets: string[] = []; - const candidates = ['/data', '/usr/src/app/upload']; - - for (const candidate of candidates) { - const isExists = this.storageRepository.existsSync(candidate); - if (isExists) { - targets.push(candidate); - } - } - - if (targets.length === 1) { - return targets[0]; - } - - return '/usr/src/app/upload'; + return detectMediaLocation(envData.storage.mediaLocation, (path) => this.storageRepository.existsSync(path)); } private get secret() { @@ -277,6 +267,9 @@ export class MaintenanceWorkerService { case MaintenanceAction.RestoreDatabase: { return this.runRestoreDatabase(action); } + case MaintenanceAction.Rollback: { + return this.runRollback(action); + } } } @@ -335,6 +328,67 @@ export class MaintenanceWorkerService { }); } + private async runRollback(action: SetMaintenanceModeDto) { + const isLock = await this.databaseRepository.tryLock(DatabaseLock.MaintenanceOperation); + if (!isLock) { + return; + } + + this.logger.log(`Running maintenance action ${action.action}`); + + await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, { + isMaintenanceMode: true, + secret: this.secret, + action: { + action: MaintenanceAction.Start, + }, + }); + + try { + if (!action.rollbackRepositoryId || !action.rollbackSnapshotId) { + throw new Error("Expected rollbackRepositoryId and rollbackSnapshotId but they're missing!"); + } + + await this.rollback(action.rollbackRepositoryId, action.rollbackSnapshotId); + } catch (error) { + this.logger.error(`Encountered error running action: ${error}`); + this.setStatus({ + active: true, + action: action.action, + task: 'error', + error: String(error), + futoBackupsJobId: this.#status.futoBackupsJobId, + }); + } + } + + private async rollback(repositoryId: string, snapshotId: string): Promise { + this.setStatus({ + active: true, + action: MaintenanceAction.Rollback, + }); + + const futoBackups = this.moduleRef.get(YuccaService, { strict: false }); + const { jobId, task, immichBackupFileName } = await futoBackups.restoreSnapshotInplace(repositoryId, snapshotId); + + this.setStatus({ + active: true, + action: MaintenanceAction.Rollback, + futoBackupsJobId: jobId, + }); + + await task; + + if (!immichBackupFileName) { + return this.setAction({ + action: MaintenanceAction.SelectDatabaseRestore, + }); + } + + const backupFileName = basename(immichBackupFileName); + await this.restoreBackup(backupFileName); + } + private async endMaintenance(): Promise { const state: MaintenanceModeState = { isMaintenanceMode: false as const }; await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state); diff --git a/server/src/middleware/auth.guard.spec.ts b/server/src/middleware/auth.guard.spec.ts index f5d9c90f3..82453199f 100644 --- a/server/src/middleware/auth.guard.spec.ts +++ b/server/src/middleware/auth.guard.spec.ts @@ -16,9 +16,15 @@ class TestController { undecoratedRoute() {} } -const contextFor = (handler: () => void) => +@Authenticated({ admin: true }) +class AdminTestController { + route() {} +} + +const contextFor = (handler: () => void, controller: object = TestController) => ({ getHandler: () => handler, + getClass: () => controller, switchToHttp: () => ({ getRequest: () => ({ headers: {}, query: {}, path: '/' }) }), }) as unknown as ExecutionContext; @@ -79,4 +85,18 @@ describe(AuthGuard.name, () => { expect(authenticate).not.toHaveBeenCalled(); }); }); + + describe('class-level @Authenticated()', () => { + it('should apply the controller options to its undecorated routes', async () => { + const authenticate = vitest.spyOn(authService, 'authenticate').mockResolvedValue({} as never); + + await expect(sut.canActivate(contextFor(AdminTestController.prototype.route, AdminTestController))).resolves.toBe( + true, + ); + + expect(authenticate).toHaveBeenCalledWith( + expect.objectContaining({ metadata: expect.objectContaining({ adminRoute: true }) }), + ); + }); + }); }); diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index c71655c70..6995d3f67 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -26,12 +26,12 @@ export type AuthenticatedOptions = AuthorizedRoute | PublicRoute; type ReflectorTarget = Parameters[1]; /** Resolves the `@Authenticated()` options of a route handler, with the defaults applied. */ -export const getAuthenticatedOptions = (reflector: Reflector, target: ReflectorTarget) => { - const options = reflector.getAllAndOverride(MetadataKey.AuthRoute, [target]); +export const getAuthenticatedOptions = (reflector: Reflector, targets: ReflectorTarget[]) => { + const options = reflector.getAllAndOverride(MetadataKey.AuthRoute, targets); return options && { sharedLink: false, admin: false, public: false, setup: false, ...options }; }; -export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorator => { +export const Authenticated = (options: AuthenticatedOptions = {}): ReturnType => { const decorators: MethodDecorator[] = [SetMetadata(MetadataKey.AuthRoute, options)]; if (!options.public) { @@ -97,7 +97,7 @@ export class AuthGuard implements CanActivate { } async canActivate(context: ExecutionContext): Promise { - const options = getAuthenticatedOptions(this.reflector, context.getHandler()); + const options = getAuthenticatedOptions(this.reflector, [context.getHandler(), context.getClass()]); if (!options) { throw new Error(`Route ${context.getHandler().name} does not declare @Authenticated()`); } diff --git a/server/src/middleware/websocket.adapter.ts b/server/src/middleware/websocket.adapter.ts index cedaabc51..ef83e8672 100644 --- a/server/src/middleware/websocket.adapter.ts +++ b/server/src/middleware/websocket.adapter.ts @@ -15,7 +15,11 @@ export class WebSocketAdapter extends IoAdapter { const server = super.createIOServer(port, options); const pubClient = new Redis(redis); const subClient = pubClient.duplicate(); - server.adapter(createAdapter(pubClient, subClient)); + const isImmichSocket = !options?.path || options.path === '/api/socket.io'; + const adapterOpts = { + key: isImmichSocket ? undefined : `socket.io` + options.path, + }; + server.adapter(createAdapter(pubClient, subClient, adapterOpts)); return server; } } diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 60a227a18..c1ad5a1f3 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -71,6 +71,10 @@ type EventMap = { /** job finishes with error */ JobError: [JobErrorEvent]; + LibraryCreate: []; + LibraryUpdate: []; + LibraryDelete: []; + // queue events QueueStart: [QueueStartEvent]; diff --git a/server/src/repositories/websocket.repository.ts b/server/src/repositories/websocket.repository.ts index 5c963d017..3a0b5aeaf 100644 --- a/server/src/repositories/websocket.repository.ts +++ b/server/src/repositories/websocket.repository.ts @@ -19,6 +19,9 @@ import { handlePromiseError } from 'src/utils/misc.js'; export const serverEvents = [ 'ConfigUpdate', 'AppRestart', + 'LibraryCreate', + 'LibraryUpdate', + 'LibraryDelete', 'HlsSegmentRequest', 'HlsSegmentResult', 'HlsHeartbeat', diff --git a/server/src/services/database-backup.service.spec.ts b/server/src/services/database-backup.service.spec.ts index c6869705c..8a57bbfc7 100644 --- a/server/src/services/database-backup.service.spec.ts +++ b/server/src/services/database-backup.service.spec.ts @@ -217,6 +217,12 @@ describe(DatabaseBackupService.name, () => { `); }); + it('should abort the backup and remove the temporary file when the signal is aborted', async () => { + await expect(sut.createDatabaseBackup('', AbortSignal.abort())).rejects.toThrow('The operation was aborted'); + expect(mocks.storage.unlink).toHaveBeenCalled(); + expect(mocks.storage.rename).not.toHaveBeenCalled(); + }); + it('should run a database backup successfully', async () => { const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.Success); diff --git a/server/src/services/database-backup.service.ts b/server/src/services/database-backup.service.ts index a2d1dbed8..4ad0185ff 100644 --- a/server/src/services/database-backup.service.ts +++ b/server/src/services/database-backup.service.ts @@ -224,7 +224,7 @@ export class DatabaseBackupService { }; } - async createDatabaseBackup(filenamePrefix: string = ''): Promise { + async createDatabaseBackup(filenamePrefix: string = '', signal?: AbortSignal): Promise { this.logger.debug(`Database Backup Started`); const { bin, args, databasePassword, databaseVersion, databaseMajorVersion } = @@ -252,7 +252,7 @@ export class DatabaseBackupService { gzip = this.processRepository.spawnDuplexStream(compression.command, compression.args); const fileStream = this.storageRepository.createWriteStream(temporaryFilePath); - await pipeline(pgdump, gzip, fileStream); + await pipeline(pgdump, gzip, fileStream, { signal }); await this.storageRepository.rename(temporaryFilePath, backupFilePath); } catch (error) { this.logger.error(`Database Backup Failure: ${error}`); diff --git a/server/src/services/futo-backups.service.ts b/server/src/services/futo-backups.service.ts new file mode 100644 index 000000000..1d2d551d3 --- /dev/null +++ b/server/src/services/futo-backups.service.ts @@ -0,0 +1,102 @@ +import { type ImmichDatabaseDumpConfig, YuccaService as OrchestratorService } from '@futo-org/backups-orchestrator-api'; +import { Injectable, Optional } from '@nestjs/common'; +import type { SystemConfig } from 'src/dtos/config.dto.js'; +import type { ArgOf } from 'src/repositories/event.repository.js'; +import { StorageCore } from 'src/cores/storage.core.js'; +import { OnEvent } from 'src/decorators.js'; +import { DatabaseLock, ImmichWorker, MaintenanceAction, StorageFolder } from 'src/enum.js'; +import { DatabaseRepository } from 'src/repositories/database.repository.js'; +import { LibraryRepository } from 'src/repositories/library.repository.js'; +import { DatabaseBackupService } from 'src/services/database-backup.service.js'; +import { MaintenanceService } from 'src/services/maintenance.service.js'; +import { SystemConfigService } from 'src/services/system-config.service.js'; +import { getExternalDomain } from 'src/utils/misc.js'; + +@Injectable() +export class FutoBackupsService { + constructor( + private readonly databaseRepository: DatabaseRepository, + private readonly libraryRepository: LibraryRepository, + private readonly databaseBackupService: DatabaseBackupService, + private readonly systemConfigService: SystemConfigService, + @Optional() private readonly maintenanceService: MaintenanceService, + @Optional() private readonly orchestratorService: OrchestratorService, + ) {} + + private updateSystemConfig({ server }: SystemConfig) { + this.orchestratorService.setExternalBaseUrl(getExternalDomain(server)); + } + + private async updateLibraryConfig() { + const libraries = await this.libraryRepository.getAll(); + + this.orchestratorService.setImmichIntegration({ + dataPath: StorageCore.getMediaLocation(), + dataFolders: Object.values(StorageFolder), + libraries: libraries + .filter((library) => !library.deletedAt) + .map(({ id, name, importPaths, exclusionPatterns }) => ({ id, name, importPaths, exclusionPatterns })), + hooks: { + createDatabaseBackup: (signal) => this.databaseBackupService.createDatabaseBackup('', signal), + cleanupDatabaseBackups: () => this.databaseBackupService.cleanupDatabaseBackups(), + getImmichDatabaseDumpConfig: () => this.getImmichDatabaseDumpConfig(), + configureImmichDatabaseDump: (databaseDump) => this.configureImmichDatabaseDump(databaseDump), + enterMaintenanceRollback: (repositoryId, snapshotId) => this.enterMaintenanceRollback(repositoryId, snapshotId), + }, + }); + } + + private async getImmichDatabaseDumpConfig(): Promise { + const { backup } = await this.systemConfigService.getAdminConfig(); + return { enabled: backup.database.enabled, keepLastAmount: backup.database.keepLastAmount }; + } + + private async configureImmichDatabaseDump(databaseDump: Partial) { + const config = await this.systemConfigService.getAdminConfig(); + await this.systemConfigService.updateAdminConfig({ + ...config, + backup: { ...config.backup, database: { ...config.backup.database, ...databaseDump } }, + }); + } + + private enterMaintenanceRollback(repositoryId: string, snapshotId: string) { + return this.maintenanceService.startMaintenance( + { + action: MaintenanceAction.Rollback, + rollbackRepositoryId: repositoryId, + rollbackSnapshotId: snapshotId, + }, + 'futo-backups-rollback', + ); + } + + @OnEvent({ name: 'ConfigInit', workers: [ImmichWorker.Api] }) + async onConfigInit({ newConfig }: ArgOf<'ConfigInit'>) { + this.updateSystemConfig(newConfig); + void this.updateLibraryConfig(); + + if (await this.databaseRepository.tryLock(DatabaseLock.FutoBackupsModuleConfig)) { + this.orchestratorService.acquireLock(); + } + } + + @OnEvent({ name: 'ConfigUpdate', workers: [ImmichWorker.Api], server: true }) + onConfigUpdate({ newConfig }: ArgOf<'ConfigUpdate'>) { + this.updateSystemConfig(newConfig); + } + + @OnEvent({ name: 'LibraryCreate', workers: [ImmichWorker.Api], server: true }) + onLibraryCreate() { + void this.updateLibraryConfig(); + } + + @OnEvent({ name: 'LibraryUpdate', workers: [ImmichWorker.Api], server: true }) + onLibraryUpdate() { + void this.updateLibraryConfig(); + } + + @OnEvent({ name: 'LibraryDelete', workers: [ImmichWorker.Api], server: true }) + onLibraryDelete() { + void this.updateLibraryConfig(); + } +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index a491c6722..0599fa4c5 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -13,6 +13,7 @@ import { DatabaseBackupService } from 'src/services/database-backup.service.js'; import { DatabaseService } from 'src/services/database.service.js'; import { DownloadService } from 'src/services/download.service.js'; import { DuplicateService } from 'src/services/duplicate.service.js'; +import { FutoBackupsService } from 'src/services/futo-backups.service.js'; import { HlsService } from 'src/services/hls.service.js'; import { IntegrityService } from 'src/services/integrity.service.js'; import { JobService } from 'src/services/job.service.js'; @@ -68,6 +69,7 @@ export const services = [ DatabaseService, DownloadService, DuplicateService, + FutoBackupsService, IntegrityService, HlsService, JobService, diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 53b3e7035..6b1d44b1d 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -619,6 +619,7 @@ describe(LibraryService.name, () => { expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LibraryDelete, data: { id: library.id } }); expect(mocks.library.softDelete).toHaveBeenCalledWith(library.id); + expect(mocks.event.emit).toHaveBeenCalledWith('LibraryDelete'); }); it('should allow an external library to be deleted', async () => { @@ -724,6 +725,7 @@ describe(LibraryService.name, () => { exclusionPatterns: expect.any(Array), }), ); + expect(mocks.event.emit).toHaveBeenCalledWith('LibraryCreate'); }); it('should create with name', async () => { @@ -891,6 +893,7 @@ describe(LibraryService.name, () => { 'library-id', expect.objectContaining({ importPaths: [`${cwd}/foo/bar`] }), ); + expect(mocks.event.emit).toHaveBeenCalledWith('LibraryUpdate'); }); }); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 7fac6db3f..e91eca69a 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -247,6 +247,8 @@ export class LibraryService extends BaseService { '**/.stfolder/**', ], }); + + await this.eventRepository.emit('LibraryCreate'); return mapLibrary(library); } @@ -358,6 +360,7 @@ export class LibraryService extends BaseService { } const library = await this.libraryRepository.update(id, dto); + await this.eventRepository.emit('LibraryUpdate'); return mapLibrary(library); } @@ -370,6 +373,8 @@ export class LibraryService extends BaseService { await this.libraryRepository.softDelete(id); await this.jobRepository.queue({ name: JobName.LibraryDelete, data: { id } }); + + await this.eventRepository.emit('LibraryDelete'); } @OnJob({ name: JobName.LibraryDelete, queue: QueueName.Library }) diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 03193d1b8..971add822 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -70,6 +70,27 @@ describe(NotificationService.name, () => { }); }); + describe('onLibraryCreate', () => { + it('should relay the event to other servers', () => { + sut.onLibraryCreate(); + expect(mocks.websocket.serverSend).toHaveBeenCalledWith('LibraryCreate'); + }); + }); + + describe('onLibraryUpdate', () => { + it('should relay the event to other servers', () => { + sut.onLibraryUpdate(); + expect(mocks.websocket.serverSend).toHaveBeenCalledWith('LibraryUpdate'); + }); + }); + + describe('onLibraryDelete', () => { + it('should relay the event to other servers', () => { + sut.onLibraryDelete(); + expect(mocks.websocket.serverSend).toHaveBeenCalledWith('LibraryDelete'); + }); + }); + describe('onConfigValidateEvent', () => { it('validates smtp config when enabling smtp', async () => { const oldConfig = configs.smtpDisabled; diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 24a6c7024..0f491e100 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -123,6 +123,21 @@ export class NotificationService extends BaseService { this.websocketRepository.serverSend('AppRestart', state); } + @OnEvent({ name: 'LibraryCreate' }) + onLibraryCreate() { + this.websocketRepository.serverSend('LibraryCreate'); + } + + @OnEvent({ name: 'LibraryUpdate' }) + onLibraryUpdate() { + this.websocketRepository.serverSend('LibraryUpdate'); + } + + @OnEvent({ name: 'LibraryDelete' }) + onLibraryDelete() { + this.websocketRepository.serverSend('LibraryDelete'); + } + @OnEvent({ name: 'ConfigValidate', priority: -100 }) async onConfigValidate({ oldConfig, newConfig }: ArgOf<'ConfigValidate'>) { try { diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index 00a6653dd..6174e994b 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -138,6 +138,7 @@ describe(ServerService.name, () => { duplicateDetection: true, facialRecognition: true, importFaces: false, + backups: false, map: true, reverseGeocoding: true, oauth: false, diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 1de815e17..119329efb 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -86,8 +86,18 @@ export class ServerService extends BaseService { } async getFeatures(): Promise { - const { reverseGeocoding, metadata, map, machineLearning, trash, oauth, passwordLogin, notifications, ffmpeg } = - await this.getConfig({ withCache: false }); + const { + reverseGeocoding, + metadata, + map, + backup, + machineLearning, + trash, + oauth, + passwordLogin, + notifications, + ffmpeg, + } = await this.getConfig({ withCache: false }); const { configFile } = this.configRepository.getEnv(); return { @@ -95,6 +105,7 @@ export class ServerService extends BaseService { facialRecognition: isFacialRecognitionEnabled(machineLearning), duplicateDetection: isDuplicateDetectionEnabled(machineLearning), map: map.enabled, + backups: !!backup.beta, reverseGeocoding: reverseGeocoding.enabled, importFaces: metadata.faces.import, sidecar: true, diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 9ac44e6f1..21df95d0a 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -15,6 +15,7 @@ import { } from 'src/enum.js'; import { BaseService } from 'src/services/base.service.js'; import { ImmichStartupError } from 'src/utils/misc.js'; +import { detectMediaLocation } from 'src/utils/storage.js'; const docsMessage = `Please see https://docs.immich.app/administration/system-integrity#folder-checks for more information.`; @@ -22,25 +23,7 @@ const docsMessage = `Please see https://docs.immich.app/administration/system-in export class StorageService extends BaseService { private detectMediaLocation(): string { const envData = this.configRepository.getEnv(); - if (envData.storage.mediaLocation) { - return envData.storage.mediaLocation; - } - - const targets: string[] = []; - const candidates = ['/data', '/usr/src/app/upload']; - - for (const candidate of candidates) { - const isExists = this.storageRepository.existsSync(candidate); - if (isExists) { - targets.push(candidate); - } - } - - if (targets.length === 1) { - return targets[0]; - } - - return '/usr/src/app/upload'; + return detectMediaLocation(envData.storage.mediaLocation, (path) => this.storageRepository.existsSync(path)); } @OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.StorageService }) diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 0f7e297f1..7968c6476 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -47,6 +47,7 @@ const updatedConfig = Object.freeze({ [QueueName.Editor]: { concurrency: 2 }, }, backup: { + beta: false, database: { enabled: true, cronExpression: '0 02 * * *', diff --git a/server/src/utils/storage.ts b/server/src/utils/storage.ts new file mode 100644 index 000000000..7485dac35 --- /dev/null +++ b/server/src/utils/storage.ts @@ -0,0 +1,49 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +const MEDIA_LOCATION_CANDIDATES = ['/data', '/usr/src/app/upload']; +const MEDIA_LOCATION_FALLBACK = '/usr/src/app/upload'; + +export const detectMediaLocation = (mediaLocation: string | undefined, exists: (path: string) => boolean): string => { + if (mediaLocation) { + return mediaLocation; + } + + const targets: string[] = []; + + for (const candidate of MEDIA_LOCATION_CANDIDATES) { + if (exists(candidate)) { + targets.push(candidate); + } + } + + if (targets.length === 1) { + return targets[0]; + } + + return MEDIA_LOCATION_FALLBACK; +}; + +export const getBackupsStatePath = (mediaLocation: string | undefined): string => { + if (mediaLocation) { + return join(mediaLocation, 'backups', 'yucca'); + } + + const candidates = MEDIA_LOCATION_CANDIDATES.filter((candidate) => existsSync(candidate)); + + let warning: string | undefined; + if (candidates.length === 0) { + warning = `None of ${MEDIA_LOCATION_CANDIDATES.join(', ')} exist and media location is not otherwise set`; + } else if (candidates.length > 1) { + warning = `Multiple paths (${candidates.join(', ')}) exist at the same time`; + } + + const statePath = join(candidates.length === 1 ? candidates[0] : MEDIA_LOCATION_FALLBACK, 'backups', 'yucca'); + if (warning) { + console.warn( + `${warning}, your backup configuration and encryption keys will be read from ${statePath}, which may not be where they were originally written.`, + ); + } + + return statePath; +}; diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index 0165a2c6e..101e444f4 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -13,6 +13,10 @@ async function bootstrap() { const app = await NestFactory.create(ApiModule, { bufferLogs: true, + // default module id algorithm can cause + // the ApiModule to be instatiated more than + // once when injected into OrchestrationApiModule + moduleIdGeneratorAlgorithm: 'deep-hash', routeConflictPolicy: { duplicate: 'error' }, routeResolutionStrategy: 'specificity', }); diff --git a/server/src/workers/maintenance.ts b/server/src/workers/maintenance.ts index 822e56e21..e45109628 100644 --- a/server/src/workers/maintenance.ts +++ b/server/src/workers/maintenance.ts @@ -12,6 +12,8 @@ async function bootstrap() { const app = await NestFactory.create(MaintenanceModule, { bufferLogs: true, + // see comment in api.ts + moduleIdGeneratorAlgorithm: 'deep-hash', routeConflictPolicy: { duplicate: 'error' }, routeResolutionStrategy: 'specificity', }); diff --git a/server/test/medium/specs/services/library.service.spec.ts b/server/test/medium/specs/services/library.service.spec.ts index 2861477a7..fc46cb251 100644 --- a/server/test/medium/specs/services/library.service.spec.ts +++ b/server/test/medium/specs/services/library.service.spec.ts @@ -16,6 +16,7 @@ import { JobRepository } from 'src/repositories/job.repository.js'; import { LibraryRepository } from 'src/repositories/library.repository.js'; import { LoggingRepository } from 'src/repositories/logging.repository.js'; import { StorageRepository } from 'src/repositories/storage.repository.js'; +import { WebsocketRepository } from 'src/repositories/websocket.repository.js'; import { DB } from 'src/schema/index.js'; import { LibraryService } from 'src/services/library.service.js'; import { systemConfigStub } from 'test/fixtures/system-config.stub.js'; @@ -49,7 +50,14 @@ class LibraryTestContext extends MediumTestContext { super(LibraryService, { database, real: [AssetRepository, AssetJobRepository, CryptoRepository, LibraryRepository, StorageRepository], - mock: [CronRepository, DatabaseRepository, EventRepository, JobRepository, LoggingRepository], + mock: [ + CronRepository, + DatabaseRepository, + EventRepository, + JobRepository, + LoggingRepository, + WebsocketRepository, + ], }); const jobs = this.getMock(JobRepository); @@ -57,6 +65,7 @@ class LibraryTestContext extends MediumTestContext { jobs.queueAll.mockResolvedValue(); this.getMock(EventRepository).emit.mockResolvedValue(); + this.getMock(WebsocketRepository).serverSend.mockReturnValue(); } async createLibrary(options: { importPaths?: string[]; exclusionPatterns?: string[] } = {}) { diff --git a/web/package.json b/web/package.json index a160ec763..f5af0ea6a 100644 --- a/web/package.json +++ b/web/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@formatjs/icu-messageformat-parser": "^3.0.0", + "@futo-org/backups-orchestrator-ui": "1.11.5", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "workspace:*", "@immich/ui": "^0.91.0", diff --git a/web/src/lib/components/backups/HideBackupsReminder.svelte b/web/src/lib/components/backups/HideBackupsReminder.svelte new file mode 100644 index 000000000..72f3c8087 --- /dev/null +++ b/web/src/lib/components/backups/HideBackupsReminder.svelte @@ -0,0 +1,9 @@ + + + + + diff --git a/web/src/lib/components/layouts/AdminPageLayout.svelte b/web/src/lib/components/layouts/AdminPageLayout.svelte index 03fa0d5fc..013f017c2 100644 --- a/web/src/lib/components/layouts/AdminPageLayout.svelte +++ b/web/src/lib/components/layouts/AdminPageLayout.svelte @@ -2,9 +2,11 @@ import BreadcrumbActionPage from '#lib/components/BreadcrumbActionPage.svelte'; import NavigationBar from '#lib/components/shared-components/navigation-bar/NavigationBar.svelte'; import BottomInfo from '#lib/components/shared-components/side-bar/BottomInfo.svelte'; + import { featureFlagsManager } from '#lib/managers/feature-flags-manager.svelte.ts'; import { Route } from '#lib/route.ts'; import { sidebarStore } from '#lib/stores/sidebar.svelte.ts'; import type { HeaderButtonActionItem } from '#lib/types.ts'; + import { ImmichBackupsAdminNavButton } from '@futo-org/backups-orchestrator-ui'; import { AppShell, AppShellHeader, AppShellSidebar, MenuItemType, NavbarItem, type BreadcrumbItem } from '@immich/ui'; import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiTrayFull, mdiWrench } from '@mdi/js'; import type { Snippet } from 'svelte'; @@ -28,6 +30,10 @@ class="flex h-full flex-col justify-between gap-2 border-none shadow-none" >
+ {#if featureFlagsManager.value.backups} + + {/if} + diff --git a/web/src/lib/components/maintenance/MaintenanceBackupCard.svelte b/web/src/lib/components/maintenance/MaintenanceBackupCard.svelte new file mode 100644 index 000000000..dce003242 --- /dev/null +++ b/web/src/lib/components/maintenance/MaintenanceBackupCard.svelte @@ -0,0 +1,114 @@ + + + + + +
+ + {#if status === BackupFileStatus.OK} + + {:else if status === BackupFileStatus.DifferentVersion} + + {:else} + + {/if} + + {#if dateDisplay} + {dateDisplay} + {:else} + {$t('unknown_date')} + {/if} + {#if relativeTime} +
+
+ {relativeTime} +
+ {/if} +
+ + {#if actions} + + {@render actions()} + + {/if} +
+ + + + {filename} + + + {#if status === BackupFileStatus.UnknownVersion} + + {$t('admin.maintenance_restore_backup_unknown_version')} + + {:else if status === BackupFileStatus.DifferentVersion} + + {$t('admin.maintenance_restore_backup_different_version')} + + {/if} + + +
+ {$t('version')}: + {version ? `v${version}` : $t('unknown')} +
+
+ {$t('size')}: + {filesizeText[0]} {filesizeText[1]} +
+
+
+
+
diff --git a/web/src/lib/components/maintenance/MaintenanceBackupEntry.spec.ts b/web/src/lib/components/maintenance/MaintenanceBackupEntry.spec.ts index 7ded3c9f0..7318184f3 100644 --- a/web/src/lib/components/maintenance/MaintenanceBackupEntry.spec.ts +++ b/web/src/lib/components/maintenance/MaintenanceBackupEntry.spec.ts @@ -5,13 +5,19 @@ import { DateTime } from 'luxon'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import MaintenanceBackupEntry from './MaintenanceBackupEntry.svelte'; -vi.mock('#lib/services/database-backups.service.ts', () => ({ - getDatabaseBackupActions: () => ({ - Download: { type: 'command', title: 'Download', onAction: vi.fn() }, - Delete: { type: 'command', title: 'Delete', onAction: vi.fn() }, - }), - handleRestoreDatabaseBackup: vi.fn(), -})); +vi.mock('#lib/services/database-backups.service.ts', async (importOriginal) => { + const original = await importOriginal(); + + return { + ...original, + getDatabaseBackupActions: () => ({ + Download: { type: 'command', title: 'Download', onAction: vi.fn() }, + Delete: { type: 'command', title: 'Delete', onAction: vi.fn() }, + }), + handleRestoreDatabaseBackup: vi.fn(), + }; +}); + describe('MaintenanceBackupEntry', () => { beforeEach(() => { diff --git a/web/src/lib/components/maintenance/MaintenanceBackupEntry.svelte b/web/src/lib/components/maintenance/MaintenanceBackupEntry.svelte index 71e148214..6e7330223 100644 --- a/web/src/lib/components/maintenance/MaintenanceBackupEntry.svelte +++ b/web/src/lib/components/maintenance/MaintenanceBackupEntry.svelte @@ -1,12 +1,8 @@ -
+
+ + {#if authManager.user.isAdmin && featureFlagsManager.value.backups && !$hideBackupsReminder} + + {/if}
diff --git a/web/src/lib/components/shared-components/side-bar/StorageSpace.svelte b/web/src/lib/components/shared-components/side-bar/StorageSpace.svelte index 11af323d2..fdff7f8a6 100644 --- a/web/src/lib/components/shared-components/side-bar/StorageSpace.svelte +++ b/web/src/lib/components/shared-components/side-bar/StorageSpace.svelte @@ -25,7 +25,7 @@
this.setLanguage(lang)); + lang.subscribe((lang) => void this.setLanguage(lang)); } - setLanguage(code: string) { + async setLanguage(code: string) { + await setYuccaLanguage(convertBCP47(code)); + const item = langs.find((item) => item.code === code); if (!item) { return; diff --git a/web/src/lib/route.ts b/web/src/lib/route.ts index f6c64878a..a8e1e065a 100644 --- a/web/src/lib/route.ts +++ b/web/src/lib/route.ts @@ -142,6 +142,12 @@ export const Route = { workflows: () => '/workflows', viewWorkflow: ({ id }: { id: string }) => `/workflows/${id}`, + // backups + backups: () => '/admin/backups', + backupSettings: () => '/admin/backups/settings', + backupAttempts: () => '/admin/backups/attempts', + backupSnapshots: () => '/admin/backups/snapshots', + // queues queues: () => '/admin/queues', viewQueue: ({ name }: { name: QueueName }) => `/admin/queues/${asQueueSlug(name)}`, diff --git a/web/src/lib/services/database-backups.service.ts b/web/src/lib/services/database-backups.service.ts index ca5c1372d..79454f7ac 100644 --- a/web/src/lib/services/database-backups.service.ts +++ b/web/src/lib/services/database-backups.service.ts @@ -6,14 +6,41 @@ import { getFormatter } from '#lib/utils/i18n.ts'; import { deleteDatabaseBackup, getBaseUrl, + listDatabaseBackups, MaintenanceAction, setMaintenanceMode, + type DatabaseBackupDto, type DatabaseBackupUploadDto, } from '@immich/sdk'; import { modalManager, type ActionItem } from '@immich/ui'; import { mdiDownload, mdiTrashCanOutline } from '@mdi/js'; +import { DateTime } from 'luxon'; import type { MessageFormatter } from 'svelte-i18n'; +export const loadDatabaseBackups = async (): Promise => { + const { backups } = await listDatabaseBackups(); + return backups; +}; + +export const getDatabaseBackupDateTime = (filename: string, zone?: string): DateTime | undefined => { + const dateMatch = filename.match(/\d+T\d+/); + if (!dateMatch) { + return; + } + + return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone }); +}; + +const getBackupTimestamp = (backup: DatabaseBackupDto): number => + getDatabaseBackupDateTime(backup.filename, backup.timezone)?.toMillis() ?? 0; + +export const getLatestBackup = (backups: DatabaseBackupDto[]): DatabaseBackupDto | undefined => { + if (backups.length === 0) { + return undefined; + } + return [...backups].sort((a, b) => getBackupTimestamp(b) - getBackupTimestamp(a))[0]; +}; + export const getDatabaseBackupActions = ($t: MessageFormatter, filename: string) => { const Download: ActionItem = { title: $t('download'), diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index 42602816b..473c01d0d 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -136,3 +136,5 @@ export const autoPlayVideo = persisted('auto-play-video', true, {}); export const alwaysLoadOriginalVideo = persisted('always-load-original-video', false, {}); export const recentAlbumsDropdown = persisted('recent-albums-open', true, {}); + +export const hideBackupsReminder = persisted('hide-backups-reminder', false, {}); diff --git a/web/src/routes/+layout.ts b/web/src/routes/+layout.ts index 2438fbb6d..8f19d4a8b 100644 --- a/web/src/routes/+layout.ts +++ b/web/src/routes/+layout.ts @@ -1,9 +1,13 @@ import { languageManager } from '#lib/managers/language-manager.svelte.ts'; import { readPreloadedQuery } from '#lib/queries/client.ts'; import { getServerConfigQuery } from '#lib/queries/server-info.query.ts'; +import { lang } from '#lib/stores/preferences.store.ts'; import { init } from '#lib/utils/client.ts'; +import { convertBCP47 } from '#lib/utils/i18n.ts'; import { maintenanceCreateUrl, maintenanceReturnUrl, maintenanceShouldRedirect } from '#lib/utils/maintenance.ts'; +import { configureYucca } from '@futo-org/backups-orchestrator-ui'; import { commandPaletteManager } from '@immich/ui'; +import { get } from 'svelte/store'; import { goto } from '$app/navigation'; import type { LayoutLoad } from './$types'; @@ -11,6 +15,8 @@ export const ssr = false; export const csr = true; export const load = (async ({ fetch, url }) => { + configureYucca({ api: 'orchestrator', language: convertBCP47(get(lang)) }); + let error; try { await init(fetch); diff --git a/web/src/routes/admin/backups/+page.svelte b/web/src/routes/admin/backups/+page.svelte new file mode 100644 index 000000000..ec927227e --- /dev/null +++ b/web/src/routes/admin/backups/+page.svelte @@ -0,0 +1,57 @@ + + +{#snippet hideReminder()} + +{/snippet} + + + goto(Route.backupSettings())} + onViewAttempts={() => goto(Route.backupAttempts())} + onViewSnapshots={() => goto(Route.backupSnapshots())} + > + {#snippet layout({ actions, children })} + + {@render children()} + + {/snippet} + + diff --git a/web/src/routes/admin/backups/+page.ts b/web/src/routes/admin/backups/+page.ts new file mode 100644 index 000000000..e172cd85f --- /dev/null +++ b/web/src/routes/admin/backups/+page.ts @@ -0,0 +1,29 @@ +import { eventManager } from '#lib/managers/event-manager.svelte.ts'; +import { authenticate } from '#lib/utils/auth.ts'; +import { getFormatter } from '#lib/utils/i18n.ts'; +import { getConfig, updateConfig } from '@immich/sdk'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); + const $t = await getFormatter(); + + try { + const config = await getConfig(); + if (!config.backup.beta) { + const newConfig = await updateConfig({ + adminConfigDto: { ...config, backup: { ...config.backup, beta: true } }, + }); + + eventManager.emit('SystemConfigUpdate', newConfig); + } + } catch { + // no-op + } + + return { + meta: { + title: $t('admin.backups'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/admin/backups/attempts/+page.svelte b/web/src/routes/admin/backups/attempts/+page.svelte new file mode 100644 index 000000000..37d9fcf32 --- /dev/null +++ b/web/src/routes/admin/backups/attempts/+page.svelte @@ -0,0 +1,20 @@ + + + + + goto(Route.backups())} /> + + diff --git a/web/src/routes/admin/backups/attempts/+page.ts b/web/src/routes/admin/backups/attempts/+page.ts new file mode 100644 index 000000000..34f673ca4 --- /dev/null +++ b/web/src/routes/admin/backups/attempts/+page.ts @@ -0,0 +1,14 @@ +import { authenticate } from '#lib/utils/auth.ts'; +import { getFormatter } from '#lib/utils/i18n.ts'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); + const $t = await getFormatter(); + + return { + meta: { + title: $t('admin.backups_attempts'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/admin/backups/settings/+page.svelte b/web/src/routes/admin/backups/settings/+page.svelte new file mode 100644 index 000000000..880aa3cbd --- /dev/null +++ b/web/src/routes/admin/backups/settings/+page.svelte @@ -0,0 +1,20 @@ + + + + + goto(Route.backups())} /> + + diff --git a/web/src/routes/admin/backups/settings/+page.ts b/web/src/routes/admin/backups/settings/+page.ts new file mode 100644 index 000000000..5a92bc576 --- /dev/null +++ b/web/src/routes/admin/backups/settings/+page.ts @@ -0,0 +1,14 @@ +import { authenticate } from '#lib/utils/auth.ts'; +import { getFormatter } from '#lib/utils/i18n.ts'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); + const $t = await getFormatter(); + + return { + meta: { + title: $t('admin.backups_settings'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/admin/backups/snapshots/+page.svelte b/web/src/routes/admin/backups/snapshots/+page.svelte new file mode 100644 index 000000000..7e9caf44b --- /dev/null +++ b/web/src/routes/admin/backups/snapshots/+page.svelte @@ -0,0 +1,20 @@ + + + + + goto(Route.backups())} /> + + diff --git a/web/src/routes/admin/backups/snapshots/+page.ts b/web/src/routes/admin/backups/snapshots/+page.ts new file mode 100644 index 000000000..a46acf8ab --- /dev/null +++ b/web/src/routes/admin/backups/snapshots/+page.ts @@ -0,0 +1,14 @@ +import { authenticate } from '#lib/utils/auth.ts'; +import { getFormatter } from '#lib/utils/i18n.ts'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); + const $t = await getFormatter(); + + return { + meta: { + title: $t('admin.backups_snapshots'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/admin/system-settings/BackupSettings.svelte b/web/src/routes/admin/system-settings/BackupSettings.svelte index c2c2b5017..338ac0957 100644 --- a/web/src/routes/admin/system-settings/BackupSettings.svelte +++ b/web/src/routes/admin/system-settings/BackupSettings.svelte @@ -71,7 +71,7 @@ inputType={SettingInputFieldType.NUMBER} required={true} label={$t('admin.backup_keep_last_amount')} - disabled={disabled || !configToEdit.backup.database.enabled} + {disabled} bind:value={configToEdit.backup.database.keepLastAmount} isEdited={configToEdit.backup.database.keepLastAmount !== config.backup.database.keepLastAmount} /> diff --git a/web/src/routes/link/+page.ts b/web/src/routes/link/+page.ts index ad4878d04..aea279385 100644 --- a/web/src/routes/link/+page.ts +++ b/web/src/routes/link/+page.ts @@ -8,6 +8,7 @@ enum LinkTarget { UNSUBSCRIBE = 'unsubscribe', VIEW_ASSET = 'view_asset', ACTIVATE_LICENSE = 'activate_license', + BACKUPS = 'backups', } export const load = (({ url }) => { @@ -22,6 +23,10 @@ export const load = (({ url }) => { return redirect(307, Route.userSettings({ isOpen: OpenQueryParam.NOTIFICATIONS })); } + case LinkTarget.BACKUPS: { + return redirect(307, Route.backups()); + } + case LinkTarget.VIEW_ASSET: { const id = queryParams.get('id'); if (id) { diff --git a/web/src/routes/maintenance/+page.svelte b/web/src/routes/maintenance/+page.svelte index cf5f9cae3..3be727cc8 100644 --- a/web/src/routes/maintenance/+page.svelte +++ b/web/src/routes/maintenance/+page.svelte @@ -7,6 +7,7 @@ import { maintenanceStore } from '#lib/stores/maintenance.store.ts'; import { MaintenanceAction } from '@immich/sdk'; import { Button, Heading, Link, ProgressBar, Scrollable, Text } from '@immich/ui'; + import { YuccaContext, ViewStatusModal } from '@futo-org/backups-orchestrator-ui'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -30,6 +31,11 @@ action: MaintenanceAction.End, }); + const startRestore = () => + handleSetMaintenanceMode({ + action: MaintenanceAction.SelectDatabaseRestore, + }); + const error = $derived( $status?.error ?.split('\n') @@ -38,58 +44,79 @@ ); - -
- {#if $status?.action === MaintenanceAction.RestoreDatabase} - {$t('maintenance_action_restore')} - {#if $status.error} - -
{error}
-
- +{#snippet errorLog()} + +
{error}
+
+ +{/snippet} + + + +
+ {#if $status?.action === MaintenanceAction.RestoreDatabase} + {$t('maintenance_action_restore')} + {#if $status.error} + {@render errorLog()} + {:else} + + {#if $status.task === 'backup'} + {$t('maintenance_task_backup')} + {/if} + {#if $status.task === 'restore'} + {$t('maintenance_task_restore')} + {/if} + {#if $status.task === 'migrations'} + {$t('maintenance_task_migrations')} + {/if} + {#if $status.task === 'rollback'} + {$t('maintenance_task_rollback')} + {/if} + {/if} + {:else if $status?.action === MaintenanceAction.Rollback && $status.futoBackupsJobId} + { + if ($status?.error) { + $status.futoBackupsJobId = ''; + } + }} + /> + {:else if $status?.action === MaintenanceAction.Rollback && $status.error} + {$t('maintenance_action_rollback')} + {@render errorLog()} + {:else if $status?.action === MaintenanceAction.SelectDatabaseRestore && $auth} + {:else} - - {#if $status.task === 'backup'} - {$t('maintenance_task_backup')} - {/if} - {#if $status.task === 'restore'} - {$t('maintenance_task_restore')} - {/if} - {#if $status.task === 'migrations'} - {$t('maintenance_task_migrations')} - {/if} - {#if $status.task === 'rollback'} - {$t('maintenance_task_rollback')} - {/if} - {/if} - {:else if $status?.action === MaintenanceAction.SelectDatabaseRestore && $auth} - - {:else} - {$t('maintenance_title')} -

- - {#snippet children({ tag, message })} - {#if tag === 'link'} - - {message} - - {/if} - {/snippet} - -

- {#if $auth} + {$t('maintenance_title')}

- {$t('maintenance_logged_in_as', { - values: { - user: $auth.username, - }, - })} + + {#snippet children({ tag, message })} + {#if tag === 'link'} + + {message} + + {/if} + {/snippet} +

- + {#if $auth} +

+ {$t('maintenance_logged_in_as', { + values: { + user: $auth.username, + }, + })} +

+
+ + +
+ {/if} {/if} - {/if} -
-
+
+
+ diff --git a/web/src/routes/maintenance/MaintenanceRestoreFlow.svelte b/web/src/routes/maintenance/MaintenanceRestoreFlow.svelte index 97119ea2f..76b2b9b70 100644 --- a/web/src/routes/maintenance/MaintenanceRestoreFlow.svelte +++ b/web/src/routes/maintenance/MaintenanceRestoreFlow.svelte @@ -1,6 +1,9 @@ -{#if stage === 0} - stage++} {end} /> -{:else} - stage--} {end} {expectedVersion} /> +{#if stage === 'overview'} + (stage = 'futo-backups')} + flowToDatabase={() => (stage = 'detect-install')} + {end} + /> +{:else if stage === 'futo-backups'} + (stage = 'overview')} onFinish={() => (stage = 'auto-select-backup')} /> +{:else if stage === 'detect-install'} + (stage = 'select-backup')} previous={() => (stage = 'overview')} /> +{:else if stage === 'select-backup'} + (stage = 'detect-install')} {end} {expectedVersion} /> +{:else if stage === 'auto-select-backup'} + (stage = 'select-backup')} {end} {expectedVersion} /> {/if} diff --git a/web/src/routes/maintenance/RestoreFlowAutoSelectBackup.svelte b/web/src/routes/maintenance/RestoreFlowAutoSelectBackup.svelte new file mode 100644 index 000000000..e94c5d9f9 --- /dev/null +++ b/web/src/routes/maintenance/RestoreFlowAutoSelectBackup.svelte @@ -0,0 +1,68 @@ + + +{$t('maintenance_restore_from_backup')} + +{#if backups === undefined} + + + {$t('maintenance_restore_loading_backups')} + +{:else if latest === undefined} + + + + + +{:else} + {$t('maintenance_restore_latest_backup_description')} + + + + + + + +{/if} diff --git a/web/src/routes/maintenance/RestoreFlowDetectInstall.svelte b/web/src/routes/maintenance/RestoreFlowDetectInstall.svelte index c0461394f..08009e6a2 100644 --- a/web/src/routes/maintenance/RestoreFlowDetectInstall.svelte +++ b/web/src/routes/maintenance/RestoreFlowDetectInstall.svelte @@ -7,10 +7,10 @@ type Props = { next: () => void; - end: () => void; + previous: () => void; }; - const { next, end }: Props = $props(); + const { next, previous }: Props = $props(); let detectedInstall: MaintenanceDetectInstallResponseDto | undefined = $state(); @@ -92,6 +92,6 @@
{$t('maintenance_restore_library_confirm')} - + diff --git a/web/src/routes/maintenance/RestoreFlowIntro.svelte b/web/src/routes/maintenance/RestoreFlowIntro.svelte new file mode 100644 index 000000000..83a3a1db2 --- /dev/null +++ b/web/src/routes/maintenance/RestoreFlowIntro.svelte @@ -0,0 +1,24 @@ + + +{$t('maintenance_restore_source_title')} + + + + + diff --git a/web/vite.config.ts b/web/vite.config.ts index 825344658..f2d608ae1 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -133,5 +133,10 @@ export default defineConfig({ env: { TZ: 'UTC', }, + server: { + deps: { + inline: ['@futo-org/backups-orchestrator-ui', '@material-symbols/svg-400'], + }, + }, }, } as UserConfig);