Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/studio/src/hooks/use-add-site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<
Expand Down
25 changes: 22 additions & 3 deletions apps/studio/src/hooks/use-import-export.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,7 +47,7 @@ type ExportProgressState = {
interface ImportExportContext {
importState: ImportProgressState;
importFile: (
file: File,
file: ImportSource,
selectedSite: SiteDetails,
options?: { showImportNotification?: boolean; isNewSite?: boolean }
) => Promise< void >;
Expand Down Expand Up @@ -68,7 +85,7 @@ export const ImportExportProvider = ( { children }: { children: React.ReactNode

const importFile = useCallback(
async (
file: File,
file: ImportSource,
selectedSite: SiteDetails,
{
showImportNotification = true,
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions apps/studio/src/ipc-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ];
Expand Down
5 changes: 5 additions & 0 deletions apps/studio/src/lib/deeplink/deeplink-handler.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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=<encoded-url> - Add site with blueprint from URL
* - wp-studio://import-backup?url=<encoded-url> - Add site by importing a backup file
*/
export async function handleDeeplink( url: string ): Promise< void > {
const urlObject = new URL( url );
Expand All @@ -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 }` );
}
Expand Down
145 changes: 145 additions & 0 deletions apps/studio/src/lib/deeplink/handlers/import-backup.ts
Original file line number Diff line number Diff line change
@@ -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=<encoded-url>
* - wp-studio://import-backup?url=<encoded-url>&name=<filename>
*
* 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 }` );
}
}
}
}
Loading