PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 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 All 50 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 2.9.0, at includes/api/class-setup-wizard-endpoint.php

684 lines 23.8 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', 'squirrly'];
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'])) {
445 return '';
446 }
447
448 // "Can ThinkRank answer this URL right now?" — not "is there a file?".
449 // Dynamic delivery serves the sitemap from PHP and writes nothing, so a
450 // file test alone hid the wizard's View Sitemap link on exactly the
451 // sites where the sitemap was working (#752).
452 $can_serve = 'dynamic' === $generator->resolve_delivery_mode($settings)
453 || $generator->primary_sitemap_file_exists($settings);
454
455 if (!$can_serve) {
456 return '';
457 }
458
459 return $generator->get_primary_sitemap_url($settings);
460 }
461
462 /**
463 * Install (and activate) the selected ecosystem plugins from WordPress.org.
464 *
465 * Best-effort: each plugin is attempted independently and its outcome is
466 * reported back. A single failure never aborts the others, so the wizard
467 * can always advance.
468 *
469 * @param WP_REST_Request $request Request object.
470 * @return WP_REST_Response
471 */
472 public function install_plugins(WP_REST_Request $request): WP_REST_Response {
473 $requested = (array) $request->get_param('slugs');
474 $results = [];
475
476 foreach ($requested as $wizard_slug) {
477 $wizard_slug = sanitize_key((string) $wizard_slug);
478
479 // Allowlist guard: silently drop anything we do not recognise.
480 if (!isset(self::ECOSYSTEM_PLUGINS[$wizard_slug])) {
481 continue;
482 }
483
484 $results[$wizard_slug] = $this->install_one_plugin(self::ECOSYSTEM_PLUGINS[$wizard_slug]);
485 }
486
487 return new WP_REST_Response([
488 'success' => true,
489 'results' => $results,
490 ], 200);
491 }
492
493 /**
494 * Install and activate a single WordPress.org plugin by its directory slug.
495 *
496 * @param string $wporg_slug WordPress.org plugin directory slug.
497 * @return array{status: string, message?: string} Outcome for this plugin.
498 */
499 private function install_one_plugin(string $wporg_slug): array {
500 require_once ABSPATH . 'wp-admin/includes/file.php';
501 require_once ABSPATH . 'wp-admin/includes/misc.php';
502 require_once ABSPATH . 'wp-admin/includes/plugin.php';
503 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
504 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
505
506 // Already installed? Find its main file and only activate if needed.
507 $plugin_file = $this->find_installed_plugin_file($wporg_slug);
508
509 if (null === $plugin_file) {
510 $api = plugins_api('plugin_information', [
511 'slug' => $wporg_slug,
512 'fields' => ['sections' => false],
513 ]);
514
515 if (is_wp_error($api) || empty($api->download_link)) {
516 return [
517 'status' => 'error',
518 'message' => is_wp_error($api)
519 ? $api->get_error_message()
520 : __('Plugin not found on WordPress.org.', 'thinkrank'),
521 ];
522 }
523
524 $skin = new \WP_Ajax_Upgrader_Skin();
525 $upgrader = new \Plugin_Upgrader($skin);
526 $result = $upgrader->install($api->download_link);
527
528 if (is_wp_error($result)) {
529 return ['status' => 'error', 'message' => $result->get_error_message()];
530 }
531
532 if (is_wp_error($skin->result)) {
533 return ['status' => 'error', 'message' => $skin->result->get_error_message()];
534 }
535
536 if (!$result) {
537 return [
538 'status' => 'error',
539 'message' => __('Plugin could not be installed (filesystem permissions?).', 'thinkrank'),
540 ];
541 }
542
543 $plugin_file = $upgrader->plugin_info();
544 }
545
546 if (empty($plugin_file)) {
547 return ['status' => 'error', 'message' => __('Could not locate the installed plugin.', 'thinkrank')];
548 }
549
550 if (is_plugin_active($plugin_file)) {
551 return ['status' => 'already_active'];
552 }
553
554 $activated = activate_plugin($plugin_file);
555
556 if (is_wp_error($activated)) {
557 return ['status' => 'installed', 'message' => $activated->get_error_message()];
558 }
559
560 return ['status' => 'activated'];
561 }
562
563 /**
564 * Locate the main plugin file of an already-installed plugin by directory slug.
565 *
566 * @param string $wporg_slug WordPress.org plugin directory slug.
567 * @return string|null Plugin file (e.g. "embedpress/embedpress.php") or null.
568 */
569 private function find_installed_plugin_file(string $wporg_slug): ?string {
570 $installed = get_plugins();
571
572 foreach (array_keys($installed) as $plugin_file) {
573 if (strpos((string) $plugin_file, $wporg_slug . '/') === 0) {
574 return $plugin_file;
575 }
576 }
577
578 return null;
579 }
580
581 /**
582 * Permission check for plugin installation.
583 *
584 * Requires both install and activate capabilities plus a valid nonce.
585 *
586 * @param WP_REST_Request $request Request object.
587 * @return bool|WP_Error
588 */
589 public function check_install_permissions(WP_REST_Request $request) {
590 if (!current_user_can('install_plugins') || !current_user_can('activate_plugins')) {
591 return new WP_Error(
592 'rest_forbidden',
593 __('You do not have permission to install plugins.', 'thinkrank'),
594 ['status' => 403]
595 );
596 }
597
598 if (!$this->verify_request_nonce($request)) {
599 return new WP_Error(
600 'rest_forbidden',
601 __('Invalid security token. Please refresh the page and try again.', 'thinkrank'),
602 ['status' => 403]
603 );
604 }
605
606 return true;
607 }
608
609 /**
610 * Permission check for deactivating a migrated source plugin.
611 *
612 * Requires the deactivate_plugins capability plus a valid nonce.
613 *
614 * @param WP_REST_Request $request Request object.
615 * @return bool|WP_Error
616 */
617 public function check_deactivate_permissions(WP_REST_Request $request) {
618 if (!current_user_can('deactivate_plugins')) {
619 return new WP_Error(
620 'rest_forbidden',
621 __('You do not have permission to deactivate plugins.', 'thinkrank'),
622 ['status' => 403]
623 );
624 }
625
626 if (!$this->verify_request_nonce($request)) {
627 return new WP_Error(
628 'rest_forbidden',
629 __('Invalid security token. Please refresh the page and try again.', 'thinkrank'),
630 ['status' => 403]
631 );
632 }
633
634 return true;
635 }
636
637 /**
638 * Permission check for read operations.
639 *
640 * @return bool|WP_Error
641 */
642 public function check_permissions() {
643 if (!current_user_can('manage_options')) {
644 return new WP_Error(
645 'rest_forbidden',
646 __('You do not have permission to access this endpoint.', 'thinkrank'),
647 ['status' => 403]
648 );
649 }
650 return true;
651 }
652
653 /**
654 * Permission check for state-changing wizard operations.
655 *
656 * These routes flip admin onboarding state and telemetry/tracking consent,
657 * so they require the admin capability (matching the GET state routes) in
658 * addition to CSRF verification — the shared edit_posts-level CSRF check let
659 * lower roles (e.g. Author) toggle consent and onboarding state.
660 *
661 * @param WP_REST_Request $request Request object
662 * @return bool|WP_Error
663 */
664 public function check_admin_csrf_permissions(WP_REST_Request $request) {
665 if (!current_user_can('manage_options')) {
666 return new WP_Error(
667 'rest_forbidden',
668 __('You do not have permission to perform this action.', 'thinkrank'),
669 ['status' => 403]
670 );
671 }
672
673 if (!$this->verify_request_nonce($request)) {
674 return new WP_Error(
675 'rest_forbidden',
676 __('Invalid security token. Please refresh the page and try again.', 'thinkrank'),
677 ['status' => 403]
678 );
679 }
680
681 return true;
682 }
683 }
684