diff --git a/src/lib/import-export/export/exporters/default-exporter.ts b/src/lib/import-export/export/exporters/default-exporter.ts index 00f449be47..f12f4a5c74 100644 --- a/src/lib/import-export/export/exporters/default-exporter.ts +++ b/src/lib/import-export/export/exporters/default-exporter.ts @@ -12,6 +12,7 @@ import { exportDatabaseToMultipleFiles, } from 'src/lib/import-export/export/export-database'; import { generateBackupFilename } from 'src/lib/import-export/export/generate-backup-filename'; +import { sanitizeWpConfig } from 'src/lib/import-export/export/sanitize-wp-config'; import { ExportOptions, BackupContents, @@ -125,8 +126,10 @@ export class DefaultExporter extends EventEmitter implements Exporter { this.archiveBuilder.pipe( output ); + let wpConfigTempPath: string | undefined; + try { - this.addWpConfig(); + wpConfigTempPath = await this.addWpConfig(); await this.addWpContent(); await this.addDatabase(); const studioJsonPath = await this.createStudioJsonFile(); @@ -143,6 +146,10 @@ export class DefaultExporter extends EventEmitter implements Exporter { if ( this.options.includes.database ) { await this.cleanupTempFiles(); } + // Clean up the wp-config temp file + if ( wpConfigTempPath ) { + await this.cleanupWpConfigTempFile( wpConfigTempPath ); + } } } @@ -177,13 +184,30 @@ export class DefaultExporter extends EventEmitter implements Exporter { } ); } - private addWpConfig(): void { + private async addWpConfig(): Promise< string | undefined > { const wpConfigPath = path.join( this.options.site.path, 'wp-config.php' ); - if ( fs.existsSync( wpConfigPath ) ) { - this.archiveBuilder.file( wpConfigPath, { - name: 'wp-config.php', - } ); + if ( ! fs.existsSync( wpConfigPath ) ) { + return undefined; } + + // Read the wp-config.php content + const content = await fsPromises.readFile( wpConfigPath, 'utf-8' ); + + // Sanitize by wrapping define() calls with defined() checks + // This prevents PHP warnings when pushing to WordPress.com + const sanitizedContent = sanitizeWpConfig( content ); + + // Write sanitized content to a temp file + const tempDir = await fsPromises.mkdtemp( path.join( os.tmpdir(), 'studio-wp-config-' ) ); + const tempWpConfigPath = path.join( tempDir, 'wp-config.php' ); + await fsPromises.writeFile( tempWpConfigPath, sanitizedContent ); + + // Add the sanitized temp file to the archive + this.archiveBuilder.file( tempWpConfigPath, { + name: 'wp-config.php', + } ); + + return tempWpConfigPath; } private async addWpContent(): Promise< void > { @@ -268,6 +292,16 @@ export class DefaultExporter extends EventEmitter implements Exporter { } } + private async cleanupWpConfigTempFile( tempPath: string ): Promise< void > { + try { + // Get the temp directory from the file path + const tempDir = path.dirname( tempPath ); + await fsPromises.rm( tempDir, { recursive: true, force: true } ); + } catch ( err ) { + console.error( `Failed to delete wp-config temp file ${ tempPath }:`, err ); + } + } + private async createStudioJsonFile(): Promise< string > { const wpVersion = await getWordPressVersionFromInstallation( this.options.site.path ); const studioJson: StudioJson = { diff --git a/src/lib/import-export/export/sanitize-wp-config.ts b/src/lib/import-export/export/sanitize-wp-config.ts new file mode 100644 index 0000000000..30b8f44b2f --- /dev/null +++ b/src/lib/import-export/export/sanitize-wp-config.ts @@ -0,0 +1,55 @@ +/** + * Sanitizes wp-config.php content by wrapping define() calls with defined() checks. + * + * This prevents PHP warnings when the exported site is pushed to WordPress.com, + * where many constants are already defined by the hosting infrastructure. + * + * Transforms: + * define( 'CONSTANT', 'value' ); + * Into: + * if ( ! defined( 'CONSTANT' ) ) { define( 'CONSTANT', 'value' ); } + * + * @param content - The wp-config.php file content + * @returns Sanitized content with safe constant definitions + */ +export function sanitizeWpConfig( content: string ): string { + // Regular expression to match define() calls + // Matches: define( 'NAME', value ); or define('NAME', value); + // Captures: + // - The whitespace/indentation before define + // - The constant name (single or double quoted) + // - The entire define statement for replacement + // Uses .+? (non-greedy) to match the value, stopping at the last ); + const defineRegex = /^(\s*)(define\s*\(\s*(['"])([A-Z_][A-Z0-9_]*)\3\s*,.+\)\s*;)/gim; + + // Track which constants we've already wrapped to avoid double-wrapping + const wrappedConstants = new Set< string >(); + + return content.replace( defineRegex, ( match, indent, defineStatement, _quote, constantName ) => { + // Check if this define is already wrapped with a defined() check + // Look for patterns like: if ( ! defined( 'CONSTANT' ) ) + const alreadyWrappedPattern = new RegExp( + `if\\s*\\(\\s*!\\s*defined\\s*\\(\\s*['"]${ constantName }['"]\\s*\\)\\s*\\)`, + 'i' + ); + + // Get some context before the match to check if it's already wrapped + const matchIndex = content.indexOf( match ); + const contextBefore = content.substring( Math.max( 0, matchIndex - 100 ), matchIndex ); + + if ( alreadyWrappedPattern.test( contextBefore ) ) { + // Already wrapped, return as-is + return match; + } + + // Skip if we've already wrapped this constant (handles duplicates) + if ( wrappedConstants.has( constantName ) ) { + return match; + } + + wrappedConstants.add( constantName ); + + // Wrap with defined() check + return `${ indent }if ( ! defined( '${ constantName }' ) ) { ${ defineStatement } }`; + } ); +} diff --git a/src/lib/import-export/tests/export/exporters/default-exporter.test.ts b/src/lib/import-export/tests/export/exporters/default-exporter.test.ts index d94bead799..592fcde19a 100644 --- a/src/lib/import-export/tests/export/exporters/default-exporter.test.ts +++ b/src/lib/import-export/tests/export/exporters/default-exporter.test.ts @@ -247,6 +247,10 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => { ( fsPromises.unlink as jest.Mock ).mockResolvedValue( undefined ); ( fsPromises.mkdtemp as jest.Mock ).mockResolvedValue( '/tmp/studio_export_123' ); ( fsPromises.writeFile as jest.Mock ).mockResolvedValue( undefined ); + ( fsPromises.readFile as jest.Mock ).mockResolvedValue( ` { expect( archiver ).toHaveBeenCalledWith( 'zip', { followSymlinks: true, zlib: { level: 9 } } ); } ); - it( 'should add wp-config.php to the archive', async () => { + it( 'should add sanitized wp-config.php to the archive', async () => { const options = { ...mockOptions, includes: { @@ -291,10 +295,10 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => { const exporter = new DefaultExporter( options ); await exporter.export(); - // wp-config.php should be called first, then meta.json + // wp-config.php should be called first (from temp dir), then meta.json expect( mockArchiver.file ).toHaveBeenNthCalledWith( 1, - normalize( '/path/to/site/wp-config.php' ), + normalize( '/tmp/studio_export_123/wp-config.php' ), { name: 'wp-config.php' } ); expect( mockArchiver.file ).toHaveBeenNthCalledWith( @@ -302,6 +306,17 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => { normalize( '/tmp/studio_export_123/meta.json' ), { name: 'meta.json' } ); + + // Verify wp-config.php was read from original location and sanitized + expect( fsPromises.readFile ).toHaveBeenCalledWith( + normalize( '/path/to/site/wp-config.php' ), + 'utf-8' + ); + // Verify sanitized content was written + expect( fsPromises.writeFile ).toHaveBeenCalledWith( + normalize( '/tmp/studio_export_123/wp-config.php' ), + expect.stringContaining( "if ( ! defined( 'WP_DEBUG' ) )" ) + ); } ); it( 'should add meta.json to the archive', async () => { @@ -334,7 +349,7 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => { // Check that wp-config.php and meta.json are both added expect( mockArchiver.file ).toHaveBeenNthCalledWith( 1, - normalize( '/path/to/site/wp-config.php' ), + normalize( '/tmp/studio_export_123/wp-config.php' ), { name: 'wp-config.php' } ); expect( mockArchiver.file ).toHaveBeenNthCalledWith( @@ -384,7 +399,7 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => { expect( mockArchiver.file ).toHaveBeenNthCalledWith( 1, - normalize( '/path/to/site/wp-config.php' ), + normalize( '/tmp/studio_export_123/wp-config.php' ), { name: 'wp-config.php' } ); expect( mockArchiver.file ).toHaveBeenNthCalledWith( @@ -415,7 +430,7 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => { expect( mockArchiver.file ).toHaveBeenNthCalledWith( 1, - normalize( '/path/to/site/wp-config.php' ), + normalize( '/tmp/studio_export_123/wp-config.php' ), { name: 'wp-config.php' } ); expect( mockArchiver.file ).toHaveBeenNthCalledWith( @@ -446,7 +461,7 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => { expect( mockArchiver.file ).toHaveBeenNthCalledWith( 1, - normalize( '/path/to/site/wp-config.php' ), + normalize( '/tmp/studio_export_123/wp-config.php' ), { name: 'wp-config.php' } ); @@ -541,7 +556,7 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => { expect( mockArchiver.file ).toHaveBeenNthCalledWith( 1, - normalize( '/path/to/site/wp-config.php' ), + normalize( '/tmp/studio_export_123/wp-config.php' ), { name: 'wp-config.php' } ); expect( mockArchiver.file ).toHaveBeenNthCalledWith( @@ -566,7 +581,7 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => { await exporter.export(); expect( mockArchiver.file ).not.toHaveBeenCalledWith( - normalize( '/path/to/site/wp-config.php' ), + normalize( '/tmp/studio_export_123/wp-config.php' ), { name: 'wp-config.php' } ); } ); @@ -765,9 +780,9 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => { normalize( '/path/to/site/wp-content/uploads/file1.jpg' ) ) ).toBe( false ); - expect( exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-config.php' ) ) ).toBe( - false - ); + expect( + exporter.isPathExcludedByPattern( normalize( '/tmp/studio_export_123/wp-config.php' ) ) + ).toBe( false ); expect( exporter.isPathExcludedByPattern( normalize( '/path/to/site/node_modules' ) ) ).toBe( false ); diff --git a/src/lib/import-export/tests/export/sanitize-wp-config.test.ts b/src/lib/import-export/tests/export/sanitize-wp-config.test.ts new file mode 100644 index 0000000000..4759cb23f8 --- /dev/null +++ b/src/lib/import-export/tests/export/sanitize-wp-config.test.ts @@ -0,0 +1,260 @@ +import { sanitizeWpConfig } from 'src/lib/import-export/export/sanitize-wp-config'; + +describe( 'sanitizeWpConfig', () => { + it( 'should wrap simple define statements with defined() checks', () => { + const input = ` { + const input = ` { + const input = ` { + const input = ` { + const input = ` { + const input = ` { + const input = ` { + const input = ` { + const input = ` { + const input = ` { + const input = ` { + // When a define is inside an if block, it should still be wrapped + const input = ` { + expect( sanitizeWpConfig( '' ) ).toBe( '' ); + } ); + + it( 'should handle array values in define', () => { + const input = ` { + const input = ` { + const input = ` { + // PHP constants are traditionally UPPERCASE, lowercase define names are rare + // but the regex should still handle standard WordPress constants + const input = ` { + const input = `