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

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