PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 3.5.3
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v3.5.3
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 +99 -534 4.9.23.5.3 View file →
@@ -4,555 +4,120 @@
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 - */
39 8 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';
9 + /**
10 + * Summary of Database
11 + * @var Database
12 + */
13 + public $database;
52 14
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' ];
15 + public function __construct( Database $database ) {
16 + $this->database = $database;
17 + }
67 18
68 - /**
69 - * Summary of Database
70 - * @var Database
71 - */
72 - public $database;
19 + /**
20 + * Default Roles Capabilities
21 + *
22 + * @var array
23 + */
24 + public function defaults_capabilities() {
25 + $default_capabilities = [
26 + 'administrator' => [
27 + // post type related caps
28 + 'edit_docs',
29 + 'edit_others_docs',
30 + 'edit_private_docs',
31 + 'edit_published_docs',
32 + 'read_private_docs',
33 + 'publish_docs',
34 + 'delete_docs',
35 + 'delete_private_docs',
36 + 'delete_published_docs',
37 + 'delete_others_docs',
73 38
74 - public function __construct( Database $database ) {
75 - $this->database = $database;
39 + // doc_terms related caps
40 + 'manage_doc_terms',
41 + 'edit_doc_terms',
42 + 'delete_doc_terms',
76 43
77 - $this->assign_admin_capabilities(); //will run when it is called
44 + // kb terms related caps
45 + 'manage_knowledge_base_terms',
46 + 'edit_knowledge_base_terms',
47 + 'delete_knowledge_base_terms',
78 48
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' ] );
49 + // Settings and Analytics Related caps
50 + 'edit_docs_settings',
51 + 'read_docs_analytics'
52 + ],
53 + 'editor' => [
54 + // post type related caps
55 + 'edit_docs',
56 + 'edit_others_docs',
57 + 'edit_private_docs',
58 + 'edit_published_docs',
59 + 'read_private_docs',
60 + 'publish_docs',
61 + 'delete_docs',
62 + 'delete_private_docs',
63 + 'delete_published_docs',
64 + 'delete_others_docs',
91 65
92 - add_action( 'admin_init', [ $this, 'repair_new_roles' ] );
93 - }
66 + // doc_terms related caps
67 + 'manage_doc_terms',
68 + 'edit_doc_terms',
69 + 'delete_doc_terms',
94 70
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 - }
71 + // kb terms related caps
72 + 'manage_knowledge_base_terms',
73 + 'edit_knowledge_base_terms',
74 + 'delete_knowledge_base_terms'
75 + ],
76 + 'author' => [
77 + 'edit_docs',
78 + 'edit_published_docs',
79 + 'publish_docs',
80 + 'delete_docs',
81 + 'delete_published_docs'
82 + ],
83 + 'contributor' => [
84 + 'edit_docs',
85 + 'delete_docs'
86 + ],
87 + 'other' => [
88 + // post type related caps
89 + 'edit_docs',
90 + 'delete_docs'
91 + ]
92 + ];
117 93
118 - $capabilities = $this->defaults_capabilities();
119 - $admin_caps = isset( $capabilities['administrator'] ) ? $capabilities['administrator'] : [];
94 + return apply_filters( 'betterdocs_default_caps', $default_capabilities );
95 + }
120 96
121 - if ( empty( $admin_caps ) ) {
122 - return;
123 - }
97 + public function setup( $remove = false ) {
98 + if ( $this->database->get( '_betterdocs_caps_initialized', false ) && ! $remove ) {
99 + return;
100 + }
124 101
125 - $administrator = get_role( 'administrator' );
126 - if ( ! $administrator ) {
127 - return;
128 - }
102 + global $wp_roles;
129 103
130 - foreach ( $admin_caps as $cap ) {
131 - if ( ! $administrator->has_cap( $cap ) ) {
132 - $administrator->add_cap( $cap );
133 - }
134 - }
135 - }
104 + $capabilities = $this->defaults_capabilities();
136 105
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 - }
106 + if ( $remove ) {
107 + unset( $capabilities['administrator'] );
108 + }
150 109
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',
110 + foreach ( $capabilities as $role => $caps ) {
111 + foreach ( $caps as $cap ) {
112 + if ( $remove ) {
113 + $wp_roles->remove_cap( $role, $cap );
114 + continue;
115 + }
170 116
171 - // doc_terms related caps
172 - 'manage_doc_terms',
173 - 'edit_doc_terms',
174 - 'delete_doc_terms',
117 + $wp_roles->add_cap( $role, $cap );
118 + }
119 + }
175 120
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 - }
121 + $this->database->get( '_betterdocs_caps_initialized', ! $remove );
122 + }
558 123 }