diff --git a/packages/playground/blueprints/src/lib/steps/import-wordpress-files.spec.ts b/packages/playground/blueprints/src/lib/steps/import-wordpress-files.spec.ts new file mode 100644 index 0000000000..cc4cbd07a0 --- /dev/null +++ b/packages/playground/blueprints/src/lib/steps/import-wordpress-files.spec.ts @@ -0,0 +1,250 @@ +import type { PHP, PHPRequestHandler } from '@php-wasm/universal'; +import { RecommendedPHPVersion } from '@wp-playground/common'; +import { importWordPressFiles } from './import-wordpress-files'; +import { zipWpContent } from './zip-wp-content'; +import { + getSqliteDriverModule, + getWordPressModule, +} from '@wp-playground/wordpress-builds'; +import { bootWordPressAndRequestHandler } from '@wp-playground/wordpress'; +import { loadNodeRuntime } from '@php-wasm/node'; +import { phpVar } from '@php-wasm/util'; +import { setURLScope } from '@php-wasm/scopes'; + +describe('Blueprint step importWordPressFiles', () => { + let sourceHandler: PHPRequestHandler; + let sourcePHP: PHP; + let targetHandler: PHPRequestHandler; + let targetPHP: PHP; + + const sourceScope = 'source-scope-123'; + const targetScope = 'target-scope-456'; + + beforeEach(async () => { + // Boot source playground with a specific scope + const sourceSiteUrl = setURLScope( + new URL('http://playground-domain/'), + sourceScope + ).toString(); + + sourceHandler = await bootWordPressAndRequestHandler({ + createPhpRuntime: async () => + await loadNodeRuntime(RecommendedPHPVersion), + siteUrl: sourceSiteUrl, + wordPressZip: await getWordPressModule(), + sqliteIntegrationPluginZip: await getSqliteDriverModule(), + }); + sourcePHP = await sourceHandler.getPrimaryPhp(); + + // Boot target playground with a different scope + const targetSiteUrl = setURLScope( + new URL('http://playground-domain/'), + targetScope + ).toString(); + + targetHandler = await bootWordPressAndRequestHandler({ + createPhpRuntime: async () => + await loadNodeRuntime(RecommendedPHPVersion), + siteUrl: targetSiteUrl, + wordPressZip: await getWordPressModule(), + sqliteIntegrationPluginZip: await getSqliteDriverModule(), + }); + targetPHP = await targetHandler.getPrimaryPhp(); + }); + + afterEach(async () => { + sourcePHP.exit(); + targetPHP.exit(); + await sourceHandler[Symbol.asyncDispose](); + await targetHandler[Symbol.asyncDispose](); + }); + + it('should include playground-export.json manifest in the exported zip', async () => { + const zipBuffer = await zipWpContent(sourcePHP); + + // Check that the zip contains the manifest by inspecting it + await targetPHP.writeFile('/tmp/check.zip', zipBuffer); + const result = await targetPHP.run({ + code: `open('/tmp/check.zip'); + $manifest = $zip->getFromName('playground-export.json'); + $zip->close(); + echo $manifest; + `, + }); + + expect(result.text).toBeTruthy(); + const manifest = JSON.parse(result.text); + expect(manifest.siteUrl).toContain(`scope:${sourceScope}`); + }); + + it('should replace old scope URLs with new scope URLs in post content during import', async () => { + // Create a post with an image URL containing the source scope + const sourceUrl = await sourcePHP.absoluteUrl; + const imageUrl = `${sourceUrl.replace(/\/$/, '')}/wp-content/uploads/2024/01/test-image.png`; + + await sourcePHP.run({ + code: ` 'Test Post with Image', + 'post_content' => 'test', + 'post_status' => 'publish', + ]); + `, + }); + + // Export from source + const zipBuffer = await zipWpContent(sourcePHP); + const zipFile = new File([zipBuffer], 'export.zip'); + + // Import into target + await importWordPressFiles(targetPHP, { + wordPressFilesZip: zipFile, + }); + + // Check that the URLs were updated + const result = await targetPHP.run({ + code: ` 'publish', 'numberposts' => 1]); + echo $posts[0]->post_content; + `, + }); + + // The image URL should now contain the target scope instead of source scope + expect(result.text).toContain(`scope:${targetScope}`); + expect(result.text).not.toContain(`scope:${sourceScope}`); + }); + + it('should replace URLs in post meta during import', async () => { + const sourceUrl = await sourcePHP.absoluteUrl; + const imageUrl = `${sourceUrl.replace(/\/$/, '')}/wp-content/uploads/2024/01/featured.jpg`; + + await sourcePHP.run({ + code: ` 'Test Post', + 'post_content' => 'Test content', + 'post_status' => 'publish', + ]); + update_post_meta($post_id, '_custom_image_url', ${phpVar(imageUrl)}); + `, + }); + + // Export and import + const zipBuffer = await zipWpContent(sourcePHP); + const zipFile = new File([zipBuffer], 'export.zip'); + await importWordPressFiles(targetPHP, { + wordPressFilesZip: zipFile, + }); + + // Check that the meta URL was updated + const result = await targetPHP.run({ + code: ` 'publish', 'numberposts' => 1]); + echo get_post_meta($posts[0]->ID, '_custom_image_url', true); + `, + }); + + expect(result.text).toContain(`scope:${targetScope}`); + expect(result.text).not.toContain(`scope:${sourceScope}`); + }); + + it('should replace URLs in options during import', async () => { + const sourceUrl = await sourcePHP.absoluteUrl; + const logoUrl = `${sourceUrl.replace(/\/$/, '')}/wp-content/uploads/logo.png`; + + await sourcePHP.run({ + code: ` { + // Create a post with an image URL containing the source scope + const sourceUrl = sourcePHP.absoluteUrl; + const imageUrl = `${sourceUrl.replace(/\/$/, '')}/wp-content/uploads/2024/01/legacy-image.png`; + + // First, update the siteurl option in the database to match the scoped URL. + // This simulates a site where the user changed the URL or where the option + // was set correctly during setup. By default, the database may contain a + // different URL than the scoped one we're using. + await sourcePHP.run({ + code: `update( + $wpdb->options, + ['option_value' => ${phpVar(sourceUrl)}], + ['option_name' => 'siteurl'] + ); + wp_insert_post([ + 'post_title' => 'Legacy Post with Image', + 'post_content' => 'legacy', + 'post_status' => 'publish', + ]); + `, + }); + + // Export from source, then remove the manifest to simulate a legacy export + const zipBuffer = await zipWpContent(sourcePHP); + await targetPHP.writeFile('/tmp/with-manifest.zip', zipBuffer); + + // Remove the manifest from the zip + await targetPHP.run({ + code: `open('/tmp/with-manifest.zip'); + $zip->deleteName('playground-export.json'); + $zip->close(); + `, + }); + + const modifiedZipBuffer = await targetPHP.readFileAsBuffer( + '/tmp/with-manifest.zip' + ); + const zipFile = new File([modifiedZipBuffer], 'legacy-export.zip'); + + // Import into target - should infer the old scope from the database + await importWordPressFiles(targetPHP, { + wordPressFilesZip: zipFile, + }); + + // Check that the URLs were updated despite no manifest + const result = await targetPHP.run({ + code: ` 'publish', 'numberposts' => 1]); + echo $posts[0]->post_content; + `, + }); + + // The image URL should now contain the target scope instead of source scope + expect(result.text).toContain(`scope:${targetScope}`); + expect(result.text).not.toContain(`scope:${sourceScope}`); + }); +}); diff --git a/packages/playground/blueprints/src/lib/steps/import-wordpress-files.ts b/packages/playground/blueprints/src/lib/steps/import-wordpress-files.ts index 61fd78aa13..8575e284a2 100644 --- a/packages/playground/blueprints/src/lib/steps/import-wordpress-files.ts +++ b/packages/playground/blueprints/src/lib/steps/import-wordpress-files.ts @@ -1,6 +1,6 @@ import type { StepHandler } from '.'; import { unzip } from './unzip'; -import { dirname, joinPaths, phpVar } from '@php-wasm/util'; +import { dirname, joinPaths, phpVar, phpVars } from '@php-wasm/util'; import type { UniversalPHP } from '@php-wasm/universal'; import { ensureWpConfig } from '@wp-playground/wordpress'; import { wpContentFilesExcludedFromExport } from '../utils/wp-content-files-excluded-from-exports'; @@ -60,6 +60,24 @@ export const importWordPressFiles: StepHandler< }); importPath = joinPaths(importPath, pathInZip); + // Read the export manifest if it exists. The manifest contains the + // site URL (including scope) at export time, which we'll use later + // to update URLs in the database when the scope changes. + const manifestPath = joinPaths(importPath, 'playground-export.json'); + let oldSiteUrl: string | null = null; + if (await playground.fileExists(manifestPath)) { + try { + const manifestContent = + await playground.readFileAsText(manifestPath); + const manifest = JSON.parse(manifestContent); + oldSiteUrl = manifest.siteUrl; + // Remove the manifest file - it's not needed in the document root + await playground.unlink(manifestPath); + } catch { + // Ignore error – tolerate missing and malformed manifests. + } + } + // Carry over any Playground-related files, such as the // SQLite database plugin, from the current wp-content // into the one that's about to be imported @@ -113,9 +131,18 @@ export const importWordPressFiles: StepHandler< // Ensure required constants are defined in wp-config.php. await ensureWpConfig(playground, documentRoot); + const newSiteUrl = await playground.absoluteUrl; + + // If the manifest didn't provide the old site URL, try to infer it from + // the database. The siteurl option still contains the URL from the export + // at this point, before we update it with defineSiteUrl. + if (!oldSiteUrl) { + oldSiteUrl = await inferSiteUrlFromDatabase(playground, documentRoot); + } + // Adjust the site URL await defineSiteUrl(playground, { - siteUrl: await playground.absoluteUrl, + siteUrl: newSiteUrl, }); // Upgrade the database @@ -128,8 +155,143 @@ export const importWordPressFiles: StepHandler< require ${upgradePhp}; `, }); + + // If the site URL changed (different scope), update all URLs in the database. + // This ensures that image and media URLs that reference the old scope + // are updated to use the new scope. + if (oldSiteUrl && oldSiteUrl !== newSiteUrl) { + await replaceSiteUrl(playground, documentRoot, oldSiteUrl, newSiteUrl); + } }; +/** + * Extracts the scope path segment from a Playground URL. + * For example, "http://playground.wordpress.net/scope:abc123/" returns "/scope:abc123/". + * Returns null if no scope is found. + */ +function extractScopePath(url: string): string | null { + const match = url.match(/\/scope:[^/]+\/?/); + return match ? match[0].replace(/\/?$/, '/') : null; +} + +/** + * Replaces the scope path segment in URLs stored in the database. + * Only replaces /scope:old-scope/ with /scope:new-scope/, leaving the rest + * of URLs intact. This is a targeted replacement that handles scope changes + * when importing a Playground export into a different scope. + * + * This approach is reasonably safe because: + * - The scope string is fairly unique (/scope:xyz/ pattern) + * - The database fits into memory anyway + * - There's no expectation of HTML entities or other escaping within the scope string + */ +async function replaceSiteUrl( + playground: UniversalPHP, + documentRoot: string, + oldSiteUrl: string, + newSiteUrl: string +) { + const oldScopePath = extractScopePath(oldSiteUrl); + const newScopePath = extractScopePath(newSiteUrl); + + // If we can't extract scope paths, there's nothing to replace + if (!oldScopePath || !newScopePath) { + return; + } + + // If the scopes are the same, no replacement needed + if (oldScopePath === newScopePath) { + return; + } + + await playground.run({ + code: `query($wpdb->prepare( + "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s)", + $old_scope, $new_scope + )); + $wpdb->query($wpdb->prepare( + "UPDATE {$wpdb->posts} SET post_excerpt = REPLACE(post_excerpt, %s, %s)", + $old_scope, $new_scope + )); + $wpdb->query($wpdb->prepare( + "UPDATE {$wpdb->posts} SET guid = REPLACE(guid, %s, %s)", + $old_scope, $new_scope + )); + + // Update URLs in post meta + $wpdb->query($wpdb->prepare( + "UPDATE {$wpdb->postmeta} SET meta_value = REPLACE(meta_value, %s, %s) WHERE meta_value LIKE %s", + $old_scope, $new_scope, '%' . $wpdb->esc_like($old_scope) . '%' + )); + + // Update URLs in options (handles both regular and serialized data) + $wpdb->query($wpdb->prepare( + "UPDATE {$wpdb->options} SET option_value = REPLACE(option_value, %s, %s) WHERE option_value LIKE %s", + $old_scope, $new_scope, '%' . $wpdb->esc_like($old_scope) . '%' + )); + + // Update URLs in user meta + $wpdb->query($wpdb->prepare( + "UPDATE {$wpdb->usermeta} SET meta_value = REPLACE(meta_value, %s, %s) WHERE meta_value LIKE %s", + $old_scope, $new_scope, '%' . $wpdb->esc_like($old_scope) . '%' + )); + + // Update URLs in term meta + $wpdb->query($wpdb->prepare( + "UPDATE {$wpdb->termmeta} SET meta_value = REPLACE(meta_value, %s, %s) WHERE meta_value LIKE %s", + $old_scope, $new_scope, '%' . $wpdb->esc_like($old_scope) . '%' + )); + + // Update URLs in comments + $wpdb->query($wpdb->prepare( + "UPDATE {$wpdb->comments} SET comment_content = REPLACE(comment_content, %s, %s) WHERE comment_content LIKE %s", + $old_scope, $new_scope, '%' . $wpdb->esc_like($old_scope) . '%' + )); + $wpdb->query($wpdb->prepare( + "UPDATE {$wpdb->comments} SET comment_author_url = REPLACE(comment_author_url, %s, %s) WHERE comment_author_url LIKE %s", + $old_scope, $new_scope, '%' . $wpdb->esc_like($old_scope) . '%' + )); + `, + env: { + DOCUMENT_ROOT: documentRoot, + OLD_SCOPE: oldScopePath, + NEW_SCOPE: newScopePath, + }, + }); +} + +/** + * Attempts to infer the old site URL from the WordPress database. + * This is used when importing legacy exports that don't have a manifest file. + * We query the siteurl option directly from the database using raw SQL because + * get_option('siteurl') would return the WP_SITEURL constant value instead of + * what's stored in the database. + */ +async function inferSiteUrlFromDatabase( + playground: UniversalPHP, + documentRoot: string +): Promise { + const js = phpVars({ documentRoot }); + const result = await playground.run({ + code: `get_row("SELECT option_value FROM {$wpdb->options} WHERE option_name = 'siteurl'"); + echo $row ? $row->option_value : ''; + `, + }); + const siteUrl = result.text.trim(); + return siteUrl || null; +} + async function removePath(playground: UniversalPHP, path: string) { if (await playground.fileExists(path)) { if (await playground.isDir(path)) { diff --git a/packages/playground/blueprints/src/lib/steps/zip-wp-content.ts b/packages/playground/blueprints/src/lib/steps/zip-wp-content.ts index 7dc2448e9a..5fd9cc5b9f 100644 --- a/packages/playground/blueprints/src/lib/steps/zip-wp-content.ts +++ b/packages/playground/blueprints/src/lib/steps/zip-wp-content.ts @@ -22,10 +22,20 @@ export const zipWpContent = async ( { selfContained = false }: ZipWpContentOptions = {} ) => { const zipPath = '/tmp/wordpress-playground.zip'; + const manifestPath = '/tmp/playground-export.json'; const documentRoot = await playground.documentRoot; const wpContentPath = joinPaths(documentRoot, 'wp-content'); + // Create a manifest file containing metadata about this export, + // including the site URL (with scope). This will be used during import + // to update URLs in the database when the scope changes. + const siteUrl = await playground.absoluteUrl; + await playground.writeFile( + manifestPath, + new TextEncoder().encode(JSON.stringify({ siteUrl })) + ); + let exceptPaths = wpContentFilesExcludedFromExport; /* * This is a temporary workaround to enable including the WordPress @@ -45,6 +55,15 @@ export const zipWpContent = async ( (path) => path !== 'mu-plugins/sqlite-database-integration' ); } + + const additionalPaths: Record = { + [manifestPath]: 'playground-export.json', + }; + if (selfContained) { + additionalPaths[joinPaths(documentRoot, 'wp-config.php')] = + 'wp-config.php'; + } + const js = phpVars({ zipPath, wpContentPath, @@ -52,11 +71,7 @@ export const zipWpContent = async ( exceptPaths: exceptPaths.map((path) => joinPaths(documentRoot, 'wp-content', path) ), - additionalPaths: selfContained - ? { - [joinPaths(documentRoot, 'wp-config.php')]: 'wp-config.php', - } - : {}, + additionalPaths, }); await runPhpWithZipFunctions( playground, @@ -69,6 +84,7 @@ export const zipWpContent = async ( const fileBuffer = await playground.readFileAsBuffer(zipPath); playground.unlink(zipPath); + playground.unlink(manifestPath); return fileBuffer; };