PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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
← All changes | includes/core/class-capability-manager.php +194 -2 1.30.02.7.0 View file →
@@ -28,9 +28,9 @@
28 28 /**
29 29 * Option storing the version that capabilities were last synced at.
30 30 */
31 31 private const VERSION_OPTION = 'thinkrank_caps_version';
32 - private const VERSION = '2';
32 + private const VERSION = '3';
33 33
34 34 /**
35 35 * Base capability required to open ThinkRank at all.
36 36 */
@@ -41,14 +41,70 @@
41 41 */
42 42 public const MANAGE_ROLES = 'thinkrank_manage_roles';
43 43
44 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 + /**
45 97 * Capability => label. Keyed by capability slug.
46 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 + *
47 103 * @return array<string,string>
48 104 */
49 105 public static function capabilities(): array {
50 - return [
106 + $labels = [
51 107 self::ACCESS => __('Access ThinkRank', 'thinkrank'),
52 108 'thinkrank_site_identity' => __('Site Identity', 'thinkrank'),
53 109 'thinkrank_analytics' => __('Analytics', 'thinkrank'),
54 110 'thinkrank_performance' => __('Performance', 'thinkrank'),
@@ -59,12 +115,28 @@
59 115 'thinkrank_crawling' => __('Crawling & AI Indexing', 'thinkrank'),
60 116 'thinkrank_instant_indexing' => __('Instant Indexing', 'thinkrank'),
61 117 'thinkrank_author_archives' => __('Author Archives', 'thinkrank'),
62 118 'thinkrank_content_tools' => __('AI Tools', 'thinkrank'),
119 + 'thinkrank_ai_insights' => __('AI Insights', 'thinkrank'),
63 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'),
64 125 'thinkrank_settings' => __('Settings & API Keys', 'thinkrank'),
65 126 self::MANAGE_ROLES => __('Manage Roles', 'thinkrank'),
66 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;
67 139 }
68 140
69 141 /**
70 142 * Nav section id => required capability. Used by the SPA (localized) and
@@ -83,9 +155,14 @@
83 155 'social-media' => 'thinkrank_social_media',
84 156 'crawling-ai-indexing' => 'thinkrank_crawling',
85 157 'instant-indexing' => 'thinkrank_instant_indexing',
86 158 'author-archives' => 'thinkrank_author_archives',
159 + 'ai-insights' => 'thinkrank_ai_insights',
87 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',
88 165 'integrations' => 'thinkrank_settings',
89 166 'role-manager' => self::MANAGE_ROLES,
90 167 ];
91 168 }
@@ -99,8 +176,17 @@
99 176 return [
100 177 'site-identity' => 'thinkrank_site_identity',
101 178 'seo-analytics' => 'thinkrank_analytics',
102 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',
103 189 'seo-score' => 'thinkrank_content_tools',
104 190 'content-brief' => 'thinkrank_content_tools',
105 191 'pillar-content' => 'thinkrank_content_tools',
106 192 'ai' => 'thinkrank_content_tools',
@@ -111,16 +197,32 @@
111 197 'performance' => 'thinkrank_performance',
112 198 'global-seo' => 'thinkrank_global_seo',
113 199 'global-robot-meta' => 'thinkrank_crawling',
114 200 'image-seo' => 'thinkrank_image_seo',
201 + 'ai-insights' => 'thinkrank_ai_insights',
115 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',
116 209 'social-media' => 'thinkrank_social_media',
117 210 'social-platforms' => 'thinkrank_settings',
118 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',
119 215 'llms-txt' => 'thinkrank_crawling',
120 216 'instant-indexing' => 'thinkrank_instant_indexing',
121 217 'author-archives' => 'thinkrank_author_archives',
122 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',
123 225 'integrations' => 'thinkrank_settings',
124 226 'settings-management' => 'thinkrank_settings',
125 227 'settings' => 'thinkrank_settings',
126 228 'role-manager' => self::MANAGE_ROLES,
@@ -127,8 +229,63 @@
127 229 ];
128 230 }
129 231
130 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 + /**
131 288 * Whether the current user has a ThinkRank capability.
132 289 *
133 290 * Administrators (`manage_options`) always pass — this is the lock-out
134 291 * safety net and means the matrix never needs to touch the admin role.
@@ -150,8 +307,14 @@
150 307 * @param string $route Full REST route (e.g. /thinkrank/v1/schema/...).
151 308 * @return string
152 309 */
153 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 +
154 317 if (!preg_match('#/thinkrank(?:-pro)?/v1/([^/]+)#', $route, $m)) {
155 318 return self::ACCESS;
156 319 }
157 320 return self::route_map()[$m[1]] ?? self::ACCESS;
@@ -197,8 +360,37 @@
197 360 }
198 361 $roles[$slug] = translate_user_role($role['name']);
199 362 }
200 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);
201 393 }
202 394
203 395 /**
204 396 * The current assignment matrix: role slug => [capability slugs it has].