| 1 |
<?php |
| 2 |
/** |
| 3 |
* The aBlocks capability layer. |
| 4 |
* |
| 5 |
* Permission slugs ARE WordPress capability strings, so every call site in the |
| 6 |
* plugin is a plain current_user_can( 'ablocks_…' ). Nothing is ever written to |
| 7 |
* the database with add_cap(); Permissions\Caps grants them for the duration of |
| 8 |
* a request through the `user_has_cap` filter, so revoking is immediate and |
| 9 |
* deactivating aBlocks leaves a user with exactly the caps they started with. |
| 10 |
* |
| 11 |
* This half — the vocabulary and the bridge — is all aBlocks itself needs. On |
| 12 |
* its own it hands out one fixed arrangement: an administrator holds |
| 13 |
* everything, a role that can edit posts gets the editing capabilities, and |
| 14 |
* nobody else reaches an aBlocks screen. That is exactly what the plugin did |
| 15 |
* before capabilities had names, so installing or updating changes nothing. |
| 16 |
* |
| 17 |
* Configuring it — per role, per user, with presets and a screen to do it on — |
| 18 |
* is aBlocks Pro. Pro supplies the stored map through |
| 19 |
* `ablocks/permissions/role_grants` and `ablocks/permissions/user_grants`; see |
| 20 |
* docs/ROLE-PERMISSIONS.md. |
| 21 |
* |
| 22 |
* @package ABlocks |
| 23 |
*/ |
| 24 |
|
| 25 |
namespace ABlocks; |
| 26 |
|
| 27 |
if ( ! defined( 'ABSPATH' ) ) { |
| 28 |
exit; |
| 29 |
} |
| 30 |
|
| 31 |
use WP_User; |
| 32 |
|
| 33 |
class Permissions { |
| 34 |
|
| 35 |
/** |
| 36 |
* Reaching any aBlocks admin screen. Derived — anyone holding at least one |
| 37 |
* other capability holds this one too, so the top-level menu can be gated |
| 38 |
* without enumerating every permission at the call site. |
| 39 |
*/ |
| 40 |
const ACCESS = 'ablocks_access'; |
| 41 |
|
| 42 |
/** |
| 43 |
* Saving anything on the Settings screen. Derived from the three groups the |
| 44 |
* settings blob is partitioned into — the endpoint accepts the request and |
| 45 |
* SettingsGuard decides, key by key, which groups the user may actually |
| 46 |
* change. |
| 47 |
*/ |
| 48 |
const SAVE_SETTINGS = 'ablocks_save_settings'; |
| 49 |
|
| 50 |
public static function init() { |
| 51 |
Permissions\Caps::init(); |
| 52 |
} |
| 53 |
|
| 54 |
/** |
| 55 |
* Capabilities that unlock an aBlocks admin screen. |
| 56 |
* |
| 57 |
* Editing capabilities are absent on purpose: someone who may style blocks |
| 58 |
* has no reason to see the aBlocks menu, and showing them a top-level menu |
| 59 |
* whose every child is hidden is worse than showing nothing. |
| 60 |
* |
| 61 |
* @return string[] |
| 62 |
*/ |
| 63 |
public static function screen_capabilities() { |
| 64 |
return [ |
| 65 |
'ablocks_manage_settings', |
| 66 |
'ablocks_manage_global_styles', |
| 67 |
'ablocks_manage_performance', |
| 68 |
'ablocks_manage_forms', |
| 69 |
'ablocks_view_submissions', |
| 70 |
'ablocks_run_scanner', |
| 71 |
'ablocks_manage_addons', |
| 72 |
'ablocks_manage_theme_builder', |
| 73 |
'ablocks_import_templates', |
| 74 |
]; |
| 75 |
} |
| 76 |
|
| 77 |
/** |
| 78 |
* Capabilities computed from other capabilities. |
| 79 |
* |
| 80 |
* Not in the catalogue and not configurable — they exist so a call site can |
| 81 |
* ask one question instead of three. |
| 82 |
* |
| 83 |
* @return array Derived capability => the capabilities that imply it. |
| 84 |
*/ |
| 85 |
public static function derived_capabilities() { |
| 86 |
return [ |
| 87 |
self::ACCESS => self::screen_capabilities(), |
| 88 |
self::SAVE_SETTINGS => [ |
| 89 |
'ablocks_manage_settings', |
| 90 |
'ablocks_manage_global_styles', |
| 91 |
'ablocks_manage_performance', |
| 92 |
], |
| 93 |
]; |
| 94 |
} |
| 95 |
|
| 96 |
/** |
| 97 |
* Every capability this module hands out, grouped for the admin screen. |
| 98 |
* |
| 99 |
* @return array |
| 100 |
*/ |
| 101 |
public static function catalogue() { |
| 102 |
return apply_filters('ablocks/permissions/catalogue', [ |
| 103 |
'editing' => [ |
| 104 |
'label' => __( 'Editing', 'ablocks' ), |
| 105 |
'permissions' => [ |
| 106 |
'ablocks_use_editor' => [ |
| 107 |
'label' => __( 'Use aBlocks blocks', 'ablocks' ), |
| 108 |
'description' => __( 'Insert and edit aBlocks blocks in the post editor.', 'ablocks' ), |
| 109 |
], |
| 110 |
'ablocks_edit_style' => [ |
| 111 |
'label' => __( 'Change styling', 'ablocks' ), |
| 112 |
'description' => __( 'Colors, typography, backgrounds and borders. Without this a user gets content controls only.', 'ablocks' ), |
| 113 |
], |
| 114 |
'ablocks_edit_advanced' => [ |
| 115 |
'label' => __( 'Advanced settings', 'ablocks' ), |
| 116 |
'description' => __( 'Spacing, position, responsive visibility and animation.', 'ablocks' ), |
| 117 |
], |
| 118 |
'ablocks_edit_custom_css' => [ |
| 119 |
'label' => __( 'Custom CSS & attributes', 'ablocks' ), |
| 120 |
'description' => __( 'Separate from advanced settings on purpose — this one injects code into the page.', 'ablocks' ), |
| 121 |
], |
| 122 |
'ablocks_copy_paste_style' => [ |
| 123 |
'label' => __( 'Copy & paste styles', 'ablocks' ), |
| 124 |
'description' => __( 'Copy styles between blocks, and paste styles from Figma.', 'ablocks' ), |
| 125 |
], |
| 126 |
], |
| 127 |
], |
| 128 |
'design' => [ |
| 129 |
'label' => __( 'Site design', 'ablocks' ), |
| 130 |
'permissions' => [ |
| 131 |
'ablocks_manage_global_styles' => [ |
| 132 |
'label' => __( 'Manage the design system', 'ablocks' ), |
| 133 |
'description' => __( 'Global colors, typography presets and container defaults — these affect every page.', 'ablocks' ), |
| 134 |
], |
| 135 |
'ablocks_manage_theme_builder' => [ |
| 136 |
'label' => __( 'Theme Builder', 'ablocks' ), |
| 137 |
'description' => __( 'Build and assign headers, footers and other site-wide layouts.', 'ablocks' ), |
| 138 |
], |
| 139 |
'ablocks_access_site_editor' => [ |
| 140 |
'label' => __( 'WordPress Site Editor', 'ablocks' ), |
| 141 |
'description' => __( 'Templates, template parts, menus and widgets. Grants the WordPress edit_theme_options capability.', 'ablocks' ), |
| 142 |
], |
| 143 |
'ablocks_import_templates' => [ |
| 144 |
'label' => __( 'Import templates & demos', 'ablocks' ), |
| 145 |
'description' => __( 'Template kits and demo import create posts and media in bulk.', 'ablocks' ), |
| 146 |
], |
| 147 |
], |
| 148 |
], |
| 149 |
'admin' => [ |
| 150 |
'label' => __( 'aBlocks screens', 'ablocks' ), |
| 151 |
'permissions' => [ |
| 152 |
'ablocks_manage_settings' => [ |
| 153 |
'label' => __( 'Settings', 'ablocks' ), |
| 154 |
'description' => __( 'Editor options, page setup, visibility and integrations.', 'ablocks' ), |
| 155 |
], |
| 156 |
'ablocks_manage_performance' => [ |
| 157 |
'label' => __( 'Performance suite', 'ablocks' ), |
| 158 |
'description' => __( 'Caching, asset generation and image tools. A wrong toggle here affects every visitor.', 'ablocks' ), |
| 159 |
], |
| 160 |
'ablocks_manage_forms' => [ |
| 161 |
'label' => __( 'Form Builder', 'ablocks' ), |
| 162 |
'description' => __( 'Build forms and edit their settings.', 'ablocks' ), |
| 163 |
], |
| 164 |
'ablocks_view_submissions' => [ |
| 165 |
'label' => __( 'Form submissions', 'ablocks' ), |
| 166 |
'description' => __( 'Read and export what visitors submitted. This is personal data — grant it deliberately.', 'ablocks' ), |
| 167 |
], |
| 168 |
'ablocks_run_scanner' => [ |
| 169 |
'label' => __( 'Site Scanner', 'ablocks' ), |
| 170 |
'description' => __( 'Run the site scan and read its report.', 'ablocks' ), |
| 171 |
], |
| 172 |
'ablocks_manage_addons' => [ |
| 173 |
'label' => __( 'Add-ons', 'ablocks' ), |
| 174 |
'description' => __( 'Turn add-ons on and off. Only ever restricts — this screen installs plugins, so it always requires the WordPress plugin-installation capability as well.', 'ablocks' ), |
| 175 |
], |
| 176 |
], |
| 177 |
], |
| 178 |
]); |
| 179 |
} |
| 180 |
|
| 181 |
/** |
| 182 |
* Flat list of every capability slug. |
| 183 |
* |
| 184 |
* @return string[] |
| 185 |
*/ |
| 186 |
public static function all_slugs() { |
| 187 |
static $slugs = null; |
| 188 |
if ( null === $slugs ) { |
| 189 |
$slugs = []; |
| 190 |
foreach ( self::catalogue() as $group ) { |
| 191 |
$slugs = array_merge( $slugs, array_keys( $group['permissions'] ) ); |
| 192 |
} |
| 193 |
} |
| 194 |
return $slugs; |
| 195 |
} |
| 196 |
|
| 197 |
public static function is_valid_slug( $slug ) { |
| 198 |
return in_array( $slug, self::all_slugs(), true ); |
| 199 |
} |
| 200 |
|
| 201 |
/** |
| 202 |
* Capabilities that can never be granted by this module, only taken away. |
| 203 |
* |
| 204 |
* The add-ons screen installs plugins (Ajax\Dashboard::install_plugin), and a |
| 205 |
* permission system that can hand out plugin installation can hand out |
| 206 |
* everything. So this permission gates the screen for people who already |
| 207 |
* hold the WordPress capability, and does nothing for anyone else. |
| 208 |
* |
| 209 |
* @return array Slug => the WordPress capability the user must already hold. |
| 210 |
*/ |
| 211 |
public static function requires_native_cap() { |
| 212 |
return [ |
| 213 |
'ablocks_manage_addons' => 'install_plugins', |
| 214 |
]; |
| 215 |
} |
| 216 |
|
| 217 |
/** |
| 218 |
* Everything the block editor itself is gated on. |
| 219 |
* |
| 220 |
* The set a role that can edit posts holds by default, and the building |
| 221 |
* block Pro's presets start from. |
| 222 |
* |
| 223 |
* @return string[] |
| 224 |
*/ |
| 225 |
public static function editing_capabilities() { |
| 226 |
return [ |
| 227 |
'ablocks_use_editor', |
| 228 |
'ablocks_edit_style', |
| 229 |
'ablocks_edit_advanced', |
| 230 |
'ablocks_edit_custom_css', |
| 231 |
'ablocks_copy_paste_style', |
| 232 |
]; |
| 233 |
} |
| 234 |
|
| 235 |
/** |
| 236 |
* Sort + filter a grants list so two equivalent lists compare equal. |
| 237 |
* |
| 238 |
* @param array $grants |
| 239 |
* |
| 240 |
* @return string[] |
| 241 |
*/ |
| 242 |
public static function normalize( array $grants ) { |
| 243 |
$grants = array_values( array_unique( array_filter( $grants, [ __CLASS__, 'is_valid_slug' ] ) ) ); |
| 244 |
sort( $grants ); |
| 245 |
return $grants; |
| 246 |
} |
| 247 |
|
| 248 |
/** |
| 249 |
* What a role gets when nothing has configured it. |
| 250 |
* |
| 251 |
* Installing or updating aBlocks must not change what anybody can do. Any |
| 252 |
* role that can edit posts could already use every aBlocks block and |
| 253 |
* control, and no role but administrator could reach an aBlocks screen. That |
| 254 |
* is exactly what this returns. |
| 255 |
* |
| 256 |
* @param string $role_slug |
| 257 |
* |
| 258 |
* @return string[] |
| 259 |
*/ |
| 260 |
public static function default_grants_for_role( $role_slug ) { |
| 261 |
$role = get_role( $role_slug ); |
| 262 |
|
| 263 |
$grants = ( $role && ! empty( $role->capabilities['edit_posts'] ) ) |
| 264 |
? self::editing_capabilities() |
| 265 |
: []; |
| 266 |
|
| 267 |
return apply_filters( 'ablocks/permissions/default_role_grants', $grants, $role_slug ); |
| 268 |
} |
| 269 |
|
| 270 |
/** |
| 271 |
* The capabilities a role holds. |
| 272 |
* |
| 273 |
* The filter is where aBlocks Pro returns the map a site configured on the |
| 274 |
* Roles & Permissions screen. It may return an empty array — "this role gets |
| 275 |
* nothing" has to be expressible — so a filtered value replaces the default |
| 276 |
* rather than adding to it. |
| 277 |
* |
| 278 |
* @param string $role_slug |
| 279 |
* |
| 280 |
* @return string[] |
| 281 |
*/ |
| 282 |
public static function get_role_grants( $role_slug ) { |
| 283 |
$grants = apply_filters( |
| 284 |
'ablocks/permissions/role_grants', |
| 285 |
self::default_grants_for_role( $role_slug ), |
| 286 |
$role_slug |
| 287 |
); |
| 288 |
|
| 289 |
return self::normalize( (array) $grants ); |
| 290 |
} |
| 291 |
|
| 292 |
/** |
| 293 |
* Every capability a user effectively holds. |
| 294 |
* |
| 295 |
* Deliberately reads roles and meta directly and never calls user_can(). |
| 296 |
* Permissions\Caps calls this from inside the `user_has_cap` filter, so a |
| 297 |
* capability check in here would recurse. |
| 298 |
* |
| 299 |
* @param WP_User|int|null $user |
| 300 |
* |
| 301 |
* @return string[] |
| 302 |
*/ |
| 303 |
public static function for_user( $user = null ) { |
| 304 |
$user = self::resolve_user( $user ); |
| 305 |
|
| 306 |
if ( ! $user || ! $user->exists() ) { |
| 307 |
return []; |
| 308 |
} |
| 309 |
|
| 310 |
if ( self::is_real_admin( $user ) ) { |
| 311 |
return self::all_slugs(); |
| 312 |
} |
| 313 |
|
| 314 |
$grants = []; |
| 315 |
foreach ( (array) $user->roles as $role_slug ) { |
| 316 |
$grants = array_merge( $grants, self::get_role_grants( $role_slug ) ); |
| 317 |
} |
| 318 |
|
| 319 |
// Where aBlocks Pro applies a per-user override. It replaces the union of |
| 320 |
// the user's roles rather than adding to it, because an override has to be |
| 321 |
// able to take something away as well as give it. |
| 322 |
$grants = apply_filters( 'ablocks/permissions/user_grants', self::normalize( $grants ), $user ); |
| 323 |
$grants = self::normalize( (array) $grants ); |
| 324 |
|
| 325 |
// Permissions that can only ever restrict. Holding one without the |
| 326 |
// underlying WordPress capability means nothing. Applied after the filter |
| 327 |
// so an override cannot route around it either. |
| 328 |
foreach ( self::requires_native_cap() as $slug => $native ) { |
| 329 |
if ( in_array( $slug, $grants, true ) && empty( $user->allcaps[ $native ] ) ) { |
| 330 |
$grants = array_values( array_diff( $grants, [ $slug ] ) ); |
| 331 |
} |
| 332 |
} |
| 333 |
|
| 334 |
return $grants; |
| 335 |
} |
| 336 |
|
| 337 |
/** |
| 338 |
* Whether a user holds a capability. |
| 339 |
* |
| 340 |
* Everywhere except inside the `user_has_cap` filter itself, prefer plain |
| 341 |
* current_user_can( 'ablocks_…' ) — Caps makes that work. |
| 342 |
* |
| 343 |
* @param string $slug |
| 344 |
* @param WP_User|int|null $user |
| 345 |
* |
| 346 |
* @return bool |
| 347 |
*/ |
| 348 |
public static function user_can( $slug, $user = null ) { |
| 349 |
$grants = self::for_user( $user ); |
| 350 |
$derived = self::derived_capabilities(); |
| 351 |
|
| 352 |
if ( isset( $derived[ $slug ] ) ) { |
| 353 |
return ! empty( array_intersect( $derived[ $slug ], $grants ) ); |
| 354 |
} |
| 355 |
|
| 356 |
return in_array( $slug, $grants, true ); |
| 357 |
} |
| 358 |
|
| 359 |
/** |
| 360 |
* A real administrator, as opposed to somebody this module elevated. |
| 361 |
* |
| 362 |
* Reads the raw capability array rather than user_can(), because the |
| 363 |
* `user_has_cap` filter can change what user_can() returns and anything that |
| 364 |
* decides who may edit the permission map has to be immune to the thing it |
| 365 |
* configures. Every permission-management endpoint gates on this. |
| 366 |
* |
| 367 |
* @param WP_User|int|null $user |
| 368 |
* |
| 369 |
* @return bool |
| 370 |
*/ |
| 371 |
public static function is_real_admin( $user = null ) { |
| 372 |
$user = self::resolve_user( $user ); |
| 373 |
|
| 374 |
if ( ! $user || ! $user->exists() ) { |
| 375 |
return false; |
| 376 |
} |
| 377 |
|
| 378 |
if ( is_multisite() && is_super_admin( $user->ID ) ) { |
| 379 |
return true; |
| 380 |
} |
| 381 |
|
| 382 |
return ! empty( $user->allcaps['manage_options'] ); |
| 383 |
} |
| 384 |
|
| 385 |
/** |
| 386 |
* @param WP_User|int|null $user |
| 387 |
* |
| 388 |
* @return WP_User|null |
| 389 |
*/ |
| 390 |
private static function resolve_user( $user = null ) { |
| 391 |
if ( $user instanceof WP_User ) { |
| 392 |
return $user; |
| 393 |
} |
| 394 |
|
| 395 |
if ( is_numeric( $user ) && $user > 0 ) { |
| 396 |
$resolved = get_user_by( 'id', (int) $user ); |
| 397 |
return $resolved ?: null; |
| 398 |
} |
| 399 |
|
| 400 |
$current = wp_get_current_user(); |
| 401 |
|
| 402 |
return $current instanceof WP_User ? $current : null; |
| 403 |
} |
| 404 |
} |
| 405 |
|