true), e.g. a footer text that * still holds the old domain. Nothing outside this list and ALLOWED_OPTIONS is ever written. */ public const FOUND_OPTIONS = 'forensix_source_options'; /** * Most settings kept in FOUND_OPTIONS. */ private const MAX_FOUND = 2000; /** * Whether a source type can be read and written. * * @param string $type Source type. */ public static function supports( string $type ): bool { return in_array( $type, self::TYPES, true ); } /** * Records a setting a scan found, so a later repair and its undo may change it. Protected * settings are never recorded. * * @param string $name Option name. */ public static function allow_option( string $name ): void { if ( self::protected_option( $name ) || in_array( $name, self::ALLOWED_OPTIONS, true ) ) { return; } $found = get_option( self::FOUND_OPTIONS, array() ); $found = is_array( $found ) ? $found : array(); if ( isset( $found[ $name ] ) ) { return; } $found[ $name ] = true; if ( count( $found ) > self::MAX_FOUND ) { $found = array_slice( $found, -self::MAX_FOUND, null, true ); } update_option( self::FOUND_OPTIONS, $found, false ); } /** * Whether a setting may be read and written: listed in ALLOWED_OPTIONS or recorded by a scan, * and not protected. * * @param string $name Option name. */ public static function option_allowed( string $name ): bool { if ( self::protected_option( $name ) ) { return false; } if ( in_array( $name, self::ALLOWED_OPTIONS, true ) ) { return true; } $found = get_option( self::FOUND_OPTIONS, array() ); return is_array( $found ) && isset( $found[ $name ] ); } /** * The stored object a ref points to, e.g. "post:12" for both post content and Elementor * element "12:abc". Two sources with the same key are written together. * * @param string $type Source type. * @param string $ref Source reference. */ public static function key( string $type, string $ref ): string { if ( Source::ELEMENTOR === $type ) { return $type . ':' . self::document( $ref ); } return $type . ':' . $ref; } /** * The document id of an Elementor ref. * * @param string $ref "document:element". */ public static function document( string $ref ): int { return (int) explode( ':', $ref )[0]; } /** * Reads the stored value, or null when the object does not exist (any more). * * @param string $type Source type. * @param string $ref Source reference. * @return string|array|null */ public function read( string $type, string $ref ): string|array|null { switch ( $type ) { case Source::POST_CONTENT: $post = get_post( (int) $ref ); return $post instanceof \WP_Post ? $post->post_content : null; case Source::ELEMENTOR: $id = self::document( $ref ); if ( $id < 1 || ! get_post( $id ) instanceof \WP_Post ) { return null; } $data = get_post_meta( $id, '_elementor_data', true ); return is_string( $data ) && '' !== $data ? $data : null; case Source::MENU_ITEM: $item = get_post( (int) $ref ); if ( ! $item instanceof \WP_Post || 'nav_menu_item' !== $item->post_type ) { return null; } return 'custom' === get_post_meta( $item->ID, '_menu_item_type', true ) ? (string) get_post_meta( $item->ID, '_menu_item_url', true ) : null; case Source::WIDGET: $widget = self::widget( $ref ); if ( null === $widget ) { return null; } $all = get_option( 'widget_' . $widget[0] ); return is_array( $all ) && is_array( $all[ $widget[1] ] ?? null ) ? $all[ $widget[1] ] : null; case Source::POST_META: $meta = self::meta( $ref ); if ( null === $meta || ! metadata_exists( 'post', $meta[0], $meta[1] ) ) { return null; } return self::plain( get_post_meta( $meta[0], $meta[1], true ) ); case Source::OPTION: if ( ! self::option_allowed( $ref ) ) { return null; } return self::plain( get_option( $ref, null ) ); } return null; } /** * Writes a value and returns whether the stored value now equals it. * * @param string $type Source type. * @param string $ref Source reference. * @param string|array $value New value, in the shape read() returns. */ public function write( string $type, string $ref, string|array $value ): bool { switch ( $type ) { case Source::POST_CONTENT: if ( ! is_string( $value ) || ! get_post( (int) $ref ) instanceof \WP_Post ) { return false; } // The content was already on the site. Without this, kses would rewrite parts of it // for users without unfiltered_html, and the stored content would not match the preview. kses_remove_filters(); $result = wp_update_post( array( 'ID' => (int) $ref, 'post_content' => wp_slash( $value ), ), true ); kses_init(); if ( is_wp_error( $result ) ) { return false; } if ( $this->read( $type, $ref ) !== $value ) { // Another plugin changed the content on save. Store it exactly, so the preview, // the content-change check and undo all see the same text. global $wpdb; $wpdb->update( $wpdb->posts, array( 'post_content' => $value ), array( 'ID' => (int) $ref ), array( '%s' ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Exact write after wp_update_post; the cache is cleared below. clean_post_cache( (int) $ref ); } break; case Source::ELEMENTOR: $id = self::document( $ref ); if ( ! is_string( $value ) || $id < 1 || ! get_post( $id ) instanceof \WP_Post ) { return false; } update_post_meta( $id, '_elementor_data', wp_slash( $value ) ); self::clear_elementor_cache( $id ); break; case Source::MENU_ITEM: if ( ! is_string( $value ) || null === $this->read( $type, $ref ) ) { return false; } update_post_meta( (int) $ref, '_menu_item_url', esc_url_raw( $value ) ); break; case Source::WIDGET: $widget = self::widget( $ref ); $all = null === $widget ? null : get_option( 'widget_' . $widget[0] ); if ( ! is_array( $value ) || null === $widget || ! is_array( $all ) || ! isset( $all[ $widget[1] ] ) ) { return false; } $all[ $widget[1] ] = $value; update_option( 'widget_' . $widget[0], $all ); break; case Source::POST_META: $meta = self::meta( $ref ); if ( null === $meta || null === $this->read( $type, $ref ) ) { return false; } update_post_meta( $meta[0], $meta[1], wp_slash( $value ) ); break; case Source::OPTION: // Only a setting from the allowed list or one a scan recorded (see option_allowed()). if ( ! self::option_allowed( $ref ) || null === $this->read( $type, $ref ) ) { return false; } update_option( $ref, $value ); break; default: return false; } return $this->read( $type, $ref ) === $value; } /** * Post id and meta key of a post_meta ref such as "12:_my_field". * * @param string $ref Reference. * @return array{0: int, 1: string}|null */ private static function meta( string $ref ): ?array { $parts = explode( ':', $ref, 2 ); $id = (int) $parts[0]; return $id > 0 && '' !== ( $parts[1] ?? '' ) ? array( $id, $parts[1] ) : null; } /** * Whether an option must not be written. * * @param string $name Option name. */ private static function protected_option( string $name ): bool { return '' === $name || in_array( $name, self::PROTECTED_OPTIONS, true ) || str_starts_with( $name, '_transient' ) || str_starts_with( $name, '_site_transient' ) || self::FOUND_OPTIONS === $name; } /** * A stored value as a string or an array; other types (objects, numbers) are not handled. * * @param mixed $value Stored value. * @return string|array|null */ private static function plain( mixed $value ): string|array|null { return is_string( $value ) || is_array( $value ) ? $value : null; } /** * Base id and number of a widget id such as "text-3". * * @param string $ref Widget id. * @return array{0: string, 1: int}|null */ private static function widget( string $ref ): ?array { if ( 1 !== preg_match( '/^([a-z0-9_-]+)-(\d+)$/', $ref, $m ) ) { return null; } return array( $m[1], (int) $m[2] ); } /** * Makes Elementor rebuild the page's CSS and element cache from the changed data. * * @param int $id Document id. */ private static function clear_elementor_cache( int $id ): void { delete_post_meta( $id, '_elementor_css' ); delete_post_meta( $id, '_elementor_element_cache' ); if ( class_exists( '\Elementor\Plugin' ) && isset( \Elementor\Plugin::$instance->files_manager ) && is_object( \Elementor\Plugin::$instance->files_manager ) && method_exists( \Elementor\Plugin::$instance->files_manager, 'clear_cache' ) ) { \Elementor\Plugin::$instance->files_manager->clear_cache(); } } }