feature: Initial functional push

This commit is contained in:
Keith Solomon
2025-12-14 16:58:52 -06:00
parent 15445ad40e
commit 189b32ccff
14 changed files with 3365 additions and 0 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
.vscode/ .vscode/
notes/ notes/
.phpcs.xml
phpcs-results.txt

View File

@@ -1,3 +1,34 @@
# WP Site Sync # 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. 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.

472
includes/class-admin.php Normal file
View File

@@ -0,0 +1,472 @@
<?php
namespace SiteSync;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles the WordPress admin interface and actions for the Site Sync plugin.
*/
class Admin {
/**
* Settings instance for managing plugin options.
*
* @var Settings
*/
private $settings;
/**
* State instance for managing sync state.
*
* @var State
*/
private $state;
/**
* Sync_Engine instance for handling sync operations.
*
* @var Sync_Engine
*/
private $engine;
/**
* Admin constructor.
*
* @param Settings $settings Settings instance for managing plugin options.
* @param State $state State instance for managing sync state.
* @param Sync_Engine $engine Sync_Engine instance for handling sync operations.
*/
public function __construct( Settings $settings, State $state, Sync_Engine $engine ) {
$this->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();
?>
<div class="wrap">
<h1><?php esc_html_e( 'Site Sync', 'site-sync' ); ?></h1>
<p><?php esc_html_e( 'Configure the connection to your peer site and schedule syncs.', 'site-sync' ); ?></p>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'site_sync_save_settings', 'site_sync_nonce' ); ?>
<input type="hidden" name="action" value="site_sync_save_settings">
<table class="form-table" role="presentation">
<tr>
<th scope="row">
<label for="site_uuid"><?php esc_html_e( 'Site ID', 'site-sync' ); ?></label>
</th>
<td>
<input type="text" id="site_uuid" name="site_uuid" value="<?php echo esc_attr( $settings['site_uuid'] ); ?>" class="regular-text" readonly>
<p class="description"><?php esc_html_e( 'Share this ID with the peer so it can identify this site.', 'site-sync' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="shared_key"><?php esc_html_e( 'Shared Secret', 'site-sync' ); ?></label>
</th>
<td>
<input type="text" id="shared_key" name="shared_key" value="<?php echo esc_attr( $settings['shared_key'] ); ?>" class="regular-text" autocomplete="off">
<p class="description"><?php esc_html_e( 'Used to sign requests to and from the peer.', 'site-sync' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="peer_site_key"><?php esc_html_e( 'Peer Site ID', 'site-sync' ); ?></label>
</th>
<td>
<input type="text" id="peer_site_key" name="peer_site_key" value="<?php echo esc_attr( $settings['peer_site_key'] ); ?>" class="regular-text">
<p class="description"><?php esc_html_e( 'Site ID provided by your peer instance.', 'site-sync' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="peer_url"><?php esc_html_e( 'Peer Site URL', 'site-sync' ); ?></label>
</th>
<td>
<input type="url" id="peer_url" name="peer_url" value="<?php echo esc_attr( $settings['peer_url'] ); ?>" class="regular-text" placeholder="https://peer-site.test">
<p class="description"><?php esc_html_e( 'Base URL to reach the peer (will call its REST endpoints).', 'site-sync' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="post_meta_keys"><?php esc_html_e( 'Post Meta Keys', 'site-sync' ); ?></label>
</th>
<td>
<input type="text" id="post_meta_keys" name="post_meta_keys" value="<?php echo esc_attr( implode( ', ', $settings['post_meta_keys'] ?? array() ) ); ?>" class="regular-text" placeholder="_thumbnail_id, custom_key">
<p class="description"><?php esc_html_e( 'Comma or space separated meta keys to sync (defaults to _thumbnail_id). Also filterable via site_sync/post_meta_keys.', 'site-sync' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Sync Schedule', 'site-sync' ); ?></th>
<td>
<select name="sync_interval" id="sync_interval">
<?php foreach ( $schedules as $key => $label ) : ?>
<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $settings['sync_interval'], $key ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
<p class="description"><?php esc_html_e( 'How often WP-Cron should attempt a sync.', 'site-sync' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Enable Sync', 'site-sync' ); ?></th>
<td>
<label>
<input type="checkbox" name="enabled" value="1" <?php checked( $settings['enabled'] ); ?>>
<?php esc_html_e( 'Allow scheduled sync jobs to run.', 'site-sync' ); ?>
</label>
</td>
</tr>
</table>
<?php submit_button( __( 'Save Settings', 'site-sync' ) ); ?>
</form>
<hr>
<h2><?php esc_html_e( 'Manual Sync', 'site-sync' ); ?></h2>
<p><?php esc_html_e( 'Trigger an immediate sync cycle using the current settings.', 'site-sync' ); ?></p>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'site_sync_manual', 'site_sync_manual_nonce' ); ?>
<input type="hidden" name="action" value="site_sync_manual">
<?php submit_button( __( 'Sync Now', 'site-sync' ), 'secondary' ); ?>
</form>
<h2><?php esc_html_e( 'Maintenance', 'site-sync' ); ?></h2>
<p><?php esc_html_e( 'Reset checkpoints to force a full resend on the next sync. Use if the destination was emptied or fell out of sync.', 'site-sync' ); ?></p>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'site_sync_reset_state', 'site_sync_reset_state_nonce' ); ?>
<input type="hidden" name="action" value="site_sync_reset_state">
<?php submit_button( __( 'Reset Sync State', 'site-sync' ), 'delete' ); ?>
</form>
<h2><?php esc_html_e( 'Status', 'site-sync' ); ?></h2>
<table class="widefat striped">
<tbody>
<tr>
<th><?php esc_html_e( 'Last Run', 'site-sync' ); ?></th>
<td><?php echo esc_html( $state['last_run'] ?? __( 'Never', 'site-sync' ) ); ?></td>
</tr>
<tr>
<th><?php esc_html_e( 'Last Error', 'site-sync' ); ?></th>
<td><?php echo esc_html( $state['last_error'] ?? __( 'None', 'site-sync' ) ); ?></td>
</tr>
<tr>
<th><?php esc_html_e( 'Last Sent', 'site-sync' ); ?></th>
<td>
<?php
$sent = $state['last_counts']['sent'] ?? array();
printf(
/* translators: 1: posts count, 2: terms count, 3: media count, 4: tombstones count */
esc_html__( 'Posts: %1$d, Terms: %2$d, Media: %3$d, Deletes: %4$d', 'site-sync' ),
(int) ( $sent['posts'] ?? 0 ),
(int) ( $sent['terms'] ?? 0 ),
(int) ( $sent['media'] ?? 0 ),
(int) ( $sent['tombstones'] ?? 0 )
);
?>
</td>
</tr>
<tr>
<th><?php esc_html_e( 'Last Applied (Inbound)', 'site-sync' ); ?></th>
<td>
<?php
$recv = $state['last_counts']['received'] ?? array();
printf(
/* translators: 1: applied count, 2: skipped count, 3: errors count */
esc_html__( 'Applied: %1$d, Skipped: %2$d, Errors: %3$d', 'site-sync' ),
(int) ( $recv['applied'] ?? 0 ),
(int) ( $recv['skipped'] ?? 0 ),
(int) ( $recv['errors'] ?? 0 )
);
?>
</td>
</tr>
</tbody>
</table>
<h2><?php esc_html_e( 'Connectivity Test', 'site-sync' ); ?></h2>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'site_sync_handshake', 'site_sync_handshake_nonce' ); ?>
<input type="hidden" name="action" value="site_sync_handshake">
<?php submit_button( __( 'Run Handshake', 'site-sync' ), 'secondary' ); ?>
</form>
<h2><?php esc_html_e( 'Recent Logs', 'site-sync' ); ?></h2>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e( 'Time', 'site-sync' ); ?></th>
<th><?php esc_html_e( 'Level', 'site-sync' ); ?></th>
<th><?php esc_html_e( 'Message', 'site-sync' ); ?></th>
<th><?php esc_html_e( 'Context', 'site-sync' ); ?></th>
</tr>
</thead>
<tbody>
<?php
$logs = \SiteSync\Logger::recent( 20 );
if ( empty( $logs ) ) :
?>
<tr><td colspan="4"><?php esc_html_e( 'No logs yet.', 'site-sync' ); ?></td></tr>
<?php
else :
foreach ( $logs as $log ) :
?>
<tr>
<td><?php echo esc_html( $log['ts'] ); ?></td>
<td><?php echo esc_html( $log['level'] ); ?></td>
<td><?php echo esc_html( $log['message'] ); ?></td>
<td><code><?php echo esc_html( $log['context'] ? wp_json_encode( $log['context'] ) : '' ); ?></code></td>
</tr>
<?php
endforeach;
endif;
?>
</tbody>
</table>
</div>
<?php
}
/**
* Handles saving the Site Sync plugin settings from the admin form submission.
*
* @return void
*/
public function handle_save_settings(): 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_save_settings', 'site_sync_nonce' );
// phpcs:disable
$data = array(
'site_uuid' => 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(
'<div class="notice notice-success is-dismissible"><p>%s</p></div>',
esc_html__( 'Site Sync settings saved.', 'site-sync' )
);
}
if ( isset( $_GET['site_sync_status'] ) && 'manual_run' === $_GET['site_sync_status'] ) {
printf(
'<div class="notice notice-info is-dismissible"><p>%s</p></div>',
esc_html__( 'Manual sync triggered.', 'site-sync' )
);
}
if ( isset( $_GET['site_sync_status'] ) && 'handshake_ok' === $_GET['site_sync_status'] ) {
printf(
'<div class="notice notice-success is-dismissible"><p>%s</p></div>',
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(
'<div class="notice notice-error is-dismissible"><p>%s</p></div>',
esc_html( $msg )
);
}
if ( isset( $_GET['site_sync_status'] ) && 'reset_ok' === $_GET['site_sync_status'] ) {
printf(
'<div class="notice notice-success is-dismissible"><p>%s</p></div>',
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;
}
}

76
includes/class-auth.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
namespace SiteSync;
use WP_Error;
use WP_REST_Request;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles authentication for SiteSync REST requests using shared key and HMAC.
*/
class Auth {
/**
* Verify an incoming REST request using shared key + HMAC.
*
* @param WP_REST_Request $request The REST request object.
* @param Settings $settings The SiteSync settings instance.
*/
public static function verify( WP_REST_Request $request, Settings $settings ) {
$providedKey = $request->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 );
}
}

106
includes/class-cron.php Normal file
View File

@@ -0,0 +1,106 @@
<?php
namespace SiteSync;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles scheduling and execution of Site Sync cron jobs.
*/
class Cron {
public const HOOK = 'site_sync_run';
public const DEFAULT_INTERVAL = 'site_sync_5min';
/**
* Site Sync settings instance.
*
* @var Settings
*/
private $settings;
/**
* Cron constructor.
*
* @param Settings $settings Site Sync settings instance.
*/
public function __construct( Settings $settings ) {
$this->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 );
}
}

64
includes/class-log.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
namespace SiteSync;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Class Log
*
* Handles logging for the Site Sync plugin, including info, warning, and error levels.
*/
class Log {
/**
* Logs an informational message.
*
* @param string $message The log message.
* @param array $context Additional context for the log entry.
*/
public static function info( string $message, array $context = array() ): void {
Logger::log( 'INFO', $message, $context );
self::write( 'INFO', $message, $context );
}
/**
* Logs a warning message.
*
* @param string $message The log message.
* @param array $context Additional context for the log entry.
*/
public static function warning( string $message, array $context = array() ): void {
Logger::log( 'WARN', $message, $context );
self::write( 'WARN', $message, $context );
}
/**
* Logs an error message.
*
* @param string $message The log message.
* @param array $context Additional context for the log entry.
*/
public static function error( string $message, array $context = array() ): void {
Logger::log( 'ERROR', $message, $context );
self::write( 'ERROR', $message, $context );
}
/**
* Writes a log entry to the error log and triggers the 'site_sync/log' action.
*
* @param string $level The log level (e.g., INFO, WARN, ERROR).
* @param string $message The log message.
* @param array $context Additional context for the log entry.
*/
private static function write( string $level, string $message, array $context ): void {
$contextStr = $context ? ' ' . wp_json_encode( $context ) : '';
error_log( sprintf( '[Site Sync][%s] %s%s', $level, $message, $contextStr ) ); // phpcs:ignore
/**
* Allow other listeners to capture logs (e.g., to DB table or UI).
*/
do_action( 'site_sync/log', $level, $message, $context );
}
}

61
includes/class-logger.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
namespace SiteSync;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Logger class for SiteSync plugin.
*
* Handles logging of messages with different levels and context.
*/
class Logger {
public const OPTION_KEY = 'site_sync_logs';
private const MAX = 200;
/**
* Logs a message with a given level and context.
*
* @param string $level The log level (e.g., 'info', 'error').
* @param string $message The log message.
* @param array $context Additional context for the log entry.
*
* @return void
*/
public static function log( string $level, string $message, array $context = array() ): void {
$entry = array(
'ts' => 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 );
}
}

148
includes/class-plugin.php Normal file
View File

@@ -0,0 +1,148 @@
<?php
namespace SiteSync;
use SiteSync\Admin;
use SiteSync\Settings;
use SiteSync\REST_Controller;
use SiteSync\Cron;
use SiteSync\Transport;
use SiteSync\Sync_Engine;
use SiteSync\State;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
require_once SITE_SYNC_PLUGIN_DIR . 'includes/class-settings.php';
require_once SITE_SYNC_PLUGIN_DIR . 'includes/class-admin.php';
require_once SITE_SYNC_PLUGIN_DIR . 'includes/class-rest-controller.php';
require_once SITE_SYNC_PLUGIN_DIR . 'includes/class-auth.php';
require_once SITE_SYNC_PLUGIN_DIR . 'includes/class-cron.php';
require_once SITE_SYNC_PLUGIN_DIR . 'includes/class-log.php';
require_once SITE_SYNC_PLUGIN_DIR . 'includes/class-logger.php';
require_once SITE_SYNC_PLUGIN_DIR . 'includes/class-transport.php';
require_once SITE_SYNC_PLUGIN_DIR . 'includes/class-sync-engine.php';
require_once SITE_SYNC_PLUGIN_DIR . 'includes/class-state.php';
/**
* Main plugin class for SiteSync.
*
* Handles initialization, hooks, and provides access to core components.
*/
class Plugin {
/**
* The singleton instance of the Plugin class.
*
* @var self
*/
private static $instance;
/**
* Settings instance.
*
* @var Settings
*/
private $settings;
/**
* Admin instance.
*
* @var Admin
*/
private $admin;
/**
* REST controller instance.
*
* @var REST_Controller
*/
private $rest;
/**
* Cron instance.
*
* @var Cron
*/
private $cron;
/**
* Handles data transport between sites.
*
* @var Transport
*/
private $transport;
/**
* Sync engine instance.
*
* @var Sync_Engine
*/
private $engine;
/**
* State instance.
*
* @var State
*/
private $state;
/**
* Constructs the Plugin instance and initializes core components.
*/
private function __construct() {
$this->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();
}
}

View File

@@ -0,0 +1,245 @@
<?php
namespace SiteSync;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* REST controller for Site Sync plugin.
*
* Handles REST API endpoints for synchronization, media, and authentication.
*/
class REST_Controller {
private const ROUTE_NAMESPACE = 'site-sync/v1';
/**
* Settings instance.
*
* @var Settings
*/
private $settings;
/**
* Sync_Engine instance.
*
* @var Sync_Engine
*/
private $engine;
/**
* REST_Controller constructor.
*
* @param Settings $settings Settings instance.
* @param Sync_Engine $engine Sync_Engine instance.
*/
public function __construct( Settings $settings, Sync_Engine $engine ) {
$this->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;
}
}

146
includes/class-settings.php Normal file
View File

@@ -0,0 +1,146 @@
<?php
namespace SiteSync;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles Site Sync plugin settings and options.
*/
class Settings {
public const OPTION_KEY = 'site_sync_settings';
private const DEFAULT_META_KEYS = array( '_thumbnail_id' );
/**
* Default settings for the Site Sync plugin.
*
* @var array
*/
private $defaults = array(
'site_uuid' => '',
'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 ) );
}
}

211
includes/class-state.php Normal file
View File

@@ -0,0 +1,211 @@
<?php
namespace SiteSync;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles the persistent state for the Site Sync plugin, including sync checkpoints, tombstones, and mappings.
*/
class State {
public const OPTION_KEY = 'site_sync_state';
private const TOMBSTONE_LIMIT = 200;
/**
* Default state structure for Site Sync plugin.
*
* @var array
*/
private $defaults = array(
'last_sent' => 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,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,222 @@
<?php
namespace SiteSync;
use WP_Error;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles HTTP transport operations for SiteSync, including GET, POST, and media transfers.
*/
class Transport {
/**
* SiteSync settings instance.
*
* @var Settings
*/
private $settings;
/**
* Transport constructor.
*
* @param Settings $settings SiteSync settings instance.
*/
public function __construct( Settings $settings ) {
$this->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 );
}
}

28
site-sync.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
/**
* Plugin Name: Site Sync
* Description: Synchronize WordPress sites bi-directionally (content, taxonomies, media) with outbound-initiated REST/HMAC transport.
* Version: 0.1.0
* Author: Site Sync Team
* Text Domain: site-sync
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Silence is golden.
}
define( 'SITE_SYNC_VERSION', '0.1.0' );
define( 'SITE_SYNC_PLUGIN_FILE', __FILE__ );
define( 'SITE_SYNC_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'SITE_SYNC_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'SITE_SYNC_META_EXTERNAL_ID', '_site_sync_external_id' );
define( 'SITE_SYNC_META_TERM_EXTERNAL_ID', '_site_sync_term_external_id' );
define( 'SITE_SYNC_META_MEDIA_EXTERNAL_ID', '_site_sync_media_external_id' );
require_once SITE_SYNC_PLUGIN_DIR . 'includes/class-plugin.php';
// Bootstrap the plugin.
SiteSync\Plugin::instance();
register_activation_hook( __FILE__, array( 'SiteSync\\Plugin', 'activate' ) );
register_deactivation_hook( __FILE__, array( 'SiteSync\\Plugin', 'deactivate' ) );