diff --git a/.gitignore b/.gitignore index 631a819..0f69194 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .vscode/ notes/ +.phpcs.xml +phpcs-results.txt diff --git a/README.md b/README.md index 1aa2fe5..ba5c4cd 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,34 @@ # WP Site Sync WP Site Sync is a WordPress plugin that allows users to synchronize content between different WordPress sites. It is particularly useful for developers and site administrators who manage multiple WordPress installations and need to keep content consistent across them. + +## Current status + +- Plugin scaffold with settings storage, admin UI, REST API, and WP-Cron schedule wiring. +- REST authentication uses shared key + HMAC headers (`X-Site-Sync-Key`, `X-Site-Sync-Signature`, `X-Site-Sync-Timestamp`) with replay protection. +- Sync engine implements outbound/inbound flow for posts/pages/CPTs and taxonomies: posts delta by `post_modified_gmt`/ID with last-modified-wins, term delta by term ID, external IDs stored in post/term meta, and term slugs synced on posts. Media entities are emitted with metadata checkpoints and fetched via authenticated REST download (fallback to GUID); uploads/ downloads carry SHA-256 checksums for optional integrity validation. +- Sync state storage added (checkpoints/mappings placeholder) with admin status for last run and errors; manual "Sync Now" and handshake test; configurable meta whitelist on the settings screen. + +## Setup (developer preview) + +1. Activate the plugin. +2. In the admin menu, open **Site Sync** and configure: + - Site ID (auto-generated) and shared secret (can be rotated). + - Peer site ID + peer site URL. + - Enable sync and choose a WP-Cron interval (default every 5 minutes). +3. Use the `/wp-json/site-sync/v1/handshake` endpoint to verify connectivity. +4. Trigger a manual sync via the **Sync Now** button. +5. Use **Run Handshake** in the admin page to test connectivity/auth (shows notice). + +## Notes on sync behavior (current) + +- Outbox batches terms (by term ID) then posts/pages/CPTs (by `post_modified_gmt` then ID), then media attachments (by `post_modified_gmt`/ID); checkpoints prevent repeats; tombstones emitted for trashed posts/media and queued with a capped buffer. +- Inbox applies posts with conflict resolution (newer modified wins), syncs taxonomy slugs, applies whitelisted post meta (via settings field or `site_sync/post_meta_keys`, defaults to `_thumbnail_id`); applies terms with parent lookup; media fetched from peer via authenticated REST media endpoint (fallback to GUID) and sideloaded; delete payloads trash/delete posts and media and delete terms. +- External IDs are stored per post (`_site_sync_external_id`) and per term (`_site_sync_term_external_id`); media uses `_site_sync_media_external_id`. +- Media upload endpoint accepts authenticated uploads (10MB cap, MIME allowlist, optional checksum header); downloads include checksum header and are verified when present; delete hooks now emit tombstones for posts/media/terms. +- Logging persisted (last 200 entries) and displayed in admin status; tombstone enqueue deduplicates and caps; delete hooks skip when applying remote deletes to avoid loops. + +## Notes + +- Sync runs outbound-only from the initiating site, satisfying private-site constraints. +- Manual sync trigger and full processing pipeline are planned for subsequent iterations. diff --git a/includes/class-admin.php b/includes/class-admin.php new file mode 100644 index 0000000..ad98670 --- /dev/null +++ b/includes/class-admin.php @@ -0,0 +1,472 @@ +settings = $settings; + $this->state = $state; + $this->engine = $engine; + } + + /** + * Registers all WordPress admin hooks for the Site Sync plugin. + * + * @return void + */ + public function hooks(): void { + add_action( 'admin_menu', array( $this, 'register_menu' ) ); + add_action( 'admin_post_site_sync_save_settings', array( $this, 'handle_save_settings' ) ); + add_action( 'admin_post_site_sync_manual', array( $this, 'handle_manual_sync' ) ); + add_action( 'admin_post_site_sync_handshake', array( $this, 'handle_handshake' ) ); + add_action( 'admin_post_site_sync_reset_state', array( $this, 'handle_reset_state' ) ); + add_action( 'admin_notices', array( $this, 'maybe_render_notices' ) ); + } + + /** + * Registers the Site Sync menu page in the WordPress admin. + * + * @return void + */ + public function register_menu(): void { + add_menu_page( + __( 'Site Sync', 'site-sync' ), + __( 'Site Sync', 'site-sync' ), + 'manage_options', + 'site-sync', + array( $this, 'render_settings_page' ), + 'dashicons-update', + 65 + ); + } + + /** + * Renders the Site Sync settings page in the WordPress admin. + * + * @return void + */ + public function render_settings_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to access this page.', 'site-sync' ) ); + } + + $settings = $this->settings->ensure_defaults(); + $schedules = $this->get_schedule_choices(); + $state = $this->state->get(); + ?> +
+

+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+

+

+
+ + + +
+ +

+

+
+ + + +
+ +

+ + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +

+
+ + + +
+ +

+ + + + + + + + + + + + + + + + + + + + + +
+
+ isset( $_POST['site_uuid'] ) ? wp_unslash( $_POST['site_uuid'] ) : '', + 'shared_key' => isset( $_POST['shared_key'] ) ? wp_unslash( $_POST['shared_key'] ) : '', + 'peer_site_key' => isset( $_POST['peer_site_key'] ) ? wp_unslash( $_POST['peer_site_key'] ) : '', + 'peer_url' => isset( $_POST['peer_url'] ) ? wp_unslash( $_POST['peer_url'] ) : '', + 'post_meta_keys' => isset( $_POST['post_meta_keys'] ) ? wp_unslash( $_POST['post_meta_keys'] ) : '', + 'sync_interval' => isset( $_POST['sync_interval'] ) ? wp_unslash( $_POST['sync_interval'] ) : '', + 'enabled' => isset( $_POST['enabled'] ), + ); + // phpcs:enable + + $this->settings->update( $data ); + $settings = $this->settings->get_settings(); + + Cron::configure( $settings['enabled'], $settings['sync_interval'] ); + + $redirect = add_query_arg( + array( + 'page' => 'site-sync', + 'site_sync_status' => 'saved', + ), + admin_url( 'admin.php' ) + ); + + wp_safe_redirect( $redirect ); + exit; + } + + /** + * Handles the manual sync action from the admin form submission. + * + * @return void + */ + public function handle_manual_sync(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to do this.', 'site-sync' ) ); + } + + check_admin_referer( 'site_sync_manual', 'site_sync_manual_nonce' ); + + $settings = $this->settings->get_settings(); + do_action( 'site_sync/manual_trigger', $settings ); + + $redirect = add_query_arg( + array( + 'page' => 'site-sync', + 'site_sync_status' => 'manual_run', + ), + admin_url( 'admin.php' ) + ); + + wp_safe_redirect( $redirect ); + exit; + } + + /** + * Renders admin notices for Site Sync actions based on query parameters. + * + * @return void + */ + public function maybe_render_notices(): void { + if ( empty( $_GET['page'] ) || 'site-sync' !== $_GET['page'] ) { + return; + } + + if ( isset( $_GET['site_sync_status'] ) && 'saved' === $_GET['site_sync_status'] ) { + printf( + '

%s

', + esc_html__( 'Site Sync settings saved.', 'site-sync' ) + ); + } + + if ( isset( $_GET['site_sync_status'] ) && 'manual_run' === $_GET['site_sync_status'] ) { + printf( + '

%s

', + esc_html__( 'Manual sync triggered.', 'site-sync' ) + ); + } + + if ( isset( $_GET['site_sync_status'] ) && 'handshake_ok' === $_GET['site_sync_status'] ) { + printf( + '

%s

', + esc_html__( 'Handshake succeeded.', 'site-sync' ) + ); + } + + if ( isset( $_GET['site_sync_status'] ) && 'handshake_fail' === $_GET['site_sync_status'] ) { + $msg = isset( $_GET['site_sync_msg'] ) ? sanitize_text_field( wp_unslash( $_GET['site_sync_msg'] ) ) : __( 'Handshake failed.', 'site-sync' ); + printf( + '

%s

', + esc_html( $msg ) + ); + } + + if ( isset( $_GET['site_sync_status'] ) && 'reset_ok' === $_GET['site_sync_status'] ) { + printf( + '

%s

', + esc_html__( 'Sync state reset. Next run will resend all items.', 'site-sync' ) + ); + } + } + + /** + * Handles the handshake action from the admin form submission. + * + * @return void + */ + public function handle_handshake(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to do this.', 'site-sync' ) ); + } + + check_admin_referer( 'site_sync_handshake', 'site_sync_handshake_nonce' ); + + $result = $this->engine->test_handshake(); + + if ( is_wp_error( $result ) ) { + $redirect = add_query_arg( + array( + 'page' => 'site-sync', + 'site_sync_status' => 'handshake_fail', + 'site_sync_msg' => rawurlencode( $result->get_error_message() ), + ), + admin_url( 'admin.php' ) + ); + } else { + $redirect = add_query_arg( + array( + 'page' => 'site-sync', + 'site_sync_status' => 'handshake_ok', + ), + admin_url( 'admin.php' ) + ); + } + + wp_safe_redirect( $redirect ); + exit; + } + + /** + * Handles the reset state action from the admin form submission. + * + * @return void + */ + public function handle_reset_state(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to do this.', 'site-sync' ) ); + } + + check_admin_referer( 'site_sync_reset_state', 'site_sync_reset_state_nonce' ); + + $this->state->reset(); + + $redirect = add_query_arg( + array( + 'page' => 'site-sync', + 'site_sync_status' => 'reset_ok', + ), + admin_url( 'admin.php' ) + ); + + wp_safe_redirect( $redirect ); + exit; + } + + /** + * Returns available schedule choices for the sync interval. + * + * @return array + */ + private function get_schedule_choices(): array { + $choices = array( + 'site_sync_5min' => __( 'Every 5 minutes', 'site-sync' ), + ); + + // Add core schedules to give admins flexibility. + $choices['hourly'] = __( 'Hourly', 'site-sync' ); + $choices['twicedaily'] = __( 'Twice Daily', 'site-sync' ); + $choices['daily'] = __( 'Daily', 'site-sync' ); + + return $choices; + } +} diff --git a/includes/class-auth.php b/includes/class-auth.php new file mode 100644 index 0000000..ec0e3cb --- /dev/null +++ b/includes/class-auth.php @@ -0,0 +1,76 @@ +get_header( 'X-Site-Sync-Key' ); + $signature = $request->get_header( 'X-Site-Sync-Signature' ); + $timestamp = $request->get_header( 'X-Site-Sync-Timestamp' ); + + if ( ! $providedKey || ! $signature || ! $timestamp ) { + return new WP_Error( 'site_sync_missing_headers', __( 'Missing authentication headers.', 'site-sync' ), array( 'status' => 401 ) ); + } + + $peerKey = $settings->get( 'peer_site_key' ); + if ( ! empty( $peerKey ) && ! hash_equals( $peerKey, $providedKey ) ) { + return new WP_Error( 'site_sync_invalid_peer', __( 'Peer key does not match.', 'site-sync' ), array( 'status' => 401 ) ); + } + + $sharedSecret = $settings->get( 'shared_key' ); + if ( empty( $sharedSecret ) ) { + return new WP_Error( 'site_sync_no_secret', __( 'Shared secret is not configured.', 'site-sync' ), array( 'status' => 401 ) ); + } + + $body = $request->get_body() ?? ''; + $expected = self::build_signature( $providedKey, $timestamp, $body, $sharedSecret ); + + if ( ! hash_equals( $expected, $signature ) ) { + return new WP_Error( 'site_sync_bad_signature', __( 'Signature verification failed.', 'site-sync' ), array( 'status' => 401 ) ); + } + + $ts = is_numeric( $timestamp ) ? (int) $timestamp : 0; + if ( $ts > 0 && abs( time() - $ts ) > 300 ) { + return new WP_Error( 'site_sync_stale', __( 'Request timestamp is outside the allowed window.', 'site-sync' ), array( 'status' => 401 ) ); + } + + $cacheKey = 'site_sync_seen_' . md5( $providedKey . $signature . $timestamp ); + if ( false !== get_transient( $cacheKey ) ) { + return new WP_Error( 'site_sync_replay', __( 'Replay detected.', 'site-sync' ), array( 'status' => 401 ) ); + } + set_transient( $cacheKey, 1, 5 * MINUTE_IN_SECONDS ); + + return true; + } + + /** + * Build an HMAC signature for the given key, timestamp, and body using the shared secret. + * + * @param string $key The peer site key. + * @param string $timestamp The request timestamp. + * @param string $body The request body. + * @param string $secret The shared secret key. + * @return string The generated HMAC signature. + */ + public static function build_signature( string $key, string $timestamp, string $body, string $secret ): string { + $canonical = implode( "\n", array( $key, $timestamp, $body ) ); + + return hash_hmac( 'sha256', $canonical, $secret ); + } +} diff --git a/includes/class-cron.php b/includes/class-cron.php new file mode 100644 index 0000000..300f2ea --- /dev/null +++ b/includes/class-cron.php @@ -0,0 +1,106 @@ +settings = $settings; + } + + /** + * Registers cron schedule and sync run hooks. + * + * @return void + */ + public function hooks(): void { + add_filter( 'cron_schedules', array( $this, 'add_schedule' ) ); // phpcs:ignore + add_action( self::HOOK, array( $this, 'run_sync' ) ); + } + + /** + * Adds a custom schedule interval for Site Sync cron jobs. + * + * @param array $schedules Existing cron schedules. + * @return array Modified cron schedules with Site Sync interval. + */ + public function add_schedule( array $schedules ): array { + if ( ! isset( $schedules[ self::DEFAULT_INTERVAL ] ) ) { + $schedules[ self::DEFAULT_INTERVAL ] = array( + 'interval' => 5 * MINUTE_IN_SECONDS, + 'display' => __( 'Every 5 minutes (Site Sync)', 'site-sync' ), + ); + } + + return $schedules; + } + + /** + * Configures the Site Sync cron event by enabling or disabling it and setting the interval. + * + * @param bool $enabled Whether to enable the cron event. + * @param string $interval The interval for the cron event. Defaults to self::DEFAULT_INTERVAL. + * + * @return void + */ + public static function configure( bool $enabled, string $interval = self::DEFAULT_INTERVAL ): void { + $interval = $interval ? $interval : self::DEFAULT_INTERVAL; + + if ( $enabled ) { + // Reset any existing schedule to honor new intervals. + wp_clear_scheduled_hook( self::HOOK ); + wp_schedule_event( time() + MINUTE_IN_SECONDS, $interval, self::HOOK ); + } else { + self::clear_event(); + } + } + + /** + * Clears the scheduled Site Sync cron event. + * + * @return void + */ + public static function clear_event(): void { + wp_clear_scheduled_hook( self::HOOK ); + } + + /** + * Executes the Site Sync cron job by triggering the sync cycle action. + * + * @return void + */ + public function run_sync(): void { + $settings = $this->settings->get_settings(); + + if ( empty( $settings['enabled'] ) ) { + return; + } + + /** + * Hook to start a sync cycle. The actual sync implementation will be + * attached by the sync engine to process outbound and inbound batches. + */ + do_action( 'site_sync/run_cycle', $settings ); + } +} diff --git a/includes/class-log.php b/includes/class-log.php new file mode 100644 index 0000000..86a4e06 --- /dev/null +++ b/includes/class-log.php @@ -0,0 +1,64 @@ + current_time( 'mysql' ), + 'level' => strtoupper( $level ), + 'message' => $message, + 'context' => $context, + ); + + $logs = get_option( self::OPTION_KEY, array() ); + if ( ! is_array( $logs ) ) { + $logs = array(); + } + + array_unshift( $logs, $entry ); + $logs = array_slice( $logs, 0, self::MAX ); + + update_option( self::OPTION_KEY, $logs ); + } + + /** + * Retrieves the most recent log entries. + * + * @param int $limit The maximum number of log entries to retrieve. + * + * @return array The array of recent log entries. + */ + public static function recent( int $limit = 50 ): array { + $logs = get_option( self::OPTION_KEY, array() ); + if ( ! is_array( $logs ) ) { + return array(); + } + + return array_slice( $logs, 0, $limit ); + } +} diff --git a/includes/class-plugin.php b/includes/class-plugin.php new file mode 100644 index 0000000..0bf7106 --- /dev/null +++ b/includes/class-plugin.php @@ -0,0 +1,148 @@ +settings = new Settings(); + $this->state = new State(); + $this->transport = new Transport( $this->settings ); + $this->engine = new Sync_Engine( $this->settings, $this->transport, $this->state ); + $this->admin = new Admin( $this->settings, $this->state, $this->engine ); + $this->rest = new REST_Controller( $this->settings, $this->engine ); + $this->cron = new Cron( $this->settings ); + $this->register_hooks(); + } + + /** + * Returns the singleton instance of the Plugin class. + * + * @return self + */ + public static function instance(): self { + if ( ! self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Registers hooks for admin, REST controller, cron, and sync engine. + * + * @return void + */ + public function register_hooks(): void { + $this->admin->hooks(); + $this->rest->hooks(); + $this->cron->hooks(); + $this->engine->hooks(); + } + + /** + * Handles plugin activation tasks such as setting defaults and configuring cron. + * + * @return void + */ + public static function activate(): void { + $settings = new Settings(); + $settings->ensure_defaults(); + Cron::configure( true, $settings->get( 'sync_interval' ) ); + } + + /** + * Handles plugin deactivation tasks such as clearing scheduled cron events. + * + * @return void + */ + public static function deactivate(): void { + Cron::clear_event(); + } +} diff --git a/includes/class-rest-controller.php b/includes/class-rest-controller.php new file mode 100644 index 0000000..96f839f --- /dev/null +++ b/includes/class-rest-controller.php @@ -0,0 +1,245 @@ +settings = $settings; + $this->engine = $engine; + } + + /** + * Registers REST API hooks for the Site Sync plugin. + * + * @return void + */ + public function hooks(): void { + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + /** + * Registers all REST API routes for the Site Sync plugin. + * + * @return void + */ + public function register_routes(): void { + register_rest_route( + self::ROUTE_NAMESPACE, + '/handshake', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'handshake' ), + 'permission_callback' => '__return_true', + ) + ); + + register_rest_route( + self::ROUTE_NAMESPACE, + '/inbox', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'receive_inbox' ), + 'permission_callback' => array( $this, 'check_auth' ), + ) + ); + + register_rest_route( + self::ROUTE_NAMESPACE, + '/outbox', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'send_outbox' ), + 'permission_callback' => array( $this, 'check_auth' ), + ) + ); + + register_rest_route( + self::ROUTE_NAMESPACE, + '/media', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'handle_media' ), + 'permission_callback' => array( $this, 'check_auth' ), + 'args' => array( + 'external_id' => array( + 'required' => true, + 'type' => 'string', + ), + 'mime_type' => array( + 'required' => true, + 'type' => 'string', + ), + 'filename' => array( + 'required' => false, + 'type' => 'string', + ), + ), + ) + ); + + register_rest_route( + self::ROUTE_NAMESPACE, + '/media', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'send_media' ), + 'permission_callback' => array( $this, 'check_auth' ), + 'args' => array( + 'external_id' => array( + 'required' => true, + 'type' => 'string', + ), + ), + ) + ); + } + + /** + * Handles the handshake endpoint for the Site Sync plugin. + * + * Returns basic site and plugin information for synchronization. + * + * @return WP_REST_Response + */ + public function handshake() { + $settings = $this->settings->ensure_defaults(); + + return new WP_REST_Response( + array( + 'site_uuid' => $settings['site_uuid'], + 'schema' => 'v1', + 'site_url' => home_url(), + 'version' => SITE_SYNC_VERSION, + 'capabilities' => array( + 'content' => true, + 'taxonomies' => true, + 'media' => true, + ), + ) + ); + } + + /** + * Handles the inbox endpoint for receiving synchronization payloads. + * + * @param WP_REST_Request $request The REST request object containing the payload. + * @return WP_REST_Response The response after processing the inbox payload. + */ + public function receive_inbox( WP_REST_Request $request ) { + $payload = $request->get_json_params() ? $request->get_json_params() : array(); + $result = $this->engine->handle_inbox( $payload ); + + return new WP_REST_Response( $result ); + } + + /** + * Handles the outbox endpoint for sending synchronization payloads. + * + * @return WP_REST_Response The response containing the outbox payload. + */ + public function send_outbox() { + $result = $this->engine->provide_outbox( true ); + $this->engine->commit_outbox( $result['cursor'], $result['meta']['counts'] ?? array() ); + + if ( isset( $result['meta'] ) ) { + unset( $result['meta'] ); + } + + return new WP_REST_Response( $result ); + } + + /** + * Handles the media endpoint for receiving and storing media files. + * + * @param WP_REST_Request $request The REST request object containing media data. + * @return WP_REST_Response|WP_Error The response after processing the media upload or WP_Error on failure. + */ + public function handle_media( WP_REST_Request $request ) { + $result = $this->engine->receive_media_stream( $request ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return new WP_REST_Response( + array( + 'status' => 'ok', + 'stored' => $result, + ) + ); + } + + /** + * Handles the media endpoint for sending media files. + * + * @param WP_REST_Request $request The REST request object containing the external_id parameter. + * @return WP_REST_Response|WP_Error The response containing the media file or WP_Error on failure. + */ + public function send_media( WP_REST_Request $request ) { + $externalId = $request->get_param( 'external_id' ); + $media = $this->engine->get_media_file( $externalId ); + + if ( is_wp_error( $media ) ) { + return $media; + } + + $response = new WP_REST_Response( $media['contents'] ); + $response->set_status( 200 ); + $response->header( 'Content-Type', $media['mime'] ); + $response->header( 'Content-Disposition', 'attachment; filename="' . $media['filename'] . '"' ); + $response->header( 'X-Site-Sync-Checksum', $media['checksum'] ); + + return $response; + } + + /** + * Checks authentication for REST API requests. + * + * @param WP_REST_Request $request The REST request object. + * @return true|WP_Error True if authentication passes, WP_Error otherwise. + */ + public function check_auth( WP_REST_Request $request ) { + $result = Auth::verify( $request, $this->settings ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return true; + } +} diff --git a/includes/class-settings.php b/includes/class-settings.php new file mode 100644 index 0000000..08520da --- /dev/null +++ b/includes/class-settings.php @@ -0,0 +1,146 @@ + '', + 'shared_key' => '', + 'peer_site_key' => '', + 'peer_url' => '', + 'enabled' => false, + 'sync_interval' => 'site_sync_5min', + 'post_meta_keys' => self::DEFAULT_META_KEYS, + ); + + /** + * Retrieves the merged plugin settings, applying defaults and normalizing meta keys. + * + * @return array The merged and normalized settings array. + */ + public function get_settings(): array { + $stored = get_option( self::OPTION_KEY, array() ); + $settings = is_array( $stored ) ? $stored : array(); + + $merged = array_merge( $this->defaults, $settings ); + $merged['post_meta_keys'] = $this->normalize_meta_keys( $merged['post_meta_keys'] ); + + return $merged; + } + + /** + * Retrieves a specific setting value by key. + * + * @param string $key The setting key to retrieve. + * @param mixed $default The default value to return if the key does not exist. + * @return mixed The value of the setting or the default value. + */ + public function get( string $key, $default = null ) { // phpcs:ignore + $settings = $this->get_settings(); + + return $settings[ $key ] ?? $default; + } + + /** + * Updates the plugin settings with provided data, sanitizing and normalizing values. + * + * @param array $data The settings data to update. + * @return void + */ + public function update( array $data ): void { + $settings = $this->get_settings(); + + $settings['peer_url'] = isset( $data['peer_url'] ) ? esc_url_raw( $data['peer_url'] ) : ''; + $settings['peer_site_key'] = isset( $data['peer_site_key'] ) ? sanitize_text_field( $data['peer_site_key'] ) : ''; + $settings['shared_key'] = isset( $data['shared_key'] ) ? sanitize_text_field( $data['shared_key'] ) : $settings['shared_key']; + $settings['site_uuid'] = isset( $data['site_uuid'] ) ? sanitize_text_field( $data['site_uuid'] ) : $settings['site_uuid']; + $settings['enabled'] = ! empty( $data['enabled'] ); + $metaKeys = $data['post_meta_keys'] ?? $settings['post_meta_keys']; + $settings['post_meta_keys'] = $this->normalize_meta_keys( $metaKeys ); + + $interval = isset( $data['sync_interval'] ) ? sanitize_text_field( $data['sync_interval'] ) : $settings['sync_interval']; + $settings['sync_interval'] = $interval ? $interval : 'site_sync_5min'; + + update_option( self::OPTION_KEY, $settings ); + } + + /** + * Ensures that default values for 'site_uuid' and 'shared_key' are set if missing. + * + * @return array The updated settings array. + */ + public function ensure_defaults(): array { + $settings = $this->get_settings(); + $changed = false; + + if ( empty( $settings['site_uuid'] ) ) { + $settings['site_uuid'] = wp_generate_uuid4(); + $changed = true; + } + + if ( empty( $settings['shared_key'] ) ) { + $settings['shared_key'] = wp_generate_password( 32, false, false ); + $changed = true; + } + + if ( $changed ) { + update_option( self::OPTION_KEY, $settings ); + } + + return $settings; + } + + /** + * Retrieves the post meta keys to be synchronized. + * + * @return array The array of post meta keys. + */ + public function get_post_meta_keys(): array { + return $this->get_settings()['post_meta_keys']; + } + + /** + * Normalizes the post meta keys input into a sanitized array of unique keys. + * + * @param mixed $value The input value, either a string or array of meta keys. + * @return array The sanitized and unique array of meta keys. + */ + private function normalize_meta_keys( $value ): array { + if ( is_string( $value ) ) { + $parts = preg_split( '/[\s,]+/', $value ); + } elseif ( is_array( $value ) ) { + $parts = $value; + } else { + $parts = array(); + } + + $keys = array(); + foreach ( $parts as $part ) { + $sanitized = ltrim( sanitize_text_field( (string) $part ) ); + if ( $sanitized !== '' ) { + $keys[] = $sanitized; + } + } + + if ( empty( $keys ) ) { + $keys = self::DEFAULT_META_KEYS; + } + + return array_values( array_unique( $keys ) ); + } +} diff --git a/includes/class-state.php b/includes/class-state.php new file mode 100644 index 0000000..073bcc6 --- /dev/null +++ b/includes/class-state.php @@ -0,0 +1,211 @@ + array( + 'posts' => array( + 'modified' => null, + 'id' => 0, + ), + 'terms' => array( + 'modified' => null, + 'id' => 0, + ), + 'media' => array( + 'modified' => null, + 'id' => 0, + ), + ), + 'last_received' => array( + 'posts' => array( + 'modified' => null, + 'id' => 0, + ), + 'terms' => array( + 'modified' => null, + 'id' => 0, + ), + 'media' => array( + 'modified' => null, + 'id' => 0, + ), + ), + 'last_counts' => array( + 'sent' => array( + 'posts' => 0, + 'terms' => 0, + 'media' => 0, + 'tombstones' => 0, + ), + 'received' => array( + 'applied' => 0, + 'skipped' => 0, + 'errors' => 0, + ), + ), + 'mappings' => array( + 'posts' => array(), // phpcs:ignore local_id => array( 'peer' => peer_id, 'peer_site' => site_uuid ) + 'terms' => array(), + 'media' => array(), + ), + 'last_deleted' => array( + 'posts' => 0, + 'terms' => 0, + 'media' => 0, + ), + 'tombstones' => array(), + 'last_run' => null, + 'last_error' => null, + ); + + /** + * Retrieves the current persistent state for the Site Sync plugin. + * + * @return array The current state array merged with defaults. + */ + public function get(): array { + $stored = get_option( self::OPTION_KEY, array() ); + $data = is_array( $stored ) ? $stored : array(); + + return array_merge( $this->defaults, $data ); + } + + /** + * Updates the persistent state with the provided data. + * + * @param array $data The data to merge into the current state. + */ + public function update( array $data ): void { + $state = $this->get(); + $state = array_merge( $state, $data ); + update_option( self::OPTION_KEY, $state ); + } + + /** + * Records the results of a sync run, including summary counts and errors. + * + * @param array $summary An array containing 'counts' and optionally 'error' from the sync run. + */ + public function record_run( array $summary ): void { + $state = $this->get(); + $state['last_run'] = current_time( 'mysql' ); + $state['last_error'] = $summary['error'] ?? null; + + if ( isset( $summary['counts'] ) && is_array( $summary['counts'] ) ) { + $state['last_counts'] = $this->merge_counts( $summary['counts'] ); + } + + update_option( self::OPTION_KEY, $state ); + } + + /** + * Reset checkpoints and tombstone queue to force a full resend on next sync. + */ + public function reset(): void { + $state = $this->get(); + $state['last_sent'] = $this->defaults['last_sent']; + $state['last_received'] = $this->defaults['last_received']; + $state['tombstones'] = array(); + $state['last_counts'] = $this->defaults['last_counts']; + $state['last_error'] = null; + update_option( self::OPTION_KEY, $state ); + } + + /** + * Adds a tombstone item to the queue, ensuring no duplicates based on external_id and type. + * + * @param array $item The tombstone item to enqueue. Should contain 'external_id', 'type', and optionally 'taxonomy'. + * + * @return void + */ + public function enqueue_tombstone( array $item ): void { + // Avoid duplicates: check if same external_id/type already queued. + $state = $this->get(); + if ( ! empty( $item['external_id'] ) && ! empty( $item['type'] ) ) { + $existing = array_filter( + $state['tombstones'], + function ( $t ) use ( $item ) { + return isset( $t['external_id'], $t['type'] ) + && $t['external_id'] === $item['external_id'] + && $t['type'] === $item['type'] + && ( ! isset( $item['taxonomy'] ) || ( $t['taxonomy'] ?? '' ) === ( $item['taxonomy'] ?? '' ) ); + } + ); + if ( ! empty( $existing ) ) { + return; + } + } + + $state['tombstones'][] = $item; + if ( count( $state['tombstones'] ) > self::TOMBSTONE_LIMIT ) { + $state['tombstones'] = array_slice( $state['tombstones'], -self::TOMBSTONE_LIMIT ); + } + update_option( self::OPTION_KEY, $state ); + } + + /** + * Removes and returns up to $limit tombstone items from the queue. + * + * @param int $limit The maximum number of tombstone items to consume. + * @return array The consumed tombstone items. + */ + public function consume_tombstones( int $limit = 50 ): array { + $state = $this->get(); + $queue = $state['tombstones'] ?? array(); + $items = array_slice( $queue, 0, $limit ); + $state['tombstones'] = array_slice( $queue, $limit ); + update_option( self::OPTION_KEY, $state ); + + return $items; + } + + /** + * Returns up to $limit tombstone items from the queue without removing them. + * + * @param int $limit The maximum number of tombstone items to return. + * @return array The tombstone items. + */ + public function peek_tombstones( int $limit = 50 ): array { + $state = $this->get(); + $queue = $state['tombstones'] ?? array(); + + return array_slice( $queue, 0, $limit ); + } + + /** + * Merges provided counts with the default counts, ensuring all values are integers. + * + * @param array $counts The counts to merge, containing 'sent' and 'received' arrays. + * @return array The merged counts array. + */ + private function merge_counts( array $counts ): array { + $defaults = $this->defaults['last_counts']; + $sent = $counts['sent'] ?? array(); + $received = $counts['received'] ?? array(); + + $mergedSent = array_merge( $defaults['sent'], array_map( 'intval', $sent ) ); + $mergedReceived = array_merge( $defaults['received'], array_map( 'intval', $received ) ); + + return array( + 'sent' => $mergedSent, + 'received' => $mergedReceived, + ); + } +} diff --git a/includes/class-sync-engine.php b/includes/class-sync-engine.php new file mode 100644 index 0000000..266d759 --- /dev/null +++ b/includes/class-sync-engine.php @@ -0,0 +1,1553 @@ +settings = $settings; + $this->transport = $transport; + $this->state = $state; + } + + /** + * Registers WordPress hooks for sync operations. + * + * @return void + */ + public function hooks(): void { + add_action( 'site_sync/run_cycle', array( $this, 'run_cycle' ), 10, 1 ); + add_action( 'site_sync/manual_trigger', array( $this, 'run_manual' ), 10, 1 ); + add_action( 'trashed_post', array( $this, 'mark_post_tombstone' ) ); + add_action( 'before_delete_post', array( $this, 'mark_post_tombstone' ) ); + add_action( 'delete_term', array( $this, 'mark_term_tombstone' ), 10, 4 ); + } + + /** + * Cron-driven sync cycle. + * + * @param array $settings Settings array. + */ + public function run_cycle( array $settings ): void { + if ( empty( $settings['enabled'] ) ) { + return; + } + + $this->run_once( 'cron' ); + } + + /** + * Manual sync trigger from admin. + * + * @param array $settings Settings array. + */ + public function run_manual( $settings = array() ): void { // phpcs:ignore + $this->run_once( 'manual' ); + } + + /** + * Main sync loop. + * + * @param string $source The source of the sync trigger (e.g., 'cron' or 'manual'). + */ + public function run_once( string $source ): void { + $settings = $this->settings->ensure_defaults(); + $outCounts = array( + 'posts' => 0, + 'terms' => 0, + 'media' => 0, + 'tombstones' => 0, + ); + $inCounts = array( + 'applied' => 0, + 'skipped' => 0, + 'errors' => 0, + ); + + if ( empty( $settings['peer_url'] ) ) { + Log::warning( 'Sync skipped; peer URL not configured.', array( 'source' => $source ) ); + $this->state->record_run( array( 'error' => 'Peer URL not configured' ) ); + return; + } + + $handshake = $this->transport->handshake(); + if ( is_wp_error( $handshake ) ) { + $this->report_error( 'Handshake failed', $handshake, $source ); + $this->state->record_run( array( 'error' => $handshake->get_error_message() ) ); + return; + } + + Log::info( + 'Handshake succeeded', + array( + 'source' => $source, + 'peer' => $handshake['site_uuid'] ?? 'unknown', + ) + ); + + $outbound = $this->provide_outbox( true ); + $outCounts = $outbound['meta']['counts'] ?? $outCounts; + $payload = $outbound; + if ( isset( $payload['meta'] ) ) { + unset( $payload['meta'] ); + } + + $push = $this->transport->post( '/wp-json/site-sync/v1/inbox', $payload ); + if ( is_wp_error( $push ) ) { + $this->report_error( 'Push to peer failed', $push, $source ); + $this->state->record_run( + array( + 'error' => $push->get_error_message(), + 'counts' => array( + 'sent' => $outCounts, + 'received' => $inCounts, + ), + ) + ); + return; + } + + $this->commit_outbox( $outbound['cursor'], $outbound['meta']['counts'] ?? array() ); + + $pull = $this->transport->get( '/wp-json/site-sync/v1/outbox' ); + if ( is_wp_error( $pull ) ) { + $this->report_error( 'Pull from peer failed', $pull, $source ); + $this->state->record_run( + array( + 'error' => $pull->get_error_message(), + 'counts' => array( + 'sent' => $outCounts, + 'received' => $inCounts, + ), + ) + ); + return; + } + + if ( is_array( $pull ) && ! empty( $pull['items'] ) ) { + $result = $this->handle_inbox( $pull ); + if ( is_array( $result ) ) { + $inCounts = array( + 'applied' => (int) ( $result['applied'] ?? 0 ), + 'skipped' => (int) ( $result['skipped'] ?? 0 ), + 'errors' => is_array( $result['errors'] ?? null ) ? count( $result['errors'] ) : 0, + ); + } + } + + $this->state->record_run( + array( + 'error' => null, + 'counts' => array( + 'sent' => $outCounts, + 'received' => $inCounts, + ), + ) + ); + Log::info( + 'Sync cycle completed', + array( + 'source' => $source, + 'sent' => $outCounts, + 'received' => $inCounts, + ) + ); + } + + /** + * Handle incoming inbox payload from peer. + * + * @param array $payload Incoming payload from peer. + * @return array Result of inbox handling. + */ + public function handle_inbox( array $payload ): array { + $items = $payload['items'] ?? $payload; + $applied = 0; + $skipped = 0; + $errors = array(); + $lastPostCursor = array( + 'modified' => null, + 'id' => 0, + ); + $lastTermCursor = array( 'id' => 0 ); + $lastMediaCursor = array( + 'modified' => null, + 'id' => 0, + ); + + if ( ! is_array( $items ) ) { + return array( + 'status' => 'error', + 'message' => __( 'Invalid payload.', 'site-sync' ), + ); + } + + foreach ( $items as $item ) { + if ( ! is_array( $item ) || empty( $item['entity'] ) ) { + ++$skipped; + continue; + } + + $entity = $item['entity']; + + if ( $entity === 'post' ) { + $result = $this->apply_post( $item ); + + if ( is_wp_error( $result ) ) { + $errors[] = $result->get_error_message(); + Log::error( + 'Inbox post apply failed', + array( + 'code' => $result->get_error_code(), + 'message' => $result->get_error_message(), + 'external_id' => $item['external_id'] ?? '', + 'id' => $item['id'] ?? '', + 'data' => $result->get_error_data(), + ) + ); + continue; + } + + ++$applied; + $lastPostCursor = array( + 'modified' => $item['modified_gmt'] ?? null, + 'id' => (int) ( $item['id'] ?? 0 ), + ); + continue; + } + + if ( $entity === 'term' ) { + $result = $this->apply_term( $item ); + + if ( is_wp_error( $result ) ) { + $errors[] = $result->get_error_message(); + Log::error( + 'Inbox term apply failed', + array( + 'code' => $result->get_error_code(), + 'message' => $result->get_error_message(), + 'external_id' => $item['external_id'] ?? '', + 'taxonomy' => $item['taxonomy'] ?? '', + 'id' => $item['id'] ?? '', + 'data' => $result->get_error_data(), + ) + ); + continue; + } + + ++$applied; + $lastTermCursor = array( + 'id' => (int) ( $item['id'] ?? 0 ), + ); + continue; + } + + if ( $entity === 'media' ) { + $result = $this->apply_media( $item ); + + if ( is_wp_error( $result ) ) { + $errors[] = $result->get_error_message(); + Log::error( + 'Inbox media apply failed', + array( + 'code' => $result->get_error_code(), + 'message' => $result->get_error_message(), + 'external_id' => $item['external_id'] ?? '', + 'id' => $item['id'] ?? '', + 'data' => $result->get_error_data(), + ) + ); + continue; + } + + ++$applied; + $lastMediaCursor = array( + 'modified' => $item['modified_gmt'] ?? null, + 'id' => (int) ( $item['id'] ?? 0 ), + ); + continue; + } + + if ( $entity === 'delete' ) { + $result = $this->apply_delete( $item ); + if ( is_wp_error( $result ) ) { + $errors[] = $result->get_error_message(); + Log::error( + 'Inbox delete apply failed', + array( + 'code' => $result->get_error_code(), + 'message' => $result->get_error_message(), + 'external_id' => $item['external_id'] ?? '', + 'type' => $item['type'] ?? '', + 'data' => $result->get_error_data(), + ) + ); + continue; + } + ++$applied; + continue; + } + + ++$skipped; + } + + // Update last_received checkpoint if progress was made. + $state = $this->state->get(); + if ( $applied > 0 ) { + if ( ! empty( $lastPostCursor['modified'] ) ) { + $state['last_received']['posts'] = $lastPostCursor; + } + if ( ! empty( $lastTermCursor['id'] ) ) { + $state['last_received']['terms'] = $lastTermCursor; + } + if ( ! empty( $lastMediaCursor['modified'] ) ) { + $state['last_received']['media'] = $lastMediaCursor; + } + $this->state->update( $state ); + } + + return array( + 'status' => empty( $errors ) ? 'ok' : 'partial', + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + ); + } + + /** + * Provide outbox payload (deltas) to peer. + * + * @param bool $withMeta Whether to include meta information in the payload. + * @return array Outbox payload. + */ + public function provide_outbox( bool $withMeta = false ): array { + $posts = $this->build_post_outbox(); + $terms = $this->build_term_outbox(); + $media = $this->build_media_outbox(); + $deletes = $this->build_tombstones(); + + $payload = array( + 'items' => array_merge( $terms['items'], $posts['items'], $media['items'], $deletes ), + 'cursor' => array( + 'posts' => $posts['cursor'], + 'terms' => $terms['cursor'], + 'media' => $media['cursor'], + ), + 'status' => 'ok', + ); + + if ( $withMeta ) { + $payload['meta'] = array( + 'counts' => array( + 'posts' => count( $posts['items'] ), + 'terms' => count( $terms['items'] ), + 'media' => count( $media['items'] ), + 'tombstones' => count( $deletes ), + ), + ); + } + + return $payload; + } + + /** + * Commit checkpoints and tombstone consumption after a successful outbound push. + * + * @param array $cursor Checkpoint cursor for posts, terms, and media. + * @param array $counts Counts of items sent in each category. + */ + public function commit_outbox( array $cursor, array $counts ): void { + $state = $this->state->get(); + $changed = false; + + if ( ! empty( $counts['posts'] ) && ! empty( $cursor['posts'] ) ) { + $state['last_sent']['posts'] = $cursor['posts']; + $changed = true; + } + + if ( ! empty( $counts['terms'] ) && ! empty( $cursor['terms'] ) ) { + $state['last_sent']['terms'] = $cursor['terms']; + $changed = true; + } + + if ( ! empty( $counts['media'] ) && ! empty( $cursor['media'] ) ) { + $state['last_sent']['media'] = $cursor['media']; + $changed = true; + } + + if ( $changed ) { + $this->state->update( $state ); + } + + if ( ! empty( $counts['tombstones'] ) ) { + $this->state->consume_tombstones( (int) $counts['tombstones'] ); + } + } + + /** + * Handshake-only test from admin. + */ + public function test_handshake() { + $settings = $this->settings->ensure_defaults(); + + if ( empty( $settings['peer_url'] ) ) { + return new WP_Error( 'site_sync_missing_peer', __( 'Peer URL not configured.', 'site-sync' ) ); + } + + return $this->transport->handshake(); + } + + /** + * Accept incoming media stream (authenticated REST upload). + * + * @param \WP_REST_Request $request The REST request object containing the media stream. + */ + public function receive_media_stream( \WP_REST_Request $request ) { + $externalId = $request->get_param( 'external_id' ); + $mime = $request->get_param( 'mime_type' ); + $filename = $request->get_param( 'filename' ) ? $request->get_param( 'filename' ) : ( 'media-' . wp_generate_password( 6, false ) ); + $body = $request->get_body(); + $size = strlen( $body ); + $headerChecksum = $request->get_header( 'X-Site-Sync-Checksum' ); + + if ( $size > 10 * 1024 * 1024 ) { + return new WP_Error( 'site_sync_media_too_large', __( 'Media payload too large.', 'site-sync' ), array( 'status' => 413 ) ); + } + + $allowed = get_allowed_mime_types(); + if ( $mime && ! in_array( $mime, $allowed, true ) ) { + return new WP_Error( 'site_sync_media_mime', __( 'MIME type not allowed.', 'site-sync' ), array( 'status' => 400 ) ); + } + + if ( ! $externalId || ! $mime || ! $body ) { + return new WP_Error( 'site_sync_bad_media', __( 'Invalid media upload payload.', 'site-sync' ), array( 'status' => 400 ) ); + } + + $computed = hash( 'sha256', $body ); + if ( $headerChecksum && ! hash_equals( $headerChecksum, $computed ) ) { + return new WP_Error( 'site_sync_media_checksum', __( 'Checksum mismatch.', 'site-sync' ), array( 'status' => 400 ) ); + } + + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + $tmp = \wp_tempnam( $filename ); + if ( ! $tmp ) { + return new WP_Error( 'site_sync_media_tmp_fail', __( 'Could not create temp file.', 'site-sync' ), array( 'status' => 500 ) ); + } + + global $wp_filesystem; + if ( empty( $wp_filesystem ) ) { + require_once ABSPATH . '/wp-admin/includes/file.php'; + WP_Filesystem(); + } + $wp_filesystem->put_contents( $tmp, $body, FS_CHMOD_FILE ); + + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + $safeName = $this->normalize_media_filename( $filename, $mime ); + $file = array( + 'name' => $safeName, + 'tmp_name' => $tmp, + 'type' => $mime, + ); + + $filters = $this->temporarily_allow_mime( $mime, pathinfo( $safeName, PATHINFO_EXTENSION ) ); + + $attachmentId = \media_handle_sideload( + $file, + 0, + '', + array( + 'post_mime_type' => $mime, + 'post_status' => 'inherit', + ) + ); + + $this->remove_temporary_mime_filters( $filters ); + + if ( is_wp_error( $attachmentId ) ) { + wp_delete_file( $tmp ); + return $attachmentId; + } + + update_post_meta( $attachmentId, SITE_SYNC_META_MEDIA_EXTERNAL_ID, $externalId ); + wp_delete_file( $tmp ); + + return $attachmentId; + } + + /** + * Builds the outbox payload for posts to be synchronized. + * + * @return array Outbox data for posts including items and cursor. + */ + private function build_post_outbox(): array { + $state = $this->state->get(); + $checkpoint = $state['last_sent']['posts'] ?? array( + 'modified' => null, + 'id' => 0, + ); + $postTypes = $this->get_supported_post_types(); + + $args = array( + 'post_type' => $postTypes, + 'post_status' => array( 'publish', 'pending', 'draft', 'future', 'private' ), + 'posts_per_page' => self::BATCH_SIZE, + 'orderby' => array( + 'modified' => 'ASC', + 'ID' => 'ASC', + ), + 'fields' => 'all', + 'no_found_rows' => true, + ); + + $filter = null; + if ( ! empty( $checkpoint['modified'] ) ) { + $modified = $checkpoint['modified']; + $lastId = (int) ( $checkpoint['id'] ?? 0 ); + $filter = function ( $where ) use ( $modified, $lastId ) { + global $wpdb; + $where .= $wpdb->prepare( + " AND ( ( $wpdb->posts.post_modified_gmt > %s ) OR ( $wpdb->posts.post_modified_gmt = %s AND $wpdb->posts.ID > %d ) )", + $modified, + $modified, + $lastId + ); + return $where; + }; + add_filter( 'posts_where', $filter ); + } + + $query = new \WP_Query( $args ); + + if ( $filter ) { + remove_filter( 'posts_where', $filter ); + } + + $items = array(); + $lastCursor = $checkpoint; + $settings = $this->settings->ensure_defaults(); + + foreach ( $query->posts as $post ) { + $extId = $this->ensure_post_external_id( $post->ID ); + $items[] = array( + 'entity' => 'post', + 'external_id' => $extId, + 'site_uuid' => $settings['site_uuid'], + 'id' => (int) $post->ID, + 'modified_gmt' => $post->post_modified_gmt, + 'data' => $this->serialize_post( $post ), + ); + + $lastCursor = array( + 'modified' => $post->post_modified_gmt, + 'id' => (int) $post->ID, + ); + } + + // Update checkpoint if we sent items. + return array( + 'items' => $items, + 'cursor' => $lastCursor, + ); + } + + /** + * Builds the outbox payload for terms to be synchronized. + * + * @return array Outbox data for terms including items and cursor. + */ + private function build_term_outbox(): array { + global $wpdb; + + $state = $this->state->get(); + $checkpoint = $state['last_sent']['terms'] ?? array( 'id' => 0 ); + $lastId = (int) ( $checkpoint['id'] ?? 0 ); + $taxonomies = $this->get_supported_taxonomies(); + + if ( empty( $taxonomies ) ) { + return array( + 'items' => array(), + 'cursor' => $checkpoint, + ); + } + + $args = array( + 'taxonomy' => $taxonomies, + 'hide_empty' => false, + 'orderby' => 'term_id', + 'order' => 'ASC', + 'number' => self::BATCH_SIZE, + 'fields' => 'all', + 'offset' => 0, + ); + + // Custom filter to only get terms with term_id > $lastId + $terms = get_terms( $args ); + + $filtered_terms = array(); + + foreach ( $terms as $term ) { + if ( $term->term_id > $lastId ) { + $filtered_terms[] = $term; + if ( count( $filtered_terms ) >= self::BATCH_SIZE ) { + break; + } + } + } + + $items = array(); + $lastCursor = $checkpoint; + $settings = $this->settings->ensure_defaults(); + + foreach ( $filtered_terms as $term ) { + $termId = (int) $term->term_id; + $extId = $this->ensure_term_external_id( $termId ); + $parentExt = $term->parent ? $this->ensure_term_external_id( (int) $term->parent ) : null; + $parentSlug = $term->parent ? get_term_field( 'slug', (int) $term->parent, $term->taxonomy ) : null; + + $items[] = array( + 'entity' => 'term', + 'id' => $termId, + 'external_id' => $extId, + 'taxonomy' => $term->taxonomy, + 'slug' => $term->slug, + 'name' => $term->name, + 'description' => $term->description, + 'parent_external_id' => $parentExt, + 'parent_slug' => $parentSlug, + 'site_uuid' => $settings['site_uuid'], + ); + + $lastCursor = array( 'id' => $termId ); + } + + return array( + 'items' => $items, + 'cursor' => $lastCursor, + ); + } + + /** + * Builds the outbox payload for media attachments to be synchronized. + * + * @return array Outbox data for media including items and cursor. + */ + private function build_media_outbox(): array { + $state = $this->state->get(); + $checkpoint = $state['last_sent']['media'] ?? array( + 'modified' => null, + 'id' => 0, + ); + + $args = array( + 'post_type' => 'attachment', + 'post_status' => 'inherit', + 'posts_per_page' => self::BATCH_SIZE, + 'orderby' => array( + 'modified' => 'ASC', + 'ID' => 'ASC', + ), + 'fields' => 'all', + 'no_found_rows' => true, + ); + + $filter = null; + if ( ! empty( $checkpoint['modified'] ) ) { + $modified = $checkpoint['modified']; + $lastId = (int) ( $checkpoint['id'] ?? 0 ); + $filter = function ( $where ) use ( $modified, $lastId ) { + global $wpdb; + $where .= $wpdb->prepare( + " AND ( ( $wpdb->posts.post_modified_gmt > %s ) OR ( $wpdb->posts.post_modified_gmt = %s AND $wpdb->posts.ID > %d ) )", + $modified, + $modified, + $lastId + ); + return $where; + }; + add_filter( 'posts_where', $filter ); + } + + $query = new \WP_Query( $args ); + + if ( $filter ) { + remove_filter( 'posts_where', $filter ); + } + + $items = array(); + $lastCursor = $checkpoint; + $settings = $this->settings->ensure_defaults(); + + foreach ( $query->posts as $attachment ) { + $extId = $this->ensure_media_external_id( $attachment->ID ); + $meta = wp_get_attachment_metadata( $attachment->ID ); + $path = get_attached_file( $attachment->ID ); + $checksum = ( $path && file_exists( $path ) ) ? hash_file( 'sha256', $path ) : null; + + $items[] = array( + 'entity' => 'media', + 'external_id' => $extId, + 'site_uuid' => $settings['site_uuid'], + 'id' => (int) $attachment->ID, + 'modified_gmt' => $attachment->post_modified_gmt, + 'data' => array( + 'post_title' => $attachment->post_title, + 'post_name' => $attachment->post_name, + 'mime_type' => $attachment->post_mime_type, + 'guid' => $attachment->guid, + 'filename' => basename( get_attached_file( $attachment->ID ) ), + 'checksum' => $checksum, + 'meta' => $meta, + ), + ); + + $lastCursor = array( + 'modified' => $attachment->post_modified_gmt, + 'id' => (int) $attachment->ID, + ); + } + + return array( + 'items' => $items, + 'cursor' => $lastCursor, + ); + } + + /** + * Builds the outbox payload for tombstone (delete) entities to be synchronized. + * + * @return array List of tombstone items. + */ + private function build_tombstones(): array { + return $this->state->peek_tombstones( self::BATCH_SIZE ); + } + + /** + * Serializes a WP_Post object into an array for synchronization. + * + * @param \WP_Post $post The post object to serialize. + * @return array The serialized post data. + */ + private function serialize_post( \WP_Post $post ): array { + return array( + 'post_type' => $post->post_type, + 'post_status' => $post->post_status, + 'post_title' => $post->post_title, + 'post_content' => $post->post_content, + 'post_excerpt' => $post->post_excerpt, + 'post_name' => $post->post_name, + 'post_parent' => (int) $post->post_parent, + 'post_date_gmt' => $post->post_date_gmt, + 'post_modified_gmt' => $post->post_modified_gmt, + 'terms' => $this->get_post_terms( $post->ID ), + 'meta' => $this->get_whitelisted_meta( $post->ID ), + ); + } + + /** + * Retrieves the terms associated with a post, grouped by taxonomy. + * + * @param int $postId The ID of the post. + * @return array Array of term slugs grouped by taxonomy. + */ + private function get_post_terms( int $postId ): array { + $taxonomies = get_object_taxonomies( get_post_type( $postId ) ); + $terms = array(); + + foreach ( $taxonomies as $tax ) { + $termObjs = wp_get_object_terms( $postId, $tax, array( 'fields' => 'slugs' ) ); + if ( ! is_wp_error( $termObjs ) && ! empty( $termObjs ) ) { + $terms[ $tax ] = $termObjs; + } + } + + return $terms; + } + + /** + * Retrieves whitelisted meta fields for a given post. + * + * @param int $postId The ID of the post. + * @return array Array of whitelisted meta key-value pairs. + */ + private function get_whitelisted_meta( int $postId ): array { + $keys = $this->settings->get_post_meta_keys(); + $keys = apply_filters( 'site_sync/post_meta_keys', $keys ); + $meta = array(); + + foreach ( $keys as $key ) { + $value = get_post_meta( $postId, $key, true ); + if ( $value !== '' ) { + $meta[ $key ] = maybe_unserialize( $value ); + } + } + + return $meta; + } + + /** + * Ensures a post has an external ID for synchronization, generating and storing one if missing. + * + * @param int $postId The ID of the post. + * @return string The external ID for the post. + */ + private function ensure_post_external_id( int $postId ): string { + $existing = get_post_meta( $postId, SITE_SYNC_META_EXTERNAL_ID, true ); + if ( $existing ) { + return (string) $existing; + } + + $uuid = wp_generate_uuid4(); + update_post_meta( $postId, SITE_SYNC_META_EXTERNAL_ID, $uuid ); + + return $uuid; + } + + /** + * Applies a post item from the sync payload to the local site. + * + * @param array $item The post item data from the sync payload. + * @return true|WP_Error True on success, WP_Error on failure. + */ + private function apply_post( array $item ) { + $externalId = $item['external_id'] ?? ''; + $data = $item['data'] ?? array(); + $modifiedIncoming = $item['modified_gmt'] ?? ''; + + if ( ! $externalId || empty( $data['post_type'] ) ) { + return new WP_Error( 'site_sync_invalid_item', __( 'Missing external ID or post type.', 'site-sync' ) ); + } + + $existingId = $this->find_post_by_external_id( $externalId ); + if ( ! $existingId && ! empty( $data['post_name'] ) ) { + $bySlug = get_page_by_path( $data['post_name'], OBJECT, array( $data['post_type'] ) ); + if ( $bySlug && ! is_wp_error( $bySlug ) ) { + $existingId = (int) $bySlug->ID; + } + } + if ( $existingId ) { + $current = get_post( $existingId ); + if ( $current ) { + $currentModified = $current->post_modified_gmt; + // Conflict resolution: last modified wins. + if ( $currentModified && $modifiedIncoming && $currentModified > $modifiedIncoming ) { + return true; // local newer, skip + } + } + } + + $postArr = array( + 'ID' => $existingId ? $existingId : 0, + 'post_type' => $data['post_type'], + 'post_status' => $data['post_status'] ?? 'draft', + 'post_title' => $data['post_title'] ?? '', + 'post_content' => $data['post_content'] ?? '', + 'post_excerpt' => $data['post_excerpt'] ?? '', + 'post_name' => $data['post_name'] ?? '', + 'post_parent' => isset( $data['post_parent'] ) ? (int) $data['post_parent'] : 0, + 'post_date_gmt' => $data['post_date_gmt'] ?? null, + 'post_modified_gmt' => $modifiedIncoming ? $modifiedIncoming : current_time( 'mysql', true ), + ); + + $postId = $existingId ? wp_update_post( wp_slash( $postArr ), true ) : wp_insert_post( wp_slash( $postArr ), true ); + + if ( is_wp_error( $postId ) ) { + return $postId; + } + + // Ensure external ID mapping is stored. + update_post_meta( $postId, SITE_SYNC_META_EXTERNAL_ID, $externalId ); + + // Taxonomy terms. + if ( ! empty( $data['terms'] ) && is_array( $data['terms'] ) ) { + foreach ( $data['terms'] as $tax => $slugs ) { + if ( ! taxonomy_exists( $tax ) ) { + continue; + } + wp_set_object_terms( $postId, $slugs, $tax, false ); + } + } + + // Whitelisted meta merge: override keys we manage, leave others untouched. + if ( ! empty( $data['meta'] ) && is_array( $data['meta'] ) ) { + foreach ( $data['meta'] as $key => $value ) { + update_post_meta( $postId, $key, $value ); + } + } + + return true; + } + + /** + * Finds a post ID by its external ID meta value. + * + * @param string $externalId The external ID to search for. + * @return int The post ID if found, or 0 if not found. + */ + private function find_post_by_external_id( string $externalId ) { + $posts = get_posts( + array( + 'post_type' => 'any', + 'meta_key' => SITE_SYNC_META_EXTERNAL_ID, + 'meta_value' => $externalId, // phpcs:ignore + 'posts_per_page' => 1, + 'fields' => 'ids', + 'post_status' => array( 'publish', 'pending', 'draft', 'future', 'private', 'trash' ), + ) + ); + + return $posts ? (int) $posts[0] : 0; + } + + /** + * Retrieves the supported post types for synchronization. + * + * @return array List of supported post type names. + */ + private function get_supported_post_types(): array { + $types = get_post_types( array( 'show_ui' => true ), 'names' ); + unset( $types['attachment'], $types['revision'], $types['nav_menu_item'] ); + + return array_values( $types ); + } + + /** + * Ensures a term has an external ID for synchronization, generating and storing one if missing. + * + * @param int $termId The ID of the term. + * @return string The external ID for the term. + */ + private function ensure_term_external_id( int $termId ): string { + $existing = get_term_meta( $termId, SITE_SYNC_META_TERM_EXTERNAL_ID, true ); + if ( $existing ) { + return (string) $existing; + } + + $uuid = wp_generate_uuid4(); + update_term_meta( $termId, SITE_SYNC_META_TERM_EXTERNAL_ID, $uuid ); + + return $uuid; + } + + /** + * Finds a term ID by its external ID meta value within a specific taxonomy. + * + * @param string $externalId The external ID to search for. + * @param string $taxonomy The taxonomy to search within. + * @return int The term ID if found, or 0 if not found. + */ + private function find_term_by_external_id( string $externalId, string $taxonomy ) { + $terms = get_terms( + array( + 'taxonomy' => $taxonomy, + 'hide_empty' => false, + 'meta_query' => array( + array( + 'key' => SITE_SYNC_META_TERM_EXTERNAL_ID, + 'value' => $externalId, + ), + ), + 'fields' => 'ids', + 'number' => 1, + ) + ); + + if ( is_wp_error( $terms ) || empty( $terms ) ) { + return 0; + } + + return (int) $terms[0]; + } + + /** + * Applies a term item from the sync payload to the local site. + * + * @param array $item The term item data from the sync payload. + * @return true|WP_Error True on success, WP_Error on failure. + */ + private function apply_term( array $item ) { + $externalId = $item['external_id'] ?? ''; + $taxonomy = $item['taxonomy'] ?? ''; + $slug = $item['slug'] ?? ''; + $name = $item['name'] ?? $slug; + + if ( ! $externalId || ! $taxonomy || ! $slug ) { + return new WP_Error( 'site_sync_invalid_term', __( 'Missing term data.', 'site-sync' ) ); + } + + if ( ! taxonomy_exists( $taxonomy ) ) { + // translators: %s: taxonomy name. + return new WP_Error( 'site_sync_unknown_taxonomy', sprintf( __( 'Taxonomy %s does not exist.', 'site-sync' ), $taxonomy ) ); + } + + $existingId = $this->find_term_by_external_id( $externalId, $taxonomy ); + if ( ! $existingId ) { + $bySlug = get_term_by( 'slug', $slug, $taxonomy ); + $existingId = $bySlug ? (int) $bySlug->term_id : 0; + } + + $parentId = 0; + if ( ! empty( $item['parent_external_id'] ) ) { + $parentId = $this->find_term_by_external_id( (string) $item['parent_external_id'], $taxonomy ); + } + if ( ! $parentId && ! empty( $item['parent_slug'] ) ) { + $parent = get_term_by( 'slug', $item['parent_slug'], $taxonomy ); + if ( $parent && ! is_wp_error( $parent ) ) { + $parentId = (int) $parent->term_id; + } + } + + $args = array( + 'slug' => $slug, + 'description' => $item['description'] ?? '', + 'parent' => $parentId, + ); + + if ( $existingId ) { + $result = wp_update_term( $existingId, $taxonomy, $args ); + $termId = is_wp_error( $result ) ? $result : (int) $result['term_id']; + } else { + $result = wp_insert_term( $name, $taxonomy, $args ); + $termId = is_wp_error( $result ) ? $result : (int) $result['term_id']; + } + + if ( is_wp_error( $termId ) ) { + return $termId; + } + + update_term_meta( $termId, SITE_SYNC_META_TERM_EXTERNAL_ID, $externalId ); + + return true; + } + + /** + * Retrieves the supported taxonomies for synchronization. + * + * @return array List of supported taxonomy names. + */ + private function get_supported_taxonomies(): array { + $tax = get_taxonomies( array( 'public' => true ), 'names' ); + + return array_values( $tax ); + } + + /** + * Ensures a media attachment has an external ID for synchronization, generating and storing one if missing. + * + * @param int $attachmentId The ID of the media attachment. + * @return string The external ID for the media attachment. + */ + private function ensure_media_external_id( int $attachmentId ): string { + $existing = get_post_meta( $attachmentId, SITE_SYNC_META_MEDIA_EXTERNAL_ID, true ); + if ( $existing ) { + return (string) $existing; + } + + $uuid = wp_generate_uuid4(); + update_post_meta( $attachmentId, SITE_SYNC_META_MEDIA_EXTERNAL_ID, $uuid ); + + return $uuid; + } + + /** + * Finds a media attachment ID by its external ID meta value. + * + * @param string $externalId The external ID to search for. + * @return int The attachment ID if found, or 0 if not found. + */ + private function find_media_by_external_id( string $externalId ) { + $posts = get_posts( + array( + 'post_type' => 'attachment', + 'meta_key' => SITE_SYNC_META_MEDIA_EXTERNAL_ID, + 'meta_value' => $externalId, // phpcs:ignore + 'posts_per_page' => 1, + 'fields' => 'ids', + 'post_status' => 'inherit', + ) + ); + + return $posts ? (int) $posts[0] : 0; + } + + /** + * Marks a post or media item for deletion by enqueuing a tombstone. + * + * @param int $postId The ID of the post or media to mark as deleted. + * @return void + */ + public function mark_post_tombstone( int $postId ): void { + $post = get_post( $postId ); + if ( ! $post ) { + return; + } + + $type = $post->post_type === 'attachment' ? 'media' : 'post'; + $externalId = $type === 'media' + ? $this->ensure_media_external_id( $postId ) + : $this->ensure_post_external_id( $postId ); + + // Skip enqueue if this is already a remote tombstone application. + if ( did_action( 'site_sync/applying_delete' ) ) { + return; + } + + $this->state->enqueue_tombstone( + array( + 'entity' => 'delete', + 'type' => $type, + 'external_id' => $externalId, + 'id' => $postId, + ) + ); + } + + /** + * Marks a term for deletion by enqueuing a tombstone. + * + * @param int|\WP_Term $term The term object or ID to mark as deleted. + * @param int $tt_id Term taxonomy ID (unused). + * @param string $taxonomy The taxonomy of the term. + * + * @return void + */ + public function mark_term_tombstone( $term, $tt_id, $taxonomy ): void { + if ( ! $taxonomy || ! $term ) { + return; + } + + $termId = is_object( $term ) ? (int) $term->term_id : (int) $term; + $externalId = $this->ensure_term_external_id( $termId ); + + if ( did_action( 'site_sync/applying_delete' ) ) { + return; + } + + $this->state->enqueue_tombstone( + array( + 'entity' => 'delete', + 'type' => 'term', + 'taxonomy' => $taxonomy, + 'external_id' => $externalId, + 'id' => $termId, + ) + ); + } + + /** + * Retrieves the media file and its metadata by external ID. + * + * @param string $externalId The external ID of the media attachment. + * @return array|WP_Error Array with file data or WP_Error on failure. + */ + public function get_media_file( string $externalId ) { + $id = $this->find_media_by_external_id( $externalId ); + if ( ! $id ) { + return new WP_Error( 'site_sync_media_not_found', __( 'Media not found for external ID.', 'site-sync' ), array( 'status' => 404 ) ); + } + + $path = get_attached_file( $id ); + if ( ! $path || ! file_exists( $path ) ) { + return new WP_Error( 'site_sync_media_missing_file', __( 'Media file not found on disk.', 'site-sync' ), array( 'status' => 404 ) ); + } + + $mime = get_post_mime_type( $id ) ? get_post_mime_type( $id ) : 'application/octet-stream'; + $filename = basename( $path ); + + $checksum = file_exists( $path ) ? hash_file( 'sha256', $path ) : null; + + global $wp_filesystem; + if ( empty( $wp_filesystem ) ) { + require_once ABSPATH . '/wp-admin/includes/file.php'; + WP_Filesystem(); + } + $contents = $wp_filesystem->get_contents( $path ); + + return array( + 'path' => $path, + 'mime' => $mime, + 'filename' => $filename, + 'contents' => $contents, + 'checksum' => $checksum, + ); + } + + /** + * Applies a media item from the sync payload to the local site. + * + * @param array $item The media item data from the sync payload. + * @return true|WP_Error True on success, WP_Error on failure. + */ + private function apply_media( array $item ) { + $externalId = $item['external_id'] ?? ''; + $data = $item['data'] ?? array(); + $modifiedIncoming = $item['modified_gmt'] ?? ''; + + if ( ! $externalId || empty( $data['mime_type'] ) ) { + return new WP_Error( 'site_sync_invalid_media', __( 'Missing media data.', 'site-sync' ) ); + } + + $existingId = $this->find_media_by_external_id( $externalId ); + if ( $existingId ) { + $current = get_post( $existingId ); + if ( $current ) { + $currentModified = $current->post_modified_gmt; + if ( $currentModified && $modifiedIncoming && $currentModified > $modifiedIncoming ) { + return true; // local newer + } + } + } + + // Try authenticated fetch from peer media endpoint; fallback to GUID fetch. + $tmp = null; + $file = null; + require_once ABSPATH . 'wp-admin/includes/file.php'; + $fetched = $this->transport->fetch_media_by_external_id( $externalId ); + if ( ! is_wp_error( $fetched ) && ! empty( $fetched['body'] ) ) { + $tmp = \wp_tempnam( $externalId ); + if ( $tmp ) { + global $wp_filesystem; + if ( empty( $wp_filesystem ) ) { + require_once ABSPATH . '/wp-admin/includes/file.php'; + WP_Filesystem(); + } + $written = $wp_filesystem->put_contents( $tmp, $fetched['body'], FS_CHMOD_FILE ); + if ( $written ) { + $mime = $data['mime_type'] ?? ''; + $filename = $data['filename'] ?? $data['post_name'] ?? ( 'media-' . substr( $externalId, 0, 8 ) ); + $ext = $this->determine_extension( $mime, $filename ); + $safeName = $this->normalize_media_filename( $filename, $mime, $ext ); + $file = array( + 'name' => $safeName, + 'tmp_name' => $tmp, + 'type' => $mime, + 'checksum' => $fetched['checksum'] ?? null, + ); + } + } + } else { + $guid = $data['guid'] ?? ''; + if ( $guid && wp_http_validate_url( $guid ) ) { + $response = wp_remote_get( $guid, array( 'timeout' => 30 ) ); + if ( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) === 200 ) { + $body = wp_remote_retrieve_body( $response ); + if ( ! empty( $body ) ) { + $tmp = \wp_tempnam( $guid ); + if ( $tmp ) { + global $wp_filesystem; + if ( empty( $wp_filesystem ) ) { + require_once ABSPATH . '/wp-admin/includes/file.php'; + WP_Filesystem(); + } + $written = $wp_filesystem->put_contents( $tmp, $body, FS_CHMOD_FILE ); + if ( $written ) { + $mime = $data['mime_type'] ?? ''; + $ext = $this->determine_extension( $mime, $guid ); + $url_parts = wp_parse_url( $guid ); + $path = isset( $url_parts['path'] ) ? $url_parts['path'] : 'sync-media'; + $safeName = $this->normalize_media_filename( basename( $path ), $mime, $ext ); + $file = array( + 'name' => $safeName, + 'tmp_name' => $tmp, + 'type' => $data['mime_type'] ?? '', + ); + } + } + } + } + } + } + + if ( $file && isset( $file['checksum'] ) ) { + $downloadChecksum = hash_file( 'sha256', $file['tmp_name'] ); + if ( $downloadChecksum && $file['checksum'] && ! hash_equals( $file['checksum'], $downloadChecksum ) ) { + wp_delete_file( $file['tmp_name'] ); + return new WP_Error( 'site_sync_media_checksum', __( 'Media checksum mismatch.', 'site-sync' ) ); + } + } + + if ( ! $file ) { + // Create a placeholder attachment post without file. + $attachment = array( + 'ID' => $existingId ? $existingId : 0, + 'post_title' => $data['post_title'] ?? ( $data['post_name'] ?? 'media' ), + 'post_name' => $data['post_name'] ?? '', + 'post_mime_type' => $data['mime_type'], + 'post_status' => 'inherit', + 'post_modified_gmt' => $modifiedIncoming ? $modifiedIncoming : current_time( 'mysql', true ), + 'post_date_gmt' => $data['post_date_gmt'] ?? null, + ); + + $attachmentId = $existingId + ? wp_update_post( wp_slash( $attachment ), true ) + : wp_insert_post( wp_slash( $attachment ), true ); + } else { + require_once ABSPATH . 'wp-admin/includes/image.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + $mime = $data['mime_type'] ?? ''; + $ext = $this->determine_extension( $mime, $file['name'] ?? '' ); + $filters = $this->temporarily_allow_mime( $mime, $ext ); + + $attachmentId = \media_handle_sideload( + $file, + 0, + $data['post_title'] ?? '', + array( + 'post_name' => $data['post_name'] ?? '', + 'post_mime_type' => $mime, + 'post_modified_gmt' => $modifiedIncoming ? $modifiedIncoming : current_time( 'mysql', true ), + 'post_date_gmt' => $data['post_date_gmt'] ?? null, + ) + ); + + $this->remove_temporary_mime_filters( $filters ); + } + + if ( is_wp_error( $attachmentId ) ) { + Log::error( + 'Media sideload failed', + array( + 'code' => $attachmentId->get_error_code(), + 'message' => $attachmentId->get_error_message(), + 'external_id' => $externalId, + 'mime' => $data['mime_type'] ?? '', + 'filename' => $file['name'] ?? '', + 'data' => $attachmentId->get_error_data(), + ) + ); + return $attachmentId; + } + + update_post_meta( $attachmentId, SITE_SYNC_META_MEDIA_EXTERNAL_ID, $externalId ); + + if ( $tmp && file_exists( $tmp ) ) { + wp_delete_file( $tmp ); + } + + return true; + } + + /** + * Applies a delete item from the sync payload to the local site. + * + * @param array $item The delete item data from the sync payload. + * @return true|WP_Error True on success, WP_Error on failure. + */ + private function apply_delete( array $item ) { + $type = $item['type'] ?? ''; + $externalId = $item['external_id'] ?? ''; + $taxonomy = $item['taxonomy'] ?? ''; + + if ( ! $type || ! $externalId ) { + return new WP_Error( 'site_sync_invalid_delete', __( 'Invalid delete payload.', 'site-sync' ) ); + } + + if ( $type === 'post' ) { + $id = $this->find_post_by_external_id( $externalId ); + if ( $id ) { + do_action( 'site_sync/applying_delete', $type, $id ); + wp_trash_post( $id ); + } + return true; + } + + if ( $type === 'media' ) { + $id = $this->find_media_by_external_id( $externalId ); + if ( $id ) { + do_action( 'site_sync/applying_delete', $type, $id ); + wp_delete_attachment( $id, true ); + } + return true; + } + + if ( $type === 'term' ) { + if ( ! $taxonomy ) { + return new WP_Error( 'site_sync_invalid_delete', __( 'Missing taxonomy for term delete.', 'site-sync' ) ); + } + $id = $this->find_term_by_external_id( $externalId, $taxonomy ); + if ( $id ) { + do_action( 'site_sync/applying_delete', $type, $id ); + wp_delete_term( $id, $taxonomy ); + } + return true; + } + + return new WP_Error( 'site_sync_unknown_delete', __( 'Unknown delete entity type.', 'site-sync' ) ); + } + + /** + * Derive a reasonable extension from MIME or filename. + * + * @param string $mime The MIME type to use for extension lookup. + * @param string $filename The filename to use for extension extraction. + * @return string The determined file extension, including the dot. + */ + private function determine_extension( string $mime, string $filename = '' ): string { + $mime = (string) $mime; + if ( $mime ) { + $extMap = wp_get_mime_types(); + $extGuess = array_search( $mime, $extMap, true ); + if ( $extGuess ) { + $parts = explode( '|', $extGuess ); + if ( ! empty( $parts[0] ) ) { + return '.' . $parts[0]; + } + } + } + + if ( $filename ) { + $ext = pathinfo( $filename, PATHINFO_EXTENSION ); + if ( $ext ) { + return '.' . strtolower( $ext ); + } + } + + return ''; + } + + /** + * Sanitize a media filename and ensure single extension. + * + * @param string $name The original filename. + * @param string $mime The MIME type of the file. + * @param string $ext The file extension (optional). + * @return string The sanitized filename with a single extension. + */ + private function normalize_media_filename( string $name, string $mime = '', string $ext = '' ): string { + $base = pathinfo( $name, PATHINFO_FILENAME ); + $base = $base ? $base : 'media'; + $safeBase = sanitize_file_name( $base ); + $cleanExt = $ext ? $ext : $this->determine_extension( $mime, $name ); + + // Remove leading dot from ext before re-appending. + $cleanExt = $cleanExt ? '.' . ltrim( $cleanExt, '.' ) : ''; + + return $safeBase . $cleanExt; + } + + /** + * Temporarily allow a specific mime for uploads/sideloads. + * + * @param string $mime The MIME type to allow. + * @param string $ext The file extension to allow (optional). + * @return array|null The added filters, or null if not applied. + */ + private function temporarily_allow_mime( string $mime, string $ext = '' ): ?array { + $mime = (string) $mime; + if ( ! $mime ) { + return null; + } + + $ext = ltrim( $ext, '.' ); + $mimesCallback = function ( $mimes ) use ( $mime, $ext ) { + if ( ! is_array( $mimes ) ) { + $mimes = array(); + } + $existing = array_search( $mime, $mimes, true ); + if ( $existing === false ) { + $extKey = $ext ? $ext : ( 'sync-' . sanitize_key( str_replace( '/', '-', $mime ) ) ); + $mimes[ $extKey ] = $mime; + } + return $mimes; + }; + + $checkCallback = function ( $types, $file, $filename, $mimes ) use ( $mime, $ext ) { // phpcs:ignore + $extOut = $ext ? $ext : pathinfo( $filename, PATHINFO_EXTENSION ); + if ( $mime && $extOut ) { + return array( + 'ext' => ltrim( strtolower( $extOut ), '.' ), + 'type' => $mime, + 'proper_filename' => $filename, + ); + } + return $types; + }; + + add_filter( 'upload_mimes', $mimesCallback, 10, 1 ); + add_filter( 'wp_check_filetype_and_ext', $checkCallback, 10, 4 ); + + return array( + 'mimes' => $mimesCallback, + 'check' => $checkCallback, + ); + } + + /** + * Removes temporary MIME type and filetype filters added for uploads/sideloads. + * + * @param array|null $filters The filters to remove, as returned by temporarily_allow_mime(). + * @return void + */ + private function remove_temporary_mime_filters( $filters ): void { + if ( ! $filters || ! is_array( $filters ) ) { + return; + } + if ( ! empty( $filters['mimes'] ) ) { + remove_filter( 'upload_mimes', $filters['mimes'], 10 ); + } + if ( ! empty( $filters['check'] ) ) { + remove_filter( 'wp_check_filetype_and_ext', $filters['check'], 10 ); + } + } + + /** + * Logs an error message with details from a WP_Error object. + * + * @param string $message The error message to log. + * @param WP_Error $error The WP_Error object containing error details. + * @param string $source The source of the error (e.g., 'cron', 'manual'). + * + * @return void + */ + private function report_error( string $message, WP_Error $error, string $source ): void { + Log::error( + $message, + array( + 'source' => $source, + 'code' => $error->get_error_code(), + 'msg' => $error->get_error_message(), + 'data' => $error->get_error_data(), + ) + ); + } +} diff --git a/includes/class-transport.php b/includes/class-transport.php new file mode 100644 index 0000000..388b264 --- /dev/null +++ b/includes/class-transport.php @@ -0,0 +1,222 @@ +settings = $settings; + } + + /** + * Perform a GET request to the peer. + * + * @param string $path The API path to request from the peer. + */ + public function get( string $path ) { + $url = $this->build_url( $path ); + $headers = $this->build_headers( '' ); + + $response = wp_remote_get( + $url, + array( + 'headers' => $headers, + 'timeout' => $this->timeout( 30 ), + ) + ); + + return $this->parse_response( $response ); + } + + /** + * Perform a POST request with JSON body to the peer. + * + * @param string $path The API path to request from the peer. + * @param array $body The data to send as the JSON body. + */ + public function post( string $path, array $body ) { + $payload = wp_json_encode( $body ); + $url = $this->build_url( $path ); + $headers = array_merge( + $this->build_headers( $payload ), + array( 'Content-Type' => 'application/json' ) + ); + + $response = wp_remote_post( + $url, + array( + 'headers' => $headers, + 'body' => $payload, + 'timeout' => $this->timeout( 180 ), + ) + ); + + return $this->parse_response( $response ); + } + + /** + * Perform a POST with raw body (for media uploads) — scaffold for future use. + * + * @param string $path The API path to request from the peer. + * @param mixed $body The raw body to send (e.g., file contents). + * @param array $extraHeaders Additional headers to include in the request. + */ + public function post_stream( string $path, $body, array $extraHeaders = array() ) { + $url = $this->build_url( $path ); + $payload = $body; + $headers = array_merge( + $this->build_headers( is_string( $payload ) ? $payload : '' ), + $extraHeaders + ); + + $response = wp_remote_post( + $url, + array( + 'headers' => $headers, + 'body' => $payload, + 'timeout' => $this->timeout( 60 ), + ) + ); + + return $this->parse_response( $response ); + } + + /** + * Fetch binary media from peer (no JSON decoding). + * + * @param string $externalId The external media ID to fetch. + */ + public function fetch_media_by_external_id( string $externalId ) { + $path = '/wp-json/site-sync/v1/media?external_id=' . rawurlencode( $externalId ); + $url = $this->build_url( $path ); + $headers = $this->build_headers( '' ); + + $response = wp_remote_get( + $url, + array( + 'headers' => $headers, + 'timeout' => $this->timeout( 60 ), + ) + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $code = wp_remote_retrieve_response_code( $response ); + if ( $code !== 200 ) { + return new WP_Error( + 'site_sync_media_fetch_failed', + sprintf( 'Media fetch failed with HTTP %d', $code ), + array( 'body' => wp_remote_retrieve_body( $response ) ) + ); + } + + $headersOut = wp_remote_retrieve_headers( $response ); + + return array( + 'body' => wp_remote_retrieve_body( $response ), + 'headers' => $headersOut, + 'checksum' => is_array( $headersOut ) && isset( $headersOut['x-site-sync-checksum'] ) ? $headersOut['x-site-sync-checksum'] : null, + 'code' => $code, + ); + } + + /** + * Run a handshake to verify connectivity and identity. + */ + public function handshake() { + return $this->get( '/wp-json/site-sync/v1/handshake' ); + } + + /** + * Build the full URL to the peer for a given API path. + * + * @param string $path The API path to append to the peer URL. + * @return string The full URL. + */ + private function build_url( string $path ): string { + $peer = rtrim( $this->settings->get( 'peer_url', '' ), '/' ); + $path = '/' . ltrim( $path, '/' ); + + return $peer . $path; + } + + /** + * Build the HTTP headers required for SiteSync authentication. + * + * @param string $body The request body to be signed. + * @return array The array of headers. + */ + private function build_headers( string $body ): array { + $siteKey = $this->settings->get( 'site_uuid' ); + $secret = $this->settings->get( 'shared_key' ); + $timestamp = (string) time(); + $signature = Auth::build_signature( $siteKey, $timestamp, $body, $secret ); + + return array( + 'X-Site-Sync-Key' => $siteKey, + 'X-Site-Sync-Timestamp' => $timestamp, + 'X-Site-Sync-Signature' => $signature, + ); + } + + /** + * Parse the HTTP response and return decoded data or WP_Error on failure. + * + * @param mixed $response The response from wp_remote_get or wp_remote_post. + * @return mixed|WP_Error Decoded response data, raw body, or WP_Error on error. + */ + private function parse_response( $response ) { + if ( is_wp_error( $response ) ) { + return $response; + } + + $code = wp_remote_retrieve_response_code( $response ); + $body = wp_remote_retrieve_body( $response ); + $data = json_decode( $body, true ); + + if ( $code >= 200 && $code < 300 ) { + return $data ?? $body; + } + + return new WP_Error( + 'site_sync_http_error', + sprintf( 'HTTP %d from peer', $code ), + array( 'body' => $body ) + ); + } + + /** + * Get the HTTP timeout value, allowing for filtering. + * + * @param int $default The default timeout value in seconds. + * @return int The filtered timeout value, minimum 5 seconds. + */ + private function timeout( int $default ): int { // phpcs:ignore + $filtered = apply_filters( 'site_sync/http_timeout', $default ); + $value = is_numeric( $filtered ) ? (int) $filtered : $default; + + return max( 5, $value ); + } +} diff --git a/site-sync.php b/site-sync.php new file mode 100644 index 0000000..36702fe --- /dev/null +++ b/site-sync.php @@ -0,0 +1,28 @@ +