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

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 );
}
}