diff --git a/apps/studio/src/hooks/use-add-site.ts b/apps/studio/src/hooks/use-add-site.ts index 5dc79b4f06..4f2e235197 100644 --- a/apps/studio/src/hooks/use-add-site.ts +++ b/apps/studio/src/hooks/use-add-site.ts @@ -7,7 +7,7 @@ import { useI18n } from '@wordpress/react-i18n'; import { useCallback, useMemo, useState } from 'react'; import { useAuth } from 'src/hooks/use-auth'; import { useContentTabs } from 'src/hooks/use-content-tabs'; -import { useImportExport } from 'src/hooks/use-import-export'; +import { useImportExport, type ImportSource } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { useAppDispatch } from 'src/stores'; @@ -53,7 +53,7 @@ export function useAddSite() { const { client } = useAuth(); const dispatch = useAppDispatch(); const { setSelectedTab } = useContentTabs(); - const [ fileForImport, setFileForImport ] = useState< File | null >( null ); + const [ fileForImport, setFileForImport ] = useState< ImportSource | null >( null ); const [ selectedBlueprint, setSelectedBlueprint ] = useState< Blueprint | undefined >(); const [ selectedRemoteSite, setSelectedRemoteSite ] = useState< SyncSite | undefined >(); const [ blueprintPreferredVersions, setBlueprintPreferredVersions ] = useState< diff --git a/apps/studio/src/hooks/use-import-export.tsx b/apps/studio/src/hooks/use-import-export.tsx index c7f0c3dc45..eeab1821c4 100644 --- a/apps/studio/src/hooks/use-import-export.tsx +++ b/apps/studio/src/hooks/use-import-export.tsx @@ -18,6 +18,23 @@ export type ImportProgressState = { }; }; +/** + * A reference to a backup file already present on disk (e.g. downloaded by the + * main process for a deeplink import). Used in place of a browser `File` when + * the renderer never received the file through a user file picker / drop. + */ +export type DeeplinkBackupFile = { + path: string; + name: string; + size: number; +}; + +export type ImportSource = File | DeeplinkBackupFile; + +export function isDeeplinkBackupFile( source: ImportSource ): source is DeeplinkBackupFile { + return ! ( source instanceof File ); +} + type ExportProgressState = { [ siteId: string ]: { statusMessage: string; @@ -30,7 +47,7 @@ type ExportProgressState = { interface ImportExportContext { importState: ImportProgressState; importFile: ( - file: File, + file: ImportSource, selectedSite: SiteDetails, options?: { showImportNotification?: boolean; isNewSite?: boolean } ) => Promise< void >; @@ -68,7 +85,7 @@ export const ImportExportProvider = ( { children }: { children: React.ReactNode const importFile = useCallback( async ( - file: File, + file: ImportSource, selectedSite: SiteDetails, { showImportNotification = true, @@ -88,12 +105,14 @@ export const ImportExportProvider = ( { children }: { children: React.ReactNode }, } ) ); - const filePath = getIpcApi().getPathForFile( file ); + const isDeeplinkBackup = isDeeplinkBackupFile( file ); + const filePath = isDeeplinkBackup ? file.path : getIpcApi().getPathForFile( file ); try { await getIpcApi().importSite( selectedSite.id, filePath, { alwaysStartServer: true, showNotification: showImportNotification, + removeBackupOnComplete: isDeeplinkBackup, } ); } catch ( error ) { // The main process handles displaying the error modal, so we don't need any explicit error diff --git a/apps/studio/src/ipc-utils.ts b/apps/studio/src/ipc-utils.ts index 9d69f6962b..48f43a0a4d 100644 --- a/apps/studio/src/ipc-utils.ts +++ b/apps/studio/src/ipc-utils.ts @@ -28,6 +28,13 @@ export interface IpcEvents { blueprintPath: string; }, ]; + 'import-backup-from-deeplink': [ + { + backupPath: string; + fileName: string; + fileSize: number; + }, + ]; 'auth-updated': [ { token: StoredAuthToken } | { token: null } | { error: unknown } ]; 'on-export': [ ExportEventTuple, string ]; 'on-import': [ ImportEventTuple, string ]; diff --git a/apps/studio/src/lib/deeplink/deeplink-handler.ts b/apps/studio/src/lib/deeplink/deeplink-handler.ts index 670e57a911..ba8f2d89fe 100644 --- a/apps/studio/src/lib/deeplink/deeplink-handler.ts +++ b/apps/studio/src/lib/deeplink/deeplink-handler.ts @@ -1,5 +1,6 @@ import { handleAddSiteWithBlueprint } from 'src/lib/deeplink/handlers/add-site-with-blueprint'; import { handleAuthDeeplink } from 'src/lib/deeplink/handlers/auth'; +import { handleImportBackupDeeplink } from 'src/lib/deeplink/handlers/import-backup'; import { handleSyncConnectSiteDeeplink } from 'src/lib/deeplink/handlers/sync-connect-site'; /** @@ -8,6 +9,7 @@ import { handleSyncConnectSiteDeeplink } from 'src/lib/deeplink/handlers/sync-co * - wp-studio://auth - OAuth authentication callback * - wp-studio://sync-connect-site - Sync site connection from WordPress.com * - wp-studio://add-site?blueprint_url= - Add site with blueprint from URL + * - wp-studio://import-backup?url= - Add site by importing a backup file */ export async function handleDeeplink( url: string ): Promise< void > { const urlObject = new URL( url ); @@ -23,6 +25,9 @@ export async function handleDeeplink( url: string ): Promise< void > { case 'add-site': await handleAddSiteWithBlueprint( urlObject ); break; + case 'import-backup': + await handleImportBackupDeeplink( urlObject ); + break; default: console.warn( `Unknown deeplink host: ${ host }` ); } diff --git a/apps/studio/src/lib/deeplink/handlers/import-backup.ts b/apps/studio/src/lib/deeplink/handlers/import-backup.ts new file mode 100644 index 0000000000..755ce1e82c --- /dev/null +++ b/apps/studio/src/lib/deeplink/handlers/import-backup.ts @@ -0,0 +1,145 @@ +import { app, dialog, shell } from 'electron'; +import nodePath from 'path'; +import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants'; +import { __ } from '@wordpress/i18n'; +import fs from 'fs-extra'; +import { sendIpcEventToRenderer } from 'src/ipc-utils'; +import { download } from 'src/lib/download'; +import { getLogsFilePath } from 'src/logging'; +import { getMainWindow } from 'src/main-window'; + +function getImportBackupDeeplinkErrorMessage( error: unknown ): string { + const errorMessage = ( error instanceof Error ? error.message : '' ).toLowerCase(); + + const networkErrors = [ 'enotfound', 'econnrefused', 'etimedout', 'network' ]; + if ( networkErrors.some( ( err ) => errorMessage.includes( err ) ) ) { + return __( + 'Could not connect to the server. Please check your internet connection and try again.' + ); + } + + return __( 'Please check the link and try again.' ); +} + +function hasAcceptedExtension( fileName: string ): boolean { + const lower = fileName.toLowerCase(); + return ACCEPTED_IMPORT_FILE_TYPES.some( ( ext ) => lower.endsWith( ext ) ); +} + +function deriveFileNameFromUrl( rawUrl: string ): string { + try { + const pathname = new URL( rawUrl ).pathname; + const candidate = pathname.split( '/' ).filter( Boolean ).pop(); + // If the URL exposes a filename with an extension, return it as-is so that + // extension validation can reject unsupported types. Only fall back to a + // default when the URL has no recognizable filename at all. + if ( candidate && /\.[a-z0-9]+/i.test( candidate ) ) { + return decodeURIComponent( candidate ); + } + } catch { + // Fall through to default. + } + return 'backup.zip'; +} + +/** + * Handles the import-backup deeplink callback. + * This function is called when a user clicks a deeplink like: + * - wp-studio://import-backup?url= + * - wp-studio://import-backup?url=&name= + * + * The backup file is downloaded to a temporary location, validated against the + * accepted import extensions, and then the Add Site modal is opened with the + * backup pre-selected on the "Import from a backup" step. + */ +export async function handleImportBackupDeeplink( urlObject: URL ): Promise< void > { + const { searchParams } = urlObject; + const backupUrl = searchParams.get( 'url' ); + const requestedName = searchParams.get( 'name' ); + + const mainWindow = await getMainWindow(); + if ( mainWindow.isMinimized() ) { + mainWindow.restore(); + } + mainWindow.focus(); + + if ( ! backupUrl ) { + console.error( 'import-backup deeplink missing url parameter' ); + return; + } + + const tmpDir = nodePath.join( app.getPath( 'temp' ), 'wp-studio-imports' ); + await fs.mkdir( tmpDir, { recursive: true } ); + + let backupPath: string | undefined; + + try { + // `URLSearchParams.get()` already returns the percent-decoded value, so + // we parse `backupUrl` directly. Decoding it again would let an attacker + // double-encode characters like `?` to shift the URL's query boundary + // past any future allowlist check (the Apache CVE-2021-41773 shape). + const parsedUrl = new URL( backupUrl ); + + // Refuse non-HTTPS schemes outright. The deeplink can be triggered from + // any web page / email / IM, so the inbound URL is attacker-controlled. + // Allowing `http:` lets a network MITM swap the backup payload, and + // backups contain PHP plugins/themes that Studio will execute via + // WordPress Playground after import. + if ( parsedUrl.protocol !== 'https:' ) { + throw new Error( + `Unsupported URL protocol "${ parsedUrl.protocol }". Only https: URLs are accepted for backup imports.` + ); + } + + const fileName = requestedName?.trim() || deriveFileNameFromUrl( backupUrl ); + + if ( ! hasAcceptedExtension( fileName ) ) { + throw new Error( + `Unsupported backup file extension for "${ fileName }". Supported: ${ ACCEPTED_IMPORT_FILE_TYPES.join( + ', ' + ) }` + ); + } + + const timestamp = Date.now(); + const safeBaseName = nodePath.basename( fileName ).replace( /[^\w.-]+/g, '_' ); + backupPath = nodePath.join( tmpDir, `import-${ timestamp }-${ safeBaseName }` ); + + await download( backupUrl, backupPath, false, 'backup' ); + + const stats = await fs.stat( backupPath ); + if ( ! stats.isFile() || stats.size === 0 ) { + throw new Error( 'Downloaded backup file is empty or not a file' ); + } + + await sendIpcEventToRenderer( 'import-backup-from-deeplink', { + backupPath, + fileName: safeBaseName, + fileSize: stats.size, + } ); + } catch ( error ) { + console.error( 'Failed to process backup from deeplink:', error ); + + if ( backupPath ) { + await fs.remove( backupPath ).catch( () => { + // Ignore cleanup errors + } ); + } + + const response = await dialog.showMessageBox( mainWindow, { + type: 'error', + message: __( 'Failed to import backup' ), + detail: getImportBackupDeeplinkErrorMessage( error ), + buttons: [ __( 'Open Studio Logs' ), __( 'OK' ) ], + defaultId: 1, + } ); + + if ( response.response === 0 ) { + const logFilePath = getLogsFilePath(); + const err = await shell.openPath( logFilePath ); + if ( err ) { + console.error( `Error opening logs file: ${ logFilePath } ${ err }` ); + } + } + } +} diff --git a/apps/studio/src/lib/deeplink/tests/import-backup.test.ts b/apps/studio/src/lib/deeplink/tests/import-backup.test.ts new file mode 100644 index 0000000000..008d7663f3 --- /dev/null +++ b/apps/studio/src/lib/deeplink/tests/import-backup.test.ts @@ -0,0 +1,269 @@ +import { app, dialog, shell, BrowserWindow } from 'electron'; +import fs from 'fs-extra'; +import { vi, beforeAll, afterAll } from 'vitest'; +import { sendIpcEventToRenderer } from 'src/ipc-utils'; +import { handleImportBackupDeeplink } from 'src/lib/deeplink/handlers/import-backup'; +import { download } from 'src/lib/download'; +import { createMock } from 'src/lib/test-utils'; +import { getMainWindow } from 'src/main-window'; + +// Factory mock so fs.stat is explicitly available (the default automock skips it). +vi.mock( 'fs-extra', () => { + const mod = { + mkdir: vi.fn(), + stat: vi.fn(), + remove: vi.fn(), + readJson: vi.fn(), + writeJson: vi.fn(), + }; + return { ...mod, default: mod }; +} ); +vi.mock( 'src/ipc-utils' ); +vi.mock( 'src/lib/download' ); +vi.mock( 'src/main-window' ); +vi.mock( 'src/logging', () => ( { + getLogsFilePath: vi.fn().mockReturnValue( '/mock/path/to/logs.log' ), +} ) ); + +// Silence console.error output +beforeAll( () => { + vi.spyOn( console, 'error' ).mockImplementation( () => {} ); +} ); + +afterAll( () => { + vi.spyOn( console, 'error' ).mockRestore(); +} ); + +describe( 'handleImportBackupDeeplink', () => { + const mockMainWindow = createMock< BrowserWindow >( { + isMinimized: vi.fn().mockReturnValue( false ), + restore: vi.fn(), + focus: vi.fn(), + } ); + + const expectErrorDialog = ( detail: string ) => { + expect( dialog.showMessageBox ).toHaveBeenCalledWith( mockMainWindow, { + type: 'error', + message: 'Failed to import backup', + detail, + buttons: [ 'Open Studio Logs', 'OK' ], + defaultId: 1, + } ); + }; + + const createBackupDeeplink = ( backupUrl: string, name?: string ): URL => { + const params = new URLSearchParams(); + params.set( 'url', backupUrl ); + if ( name ) { + params.set( 'name', name ); + } + return new URL( `wp-studio://import-backup?${ params.toString() }` ); + }; + + beforeEach( () => { + vi.clearAllMocks(); + vi.mocked( mockMainWindow.isMinimized ).mockReturnValue( false ); + vi.mocked( app.getPath ).mockReturnValue( '/tmp' ); + vi.mocked( fs.mkdir ).mockImplementation( async () => {} ); + vi.mocked( getMainWindow ).mockResolvedValue( mockMainWindow ); + vi.mocked( dialog.showMessageBox ).mockResolvedValue( { + response: 1, + checkboxChecked: false, + } ); + } ); + + it( 'downloads the backup and sends the import IPC event', async () => { + const backupUrl = 'https://example.com/site.zip'; + const url = createBackupDeeplink( backupUrl ); + + vi.mocked( download ).mockResolvedValue( undefined ); + vi.mocked( fs.stat ).mockResolvedValue( { + isFile: () => true, + size: 1234, + } as unknown as Awaited< ReturnType< typeof fs.stat > > ); + + await handleImportBackupDeeplink( url ); + + expect( download ).toHaveBeenCalledWith( + backupUrl, + expect.stringContaining( 'import-' ), + false, + 'backup' + ); + expect( sendIpcEventToRenderer ).toHaveBeenCalledWith( 'import-backup-from-deeplink', { + backupPath: expect.stringContaining( 'import-' ), + fileName: 'site.zip', + fileSize: 1234, + } ); + expect( mockMainWindow.focus ).toHaveBeenCalled(); + } ); + + it( 'uses an explicit ?name= when supplied', async () => { + const backupUrl = 'https://example.com/download?token=abc'; + const url = createBackupDeeplink( backupUrl, 'my-export.tar.gz' ); + + vi.mocked( download ).mockResolvedValue( undefined ); + vi.mocked( fs.stat ).mockResolvedValue( { + isFile: () => true, + size: 4096, + } as unknown as Awaited< ReturnType< typeof fs.stat > > ); + + await handleImportBackupDeeplink( url ); + + expect( sendIpcEventToRenderer ).toHaveBeenCalledWith( 'import-backup-from-deeplink', { + backupPath: expect.stringContaining( 'my-export.tar.gz' ), + fileName: 'my-export.tar.gz', + fileSize: 4096, + } ); + } ); + + it( 'rejects non-https URL schemes', async () => { + // http:, file:, ftp: etc. must all be rejected — a network attacker can + // swap the payload of cleartext http: downloads, and backups carry + // executable PHP code that Studio runs via WordPress Playground. + const httpUrl = createBackupDeeplink( 'http://example.com/site.zip' ); + vi.mocked( fs.remove ).mockImplementation( async () => {} ); + + await handleImportBackupDeeplink( httpUrl ); + + expect( download ).not.toHaveBeenCalled(); + expect( sendIpcEventToRenderer ).not.toHaveBeenCalled(); + expectErrorDialog( 'Please check the link and try again.' ); + + vi.clearAllMocks(); + vi.mocked( dialog.showMessageBox ).mockResolvedValue( { + response: 1, + checkboxChecked: false, + } ); + + const fileUrl = createBackupDeeplink( 'file:///etc/passwd.zip' ); + await handleImportBackupDeeplink( fileUrl ); + + expect( download ).not.toHaveBeenCalled(); + expect( sendIpcEventToRenderer ).not.toHaveBeenCalled(); + } ); + + it( 'rejects unsupported file extensions', async () => { + const url = createBackupDeeplink( 'https://example.com/site.txt' ); + vi.mocked( fs.remove ).mockImplementation( async () => {} ); + + await handleImportBackupDeeplink( url ); + + expect( download ).not.toHaveBeenCalled(); + expect( sendIpcEventToRenderer ).not.toHaveBeenCalled(); + expectErrorDialog( 'Please check the link and try again.' ); + } ); + + it( 'does nothing if the url parameter is missing', async () => { + const url = new URL( 'wp-studio://import-backup' ); + + await handleImportBackupDeeplink( url ); + + expect( download ).not.toHaveBeenCalled(); + expect( sendIpcEventToRenderer ).not.toHaveBeenCalled(); + } ); + + it( 'shows an error dialog when the url is malformed', async () => { + const url = createBackupDeeplink( 'not-a-valid-url' ); + vi.mocked( fs.remove ).mockImplementation( async () => {} ); + + await handleImportBackupDeeplink( url ); + + expect( download ).not.toHaveBeenCalled(); + expect( sendIpcEventToRenderer ).not.toHaveBeenCalled(); + expectErrorDialog( 'Please check the link and try again.' ); + } ); + + it( 'cleans up the temp file and shows an error if download fails', async () => { + const url = createBackupDeeplink( 'https://example.com/site.zip' ); + + vi.mocked( download ).mockRejectedValue( new Error( 'Download failed' ) ); + vi.mocked( fs.remove ).mockImplementation( async () => {} ); + + await handleImportBackupDeeplink( url ); + + expect( download ).toHaveBeenCalled(); + expect( sendIpcEventToRenderer ).not.toHaveBeenCalled(); + expect( fs.remove ).toHaveBeenCalledWith( expect.stringContaining( 'import-' ) ); + expectErrorDialog( 'Please check the link and try again.' ); + } ); + + it( 'rejects an empty file', async () => { + const url = createBackupDeeplink( 'https://example.com/site.zip' ); + + vi.mocked( download ).mockResolvedValue( undefined ); + vi.mocked( fs.stat ).mockResolvedValue( { + isFile: () => true, + size: 0, + } as unknown as Awaited< ReturnType< typeof fs.stat > > ); + vi.mocked( fs.remove ).mockImplementation( async () => {} ); + + await handleImportBackupDeeplink( url ); + + expect( sendIpcEventToRenderer ).not.toHaveBeenCalled(); + expect( fs.remove ).toHaveBeenCalled(); + expectErrorDialog( 'Please check the link and try again.' ); + } ); + + it( 'shows a network-specific error message for connectivity failures', async () => { + const url = createBackupDeeplink( 'https://example.com/site.zip' ); + + vi.mocked( download ).mockRejectedValue( new Error( 'getaddrinfo ENOTFOUND example.com' ) ); + vi.mocked( fs.remove ).mockImplementation( async () => {} ); + + await handleImportBackupDeeplink( url ); + + expectErrorDialog( + 'Could not connect to the server. Please check your internet connection and try again.' + ); + } ); + + it( 'restores and focuses the window when minimized', async () => { + const url = createBackupDeeplink( 'https://example.com/site.zip' ); + + vi.mocked( mockMainWindow.isMinimized ).mockReturnValue( true ); + vi.mocked( download ).mockResolvedValue( undefined ); + vi.mocked( fs.stat ).mockResolvedValue( { + isFile: () => true, + size: 100, + } as unknown as Awaited< ReturnType< typeof fs.stat > > ); + + await handleImportBackupDeeplink( url ); + + expect( mockMainWindow.restore ).toHaveBeenCalled(); + expect( mockMainWindow.focus ).toHaveBeenCalled(); + } ); + + it( 'opens the logs file when the user clicks Open Studio Logs', async () => { + const url = createBackupDeeplink( 'https://example.com/site.zip' ); + + vi.mocked( download ).mockRejectedValue( new Error( 'boom' ) ); + vi.mocked( fs.remove ).mockImplementation( async () => {} ); + vi.mocked( dialog.showMessageBox ).mockResolvedValue( { + response: 0, + checkboxChecked: false, + } ); + + await handleImportBackupDeeplink( url ); + + expect( shell.openPath ).toHaveBeenCalledWith( '/mock/path/to/logs.log' ); + } ); + + it( 'sanitizes weird characters in the derived file name', async () => { + const url = createBackupDeeplink( 'https://example.com/some site!@#.zip' ); + + vi.mocked( download ).mockResolvedValue( undefined ); + vi.mocked( fs.stat ).mockResolvedValue( { + isFile: () => true, + size: 10, + } as unknown as Awaited< ReturnType< typeof fs.stat > > ); + + await handleImportBackupDeeplink( url ); + + const call = vi.mocked( sendIpcEventToRenderer ).mock.calls[ 0 ]; + expect( call[ 0 ] ).toBe( 'import-backup-from-deeplink' ); + const payload = call[ 1 ] as { fileName: string }; + expect( payload.fileName ).not.toMatch( /[!@#\s]/ ); + expect( payload.fileName ).toMatch( /\.zip$/ ); + } ); +} ); diff --git a/apps/studio/src/modules/add-site/hooks/use-import-backup-deeplink.ts b/apps/studio/src/modules/add-site/hooks/use-import-backup-deeplink.ts new file mode 100644 index 0000000000..e3386646cd --- /dev/null +++ b/apps/studio/src/modules/add-site/hooks/use-import-backup-deeplink.ts @@ -0,0 +1,51 @@ +import { useCallback } from 'react'; +import { type ImportSource } from 'src/hooks/use-import-export'; +import { useIpcListener } from 'src/hooks/use-ipc-listener'; + +interface UseImportBackupDeeplinkOptions { + isAnySiteProcessing: boolean; + setFileForImport: ( file: ImportSource | null ) => void; + setIsDeeplinkFlow: ( isDeeplink: boolean ) => void; + onModalOpen?: () => void; +} + +/** + * Listens for the `import-backup-from-deeplink` IPC event emitted by the + * `wp-studio://import-backup?url=…` deeplink handler. When triggered, it loads + * the already-downloaded backup file reference into the Add Site form and + * routes the modal to the backup create step. + */ +export function useImportBackupDeeplink( options: UseImportBackupDeeplinkOptions ): void { + const { isAnySiteProcessing, setFileForImport, setIsDeeplinkFlow, onModalOpen } = options; + + useIpcListener( + 'import-backup-from-deeplink', + useCallback( + ( + _event: unknown, + { + backupPath, + fileName, + fileSize, + }: { + backupPath: string; + fileName: string; + fileSize: number; + } + ) => { + if ( isAnySiteProcessing ) { + return; + } + + setFileForImport( { + path: backupPath, + name: fileName, + size: fileSize, + } ); + setIsDeeplinkFlow( true ); + onModalOpen?.(); + }, + [ isAnySiteProcessing, setFileForImport, setIsDeeplinkFlow, onModalOpen ] + ) + ); +} diff --git a/apps/studio/src/modules/add-site/index.tsx b/apps/studio/src/modules/add-site/index.tsx index 4606a2b9dd..5d2f4b129c 100644 --- a/apps/studio/src/modules/add-site/index.tsx +++ b/apps/studio/src/modules/add-site/index.tsx @@ -20,11 +20,13 @@ import { DotGrid } from 'src/components/dot-grid'; import { FullscreenModal } from 'src/components/fullscreen-modal'; import { useAddSite, CreateSiteFormValues } from 'src/hooks/use-add-site'; import { useFeatureFlags } from 'src/hooks/use-feature-flags'; +import { type ImportSource } from 'src/hooks/use-import-export'; import { useIpcListener } from 'src/hooks/use-ipc-listener'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { useBlueprintDeeplink } from 'src/modules/add-site/hooks/use-blueprint-deeplink'; +import { useImportBackupDeeplink } from 'src/modules/add-site/hooks/use-import-backup-deeplink'; import { useRootSelector, useAppDispatch, useI18nLocale } from 'src/stores'; import { formatRtkError } from 'src/stores/format-rtk-error'; import { openAddSiteModal, closeAddSiteModal, selectIsAddSiteModalOpen } from 'src/stores/ui-slice'; @@ -79,7 +81,8 @@ interface NavigationContentProps { onFormSubmit: ( values: CreateSiteFormValues ) => void; onValidityChange: ( isValid: boolean ) => void; canSubmit: boolean; - setFileForImport: ( file: File | null ) => void; + fileForImport: ImportSource | null; + setFileForImport: ( file: ImportSource | null ) => void; setSelectedBlueprint: ( blueprint?: Blueprint ) => void; selectedBlueprint?: Blueprint; blueprintPreferredVersions?: BlueprintPreferredVersions; @@ -117,6 +120,7 @@ function NavigationContent( props: NavigationContentProps ) { onFormSubmit, onValidityChange, canSubmit, + fileForImport, setFileForImport, selectedBlueprint, setSelectedBlueprint, @@ -143,11 +147,17 @@ function NavigationContent( props: NavigationContentProps ) { }, [ location.path, onPathChange ] ); useEffect( () => { - if ( isDeeplinkFlow && selectedBlueprint ) { + if ( ! isDeeplinkFlow ) { + return; + } + if ( selectedBlueprint ) { goTo( '/blueprint/deeplink' ); setIsDeeplinkFlow( false ); + } else if ( fileForImport ) { + goTo( '/backup/create' ); + setIsDeeplinkFlow( false ); } - }, [ isDeeplinkFlow, goTo, setIsDeeplinkFlow, selectedBlueprint ] ); + }, [ isDeeplinkFlow, goTo, setIsDeeplinkFlow, selectedBlueprint, fileForImport ] ); const handleOptionSelect = useCallback( ( option: AddSiteFlowType ) => { @@ -455,6 +465,7 @@ export function AddSiteModalContent( { generateProposedPath, deeplinkPhpVersion, deeplinkWpVersion, + fileForImport, setFileForImport, selectedBlueprint, setSelectedBlueprint, @@ -481,7 +492,12 @@ export function AddSiteModalContent( { } ); const latestStableVersion = versions.find( ( version ) => version.value === 'latest' ); - const initialNavigatorPath = selectedBlueprint ? '/blueprint/deeplink' : '/'; + let initialNavigatorPath = '/'; + if ( selectedBlueprint ) { + initialNavigatorPath = '/blueprint/deeplink'; + } else if ( fileForImport ) { + initialNavigatorPath = '/backup/create'; + } // Initialize form with generated site name and path useEffect( () => { @@ -591,6 +607,7 @@ export function AddSiteModalContent( { onFormSubmit: handleFormSubmit, onValidityChange: setIsFormValid, canSubmit, + fileForImport, setFileForImport, selectedBlueprint, setSelectedBlueprint, @@ -659,6 +676,7 @@ export default function AddSiteModal( { className }: AddSiteModalProps ) { const { resetForm, isAnySiteProcessing, + setFileForImport, setSelectedBlueprint, setDeeplinkPhpVersion, setDeeplinkWpVersion, @@ -696,6 +714,13 @@ export default function AddSiteModal( { className }: AddSiteModalProps ) { onModalOpen: openModal, } ); + useImportBackupDeeplink( { + isAnySiteProcessing, + setFileForImport, + setIsDeeplinkFlow, + onModalOpen: openModal, + } ); + return ( <>