content_kses_would_strip( $content ); if ( null !== $stripped ) { return $stripped; } $preserve_dates = $this->should_preserve_modified_date( $post_id ); if ( $preserve_dates ) { $this->hold_modified_date( (string) $post->post_modified, (string) $post->post_modified_gmt ); } // Suppress revision creation -- this is a programmatic insert, not a // user edit. remove_action( 'post_updated', 'wp_save_post_revision' ); $updated = wp_update_post( array( 'ID' => $post_id, 'post_content' => $content, ), true ); // @phpstan-ignore-next-line WordPress core registers this same callback for post revisions. add_action( 'post_updated', 'wp_save_post_revision' ); if ( $preserve_dates ) { $this->release_modified_date(); } if ( is_wp_error( $updated ) ) { return $updated; } return true; } /** * Whether this save should leave the post's modified date alone. * * Defaults to yes. A link inserted by a maintenance tool is not the * article being updated, and bumping the date makes every post the tool * touches look freshly written to sitemaps, feeds, and any ordering built * on `post_modified`. * * @since 1.17.0 * * @param int $post_id Post being written. * * @return bool */ private function should_preserve_modified_date( int $post_id ): bool { /** * Filters whether link insertion preserves the post's modified date. * * @since 1.17.0 * * @param bool $preserve True to leave the date unchanged. * @param int $post_id Post being written. */ return (bool) apply_filters( 'postlinkly_preserve_post_modified_date', true, $post_id ); } /** * The dates to restore while an update runs, or null when not holding. * * @var array{post_modified: string, post_modified_gmt: string}|null */ private ?array $held_dates = null; /** * Keeps the existing modified date across the coming update. * * `wp_insert_post()` overwrites `post_modified` regardless of what is * passed in the update array, so the value has to be put back as the row * is assembled rather than requested up front. * * @since 1.17.0 * * @param string $modified Existing local modified date. * @param string $modified_gmt Existing GMT modified date. * * @return void */ private function hold_modified_date( string $modified, string $modified_gmt ): void { $this->held_dates = array( 'post_modified' => $modified, 'post_modified_gmt' => $modified_gmt, ); add_filter( 'wp_insert_post_data', array( $this, 'restore_modified_date' ), 999 ); } /** * Stops holding the modified date. * * @since 1.17.0 * * @return void */ private function release_modified_date(): void { remove_filter( 'wp_insert_post_data', array( $this, 'restore_modified_date' ), 999 ); $this->held_dates = null; } /** * Puts the original modified date back into the row being written. * * Public because WordPress calls it as a filter; not part of this class's * usable surface. * * @since 1.17.0 * * @param array $data Row about to be written. * * @return array */ public function restore_modified_date( array $data ): array { if ( null === $this->held_dates ) { return $data; } $data['post_modified'] = $this->held_dates['post_modified']; $data['post_modified_gmt'] = $this->held_dates['post_modified_gmt']; return $data; } /** * Returns an error when saving would let kses strip content. * * `wp_update_post()` runs content through kses for any user without * `unfiltered_html`, over the *whole* post rather than the part being * changed. So an author inserting one link into a post containing an * embed, a script, or an attribute their role cannot save loses that * markup -- silently, and with no revision to recover from, because this * write deliberately creates none. * * Refusing is the only honest outcome: the alternative is destroying * content the reader never touched while reporting a successful insert. * * @since 1.17.0 * * @param string $content Content about to be saved. * * @return \WP_Error|null Error when content would be altered, else null. */ private function content_kses_would_strip( string $content ): ?\WP_Error { if ( current_user_can( 'unfiltered_html' ) ) { return null; } if ( ! function_exists( 'wp_kses_post' ) ) { return null; } if ( wp_kses_post( $content ) === $content ) { return null; } return new \WP_Error( 'postlinkly_kses_would_strip', __( 'Saving this post would remove markup your role is not allowed to save, so the link was not inserted. An administrator can insert it, or the markup can be removed first.', 'postlinkly' ) ); } }