blocks
1 month ago
dynamic-tags
1 week ago
pattern-library
1 month ago
utils
2 years ago
class-do-css.php
3 years ago
class-dynamic-content.php
1 week ago
class-dynamic-tag-security.php
1 week ago
class-enqueue-css.php
1 week ago
class-legacy-attributes.php
4 years ago
class-map-deprecated-attributes.php
3 years ago
class-meta-handler.php
1 week ago
class-plugin-update.php
1 year ago
class-query-loop.php
2 years ago
class-query-utils.php
1 week ago
class-render-blocks.php
1 week ago
class-rest.php
1 year ago
class-save-gate.php
1 week ago
class-settings.php
1 year ago
dashboard.php
1 week ago
defaults.php
1 year ago
deprecated.php
1 year ago
functions.php
1 week ago
general.php
1 week ago
class-save-gate.php
844 lines
| 1 | <?php |
| 2 | /** |
| 3 | * The save gate: capability-based save restrictions for post content. |
| 4 | * |
| 5 | * @package GenerateBlocks |
| 6 | */ |
| 7 | |
| 8 | if ( ! defined( 'ABSPATH' ) ) { |
| 9 | exit; // Exit if accessed directly. |
| 10 | } |
| 11 | |
| 12 | /** |
| 13 | * Class GenerateBlocks_Save_Gate |
| 14 | * |
| 15 | * A single enforcement spine for every "this user may not save that content" |
| 16 | * rule. The gate owns the save entry points and their shared exemption logic; |
| 17 | * rules supply only their detection predicate, capability check, and message. |
| 18 | * A rule registered here inherits coverage of every entry point below without |
| 19 | * knowing they exist — which is the point: entry-point coverage is proven once, |
| 20 | * here, instead of re-discovered by each feature. |
| 21 | * |
| 22 | * ENTRY POINTS — every path core uses to persist post content: |
| 23 | * |
| 24 | * - rest_pre_insert_{post_type} for REST creates/updates (the block editor). |
| 25 | * - rest_dispatch_request for Gutenberg autosaves, which core dispatches before |
| 26 | * pre-insert errors can propagate. |
| 27 | * - wp_insert_post_data for classic editor, XML-RPC, and programmatic saves. |
| 28 | * - wp_insert_attachment_data because core routes attachment saves through it, |
| 29 | * and an attachment's "description" field is its post_content. |
| 30 | * |
| 31 | * THE NO-NEW-EXPOSURE EXEMPTION (shared by every rule): a save whose content is |
| 32 | * byte-identical to the stored row authored nothing, so it is exempt as long as |
| 33 | * it also does not move the post to a more exposed status (draft/pending → |
| 34 | * publish/future/private, private → publish), strip the post's password, change |
| 35 | * its parent (an inherit-status attachment's effective exposure IS its parent's |
| 36 | * status), or change its post type. This lets restricted users edit |
| 37 | * titles/slugs/terms/meta of trusted-authored restricted posts, and unpublish |
| 38 | * or trash them — but never newly publish or newly expose one. |
| 39 | * |
| 40 | * RULES — registered via register_rule() with this shape: |
| 41 | * |
| 42 | * [ |
| 43 | * 'id' => 'dynamic_data', // Unique slug. |
| 44 | * 'applies' => callable( $content, $context ): bool, |
| 45 | * // Whether $content contains material this rule |
| 46 | * // restricts. $context = [ 'post_id', 'post_type' ]. |
| 47 | * 'user_can' => callable(): bool, // Whether the current user |
| 48 | * // may author that material. |
| 49 | * 'message' => callable(): string, // User-facing block message, |
| 50 | * // resolved at block time (or a plain string). |
| 51 | * 'error_code' => 'my_error_code', // WP_Error code on block. |
| 52 | * 'enforced' => callable( $context ): bool, // Optional. Rule-specific |
| 53 | * // enforcement toggle (e.g. a legacy filter). |
| 54 | * 'exempt' => callable( $content, $context ): bool, // Optional. |
| 55 | * // Rule-specific exemption checked after the shared |
| 56 | * // no-new-exposure exemption fails — for rules that |
| 57 | * // can prove a finer-grained save safe (e.g. a diff |
| 58 | * // that shows the restricted material is unchanged). |
| 59 | * ] |
| 60 | * |
| 61 | * Partner plugins register rules directly on plugins_loaded or later, behind a |
| 62 | * class_exists( 'GenerateBlocks_Save_Gate' ) check — there is no registration |
| 63 | * action to miss. Re-registering an id replaces that rule. |
| 64 | * |
| 65 | * CONTRACT: the gate is a convenience/authoring layer, not a security boundary |
| 66 | * on its own. Saves can predate a rule or arrive with the gate disabled by |
| 67 | * filter, so every rule needs its own authoritative guard at output time (the |
| 68 | * dynamic-data rule's is the render-time taint in |
| 69 | * GenerateBlocks_Dynamic_Tag_Security; a kses-stripped field's is kses itself). |
| 70 | * |
| 71 | * @since 2.4.0 |
| 72 | */ |
| 73 | class GenerateBlocks_Save_Gate extends GenerateBlocks_Singleton { |
| 74 | |
| 75 | /** |
| 76 | * Registered rules, keyed by rule id, in registration order. |
| 77 | * |
| 78 | * @var array<string, array> |
| 79 | */ |
| 80 | private $rules = []; |
| 81 | |
| 82 | /** |
| 83 | * Initialize all hooks. |
| 84 | * |
| 85 | * @return void |
| 86 | */ |
| 87 | public function init() { |
| 88 | add_action( 'rest_api_init', [ $this, 'register_rest_filters' ] ); |
| 89 | add_filter( 'rest_dispatch_request', [ $this, 'validate_autosave_rest_request' ], 10, 4 ); |
| 90 | |
| 91 | // PHP_INT_MAX: the gate must inspect the content that will actually be persisted, |
| 92 | // after any other plugin's filter has mutated it. Attachments need their own hook — |
| 93 | // core routes them through wp_insert_attachment_data instead, and an attachment's |
| 94 | // "description" field is its post_content. |
| 95 | add_filter( 'wp_insert_post_data', [ $this, 'validate_insert_post_data' ], PHP_INT_MAX, 4 ); |
| 96 | add_filter( 'wp_insert_attachment_data', [ $this, 'validate_insert_post_data' ], PHP_INT_MAX, 4 ); |
| 97 | } |
| 98 | |
| 99 | /** |
| 100 | * Register a save-gate rule. |
| 101 | * |
| 102 | * See the class doc block for the rule shape. Registering an id that already |
| 103 | * exists replaces that rule. A malformed rule is rejected so a broken |
| 104 | * registration can never half-enforce. |
| 105 | * |
| 106 | * @since 2.4.0 |
| 107 | * |
| 108 | * @param array $rule Rule definition. |
| 109 | * @return bool Whether the rule was registered. |
| 110 | */ |
| 111 | public function register_rule( $rule ) { |
| 112 | $valid = is_array( $rule ) && |
| 113 | isset( $rule['id'] ) && is_string( $rule['id'] ) && '' !== $rule['id'] && |
| 114 | isset( $rule['applies'] ) && is_callable( $rule['applies'] ) && |
| 115 | isset( $rule['user_can'] ) && is_callable( $rule['user_can'] ) && |
| 116 | isset( $rule['error_code'] ) && is_string( $rule['error_code'] ) && '' !== $rule['error_code'] && |
| 117 | isset( $rule['message'] ) && ( is_callable( $rule['message'] ) || is_string( $rule['message'] ) ) && |
| 118 | ( ! isset( $rule['enforced'] ) || is_callable( $rule['enforced'] ) ) && |
| 119 | ( ! isset( $rule['exempt'] ) || is_callable( $rule['exempt'] ) ); |
| 120 | |
| 121 | if ( ! $valid ) { |
| 122 | if ( function_exists( '_doing_it_wrong' ) ) { |
| 123 | _doing_it_wrong( |
| 124 | __METHOD__, |
| 125 | 'Save-gate rules require a non-empty id and error_code, callable applies and user_can, and a callable or string message.', |
| 126 | '2.4.0' |
| 127 | ); |
| 128 | } |
| 129 | |
| 130 | return false; |
| 131 | } |
| 132 | |
| 133 | $this->rules[ $rule['id'] ] = $rule; |
| 134 | |
| 135 | return true; |
| 136 | } |
| 137 | |
| 138 | /** |
| 139 | * Whether a rule id is registered. |
| 140 | * |
| 141 | * @since 2.4.0 |
| 142 | * |
| 143 | * @param string $rule_id Rule id. |
| 144 | * @return bool |
| 145 | */ |
| 146 | public function has_rule( $rule_id ) { |
| 147 | return is_string( $rule_id ) && isset( $this->rules[ $rule_id ] ); |
| 148 | } |
| 149 | |
| 150 | /** |
| 151 | * Determine whether a prospective save is restricted, before exemptions. |
| 152 | * |
| 153 | * This is the pure decision layer — enforcement toggles, user context, the |
| 154 | * rule's capability check, and its content predicate. The per-save |
| 155 | * exemptions (byte-identical content, no new exposure) are applied by the |
| 156 | * entry points, not here. |
| 157 | * |
| 158 | * @since 2.4.0 |
| 159 | * |
| 160 | * @param string $content Incoming post content, already unslashed by the caller. |
| 161 | * @param int $post_id Existing post ID, or 0 for a new post. |
| 162 | * @param string $post_type Post type being saved. |
| 163 | * @param string $rule_id Optional. Restrict the check to one rule id; |
| 164 | * empty checks every registered rule. |
| 165 | * @return bool True when the save is restricted. |
| 166 | */ |
| 167 | public function save_is_restricted( $content, $post_id, $post_type, $rule_id = '' ) { |
| 168 | $context = self::build_context( $post_id, $post_type ); |
| 169 | |
| 170 | foreach ( $this->rules as $id => $rule ) { |
| 171 | if ( '' !== $rule_id && $rule_id !== $id ) { |
| 172 | continue; |
| 173 | } |
| 174 | |
| 175 | if ( $this->rule_restricts_save( $rule, $content, $context ) ) { |
| 176 | return true; |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | return false; |
| 181 | } |
| 182 | |
| 183 | /** |
| 184 | * Register REST pre-insert save gate filters for REST-exposed post types. |
| 185 | * |
| 186 | * @since 2.4.0 |
| 187 | * |
| 188 | * @return void |
| 189 | */ |
| 190 | public function register_rest_filters() { |
| 191 | if ( ! function_exists( 'get_post_types' ) ) { |
| 192 | return; |
| 193 | } |
| 194 | |
| 195 | $post_types = get_post_types( |
| 196 | [ |
| 197 | 'show_in_rest' => true, |
| 198 | ], |
| 199 | 'names' |
| 200 | ); |
| 201 | |
| 202 | foreach ( $post_types as $post_type ) { |
| 203 | add_filter( "rest_pre_insert_{$post_type}", [ $this, 'validate_rest_save' ], 10, 2 ); |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | /** |
| 208 | * Block REST saves that introduce restricted content for restricted users. |
| 209 | * |
| 210 | * @since 2.4.0 |
| 211 | * |
| 212 | * @param object $prepared_post An object representing the post prepared for the database. |
| 213 | * @param object $request Request object. |
| 214 | * @return object|WP_Error The prepared post, or a restriction error. |
| 215 | */ |
| 216 | public function validate_rest_save( $prepared_post, $request ) { |
| 217 | unset( $request ); |
| 218 | |
| 219 | if ( ! is_object( $prepared_post ) ) { |
| 220 | return $prepared_post; |
| 221 | } |
| 222 | |
| 223 | $post_id = ! empty( $prepared_post->ID ) ? absint( $prepared_post->ID ) : 0; |
| 224 | $post_type = isset( $prepared_post->post_type ) && is_string( $prepared_post->post_type ) ? $prepared_post->post_type : ''; |
| 225 | |
| 226 | // Fall back to the stored content for content-less updates so a re-save/publish of a |
| 227 | // restricted post is still gated on the post's real content. |
| 228 | $content = self::resolve_effective_post_content( $prepared_post, $post_id ); |
| 229 | |
| 230 | if ( '' === $content ) { |
| 231 | return $prepared_post; |
| 232 | } |
| 233 | |
| 234 | // Core only sets a field on the prepared object when the request submitted it; an unset |
| 235 | // field keeps the stored value, which is_no_new_exposure_save() expresses as null. This |
| 236 | // exemption also subsumes a status-only trash: it matches the stored content and steps |
| 237 | // exposure down (exempt), while restricted content smuggled in alongside a trash status |
| 238 | // fails the byte match and stays blocked. |
| 239 | $new_status = isset( $prepared_post->post_status ) && is_string( $prepared_post->post_status ) ? $prepared_post->post_status : null; |
| 240 | $new_password = isset( $prepared_post->post_password ) && is_string( $prepared_post->post_password ) ? $prepared_post->post_password : null; |
| 241 | $new_parent = isset( $prepared_post->post_parent ) ? absint( $prepared_post->post_parent ) : null; |
| 242 | $new_type = '' !== $post_type ? $post_type : null; |
| 243 | |
| 244 | $blocking_rule = $this->get_blocking_rule( |
| 245 | $content, |
| 246 | self::build_context( $post_id, $post_type ), |
| 247 | static function() use ( $content, $post_id, $new_status, $new_password, $new_parent, $new_type ) { |
| 248 | return self::is_no_new_exposure_save( $content, $post_id, $new_status, $new_password, $new_parent, $new_type ); |
| 249 | } |
| 250 | ); |
| 251 | |
| 252 | if ( $blocking_rule ) { |
| 253 | return self::get_rule_error( $blocking_rule ); |
| 254 | } |
| 255 | |
| 256 | return $prepared_post; |
| 257 | } |
| 258 | |
| 259 | /** |
| 260 | * Intercept Gutenberg autosave REST requests before core bypasses pre-insert errors. |
| 261 | * |
| 262 | * The trailing parameters are optional so a legacy three-argument caller |
| 263 | * degrades to a no-op pass-through instead of fataling on arity. |
| 264 | * |
| 265 | * @since 2.4.0 |
| 266 | * |
| 267 | * @param mixed $response Response to replace the requested version with. Default null. |
| 268 | * @param object $request Request used to generate the response. |
| 269 | * @param string $route Matched route. |
| 270 | * @param array $handler Route handler used for the request. |
| 271 | * @return mixed Either the original response or a WP_Error to halt dispatch. |
| 272 | */ |
| 273 | public function validate_autosave_rest_request( $response, $request = null, $route = '', $handler = null ) { |
| 274 | unset( $handler ); |
| 275 | |
| 276 | if ( null !== $response ) { |
| 277 | return $response; |
| 278 | } |
| 279 | |
| 280 | if ( ! is_object( $request ) || ! method_exists( $request, 'get_method' ) || 'POST' !== $request->get_method() ) { |
| 281 | return $response; |
| 282 | } |
| 283 | |
| 284 | if ( ! is_string( $route ) || false === strpos( $route, '/autosaves' ) || ! preg_match( '#/autosaves/?$#', $route ) ) { |
| 285 | return $response; |
| 286 | } |
| 287 | |
| 288 | $content = self::get_rest_request_content_param( $request ); |
| 289 | |
| 290 | if ( null === $content ) { |
| 291 | return $response; |
| 292 | } |
| 293 | |
| 294 | // Resolve the parent post from the concrete request path so rule callbacks receive |
| 295 | // real context (post_id/post_type), letting a site scope a rule per post type for |
| 296 | // autosaves too. The $route argument is the registered pattern and carries no literal ID. |
| 297 | $post_id = self::get_autosave_route_parent_id( $request ); |
| 298 | $post_type = $post_id && function_exists( 'get_post_type' ) ? get_post_type( $post_id ) : ''; |
| 299 | $post_type = is_string( $post_type ) ? $post_type : ''; |
| 300 | |
| 301 | $blocking_rule = $this->get_blocking_rule( |
| 302 | $content, |
| 303 | self::build_context( $post_id, $post_type ), |
| 304 | static function() use ( $content, $post_id ) { |
| 305 | // A byte-identical autosave persists nothing new, and an autosave can never |
| 306 | // change the parent row's status or password, so no exposure check is needed. |
| 307 | // Template autosaves resolve no numeric parent (post_id 0) and stay blocked. |
| 308 | return self::content_matches_stored( $content, $post_id ); |
| 309 | } |
| 310 | ); |
| 311 | |
| 312 | if ( $blocking_rule ) { |
| 313 | return self::get_rule_error( $blocking_rule ); |
| 314 | } |
| 315 | |
| 316 | return $response; |
| 317 | } |
| 318 | |
| 319 | /** |
| 320 | * Catch classic/editor/programmatic saves that bypass REST pre-insert checks. |
| 321 | * |
| 322 | * Hooked on both wp_insert_post_data and wp_insert_attachment_data: core routes |
| 323 | * attachment saves (media modal / edit-media screen description = post_content) |
| 324 | * through the latter. |
| 325 | * |
| 326 | * @since 2.4.0 |
| 327 | * |
| 328 | * @param array $data Slashed post data. |
| 329 | * @param array $postarr Sanitized post array. |
| 330 | * @param array $unsanitized_postarr Original unsanitized post array. |
| 331 | * @param bool $update Whether this is an update. |
| 332 | * @return array Slashed post data. |
| 333 | */ |
| 334 | public function validate_insert_post_data( $data, $postarr, $unsanitized_postarr = null, $update = null ) { |
| 335 | unset( $unsanitized_postarr, $update ); |
| 336 | |
| 337 | if ( ! is_array( $data ) ) { |
| 338 | return $data; |
| 339 | } |
| 340 | |
| 341 | $is_revision = isset( $data['post_type'] ) && 'revision' === $data['post_type']; |
| 342 | $is_autosave_revision = $is_revision && self::is_autosave_revision_data( $data, $postarr ); |
| 343 | |
| 344 | if ( $is_revision && ! $is_autosave_revision ) { |
| 345 | return $data; |
| 346 | } |
| 347 | |
| 348 | // For updates, core has already merged the stored row into $data, so $data['post_content'] |
| 349 | // is the content that will actually be persisted (submitted or unchanged). Gate on it |
| 350 | // directly — there is no reliable "was content submitted?" signal at this layer. Note |
| 351 | // sanitize_post() (kses for restricted users) has already run on $data here, so a stored |
| 352 | // row holding kses-hostile trusted markup won't byte-match below and the exemption fails |
| 353 | // closed to the full gate. |
| 354 | $content = self::unslash_post_content( isset( $data['post_content'] ) ? $data['post_content'] : '' ); |
| 355 | $post_id = $is_autosave_revision && isset( $data['post_parent'] ) ? absint( $data['post_parent'] ) : ( isset( $postarr['ID'] ) ? absint( $postarr['ID'] ) : 0 ); |
| 356 | $post_type = isset( $data['post_type'] ) && is_string( $data['post_type'] ) ? $data['post_type'] : ''; |
| 357 | |
| 358 | if ( $is_autosave_revision && $post_id && function_exists( 'get_post_type' ) ) { |
| 359 | $parent_post_type = get_post_type( $post_id ); |
| 360 | |
| 361 | if ( is_string( $parent_post_type ) ) { |
| 362 | $post_type = $parent_post_type; |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | if ( $is_autosave_revision ) { |
| 367 | // A byte-identical autosave persists nothing new, and an autosave never changes |
| 368 | // the parent row's status or password, so no exposure check applies. Compare |
| 369 | // against the PARENT's stored content ($post_id is the parent here). |
| 370 | $is_generically_exempt = static function() use ( $content, $post_id ) { |
| 371 | return self::content_matches_stored( $content, $post_id ); |
| 372 | }; |
| 373 | } else { |
| 374 | $new_status = isset( $data['post_status'] ) && is_string( $data['post_status'] ) ? $data['post_status'] : ''; |
| 375 | $new_password = isset( $data['post_password'] ) && is_string( $data['post_password'] ) ? $data['post_password'] : ''; |
| 376 | $new_parent = isset( $data['post_parent'] ) ? absint( $data['post_parent'] ) : null; |
| 377 | $new_type = '' !== $post_type ? $post_type : null; |
| 378 | |
| 379 | // Content unchanged and no exposure increase: metadata edits, status step-downs, |
| 380 | // and trash (wp_trash_post() re-saves the stored content unchanged) all pass. A |
| 381 | // trash request that also rewrites the content fails the byte match and stays |
| 382 | // gated, as does any transition toward publish/future, a password removal, a |
| 383 | // parent change, or a post-type change. |
| 384 | $is_generically_exempt = static function() use ( $content, $post_id, $new_status, $new_password, $new_parent, $new_type ) { |
| 385 | return self::is_no_new_exposure_save( $content, $post_id, $new_status, $new_password, $new_parent, $new_type ); |
| 386 | }; |
| 387 | } |
| 388 | |
| 389 | $blocking_rule = $this->get_blocking_rule( |
| 390 | $content, |
| 391 | self::build_context( $post_id, $post_type ), |
| 392 | $is_generically_exempt |
| 393 | ); |
| 394 | |
| 395 | if ( $blocking_rule ) { |
| 396 | wp_die( |
| 397 | esc_html( self::get_rule_message( $blocking_rule ) ), |
| 398 | '', |
| 399 | [ |
| 400 | 'response' => 403, |
| 401 | 'back_link' => true, |
| 402 | ] |
| 403 | ); |
| 404 | } |
| 405 | |
| 406 | return $data; |
| 407 | } |
| 408 | |
| 409 | /** |
| 410 | * Find the first registered rule that both restricts and is not exempted |
| 411 | * from a prospective save. |
| 412 | * |
| 413 | * The shared exemption is rule-independent — content byte-identical to the |
| 414 | * stored row authored nothing under ANY rule — so it is evaluated once, |
| 415 | * lazily, when the first rule restricts, and short-circuits every rule. A |
| 416 | * rule's own 'exempt' callback only skips that rule. |
| 417 | * |
| 418 | * @since 2.4.0 |
| 419 | * |
| 420 | * @param string $content Incoming post content, already unslashed. |
| 421 | * @param array $context Save context ('post_id', 'post_type'). |
| 422 | * @param callable $is_generically_exempt Lazy evaluator for the entry point's |
| 423 | * shared exemption. |
| 424 | * @return array|null The blocking rule, or null when the save may proceed. |
| 425 | */ |
| 426 | private function get_blocking_rule( $content, $context, $is_generically_exempt ) { |
| 427 | $generic_exempt = null; |
| 428 | |
| 429 | foreach ( $this->rules as $rule ) { |
| 430 | if ( ! $this->rule_restricts_save( $rule, $content, $context ) ) { |
| 431 | continue; |
| 432 | } |
| 433 | |
| 434 | if ( null === $generic_exempt ) { |
| 435 | $generic_exempt = (bool) call_user_func( $is_generically_exempt ); |
| 436 | } |
| 437 | |
| 438 | if ( $generic_exempt ) { |
| 439 | return null; |
| 440 | } |
| 441 | |
| 442 | if ( isset( $rule['exempt'] ) && call_user_func( $rule['exempt'], $content, $context ) ) { |
| 443 | continue; |
| 444 | } |
| 445 | |
| 446 | return $rule; |
| 447 | } |
| 448 | |
| 449 | return null; |
| 450 | } |
| 451 | |
| 452 | /** |
| 453 | * Whether a single rule restricts a prospective save, before exemptions. |
| 454 | * |
| 455 | * @since 2.4.0 |
| 456 | * |
| 457 | * @param array $rule Rule definition. |
| 458 | * @param string $content Incoming post content, already unslashed. |
| 459 | * @param array $context Save context ('post_id', 'post_type'). |
| 460 | * @return bool True when the rule restricts the save. |
| 461 | */ |
| 462 | private function rule_restricts_save( $rule, $content, $context ) { |
| 463 | /** |
| 464 | * Whether to enforce a save-gate rule. |
| 465 | * |
| 466 | * @since 2.4.0 |
| 467 | * |
| 468 | * @param bool $enforce Whether to enforce the rule. |
| 469 | * @param string $rule_id Rule id. |
| 470 | * @param array $context Save context. |
| 471 | */ |
| 472 | $enforce = apply_filters( 'generateblocks_enforce_save_gate_rule', true, $rule['id'], $context ); |
| 473 | |
| 474 | if ( ! $enforce ) { |
| 475 | return false; |
| 476 | } |
| 477 | |
| 478 | if ( isset( $rule['enforced'] ) && ! call_user_func( $rule['enforced'], $context ) ) { |
| 479 | return false; |
| 480 | } |
| 481 | |
| 482 | // No user context (cron, WP-CLI, other system saves) is never gated: there is no |
| 483 | // author to attribute the content to, and each rule's output-time guard is the |
| 484 | // authoritative layer for content that reaches the database anyway. |
| 485 | $user_id = function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0; |
| 486 | |
| 487 | if ( ! $user_id ) { |
| 488 | return false; |
| 489 | } |
| 490 | |
| 491 | if ( call_user_func( $rule['user_can'] ) ) { |
| 492 | return false; |
| 493 | } |
| 494 | |
| 495 | return (bool) call_user_func( $rule['applies'], $content, $context ); |
| 496 | } |
| 497 | |
| 498 | /** |
| 499 | * Build the save context passed to rule callbacks and filters. |
| 500 | * |
| 501 | * @since 2.4.0 |
| 502 | * |
| 503 | * @param int $post_id Existing post ID, or 0 for a new post. |
| 504 | * @param string $post_type Post type being saved. |
| 505 | * @return array Save context. |
| 506 | */ |
| 507 | protected static function build_context( $post_id, $post_type ) { |
| 508 | return [ |
| 509 | 'post_id' => absint( $post_id ), |
| 510 | 'post_type' => is_string( $post_type ) ? $post_type : '', |
| 511 | ]; |
| 512 | } |
| 513 | |
| 514 | /** |
| 515 | * Resolve a rule's user-facing block message. |
| 516 | * |
| 517 | * @since 2.4.0 |
| 518 | * |
| 519 | * @param array $rule Rule definition. |
| 520 | * @return string Block message. |
| 521 | */ |
| 522 | protected static function get_rule_message( $rule ) { |
| 523 | $message = isset( $rule['message'] ) ? $rule['message'] : ''; |
| 524 | |
| 525 | if ( is_callable( $message ) ) { |
| 526 | $message = call_user_func( $message ); |
| 527 | } |
| 528 | |
| 529 | return is_string( $message ) ? $message : ''; |
| 530 | } |
| 531 | |
| 532 | /** |
| 533 | * Build the WP_Error returned when a rule blocks a save. |
| 534 | * |
| 535 | * @since 2.4.0 |
| 536 | * |
| 537 | * @param array $rule Rule definition. |
| 538 | * @return WP_Error Restriction error. |
| 539 | */ |
| 540 | protected static function get_rule_error( $rule ) { |
| 541 | return new WP_Error( |
| 542 | $rule['error_code'], |
| 543 | self::get_rule_message( $rule ), |
| 544 | [ 'status' => 403 ] |
| 545 | ); |
| 546 | } |
| 547 | |
| 548 | /** |
| 549 | * Whether the given content is identical to the stored post's content. |
| 550 | * |
| 551 | * The anchor of the no-new-exposure exemption: a save whose content matches the stored |
| 552 | * row byte-for-byte authored nothing. A new post (post_id 0) has no stored row, so any |
| 553 | * content is treated as changed, and any mutation another filter (or kses) applied to |
| 554 | * the incoming content fails the match — the exemption fails closed to the full gate. |
| 555 | * |
| 556 | * @since 2.4.0 |
| 557 | * |
| 558 | * @param string $content Unslashed content that will be persisted. |
| 559 | * @param int $post_id Existing post ID, or 0 for a new post. |
| 560 | * @return bool True when the content matches the stored row. |
| 561 | */ |
| 562 | protected static function content_matches_stored( $content, $post_id ) { |
| 563 | $post_id = absint( $post_id ); |
| 564 | |
| 565 | if ( ! $post_id || ! function_exists( 'get_post' ) ) { |
| 566 | return false; |
| 567 | } |
| 568 | |
| 569 | $stored = get_post( $post_id ); |
| 570 | |
| 571 | if ( ! is_object( $stored ) || ! isset( $stored->post_content ) || ! is_string( $stored->post_content ) ) { |
| 572 | return false; |
| 573 | } |
| 574 | |
| 575 | return $stored->post_content === $content; |
| 576 | } |
| 577 | |
| 578 | /** |
| 579 | * Whether a restricted save may pass the gate because it persists the stored content |
| 580 | * unchanged AND does not increase how exposed that content is. |
| 581 | * |
| 582 | * The gate blocks restricted users from AUTHORING restricted content. A save whose |
| 583 | * content is byte-identical to the stored row authored nothing — but it can still |
| 584 | * newly expose trusted-authored restricted content by moving the post to a more |
| 585 | * public status (draft → publish/future, private → publish), by removing its |
| 586 | * password, by reparenting it, or by changing its post type. Those transitions stay |
| 587 | * blocked; everything else (metadata edits, status step-downs, trash) passes. A new |
| 588 | * post has no stored row and is never exempt. |
| 589 | * |
| 590 | * @since 2.4.0 |
| 591 | * |
| 592 | * @param string $content Unslashed content that will be persisted. |
| 593 | * @param int $post_id Existing post ID, or 0 for a new post. |
| 594 | * @param string|null $new_status Status being saved, or null when the save leaves it unchanged. |
| 595 | * @param string|null $new_password Password being saved, or null when the save leaves it unchanged. |
| 596 | * @param int|null $new_parent Parent ID being saved, or null when the save leaves it unchanged. |
| 597 | * @param string|null $new_type Post type being saved, or null when the save leaves it unchanged. |
| 598 | * @return bool True when the save is exempt from the gate. |
| 599 | */ |
| 600 | protected static function is_no_new_exposure_save( $content, $post_id, $new_status = null, $new_password = null, $new_parent = null, $new_type = null ) { |
| 601 | $post_id = absint( $post_id ); |
| 602 | |
| 603 | if ( ! $post_id || ! self::content_matches_stored( $content, $post_id ) ) { |
| 604 | return false; |
| 605 | } |
| 606 | |
| 607 | $stored = function_exists( 'get_post' ) ? get_post( $post_id ) : null; |
| 608 | |
| 609 | if ( ! is_object( $stored ) ) { |
| 610 | return false; |
| 611 | } |
| 612 | |
| 613 | $old_status = isset( $stored->post_status ) && is_string( $stored->post_status ) ? $stored->post_status : ''; |
| 614 | $old_password = isset( $stored->post_password ) && is_string( $stored->post_password ) ? $stored->post_password : ''; |
| 615 | $old_parent = isset( $stored->post_parent ) ? absint( $stored->post_parent ) : 0; |
| 616 | $old_type = isset( $stored->post_type ) && is_string( $stored->post_type ) ? $stored->post_type : ''; |
| 617 | |
| 618 | // A parent change can raise EFFECTIVE exposure while the literal status stays put: |
| 619 | // core resolves an inherit-status attachment's visibility through its parent, and |
| 620 | // treats an unattached attachment as published — so reparenting a draft-attached |
| 621 | // restricted attachment to a published post (or to 0) newly exposes it. A post-type |
| 622 | // change can likewise move content from a non-public type into a public one. |
| 623 | // Neither is provable-safe from here, so both fail closed to the full gate — |
| 624 | // exactly where every such save landed before this exemption existed. |
| 625 | if ( null !== $new_parent && absint( $new_parent ) !== $old_parent ) { |
| 626 | return false; |
| 627 | } |
| 628 | |
| 629 | if ( null !== $new_type && $new_type !== $old_type ) { |
| 630 | return false; |
| 631 | } |
| 632 | |
| 633 | // Removing the password from a password-protected post newly exposes its rendered |
| 634 | // content even though status and content are unchanged. Setting or changing a |
| 635 | // password only ever narrows exposure. |
| 636 | if ( null !== $new_password && '' !== $old_password && '' === $new_password ) { |
| 637 | return false; |
| 638 | } |
| 639 | |
| 640 | if ( null === $new_status ) { |
| 641 | $new_status = $old_status; |
| 642 | } |
| 643 | |
| 644 | return ! self::status_transition_increases_exposure( $old_status, $new_status ); |
| 645 | } |
| 646 | |
| 647 | /** |
| 648 | * Whether a status transition makes a post's rendered content more publicly exposed. |
| 649 | * |
| 650 | * Identical statuses (including unknown/custom ones) never increase exposure. For a |
| 651 | * real transition, unknown statuses fail closed on both sides: an unrecognized NEW |
| 652 | * status ranks fully public (blocked unless the post already was), an unrecognized |
| 653 | * OLD status ranks unexposed. |
| 654 | * |
| 655 | * @since 2.4.0 |
| 656 | * |
| 657 | * @param string $old_status Stored post status. |
| 658 | * @param string $new_status Status being saved. |
| 659 | * @return bool True when the transition increases exposure. |
| 660 | */ |
| 661 | protected static function status_transition_increases_exposure( $old_status, $new_status ) { |
| 662 | if ( (string) $new_status === (string) $old_status ) { |
| 663 | return false; |
| 664 | } |
| 665 | |
| 666 | return self::get_status_exposure_rank( $new_status, 3 ) > self::get_status_exposure_rank( $old_status, 1 ); |
| 667 | } |
| 668 | |
| 669 | /** |
| 670 | * Rank how exposed a post status makes rendered content. |
| 671 | * |
| 672 | * 3 = world ('future' counts: cron flips it to 'publish' with no user context, so it |
| 673 | * must rank at scheduling time), 2 = privileged viewers only, 1 = not rendered on the |
| 674 | * frontend. |
| 675 | * |
| 676 | * @since 2.4.0 |
| 677 | * |
| 678 | * @param string $status Post status. |
| 679 | * @param int $unknown_rank Fail-closed rank for statuses not in the map. |
| 680 | * @return int Exposure rank. |
| 681 | */ |
| 682 | protected static function get_status_exposure_rank( $status, $unknown_rank ) { |
| 683 | $ranks = [ |
| 684 | 'publish' => 3, |
| 685 | 'future' => 3, |
| 686 | 'private' => 2, |
| 687 | 'draft' => 1, |
| 688 | 'pending' => 1, |
| 689 | 'trash' => 1, |
| 690 | 'auto-draft' => 1, |
| 691 | 'inherit' => 1, |
| 692 | ]; |
| 693 | |
| 694 | return $ranks[ $status ] ?? $unknown_rank; |
| 695 | } |
| 696 | |
| 697 | /** |
| 698 | * Extract the parent post ID from an autosave REST request's concrete path. |
| 699 | * |
| 700 | * @since 2.4.0 |
| 701 | * |
| 702 | * @param mixed $request WP_REST_Request instance. |
| 703 | * @return int Parent post ID, or 0 when it cannot be determined. |
| 704 | */ |
| 705 | protected static function get_autosave_route_parent_id( $request ) { |
| 706 | if ( ! is_object( $request ) || ! method_exists( $request, 'get_route' ) ) { |
| 707 | return 0; |
| 708 | } |
| 709 | |
| 710 | $route = $request->get_route(); |
| 711 | |
| 712 | if ( ! is_string( $route ) || ! preg_match( '#/(\d+)/autosaves/?$#', $route, $matches ) ) { |
| 713 | return 0; |
| 714 | } |
| 715 | |
| 716 | return absint( $matches[1] ); |
| 717 | } |
| 718 | |
| 719 | /** |
| 720 | * Normalize the REST content parameter shape used by post and autosave routes. |
| 721 | * |
| 722 | * @since 2.4.0 |
| 723 | * |
| 724 | * @param mixed $request WP_REST_Request instance. |
| 725 | * @return string|null Content string when present, otherwise null. |
| 726 | */ |
| 727 | protected static function get_rest_request_content_param( $request ) { |
| 728 | if ( ! is_object( $request ) || ! method_exists( $request, 'get_param' ) ) { |
| 729 | return null; |
| 730 | } |
| 731 | |
| 732 | $content = $request->get_param( 'content' ); |
| 733 | |
| 734 | if ( is_string( $content ) ) { |
| 735 | return $content; |
| 736 | } |
| 737 | |
| 738 | if ( is_array( $content ) && isset( $content['raw'] ) && is_string( $content['raw'] ) ) { |
| 739 | return $content['raw']; |
| 740 | } |
| 741 | |
| 742 | return null; |
| 743 | } |
| 744 | |
| 745 | /** |
| 746 | * Unslash incoming post content without trimming it. |
| 747 | * |
| 748 | * @since 2.4.0 |
| 749 | * |
| 750 | * @param mixed $content Incoming post content. |
| 751 | * @return string Unslashed content. |
| 752 | */ |
| 753 | protected static function unslash_post_content( $content ) { |
| 754 | if ( ! is_string( $content ) ) { |
| 755 | return ''; |
| 756 | } |
| 757 | |
| 758 | return function_exists( 'wp_unslash' ) ? wp_unslash( $content ) : stripslashes( $content ); |
| 759 | } |
| 760 | |
| 761 | /** |
| 762 | * Whether post data represents a core autosave revision. |
| 763 | * |
| 764 | * @since 2.4.0 |
| 765 | * |
| 766 | * @param array $data Slashed post data. |
| 767 | * @param array $postarr Sanitized post array. |
| 768 | * @return bool True when the row is an autosave revision. |
| 769 | */ |
| 770 | protected static function is_autosave_revision_data( $data, $postarr = [] ) { |
| 771 | if ( ! is_array( $data ) || ! isset( $data['post_type'] ) || 'revision' !== $data['post_type'] ) { |
| 772 | return false; |
| 773 | } |
| 774 | |
| 775 | $post_name = isset( $data['post_name'] ) && is_string( $data['post_name'] ) ? $data['post_name'] : ''; |
| 776 | $post_parent = isset( $data['post_parent'] ) ? absint( $data['post_parent'] ) : 0; |
| 777 | |
| 778 | if ( $post_parent && self::is_autosave_revision_name( $post_name, $post_parent ) ) { |
| 779 | return true; |
| 780 | } |
| 781 | |
| 782 | $revision_id = isset( $postarr['ID'] ) ? absint( $postarr['ID'] ) : 0; |
| 783 | |
| 784 | if ( $revision_id && function_exists( 'wp_is_post_autosave' ) ) { |
| 785 | return (bool) wp_is_post_autosave( $revision_id ); |
| 786 | } |
| 787 | |
| 788 | return false; |
| 789 | } |
| 790 | |
| 791 | /** |
| 792 | * Whether a revision slug matches core's autosave naming convention. |
| 793 | * |
| 794 | * @since 2.4.0 |
| 795 | * |
| 796 | * @param string $post_name Revision post_name. |
| 797 | * @param int $post_parent Parent post ID. |
| 798 | * @return bool True when this is an autosave slug. |
| 799 | */ |
| 800 | protected static function is_autosave_revision_name( $post_name, $post_parent ) { |
| 801 | $post_parent = absint( $post_parent ); |
| 802 | |
| 803 | if ( ! $post_parent || ! is_string( $post_name ) ) { |
| 804 | return false; |
| 805 | } |
| 806 | |
| 807 | return 0 === strpos( $post_name, $post_parent . '-autosave' ); |
| 808 | } |
| 809 | |
| 810 | /** |
| 811 | * Resolve the content that a REST save will actually persist. |
| 812 | * |
| 813 | * Core only sets post_content on the prepared object when the request included a |
| 814 | * content field, so a set value means "submitted" and an unset value means the |
| 815 | * stored content will be kept. Fall back to the stored content in the latter case |
| 816 | * so content-less updates are still gated on the post's real content. |
| 817 | * |
| 818 | * @since 2.4.0 |
| 819 | * |
| 820 | * @param object $prepared_post Prepared post object. |
| 821 | * @param int $post_id Existing post ID, or 0 for a new post. |
| 822 | * @return string Effective content to validate. |
| 823 | */ |
| 824 | protected static function resolve_effective_post_content( $prepared_post, $post_id ) { |
| 825 | if ( is_object( $prepared_post ) && isset( $prepared_post->post_content ) && is_string( $prepared_post->post_content ) ) { |
| 826 | return $prepared_post->post_content; |
| 827 | } |
| 828 | |
| 829 | $post_id = absint( $post_id ); |
| 830 | |
| 831 | if ( $post_id && function_exists( 'get_post' ) ) { |
| 832 | $stored = get_post( $post_id ); |
| 833 | |
| 834 | if ( is_object( $stored ) && isset( $stored->post_content ) && is_string( $stored->post_content ) ) { |
| 835 | return $stored->post_content; |
| 836 | } |
| 837 | } |
| 838 | |
| 839 | return ''; |
| 840 | } |
| 841 | } |
| 842 | |
| 843 | GenerateBlocks_Save_Gate::get_instance()->init(); |
| 844 |