PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.5.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.5.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 1.1.0 1.10.0 All 48 releases
thinkrank / includes / core / class-capability-manager.php

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

501 lines 20.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace ThinkRank\Core;
6
7 // Prevent direct access
8 if (!defined('ABSPATH')) {
9 exit;
10 }
11
12 /**
13 * Capability Manager
14 *
15 * Single source of truth for ThinkRank's role/capability access control.
16 * Defines one capability per admin area (plus a base access cap and a
17 * manage-roles cap), maps REST route prefixes to those capabilities, and
18 * reads/writes the per-role assignment matrix.
19 *
20 * Administrators implicitly have every capability via the `manage_options`
21 * bypass in {@see Capability_Manager::current_user_can()}, so the matrix
22 * never edits the administrator role (no lock-out possible).
23 *
24 * @since 1.12.0
25 */
26 class Capability_Manager {
27
28 /**
29 * Option storing the version that capabilities were last synced at.
30 */
31 private const VERSION_OPTION = 'thinkrank_caps_version';
32 private const VERSION = '3';
33
34 /**
35 * Base capability required to open ThinkRank at all.
36 */
37 public const ACCESS = 'thinkrank_access';
38
39 /**
40 * Capability required to manage the Role Manager itself.
41 */
42 public const MANAGE_ROLES = 'thinkrank_manage_roles';
43
44 /**
45 * Every ThinkRank capability, in the order the Role Manager lists them.
46 *
47 * Slugs live here rather than as the keys of capabilities() because the two
48 * callers want different things and only one of them can afford a
49 * translation. grant_admin_caps() runs on `user_has_cap`, which core fires
50 * from wp_set_current_user() during wp-settings.php — before `init`, so a
51 * __() there is both wasted (it discards the labels) and illegal, and WP
52 * 6.7+ answers it with a _load_textdomain_just_in_time notice on every
53 * request. Same class of bug as the cron interval labels in #331.
54 *
55 * capabilities() below builds its labels from this list, so a capability
56 * added here cannot go missing from the admin bypass or the Role Manager UI.
57 *
58 * @since 2.2.0
59 */
60 private const SLUGS = [
61 self::ACCESS,
62 'thinkrank_site_identity',
63 'thinkrank_analytics',
64 'thinkrank_performance',
65 'thinkrank_global_seo',
66 'thinkrank_image_seo',
67 'thinkrank_schema',
68 'thinkrank_social_media',
69 'thinkrank_crawling',
70 'thinkrank_instant_indexing',
71 'thinkrank_author_archives',
72 'thinkrank_content_tools',
73 'thinkrank_ai_insights',
74 'thinkrank_internal_links',
75 'thinkrank_external_links',
76 'thinkrank_redirections',
77 'thinkrank_broken_links',
78 'thinkrank_woocommerce',
79 'thinkrank_settings',
80 self::MANAGE_ROLES,
81 ];
82
83 /**
84 * Capability slugs, with no translation involved.
85 *
86 * Safe to call at any point in the request, including before `init`.
87 *
88 * @since 2.2.0
89 *
90 * @return array<int,string>
91 */
92 public static function slugs(): array {
93 return self::SLUGS;
94 }
95
96 /**
97 * Capability => label. Keyed by capability slug.
98 *
99 * Only for user-facing output (the Role Manager matrix). Calling this
100 * before `init` triggers a textdomain notice — use slugs() when the labels
101 * are not needed.
102 *
103 * @return array<string,string>
104 */
105 public static function capabilities(): array {
106 $labels = [
107 self::ACCESS => __('Access ThinkRank', 'thinkrank'),
108 'thinkrank_site_identity' => __('Site Identity', 'thinkrank'),
109 'thinkrank_analytics' => __('Analytics', 'thinkrank'),
110 'thinkrank_performance' => __('Performance', 'thinkrank'),
111 'thinkrank_global_seo' => __('Bulk SEO Optimization', 'thinkrank'),
112 'thinkrank_image_seo' => __('Image SEO', 'thinkrank'),
113 'thinkrank_schema' => __('Schema Manager', 'thinkrank'),
114 'thinkrank_social_media' => __('Social Media', 'thinkrank'),
115 'thinkrank_crawling' => __('Crawling & AI Indexing', 'thinkrank'),
116 'thinkrank_instant_indexing' => __('Instant Indexing', 'thinkrank'),
117 'thinkrank_author_archives' => __('Author Archives', 'thinkrank'),
118 'thinkrank_content_tools' => __('AI Tools', 'thinkrank'),
119 'thinkrank_ai_insights' => __('AI Insights', 'thinkrank'),
120 'thinkrank_internal_links' => __('Internal Links', 'thinkrank'),
121 'thinkrank_external_links' => __('External Links', 'thinkrank'),
122 'thinkrank_redirections' => __('Redirections', 'thinkrank'),
123 'thinkrank_broken_links' => __('Broken Links', 'thinkrank'),
124 'thinkrank_woocommerce' => __('WooCommerce', 'thinkrank'),
125 'thinkrank_settings' => __('Settings & API Keys', 'thinkrank'),
126 self::MANAGE_ROLES => __('Manage Roles', 'thinkrank'),
127 ];
128
129 // SLUGS is the source of truth for which capabilities exist; the map
130 // above only supplies wording. Ordering by SLUGS means a slug added
131 // without a label still appears (labelled by its slug) rather than
132 // silently vanishing from the matrix.
133 $out = [];
134 foreach (self::SLUGS as $slug) {
135 $out[$slug] = $labels[$slug] ?? $slug;
136 }
137
138 return $out;
139 }
140
141 /**
142 * Nav section id => required capability. Used by the SPA (localized) and
143 * mirrors the route map below.
144 *
145 * @return array<string,string>
146 */
147 public static function section_map(): array {
148 return [
149 'site-identity' => 'thinkrank_site_identity',
150 'analytics' => 'thinkrank_analytics',
151 'performance' => 'thinkrank_performance',
152 'global-seo' => 'thinkrank_global_seo',
153 'image-seo' => 'thinkrank_image_seo',
154 'schema' => 'thinkrank_schema',
155 'social-media' => 'thinkrank_social_media',
156 'crawling-ai-indexing' => 'thinkrank_crawling',
157 'instant-indexing' => 'thinkrank_instant_indexing',
158 'author-archives' => 'thinkrank_author_archives',
159 'ai-insights' => 'thinkrank_ai_insights',
160 'internal-links' => 'thinkrank_internal_links',
161 'external-links' => 'thinkrank_external_links',
162 'redirections' => 'thinkrank_redirections',
163 'broken-links' => 'thinkrank_broken_links',
164 'woocommerce' => 'thinkrank_woocommerce',
165 'integrations' => 'thinkrank_settings',
166 'role-manager' => self::MANAGE_ROLES,
167 ];
168 }
169
170 /**
171 * REST route prefix (first segment after the namespace) => capability.
172 *
173 * @return array<string,string>
174 */
175 public static function route_map(): array {
176 return [
177 'site-identity' => 'thinkrank_site_identity',
178 'seo-analytics' => 'thinkrank_analytics',
179 'analytics' => 'thinkrank_analytics',
180 // Analytics sub-features that register their own Pro route prefixes
181 // (rather than nesting under /analytics/) — gate them with the
182 // Analytics capability, not the base ACCESS fall-through.
183 'rank-tracker' => 'thinkrank_analytics',
184 'keywords' => 'thinkrank_analytics',
185 'email-report' => 'thinkrank_analytics',
186 'top-content' => 'thinkrank_analytics',
187 'url-inspection' => 'thinkrank_analytics',
188 'refresh-radar' => 'thinkrank_analytics',
189 'seo-score' => 'thinkrank_content_tools',
190 'content-brief' => 'thinkrank_content_tools',
191 'pillar-content' => 'thinkrank_content_tools',
192 'ai' => 'thinkrank_content_tools',
193 // /metadata/<id> reads a post's stored SEO meta and belongs to the
194 // AI Tools section — gate it with the same capability as the AI
195 // generators above (was unmapped, so it fell back to base ACCESS).
196 'metadata' => 'thinkrank_content_tools',
197 'performance' => 'thinkrank_performance',
198 'global-seo' => 'thinkrank_global_seo',
199 'global-robot-meta' => 'thinkrank_crawling',
200 'image-seo' => 'thinkrank_image_seo',
201 'ai-insights' => 'thinkrank_ai_insights',
202 // Brand Visibility is part of the AI Insights section.
203 'brand-visibility' => 'thinkrank_ai_insights',
204 'schema' => 'thinkrank_schema',
205 // Custom Schema (Pro) lives in the Schema Manager section but
206 // registers its own /custom-schema/ prefix.
207 'custom-schema' => 'thinkrank_schema',
208 // Custom Field Mapping (Pro) also lives in the Schema Manager
209 // section and registers its own /field-mapping/ prefix.
210 'field-mapping' => 'thinkrank_schema',
211 'social-media' => 'thinkrank_social_media',
212 'social-platforms' => 'thinkrank_settings',
213 'sitemap' => 'thinkrank_crawling',
214 // Publisher Sitemaps (Pro) is part of the Crawling & AI Indexing
215 // section but registers its own /publisher-sitemaps/ prefix.
216 'publisher-sitemaps' => 'thinkrank_crawling',
217 'llms-txt' => 'thinkrank_crawling',
218 'instant-indexing' => 'thinkrank_instant_indexing',
219 'author-archives' => 'thinkrank_author_archives',
220 'internal-links' => 'thinkrank_internal_links',
221 'external-links' => 'thinkrank_external_links',
222 'redirections' => 'thinkrank_redirections',
223 'broken-links' => 'thinkrank_broken_links',
224 'woocommerce' => 'thinkrank_woocommerce',
225 // Multi-location (Pro) is managed inside Site Identity › Business Info.
226 'locations' => 'thinkrank_site_identity',
227 'integrations' => 'thinkrank_settings',
228 'settings-management' => 'thinkrank_settings',
229 'settings' => 'thinkrank_settings',
230 'role-manager' => self::MANAGE_ROLES,
231 ];
232 }
233
234 /**
235 * Settings-management category => the section capability that owns it.
236 *
237 * `/settings-management/category/<category>` is the one cross-section route
238 * in the plugin. Every other prefix belongs to exactly one section, so
239 * resolving a capability from the first path segment is right for them; here
240 * the segment is the same for all thirteen categories and the *category*
241 * names whose data is being touched.
242 *
243 * Mapping the whole prefix to `thinkrank_settings` therefore gave one answer
244 * to a question with thirteen. It was too strict for Analytics, whose tab
245 * persists through this route and 403'd for a role that had been granted
246 * Analytics, and too loose for anyone holding `thinkrank_settings`, who
247 * could read every other section's settings here while the direct section
248 * routes correctly refused them (#573).
249 *
250 * Every key of Settings_Manager::$settings_categories must appear below;
251 * CapabilityManagerTest pins the two together. An unlisted category falls
252 * back to `thinkrank_settings`, which fails closed rather than open.
253 *
254 * @since 2.1.3
255 *
256 * @return array<string,string>
257 */
258 public static function settings_category_map(): array {
259 return [
260 'seo_analytics' => 'thinkrank_analytics',
261 'social_media' => 'thinkrank_social_media',
262 'sitemap' => 'thinkrank_crawling',
263 'schema_management' => 'thinkrank_schema',
264 'performance_monitoring' => 'thinkrank_performance',
265 'site_identity' => 'thinkrank_site_identity',
266 'content_analysis' => 'thinkrank_content_tools',
267 'content_optimization' => 'thinkrank_content_tools',
268 // Plugin-wide configuration with no single owning section.
269 'core' => 'thinkrank_settings',
270 'seo' => 'thinkrank_settings',
271 'ui' => 'thinkrank_settings',
272 'integrations' => 'thinkrank_settings',
273 'basic_integrations' => 'thinkrank_settings',
274 ];
275 }
276
277 /**
278 * The capability owning a settings-management category.
279 *
280 * @since 2.1.3
281 *
282 * @param string $category Category key.
283 * @return string
284 */
285 public static function capability_for_settings_category(string $category): string {
286 return self::settings_category_map()[$category] ?? 'thinkrank_settings';
287 }
288
289 /**
290 * Whether the current user has a ThinkRank capability.
291 *
292 * Administrators (`manage_options`) always pass — this is the lock-out
293 * safety net and means the matrix never needs to touch the admin role.
294 *
295 * @param string $capability Capability slug.
296 * @return bool
297 */
298 public static function current_user_can(string $capability): bool {
299 if (current_user_can('manage_options')) {
300 return true;
301 }
302 return current_user_can($capability);
303 }
304
305 /**
306 * The capability guarding a REST route, or the base access cap when the
307 * route's prefix isn't specifically mapped.
308 *
309 * @param string $route Full REST route (e.g. /thinkrank/v1/schema/...).
310 * @return string
311 */
312 public static function capability_for_route(string $route): string {
313 // Settings-management categories resolve by category rather than by
314 // prefix — see settings_category_map() for why this one route differs.
315 if (preg_match('#/thinkrank(?:-pro)?/v1/settings-management/category/([a-zA-Z0-9_-]+)#', $route, $c)) {
316 return self::capability_for_settings_category($c[1]);
317 }
318
319 if (!preg_match('#/thinkrank(?:-pro)?/v1/([^/]+)#', $route, $m)) {
320 return self::ACCESS;
321 }
322 return self::route_map()[$m[1]] ?? self::ACCESS;
323 }
324
325 /**
326 * The list of ThinkRank capabilities the given user holds (for localizing
327 * to the SPA). Administrators get the full set.
328 *
329 * @param int $user_id Optional user id (defaults to current user).
330 * @return string[]
331 */
332 public static function user_capabilities(int $user_id = 0): array {
333 $user = $user_id ? get_userdata($user_id) : wp_get_current_user();
334 if (!$user || !$user->exists()) {
335 return [];
336 }
337 if (user_can($user, 'manage_options')) {
338 return array_keys(self::capabilities());
339 }
340 return array_values(array_filter(
341 array_keys(self::capabilities()),
342 static fn($cap) => user_can($user, $cap)
343 ));
344 }
345
346 /**
347 * Editable roles excluding administrator (which always has everything).
348 *
349 * @return array<string,string> role slug => display name.
350 */
351 public static function editable_roles(): array {
352 // get_editable_roles() lives in wp-admin/includes/user.php, which is not
353 // loaded during REST requests — pull it in so this works in any context.
354 if (!function_exists('get_editable_roles')) {
355 require_once ABSPATH . 'wp-admin/includes/user.php';
356 }
357
358 $roles = [];
359 foreach (get_editable_roles() as $slug => $role) {
360 if ($slug === 'administrator') {
361 continue;
362 }
363 $roles[$slug] = translate_user_role($role['name']);
364 }
365 return $roles;
366 }
367
368 /**
369 * The WordPress capability a role needs before a ThinkRank grant does
370 * anything.
371 *
372 * Several endpoints run their own `edit_posts` check on top of the section
373 * gate — the plugin acts on posts, and a Subscriber has no business there.
374 * That check is not wrong; what was wrong is that the Role Manager modelled
375 * only the section gate. Granting an area to a role below this baseline
376 * saved, ticked the box and showed the section, while every request still
377 * failed, with nothing in the UI to explain why (#576).
378 *
379 * @since 2.1.3
380 */
381 public const BASELINE_CAPABILITY = 'edit_posts';
382
383 /**
384 * Whether a role can actually act on a ThinkRank grant.
385 *
386 * @since 2.1.3
387 *
388 * @param string $slug Role slug.
389 * @return bool
390 */
391 public static function role_meets_baseline(string $slug): bool {
392 $role = get_role($slug);
393
394 return $role instanceof \WP_Role && $role->has_cap(self::BASELINE_CAPABILITY);
395 }
396
397 /**
398 * The current assignment matrix: role slug => [capability slugs it has].
399 *
400 * @return array<string,string[]>
401 */
402 public static function get_matrix(): array {
403 $caps = array_keys(self::capabilities());
404 $matrix = [];
405 foreach (array_keys(self::editable_roles()) as $slug) {
406 $role = get_role($slug);
407 if (!$role) {
408 continue;
409 }
410 $matrix[$slug] = array_values(array_filter($caps, static fn($cap) => $role->has_cap($cap)));
411 }
412 return $matrix;
413 }
414
415 /**
416 * Persist an assignment matrix (role slug => [capability slugs]).
417 *
418 * The administrator role is never modified. Granting any section cap also
419 * grants the base ACCESS cap so the role can open ThinkRank.
420 *
421 * @param array $matrix role slug => array of capability slugs.
422 * @return void
423 */
424 public static function save_matrix(array $matrix): void {
425 $all = array_keys(self::capabilities());
426 $editable = self::editable_roles();
427
428 foreach ($editable as $slug => $name) {
429 $role = get_role($slug);
430 if (!$role) {
431 continue;
432 }
433
434 // Only modify roles explicitly present in this request, so a partial
435 // save cannot silently strip capabilities from other delegated roles.
436 if (!array_key_exists($slug, $matrix)) {
437 continue;
438 }
439
440 $granted = is_array($matrix[$slug])
441 ? array_values(array_intersect($all, array_map('sanitize_key', $matrix[$slug])))
442 : [];
443
444 // Any granted section cap implies base access.
445 if (!empty(array_diff($granted, [self::ACCESS])) && !in_array(self::ACCESS, $granted, true)) {
446 $granted[] = self::ACCESS;
447 }
448
449 foreach ($all as $cap) {
450 if (in_array($cap, $granted, true)) {
451 $role->add_cap($cap);
452 } else {
453 $role->remove_cap($cap);
454 }
455 }
456 }
457 }
458
459 /**
460 * Ensure the administrator role holds every ThinkRank capability. Runs
461 * once per version (and is safe to call on activation).
462 *
463 * The version option alone is not a sufficient guard: uninstall strips the
464 * capabilities from every role but keeps the option unless the user opted
465 * into deleting all data, so a reinstall would short-circuit here and leave
466 * administrators without {@see self::ACCESS} — locking them out of the admin
467 * menu entirely. Verify the capability is actually present before skipping,
468 * so a stranded option self-heals on the next request.
469 *
470 * @return void
471 */
472 public static function ensure(): void {
473 $admin = get_role('administrator');
474
475 if (get_option(self::VERSION_OPTION) === self::VERSION
476 && $admin
477 && $admin->has_cap(self::ACCESS)
478 ) {
479 return;
480 }
481
482 if ($admin) {
483 foreach (array_keys(self::capabilities()) as $cap) {
484 $admin->add_cap($cap);
485 }
486 }
487 update_option(self::VERSION_OPTION, self::VERSION, false);
488
489 // add_cap() updates the role, not an already-instantiated WP_User: that
490 // object cached its allcaps when it was first built, which on this request
491 // happened before `init`. Without rebuilding it, current_user_can() keeps
492 // returning false until the next request — long enough for admin_menu to
493 // skip every ThinkRank page and hand the user a "not allowed" screen right
494 // after activation. Rebuild so the grant takes effect immediately.
495 $user = wp_get_current_user();
496 if ($user instanceof \WP_User && $user->exists()) {
497 $user->get_role_caps();
498 }
499 }
500 }
501