Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Blueprints] Prevent plugin activation error if plugin redirects during activation or produces an output #2066

Merged
merged 29 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6fed4da
Prevent plugin activation error if plugin redirects during activation
bgrgicak Dec 10, 2024
2ca6335
Merge branch 'trunk' into fix/1979-plugin-activation-fails-on-redirect
bgrgicak Dec 13, 2024
2f45f25
[Blueprints] Prevent WSOD when autologin is enabled and a plugin logs…
adamziel Dec 13, 2024
3d69181
Merge branch 'prevent-autologin-white-screen-of-death' into fix/1979-…
bgrgicak Dec 17, 2024
d1ee3c9
Use options to validate if a plugin is active
bgrgicak Dec 17, 2024
1747574
Explain why we had to split into two runs
bgrgicak Dec 17, 2024
75cd7b4
Merge branch 'trunk' into fix/1979-plugin-activation-fails-on-redirect
bgrgicak Dec 17, 2024
a46d412
Merge branch 'trunk' into fix/1979-plugin-activation-fails-on-redirect
bgrgicak Dec 17, 2024
7204067
Revert "[Blueprints] Prevent WSOD when autologin is enabled and a plu…
bgrgicak Dec 17, 2024
7aa4329
Always return activation errors
bgrgicak Dec 17, 2024
aaf683d
Merge branch 'trunk' into fix/1979-plugin-activation-fails-on-redirect
bgrgicak Dec 17, 2024
d255141
Merge branch 'trunk' into fix/1979-plugin-activation-fails-on-redirect
bgrgicak Dec 18, 2024
d351a63
Update packages/playground/blueprints/src/lib/steps/activate-plugin.ts
bgrgicak Dec 18, 2024
cf2a580
Remove plugin dir path only from the beginning of a plugin path
bgrgicak Dec 18, 2024
a3eccc6
Return error if plugin wasn't found
bgrgicak Dec 18, 2024
8627934
Update error message
bgrgicak Dec 18, 2024
fd4cb60
Fix linter errror
bgrgicak Dec 18, 2024
7414381
Move activate plugin error logging before is active check
bgrgicak Dec 18, 2024
2dd659e
Add test for double plugin activation
bgrgicak Dec 18, 2024
9b148b2
Ensure all supported plugin paths can be activated
bgrgicak Dec 18, 2024
622099d
Clear up is active check
bgrgicak Dec 19, 2024
d7e7827
Add trailing slash activation test
bgrgicak Dec 19, 2024
56cd875
Apply suggestions from code review
bgrgicak Dec 19, 2024
2efc8e2
Check if the plugin exists in the filesystem
bgrgicak Dec 19, 2024
dde935a
Check if the current relative plugin path is a directory before addin…
bgrgicak Dec 19, 2024
c0dd231
Use resolves.not.toThrow in tests
bgrgicak Dec 19, 2024
0c09fc0
Explain why a WP_Error during plugin activation doesn't mean the plug…
bgrgicak Dec 19, 2024
e27c6fb
Remove .php check
bgrgicak Dec 19, 2024
bb24b49
Use rtrim and only is_dir
bgrgicak Dec 19, 2024
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
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,45 @@ describe('Blueprint step activatePlugin()', () => {

expect(php.fileExists(createdFilePath)).toBe(true);
});

it('should succeed when the plugin redirects during activation', async () => {
const docroot = php.documentRoot;
php.writeFile(
`${docroot}/wp-content/plugins/test-plugin.php`,
`<?php
/**
* Plugin Name: Test Plugin
*/
add_action( 'activated_plugin', function( $plugin ) {
if( $plugin == plugin_basename( __FILE__ ) ) {
wp_redirect( admin_url( 'edit.php' ) );
exit();
}
} );
`
);
await expect(async () => {
await activatePlugin(php, {
pluginPath: 'test-plugin.php',
});
}).not.toThrow();
});

it('should activate the plugin if it produces output during activation', async () => {
const docroot = php.documentRoot;
php.writeFile(
`${docroot}/wp-content/plugins/test-plugin.php`,
`<?php
/**
* Plugin Name: Test Plugin
*/
echo 'Hello World';
`
);
await expect(async () => {
await activatePlugin(php, {
pluginPath: 'test-plugin.php',
});
}).not.toThrow();
});
});
58 changes: 48 additions & 10 deletions packages/playground/blueprints/src/lib/steps/activate-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { phpVar } from '@php-wasm/util';
import { StepHandler } from '.';
import { logger } from '@php-wasm/logger';

/**
* @inheritDoc activatePlugin
* @example
Expand Down Expand Up @@ -39,7 +38,7 @@ export const activatePlugin: StepHandler<ActivatePluginStep> = async (
progress?.tracker.setCaption(`Activating ${pluginName || pluginPath}`);

const docroot = await playground.documentRoot;
const result = await playground.run({
const activatePluginResult = await playground.run({
code: `<?php
define( 'WP_ADMIN', true );
require_once( ${phpVar(docroot)}. "/wp-load.php" );
Expand All @@ -65,22 +64,61 @@ export const activatePlugin: StepHandler<ActivatePluginStep> = async (
}
}

if ( null === $response ) {
die('Plugin activated successfully');
} else if ( is_wp_error( $response ) ) {
throw new Exception( $response->get_error_message() );
if (is_wp_error($response)) {
Copy link
Collaborator

@adamziel adamziel Dec 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would it mean when $response is null or false and how should we act in each of these cases? Just ignore it and rely on the check below? Also, why replace throw with die()?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When using throw a WP_Error caused by the plugin activation would cause PHP to fail with a 255 response code and we wouldn't be able to read the error message AFAIK.

We would also need to use try/catch for the activation code which would be less elegant, especially in a case where we don't consider all WP_Errors created by the the activation process to be errors for us.

More details about why we don't consider all WP_Errors to be activation errors are documented here https://github.com/WordPress/wordpress-playground/pull/2066/files/aaf683d47f401d18f9958a8437b7abe4f428d915#diff-4a02b123bb6311fff3ec089ca1ade75c75f1af35aaa4f1a717f14d444fc9b485R81

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would it mean when $response is null or false and how should we act in each of these cases? Just ignore it and rely on the check below?

null is success, and I deliberately left the response empty. Otherwise it would be printed to logs (or we would need to account for it in a if) and it doesn't provide any value.

false means the plugin couldn't be found by the activatePlugin step. I added a check for it now in 8627934. The error message I added is is different from the error message provided by WordPress when a plugin isn't found so that we will be able to distinguish between these two.

die($response->get_error_message());
}
`,
});

/**
* Instead of checking the plugin activation response,
* check if the plugin is active by looking at the active plugins list.
*
* We have to split the activation and the check into two PHP runs
* because some plugins might redirect during activation,
* which would prevent any output that happens after activation from being returned.
*
* Relying on the plugin activation response is not reliable because if the plugin activation
* produces any output, it will be threaded as an error.
* See WordPress source code for more details:
* https://github.com/WordPress/wordpress-develop/blob/6.7/src/wp-admin/includes/plugin.php#L733
*
* Because some plugins can create an output, we need to use output buffering
* to ensure the 'true' response is not polluted by other outputs.
* If the plugin activation fails, we will return the buffered output as it might
* contain more information about the failure.
*/
const isActiveCheckResult = await playground.run({
code: `<?php
ob_start();
require_once( ${phpVar(docroot)}. "/wp-load.php" );

throw new Exception( 'Unable to activate plugin' );
$relative_plugin_path = str_replace( ${phpVar(
docroot
)}. "/wp-content/plugins/", '', ${phpVar(pluginPath)} );
$active_plugins = get_option( 'active_plugins' );
foreach ( $active_plugins as $plugin ) {
if ( strpos( $plugin, $relative_plugin_path ) === 0 ) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would yield a false positive if one plugin is called gutenberg and another one is called gutenberg-beta. Let's account for that.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would this happen? We required absolute or relative plugin paths for plugin activation, not slugs.

gutenberg/gutenberg.php wouldn't result in true if gutenberg-beta/guternberg.php was active.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My suggestion for the whole section is to be more explicit on why you use str_replace vs substr or strpos vs ===. The former ones can be considered "fuzzy" while the latter ones are more precise. We should opt for the latter if possible and if not, explain why this is needed in a comment.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a working solution, but it's not clean. Let me think a bit more about it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I update the is active check to either match the full PLUGIN_DIR/PLUGIN.php pattern or PLUGIN_DIR/.
If it's a PLUGIN_DIR/ the trailing slash will ensure we are checking the full plugin directory name and not a partial name.

Let me know what you think of the implementation https://github.com/WordPress/wordpress-playground/pull/2066/files#diff-4a02b123bb6311fff3ec089ca1ade75c75f1af35aaa4f1a717f14d444fc9b485R101-R147

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking really good, thank you @bgrgicak! I left a few minor nitpicks, no major changes needed.

ob_end_clean();
die('true');
}
}
die(ob_get_flush() ?? 'false');
`,
});
if (result.text !== 'Plugin activated successfully') {
logger.debug(result);

if (activatePluginResult.text) {
logger.warn(activatePluginResult.text);
}
if (isActiveCheckResult.text !== 'true') {
if (isActiveCheckResult.text !== 'false') {
logger.debug(isActiveCheckResult.text);
}
throw new Error(
`Plugin ${pluginPath} could not be activated – WordPress exited with no error. ` +
`Sometimes, when $_SERVER or site options are not configured correctly, ` +
`WordPress exits early with a 301 redirect. ` +
`Inspect the "debug" logs in the console for more details`
`Inspect the "debug" logs in the console for more details.`
);
}
};
Loading