Skip to content

Commit 172e002

Browse files
authored
Blueprints: Add a deeplink to create a new site from the blueprint URL (#1989)
* Create site from deeplink using URL * Add error handling * Refactor useBlueprintDeeplink to use a dedicated handler for 'add-site-blueprint' IPC event * Optimize dependency array in NavigationContent to remove unnecessary props * Implement directory validation for blueprint file paths in readBlueprintFile function * Remove error message box display on blueprint download failure in add-site-blueprint handler * Add error message box display for blueprint download failure in add-site-blueprint handler * Fix lint issues * Refactor blueprint error handling to use local state management * Rename handler function for adding site blueprints to improve clarity and consistency * Add unit tests for handleAddSiteWithBlueprint function * Remove BlueprintError component and update error handling * Update add-site tests
1 parent a0b21ef commit 172e002

File tree

10 files changed

+409
-4
lines changed

10 files changed

+409
-4
lines changed

src/ipc-handlers.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1858,6 +1858,22 @@ export async function validateBlueprint(
18581858
};
18591859
}
18601860

1861+
export async function readBlueprintFile(
1862+
_event: IpcMainInvokeEvent,
1863+
filePath: string
1864+
): Promise< Blueprint[ 'blueprint' ] > {
1865+
const allowedDir = nodePath.join( app.getPath( 'temp' ), 'wp-studio-blueprints' );
1866+
const resolvedPath = nodePath.resolve( filePath );
1867+
1868+
const normalizedAllowedDir = nodePath.resolve( allowedDir );
1869+
if ( ! resolvedPath.startsWith( normalizedAllowedDir + nodePath.sep ) ) {
1870+
throw new Error( 'Blueprint file path must be within the allowed directory' );
1871+
}
1872+
1873+
const fileContents = await fsPromises.readFile( resolvedPath, 'utf-8' );
1874+
return JSON.parse( fileContents );
1875+
}
1876+
18611877
export async function setWindowControlVisibility( event: IpcMainInvokeEvent, visible: boolean ) {
18621878
const parentWindow = BrowserWindow.fromWebContents( event.sender );
18631879
if ( parentWindow && process.platform === 'darwin' ) {

src/ipc-utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type SnapshotKeyValueEventData = {
1919

2020
export interface IpcEvents {
2121
'add-site': [ void ];
22+
'add-site-blueprint': [ { blueprintPath: string } ];
2223
'auth-updated': [ { token: StoredToken } | { error: unknown } ];
2324
'on-export': [ ImportExportEventData, string ];
2425
'on-import': [ ImportExportEventData, string ];

src/lib/deeplink/deeplink-handler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { handleAddSiteWithBlueprint } from 'src/lib/deeplink/handlers/add-site-blueprint-with-url';
12
import { handleAuthDeeplink } from 'src/lib/deeplink/handlers/auth';
23
import { handleSyncConnectSiteDeeplink } from 'src/lib/deeplink/handlers/sync-connect-site';
34

@@ -6,6 +7,7 @@ import { handleSyncConnectSiteDeeplink } from 'src/lib/deeplink/handlers/sync-co
67
* Supports the following deeplink schemes:
78
* - wpcom-local-dev://auth - OAuth authentication callback
89
* - wpcom-local-dev://sync-connect-site - Sync site connection from WordPress.com
10+
* - wpcom-local-dev://add-site?blueprint_url=<encoded-url> - Add site with blueprint from URL
911
*/
1012
export async function handleDeeplink( url: string ): Promise< void > {
1113
const urlObject = new URL( url );
@@ -18,6 +20,9 @@ export async function handleDeeplink( url: string ): Promise< void > {
1820
case 'sync-connect-site':
1921
await handleSyncConnectSiteDeeplink( urlObject );
2022
break;
23+
case 'add-site':
24+
await handleAddSiteWithBlueprint( urlObject );
25+
break;
2126
default:
2227
console.warn( `Unknown deeplink host: ${ host }` );
2328
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { app, dialog } from 'electron';
2+
import nodePath from 'path';
3+
import { __ } from '@wordpress/i18n';
4+
import fs from 'fs-extra';
5+
import { sendIpcEventToRenderer } from 'src/ipc-utils';
6+
import { download } from 'src/lib/download';
7+
import { getMainWindow } from 'src/main-window';
8+
9+
/**
10+
* Handles the add-site deeplink callback.
11+
* This function is called when a user clicks a deeplink like:
12+
* wpcom-local-dev://add-site?blueprint_url=<encoded-url>
13+
*
14+
* It downloads the blueprint from the URL, saves it locally, and opens the Add Site modal
15+
* with the blueprint pre-filled.
16+
*/
17+
export async function handleAddSiteWithBlueprint( urlObject: URL ): Promise< void > {
18+
const { searchParams } = urlObject;
19+
const blueprintUrl = searchParams.get( 'blueprint_url' );
20+
21+
if ( ! blueprintUrl ) {
22+
console.error( 'add-site deeplink missing blueprint_url parameter' );
23+
return;
24+
}
25+
26+
try {
27+
const decodedUrl = decodeURIComponent( blueprintUrl );
28+
new URL( decodedUrl );
29+
} catch ( error ) {
30+
console.error( 'Invalid blueprint_url in add-site deeplink:', error );
31+
return;
32+
}
33+
34+
const decodedUrl = decodeURIComponent( blueprintUrl );
35+
36+
const tmpDir = nodePath.join( app.getPath( 'temp' ), 'wp-studio-blueprints' );
37+
await fs.mkdir( tmpDir, { recursive: true } );
38+
39+
const urlHash = Buffer.from( decodedUrl ).toString( 'base64url' ).slice( 0, 16 );
40+
const blueprintPath = nodePath.join( tmpDir, `blueprint-${ urlHash }.json` );
41+
42+
try {
43+
await download( decodedUrl, blueprintPath, false, 'blueprint' );
44+
45+
const mainWindow = await getMainWindow();
46+
if ( mainWindow.isMinimized() ) {
47+
mainWindow.restore();
48+
}
49+
mainWindow.focus();
50+
51+
await sendIpcEventToRenderer( 'add-site-blueprint', { blueprintPath } );
52+
} catch ( error ) {
53+
console.error( 'Failed to download blueprint from deeplink:', error );
54+
await fs.remove( blueprintPath ).catch( () => {
55+
// Ignore cleanup errors
56+
} );
57+
58+
const mainWindow = await getMainWindow();
59+
await dialog.showMessageBox( mainWindow, {
60+
type: 'error',
61+
message: __( 'Failed to download blueprint' ),
62+
detail: __( 'The blueprint could not be downloaded. Please check the URL and try again.' ),
63+
buttons: [ __( 'OK' ) ],
64+
} );
65+
}
66+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/* eslint-disable prettier/prettier */
2+
/**
3+
* @jest-environment node
4+
*/
5+
import { app, dialog, BrowserWindow } from 'electron';
6+
import fs from 'fs-extra';
7+
import { sendIpcEventToRenderer } from 'src/ipc-utils';
8+
import { handleAddSiteWithBlueprint } from 'src/lib/deeplink/handlers/add-site-blueprint-with-url';
9+
import { download } from 'src/lib/download';
10+
import { getMainWindow } from 'src/main-window';
11+
12+
jest.mock( 'electron' );
13+
jest.mock( 'fs-extra' );
14+
jest.mock( 'src/ipc-utils' );
15+
jest.mock( 'src/lib/download' );
16+
jest.mock( 'src/main-window' );
17+
18+
// Silence console.error output
19+
beforeAll( () => {
20+
jest.spyOn( console, 'error' ).mockImplementation( () => {} );
21+
} );
22+
23+
afterAll( () => {
24+
jest.spyOn( console, 'error' ).mockRestore();
25+
} );
26+
27+
describe( 'handleAddSiteWithBlueprint', () => {
28+
const mockMainWindow = {
29+
isMinimized: jest.fn().mockReturnValue( false ),
30+
restore: jest.fn(),
31+
focus: jest.fn(),
32+
} as unknown as BrowserWindow;
33+
34+
beforeEach( () => {
35+
jest.clearAllMocks();
36+
jest.mocked( app.getPath ).mockReturnValue( '/tmp' );
37+
( fs.mkdir as unknown as jest.Mock ).mockResolvedValue( undefined );
38+
jest.mocked( getMainWindow ).mockResolvedValue( mockMainWindow );
39+
jest.mocked( dialog.showMessageBox ).mockResolvedValue( {
40+
response: 0,
41+
checkboxChecked: false,
42+
} );
43+
} );
44+
45+
it( 'should handle add-site with valid blueprint_url', async () => {
46+
const blueprintUrl = 'https://example.com/blueprint.json';
47+
const encodedUrl = encodeURIComponent( blueprintUrl );
48+
const url = new URL( `wpcom-local-dev://add-site?blueprint_url=${ encodedUrl }` );
49+
50+
jest.mocked( download ).mockResolvedValue( undefined );
51+
52+
await handleAddSiteWithBlueprint( url );
53+
54+
expect( download ).toHaveBeenCalledWith(
55+
blueprintUrl,
56+
expect.stringContaining( 'blueprint-' ),
57+
false,
58+
'blueprint'
59+
);
60+
expect( sendIpcEventToRenderer ).toHaveBeenCalledWith( 'add-site-blueprint', {
61+
blueprintPath: expect.stringContaining( 'blueprint-' ),
62+
} );
63+
expect( mockMainWindow.focus ).toHaveBeenCalled();
64+
} );
65+
66+
it( 'should not send event if blueprint_url parameter is missing', async () => {
67+
const url = new URL( 'wpcom-local-dev://add-site' );
68+
69+
await handleAddSiteWithBlueprint( url );
70+
71+
expect( download ).not.toHaveBeenCalled();
72+
expect( sendIpcEventToRenderer ).not.toHaveBeenCalled();
73+
} );
74+
75+
it( 'should handle invalid blueprint_url gracefully', async () => {
76+
const invalidUrl = 'not-a-valid-url';
77+
const encodedUrl = encodeURIComponent( invalidUrl );
78+
const url = new URL( `wpcom-local-dev://add-site?blueprint_url=${ encodedUrl }` );
79+
80+
await handleAddSiteWithBlueprint( url );
81+
82+
expect( download ).not.toHaveBeenCalled();
83+
expect( sendIpcEventToRenderer ).not.toHaveBeenCalled();
84+
expect( dialog.showMessageBox ).not.toHaveBeenCalled();
85+
} );
86+
87+
it( 'should handle download failure gracefully', async () => {
88+
const blueprintUrl = 'https://example.com/blueprint.json';
89+
const encodedUrl = encodeURIComponent( blueprintUrl );
90+
const url = new URL( `wpcom-local-dev://add-site?blueprint_url=${ encodedUrl }` );
91+
92+
const downloadError = new Error( 'Download failed' );
93+
jest.mocked( download ).mockRejectedValue( downloadError );
94+
( fs.remove as unknown as jest.Mock ).mockResolvedValue( undefined );
95+
96+
await handleAddSiteWithBlueprint( url );
97+
98+
expect( download ).toHaveBeenCalled();
99+
expect( sendIpcEventToRenderer ).not.toHaveBeenCalled();
100+
expect( fs.remove ).toHaveBeenCalledWith(
101+
expect.stringContaining( 'blueprint-' )
102+
);
103+
expect( dialog.showMessageBox ).toHaveBeenCalledWith( mockMainWindow, {
104+
type: 'error',
105+
message: expect.any( String ),
106+
detail: expect.any( String ),
107+
buttons: expect.any( Array ),
108+
} );
109+
} );
110+
111+
it( 'should restore and focus window when minimized', async () => {
112+
const blueprintUrl = 'https://example.com/blueprint.json';
113+
const encodedUrl = encodeURIComponent( blueprintUrl );
114+
const url = new URL( `wpcom-local-dev://add-site?blueprint_url=${ encodedUrl }` );
115+
116+
( mockMainWindow.isMinimized as jest.Mock ).mockReturnValue( true );
117+
jest.mocked( download ).mockResolvedValue( undefined );
118+
119+
await handleAddSiteWithBlueprint( url );
120+
121+
expect( mockMainWindow.restore ).toHaveBeenCalled();
122+
expect( mockMainWindow.focus ).toHaveBeenCalled();
123+
} );
124+
125+
it( 'should handle cleanup errors gracefully on download failure', async () => {
126+
const blueprintUrl = 'https://example.com/blueprint.json';
127+
const encodedUrl = encodeURIComponent( blueprintUrl );
128+
const url = new URL( `wpcom-local-dev://add-site?blueprint_url=${ encodedUrl }` );
129+
130+
const downloadError = new Error( 'Download failed' );
131+
jest.mocked( download ).mockRejectedValue( downloadError );
132+
( fs.remove as unknown as jest.Mock ).mockRejectedValue( new Error( 'Cleanup failed' ) );
133+
134+
await expect( handleAddSiteWithBlueprint( url ) ).resolves.not.toThrow();
135+
136+
expect( dialog.showMessageBox ).toHaveBeenCalled();
137+
} );
138+
} );

src/lib/download.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { https } from 'follow-redirects';
1+
import { https, http } from 'follow-redirects';
22
import fs from 'fs-extra';
33

44
export async function download(
@@ -9,9 +9,11 @@ export async function download(
99
signal?: AbortSignal
1010
) {
1111
const file = fs.createWriteStream( filePath );
12+
const urlProtocol = new URL( url ).protocol;
13+
const httpModule = urlProtocol === 'https:' ? https : http;
1214

1315
await new Promise< void >( ( resolve, reject ) => {
14-
const request = https.get( url, ( response ) => {
16+
const request = httpModule.get( url, ( response ) => {
1517
if ( response.statusCode !== 200 ) {
1618
reject( new Error( `Request failed with status code: ${ response.statusCode }` ) );
1719
return;

src/modules/add-site/components/blueprints.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ interface AddSiteBlueprintProps {
119119
selectedBlueprint: string | null;
120120
onBlueprintChange: ( blueprintId: string ) => void;
121121
onFileBlueprintSelect?: ( blueprint: Blueprint ) => void;
122+
blueprintError?: string | null;
123+
onErrorDismiss?: () => void;
122124
}
123125

124126
export function AddSiteBlueprintSelector( {
@@ -128,6 +130,8 @@ export function AddSiteBlueprintSelector( {
128130
selectedBlueprint,
129131
onBlueprintChange,
130132
onFileBlueprintSelect,
133+
blueprintError,
134+
onErrorDismiss,
131135
}: AddSiteBlueprintProps ) {
132136
const { __ } = useI18n();
133137
const { refetch: refetchBlueprints, isFetching: isFetchingBlueprints } = useGetBlueprints();
@@ -369,6 +373,19 @@ export function AddSiteBlueprintSelector( {
369373
onClose={ () => setShowIssuesModal( false ) }
370374
/>
371375

376+
{ blueprintError && (
377+
<Notice
378+
status="error"
379+
isDismissible={ false }
380+
onRemove={ onErrorDismiss || ( () => {} ) }
381+
className="mx-0 mb-4"
382+
>
383+
<strong>{ __( 'Blueprint Error' ) }</strong>
384+
<br />
385+
{ blueprintError }
386+
</Notice>
387+
) }
388+
372389
{ validationError && (
373390
<Notice
374391
status="error"

0 commit comments

Comments
 (0)