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 +164 -3 2.1.12.7.0 View file →
@@ -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'),
@@ -61,8 +117,9 @@
61 117 'thinkrank_author_archives' => __('Author Archives', 'thinkrank'),
62 118 'thinkrank_content_tools' => __('AI Tools', 'thinkrank'),
63 119 'thinkrank_ai_insights' => __('AI Insights', 'thinkrank'),
64 120 'thinkrank_internal_links' => __('Internal Links', 'thinkrank'),
121 + 'thinkrank_external_links' => __('External Links', 'thinkrank'),
65 122 'thinkrank_redirections' => __('Redirections', 'thinkrank'),
66 123 'thinkrank_broken_links' => __('Broken Links', 'thinkrank'),
67 124 'thinkrank_woocommerce' => __('WooCommerce', 'thinkrank'),
68 125 'thinkrank_settings' => __('Settings & API Keys', 'thinkrank'),
@@ -67,8 +124,19 @@
67 124 'thinkrank_woocommerce' => __('WooCommerce', 'thinkrank'),
68 125 'thinkrank_settings' => __('Settings & API Keys', 'thinkrank'),
69 126 self::MANAGE_ROLES => __('Manage Roles', 'thinkrank'),
70 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;
71 139 }
72 140
73 141 /**
74 142 * Nav section id => required capability. Used by the SPA (localized) and
@@ -89,8 +157,9 @@
89 157 'instant-indexing' => 'thinkrank_instant_indexing',
90 158 'author-archives' => 'thinkrank_author_archives',
91 159 'ai-insights' => 'thinkrank_ai_insights',
92 160 'internal-links' => 'thinkrank_internal_links',
161 + 'external-links' => 'thinkrank_external_links',
93 162 'redirections' => 'thinkrank_redirections',
94 163 'broken-links' => 'thinkrank_broken_links',
95 164 'woocommerce' => 'thinkrank_woocommerce',
96 165 'integrations' => 'thinkrank_settings',
@@ -129,14 +198,15 @@
129 198 'global-seo' => 'thinkrank_global_seo',
130 199 'global-robot-meta' => 'thinkrank_crawling',
131 200 'image-seo' => 'thinkrank_image_seo',
132 201 'ai-insights' => 'thinkrank_ai_insights',
133 - // Brand Visibility is part of the AI Insights section.
134 - 'brand-visibility' => 'thinkrank_ai_insights',
135 202 'schema' => 'thinkrank_schema',
136 203 // Custom Schema (Pro) lives in the Schema Manager section but
137 204 // registers its own /custom-schema/ prefix.
138 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',
139 209 'social-media' => 'thinkrank_social_media',
140 210 'social-platforms' => 'thinkrank_settings',
141 211 'sitemap' => 'thinkrank_crawling',
142 212 // Publisher Sitemaps (Pro) is part of the Crawling & AI Indexing
@@ -145,8 +215,9 @@
145 215 'llms-txt' => 'thinkrank_crawling',
146 216 'instant-indexing' => 'thinkrank_instant_indexing',
147 217 'author-archives' => 'thinkrank_author_archives',
148 218 'internal-links' => 'thinkrank_internal_links',
219 + 'external-links' => 'thinkrank_external_links',
149 220 'redirections' => 'thinkrank_redirections',
150 221 'broken-links' => 'thinkrank_broken_links',
151 222 'woocommerce' => 'thinkrank_woocommerce',
152 223 // Multi-location (Pro) is managed inside Site Identity › Business Info.
@@ -158,8 +229,63 @@
158 229 ];
159 230 }
160 231
161 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 + /**
162 288 * Whether the current user has a ThinkRank capability.
163 289 *
164 290 * Administrators (`manage_options`) always pass — this is the lock-out
165 291 * safety net and means the matrix never needs to touch the admin role.
@@ -181,8 +307,14 @@
181 307 * @param string $route Full REST route (e.g. /thinkrank/v1/schema/...).
182 308 * @return string
183 309 */
184 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 +
185 317 if (!preg_match('#/thinkrank(?:-pro)?/v1/([^/]+)#', $route, $m)) {
186 318 return self::ACCESS;
187 319 }
188 320 return self::route_map()[$m[1]] ?? self::ACCESS;
@@ -228,8 +360,37 @@
228 360 }
229 361 $roles[$slug] = translate_user_role($role['name']);
230 362 }
231 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);
232 393 }
233 394
234 395 /**
235 396 * The current assignment matrix: role slug => [capability slugs it has].