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

338 lines 13.7 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 * Capability => label. Keyed by capability slug.
46 *
47 * @return array<string,string>
48 */
49 public static function capabilities(): array {
50 return [
51 self::ACCESS => __('Access ThinkRank', 'thinkrank'),
52 'thinkrank_site_identity' => __('Site Identity', 'thinkrank'),
53 'thinkrank_analytics' => __('Analytics', 'thinkrank'),
54 'thinkrank_performance' => __('Performance', 'thinkrank'),
55 'thinkrank_global_seo' => __('Bulk SEO Optimization', 'thinkrank'),
56 'thinkrank_image_seo' => __('Image SEO', 'thinkrank'),
57 'thinkrank_schema' => __('Schema Manager', 'thinkrank'),
58 'thinkrank_social_media' => __('Social Media', 'thinkrank'),
59 'thinkrank_crawling' => __('Crawling & AI Indexing', 'thinkrank'),
60 'thinkrank_instant_indexing' => __('Instant Indexing', 'thinkrank'),
61 'thinkrank_author_archives' => __('Author Archives', 'thinkrank'),
62 'thinkrank_content_tools' => __('AI Tools', 'thinkrank'),
63 'thinkrank_ai_insights' => __('AI Insights', 'thinkrank'),
64 'thinkrank_internal_links' => __('Internal Links', 'thinkrank'),
65 'thinkrank_redirections' => __('Redirections', 'thinkrank'),
66 'thinkrank_broken_links' => __('Broken Links', 'thinkrank'),
67 'thinkrank_woocommerce' => __('WooCommerce', 'thinkrank'),
68 'thinkrank_settings' => __('Settings & API Keys', 'thinkrank'),
69 self::MANAGE_ROLES => __('Manage Roles', 'thinkrank'),
70 ];
71 }
72
73 /**
74 * Nav section id => required capability. Used by the SPA (localized) and
75 * mirrors the route map below.
76 *
77 * @return array<string,string>
78 */
79 public static function section_map(): array {
80 return [
81 'site-identity' => 'thinkrank_site_identity',
82 'analytics' => 'thinkrank_analytics',
83 'performance' => 'thinkrank_performance',
84 'global-seo' => 'thinkrank_global_seo',
85 'image-seo' => 'thinkrank_image_seo',
86 'schema' => 'thinkrank_schema',
87 'social-media' => 'thinkrank_social_media',
88 'crawling-ai-indexing' => 'thinkrank_crawling',
89 'instant-indexing' => 'thinkrank_instant_indexing',
90 'author-archives' => 'thinkrank_author_archives',
91 'ai-insights' => 'thinkrank_ai_insights',
92 'internal-links' => 'thinkrank_internal_links',
93 'redirections' => 'thinkrank_redirections',
94 'broken-links' => 'thinkrank_broken_links',
95 'woocommerce' => 'thinkrank_woocommerce',
96 'integrations' => 'thinkrank_settings',
97 'role-manager' => self::MANAGE_ROLES,
98 ];
99 }
100
101 /**
102 * REST route prefix (first segment after the namespace) => capability.
103 *
104 * @return array<string,string>
105 */
106 public static function route_map(): array {
107 return [
108 'site-identity' => 'thinkrank_site_identity',
109 'seo-analytics' => 'thinkrank_analytics',
110 'analytics' => 'thinkrank_analytics',
111 // Analytics sub-features that register their own Pro route prefixes
112 // (rather than nesting under /analytics/) — gate them with the
113 // Analytics capability, not the base ACCESS fall-through.
114 'rank-tracker' => 'thinkrank_analytics',
115 'keywords' => 'thinkrank_analytics',
116 'email-report' => 'thinkrank_analytics',
117 'top-content' => 'thinkrank_analytics',
118 'url-inspection' => 'thinkrank_analytics',
119 'refresh-radar' => 'thinkrank_analytics',
120 'seo-score' => 'thinkrank_content_tools',
121 'content-brief' => 'thinkrank_content_tools',
122 'pillar-content' => 'thinkrank_content_tools',
123 'ai' => 'thinkrank_content_tools',
124 // /metadata/<id> reads a post's stored SEO meta and belongs to the
125 // AI Tools section — gate it with the same capability as the AI
126 // generators above (was unmapped, so it fell back to base ACCESS).
127 'metadata' => 'thinkrank_content_tools',
128 'performance' => 'thinkrank_performance',
129 'global-seo' => 'thinkrank_global_seo',
130 'global-robot-meta' => 'thinkrank_crawling',
131 'image-seo' => 'thinkrank_image_seo',
132 'ai-insights' => 'thinkrank_ai_insights',
133 // Brand Visibility is part of the AI Insights section.
134 'brand-visibility' => 'thinkrank_ai_insights',
135 'schema' => 'thinkrank_schema',
136 // Custom Schema (Pro) lives in the Schema Manager section but
137 // registers its own /custom-schema/ prefix.
138 'custom-schema' => 'thinkrank_schema',
139 'social-media' => 'thinkrank_social_media',
140 'social-platforms' => 'thinkrank_settings',
141 'sitemap' => 'thinkrank_crawling',
142 // Publisher Sitemaps (Pro) is part of the Crawling & AI Indexing
143 // section but registers its own /publisher-sitemaps/ prefix.
144 'publisher-sitemaps' => 'thinkrank_crawling',
145 'llms-txt' => 'thinkrank_crawling',
146 'instant-indexing' => 'thinkrank_instant_indexing',
147 'author-archives' => 'thinkrank_author_archives',
148 'internal-links' => 'thinkrank_internal_links',
149 'redirections' => 'thinkrank_redirections',
150 'broken-links' => 'thinkrank_broken_links',
151 'woocommerce' => 'thinkrank_woocommerce',
152 // Multi-location (Pro) is managed inside Site Identity › Business Info.
153 'locations' => 'thinkrank_site_identity',
154 'integrations' => 'thinkrank_settings',
155 'settings-management' => 'thinkrank_settings',
156 'settings' => 'thinkrank_settings',
157 'role-manager' => self::MANAGE_ROLES,
158 ];
159 }
160
161 /**
162 * Whether the current user has a ThinkRank capability.
163 *
164 * Administrators (`manage_options`) always pass — this is the lock-out
165 * safety net and means the matrix never needs to touch the admin role.
166 *
167 * @param string $capability Capability slug.
168 * @return bool
169 */
170 public static function current_user_can(string $capability): bool {
171 if (current_user_can('manage_options')) {
172 return true;
173 }
174 return current_user_can($capability);
175 }
176
177 /**
178 * The capability guarding a REST route, or the base access cap when the
179 * route's prefix isn't specifically mapped.
180 *
181 * @param string $route Full REST route (e.g. /thinkrank/v1/schema/...).
182 * @return string
183 */
184 public static function capability_for_route(string $route): string {
185 if (!preg_match('#/thinkrank(?:-pro)?/v1/([^/]+)#', $route, $m)) {
186 return self::ACCESS;
187 }
188 return self::route_map()[$m[1]] ?? self::ACCESS;
189 }
190
191 /**
192 * The list of ThinkRank capabilities the given user holds (for localizing
193 * to the SPA). Administrators get the full set.
194 *
195 * @param int $user_id Optional user id (defaults to current user).
196 * @return string[]
197 */
198 public static function user_capabilities(int $user_id = 0): array {
199 $user = $user_id ? get_userdata($user_id) : wp_get_current_user();
200 if (!$user || !$user->exists()) {
201 return [];
202 }
203 if (user_can($user, 'manage_options')) {
204 return array_keys(self::capabilities());
205 }
206 return array_values(array_filter(
207 array_keys(self::capabilities()),
208 static fn($cap) => user_can($user, $cap)
209 ));
210 }
211
212 /**
213 * Editable roles excluding administrator (which always has everything).
214 *
215 * @return array<string,string> role slug => display name.
216 */
217 public static function editable_roles(): array {
218 // get_editable_roles() lives in wp-admin/includes/user.php, which is not
219 // loaded during REST requests — pull it in so this works in any context.
220 if (!function_exists('get_editable_roles')) {
221 require_once ABSPATH . 'wp-admin/includes/user.php';
222 }
223
224 $roles = [];
225 foreach (get_editable_roles() as $slug => $role) {
226 if ($slug === 'administrator') {
227 continue;
228 }
229 $roles[$slug] = translate_user_role($role['name']);
230 }
231 return $roles;
232 }
233
234 /**
235 * The current assignment matrix: role slug => [capability slugs it has].
236 *
237 * @return array<string,string[]>
238 */
239 public static function get_matrix(): array {
240 $caps = array_keys(self::capabilities());
241 $matrix = [];
242 foreach (array_keys(self::editable_roles()) as $slug) {
243 $role = get_role($slug);
244 if (!$role) {
245 continue;
246 }
247 $matrix[$slug] = array_values(array_filter($caps, static fn($cap) => $role->has_cap($cap)));
248 }
249 return $matrix;
250 }
251
252 /**
253 * Persist an assignment matrix (role slug => [capability slugs]).
254 *
255 * The administrator role is never modified. Granting any section cap also
256 * grants the base ACCESS cap so the role can open ThinkRank.
257 *
258 * @param array $matrix role slug => array of capability slugs.
259 * @return void
260 */
261 public static function save_matrix(array $matrix): void {
262 $all = array_keys(self::capabilities());
263 $editable = self::editable_roles();
264
265 foreach ($editable as $slug => $name) {
266 $role = get_role($slug);
267 if (!$role) {
268 continue;
269 }
270
271 // Only modify roles explicitly present in this request, so a partial
272 // save cannot silently strip capabilities from other delegated roles.
273 if (!array_key_exists($slug, $matrix)) {
274 continue;
275 }
276
277 $granted = is_array($matrix[$slug])
278 ? array_values(array_intersect($all, array_map('sanitize_key', $matrix[$slug])))
279 : [];
280
281 // Any granted section cap implies base access.
282 if (!empty(array_diff($granted, [self::ACCESS])) && !in_array(self::ACCESS, $granted, true)) {
283 $granted[] = self::ACCESS;
284 }
285
286 foreach ($all as $cap) {
287 if (in_array($cap, $granted, true)) {
288 $role->add_cap($cap);
289 } else {
290 $role->remove_cap($cap);
291 }
292 }
293 }
294 }
295
296 /**
297 * Ensure the administrator role holds every ThinkRank capability. Runs
298 * once per version (and is safe to call on activation).
299 *
300 * The version option alone is not a sufficient guard: uninstall strips the
301 * capabilities from every role but keeps the option unless the user opted
302 * into deleting all data, so a reinstall would short-circuit here and leave
303 * administrators without {@see self::ACCESS} — locking them out of the admin
304 * menu entirely. Verify the capability is actually present before skipping,
305 * so a stranded option self-heals on the next request.
306 *
307 * @return void
308 */
309 public static function ensure(): void {
310 $admin = get_role('administrator');
311
312 if (get_option(self::VERSION_OPTION) === self::VERSION
313 && $admin
314 && $admin->has_cap(self::ACCESS)
315 ) {
316 return;
317 }
318
319 if ($admin) {
320 foreach (array_keys(self::capabilities()) as $cap) {
321 $admin->add_cap($cap);
322 }
323 }
324 update_option(self::VERSION_OPTION, self::VERSION, false);
325
326 // add_cap() updates the role, not an already-instantiated WP_User: that
327 // object cached its allcaps when it was first built, which on this request
328 // happened before `init`. Without rebuilding it, current_user_can() keeps
329 // returning false until the next request — long enough for admin_menu to
330 // skip every ThinkRank page and hand the user a "not allowed" screen right
331 // after activation. Rebuild so the grant takes effect immediately.
332 $user = wp_get_current_user();
333 if ($user instanceof \WP_User && $user->exists()) {
334 $user->get_role_caps();
335 }
336 }
337 }
338