PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
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 1.11.0 All 47 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 trunk, at includes/core/class-capability-manager.php

499 lines 20.2 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 // Settings-management categories resolve by category rather than by
312 // prefix — see settings_category_map() for why this one route differs.
313 if (preg_match('#/thinkrank(?:-pro)?/v1/settings-management/category/([a-zA-Z0-9_-]+)#', $route, $c)) {
314 return self::capability_for_settings_category($c[1]);
315 }
316
317 if (!preg_match('#/thinkrank(?:-pro)?/v1/([^/]+)#', $route, $m)) {
318 return self::ACCESS;
319 }
320 return self::route_map()[$m[1]] ?? self::ACCESS;
321 }
322
323 /**
324 * The list of ThinkRank capabilities the given user holds (for localizing
325 * to the SPA). Administrators get the full set.
326 *
327 * @param int $user_id Optional user id (defaults to current user).
328 * @return string[]
329 */
330 public static function user_capabilities(int $user_id = 0): array {
331 $user = $user_id ? get_userdata($user_id) : wp_get_current_user();
332 if (!$user || !$user->exists()) {
333 return [];
334 }
335 if (user_can($user, 'manage_options')) {
336 return array_keys(self::capabilities());
337 }
338 return array_values(array_filter(
339 array_keys(self::capabilities()),
340 static fn($cap) => user_can($user, $cap)
341 ));
342 }
343
344 /**
345 * Editable roles excluding administrator (which always has everything).
346 *
347 * @return array<string,string> role slug => display name.
348 */
349 public static function editable_roles(): array {
350 // get_editable_roles() lives in wp-admin/includes/user.php, which is not
351 // loaded during REST requests — pull it in so this works in any context.
352 if (!function_exists('get_editable_roles')) {
353 require_once ABSPATH . 'wp-admin/includes/user.php';
354 }
355
356 $roles = [];
357 foreach (get_editable_roles() as $slug => $role) {
358 if ($slug === 'administrator') {
359 continue;
360 }
361 $roles[$slug] = translate_user_role($role['name']);
362 }
363 return $roles;
364 }
365
366 /**
367 * The WordPress capability a role needs before a ThinkRank grant does
368 * anything.
369 *
370 * Several endpoints run their own `edit_posts` check on top of the section
371 * gate — the plugin acts on posts, and a Subscriber has no business there.
372 * That check is not wrong; what was wrong is that the Role Manager modelled
373 * only the section gate. Granting an area to a role below this baseline
374 * saved, ticked the box and showed the section, while every request still
375 * failed, with nothing in the UI to explain why (#576).
376 *
377 * @since 2.1.3
378 */
379 public const BASELINE_CAPABILITY = 'edit_posts';
380
381 /**
382 * Whether a role can actually act on a ThinkRank grant.
383 *
384 * @since 2.1.3
385 *
386 * @param string $slug Role slug.
387 * @return bool
388 */
389 public static function role_meets_baseline(string $slug): bool {
390 $role = get_role($slug);
391
392 return $role instanceof \WP_Role && $role->has_cap(self::BASELINE_CAPABILITY);
393 }
394
395 /**
396 * The current assignment matrix: role slug => [capability slugs it has].
397 *
398 * @return array<string,string[]>
399 */
400 public static function get_matrix(): array {
401 $caps = array_keys(self::capabilities());
402 $matrix = [];
403 foreach (array_keys(self::editable_roles()) as $slug) {
404 $role = get_role($slug);
405 if (!$role) {
406 continue;
407 }
408 $matrix[$slug] = array_values(array_filter($caps, static fn($cap) => $role->has_cap($cap)));
409 }
410 return $matrix;
411 }
412
413 /**
414 * Persist an assignment matrix (role slug => [capability slugs]).
415 *
416 * The administrator role is never modified. Granting any section cap also
417 * grants the base ACCESS cap so the role can open ThinkRank.
418 *
419 * @param array $matrix role slug => array of capability slugs.
420 * @return void
421 */
422 public static function save_matrix(array $matrix): void {
423 $all = array_keys(self::capabilities());
424 $editable = self::editable_roles();
425
426 foreach ($editable as $slug => $name) {
427 $role = get_role($slug);
428 if (!$role) {
429 continue;
430 }
431
432 // Only modify roles explicitly present in this request, so a partial
433 // save cannot silently strip capabilities from other delegated roles.
434 if (!array_key_exists($slug, $matrix)) {
435 continue;
436 }
437
438 $granted = is_array($matrix[$slug])
439 ? array_values(array_intersect($all, array_map('sanitize_key', $matrix[$slug])))
440 : [];
441
442 // Any granted section cap implies base access.
443 if (!empty(array_diff($granted, [self::ACCESS])) && !in_array(self::ACCESS, $granted, true)) {
444 $granted[] = self::ACCESS;
445 }
446
447 foreach ($all as $cap) {
448 if (in_array($cap, $granted, true)) {
449 $role->add_cap($cap);
450 } else {
451 $role->remove_cap($cap);
452 }
453 }
454 }
455 }
456
457 /**
458 * Ensure the administrator role holds every ThinkRank capability. Runs
459 * once per version (and is safe to call on activation).
460 *
461 * The version option alone is not a sufficient guard: uninstall strips the
462 * capabilities from every role but keeps the option unless the user opted
463 * into deleting all data, so a reinstall would short-circuit here and leave
464 * administrators without {@see self::ACCESS} — locking them out of the admin
465 * menu entirely. Verify the capability is actually present before skipping,
466 * so a stranded option self-heals on the next request.
467 *
468 * @return void
469 */
470 public static function ensure(): void {
471 $admin = get_role('administrator');
472
473 if (get_option(self::VERSION_OPTION) === self::VERSION
474 && $admin
475 && $admin->has_cap(self::ACCESS)
476 ) {
477 return;
478 }
479
480 if ($admin) {
481 foreach (array_keys(self::capabilities()) as $cap) {
482 $admin->add_cap($cap);
483 }
484 }
485 update_option(self::VERSION_OPTION, self::VERSION, false);
486
487 // add_cap() updates the role, not an already-instantiated WP_User: that
488 // object cached its allcaps when it was first built, which on this request
489 // happened before `init`. Without rebuilding it, current_user_can() keeps
490 // returning false until the next request — long enough for admin_menu to
491 // skip every ThinkRank page and hand the user a "not allowed" screen right
492 // after activation. Rebuild so the grant takes effect immediately.
493 $user = wp_get_current_user();
494 if ($user instanceof \WP_User && $user->exists()) {
495 $user->get_role_caps();
496 }
497 }
498 }
499