PluginProbe ʕ •ᴥ•ʔ
GenerateBlocks / 2.4.0
GenerateBlocks v2.4.0
2.4.0 trunk 1.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.6.0 1.7.0 1.7.1 1.7.2 1.7.3 1.8.0 1.8.1 1.8.2 1.8.3 1.9.0 1.9.1 2.0.0 2.0.1 2.0.2 2.1.0 2.1.1 2.1.2 2.2.0 2.2.1 2.3.0
generateblocks / includes / class-save-gate.php
generateblocks / includes Last commit date
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