PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.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 All 51 releases
thinkrank / includes / core / class-activator.php

class-activator.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.10.0, at includes/core/class-activator.php

465 lines 17.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Plugin Activator Class
5 *
6 * Handles plugin activation tasks
7 *
8 * @package ThinkRank\Core
9 * @since 1.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\Core;
15
16 use ThinkRank\Database\Database_Schema;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * Activator Class
25 *
26 * Single Responsibility: Handle plugin activation tasks only
27 *
28 * @since 1.0.0
29 */
30 class Activator {
31
32 /**
33 * Option the uninstaller sets to record a deliberate removal.
34 *
35 * Pro's Free_Plugin_Installer skips its silent auto-install while this is
36 * set, so activating again — the user asking for the plugin back — has to
37 * clear it. Keep in sync with uninstall.php.
38 */
39 public const UNINSTALLED_OPTION = 'thinkrank_uninstalled';
40
41 /**
42 * Plugin activation tasks
43 *
44 * @return void
45 * @throws \Exception If activation fails
46 */
47 public function activate(): void {
48 delete_option(self::UNINSTALLED_OPTION);
49
50 $this->check_requirements();
51 $this->create_database_tables();
52 // Both must precede set_default_options(): they read
53 // `thinkrank_version`, which that method creates.
54 $this->retire_sitemap_legacy_fallback();
55 $this->seed_feed_defaults();
56 $this->skip_key_features_migration();
57 $this->set_default_options();
58 $this->setup_indexnow_key();
59 $this->schedule_cron_jobs();
60 $this->restore_webroot_artifacts();
61 $this->set_activation_flag();
62
63 // Grant the admin capabilities here rather than waiting for the `init`
64 // hook Role_Manager registers, so the menu is reachable on the very
65 // first admin request after activation.
66 Capability_Manager::ensure();
67 }
68
69 /**
70 * Setup IndexNow API Key
71 *
72 * Generates a unique 128-bit key and creates the key file in the root directory.
73 *
74 * @return void
75 */
76 private function setup_indexnow_key(): void {
77 $option_name = 'thinkrank_instant_indexing_settings';
78 $settings = get_option($option_name, []);
79
80 // Check if key exists
81 if (empty($settings['api_key'])) {
82 try {
83 // Generate 128-bit key (32 hex characters)
84 // Using bin2hex(random_bytes(16)) as requested
85 $key = bin2hex(random_bytes(16));
86
87 // Save to options
88 $settings['api_key'] = $key;
89
90 // Initialize default post types if not set
91 if (!isset($settings['auto_submit_post_types'])) {
92 $settings['auto_submit_post_types'] = ['post', 'page'];
93 }
94
95 update_option($option_name, $settings);
96
97 // Create the key file in WordPress root using WP_Filesystem
98 $file_path = ABSPATH . $key . '.txt';
99 global $wp_filesystem;
100 if (!function_exists('WP_Filesystem')) {
101 require_once ABSPATH . 'wp-admin/includes/file.php';
102 }
103 WP_Filesystem();
104 if ($wp_filesystem && $wp_filesystem->is_writable(ABSPATH)) {
105 $wp_filesystem->put_contents($file_path, $key, FS_CHMOD_FILE);
106 }
107 } catch (\Exception $e) {
108 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
109 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
110 error_log('ThinkRank: Failed to create IndexNow key file: ' . $e->getMessage());
111 }
112 }
113 }
114 }
115
116 /**
117 * Check system requirements
118 *
119 * @return void
120 * @throws \Exception If requirements not met
121 */
122 private function check_requirements(): void {
123 // PHP version check
124 if (version_compare(PHP_VERSION, '7.4', '<')) {
125 throw new \Exception('ThinkRank requires PHP 7.4 or higher');
126 }
127
128 // WordPress version check
129 if (version_compare(get_bloginfo('version'), '6.0', '<')) {
130 throw new \Exception('ThinkRank requires WordPress 6.0 or higher');
131 }
132
133 // Required PHP extensions
134 $required_extensions = ['curl', 'json', 'mbstring'];
135 foreach ($required_extensions as $extension) {
136 if (!extension_loaded($extension)) {
137 throw new \Exception(sprintf("Required PHP extension '%s' is not loaded", esc_html($extension)));
138 }
139 }
140
141 // Check if we can write to WordPress root directory (for robots.txt, llms.txt,
142 // the Instant Indexing key file). Never blocks activation — some hosts restrict
143 // ABSPATH writes by design.
144 //
145 // The result is recorded rather than only logged. It used to reach error_log()
146 // and only under WP_DEBUG, so on a production site nobody was ever told, and the
147 // features that need it failed later with messages describing the symptom rather
148 // than the cause (#753). Webroot_Writable_Notice re-evaluates the condition live —
149 // permissions change without a reactivation — and this value only distinguishes
150 // "never worked here" from "worked until the host changed something".
151 $writable = wp_is_writable(ABSPATH);
152
153 update_option(
154 \ThinkRank\Admin\Webroot_Writable_Notice::OPT_ACTIVATION_STATE,
155 $writable ? 'writable' : 'not-writable',
156 false
157 );
158
159 if (!$writable) {
160 // A fresh activation on a broken root should warn even if a previous
161 // install's dismissal is still on record.
162 delete_option(\ThinkRank\Admin\Webroot_Writable_Notice::OPT_DISMISSED);
163
164 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
165 // Not a fixed feature list: robots.txt, llms.txt and the Instant
166 // Indexing key all have a PHP path, so naming them as broken
167 // was untrue since #756. Webroot_Writable_Notice works out
168 // what, if anything, is actually affected.
169 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
170 error_log('ThinkRank: WordPress root directory is not writable. Features on Automatic delivery are served from WordPress; any feature explicitly set to write files cannot publish them.');
171 }
172 }
173 }
174
175 /**
176 * Create database tables
177 *
178 * Uses the consolidated Database_Schema class to create all 11 ThinkRank tables:
179 * - SEO Tables (7): Settings, Analysis, Keywords, Schema, Social, Performance, Local
180 * - AI/Core Tables (4): AI Cache, AI Usage, Content Briefs, SEO Scores
181 *
182 * @return void
183 * @throws \Exception If table creation fails
184 */
185 private function create_database_tables(): void {
186 try {
187 // Use the Database_Schema class to create all 11 ThinkRank tables
188 $schema = new Database_Schema();
189 $results = $schema->create_tables();
190
191 if (!$results['success']) {
192 $error_message = 'Failed to create database tables: ' . implode(', ', $results['errors']);
193 throw new \Exception($error_message);
194 }
195
196 // Add performance indexes for Phase 2 optimization
197 // Best effort: activation continues if the performance indexes
198 // cannot be created.
199 $schema->add_performance_indexes();
200
201 // Database tables created successfully
202
203 } catch (\Exception $e) {
204 throw new \Exception('Database table creation failed: ' . esc_html($e->getMessage()));
205 }
206 }
207
208
209
210 /**
211 * On a brand-new install, close the pre-2.1.1 sitemap ownership fallback
212 * before it can ever open.
213 *
214 * {@see thinkrank_webroot_sitemap_is_ours()} keeps one narrow escape hatch:
215 * a sitemap written before 2.1.1 with `enable_styling` off carries neither
216 * the marker nor our XSL href, so it can only be recognised by the name the
217 * stored settings derive. That fallback is gated on this install never
218 * having written a marked sitemap — but "never written one" describes two
219 * completely different sites:
220 *
221 * - a pre-2.1.1 install that has not regenerated since upgrading, which
222 * is exactly what the fallback exists to recover; and
223 * - a fresh install that simply has not generated yet, which cannot have
224 * a legacy file of ours on disk at all.
225 *
226 * On the second, the fallback has nothing to recover and can only delete
227 * somebody else's sitemap from one of the canonical names — #515 again, in
228 * a site that never had the problem the fallback addresses. It is not a
229 * narrow window either: `regenerate_sitemap_from_settings()` returns early
230 * while the master `enabled` flag is off, so a site with sitemaps disabled
231 * and styling saved off never records a marked write, and stays exposed for
232 * as long as it stays in that configuration.
233 *
234 * Recording the marker here on a fresh install separates the two cases. An
235 * upgrade does not reach this code — WordPress does not re-run the
236 * activation hook on update — so a genuine pre-2.1.1 site keeps the
237 * fallback until its first marked write, exactly as before.
238 *
239 * `thinkrank_version` is the signal: set_default_options() adds it only
240 * when absent and never updates it, so it is missing on the very first
241 * activation and present on every one after.
242 *
243 * @since 2.1.1
244 *
245 * @return void
246 */
247 private function retire_sitemap_legacy_fallback(): void {
248 if (get_option('thinkrank_version') !== false) {
249 return;
250 }
251
252 require_once THINKRANK_PLUGIN_DIR . 'includes/cleanup-webroot.php';
253
254 add_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION, '1', '', false);
255 }
256
257 /**
258 * Give a brand-new install the feed posture the competitors ship with.
259 *
260 * The feed controls (#635) default to off in
261 * {@see Site_Identity_Manager::get_default_settings()}, and they have to:
262 * two of the three change what a site already publishes. Signing every
263 * entry adds a line to what existing subscribers receive, and noindexing
264 * feeds withdraws URLs a site may have had indexed for years — on a podcast
265 * site, whose feed has to stay indexable, silently at that. Neither belongs
266 * in a plugin update.
267 *
268 * A first install has no subscribers and no indexed feed, so there is
269 * nothing to change and the protective defaults are simply the right
270 * starting point — which is what The SEO Framework, Yoast and Rank Math all
271 * ship. Seeding them here rather than in the defaults is what separates the
272 * two cases.
273 *
274 * Excerpt-only is left off even here: it changes what readers get rather
275 * than what scrapers can take, and that is the site owner's call.
276 *
277 * Same signal and same reasoning as {@see self::retire_sitemap_legacy_fallback()}:
278 * `thinkrank_version` is absent only on the very first activation, and an
279 * upgrade does not re-run the activation hook at all.
280 *
281 * @since 2.7.0
282 *
283 * @return void
284 */
285 private function seed_feed_defaults(): void {
286 if (get_option('thinkrank_version') !== false) {
287 return;
288 }
289
290 $manager = new \ThinkRank\SEO\Site_Identity_Manager();
291
292 $manager->save_settings(
293 'site',
294 null,
295 [
296 'feed_source_link' => true,
297 'feed_noindex' => true,
298 ]
299 );
300 }
301
302 /**
303 * Mark the llms.txt Key Features migration done on a fresh install.
304 *
305 * {@see \ThinkRank\SEO\LLMs_Txt_Manager::maybe_migrate_legacy_key_features()}
306 * converts a value saved while commas separated features. A brand-new
307 * install never saved one, so anything it stores later follows the
308 * one-per-line rule and must not be split on its commas by a migration
309 * that runs after the user typed it.
310 *
311 * Same signal as {@see self::seed_feed_defaults()}: `thinkrank_version` is
312 * absent only on the very first activation.
313 *
314 * @since 2.10.0
315 *
316 * @return void
317 */
318 private function skip_key_features_migration(): void {
319 if (get_option('thinkrank_version') !== false) {
320 return;
321 }
322
323 add_option(
324 \ThinkRank\SEO\LLMs_Txt_Manager::KEY_FEATURES_MIGRATION_OPTION,
325 \ThinkRank\SEO\LLMs_Txt_Manager::KEY_FEATURES_MIGRATION_VERSION
326 );
327 }
328
329 /**
330 * Set default plugin options
331 *
332 * @return void
333 */
334 private function set_default_options(): void {
335 $default_options = [
336 'thinkrank_version' => THINKRANK_VERSION,
337
338 'thinkrank_ai_provider' => \ThinkRank\Core\Settings::AI_PROVIDER_NONE,
339 'thinkrank_cache_duration' => 3600, // 1 hour
340 'thinkrank_max_requests_per_minute' => 0,
341 'thinkrank_enable_logging' => true,
342 'thinkrank_auto_optimize' => false,
343 'thinkrank_seo_score_threshold' => 70,
344 ];
345
346 foreach ($default_options as $option_name => $option_value) {
347 if (get_option($option_name) === false) {
348 add_option($option_name, $option_value);
349 }
350 }
351 }
352
353
354 /**
355 * Republish the web-root artifacts deactivation took away.
356 *
357 * Deactivation removes the published sitemap, robots.txt and llms.txt so an
358 * inactive ThinkRank stops shadowing whatever the user switched to (#510).
359 * That is only safe if switching the plugin back on puts them back, which is
360 * what this does.
361 *
362 * Restores strictly what {@see Deactivator::REPUBLISH_OPTION} recorded as
363 * having been removed — never "everything the settings would allow", which
364 * on a fresh install would publish files the site never had.
365 *
366 * The sitemap goes through schedule_regeneration() rather than being built
367 * inline: a full rebuild on a large site is far too slow to sit inside an
368 * activation request, and the debounced hook already respects the master
369 * `enabled` flag. robots.txt and llms.txt are single small writes, so they
370 * happen here.
371 *
372 * @since 2.1.0
373 *
374 * @return void
375 */
376 private function restore_webroot_artifacts(): void {
377 $republish = get_option(Deactivator::REPUBLISH_OPTION, null);
378
379 if ($republish === null) {
380 // No recorded deactivation — a first install, or an activation that
381 // already consumed the record.
382 return;
383 }
384
385 // Consume it first. A restore that fatals must not re-run on every
386 // subsequent activation, and each entry below is independently guarded.
387 delete_option(Deactivator::REPUBLISH_OPTION);
388
389 if (!is_array($republish)) {
390 return;
391 }
392
393 try {
394 if (in_array('sitemap', $republish, true) && class_exists('ThinkRank\\SEO\\Sitemap_Generator')) {
395 // Read-only instance: the hook-registering one would bind a
396 // second set of content-change listeners to this request.
397 (new \ThinkRank\SEO\Sitemap_Generator(false))->schedule_regeneration();
398 }
399
400 if (in_array('robots', $republish, true) && class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
401 (new \ThinkRank\SEO\Site_Identity_Manager())->sync_robots_txt_file();
402 }
403
404 if (in_array('llms', $republish, true) && class_exists('ThinkRank\\SEO\\LLMs_Txt_Manager')) {
405 $llms = new \ThinkRank\SEO\LLMs_Txt_Manager();
406 $content = $llms->get_published_content();
407
408 // write_llms_txt_to_file() enforces the enabled toggle and the
409 // delivery mode itself, so an empty document is the only case
410 // worth short-circuiting here.
411 if ($content !== '') {
412 $llms->write_llms_txt_to_file($content);
413 }
414 }
415 } catch (\Throwable $e) {
416 // A failed republish must not block activation — the user would be
417 // left unable to switch the plugin on at all. The artifacts rebuild
418 // on the next content or settings save.
419 if (defined('WP_DEBUG') && WP_DEBUG) {
420 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
421 error_log('ThinkRank: failed to restore web-root artifacts: ' . $e->getMessage());
422 }
423 }
424 }
425
426 /**
427 * Schedule cron jobs
428 *
429 * @return void
430 */
431 private function schedule_cron_jobs(): void {
432
433 // Schedule cache cleanup
434 if (!wp_next_scheduled('thinkrank_cache_cleanup')) {
435 wp_schedule_event(time(), 'daily', 'thinkrank_cache_cleanup');
436 }
437
438 // Schedule usage analytics
439 if (!wp_next_scheduled('thinkrank_usage_analytics')) {
440 wp_schedule_event(time(), 'weekly', 'thinkrank_usage_analytics');
441 }
442 }
443
444 /**
445 * Set activation flag for first-time setup
446 *
447 * @return void
448 */
449 private function set_activation_flag(): void {
450 update_option('thinkrank_activated', true);
451 update_option('thinkrank_activation_time', time());
452
453 // Set flag for showing welcome screen
454 update_option('thinkrank_show_welcome', true);
455
456 // Trigger a one-time redirect to the Setup Wizard on the next admin load,
457 // but only when the wizard has not already been completed. A short-lived
458 // transient is used so it auto-expires and never fires for bulk/network
459 // activations that skip the redirect window.
460 if (!get_option('thinkrank_setup_wizard_completed')) {
461 set_transient('thinkrank_setup_wizard_redirect', 1, 60);
462 }
463 }
464 }
465