PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 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 All 200 releases
← All changes | includes/Core/Roles.php +376 -0 4.6.14.9.2 View file →
@@ -4,10 +4,69 @@
4 4
5 5 use WPDeveloper\BetterDocs\Utils\Base;
6 6 use WPDeveloper\BetterDocs\Utils\Database;
7 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 + */
8 39 class Roles extends Base {
9 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 + /**
10 69 * Summary of Database
11 70 * @var Database
12 71 */
13 72 public $database;
@@ -15,8 +74,23 @@
15 74 public function __construct( Database $database ) {
16 75 $this->database = $database;
17 76
18 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' ] );
19 93 }
20 94
21 95 /**
22 96 * Ensure the administrator role always has every BetterDocs capability.
@@ -150,8 +224,310 @@
150 224 ]
151 225 ];
152 226
153 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;
154 530 }
155 531
156 532 public function setup( $remove = false ) {
157 533 if ( $this->database->get( '_betterdocs_caps_initialized', false ) && ! $remove ) {