Skip to content
Draft
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
46 changes: 40 additions & 6 deletions src/lib/import-export/export/exporters/default-exporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand All @@ -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 );
}
}
}

Expand Down Expand Up @@ -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 > {
Expand Down Expand Up @@ -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 = {
Expand Down
55 changes: 55 additions & 0 deletions src/lib/import-export/export/sanitize-wp-config.ts
Original file line number Diff line number Diff line change
@@ -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 } }`;
} );
}
Original file line number Diff line number Diff line change
Expand Up @@ -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( `<?php
define( 'WP_DEBUG', false );
` );
( fsPromises.rm as jest.Mock ).mockResolvedValue( undefined );
( os.tmpdir as jest.Mock ).mockReturnValue( '/tmp' );
( format as jest.Mock ).mockReturnValue( '2023-07-31-12-00-00' );

Expand Down Expand Up @@ -279,7 +283,7 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => {
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: {
Expand All @@ -291,17 +295,28 @@ 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(
2,
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 () => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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' }
);

Expand Down Expand Up @@ -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(
Expand All @@ -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' }
);
} );
Expand Down Expand Up @@ -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
);
Expand Down
Loading