false`, never instantiated, never written; * - fields that point at other content or media (relationships, files, * images), and Pods fields kept in Pods' own tables; * - a value that is itself PHP-serialized text; * - a new value with running markup (scripts, frames, event handlers) unless * the connected user has `unfiltered_html`; * - a key the user may not `edit_post_meta` (register_meta auth callbacks). * A refused field is answered with its refusal and none of its values; so is * one holding a value the 1.5.19 read would redact as a secret. * * Values are written with WordPress's own functions — ACF's `update_field()` * when ACF owns the value, else `update_post_meta()` / `delete_post_meta()`, * and `update_metadata_by_mid()` for one row of several — so every meta hook * fires; never raw SQL. The stored rows (and their meta ids) are READ with one * prepared query so they can be compared and restored byte for byte without * unserializing anything into an object. A write is held under a short * per-field lock (`add_option`) from the hash check to the read-back; if the * site stores something other than what was sent, the write is put straight * back and the answer says what landed (`transformed`). */ class Custom_Field_Write { /** Bytes of one field's stored rows, all together. */ public const MAX_STATE_BYTES = 65536; /** Bytes of one new value. */ public const MAX_VALUE_BYTES = 65536; /** Steps in a path. */ public const MAX_PATH = 6; /** Rows of one key (a Pods repeatable field) this route will handle. */ public const MAX_ROWS = 200; /** * Field types that point at other content or media: their value is ids, * and editing it as text would break the link. */ private const REFERENCE_TYPES = array( 'pick', 'file', 'relationship', 'post_object', 'page_link', 'image', 'gallery', 'user', 'taxonomy', 'avatar', 'posts', 'media', 'map', 'google_map', ); /** ACF types whose sub-values live under their own meta keys. */ private const ACF_ROW_TYPES = array( 'repeater', 'flexible_content' ); /** Field types that hold code the site runs. */ private const EXECUTABLE_TYPES = array( 'code', 'html', 'script', 'css', 'javascript' ); /** Key words that mark a field holding code or markup the site runs. */ private const EXECUTABLE_KEY = '/(?:^|[_\-])(?:script|scripts|js|javascript|html|css|code|php|embed|iframe|pixel|tracking|snippet|shortcode|elementor_data)(?:$|[_\-])/i'; /** Markup that runs: scripts, styles, frames, event handlers, javascript: links. */ private const EXECUTABLE_VALUE = '/<\s*(?:script|style|iframe|object|embed|link|meta|form)\b|\son[a-z]+\s*=|javascript\s*:/i'; private Logger $logger; private ?Cache_Purger $cache; public function __construct( Logger $logger, ?Cache_Purger $cache = null ) { $this->logger = $logger; $this->cache = $cache; } /** * Register the route under wppilot/v1. * * @param callable $permission The backend callback-token check. */ public function register_routes( callable $permission ): void { $id_arg = array( 'validate_callback' => static fn( $value ): bool => is_numeric( $value ) && (int) $value > 0, ); register_rest_route( 'wppilot/v1', '/posts/(?P\d+)/custom-field', array( array( 'methods' => 'GET', 'callback' => array( $this, 'rest_read' ), 'permission_callback' => $permission, 'args' => array( 'id' => $id_arg, 'key' => array( 'required' => true ), ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'rest_write' ), 'permission_callback' => $permission, 'args' => array( 'id' => $id_arg, 'key' => array( 'required' => true ), ), ), ) ); } /** * GET /posts/{id}/custom-field — the field exactly as stored, and the one * value `path` addresses. A field that may not be changed here (a private * or credential key, a secret-looking value) is answered with its refusal * and NO values. */ public function rest_read( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $address = $this->address( $request ); if ( $address instanceof \WP_Error ) { return $address; } if ( null === $address['post'] ) { return new \WP_REST_Response( array( 'found' => false ), 200 ); } return new \WP_REST_Response( self::public_view( $this->describe( $address ) ), 200 ); } /** * POST /posts/{id}/custom-field — write one value, or put a field back. * * Held under a short per-field lock from the hash check to the read-back, * so two WPPilot requests for the same field cannot interleave. (A save in * wp-admin does not take the lock; the hash check, made immediately before * the write, is what catches that.) */ public function rest_write( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $address = $this->address( $request ); if ( $address instanceof \WP_Error ) { return $address; } if ( null === $address['post'] ) { return new \WP_REST_Response( array( 'result' => 'not_found' ), 200 ); } $mode = (string) $request->get_param( 'mode' ); $value = $request->get_param( 'value' ); if ( ! in_array( $mode, array( 'write', 'restore' ), true ) || ! is_string( $value ) ) { return self::bad_request( 'mode must be "write" or "restore", and value a string.' ); } $lock = self::lock( (int) $address['post']->ID, (string) $address['storage_key'] ); if ( null === $lock ) { return new \WP_REST_Response( array( 'result' => 'refused', 'refusal' => self::refusal( 'busy', 'Another change to this field is being made right now, so nothing was changed. Try again in a moment.' ), ), 200 ); } try { return $this->write_locked( $address, $mode, $value, $request ); } finally { self::unlock( $lock ); } } /** * The write itself, under the lock. * * @param array $address */ private function write_locked( array $address, string $mode, string $value, \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $post_id = (int) $address['post']->ID; $storage_key = (string) $address['storage_key']; $current = $this->describe( $address ); $refused = static fn( array $refusal ): \WP_REST_Response => new \WP_REST_Response( array( 'result' => 'refused', 'refusal' => $refusal, ) + self::public_view( $current ), 200 ); // A field that may not be changed here blocks both directions. if ( null !== $current['blocking'] ) { return $refused( $current['blocking'] ); } $live = $current['state']; if ( 'write' === $mode ) { if ( null !== $current['refusal'] ) { return $refused( $current['refusal'] ); } $expected = $request->get_param( 'expected_hash' ); if ( ! is_string( $expected ) || '' === $expected ) { return self::bad_request( 'expected_hash is required for a write.' ); } if ( ! hash_equals( $expected, (string) $current['state_hash'] ) ) { return new \WP_REST_Response( array( 'result' => 'drift' ) + self::public_view( $current ), 200 ); } if ( self::is_running_markup( $value ) && ! current_user_can( 'unfiltered_html' ) ) { return $refused( self::refusal( 'unfiltered_html', 'The new value contains a script, frame or event handler, and the connected WordPress user is not allowed to publish code like that, so nothing was changed.' ) ); } $plan = self::plan_write( $live[ $storage_key ] ?? array(), $address['path'], $value ); if ( isset( $plan['refusal'] ) ) { return $refused( $plan['refusal'] ); } $target = $live; $target[ $storage_key ] = $plan['rows']; // The state this write leaves must be one this route can still read and undo. if ( self::state_bytes( $target ) > self::MAX_STATE_BYTES ) { return $refused( self::refusal( 'too_large', 'With this change the field would hold more than WPPilot can put back in one go, so nothing was changed.' ) ); } } else { $original = self::validate_original( $request->get_param( 'original' ), array_keys( $live ) ); if ( $original instanceof \WP_Error ) { return $original; } foreach ( $original as $rows ) { foreach ( $rows as $row ) { $decoded = self::decode_row( $row ); if ( isset( $decoded['refusal'] ) ) { return $refused( $decoded['refusal'] ); } } } if ( hash_equals( self::state_hash( $original ), (string) $current['state_hash'] ) ) { return new \WP_REST_Response( array( 'result' => 'unchanged' ) + self::public_view( $current ), 200 ); } /* * What WPPilot's write left: the original with only the one value * changed. Checked by hash equality alone — the planning rules a new * write must pass (size, the addressed value existing) never stand * between an owner and their exact before-copy. */ $forward = self::plan_write( $original[ $storage_key ] ?? array(), $address['path'], $value, false ); $left = $original; if ( ! isset( $forward['refusal'] ) ) { $left[ $storage_key ] = $forward['rows']; } if ( isset( $forward['refusal'] ) || ! hash_equals( self::state_hash( $left ), (string) $current['state_hash'] ) ) { return new \WP_REST_Response( array( 'result' => 'drift' ) + self::public_view( $current ), 200 ); } $target = $original; } /* * A theme or template may print the field as stored, and a connected * administrator (`unfiltered_html`) passes the check above: in either * direction the field may not gain a script, an event handler or an * unsafe address it does not hold now — the rule every markup write * keeps (`Markup_Safety::change_refusal`). */ $unsafe = Markup_Safety::change_refusal( Markup_Safety::strings_of( $live ), Markup_Safety::strings_of( $target ) ); if ( null !== $unsafe ) { return $refused( self::refusal( 'unsafe_markup', 'The new value adds a script, an event handler, an embedded frame or form, or an unsafe address that the field does not hold now, so nothing was changed.' ) ); } // WordPress's own per-key check (register_meta auth), for every key this request changes. foreach ( self::changed_keys( $live, $target ) as $key ) { if ( ! current_user_can( 'edit_post_meta', $post_id, $key ) ) { return $refused( self::refusal( 'forbidden_key', 'The connected WordPress user is not allowed to change this field, so nothing was changed.' ) ); } } $moved = self::move_state( $post_id, $live, $target, $storage_key, $address['acf_selector'] ); if ( isset( $moved['refusal'] ) ) { return $refused( $moved['refusal'] ); } $after = $this->describe( $address ); if ( $after['state'] === $target ) { $cache = null !== $this->cache ? $this->cache->purge_post_cache( $post_id ) : null; $this->logger->info( sprintf( 'Custom field %s on post %d: %s stored.', $storage_key, $post_id, $mode ) ); return new \WP_REST_Response( array( 'result' => 'changed', 'written_via' => $moved['via'], ) + self::public_view( $after ) + ( null !== $cache ? array( 'cache' => $cache ) : array() ), 200 ); } /* * The site stored something other than what was sent — a filter on the * field rewrote it. A write is put straight back, so nothing the owner * did not approve stays on the site, and the answer says what landed. */ if ( 'write' === $mode ) { $landed = null === $after['blocking'] ? $after['leaf']['value'] : null; self::move_state( $post_id, $after['state'], $live, $storage_key, null ); $reverted = $this->describe( $address ); $back = $reverted['state'] === $live; $this->logger->warning( sprintf( 'Custom field %s on post %d: the site transformed the value; %s.', $storage_key, $post_id, $back ? 'put back' : 'could not put back' ) ); if ( $back ) { return new \WP_REST_Response( array( 'result' => 'transformed', 'landed' => $landed, ) + self::public_view( $reverted ), 200 ); } $after = $reverted; } $this->logger->error( sprintf( 'Custom field %s on post %d: %s did not store as sent.', $storage_key, $post_id, $mode ) ); return new \WP_REST_Response( array( 'result' => 'failed', 'written_via' => $moved['via'], ) + self::public_view( $after ), 200 ); } /* --------------------------------------------------------------- address */ /** * The post, field and path a request names, resolved to where it is * stored and to the definition of the value actually written. * * @return array|\WP_Error */ private function address( \WP_REST_Request $request ): array|\WP_Error { $post_id = (int) $request->get_param( 'id' ); $key = $request->get_param( 'key' ); if ( ! is_string( $key ) || '' === $key || strlen( $key ) > 255 ) { return self::bad_request( 'key must name one custom field.' ); } $path = self::parse_path( $request->get_param( 'path' ) ); if ( null === $path ) { return self::bad_request( sprintf( 'path must be a list of at most %d keys or positions.', self::MAX_PATH ) ); } $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post ) { return array( 'post' => null ); } // An HPOS order's placeholder post keeps none of the order's fields: refused, never read or written as them. $storage = Post_Storage::refusal( $post ); if ( null !== $storage ) { return $storage; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return new \WP_Error( 'rest_forbidden', 'The connected user cannot edit this post.', array( 'status' => 403 ) ); } $definitions = class_exists( __NAMESPACE__ . '\\Custom_Fields' ) ? Custom_Fields::definitions( $post ) : array(); $definition = isset( $definitions[ $key ] ) && is_array( $definitions[ $key ] ) ? $definitions[ $key ] : array(); $acf = 'ACF' === ( $definition['plugin'] ?? '' ); $storage = self::storage_for( $key, $path, $definition, $acf ? self::acf_row_count( self::raw_rows( $post_id, $key ) ) : 0 ); $storage_key = (string) ( $storage['storage_key'] ?? $key ); $rest_path = $storage['path'] ?? $path; // A refusal about the field itself blocks it entirely; a missing row only blocks a write. $blocking = self::key_refusal( $key, $definition ); $location = $storage['refusal'] ?? null; // The definition of the value written: the field's own, or an ACF sub-field's. $stored_definition = $definition; if ( null === $blocking && null === $location && $storage_key !== $key ) { $sub = self::acf_sub_definition( $post_id, $storage_key ); if ( isset( $sub['refusal'] ) ) { $blocking = $sub['refusal']; } else { $stored_definition = $sub; $blocking = self::key_refusal( $storage_key, $sub ); } } // ACF keeps a `_{name}` reference beside each value: part of the field's state. $state_keys = array( $key ); if ( $storage_key !== $key ) { $state_keys[] = $storage_key; } if ( $acf ) { $state_keys[] = '_' . $key; if ( $storage_key !== $key ) { $state_keys[] = '_' . $storage_key; } } // Through ACF only when ACF already knows this value's field (its // reference is there): an unknown name would make ACF add a reference. $selector = null; if ( $acf && array() === $rest_path ) { $reference = get_post_meta( $post_id, '_' . $storage_key, true ); if ( is_string( $reference ) && str_starts_with( $reference, 'field_' ) ) { $selector = $storage_key === $key ? $reference : $storage_key; } } return array( 'post' => $post, 'key' => $key, 'definition' => $definition, 'stored_definition' => $stored_definition, 'storage_key' => $storage_key, 'path' => $rest_path, 'state_keys' => $state_keys, 'blocking' => $blocking, 'location' => $location, 'acf_selector' => $selector, 'acf_row' => $storage['acf_row'] ?? null, ); } /** * Everything a caller decides from: the stored rows, their hash, the * addressed value, and a refusal when it cannot be changed here. * * `blocking` is a refusal about the field itself (its key or type, a * secret-looking value): none of it is shown and nothing is written, in * either direction. `refusal` is everything that stops a NEW write. * * @param array $address * @return array */ private function describe( array $address ): array { $post_id = (int) $address['post']->ID; $storage_key = (string) $address['storage_key']; $state = array(); foreach ( $address['state_keys'] as $state_key ) { $state[ $state_key ] = self::raw_rows( $post_id, (string) $state_key ); } $rows = $state[ $storage_key ] ?? array(); $definition = $address['stored_definition']; $blocking = $address['blocking'] ?? self::secret_refusal( $storage_key, $rows ); $refusal = $blocking ?? $address['location']; if ( null === $refusal && isset( $address['definition']['values'] ) && array() === $rows && $storage_key === $address['key'] ) { $refusal = self::refusal( 'pods_table', 'This field is kept in Pods\' own table rather than with the post, which WPPilot can read but not yet change.' ); } if ( null === $refusal && self::state_bytes( $state ) > self::MAX_STATE_BYTES ) { $refusal = self::refusal( 'too_large', sprintf( 'This field holds more than %d KB, more than WPPilot changes in one go.', (int) ( self::MAX_STATE_BYTES / 1024 ) ) ); } if ( null === $refusal && count( $rows ) > self::MAX_ROWS ) { $refusal = self::refusal( 'too_large', 'This field is stored as more separate values than WPPilot changes in one go.' ); } $located = null === $refusal ? self::locate( $rows, $address['path'] ) : null; if ( null !== $located && isset( $located['refusal'] ) ) { $refusal = $located['refusal']; $located = null; } $leaf = null !== $located ? $located['leaf'] : null; return array( 'found' => true, 'post' => array( 'id' => $post_id, 'title' => function_exists( 'get_the_title' ) ? (string) get_the_title( $address['post'] ) : (string) ( $address['post']->post_title ?? '' ), 'type' => (string) $address['post']->post_type, 'status' => (string) $address['post']->post_status, 'url' => function_exists( 'get_permalink' ) ? ( get_permalink( $address['post'] ) ?: null ) : null, ), 'field' => array_filter( array( 'key' => (string) $address['key'], 'label' => isset( $address['definition']['label'] ) ? (string) $address['definition']['label'] : null, 'plugin' => isset( $address['definition']['plugin'] ) ? (string) $address['definition']['plugin'] : null, 'type' => isset( $definition['type'] ) ? (string) $definition['type'] : null, ), static fn( $v ): bool => null !== $v ), 'storage_key' => $storage_key, 'path' => $address['path'], 'state' => $state, 'state_hash' => self::state_hash( $state ), 'leaf' => array( 'exists' => null !== $located && $located['exists'], 'value' => null === $leaf ? null : (string) $leaf, 'type' => null === $leaf ? 'none' : gettype( $leaf ), ), 'context' => null !== $located ? self::context( $address, $located ) : array(), 'outline' => self::outline( $rows ), 'executable' => self::is_executable( $storage_key, $definition, $state ), 'blocking' => $blocking, 'refusal' => $refusal, ); } /** * What leaves this route: a blocked field without any of its values. * * @param array $described * @return array */ private static function public_view( array $described ): array { $blocking = $described['blocking']; unset( $described['blocking'] ); if ( null === $blocking ) { return $described; } $described['state'] = new \stdClass(); $described['state_hash'] = self::state_hash( array() ); $described['leaf'] = array( 'exists' => false, 'value' => null, 'type' => 'none', ); $described['context'] = array(); $described['outline'] = array( 'rows' => 0 ); $described['executable'] = false; return $described; } /** * Why a field may not be read or changed because a value in it looks like * a secret — the judgement the 1.5.19 read redacts by — or null. * * @param array $rows * @return array{code: string, message: string}|null */ public static function secret_refusal( string $key, array $rows ): ?array { foreach ( $rows as $row ) { $decoded = self::decode_row( $row ); if ( isset( $decoded['refusal'] ) ) { continue; } $redacted = Scan_Policy::redact_value( $key, $decoded['value'] ); if ( (int) $redacted['redactions'] > 0 ) { return self::refusal( 'secret_value', 'This field holds something that looks like a password or key, so WPPilot never reads or changes it.' ); } } return null; } /** * The other values beside the addressed one — the rest of its row — so the * owner sees WHICH row is being changed. Plain text, redacted, bounded. * * @param array $address * @param array $located * @return array */ private static function context( array $address, array $located ): array { $out = array(); if ( null !== $address['acf_row'] ) { $prefix = $address['key'] . '_' . $address['acf_row'] . '_'; $all = get_post_meta( (int) $address['post']->ID ); foreach ( is_array( $all ) ? $all : array() as $meta_key => $values ) { if ( is_string( $meta_key ) && str_starts_with( $meta_key, $prefix ) && $meta_key !== $address['storage_key'] && is_array( $values ) && isset( $values[0] ) && is_scalar( $values[0] ) ) { $out[ substr( $meta_key, strlen( $prefix ) ) ] = $values[0]; } } } elseif ( count( $located['keys'] ) > 0 && is_array( $located['decoded'] ) ) { $parent = $located['decoded']; foreach ( array_slice( $located['keys'], 0, -1 ) as $step ) { $parent = $parent[ $step ]; } $leaf_key = end( $located['keys'] ); foreach ( is_array( $parent ) ? $parent : array() as $sibling => $value ) { if ( $sibling !== $leaf_key && is_scalar( $value ) && ! is_bool( $value ) ) { $out[ (string) $sibling ] = $value; } } } $clean = array(); foreach ( array_slice( $out, 0, 8, true ) as $name => $value ) { $redacted = Scan_Policy::redact_value( (string) $name, (string) $value ); $text = trim( (string) preg_replace( '/\s+/', ' ', wp_strip_all_tags( (string) $redacted['value'] ) ) ); if ( '' !== $text ) { $clean[ (string) $name ] = function_exists( 'mb_substr' ) ? mb_substr( $text, 0, 200, 'UTF-8' ) : substr( $text, 0, 200 ); } } return $clean; } /** * Where a field's addressed value is stored. * * An ACF repeater or flexible-content field keeps each sub-value under its * own key (`{field}_{row}_{sub}`), and a group under `{field}_{sub}`: the * path's first steps become that key, and the parent (which holds the rows) * is part of the state the drift check compares. * * @param array $path * @param array $definition From {@see Custom_Fields::definitions()}. * @param int $rows How many rows the ACF parent has. * @return array */ public static function storage_for( string $key, array $path, array $definition, int $rows ): array { $plugin = (string) ( $definition['plugin'] ?? '' ); $type = (string) ( $definition['type'] ?? '' ); if ( 'ACF' !== $plugin || array() === $path ) { return array( 'storage_key' => $key, 'path' => $path, ); } if ( in_array( $type, self::ACF_ROW_TYPES, true ) ) { $row = $path[0]; $sub = $path[1] ?? null; if ( ! is_int( $row ) || ! is_string( $sub ) || ! preg_match( '/^[A-Za-z0-9_\-]{1,64}$/', $sub ) ) { return array( 'refusal' => self::refusal( 'path_not_found', 'Name the row and the sub-field inside it.' ) ); } if ( $row < 0 || $row >= $rows ) { return array( 'refusal' => self::refusal( 'path_not_found', sprintf( 'This field has %d rows; there is no row %d.', $rows, $row + 1 ) ) ); } return array( 'storage_key' => $key . '_' . $row . '_' . $sub, 'path' => array_slice( $path, 2 ), 'acf_row' => $row, ); } if ( 'group' === $type ) { $sub = $path[0]; if ( ! is_string( $sub ) || ! preg_match( '/^[A-Za-z0-9_\-]{1,64}$/', $sub ) ) { return array( 'refusal' => self::refusal( 'path_not_found', 'Name the sub-field inside this group.' ) ); } return array( 'storage_key' => $key . '_' . $sub, 'path' => array_slice( $path, 1 ), ); } return array( 'storage_key' => $key, 'path' => $path, ); } /** * How many rows an ACF row field has. A repeater stores the count; a * flexible-content field stores the list of its rows' layout names. * * @param array $rows The parent's stored rows. */ public static function acf_row_count( array $rows ): int { if ( array() === $rows ) { return 0; } $decoded = self::decode_row( $rows[0] ); $value = $decoded['value'] ?? null; if ( is_array( $value ) ) { return count( $value ); } return is_numeric( $value ) ? max( 0, (int) $value ) : 0; } /** * An ACF sub-value's own field definition, from the reference ACF keeps * beside it (`_{name}` → `field_…`), so its type is judged like any * field's. Refused when it cannot be established, or when it holds rows. * * @return array */ public static function acf_sub_definition( int $post_id, string $storage_key ): array { $reference = get_post_meta( $post_id, '_' . $storage_key, true ); $field = is_string( $reference ) && str_starts_with( $reference, 'field_' ) && function_exists( 'acf_get_field' ) ? acf_get_field( $reference ) : null; if ( ! is_array( $field ) || ! isset( $field['type'] ) ) { return array( 'refusal' => self::refusal( 'unknown_subfield', 'WPPilot could not confirm what kind of sub-field this is, so it does not change it.' ) ); } $type = (string) $field['type']; if ( in_array( $type, self::ACF_ROW_TYPES, true ) || 'group' === $type ) { return array( 'refusal' => self::refusal( 'nested_rows', 'This sub-field holds rows of its own, which WPPilot does not change yet.' ) ); } return array( 'plugin' => 'ACF', 'type' => $type, ) + ( isset( $field['label'] ) && is_string( $field['label'] ) ? array( 'label' => $field['label'] ) : array() ); } /* ------------------------------------------------------------ the limits */ /** * Why this key may not be written through this address, or null. * The same boundary as the read: what it may not show, it may not change. * * @param array $definition * @return array{code: string, message: string}|null */ public static function key_refusal( string $key, array $definition ): ?array { if ( preg_match( '/[\x00-\x1f\x7f]/', $key ) ) { return self::refusal( 'protected_key', 'That is not a field name WPPilot can change.' ); } if ( '' === $key || '_' === $key[0] ) { return self::refusal( 'protected_key', 'This is one of the values WordPress or a plugin keeps for itself (its name starts with "_"), so WPPilot does not change it this way.' ); } if ( class_exists( __NAMESPACE__ . '\\Custom_Fields' ) && ! Custom_Fields::is_public_key( $key ) ) { return self::refusal( 'protected_key', 'This is a plugin or theme setting rather than content, so WPPilot does not change it as a custom field.' ); } if ( Scan_Policy::is_credential_name( $key ) || 'password' === ( $definition['type'] ?? '' ) ) { return self::refusal( 'protected_key', 'This field looks like it holds a password or key, so WPPilot never reads or changes it.' ); } if ( in_array( (string) ( $definition['type'] ?? '' ), self::REFERENCE_TYPES, true ) ) { return self::refusal( 'reference_field', 'This field points at other content or media (it stores their ids), so changing it as text would break the link. It is changed where the post is edited.' ); } return null; } /** * Whether this field holds code or markup the site runs — a change to it * is tier 2 and is shown as its exact payload. * * @param array $definition * @param array> $state */ public static function is_executable( string $key, array $definition, array $state ): bool { if ( preg_match( self::EXECUTABLE_KEY, $key ) || in_array( (string) ( $definition['type'] ?? '' ), self::EXECUTABLE_TYPES, true ) ) { return true; } foreach ( $state as $rows ) { foreach ( $rows as $row ) { if ( self::is_executable_value( $row ) ) { return true; } } } return false; } /** Markup that runs, or a shortcode, in a value. */ public static function is_executable_value( string $value ): bool { if ( preg_match( self::EXECUTABLE_VALUE, $value ) ) { return true; } if ( function_exists( 'get_shortcode_regex' ) ) { return 1 === preg_match( '/' . get_shortcode_regex() . '/', $value ); } return 1 === preg_match( '/\[\/?[a-z][a-z0-9_\-]*(?:\s[^\]]*)?\]/', $value ); } /** * Whether stored text is PHP-serialized data. WordPress's own test when it * is loaded — the one `maybe_serialize()` uses — else the same rules. */ public static function looks_serialized( string $raw ): bool { if ( function_exists( 'is_serialized' ) ) { return (bool) is_serialized( $raw ); } $data = trim( $raw ); if ( 'N;' === $data ) { return true; } if ( strlen( $data ) < 4 || ':' !== $data[1] ) { return false; } $last = substr( $data, -1 ); if ( ';' !== $last && '}' !== $last ) { return false; } $token = $data[0]; switch ( $token ) { case 's': return '"' === substr( $data, -2, 1 ) && 1 === preg_match( '/^s:[0-9]+:/s', $data ); case 'a': case 'O': case 'E': case 'C': return 1 === preg_match( "/^{$token}:[0-9]+:/s", $data ); case 'b': case 'i': case 'd': return 1 === preg_match( "/^{$token}:[0-9.E+-]+;\$/", $data ); } return false; } /** Whether serialized text holds an object anywhere in it — the one injection route. */ public static function has_serialized_object( string $raw ): bool { return 1 === preg_match( '/(?:^|[;{}])\s*[OCE]:\d+:"/', ltrim( $raw ) ); } /** * One stored row as a value, never instantiating an object. * * @return array{value: mixed}|array{refusal: array{code: string, message: string}} */ public static function decode_row( string $raw ): array { if ( ! self::looks_serialized( $raw ) ) { return array( 'value' => $raw ); } if ( self::has_serialized_object( $raw ) ) { return array( 'refusal' => self::serialized_object_refusal() ); } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- classes are disallowed, so nothing is instantiated; the text was checked for objects above. $value = @unserialize( $raw, array( 'allowed_classes' => false ) ); if ( false === $value && 'b:0;' !== trim( $raw ) ) { return array( 'refusal' => self::refusal( 'unreadable', 'This field\'s stored value could not be read safely, so WPPilot does not change it.' ) ); } if ( self::holds_object( $value ) ) { return array( 'refusal' => self::serialized_object_refusal() ); } return array( 'value' => $value ); } /** * A value as WordPress stores it (`maybe_serialize()`'s rules), so a * planned row can be compared with what WordPress will write. * * @param mixed $value */ public static function encode_value( $value ): string { if ( is_array( $value ) ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- WordPress stores arrays in meta serialized; this mirrors maybe_serialize(). return serialize( $value ); } if ( is_string( $value ) ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- maybe_serialize() serializes serialized-looking strings again. return self::looks_serialized( $value ) ? serialize( $value ) : $value; } if ( is_bool( $value ) ) { return $value ? '1' : ''; } return null === $value ? '' : (string) $value; } /* ------------------------------------------------------------ the walk */ /** * The one value `path` addresses in a field's rows. * * Several rows (a Pods repeatable field): the first step picks the row. * One row: the path walks into its value. A number is always a position in * stored order (so row 2 is the second row whether the keys are 0-, 1-based * or JetEngine's `item-0`); a string is always a key. No rows: only the * field itself, empty, can be addressed. * * @param array $rows * @param array $path * @return array */ public static function locate( array $rows, array $path ): array { if ( array() === $rows ) { if ( array() !== $path ) { return array( 'refusal' => self::refusal( 'path_not_found', 'This field is empty on this post, so there is nothing inside it to change.' ) ); } return array( 'row' => null, 'keys' => array(), 'decoded' => null, 'leaf' => null, 'exists' => false, ); } $row = 0; if ( count( $rows ) > 1 ) { $first = array_shift( $path ); if ( ! is_int( $first ) || $first < 0 || $first >= count( $rows ) ) { return array( 'refusal' => self::refusal( 'path_not_found', sprintf( 'This field holds %d separate values; name which one.', count( $rows ) ) ) ); } $row = $first; } $decoded = self::decode_row( $rows[ $row ] ); if ( isset( $decoded['refusal'] ) ) { return $decoded; } $current = $decoded['value']; $keys = array(); foreach ( $path as $step ) { if ( ! is_array( $current ) ) { return array( 'refusal' => self::refusal( 'path_not_found', 'That part of the field does not exist: it holds a single value there.' ) ); } // A number is a POSITION (the n-th row or item, in stored order), // whatever the keys are; a string is a key. if ( is_int( $step ) && $step < count( $current ) ) { $actual = array_keys( $current )[ $step ]; } elseif ( is_string( $step ) && array_key_exists( $step, $current ) ) { $actual = $step; } else { return array( 'refusal' => self::refusal( 'path_not_found', sprintf( 'That part of the field does not exist (no "%s" there).', (string) $step ) ) ); } $keys[] = $actual; $current = $current[ $actual ]; } if ( is_array( $current ) ) { return array( 'refusal' => self::refusal( 'not_a_value', 'That part of the field is a group of values; name the one inside it to change.' ) ); } if ( is_bool( $current ) ) { return array( 'refusal' => self::refusal( 'not_text', 'That value is a yes/no switch, not text, so WPPilot does not change it this way.' ) ); } return array( 'row' => $row, 'keys' => $keys, 'decoded' => $decoded['value'], 'leaf' => $current, 'exists' => true, ); } /** * The field's rows with only the addressed value replaced. * * A number stays a number when the new value is one, so a plugin reading * `i:3;` is not handed `s:1:"3";`. * * `$new_write` false recomputes what an earlier write left (for its undo), * which the size limit for new writes does not apply to. * * @param array $rows * @param array $path * @return array{rows: array, row: int}|array{refusal: array{code: string, message: string}} */ public static function plan_write( array $rows, array $path, string $value, bool $new_write = true ): array { if ( $new_write && strlen( $value ) > self::MAX_VALUE_BYTES ) { return array( 'refusal' => self::refusal( 'too_large', 'The new value is longer than WPPilot writes in one go.' ) ); } if ( self::looks_serialized( $value ) || self::has_serialized_object( $value ) ) { return array( 'refusal' => self::refusal( 'serialized_value', 'The new value is PHP-serialized data, which WPPilot never writes into a field.' ) ); } foreach ( $rows as $row ) { if ( self::looks_serialized( $row ) && self::has_serialized_object( $row ) ) { return array( 'refusal' => self::serialized_object_refusal() ); } } $located = self::locate( $rows, $path ); if ( isset( $located['refusal'] ) ) { return $located; } $leaf = $located['leaf']; $new = $value; if ( is_int( $leaf ) && preg_match( '/^-?\d{1,18}$/', $value ) ) { $new = (int) $value; } elseif ( is_float( $leaf ) && is_numeric( $value ) ) { $new = (float) $value; } if ( null === $located['row'] ) { $next = array( self::encode_value( $new ) ); $row = 0; } else { $row = (int) $located['row']; $decoded = $located['decoded']; if ( array() === $located['keys'] ) { $decoded = $new; } else { $cursor = &$decoded; foreach ( $located['keys'] as $key ) { $cursor = &$cursor[ $key ]; } $cursor = $new; unset( $cursor ); } $next = $rows; $next[ $row ] = self::encode_value( $decoded ); } if ( $next === $rows ) { return array( 'refusal' => self::refusal( 'unchanged', 'The field already holds exactly that.' ) ); } return array( 'rows' => $next, 'row' => $row, ); } /** * Move the field from one state to another, key by key, with WordPress's * own functions; only the keys whose rows differ are touched. * * @param array> $from * @param array> $to * @return array{via: string}|array{refusal: array{code: string, message: string}} */ public static function move_state( int $post_id, array $from, array $to, string $storage_key, ?string $acf_selector ): array { $via = 'none'; foreach ( self::changed_keys( $from, $to ) as $key ) { $applied = self::apply_rows( $post_id, $key, $from[ $key ] ?? array(), $to[ $key ] ?? array(), $key === $storage_key ? $acf_selector : null ); if ( isset( $applied['refusal'] ) ) { return $applied; } if ( $key === $storage_key || 'none' === $via ) { $via = $applied['via']; } } if ( function_exists( 'wp_cache_delete' ) ) { wp_cache_delete( $post_id, 'post_meta' ); } return array( 'via' => $via ); } /** * Move one key's rows from `$from` to `$to`. * * - To nothing: `delete_post_meta()`. * - One row (or none) to one row: ACF's `update_field()` when ACF owns the * value — by field key for a top-level field, by stored name for a * sub-value, so ACF's update filters and caches run as when the owner * saves — else `update_post_meta()`. * - Several rows: the one row that differs is updated BY ITS META ID * (`update_metadata_by_mid()`). Never `update_post_meta()` with a * previous value: WordPress ignores a previous value that is empty() * ("0", "", an empty array) and would overwrite every row. * * The read-back that follows decides whether it stored exactly what was sent. * * @param array $from * @param array $to * @return array{via: string}|array{refusal: array{code: string, message: string}} */ public static function apply_rows( int $post_id, string $key, array $from, array $to, ?string $acf_selector = null ): array { if ( array() === $to ) { delete_post_meta( $post_id, $key ); return array( 'via' => 'delete_post_meta' ); } $decode = static function ( string $raw ) { $decoded = self::decode_row( $raw ); return $decoded['value'] ?? null; }; if ( count( $to ) === 1 && count( $from ) <= 1 ) { $value = $decode( $to[0] ); /* * ACF's own update passes the value to update_metadata(), which * unslashes it, and ACF does not slash first — so a backslash would * be lost. Such a value goes through update_post_meta(), slashed. */ if ( null !== $acf_selector && function_exists( 'update_field' ) && ! is_array( $value ) && ! str_contains( (string) $value, '\\' ) ) { update_field( $acf_selector, $value, $post_id ); return array( 'via' => 'acf_update_field' ); } update_post_meta( $post_id, $key, wp_slash( $value ) ); return array( 'via' => 'update_post_meta' ); } if ( count( $to ) !== count( $from ) ) { return array( 'refusal' => self::refusal( 'shape_changed', 'This field\'s number of values changed, so WPPilot left it as it is.' ) ); } $ids = self::meta_ids( $post_id, $key ); if ( count( $ids ) !== count( $from ) ) { return array( 'refusal' => self::refusal( 'shape_changed', 'This field changed while it was being written, so WPPilot left it as it is.' ) ); } foreach ( array_keys( array_diff_assoc( $to, $from ) ) as $index ) { // update_metadata_by_mid() does not unslash: the value goes as it is. update_metadata_by_mid( 'post', $ids[ $index ], $decode( $to[ $index ] ) ); } return array( 'via' => 'update_metadata_by_mid' ); } /* -------------------------------------------------------------- helpers */ /** * A key's stored rows, exactly as stored, in the order WordPress reads them. * * @return array */ public static function raw_rows( int $post_id, string $key ): array { return array_map( static fn( array $row ): string => $row[1], self::raw_meta( $post_id, $key ) ); } /** * A key's meta ids, in the same order as {@see self::raw_rows()}. * * @return array */ public static function meta_ids( int $post_id, string $key ): array { return array_map( static fn( array $row ): int => $row[0], self::raw_meta( $post_id, $key ) ); } /** * `[meta_id, meta_value]` for each row of a key, by meta_id. * * @return array */ private static function raw_meta( int $post_id, string $key ): array { global $wpdb; if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) { return array(); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- a read of one field's exact stored bytes and row ids, for a compare-and-swap; the object cache holds them unserialized and without ids. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_id, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s ORDER BY meta_id ASC", $post_id, $key ), ARRAY_N ); $out = array(); foreach ( is_array( $rows ) ? $rows : array() as $row ) { $out[] = array( (int) $row[0], (string) $row[1] ); } return $out; } /** * The keys whose rows differ between two states. * * @param array> $a * @param array> $b * @return array */ public static function changed_keys( array $a, array $b ): array { $keys = array(); foreach ( array_unique( array_merge( array_keys( $a ), array_keys( $b ) ) ) as $key ) { if ( ( $a[ $key ] ?? array() ) !== ( $b[ $key ] ?? array() ) ) { $keys[] = (string) $key; } } return $keys; } /** @param array> $state */ public static function state_bytes( array $state ): int { $bytes = 0; foreach ( $state as $rows ) { foreach ( $rows as $row ) { $bytes += strlen( $row ); } } return $bytes; } /** Markup that runs in a browser: scripts, styles, frames, event handlers, javascript: links. */ public static function is_running_markup( string $value ): bool { return 1 === preg_match( self::EXECUTABLE_VALUE, $value ); } /** * Take the field's write lock, or null when another request holds it. * `add_option()` inserts only when the name is free; a lock older than a * minute is a crashed request's and is taken over. */ private static function lock( int $post_id, string $key ): ?string { $name = 'wppilot_cf_lock_' . md5( $post_id . '|' . $key ); if ( add_option( $name, (string) time(), '', false ) ) { return $name; } $since = (int) get_option( $name, 0 ); if ( $since > 0 && time() - $since > 60 ) { delete_option( $name ); return add_option( $name, (string) time(), '', false ) ? $name : null; } return null; } private static function unlock( string $name ): void { delete_option( $name ); } /** * The hash a write must match: every row of every key the field spans. * * @param array> $state */ public static function state_hash( array $state ): string { ksort( $state ); return hash( 'sha256', (string) wp_json_encode( (object) $state ) ); } /** * What a field holds, in outline, for a caller that addressed it wrongly. * * @param array $rows * @return array */ public static function outline( array $rows ): array { $out = array( 'rows' => count( $rows ) ); if ( 1 !== count( $rows ) ) { return $out; } $decoded = self::decode_row( $rows[0] ); $value = $decoded['value'] ?? null; if ( ! is_array( $value ) ) { return $out; } $out['items'] = count( $value ); $out['keys'] = array_slice( array_map( 'strval', array_keys( $value ) ), 0, 30 ); $first = reset( $value ); if ( is_array( $first ) ) { $out['item_keys'] = array_slice( array_map( 'strval', array_keys( $first ) ), 0, 30 ); } return $out; } /** * `path` as a list of keys and positions, or null when malformed. * * @param mixed $raw A JSON list, or a list. * @return array|null */ public static function parse_path( $raw ): ?array { if ( null === $raw || '' === $raw ) { return array(); } if ( is_string( $raw ) ) { $raw = json_decode( $raw, true ); } if ( ! is_array( $raw ) || count( $raw ) > self::MAX_PATH || array_values( $raw ) !== $raw ) { return null; } $out = array(); foreach ( $raw as $step ) { if ( is_int( $step ) && $step >= 0 ) { $out[] = $step; } elseif ( is_string( $step ) && '' !== $step && strlen( $step ) <= 64 && ! preg_match( '/[\x00-\x1f]/', $step ) ) { $out[] = $step; } else { return null; } } return $out; } /** * `original` for a restore: exactly the keys this field spans, each a list * of stored rows. * * @param mixed $raw * @param array $keys * @return array>|\WP_Error */ public static function validate_original( $raw, array $keys ): array|\WP_Error { if ( ! is_array( $raw ) ) { return self::bad_request( 'original must be the field\'s rows as they were.' ); } $out = array(); foreach ( $keys as $key ) { $rows = $raw[ $key ] ?? null; if ( ! is_array( $rows ) || count( $rows ) > self::MAX_ROWS || array_values( $rows ) !== $rows ) { return self::bad_request( 'original must list the rows of every key the field spans.' ); } foreach ( $rows as $row ) { if ( ! is_string( $row ) ) { return self::bad_request( 'original rows must be strings.' ); } } $out[ $key ] = $rows; } if ( count( $raw ) !== count( $keys ) ) { return self::bad_request( 'original names a key this field does not span.' ); } return $out; } /** @param mixed $value */ private static function holds_object( $value ): bool { if ( is_object( $value ) ) { return true; } if ( is_array( $value ) ) { foreach ( $value as $child ) { if ( self::holds_object( $child ) ) { return true; } } } return false; } /** @return array{code: string, message: string} */ private static function serialized_object_refusal(): array { return self::refusal( 'serialized_object', 'This field stores a PHP object, which WPPilot never rewrites: doing so is how stored data gets turned into code.' ); } /** @return array{code: string, message: string} */ private static function refusal( string $code, string $message ): array { return array( 'code' => $code, 'message' => $message, ); } private static function bad_request( string $message ): \WP_Error { return new \WP_Error( 'wppilot_bad_request', $message, array( 'status' => 400 ) ); } }