PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 1.11.0 All 47 releases
thinkrank / includes / api / class-setup-wizard-endpoint.php

class-setup-wizard-endpoint.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO trunk, at includes/api/class-setup-wizard-endpoint.php

673 lines 23.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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