| 1 |
<?php |
| 2 |
|
| 3 |
/** |
| 4 |
* Setup Wizard API Endpoint |
| 5 |
* |
| 6 |
* Thin state-only endpoint for the onboarding Setup Wizard. The actual SEO |
| 7 |
* settings saved by each step reuse the existing per-feature endpoints |
| 8 |
* (site-identity, sitemap, schema). This endpoint only tracks wizard progress |
| 9 |
* and completion. |
| 10 |
* |
| 11 |
* @package ThinkRank |
| 12 |
* @subpackage API |
| 13 |
* @since 1.0.0 |
| 14 |
*/ |
| 15 |
|
| 16 |
declare(strict_types=1); |
| 17 |
|
| 18 |
namespace ThinkRank\API; |
| 19 |
|
| 20 |
use ThinkRank\API\Traits\CSRF_Protection; |
| 21 |
use ThinkRank\Admin\Setup_Wizard; |
| 22 |
use ThinkRank\Core\SEO_Plugin_Detector; |
| 23 |
use WP_REST_Controller; |
| 24 |
use WP_REST_Request; |
| 25 |
use WP_REST_Response; |
| 26 |
use WP_Error; |
| 27 |
|
| 28 |
// Load CSRF Protection trait |
| 29 |
require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-csrf-protection.php'; |
| 30 |
|
| 31 |
// Prevent direct access |
| 32 |
if (!defined('ABSPATH')) { |
| 33 |
exit; |
| 34 |
} |
| 35 |
|
| 36 |
/** |
| 37 |
* Setup Wizard API Endpoint Class |
| 38 |
* |
| 39 |
* @since 1.0.0 |
| 40 |
*/ |
| 41 |
class Setup_Wizard_Endpoint extends WP_REST_Controller { |
| 42 |
use CSRF_Protection; |
| 43 |
|
| 44 |
/** |
| 45 |
* API namespace. |
| 46 |
* |
| 47 |
* @var string |
| 48 |
*/ |
| 49 |
protected $namespace = 'thinkrank/v1'; |
| 50 |
|
| 51 |
/** |
| 52 |
* API resource base. |
| 53 |
* |
| 54 |
* @var string |
| 55 |
*/ |
| 56 |
protected $rest_base = 'setup-wizard'; |
| 57 |
|
| 58 |
/** |
| 59 |
* Allowlist mapping wizard plugin slugs to their WordPress.org directory slugs. |
| 60 |
* |
| 61 |
* Only plugins in this map can be installed by the Ecosystem step. User input |
| 62 |
* is never used to install an arbitrary slug. |
| 63 |
* |
| 64 |
* @var array<string, string> |
| 65 |
*/ |
| 66 |
const ECOSYSTEM_PLUGINS = [ |
| 67 |
'schedulepress' => 'wp-scheduled-posts', |
| 68 |
'betterdocs' => 'betterdocs', |
| 69 |
'templately' => 'templately', |
| 70 |
'essential-addons' => 'essential-addons-for-elementor-lite', |
| 71 |
'essential-blocks' => 'essential-blocks', |
| 72 |
'notificationx' => 'notificationx', |
| 73 |
'better-payment' => 'better-payment', |
| 74 |
'easyjobs' => 'easyjobs', |
| 75 |
'betterlinks' => 'betterlinks', |
| 76 |
'embedpress' => 'embedpress', |
| 77 |
]; |
| 78 |
|
| 79 |
/** |
| 80 |
* Source-plugin slugs the wizard can record as migrated. Mirrors |
| 81 |
* Import_Controller::ALLOWED_PLUGINS so an arbitrary slug can never be stored. |
| 82 |
* |
| 83 |
* @var string[] |
| 84 |
*/ |
| 85 |
const MIGRATABLE_PLUGINS = ['yoast', 'rankmath', 'seopress', 'aioseo']; |
| 86 |
|
| 87 |
/** |
| 88 |
* Register API routes. |
| 89 |
* |
| 90 |
* @return void |
| 91 |
*/ |
| 92 |
public function register_routes(): void { |
| 93 |
register_rest_route( |
| 94 |
$this->namespace, |
| 95 |
'/' . $this->rest_base . '/state', |
| 96 |
[ |
| 97 |
[ |
| 98 |
'methods' => 'GET', |
| 99 |
'callback' => [$this, 'get_state'], |
| 100 |
'permission_callback' => [$this, 'check_permissions'], |
| 101 |
], |
| 102 |
] |
| 103 |
); |
| 104 |
|
| 105 |
register_rest_route( |
| 106 |
$this->namespace, |
| 107 |
'/' . $this->rest_base . '/migrated-site-data', |
| 108 |
[ |
| 109 |
[ |
| 110 |
'methods' => 'GET', |
| 111 |
'callback' => [$this, 'get_migrated_site_data'], |
| 112 |
'permission_callback' => [$this, 'check_permissions'], |
| 113 |
], |
| 114 |
] |
| 115 |
); |
| 116 |
|
| 117 |
register_rest_route( |
| 118 |
$this->namespace, |
| 119 |
'/' . $this->rest_base . '/migrated-plugins', |
| 120 |
[ |
| 121 |
[ |
| 122 |
'methods' => 'POST', |
| 123 |
'callback' => [$this, 'mark_plugin_migrated'], |
| 124 |
'permission_callback' => [$this, 'check_admin_csrf_permissions'], |
| 125 |
'args' => [ |
| 126 |
'plugin' => [ |
| 127 |
'required' => true, |
| 128 |
'type' => 'string', |
| 129 |
'enum' => self::MIGRATABLE_PLUGINS, |
| 130 |
'sanitize_callback' => 'sanitize_key', |
| 131 |
], |
| 132 |
], |
| 133 |
], |
| 134 |
] |
| 135 |
); |
| 136 |
|
| 137 |
register_rest_route( |
| 138 |
$this->namespace, |
| 139 |
'/' . $this->rest_base . '/deactivate-plugin', |
| 140 |
[ |
| 141 |
[ |
| 142 |
'methods' => 'POST', |
| 143 |
'callback' => [$this, 'deactivate_migrated_plugin'], |
| 144 |
'permission_callback' => [$this, 'check_deactivate_permissions'], |
| 145 |
'args' => [ |
| 146 |
'plugin' => [ |
| 147 |
'required' => true, |
| 148 |
'type' => 'string', |
| 149 |
'enum' => self::MIGRATABLE_PLUGINS, |
| 150 |
'sanitize_callback' => 'sanitize_key', |
| 151 |
], |
| 152 |
], |
| 153 |
], |
| 154 |
] |
| 155 |
); |
| 156 |
|
| 157 |
register_rest_route( |
| 158 |
$this->namespace, |
| 159 |
'/' . $this->rest_base . '/step', |
| 160 |
[ |
| 161 |
[ |
| 162 |
'methods' => 'POST', |
| 163 |
'callback' => [$this, 'save_step'], |
| 164 |
'permission_callback' => [$this, 'check_admin_csrf_permissions'], |
| 165 |
'args' => [ |
| 166 |
'step' => [ |
| 167 |
'required' => true, |
| 168 |
'type' => 'integer', |
| 169 |
'minimum' => 1, |
| 170 |
'maximum' => Setup_Wizard::TOTAL_STEPS, |
| 171 |
'sanitize_callback' => 'absint', |
| 172 |
], |
| 173 |
], |
| 174 |
], |
| 175 |
] |
| 176 |
); |
| 177 |
|
| 178 |
register_rest_route( |
| 179 |
$this->namespace, |
| 180 |
'/' . $this->rest_base . '/install-plugins', |
| 181 |
[ |
| 182 |
[ |
| 183 |
'methods' => 'POST', |
| 184 |
'callback' => [$this, 'install_plugins'], |
| 185 |
'permission_callback' => [$this, 'check_install_permissions'], |
| 186 |
'args' => [ |
| 187 |
'slugs' => [ |
| 188 |
'required' => true, |
| 189 |
'type' => 'array', |
| 190 |
'items' => ['type' => 'string'], |
| 191 |
], |
| 192 |
], |
| 193 |
], |
| 194 |
] |
| 195 |
); |
| 196 |
|
| 197 |
register_rest_route( |
| 198 |
$this->namespace, |
| 199 |
'/' . $this->rest_base . '/consent', |
| 200 |
[ |
| 201 |
[ |
| 202 |
'methods' => 'POST', |
| 203 |
'callback' => [$this, 'grant_tracking_consent'], |
| 204 |
'permission_callback' => [$this, 'check_admin_csrf_permissions'], |
| 205 |
], |
| 206 |
] |
| 207 |
); |
| 208 |
|
| 209 |
register_rest_route( |
| 210 |
$this->namespace, |
| 211 |
'/' . $this->rest_base . '/complete', |
| 212 |
[ |
| 213 |
[ |
| 214 |
'methods' => 'POST', |
| 215 |
'callback' => [$this, 'complete'], |
| 216 |
'permission_callback' => [$this, 'check_admin_csrf_permissions'], |
| 217 |
], |
| 218 |
] |
| 219 |
); |
| 220 |
} |
| 221 |
|
| 222 |
/** |
| 223 |
* Get wizard state. |
| 224 |
* |
| 225 |
* @param WP_REST_Request $request Request object. |
| 226 |
* @return WP_REST_Response |
| 227 |
*/ |
| 228 |
public function get_state(WP_REST_Request $request): WP_REST_Response { |
| 229 |
$started = (bool) get_option(Setup_Wizard::OPT_STARTED, false); |
| 230 |
|
| 231 |
return new WP_REST_Response([ |
| 232 |
'success' => true, |
| 233 |
'completed' => (bool) get_option(Setup_Wizard::OPT_COMPLETED, false), |
| 234 |
'started' => $started, |
| 235 |
'current_step' => Setup_Wizard::resolve_current_step($started), |
| 236 |
'total_steps' => Setup_Wizard::TOTAL_STEPS, |
| 237 |
], 200); |
| 238 |
} |
| 239 |
|
| 240 |
/** |
| 241 |
* Return SEO data migrated from another plugin, mapped to the Site Setup |
| 242 |
* step's form fields. |
| 243 |
* |
| 244 |
* The Migration step's import writes into ThinkRank's option-based stores |
| 245 |
* (e.g. {@see thinkrank_site_identity_settings}); this surfaces those values |
| 246 |
* so the Site Setup step can pre-fill them. Only keys with a migrated value |
| 247 |
* are returned, so the frontend never overrides a field with an empty value. |
| 248 |
* |
| 249 |
* @param WP_REST_Request $request Request object. |
| 250 |
* @return WP_REST_Response |
| 251 |
*/ |
| 252 |
public function get_migrated_site_data(WP_REST_Request $request): WP_REST_Response { |
| 253 |
$identity = get_option('thinkrank_site_identity_settings', []); |
| 254 |
$identity = is_array($identity) ? $identity : []; |
| 255 |
|
| 256 |
$settings = []; |
| 257 |
|
| 258 |
// The importer stores the brand/site name under `organization_name` and |
| 259 |
// the logo under `organization_logo`; map them onto the Site Setup form. |
| 260 |
if (!empty($identity['organization_name'])) { |
| 261 |
$settings['site_name'] = (string) $identity['organization_name']; |
| 262 |
} |
| 263 |
if (!empty($identity['organization_logo'])) { |
| 264 |
$settings['logo_url'] = (string) $identity['organization_logo']; |
| 265 |
} |
| 266 |
|
| 267 |
return new WP_REST_Response([ |
| 268 |
'success' => true, |
| 269 |
'settings' => $settings, |
| 270 |
], 200); |
| 271 |
} |
| 272 |
|
| 273 |
/** |
| 274 |
* Record that a source plugin has been migrated from inside the wizard. |
| 275 |
* |
| 276 |
* Wizard-only: the standalone Migration page never calls this and is |
| 277 |
* unaffected, so a user can still deliberately re-run a migration there. The |
| 278 |
* stored list disables the Import button (and shows "Imported") for that |
| 279 |
* plugin so it can't be imported twice during onboarding. Idempotent. |
| 280 |
* |
| 281 |
* @param WP_REST_Request $request Request object. |
| 282 |
* @return WP_REST_Response |
| 283 |
*/ |
| 284 |
public function mark_plugin_migrated(WP_REST_Request $request): WP_REST_Response { |
| 285 |
$plugin = sanitize_key((string) $request->get_param('plugin')); |
| 286 |
|
| 287 |
$migrated = get_option(Setup_Wizard::OPT_MIGRATED_PLUGINS, []); |
| 288 |
$migrated = is_array($migrated) ? $migrated : []; |
| 289 |
|
| 290 |
if (!in_array($plugin, $migrated, true)) { |
| 291 |
$migrated[] = $plugin; |
| 292 |
update_option(Setup_Wizard::OPT_MIGRATED_PLUGINS, array_values($migrated)); |
| 293 |
} |
| 294 |
|
| 295 |
return new WP_REST_Response([ |
| 296 |
'success' => true, |
| 297 |
'migrated_plugins' => array_values($migrated), |
| 298 |
], 200); |
| 299 |
} |
| 300 |
|
| 301 |
/** |
| 302 |
* Deactivate a source SEO plugin whose data was migrated inside the wizard. |
| 303 |
* |
| 304 |
* Wizard-only: the standalone Migration page never calls this, so importing |
| 305 |
* there never deactivates anything. Invoked when the user leaves the |
| 306 |
* Migration step via "Continue" so a source plugin isn't left running |
| 307 |
* alongside ThinkRank (duplicate meta/sitemaps/schema cause conflicts). |
| 308 |
* |
| 309 |
* The plugin file(s) — including premium companions — are resolved |
| 310 |
* server-side from the slug via {@see SEO_Plugin_Detector}, so no arbitrary |
| 311 |
* plugin path is ever passed to deactivate_plugins(). Best-effort: an |
| 312 |
* already-inactive plugin is a no-op success; a plugin that resists |
| 313 |
* deactivation returns success=false so the wizard can warn but still |
| 314 |
* advance. |
| 315 |
* |
| 316 |
* @param WP_REST_Request $request Request object. |
| 317 |
* @return WP_REST_Response |
| 318 |
*/ |
| 319 |
public function deactivate_migrated_plugin(WP_REST_Request $request): WP_REST_Response { |
| 320 |
$plugin = sanitize_key((string) $request->get_param('plugin')); |
| 321 |
$name = SEO_Plugin_Detector::get_plugin_name($plugin); |
| 322 |
$files = SEO_Plugin_Detector::get_deactivatable_files($plugin); |
| 323 |
|
| 324 |
// Nothing active to deactivate (already off or never installed) — treat |
| 325 |
// as a successful no-op so the wizard doesn't warn needlessly. |
| 326 |
if (empty($files)) { |
| 327 |
return new WP_REST_Response([ |
| 328 |
'success' => true, |
| 329 |
'deactivated' => false, |
| 330 |
'plugin' => $plugin, |
| 331 |
'name' => $name, |
| 332 |
], 200); |
| 333 |
} |
| 334 |
|
| 335 |
if (!function_exists('deactivate_plugins')) { |
| 336 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 337 |
} |
| 338 |
|
| 339 |
deactivate_plugins($files); |
| 340 |
|
| 341 |
// Confirm the plugin actually went inactive; if something re-activated it |
| 342 |
// (must-use loader, another plugin), report the failure so the UI can |
| 343 |
// tell the user to deactivate it manually. |
| 344 |
$still_active = array_values(array_filter($files, 'is_plugin_active')); |
| 345 |
if (!empty($still_active)) { |
| 346 |
return new WP_REST_Response([ |
| 347 |
'success' => false, |
| 348 |
'deactivated' => false, |
| 349 |
'plugin' => $plugin, |
| 350 |
'name' => $name, |
| 351 |
'message' => __('The plugin could not be deactivated automatically.', 'thinkrank'), |
| 352 |
], 200); |
| 353 |
} |
| 354 |
|
| 355 |
return new WP_REST_Response([ |
| 356 |
'success' => true, |
| 357 |
'deactivated' => true, |
| 358 |
'plugin' => $plugin, |
| 359 |
'name' => $name, |
| 360 |
], 200); |
| 361 |
} |
| 362 |
|
| 363 |
/** |
| 364 |
* Persist the current step (used to resume the wizard later). |
| 365 |
* |
| 366 |
* @param WP_REST_Request $request Request object. |
| 367 |
* @return WP_REST_Response |
| 368 |
*/ |
| 369 |
public function save_step(WP_REST_Request $request): WP_REST_Response { |
| 370 |
$step = (int) $request->get_param('step'); |
| 371 |
$step = max(1, min(Setup_Wizard::TOTAL_STEPS, $step)); |
| 372 |
|
| 373 |
update_option(Setup_Wizard::OPT_STEP, $step); |
| 374 |
|
| 375 |
return new WP_REST_Response([ |
| 376 |
'success' => true, |
| 377 |
'current_step' => $step, |
| 378 |
], 200); |
| 379 |
} |
| 380 |
|
| 381 |
/** |
| 382 |
* Record usage-tracking consent. |
| 383 |
* |
| 384 |
* Triggered when the user clicks "Get Started" on the first wizard step. |
| 385 |
* Delegates to the usage tracker manager which flags tracking allowed, |
| 386 |
* schedules the cron and suppresses the opt-in notice. Idempotent. |
| 387 |
* |
| 388 |
* @param WP_REST_Request $request Request object. |
| 389 |
* @return WP_REST_Response |
| 390 |
*/ |
| 391 |
public function grant_tracking_consent(WP_REST_Request $request): WP_REST_Response { |
| 392 |
// "Get Started" permanently dismisses the Start step. Persist this before |
| 393 |
// anything else so a direct URL hit or refresh can never return to Start. |
| 394 |
update_option(Setup_Wizard::OPT_STARTED, true); |
| 395 |
|
| 396 |
$manager = function_exists('thinkrank') ? thinkrank()->get_component('usage_tracker') : null; |
| 397 |
|
| 398 |
if ($manager instanceof \ThinkRank\Core\Usage_Tracker_Manager) { |
| 399 |
$manager->grant_consent(); |
| 400 |
} |
| 401 |
|
| 402 |
return new WP_REST_Response(['success' => true], 200); |
| 403 |
} |
| 404 |
|
| 405 |
/** |
| 406 |
* Mark the wizard as completed. |
| 407 |
* |
| 408 |
* Idempotent: safe to call multiple times. |
| 409 |
* |
| 410 |
* @param WP_REST_Request $request Request object. |
| 411 |
* @return WP_REST_Response |
| 412 |
*/ |
| 413 |
public function complete(WP_REST_Request $request): WP_REST_Response { |
| 414 |
update_option(Setup_Wizard::OPT_COMPLETED, true); |
| 415 |
delete_option(Setup_Wizard::OPT_STEP); |
| 416 |
|
| 417 |
return new WP_REST_Response([ |
| 418 |
'success' => true, |
| 419 |
'redirect' => admin_url('admin.php?page=thinkrank'), |
| 420 |
'sitemap_url' => $this->get_sitemap_url(), |
| 421 |
], 200); |
| 422 |
} |
| 423 |
|
| 424 |
/** |
| 425 |
* URL of the sitemap the site currently publishes, if any. |
| 426 |
* |
| 427 |
* Resolved here rather than reused from the page-load config because the |
| 428 |
* wizard is a single page load: a migration on the Migration step can switch |
| 429 |
* the site to an index sitemap, which changes the filename. Empty when the |
| 430 |
* sitemap is disabled or no file was written, so the final step can hide the |
| 431 |
* "View Sitemap" link instead of pointing at a URL WordPress core answers |
| 432 |
* with its own wp-sitemap.xml. |
| 433 |
* |
| 434 |
* @return string Sitemap URL, or an empty string when none is published. |
| 435 |
*/ |
| 436 |
private function get_sitemap_url(): string { |
| 437 |
if (!class_exists('ThinkRank\\SEO\\Sitemap_Generator')) { |
| 438 |
return ''; |
| 439 |
} |
| 440 |
|
| 441 |
$generator = new \ThinkRank\SEO\Sitemap_Generator(); |
| 442 |
$settings = $generator->get_settings('site'); |
| 443 |
|
| 444 |
if (empty($settings['enabled']) || !$generator->primary_sitemap_file_exists($settings)) { |
| 445 |
return ''; |
| 446 |
} |
| 447 |
|
| 448 |
return $generator->get_primary_sitemap_url($settings); |
| 449 |
} |
| 450 |
|
| 451 |
/** |
| 452 |
* Install (and activate) the selected ecosystem plugins from WordPress.org. |
| 453 |
* |
| 454 |
* Best-effort: each plugin is attempted independently and its outcome is |
| 455 |
* reported back. A single failure never aborts the others, so the wizard |
| 456 |
* can always advance. |
| 457 |
* |
| 458 |
* @param WP_REST_Request $request Request object. |
| 459 |
* @return WP_REST_Response |
| 460 |
*/ |
| 461 |
public function install_plugins(WP_REST_Request $request): WP_REST_Response { |
| 462 |
$requested = (array) $request->get_param('slugs'); |
| 463 |
$results = []; |
| 464 |
|
| 465 |
foreach ($requested as $wizard_slug) { |
| 466 |
$wizard_slug = sanitize_key((string) $wizard_slug); |
| 467 |
|
| 468 |
// Allowlist guard: silently drop anything we do not recognise. |
| 469 |
if (!isset(self::ECOSYSTEM_PLUGINS[$wizard_slug])) { |
| 470 |
continue; |
| 471 |
} |
| 472 |
|
| 473 |
$results[$wizard_slug] = $this->install_one_plugin(self::ECOSYSTEM_PLUGINS[$wizard_slug]); |
| 474 |
} |
| 475 |
|
| 476 |
return new WP_REST_Response([ |
| 477 |
'success' => true, |
| 478 |
'results' => $results, |
| 479 |
], 200); |
| 480 |
} |
| 481 |
|
| 482 |
/** |
| 483 |
* Install and activate a single WordPress.org plugin by its directory slug. |
| 484 |
* |
| 485 |
* @param string $wporg_slug WordPress.org plugin directory slug. |
| 486 |
* @return array{status: string, message?: string} Outcome for this plugin. |
| 487 |
*/ |
| 488 |
private function install_one_plugin(string $wporg_slug): array { |
| 489 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 490 |
require_once ABSPATH . 'wp-admin/includes/misc.php'; |
| 491 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 492 |
require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; |
| 493 |
require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; |
| 494 |
|
| 495 |
// Already installed? Find its main file and only activate if needed. |
| 496 |
$plugin_file = $this->find_installed_plugin_file($wporg_slug); |
| 497 |
|
| 498 |
if (null === $plugin_file) { |
| 499 |
$api = plugins_api('plugin_information', [ |
| 500 |
'slug' => $wporg_slug, |
| 501 |
'fields' => ['sections' => false], |
| 502 |
]); |
| 503 |
|
| 504 |
if (is_wp_error($api) || empty($api->download_link)) { |
| 505 |
return [ |
| 506 |
'status' => 'error', |
| 507 |
'message' => is_wp_error($api) |
| 508 |
? $api->get_error_message() |
| 509 |
: __('Plugin not found on WordPress.org.', 'thinkrank'), |
| 510 |
]; |
| 511 |
} |
| 512 |
|
| 513 |
$skin = new \WP_Ajax_Upgrader_Skin(); |
| 514 |
$upgrader = new \Plugin_Upgrader($skin); |
| 515 |
$result = $upgrader->install($api->download_link); |
| 516 |
|
| 517 |
if (is_wp_error($result)) { |
| 518 |
return ['status' => 'error', 'message' => $result->get_error_message()]; |
| 519 |
} |
| 520 |
|
| 521 |
if (is_wp_error($skin->result)) { |
| 522 |
return ['status' => 'error', 'message' => $skin->result->get_error_message()]; |
| 523 |
} |
| 524 |
|
| 525 |
if (!$result) { |
| 526 |
return [ |
| 527 |
'status' => 'error', |
| 528 |
'message' => __('Plugin could not be installed (filesystem permissions?).', 'thinkrank'), |
| 529 |
]; |
| 530 |
} |
| 531 |
|
| 532 |
$plugin_file = $upgrader->plugin_info(); |
| 533 |
} |
| 534 |
|
| 535 |
if (empty($plugin_file)) { |
| 536 |
return ['status' => 'error', 'message' => __('Could not locate the installed plugin.', 'thinkrank')]; |
| 537 |
} |
| 538 |
|
| 539 |
if (is_plugin_active($plugin_file)) { |
| 540 |
return ['status' => 'already_active']; |
| 541 |
} |
| 542 |
|
| 543 |
$activated = activate_plugin($plugin_file); |
| 544 |
|
| 545 |
if (is_wp_error($activated)) { |
| 546 |
return ['status' => 'installed', 'message' => $activated->get_error_message()]; |
| 547 |
} |
| 548 |
|
| 549 |
return ['status' => 'activated']; |
| 550 |
} |
| 551 |
|
| 552 |
/** |
| 553 |
* Locate the main plugin file of an already-installed plugin by directory slug. |
| 554 |
* |
| 555 |
* @param string $wporg_slug WordPress.org plugin directory slug. |
| 556 |
* @return string|null Plugin file (e.g. "embedpress/embedpress.php") or null. |
| 557 |
*/ |
| 558 |
private function find_installed_plugin_file(string $wporg_slug): ?string { |
| 559 |
$installed = get_plugins(); |
| 560 |
|
| 561 |
foreach (array_keys($installed) as $plugin_file) { |
| 562 |
if (strpos((string) $plugin_file, $wporg_slug . '/') === 0) { |
| 563 |
return $plugin_file; |
| 564 |
} |
| 565 |
} |
| 566 |
|
| 567 |
return null; |
| 568 |
} |
| 569 |
|
| 570 |
/** |
| 571 |
* Permission check for plugin installation. |
| 572 |
* |
| 573 |
* Requires both install and activate capabilities plus a valid nonce. |
| 574 |
* |
| 575 |
* @param WP_REST_Request $request Request object. |
| 576 |
* @return bool|WP_Error |
| 577 |
*/ |
| 578 |
public function check_install_permissions(WP_REST_Request $request) { |
| 579 |
if (!current_user_can('install_plugins') || !current_user_can('activate_plugins')) { |
| 580 |
return new WP_Error( |
| 581 |
'rest_forbidden', |
| 582 |
__('You do not have permission to install plugins.', 'thinkrank'), |
| 583 |
['status' => 403] |
| 584 |
); |
| 585 |
} |
| 586 |
|
| 587 |
if (!$this->verify_request_nonce($request)) { |
| 588 |
return new WP_Error( |
| 589 |
'rest_forbidden', |
| 590 |
__('Invalid security token. Please refresh the page and try again.', 'thinkrank'), |
| 591 |
['status' => 403] |
| 592 |
); |
| 593 |
} |
| 594 |
|
| 595 |
return true; |
| 596 |
} |
| 597 |
|
| 598 |
/** |
| 599 |
* Permission check for deactivating a migrated source plugin. |
| 600 |
* |
| 601 |
* Requires the deactivate_plugins capability plus a valid nonce. |
| 602 |
* |
| 603 |
* @param WP_REST_Request $request Request object. |
| 604 |
* @return bool|WP_Error |
| 605 |
*/ |
| 606 |
public function check_deactivate_permissions(WP_REST_Request $request) { |
| 607 |
if (!current_user_can('deactivate_plugins')) { |
| 608 |
return new WP_Error( |
| 609 |
'rest_forbidden', |
| 610 |
__('You do not have permission to deactivate plugins.', 'thinkrank'), |
| 611 |
['status' => 403] |
| 612 |
); |
| 613 |
} |
| 614 |
|
| 615 |
if (!$this->verify_request_nonce($request)) { |
| 616 |
return new WP_Error( |
| 617 |
'rest_forbidden', |
| 618 |
__('Invalid security token. Please refresh the page and try again.', 'thinkrank'), |
| 619 |
['status' => 403] |
| 620 |
); |
| 621 |
} |
| 622 |
|
| 623 |
return true; |
| 624 |
} |
| 625 |
|
| 626 |
/** |
| 627 |
* Permission check for read operations. |
| 628 |
* |
| 629 |
* @return bool|WP_Error |
| 630 |
*/ |
| 631 |
public function check_permissions() { |
| 632 |
if (!current_user_can('manage_options')) { |
| 633 |
return new WP_Error( |
| 634 |
'rest_forbidden', |
| 635 |
__('You do not have permission to access this endpoint.', 'thinkrank'), |
| 636 |
['status' => 403] |
| 637 |
); |
| 638 |
} |
| 639 |
return true; |
| 640 |
} |
| 641 |
|
| 642 |
/** |
| 643 |
* Permission check for state-changing wizard operations. |
| 644 |
* |
| 645 |
* These routes flip admin onboarding state and telemetry/tracking consent, |
| 646 |
* so they require the admin capability (matching the GET state routes) in |
| 647 |
* addition to CSRF verification — the shared edit_posts-level CSRF check let |
| 648 |
* lower roles (e.g. Author) toggle consent and onboarding state. |
| 649 |
* |
| 650 |
* @param WP_REST_Request $request Request object |
| 651 |
* @return bool|WP_Error |
| 652 |
*/ |
| 653 |
public function check_admin_csrf_permissions(WP_REST_Request $request) { |
| 654 |
if (!current_user_can('manage_options')) { |
| 655 |
return new WP_Error( |
| 656 |
'rest_forbidden', |
| 657 |
__('You do not have permission to perform this action.', 'thinkrank'), |
| 658 |
['status' => 403] |
| 659 |
); |
| 660 |
} |
| 661 |
|
| 662 |
if (!$this->verify_request_nonce($request)) { |
| 663 |
return new WP_Error( |
| 664 |
'rest_forbidden', |
| 665 |
__('Invalid security token. Please refresh the page and try again.', 'thinkrank'), |
| 666 |
['status' => 403] |
| 667 |
); |
| 668 |
} |
| 669 |
|
| 670 |
return true; |
| 671 |
} |
| 672 |
} |
| 673 |
|