PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.3
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.3
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.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 All 58 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.14.3, at includes/core/class-capability-manager.php

505 lines 20.5 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 'schema' => 'thinkrank_schema',
203 // Custom Schema (Pro) lives in the Schema Manager section but
204 // registers its own /custom-schema/ prefix.
205 'custom-schema' => 'thinkrank_schema',
206 // Custom Field Mapping (Pro) also lives in the Schema Manager
207 // section and registers its own /field-mapping/ prefix.
208 'field-mapping' => 'thinkrank_schema',
209 'social-media' => 'thinkrank_social_media',
210 'social-platforms' => 'thinkrank_settings',
211 'sitemap' => 'thinkrank_crawling',
212 // Publisher Sitemaps (Pro) is part of the Crawling & AI Indexing
213 // section but registers its own /publisher-sitemaps/ prefix.
214 'publisher-sitemaps' => 'thinkrank_crawling',
215 'llms-txt' => 'thinkrank_crawling',
216 'instant-indexing' => 'thinkrank_instant_indexing',
217 'author-archives' => 'thinkrank_author_archives',
218 'internal-links' => 'thinkrank_internal_links',
219 'external-links' => 'thinkrank_external_links',
220 'redirections' => 'thinkrank_redirections',
221 'broken-links' => 'thinkrank_broken_links',
222 'woocommerce' => 'thinkrank_woocommerce',
223 // Multi-location (Pro) is managed inside Site Identity › Business Info.
224 'locations' => 'thinkrank_site_identity',
225 'integrations' => 'thinkrank_settings',
226 'settings-management' => 'thinkrank_settings',
227 'settings' => 'thinkrank_settings',
228 'role-manager' => self::MANAGE_ROLES,
229 ];
230 }
231
232 /**
233 * Settings-management category => the section capability that owns it.
234 *
235 * `/settings-management/category/<category>` is the one cross-section route
236 * in the plugin. Every other prefix belongs to exactly one section, so
237 * resolving a capability from the first path segment is right for them; here
238 * the segment is the same for all thirteen categories and the *category*
239 * names whose data is being touched.
240 *
241 * Mapping the whole prefix to `thinkrank_settings` therefore gave one answer
242 * to a question with thirteen. It was too strict for Analytics, whose tab
243 * persists through this route and 403'd for a role that had been granted
244 * Analytics, and too loose for anyone holding `thinkrank_settings`, who
245 * could read every other section's settings here while the direct section
246 * routes correctly refused them (#573).
247 *
248 * Every key of Settings_Manager::$settings_categories must appear below;
249 * CapabilityManagerTest pins the two together. An unlisted category falls
250 * back to `thinkrank_settings`, which fails closed rather than open.
251 *
252 * @since 2.1.3
253 *
254 * @return array<string,string>
255 */
256 public static function settings_category_map(): array {
257 return [
258 'seo_analytics' => 'thinkrank_analytics',
259 'social_media' => 'thinkrank_social_media',
260 'sitemap' => 'thinkrank_crawling',
261 'schema_management' => 'thinkrank_schema',
262 'performance_monitoring' => 'thinkrank_performance',
263 'site_identity' => 'thinkrank_site_identity',
264 'content_analysis' => 'thinkrank_content_tools',
265 'content_optimization' => 'thinkrank_content_tools',
266 // Plugin-wide configuration with no single owning section.
267 'core' => 'thinkrank_settings',
268 'seo' => 'thinkrank_settings',
269 'ui' => 'thinkrank_settings',
270 'integrations' => 'thinkrank_settings',
271 'basic_integrations' => 'thinkrank_settings',
272 ];
273 }
274
275 /**
276 * The capability owning a settings-management category.
277 *
278 * @since 2.1.3
279 *
280 * @param string $category Category key.
281 * @return string
282 */
283 public static function capability_for_settings_category(string $category): string {
284 return self::settings_category_map()[$category] ?? 'thinkrank_settings';
285 }
286
287 /**
288 * Whether the current user has a ThinkRank capability.
289 *
290 * Administrators (`manage_options`) always pass — this is the lock-out
291 * safety net and means the matrix never needs to touch the admin role.
292 *
293 * @param string $capability Capability slug.
294 * @return bool
295 */
296 public static function current_user_can(string $capability): bool {
297 if (current_user_can('manage_options')) {
298 return true;
299 }
300 return current_user_can($capability);
301 }
302
303 /**
304 * The capability guarding a REST route, or the base access cap when the
305 * route's prefix isn't specifically mapped.
306 *
307 * @param string $route Full REST route (e.g. /thinkrank/v1/schema/...).
308 * @return string
309 */
310 public static function capability_for_route(string $route): string {
311 // Routes match case-insensitively in WP_REST_Server, and every key in
312 // route_map() and settings_category_map() is lowercase, so a section
313 // spelled /SiteMap/ must resolve to the same capability as /sitemap/
314 // rather than fall back to the base access cap (#975).
315 $route = strtolower($route);
316
317 // Settings-management categories resolve by category rather than by
318 // prefix — see settings_category_map() for why this one route differs.
319 if (preg_match('#/thinkrank(?:-pro)?/v1/settings-management/category/([a-zA-Z0-9_-]+)#', $route, $c)) {
320 return self::capability_for_settings_category($c[1]);
321 }
322
323 if (!preg_match('#/thinkrank(?:-pro)?/v1/([^/]+)#', $route, $m)) {
324 return self::ACCESS;
325 }
326 return self::route_map()[$m[1]] ?? self::ACCESS;
327 }
328
329 /**
330 * The list of ThinkRank capabilities the given user holds (for localizing
331 * to the SPA). Administrators get the full set.
332 *
333 * @param int $user_id Optional user id (defaults to current user).
334 * @return string[]
335 */
336 public static function user_capabilities(int $user_id = 0): array {
337 $user = $user_id ? get_userdata($user_id) : wp_get_current_user();
338 if (!$user || !$user->exists()) {
339 return [];
340 }
341 if (user_can($user, 'manage_options')) {
342 return array_keys(self::capabilities());
343 }
344 return array_values(array_filter(
345 array_keys(self::capabilities()),
346 static fn($cap) => user_can($user, $cap)
347 ));
348 }
349
350 /**
351 * Editable roles excluding administrator (which always has everything).
352 *
353 * @return array<string,string> role slug => display name.
354 */
355 public static function editable_roles(): array {
356 // get_editable_roles() lives in wp-admin/includes/user.php, which is not
357 // loaded during REST requests — pull it in so this works in any context.
358 if (!function_exists('get_editable_roles')) {
359 require_once ABSPATH . 'wp-admin/includes/user.php';
360 }
361
362 $roles = [];
363 foreach (get_editable_roles() as $slug => $role) {
364 if ($slug === 'administrator') {
365 continue;
366 }
367 $roles[$slug] = translate_user_role($role['name']);
368 }
369 return $roles;
370 }
371
372 /**
373 * The WordPress capability a role needs before a ThinkRank grant does
374 * anything.
375 *
376 * Several endpoints run their own `edit_posts` check on top of the section
377 * gate — the plugin acts on posts, and a Subscriber has no business there.
378 * That check is not wrong; what was wrong is that the Role Manager modelled
379 * only the section gate. Granting an area to a role below this baseline
380 * saved, ticked the box and showed the section, while every request still
381 * failed, with nothing in the UI to explain why (#576).
382 *
383 * @since 2.1.3
384 */
385 public const BASELINE_CAPABILITY = 'edit_posts';
386
387 /**
388 * Whether a role can actually act on a ThinkRank grant.
389 *
390 * @since 2.1.3
391 *
392 * @param string $slug Role slug.
393 * @return bool
394 */
395 public static function role_meets_baseline(string $slug): bool {
396 $role = get_role($slug);
397
398 return $role instanceof \WP_Role && $role->has_cap(self::BASELINE_CAPABILITY);
399 }
400
401 /**
402 * The current assignment matrix: role slug => [capability slugs it has].
403 *
404 * @return array<string,string[]>
405 */
406 public static function get_matrix(): array {
407 $caps = array_keys(self::capabilities());
408 $matrix = [];
409 foreach (array_keys(self::editable_roles()) as $slug) {
410 $role = get_role($slug);
411 if (!$role) {
412 continue;
413 }
414 $matrix[$slug] = array_values(array_filter($caps, static fn($cap) => $role->has_cap($cap)));
415 }
416 return $matrix;
417 }
418
419 /**
420 * Persist an assignment matrix (role slug => [capability slugs]).
421 *
422 * The administrator role is never modified. Granting any section cap also
423 * grants the base ACCESS cap so the role can open ThinkRank.
424 *
425 * @param array $matrix role slug => array of capability slugs.
426 * @return void
427 */
428 public static function save_matrix(array $matrix): void {
429 $all = array_keys(self::capabilities());
430 $editable = self::editable_roles();
431
432 foreach ($editable as $slug => $name) {
433 $role = get_role($slug);
434 if (!$role) {
435 continue;
436 }
437
438 // Only modify roles explicitly present in this request, so a partial
439 // save cannot silently strip capabilities from other delegated roles.
440 if (!array_key_exists($slug, $matrix)) {
441 continue;
442 }
443
444 $granted = is_array($matrix[$slug])
445 ? array_values(array_intersect($all, array_map('sanitize_key', $matrix[$slug])))
446 : [];
447
448 // Any granted section cap implies base access.
449 if (!empty(array_diff($granted, [self::ACCESS])) && !in_array(self::ACCESS, $granted, true)) {
450 $granted[] = self::ACCESS;
451 }
452
453 foreach ($all as $cap) {
454 if (in_array($cap, $granted, true)) {
455 $role->add_cap($cap);
456 } else {
457 $role->remove_cap($cap);
458 }
459 }
460 }
461 }
462
463 /**
464 * Ensure the administrator role holds every ThinkRank capability. Runs
465 * once per version (and is safe to call on activation).
466 *
467 * The version option alone is not a sufficient guard: uninstall strips the
468 * capabilities from every role but keeps the option unless the user opted
469 * into deleting all data, so a reinstall would short-circuit here and leave
470 * administrators without {@see self::ACCESS} — locking them out of the admin
471 * menu entirely. Verify the capability is actually present before skipping,
472 * so a stranded option self-heals on the next request.
473 *
474 * @return void
475 */
476 public static function ensure(): void {
477 $admin = get_role('administrator');
478
479 if (get_option(self::VERSION_OPTION) === self::VERSION
480 && $admin
481 && $admin->has_cap(self::ACCESS)
482 ) {
483 return;
484 }
485
486 if ($admin) {
487 foreach (array_keys(self::capabilities()) as $cap) {
488 $admin->add_cap($cap);
489 }
490 }
491 update_option(self::VERSION_OPTION, self::VERSION, false);
492
493 // add_cap() updates the role, not an already-instantiated WP_User: that
494 // object cached its allcaps when it was first built, which on this request
495 // happened before `init`. Without rebuilding it, current_user_can() keeps
496 // returning false until the next request — long enough for admin_menu to
497 // skip every ThinkRank page and hand the user a "not allowed" screen right
498 // after activation. Rebuild so the grant takes effect immediately.
499 $user = wp_get_current_user();
500 if ($user instanceof \WP_User && $user->exists()) {
501 $user->get_role_caps();
502 }
503 }
504 }
505