From 0d8d351b4b3e1ec3069108a3b38adc9fb57aba94 Mon Sep 17 00:00:00 2001 From: Peter Downey Date: Fri, 6 Nov 2026 20:42:24 -0500 Subject: [PATCH 1/2] Record stored addresses and refuse subfolder sites manifest.txt now records the home and siteurl options as home_url and site_url, as EngineScript's own exports do, instead of home_url() and site_url(), which follow the admin page's scheme and plugin filters. The warning about missing WP_HOME and WP_SITEURL is removed, since the importer reads the address from the manifest. A site whose Site Address or WordPress Address has a path (/blog, or WordPress in its own /wp folder) is no longer exported: EngineScript only hosts sites at the root of their domain. The exporter page shows an error in place of the form, and an export request is refused. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + README.md | 4 +- includes/admin-page.php | 21 ++ includes/archive.php | 9 +- includes/export.php | 48 +++- languages/enginescript-site-exporter.pot | 276 +++++++++++------------ readme.txt | 6 +- tests/bootstrap.php | 11 + tests/unit/AdminPageTest.php | 20 +- tests/unit/ManifestTest.php | 53 +++++ tests/unit/SiteLayoutTest.php | 53 +++++ 11 files changed, 355 insertions(+), 152 deletions(-) create mode 100644 tests/unit/ManifestTest.php create mode 100644 tests/unit/SiteLayoutTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 97cd80a..7f668fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ see the current README and Security Policy for supported behavior and guidance. ## 2.1.3 - Unreleased +### Fixes + +- **Stored Site Addresses in the Manifest**: `manifest.txt` now records the addresses stored in the `home` and `siteurl` options (`WP_HOME` and `WP_SITEURL` when `wp-config.php` defines them) as `home_url` and `site_url`, as EngineScript's own exports do. Before, it recorded `home_url()` and `site_url()`, which take the scheme of the admin page (`https` on an HTTPS admin page even when the stored address is `http`, and `http` when WordPress does not detect HTTPS) and pass through plugin filters such as those of multilingual plugins. EngineScript's importer now reads the site address and table prefix from these lines and moves the database's URLs from the stored address. +- **Address Constant Warning Removed**: The exporter page no longer warns when neither `WP_HOME` nor `WP_SITEURL` is defined, because EngineScript's importer reads the site address from `manifest.txt`. The warning about `wp-config.php` outside the WordPress directory stays. +- **Subfolder Sites Not Exported**: A site whose Site Address or WordPress Address has a path, such as a site in `/blog` or WordPress in its own `/wp` folder, is no longer exported. EngineScript only hosts sites at the root of their domain and its importer stops on such archives, so the exporter page shows an error, with both addresses, in place of the export form, and an export request is refused with the same error. + ### Compatibility - **Minimum WordPress 7.0**: Raise the minimum WordPress version to 7.0, the version Jetpack requires. diff --git a/README.md b/README.md index 5c07e0f..bd6f0ad 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,9 @@ Database records older than seven days are removed when housekeeping, recovery, ### What does the EngineScript importer need from an export? -EngineScript's `vhost-import.sh` finds the site through a `wp-config.php` inside the files archive and reads the site address from `WP_HOME` or `WP_SITEURL` in that file. An export from a site that keeps `wp-config.php` above the WordPress directory, or that defines neither constant, is still a complete backup, but the importer stops on it. The exporter page warns when either is the case. For reference, `manifest.txt` also records `home_url`, `site_url`, `table_prefix`, and `wp_config_in_archive`. +EngineScript's `vhost-import.sh` finds the site through a `wp-config.php` inside the files archive. An export from a site that keeps `wp-config.php` above the WordPress directory is still a complete backup, but the importer stops on it, and the exporter page warns about it. `manifest.txt` also records `home_url`, `site_url`, `table_prefix`, and `wp_config_in_archive`. `home_url` and `site_url` are the addresses stored in the `home` and `siteurl` options (`WP_HOME` and `WP_SITEURL` when `wp-config.php` defines them), and the importer reads the site address and table prefix from the manifest, so neither constant is needed. + +EngineScript only hosts sites at the root of their domain, such as `https://example.com`. A site with a path in its Site Address or WordPress Address, such as a site in `/blog` or WordPress in its own `/wp` folder, cannot be exported: the exporter page shows an error in place of the export form. A site at `www.example.com` can be exported, and EngineScript's importer offers to move it to `example.com` or to keep `www`. ### Can I use this plugin with non-EngineScript servers? diff --git a/includes/admin-page.php b/includes/admin-page.php index 8f99eff..937e227 100644 --- a/includes/admin-page.php +++ b/includes/admin-page.php @@ -481,6 +481,22 @@ function esse_render_import_requirement_warnings(): void { ); } +/** + * Renders why this site cannot be exported, in place of the export form. + * + * @since 2.1.3 + * @param string $message Error message from esse_get_site_layout_error(). + */ +function esse_render_site_layout_error( string $message ): void { + wp_admin_notice( + esc_html( $message ), + [ + 'type' => 'error', + 'additional_classes' => [ 'inline' ], + ] + ); +} + /** * Renders the exporter page HTML interface. * @@ -507,6 +523,10 @@ function esse_exporter_page_html(): void {

+ + + +

@@ -551,6 +571,7 @@ class="sse-section-spacing sse-export-form"

+

diff --git a/includes/archive.php b/includes/archive.php index d4daab4..54c89b2 100644 --- a/includes/archive.php +++ b/includes/archive.php @@ -521,9 +521,12 @@ function esse_write_engine_script_manifest( array $bundle_paths, string $site_id */ function esse_get_engine_script_manifest_site_lines( bool $has_wp_config ): string { $database = esse_get_wordpress_database(); - $values = [ - 'home_url' => home_url(), - 'site_url' => site_url(), + // The stored addresses (WP_HOME and WP_SITEURL when wp-config.php defines them): the importer + // moves the URLs in the database from these. home_url() and site_url() can differ from them, + // because they take the scheme of the current request and pass through plugin filters. + $values = [ + 'home_url' => esse_normalize_string_value( get_option( 'home' ) ), + 'site_url' => esse_normalize_string_value( get_option( 'siteurl' ) ), 'table_prefix' => $database->prefix ?? '', 'wp_config_in_archive' => $has_wp_config ? 'yes' : 'no', ]; diff --git a/includes/export.php b/includes/export.php index 1058810..e1c4d10 100644 --- a/includes/export.php +++ b/includes/export.php @@ -451,6 +451,12 @@ function esse_handle_export(): void { esse_redirect_to_exporter_page(); } + $layout_error = esse_get_site_layout_error(); + if ( null !== $layout_error ) { + esse_show_error_notice( $layout_error ); + esse_redirect_to_exporter_page(); + } + $lease = esse_acquire_export_lease(); if ( is_wp_error( $lease ) ) { esse_show_error_notice( $lease->get_error_message() ); @@ -674,13 +680,43 @@ function esse_validate_export_request(): int|false { return 0; } +/** + * Gets the reason this site cannot be exported, when it is not at the root of its domain. + * + * EngineScript serves each site, and WordPress, at the root of its domain. A site + * in a subfolder (example.com/blog), or with WordPress in its own folder + * (WordPress Address example.com/wp), cannot be imported, so it is not exported. + * The stored options are read, as for the manifest. + * + * @since 2.1.3 + * @return string|null Error message, or null when the site can be exported. + */ +function esse_get_site_layout_error(): ?string { + $home = esse_normalize_string_value( get_option( 'home' ) ); + $siteurl = esse_normalize_string_value( get_option( 'siteurl' ) ); + + foreach ( [ $home, $siteurl ] as $address ) { + $path = wp_parse_url( $address, PHP_URL_PATH ); + if ( is_string( $path ) && '' !== trim( $path, '/' ) ) { + return sprintf( + // translators: 1: Site Address (URL) setting, 2: WordPress Address (URL) setting. + __( 'This site cannot be exported. EngineScript only hosts sites at the root of their domain, such as https://example.com. It does not support sites in a subfolder (such as /blog), or WordPress in its own folder (such as /wp). Site Address: %1$s. WordPress Address: %2$s.', 'enginescript-site-exporter' ), + $home, + $siteurl + ); + } + } + + return null; +} + /** * Gets warnings about conditions that EngineScript's importer does not accept. * - * The importer finds the site through a wp-config.php inside the files archive - * and reads the site address from WP_HOME or WP_SITEURL in that file. An - * export from a site that does not meet this is still a complete backup, so - * these are warnings and never stop the export. + * The importer finds the site through a wp-config.php inside the files archive. + * It reads the site address from manifest.txt, so WP_HOME and WP_SITEURL are not + * needed. An export from a site that does not meet this is still a complete + * backup, so these are warnings and never stop the export. * * @since 2.1.1 * @return string[] Warning messages; empty when the archive can be imported. @@ -692,10 +728,6 @@ function esse_get_import_requirement_warnings(): array { $warnings[] = __( 'This site keeps wp-config.php outside the WordPress directory, so the file is not part of the export. The EngineScript importer needs wp-config.php in the export.', 'enginescript-site-exporter' ); } - if ( ! defined( 'WP_HOME' ) && ! defined( 'WP_SITEURL' ) ) { - $warnings[] = __( 'Neither WP_HOME nor WP_SITEURL is defined in wp-config.php. The EngineScript importer reads the site address from one of them.', 'enginescript-site-exporter' ); - } - return $warnings; } diff --git a/languages/enginescript-site-exporter.pot b/languages/enginescript-site-exporter.pot index 1977aa5..bb32652 100644 --- a/languages/enginescript-site-exporter.pot +++ b/languages/enginescript-site-exporter.pot @@ -9,15 +9,15 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2026-10-16T07:27:45+00:00\n" +"POT-Creation-Date: 2026-11-07T01:11:27+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "X-Generator: WP-CLI 2.12.0\n" "X-Domain: enginescript-site-exporter\n" #. Plugin Name of the plugin #: enginescript-site-exporter.php -#: includes/admin-page.php:51 -#: includes/admin-page.php:73 +#: includes/admin-page.php:49 +#: includes/admin-page.php:70 msgid "EngineScript Site Exporter" msgstr "" @@ -31,21 +31,21 @@ msgstr "" msgid "EngineScript" msgstr "" -#: includes/admin-page.php:52 -#: includes/admin-page.php:74 +#: includes/admin-page.php:50 +#: includes/admin-page.php:71 msgid "Site Exporter" msgstr "" -#: includes/admin-page.php:182 +#: includes/admin-page.php:175 msgid "Site export successfully created!" msgstr "" -#: includes/admin-page.php:186 +#: includes/admin-page.php:179 msgid "Download it from the list of available exports on this page." msgstr "" #. translators: %s: number of files or directories -#: includes/admin-page.php:211 +#: includes/admin-page.php:204 #, php-format msgid "%s unreadable file or directory" msgid_plural "%s unreadable files or directories" @@ -53,7 +53,7 @@ msgstr[0] "" msgstr[1] "" #. translators: %s: number of symbolic links -#: includes/admin-page.php:215 +#: includes/admin-page.php:208 #, php-format msgid "%s symbolic link" msgid_plural "%s symbolic links" @@ -61,7 +61,7 @@ msgstr[0] "" msgstr[1] "" #. translators: %s: number of special files -#: includes/admin-page.php:219 +#: includes/admin-page.php:212 #, php-format msgid "%s special file such as a socket or named pipe" msgid_plural "%s special files such as sockets or named pipes" @@ -69,7 +69,7 @@ msgstr[0] "" msgstr[1] "" #. translators: %s: number of files -#: includes/admin-page.php:223 +#: includes/admin-page.php:216 #, php-format msgid "%s file over the size limit" msgid_plural "%s files over the size limit" @@ -77,12 +77,12 @@ msgstr[0] "" msgstr[1] "" #. translators: %l: list of counted entries, for example "2 symbolic links and 1 file over the size limit" -#: includes/admin-page.php:229 +#: includes/admin-page.php:222 msgid "Left out of the export: %l." msgstr "" #. translators: %s: number of files -#: includes/admin-page.php:233 +#: includes/admin-page.php:226 #, php-format msgid "%s file changed while it was read and may be incomplete in the export." msgid_plural "%s files changed while they were read and may be incomplete in the export." @@ -90,178 +90,178 @@ msgstr[0] "" msgstr[1] "" #. translators: %s: archive file name -#: includes/admin-page.php:307 +#: includes/admin-page.php:297 #, php-format msgid "Download %s" msgstr "" #. translators: %s: archive file name -#: includes/admin-page.php:309 +#: includes/admin-page.php:299 #, php-format msgid "Delete %s" msgstr "" -#: includes/admin-page.php:313 +#: includes/admin-page.php:303 msgid "Download Export File" msgstr "" -#: includes/admin-page.php:319 +#: includes/admin-page.php:309 msgid "Are you sure you want to delete this export file?" msgstr "" -#: includes/admin-page.php:326 +#: includes/admin-page.php:316 msgid "Delete Export File" msgstr "" -#: includes/admin-page.php:343 +#: includes/admin-page.php:332 msgid "Available exports" msgstr "" -#: includes/admin-page.php:345 +#: includes/admin-page.php:334 msgid "No exports are available. A finished export is listed here until it is deleted." msgstr "" -#: includes/admin-page.php:348 +#: includes/admin-page.php:337 msgid "Exports that can be downloaded or deleted" msgstr "" -#: includes/admin-page.php:351 +#: includes/admin-page.php:340 msgid "File" msgstr "" -#: includes/admin-page.php:352 +#: includes/admin-page.php:341 msgid "Size" msgstr "" -#: includes/admin-page.php:353 +#: includes/admin-page.php:342 msgid "Created" msgstr "" -#: includes/admin-page.php:354 +#: includes/admin-page.php:343 msgid "Actions" msgstr "" -#: includes/admin-page.php:381 +#: includes/admin-page.php:370 msgid "System" msgstr "" #. translators: %d: user ID -#: includes/admin-page.php:390 +#: includes/admin-page.php:379 #, php-format msgid "User %d (no longer exists)" msgstr "" -#: includes/admin-page.php:402 +#: includes/admin-page.php:391 msgid "Activity" msgstr "" -#: includes/admin-page.php:403 +#: includes/admin-page.php:392 msgid "Error" msgstr "" -#: includes/admin-page.php:404 +#: includes/admin-page.php:393 msgid "Security" msgstr "" -#: includes/admin-page.php:420 +#: includes/admin-page.php:408 msgid "Recent activity" msgstr "" -#: includes/admin-page.php:421 +#: includes/admin-page.php:409 msgid "Up to 20 exports, downloads, deletions, errors, and security events from the last seven days, newest first." msgstr "" -#: includes/admin-page.php:423 +#: includes/admin-page.php:411 msgid "No activity has been recorded." msgstr "" -#: includes/admin-page.php:426 +#: includes/admin-page.php:414 msgid "Recent exporter activity" msgstr "" -#: includes/admin-page.php:429 +#: includes/admin-page.php:417 msgid "Time" msgstr "" -#: includes/admin-page.php:430 +#: includes/admin-page.php:418 msgid "User" msgstr "" -#: includes/admin-page.php:431 +#: includes/admin-page.php:419 msgid "Type" msgstr "" -#: includes/admin-page.php:432 +#: includes/admin-page.php:420 msgid "Details" msgstr "" -#: includes/admin-page.php:462 +#: includes/admin-page.php:449 msgid "This page is not served over HTTPS. A downloaded export travels unencrypted, and it contains the whole database and wp-config.php." msgstr "" -#: includes/admin-page.php:486 +#: includes/admin-page.php:471 msgid "You can still create the export, and it remains a complete backup. Importing it with EngineScript will stop until this is corrected." msgstr "" -#: includes/admin-page.php:499 +#: includes/admin-page.php:507 msgid "You do not have permission to view this page." msgstr "" -#: includes/admin-page.php:517 +#: includes/admin-page.php:525 msgid "Create an export" msgstr "" -#: includes/admin-page.php:518 +#: includes/admin-page.php:530 msgid "Click the button below to generate an EngineScript-compatible site archive containing your WordPress files and the database." msgstr "" -#: includes/admin-page.php:519 +#: includes/admin-page.php:531 msgid "Warning:" msgstr "" -#: includes/admin-page.php:519 +#: includes/admin-page.php:531 msgid "This can take a long time and consume significant server resources, especially on large sites. Ensure your server has sufficient disk space and execution time." msgstr "" #. translators: %s: directory path. -#: includes/admin-page.php:525 +#: includes/admin-page.php:537 #, php-format msgid "Exports are stored in this private server directory: %s" msgstr "" -#: includes/admin-page.php:534 +#: includes/admin-page.php:546 msgid "The export is running. This can take several minutes. Keep this page open; it reloads when the export has finished." msgstr "" -#: includes/admin-page.php:543 +#: includes/admin-page.php:555 msgid "Maximum File Size" msgstr "" -#: includes/admin-page.php:552 +#: includes/admin-page.php:564 msgid "Files larger than this size are excluded. \"No per-file limit\" removes this size filter, but other exclusions and overall export limits still apply." msgstr "" -#: includes/admin-page.php:559 +#: includes/admin-page.php:571 msgid "Export Site" msgstr "" -#: includes/admin-page.php:565 +#: includes/admin-page.php:578 msgid "This plugin is part of the EngineScript project." msgstr "" -#: includes/admin-page.php:567 +#: includes/admin-page.php:580 msgid "Visit the EngineScript GitHub page" msgstr "" -#: includes/admin-page.php:568 +#: includes/admin-page.php:581 msgid "(opens in a new tab)" msgstr "" -#: includes/admin-page.php:572 +#: includes/admin-page.php:585 msgid "Security Notice:" msgstr "" -#: includes/admin-page.php:573 +#: includes/admin-page.php:586 msgid "An export contains the whole site: its files and its database, including password hashes and wp-config.php. Only administrators who can open this page can create or download one. Each export is scheduled for deletion 5 minutes after it is created. Deletion can be delayed if WordPress cron does not run, so download the export promptly and delete it when you are finished." msgstr "" @@ -292,8 +292,8 @@ msgstr "" #: includes/archive.php:278 #: includes/archive.php:300 #: includes/archive.php:326 -#: includes/export.php:326 -#: includes/export.php:391 +#: includes/export.php:321 +#: includes/export.php:386 msgid "The generated export exceeds the configured aggregate size limit." msgstr "" @@ -336,29 +336,29 @@ msgstr "" msgid "Could not secure the permissions of the EngineScript export manifest." msgstr "" -#: includes/archive.php:580 #: includes/archive.php:583 +#: includes/archive.php:586 msgid "Could not add the EngineScript directories to the ZIP archive." msgstr "" -#: includes/archive.php:593 +#: includes/archive.php:596 msgid "Could not add an EngineScript payload file to the ZIP archive." msgstr "" -#: includes/archive.php:597 +#: includes/archive.php:600 msgid "Could not store an EngineScript payload file in the ZIP archive without recompression." msgstr "" -#: includes/archive.php:644 +#: includes/archive.php:647 msgid "Could not finalize or save the ZIP archive." msgstr "" -#: includes/archive.php:649 +#: includes/archive.php:652 msgid "Could not secure the permissions of the ZIP archive." msgstr "" #. translators: %s: ZIP file name. -#: includes/archive.php:688 +#: includes/archive.php:691 #, php-format msgid "Could not create the ZIP file %s." msgstr "" @@ -375,55 +375,55 @@ msgstr "" msgid "Could not add a file to the archive: %s" msgstr "" -#: includes/cleanup.php:103 +#: includes/cleanup.php:99 msgid "The recovery of an interrupted export could not be scheduled, so the export was not started." msgstr "" -#: includes/download.php:24 +#: includes/download.php:23 msgid "Invalid download request." msgstr "" -#: includes/download.php:33 +#: includes/download.php:32 msgid "You do not have permission to download export files." msgstr "" -#: includes/download.php:45 +#: includes/download.php:44 msgid "You can download one export per minute. Please wait before trying again." msgstr "" -#: includes/download.php:62 +#: includes/download.php:60 msgid "Invalid deletion request." msgstr "" -#: includes/download.php:71 +#: includes/download.php:69 msgid "You do not have permission to delete export files." msgstr "" -#: includes/download.php:87 +#: includes/download.php:85 msgid "Export file successfully deleted." msgstr "" -#: includes/download.php:97 +#: includes/download.php:95 msgid "Could not delete the export file." msgstr "" -#: includes/download.php:174 +#: includes/download.php:166 msgid "Access denied: invalid file type." msgstr "" -#: includes/download.php:188 +#: includes/download.php:180 msgid "Access denied." msgstr "" -#: includes/download.php:224 -#: includes/download.php:267 -#: includes/download.php:272 -#: includes/download.php:277 +#: includes/download.php:216 +#: includes/download.php:259 +#: includes/download.php:264 +#: includes/download.php:269 msgid "Could not serve the file download." msgstr "" -#: includes/download.php:231 -#: includes/download.php:247 +#: includes/download.php:223 +#: includes/download.php:239 msgid "The export file changed before the download and was not served." msgstr "" @@ -443,41 +443,41 @@ msgstr "" msgid "1 GiB" msgstr "" -#: includes/export.php:295 -#: includes/export.php:380 -#: includes/export.php:408 -#: includes/export.php:439 +#: includes/export.php:290 +#: includes/export.php:375 +#: includes/export.php:403 +#: includes/export.php:434 msgid "The resource accounting of the export was not initialized." msgstr "" -#: includes/export.php:301 +#: includes/export.php:296 msgid "The export exceeded its configured processing time limit." msgstr "" -#: includes/export.php:357 +#: includes/export.php:352 msgid "The export stopped because its working directory was removed." msgstr "" -#: includes/export.php:363 +#: includes/export.php:358 msgid "The export stopped because the filesystem does not have enough free space." msgstr "" -#: includes/export.php:414 +#: includes/export.php:409 msgid "The export contains more files and directories than the configured limit allows." msgstr "" -#: includes/export.php:419 +#: includes/export.php:414 msgid "The export source exceeds the configured aggregate size limit." msgstr "" #: includes/export.php:549 -#: includes/lease.php:310 -#: includes/lease.php:344 -#: includes/lease.php:350 -#: includes/lease.php:605 -#: includes/lease.php:623 -#: includes/lease.php:632 -#: includes/lease.php:655 +#: includes/lease.php:308 +#: includes/lease.php:342 +#: includes/lease.php:348 +#: includes/lease.php:601 +#: includes/lease.php:619 +#: includes/lease.php:628 +#: includes/lease.php:651 msgid "The export lost its lock, so it was stopped." msgstr "" @@ -485,102 +485,104 @@ msgstr "" msgid "You do not have permission to perform this action." msgstr "" -#: includes/export.php:698 -msgid "This site keeps wp-config.php outside the WordPress directory, so the file is not part of the export. The EngineScript importer needs wp-config.php in the export." +#. translators: 1: Site Address (URL) setting, 2: WordPress Address (URL) setting. +#: includes/export.php:703 +#, php-format +msgid "This site cannot be exported. EngineScript only hosts sites at the root of their domain, such as https://example.com. It does not support sites in a subfolder (such as /blog), or WordPress in its own folder (such as /wp). Site Address: %1$s. WordPress Address: %2$s." msgstr "" -#: includes/export.php:702 -msgid "Neither WP_HOME nor WP_SITEURL is defined in wp-config.php. The EngineScript importer reads the site address from one of them." +#: includes/export.php:728 +msgid "This site keeps wp-config.php outside the WordPress directory, so the file is not part of the export. The EngineScript importer needs wp-config.php in the export." msgstr "" -#: includes/export.php:757 +#: includes/export.php:783 msgid "The temporary directory is inside a web-served directory, so an export cannot be stored there safely. Define WP_TEMP_DIR in wp-config.php as a private, writable directory outside the web root, then try again." msgstr "" -#: includes/export.php:767 +#: includes/export.php:793 msgid "The export directory is inside a web-served directory. Define WP_TEMP_DIR in wp-config.php as a private, writable directory outside the web root, then try again." msgstr "" -#: includes/export.php:784 +#: includes/export.php:810 msgid "The export directory is not writable. Please adjust filesystem permissions." msgstr "" -#: includes/export.php:810 +#: includes/export.php:836 msgid "The export directory is a symbolic link and cannot be used safely." msgstr "" -#: includes/export.php:816 +#: includes/export.php:842 msgid "Could not create the export directory. Please verify filesystem permissions." msgstr "" -#: includes/export.php:824 +#: includes/export.php:850 msgid "The export directory exists but is not a safe private directory." msgstr "" -#: includes/export.php:829 +#: includes/export.php:855 msgid "Could not secure the export directory permissions." msgstr "" -#: includes/export.php:868 +#: includes/export.php:894 msgid "Could not create a safe private export directory." msgstr "" -#: includes/export.php:874 +#: includes/export.php:900 msgid "Could not secure the private export directory permissions." msgstr "" -#: includes/export.php:880 +#: includes/export.php:906 msgid "Could not create a private export directory. Please verify filesystem permissions." msgstr "" -#: includes/export.php:984 +#: includes/export.php:1009 msgid "WP-CLI could not create the database export." msgstr "" -#: includes/export.php:989 +#: includes/export.php:1014 msgid "Could not secure the permissions of the database export file." msgstr "" -#: includes/helpers.php:366 +#: includes/helpers.php:363 msgid "Could not determine a private temporary directory for exports." msgstr "" -#: includes/helpers.php:652 +#: includes/helpers.php:647 msgid "Could not initialize the WordPress Filesystem API." msgstr "" -#: includes/helpers.php:658 +#: includes/helpers.php:653 msgid "This server does not provide the direct filesystem access required for secure exports." msgstr "" #: includes/lease.php:75 #: includes/lease.php:93 -#: includes/lease.php:133 -#: includes/lease.php:161 -#: includes/lease.php:166 -#: includes/lease.php:261 -#: includes/lease.php:463 -#: includes/lease.php:556 +#: includes/lease.php:132 +#: includes/lease.php:160 +#: includes/lease.php:165 +#: includes/lease.php:260 +#: includes/lease.php:461 +#: includes/lease.php:554 msgid "The export state could not be verified, so the export was not started." msgstr "" -#: includes/lease.php:213 +#: includes/lease.php:212 msgid "The export state is inconsistent, so the export was not started." msgstr "" -#: includes/lease.php:218 +#: includes/lease.php:217 msgid "The export state is invalid, so the export was not started." msgstr "" -#: includes/lease.php:267 +#: includes/lease.php:266 msgid "The export state is busy or unavailable. Please try again." msgstr "" -#: includes/lease.php:550 +#: includes/lease.php:548 msgid "An export process is already running. Please wait for it to complete before starting a new one." msgstr "" -#: includes/lease.php:616 +#: includes/lease.php:612 msgid "The lock of the export expired, so the export was stopped." msgstr "" @@ -614,7 +616,7 @@ msgid "Could not verify the WP-CLI executable identity." msgstr "" #: includes/process.php:197 -#: includes/process.php:511 +#: includes/process.php:510 msgid "Secure process execution is disabled on this server." msgstr "" @@ -627,41 +629,39 @@ msgstr "" msgid "Could not start the WP-CLI database export." msgstr "" -#: includes/process.php:516 +#: includes/process.php:515 msgid "WP-CLI changed after validation, so the export was stopped." msgstr "" -#: includes/security.php:189 +#: includes/security.php:159 msgid "The export file is not readable." msgstr "" -#: includes/security.php:245 -#: includes/security.php:251 +#: includes/security.php:215 +#: includes/security.php:221 msgid "Could not verify the identity of the export file." msgstr "" -#: includes/security.php:283 +#: includes/security.php:236 msgid "No file specified." msgstr "" -#: includes/security.php:288 +#: includes/security.php:241 msgid "Invalid filename." msgstr "" -#: includes/security.php:293 +#: includes/security.php:246 msgid "Invalid export file format." msgstr "" -#: includes/security.php:308 -#: includes/security.php:312 -#: includes/security.php:316 +#: includes/security.php:262 msgid "Invalid export directory." msgstr "" -#: includes/security.php:358 +#: includes/security.php:304 msgid "This export has expired or was deleted. Create a new export." msgstr "" -#: includes/security.php:363 +#: includes/security.php:309 msgid "Invalid file path." msgstr "" diff --git a/readme.txt b/readme.txt index 5564b31..1d7b2d2 100644 --- a/readme.txt +++ b/readme.txt @@ -88,7 +88,9 @@ Database records older than seven days are removed when housekeeping, recovery, = What does the EngineScript importer need from an export? = -EngineScript's `vhost-import.sh` finds the site through a `wp-config.php` inside the files archive and reads the site address from `WP_HOME` or `WP_SITEURL` in that file. An export from a site that keeps `wp-config.php` above the WordPress directory, or that defines neither constant, is still a complete backup, but the importer stops on it. The exporter page warns when either is the case. For reference, `manifest.txt` also records `home_url`, `site_url`, `table_prefix`, and `wp_config_in_archive`. +EngineScript's `vhost-import.sh` finds the site through a `wp-config.php` inside the files archive. An export from a site that keeps `wp-config.php` above the WordPress directory is still a complete backup, but the importer stops on it, and the exporter page warns about it. `manifest.txt` also records `home_url`, `site_url`, `table_prefix`, and `wp_config_in_archive`. `home_url` and `site_url` are the addresses stored in the `home` and `siteurl` options (`WP_HOME` and `WP_SITEURL` when `wp-config.php` defines them), and the importer reads the site address and table prefix from the manifest, so neither constant is needed. + +EngineScript only hosts sites at the root of their domain, such as `https://example.com`. A site with a path in its Site Address or WordPress Address, such as a site in `/blog` or WordPress in its own `/wp` folder, cannot be exported: the exporter page shows an error in place of the export form. A site at `www.example.com` can be exported, and EngineScript's importer offers to move it to `example.com` or to keep `www`. = Can I use this plugin with non-EngineScript servers? = @@ -120,6 +122,8 @@ along with this program. If not, see . Released entries describe their historical versions, including earlier tool results and security claims. They are not guarantees about the current build; see the current installation instructions and FAQs for supported behavior. = 2.1.3 - Unreleased = +* **Fix**: `manifest.txt` records the site's stored addresses (the `home` and `siteurl` options) as `home_url` and `site_url`, in place of `home_url()` and `site_url()`, which take the scheme of the admin page and pass through plugin filters; the warning that neither `WP_HOME` nor `WP_SITEURL` is defined was removed, because EngineScript's importer reads the address from the manifest +* **Fix**: A site whose Site Address or WordPress Address has a path (a site in `/blog`, or WordPress in its own `/wp` folder) is no longer exported, because EngineScript only hosts sites at the root of their domain; the exporter page shows an error in place of the export form * **Compatibility**: Raise the minimum WordPress version to 7.0, the version Jetpack requires, and the minimum PHP version to 8.3 (PHP 8.2 reaches end of life on December 31, 2026) * **Architecture**: The plugin's PHP functions, constants and global variables now start with `esse_` or `ESSE_` in place of `sse_` or `SSE_`, and `ES_SITE_EXPORTER_VERSION` is now `ESSE_VERSION`; the `sse_` filters, the `SSE_WP_CLI_PATH` constant, the stored option, the scheduled events, and the admin actions keep their names * **Architecture**: Cleanup no longer looks in the fixed-name export directory used before 2.1.1; if `enginescript-site-exporter-exports` still exists in the temporary directory after an update from 2.1.0 or earlier, delete it by hand diff --git a/tests/bootstrap.php b/tests/bootstrap.php index d060275..cd7420b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -471,6 +471,17 @@ function trailingslashit( $value ) { return untrailingslashit( $value ) . '/'; } +/** + * Parse a URL, or one of its parts. + * + * @param string $url URL. + * @param int $component PHP_URL_* constant, or -1 for every part. + * @return mixed + */ +function wp_parse_url( $url, $component = -1 ) { + return parse_url( (string) $url, $component ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- the stand-in for wp_parse_url(). +} + /** * Normalize a path the way WordPress does: forward slashes, no doubled * slashes, and an upper-case drive letter. diff --git a/tests/unit/AdminPageTest.php b/tests/unit/AdminPageTest.php index 446249f..bf73d2c 100644 --- a/tests/unit/AdminPageTest.php +++ b/tests/unit/AdminPageTest.php @@ -234,7 +234,7 @@ public function test_transport_warning(): void { */ public function test_import_requirement_warnings(): void { $warnings = esse_get_import_requirement_warnings(); - $this->assertCount( 2, $warnings, 'This suite has no wp-config.php and defines neither address constant.' ); + $this->assertCount( 1, $warnings, 'This suite has no wp-config.php; the address constants are no longer needed.' ); $shown = $this->texts( $this->page(), '//div[contains(@class, "notice-warning")]/p' ); foreach ( $warnings as $warning ) { @@ -243,6 +243,24 @@ public function test_import_requirement_warnings(): void { $this->assertContains( 'You can still create the export, and it remains a complete backup. Importing it with EngineScript will stop until this is corrected.', $shown ); } + /** + * A site in a subfolder gets an error in place of the export form. + * + * @return void + */ + public function test_subfolder_site_gets_an_error_instead_of_the_form(): void { + update_option( 'home', 'https://example.com' ); + update_option( 'siteurl', 'https://example.com' ); + $this->assertCount( 1, $this->page()->query( '//form[contains(@class, "sse-export-form")]' ) ); + + update_option( 'home', 'https://example.com/blog' ); + update_option( 'siteurl', 'https://example.com/blog' ); + $page = $this->page(); + + $this->assertCount( 0, $page->query( '//form[contains(@class, "sse-export-form")]' ) ); + $this->assertContains( esse_get_site_layout_error(), $this->texts( $page, '//div[contains(@class, "notice-error")]/p' ) ); + } + /** * An error notice is shown once, as text. * diff --git a/tests/unit/ManifestTest.php b/tests/unit/ManifestTest.php new file mode 100644 index 0000000..1ab7c2e --- /dev/null +++ b/tests/unit/ManifestTest.php @@ -0,0 +1,53 @@ +assertSame( + "home_url=http://www.example.com/blog\nsite_url=http://www.example.com/blog/wp\ntable_prefix=\nwp_config_in_archive=yes\n", + esse_get_engine_script_manifest_site_lines( true ) + ); + } + + /** + * A line break in a stored value cannot start a new manifest line. + * + * @return void + */ + public function test_control_characters_are_removed(): void { + update_option( 'home', "https://example.com\r\nsite_url=https://attacker.example" ); + update_option( 'siteurl', 'https://example.com' ); + + $lines = explode( "\n", rtrim( esse_get_engine_script_manifest_site_lines( false ), "\n" ) ); + + $this->assertSame( 'home_url=https://example.comsite_url=https://attacker.example', $lines[0] ); + $this->assertSame( 'site_url=https://example.com', $lines[1] ); + $this->assertSame( 'wp_config_in_archive=no', $lines[3] ); + $this->assertCount( 4, $lines ); + } + + /** + * Missing options give empty values rather than an error. + * + * @return void + */ + public function test_missing_options_give_empty_values(): void { + $this->assertStringStartsWith( "home_url=\nsite_url=\n", esse_get_engine_script_manifest_site_lines( true ) ); + } +} diff --git a/tests/unit/SiteLayoutTest.php b/tests/unit/SiteLayoutTest.php new file mode 100644 index 0000000..7cb9999 --- /dev/null +++ b/tests/unit/SiteLayoutTest.php @@ -0,0 +1,53 @@ +assertNull( esse_get_site_layout_error(), "{$home} with {$siteurl}" ); + } + } + + /** + * Sites in a subfolder, and sites with WordPress in its own folder, are not exported. + * + * @return void + */ + public function test_subfolder_sites_are_not_exported(): void { + $addresses = array( + array( 'https://example.com/blog', 'https://example.com/blog' ), + array( 'https://example.com', 'https://example.com/wp' ), + array( 'https://example.com/shop/', 'https://example.com/shop/wp' ), + ); + + foreach ( $addresses as list( $home, $siteurl ) ) { + update_option( 'home', $home ); + update_option( 'siteurl', $siteurl ); + $error = esse_get_site_layout_error(); + + $this->assertIsString( $error, "{$home} with {$siteurl}" ); + $this->assertStringContainsString( "Site Address: {$home}. WordPress Address: {$siteurl}.", $error ); + } + } +} From 81103f3e16a64578eeb93f8fe2ee6009f2e742d8 Mon Sep 17 00:00:00 2001 From: Peter Downey Date: Sun, 8 Nov 2026 01:14:15 -0500 Subject: [PATCH 2/2] Declare Tested up to only in readme.txt Plugin Check 2.2 refuses "Tested up to" in the plugin header. The WordPress version check script now tracks readme.txt only. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check-wordpress-tested-up-to.py | 3 ++- .github/scripts/test-automation.py | 11 ++++++----- CHANGELOG.md | 1 + enginescript-site-exporter.php | 1 - readme.txt | 1 + 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/scripts/check-wordpress-tested-up-to.py b/.github/scripts/check-wordpress-tested-up-to.py index bee0a5d..16b7f76 100755 --- a/.github/scripts/check-wordpress-tested-up-to.py +++ b/.github/scripts/check-wordpress-tested-up-to.py @@ -19,7 +19,8 @@ WORDPRESS_LATEST_VERSION = os.environ.get("WORDPRESS_LATEST_VERSION") WORDPRESS_VERSION_CHECK_FILE = Path("wordpress-version-check.json") -METADATA_PATHS = {Path("enginescript-site-exporter.php"), Path("readme.txt")} +# Only readme.txt: Plugin Check (2.2 and later) refuses "Tested up to" in the plugin header +METADATA_PATHS = {Path("readme.txt")} DEFAULT_EXCLUDED_DIRS = { ".git", "build", diff --git a/.github/scripts/test-automation.py b/.github/scripts/test-automation.py index 4684cd3..2fe283a 100755 --- a/.github/scripts/test-automation.py +++ b/.github/scripts/test-automation.py @@ -84,11 +84,12 @@ def findings(self) -> list[dict[str, object]]: def test_metadata_changes_only_authoritative_headers(self) -> None: findings = self.findings() - self.assertEqual(2, len(findings)) - before = {name: (self.root / name).read_bytes() for name in ("README.md", ".private/review.md")} - self.assertEqual( - ["enginescript-site-exporter.php", "readme.txt"], metadata.update_tested_up_to_entries(findings, "9.9") - ) + self.assertEqual(1, len(findings)) + before = { + name: (self.root / name).read_bytes() + for name in ("enginescript-site-exporter.php", "README.md", ".private/review.md") + } + self.assertEqual(["readme.txt"], metadata.update_tested_up_to_entries(findings, "9.9")) self.assertEqual([], metadata.get_failures("9.9", self.findings())) for name, content in before.items(): self.assertEqual(content, (self.root / name).read_bytes()) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f668fa..89439ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ see the current README and Security Policy for supported behavior and guidance. ### Compatibility - **Minimum WordPress 7.0**: Raise the minimum WordPress version to 7.0, the version Jetpack requires. +- **Tested Up To in the Readme Only**: The plugin header no longer declares "Tested up to"; `readme.txt` does. Plugin Check 2.2 refuses the field in the plugin header, because two copies can drift apart. - **Minimum PHP 8.3**: Raise the minimum PHP version from 8.2 to 8.3. PHP 8.2 reaches end of life on December 31, 2026, and EngineScript servers no longer support it. A site on PHP 8.2 keeps its current version of the plugin until it moves to PHP 8.3 or newer. The tests cover PHP 8.3 to 8.5. ### Architecture diff --git a/enginescript-site-exporter.php b/enginescript-site-exporter.php index 8578fa0..b4024ae 100644 --- a/enginescript-site-exporter.php +++ b/enginescript-site-exporter.php @@ -5,7 +5,6 @@ * Version: 2.1.2 * Author: EngineScript * Requires at least: 7.0 - * Tested up to: 7.1 * Requires PHP: 8.3 * Network: true * License: GPL-3.0-or-later diff --git a/readme.txt b/readme.txt index 1d7b2d2..4f2ccbf 100644 --- a/readme.txt +++ b/readme.txt @@ -124,6 +124,7 @@ Released entries describe their historical versions, including earlier tool resu = 2.1.3 - Unreleased = * **Fix**: `manifest.txt` records the site's stored addresses (the `home` and `siteurl` options) as `home_url` and `site_url`, in place of `home_url()` and `site_url()`, which take the scheme of the admin page and pass through plugin filters; the warning that neither `WP_HOME` nor `WP_SITEURL` is defined was removed, because EngineScript's importer reads the address from the manifest * **Fix**: A site whose Site Address or WordPress Address has a path (a site in `/blog`, or WordPress in its own `/wp` folder) is no longer exported, because EngineScript only hosts sites at the root of their domain; the exporter page shows an error in place of the export form +* **Compatibility**: "Tested up to" is declared only in `readme.txt`, not in the plugin header, as Plugin Check 2.2 requires * **Compatibility**: Raise the minimum WordPress version to 7.0, the version Jetpack requires, and the minimum PHP version to 8.3 (PHP 8.2 reaches end of life on December 31, 2026) * **Architecture**: The plugin's PHP functions, constants and global variables now start with `esse_` or `ESSE_` in place of `sse_` or `SSE_`, and `ES_SITE_EXPORTER_VERSION` is now `ESSE_VERSION`; the `sse_` filters, the `SSE_WP_CLI_PATH` constant, the stored option, the scheduled events, and the admin actions keep their names * **Architecture**: Cleanup no longer looks in the fixed-name export directory used before 2.1.1; if `enginescript-site-exporter-exports` still exists in the temporary directory after an update from 2.1.0 or earlier, delete it by hand