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 / 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.9.0, at includes/core/class-activator.php

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