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

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