✨feature: Initial functional push
This commit is contained in:
472
includes/class-admin.php
Normal file
472
includes/class-admin.php
Normal 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
76
includes/class-auth.php
Normal 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
106
includes/class-cron.php
Normal 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
64
includes/class-log.php
Normal 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
61
includes/class-logger.php
Normal 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
148
includes/class-plugin.php
Normal 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();
|
||||
}
|
||||
}
|
||||
245
includes/class-rest-controller.php
Normal file
245
includes/class-rest-controller.php
Normal 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
146
includes/class-settings.php
Normal 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
211
includes/class-state.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
1553
includes/class-sync-engine.php
Normal file
1553
includes/class-sync-engine.php
Normal file
File diff suppressed because it is too large
Load Diff
222
includes/class-transport.php
Normal file
222
includes/class-transport.php
Normal 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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user