PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.0
4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 3.5.2 All 199 releases
betterdocs / includes / Core / Roles.php

Roles.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.0, at includes/Core/Roles.php

559 lines 17.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPDeveloper\BetterDocs\Core;
4
5 use WPDeveloper\BetterDocs\Utils\Base;
6 use WPDeveloper\BetterDocs\Utils\Database;
7
8 /**
9 * BetterDocs roles and capabilities.
10 *
11 * Capabilities are granted on activation ({@see Install::activate()} →
12 * {@see self::setup()}), which covers the five roles WordPress ships with. Two
13 * gaps are repaired here:
14 *
15 * - a role **created after activation** never receives anything, because
16 * WordPress has no hook on `add_role()`;
17 * - a site that was activated silently (by another plugin) never ran
18 * `setup()` at all.
19 *
20 * {@see self::grant_caps_to_role()} is the repair, exposed to support and to
21 * site owners as an action:
22 *
23 * wp eval 'do_action( "betterdocs_grant_caps", "support" );'
24 *
25 * On a **Pro-active site** capability assignment belongs to Pro's Settings →
26 * Roles screen (ADR-033): Pro's `Core\Roles::saved_settings()` grants the
27 * bundle to every role listed in `article_roles`, and `reconcile_role_caps()`
28 * adds *and revokes* `edit_docs_settings` / `read_docs_analytics` /
29 * `read_faq_builder` on every admin load. Granting capabilities directly there
30 * would be undone on the next `admin_init` and would make the Settings screen
31 * lie about who holds what, so the repair writes the **setting** instead and
32 * lets Pro do the granting.
33 *
34 * Nothing here ever revokes.
35 *
36 * @since 4.9.0 grant_caps_to_role(), repair_new_roles(), bucket_for_role() and
37 * the `betterdocs_grant_caps` action.
38 */
39 class Roles extends Base {
40 /**
41 * Option holding the role slugs this site has already seen.
42 *
43 * Seeded (without granting) the first time {@see self::repair_new_roles()}
44 * runs, so an existing site is never changed retroactively; from then on
45 * anything absent from it is a role created after activation.
46 *
47 * @since 4.9.0
48 *
49 * @var string
50 */
51 const KNOWN_ROLES_OPTION = 'betterdocs_known_roles';
52
53 /**
54 * The capabilities BetterDocs Pro reconciles on every `admin_init`.
55 *
56 * Pro's `Core\Roles::reconcile_role_caps()` adds **and revokes** these three
57 * from `settings_roles` / `analytics_roles` / `faq_roles`, so writing them
58 * onto a role directly is undone on the next admin page load. The `edit_docs`
59 * bundle is not reconciled — Pro only grants it, on save — which is what
60 * makes {@see self::grant_caps_to_role()}'s top-up safe.
61 *
62 * @since 4.9.0
63 *
64 * @var string[]
65 */
66 const PRO_RECONCILED_CAPS = [ 'edit_docs_settings', 'read_docs_analytics', 'read_faq_builder' ];
67
68 /**
69 * Summary of Database
70 * @var Database
71 */
72 public $database;
73
74 public function __construct( Database $database ) {
75 $this->database = $database;
76
77 $this->assign_admin_capabilities(); //will run when it is called
78
79 /**
80 * Grant one role its default BetterDocs capabilities.
81 *
82 * The supported repair path for a role created after activation, and
83 * the one line support can hand a site owner:
84 * `wp eval 'do_action( "betterdocs_grant_caps", "support" );'`
85 *
86 * @since 4.9.0
87 *
88 * @param string $role Role slug.
89 */
90 add_action( 'betterdocs_grant_caps', [ $this, 'grant_caps_to_role' ] );
91
92 add_action( 'admin_init', [ $this, 'repair_new_roles' ] );
93 }
94
95 /**
96 * Ensure the administrator role always has every BetterDocs capability.
97 *
98 * Capabilities are normally granted on activation via Install::activate() ->
99 * setup(). But when BetterDocs is installed/activated silently by another
100 * plugin (e.g. Essential Addons from its Integrations screen, or BetterDocs
101 * Pro), the activation hook does not fire, so setup() never runs and the
102 * administrator never receives caps like edit_docs, edit_docs_settings or
103 * read_docs_analytics. That hides most of the BetterDocs admin menu (leaving
104 * only Quick Setup, gated by the core `delete_users` cap, and FAQ Builder)
105 * and makes the pages return "Sorry, you are not allowed to access this page."
106 *
107 * This self-heals that on every request: any missing admin cap is added back.
108 * add_cap() is only called for caps the role lacks, so once healed there are
109 * no further DB writes.
110 *
111 * @return void
112 */
113 public function assign_admin_capabilities() {
114 if ( ! current_user_can( 'administrator' ) ) {
115 return;
116 }
117
118 $capabilities = $this->defaults_capabilities();
119 $admin_caps = isset( $capabilities['administrator'] ) ? $capabilities['administrator'] : [];
120
121 if ( empty( $admin_caps ) ) {
122 return;
123 }
124
125 $administrator = get_role( 'administrator' );
126 if ( ! $administrator ) {
127 return;
128 }
129
130 foreach ( $admin_caps as $cap ) {
131 if ( ! $administrator->has_cap( $cap ) ) {
132 $administrator->add_cap( $cap );
133 }
134 }
135 }
136
137 /**
138 * Assign FAQ Builder Capability To The Admin
139 *
140 * @deprecated Use assign_admin_capabilities() which grants the full admin
141 * capability set (including read_faq_builder). Kept for backward
142 * compatibility with any external callers.
143 */
144 public function assgin_faq_builder_capability_to_admin() {
145 if( current_user_can('administrator') && ! current_user_can('read_faq_builder') ) { // if the current user is admin, and current user does not have faq menu visibility option, then assign the faq menu visibility capability
146 $current_user_role = get_role('administrator');
147 $current_user_role->add_cap('read_faq_builder');
148 }
149 }
150
151 /**
152 * Default Roles Capabilities
153 *
154 * @var array
155 */
156 public function defaults_capabilities() {
157 $default_capabilities = [
158 'administrator' => [
159 // post type related caps
160 'edit_docs',
161 'edit_others_docs',
162 'edit_private_docs',
163 'edit_published_docs',
164 'read_private_docs',
165 'publish_docs',
166 'delete_docs',
167 'delete_private_docs',
168 'delete_published_docs',
169 'delete_others_docs',
170
171 // doc_terms related caps
172 'manage_doc_terms',
173 'edit_doc_terms',
174 'delete_doc_terms',
175
176 // kb terms related caps
177 'manage_knowledge_base_terms',
178 'edit_knowledge_base_terms',
179 'delete_knowledge_base_terms',
180
181 // Settings and Analytics Related caps
182 'edit_docs_settings',
183 'read_docs_analytics',
184 'read_faq_builder'
185 ],
186 'editor' => [
187 // post type related caps
188 'edit_docs',
189 'edit_others_docs',
190 'edit_private_docs',
191 'edit_published_docs',
192 'read_private_docs',
193 'publish_docs',
194 'delete_docs',
195 'delete_private_docs',
196 'delete_published_docs',
197 'delete_others_docs',
198
199 // doc_terms related caps
200 'manage_doc_terms',
201 'edit_doc_terms',
202 'delete_doc_terms',
203
204 // kb terms related caps
205 'manage_knowledge_base_terms',
206 'edit_knowledge_base_terms',
207 'delete_knowledge_base_terms'
208 ],
209 'author' => [
210 'edit_docs',
211 'edit_published_docs',
212 'publish_docs',
213 'delete_docs',
214 'delete_published_docs'
215 ],
216 'contributor' => [
217 'edit_docs',
218 'delete_docs'
219 ],
220 'other' => [
221 // post type related caps
222 'edit_docs',
223 'delete_docs'
224 ]
225 ];
226
227 return apply_filters( 'betterdocs_default_caps', $default_capabilities );
228 }
229
230 /**
231 * The capability bucket a role slug belongs to.
232 *
233 * The five buckets are the keys of {@see self::defaults_capabilities()}
234 * (`administrator`, `editor`, `author`, `contributor`, `other`). A slug that
235 * is not itself a bucket — every role a site or another plugin created —
236 * falls to `other`, which is the deliberately small "can write and delete
237 * their own docs" set.
238 *
239 * Reads the filtered map, so a site that added a bucket through
240 * `betterdocs_default_caps` gets its own bucket back rather than `other`.
241 *
242 * @since 4.9.0
243 *
244 * @param string $role Role slug.
245 * @return string Bucket key.
246 */
247 public function bucket_for_role( $role ) {
248 $role = sanitize_key( (string) $role );
249 $capabilities = $this->defaults_capabilities();
250
251 if ( '' !== $role && isset( $capabilities[ $role ] ) && 'other' !== $role ) {
252 return $role;
253 }
254
255 return 'other';
256 }
257
258 /**
259 * Grant one role the capabilities of its bucket. Idempotent, never revokes.
260 *
261 * Two paths, because two different things own capability assignment:
262 *
263 * - **Free-only site** — adds the bucket's capabilities the role does not
264 * already hold, straight onto the role (`WP_Roles::add_cap()`).
265 * - **Pro active** — adds the role to the `article_roles` setting (and to
266 * `faq_roles` when the bucket is `editor`) through
267 * `Settings::save_settings()`, which fires `betterdocs::settings::saved`
268 * and lets Pro's `Core\Roles::saved_settings()` do the granting. Writing
269 * the capabilities here instead would be reverted by Pro's
270 * `reconcile_role_caps()` on the next `admin_init`, and Settings → Roles
271 * would show a role holding nothing it lists. `settings_roles` and
272 * `analytics_roles` are never touched: `edit_docs_settings` and
273 * `read_docs_analytics` are the two capabilities a site owner most likely
274 * meant to withhold. See ADR-033.
275 *
276 * Both paths do nothing at all when there is nothing to add, so the double
277 * registration on a Pro site (Free's `Roles` and Pro's subclass are separate
278 * container entries, and both constructors hook this action) costs one
279 * array comparison the second time round.
280 *
281 * The Pro path needs `edit_docs_settings` — `Settings::save_settings()`
282 * refuses otherwise — so run it as an administrator
283 * (`wp eval --user=1 '…'`). It returns an empty array and logs under
284 * `WP_DEBUG` when the save is refused.
285 *
286 * @since 4.9.0
287 *
288 * @param string $role Role slug. Must already exist.
289 * @param string|null $bucket Bucket to grant; defaults to the role's own.
290 * @return string[] The capabilities this call added, re-read from the role.
291 */
292 public function grant_caps_to_role( $role, $bucket = null ) {
293 $role = sanitize_key( (string) $role );
294
295 if ( '' === $role || ! function_exists( 'wp_roles' ) ) {
296 return [];
297 }
298
299 $roles = wp_roles();
300
301 if ( ! is_object( $roles ) || ! method_exists( $roles, 'is_role' ) || ! $roles->is_role( $role ) ) {
302 return [];
303 }
304
305 $bucket = null === $bucket ? $this->bucket_for_role( $role ) : sanitize_key( (string) $bucket );
306 $capabilities = $this->defaults_capabilities();
307 $caps = isset( $capabilities[ $bucket ] ) ? (array) $capabilities[ $bucket ] : (array) $capabilities['other'];
308
309 if ( empty( $caps ) ) {
310 return [];
311 }
312
313 $before = $this->role_caps( $role );
314
315 if ( $this->pro_owns_capabilities() ) {
316 $this->add_role_to_pro_settings( $role, $bucket );
317
318 // Pro grants on save, so a role that was *already* listed gets
319 // nothing from the line above — and a role can be listed while
320 // holding nothing at all: Pro's `Install::init()` runs
321 // `Roles::setup( true )` on the request after every Pro
322 // (re)activation, which strips the bundle from every non-admin role
323 // without touching the setting (measured 2026-08-23). Top the
324 // listed role up here. Only the bundle: the three capabilities Pro
325 // reconciles from its own settings are never written by hand.
326 if ( $this->listed_in_pro_settings( $role ) ) {
327 $held = $this->role_caps( $role );
328
329 foreach ( array_diff( $caps, self::PRO_RECONCILED_CAPS ) as $cap ) {
330 if ( ! in_array( $cap, $held, true ) ) {
331 $roles->add_cap( $role, $cap );
332 }
333 }
334 }
335 } else {
336 foreach ( $caps as $cap ) {
337 if ( ! in_array( $cap, $before, true ) ) {
338 $roles->add_cap( $role, $cap );
339 }
340 }
341 }
342
343 return array_values( array_intersect( $caps, array_diff( $this->role_caps( $role ), $before ) ) );
344 }
345
346 /**
347 * Grant the default bucket to every role this site has not seen before.
348 *
349 * Runs on `admin_init`. **Free-only sites only** — on a Pro site Settings →
350 * Roles is the source of truth and a new role is simply one more box to
351 * tick, so this returns immediately rather than quietly widening a list the
352 * site owner curates (ADR-033).
353 *
354 * The first run **seeds and grants nothing**: an existing site's roles were
355 * left as they are on purpose, and retroactively granting `edit_docs` to
356 * every custom role on upgrade would be a surprise. Only roles that appear
357 * after that first sweep are repaired.
358 *
359 * @since 4.9.0
360 *
361 * @return array<string,string[]> Role slug => capabilities granted.
362 */
363 public function repair_new_roles() {
364 if ( ! function_exists( 'wp_roles' ) || ! function_exists( 'betterdocs' ) ) {
365 return [];
366 }
367
368 if ( betterdocs()->is_pro_active() ) {
369 return [];
370 }
371
372 $roles = wp_roles();
373
374 if ( ! is_object( $roles ) || ! method_exists( $roles, 'get_names' ) ) {
375 return [];
376 }
377
378 $current = array_map( 'strval', array_keys( (array) $roles->get_names() ) );
379 $known = $this->database->get( self::KNOWN_ROLES_OPTION, false );
380
381 // First run: remember what is here, change nothing.
382 if ( ! is_array( $known ) ) {
383 $this->database->save( self::KNOWN_ROLES_OPTION, $current );
384
385 return [];
386 }
387
388 $known = array_map( 'strval', $known );
389 $granted = [];
390
391 foreach ( array_diff( $current, $known ) as $role ) {
392 $caps = $this->grant_caps_to_role( $role );
393
394 if ( ! empty( $caps ) ) {
395 $granted[ $role ] = $caps;
396 }
397 }
398
399 if ( array_values( $current ) !== array_values( $known ) ) {
400 $this->database->save( self::KNOWN_ROLES_OPTION, $current );
401 }
402
403 return $granted;
404 }
405
406 /**
407 * The capabilities a role currently holds, as a flat list of slugs.
408 *
409 * Reads `WP_Roles::$roles`, **not** `get_role()`. `WP_Roles::add_cap()`
410 * writes to `$roles` and to the `wp_user_roles` option but leaves the
411 * already-built `WP_Role` objects alone, so a `get_role()` taken after a
412 * grant in the same request still reports the capabilities the role had
413 * before it — measured on WordPress 7.1. `WP_Role::add_cap()`, which is what
414 * Pro uses, updates both, so this is correct for either path.
415 *
416 * @since 4.9.0
417 *
418 * @param string $role Role slug.
419 * @return string[]
420 */
421 protected function role_caps( $role ) {
422 $roles = function_exists( 'wp_roles' ) ? wp_roles() : null;
423
424 if ( is_object( $roles ) && isset( $roles->roles[ $role ]['capabilities'] ) && is_array( $roles->roles[ $role ]['capabilities'] ) ) {
425 return array_keys( array_filter( $roles->roles[ $role ]['capabilities'] ) );
426 }
427
428 $role_object = get_role( $role );
429
430 if ( ! is_object( $role_object ) || ! isset( $role_object->capabilities ) || ! is_array( $role_object->capabilities ) ) {
431 return [];
432 }
433
434 return array_keys( array_filter( $role_object->capabilities ) );
435 }
436
437 /**
438 * Whether BetterDocs Pro is the one assigning capabilities on this site.
439 *
440 * @since 4.9.0
441 *
442 * @return bool
443 */
444 protected function pro_owns_capabilities() {
445 return function_exists( 'betterdocs' ) && betterdocs()->is_pro_active();
446 }
447
448 /**
449 * Whether Pro's `article_roles` setting lists this role.
450 *
451 * @since 4.9.0
452 *
453 * @param string $role Role slug.
454 * @return bool
455 */
456 protected function listed_in_pro_settings( $role ) {
457 $settings = betterdocs()->settings;
458
459 if ( ! is_object( $settings ) || ! method_exists( $settings, 'get' ) ) {
460 return false;
461 }
462
463 $listed = (array) $settings->get( 'article_roles', [ 'administrator' ] );
464
465 return in_array( (string) $role, array_map( 'strval', $listed ), true );
466 }
467
468 /**
469 * Add a role to Pro's role settings, so Pro grants it the bundle.
470 *
471 * Writes nothing when the role is already listed, which is what makes
472 * {@see self::grant_caps_to_role()} idempotent on a Pro site.
473 *
474 * @since 4.9.0
475 *
476 * @param string $role Role slug.
477 * @param string $bucket Bucket being granted.
478 * @return bool Whether a save was attempted and succeeded.
479 */
480 protected function add_role_to_pro_settings( $role, $bucket ) {
481 $settings = betterdocs()->settings;
482
483 if ( ! is_object( $settings ) || ! method_exists( $settings, 'save_settings' ) ) {
484 return false;
485 }
486
487 $payload = [];
488 $keys = [ 'article_roles' ];
489
490 // `read_faq_builder` is not in the `editor` bucket, so the Free path
491 // never grants it; Pro keeps FAQ Builder access in its own setting and
492 // the rig's own baseline lists `editor` there. Only the editor bucket
493 // asks for it.
494 if ( 'editor' === $bucket ) {
495 $keys[] = 'faq_roles';
496 }
497
498 foreach ( $keys as $key ) {
499 $listed = (array) $settings->get( $key, [ 'administrator' ] );
500 $listed = array_values( array_unique( array_map( 'strval', $listed ) ) );
501
502 if ( ! in_array( $role, $listed, true ) ) {
503 $listed[] = $role;
504 $payload[ $key ] = $listed;
505 }
506 }
507
508 if ( empty( $payload ) ) {
509 return false;
510 }
511
512 $saved = $settings->save_settings( $payload );
513
514 if ( is_wp_error( $saved ) ) {
515 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
516 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated diagnostic.
517 error_log(
518 sprintf(
519 '[BD-MCP] Could not add the "%1$s" role to BetterDocs Pro\'s role settings: %2$s',
520 $role,
521 $saved->get_error_message()
522 )
523 );
524 }
525
526 return false;
527 }
528
529 return (bool) $saved;
530 }
531
532 public function setup( $remove = false ) {
533 if ( $this->database->get( '_betterdocs_caps_initialized', false ) && ! $remove ) {
534 return;
535 }
536
537 global $wp_roles;
538
539 $capabilities = $this->defaults_capabilities();
540
541 if ( $remove ) {
542 unset( $capabilities['administrator'] );
543 }
544
545 foreach ( $capabilities as $role => $caps ) {
546 foreach ( $caps as $cap ) {
547 if ( $remove ) {
548 $wp_roles->remove_cap( $role, $cap );
549 continue;
550 }
551
552 $wp_roles->add_cap( $role, $cap );
553 }
554 }
555
556 $this->database->save( '_betterdocs_caps_initialized', ! $remove );
557 }
558 }
559