'POST', 'callback' => 'aim_be_rest_wake', 'permission_callback' => 'aim_be_rest_wake_permitted', ) ); } add_action( 'rest_api_init', 'aim_be_register_rest_routes' ); /** * Only AiM, and only once the member has connected this site. * * @param WP_REST_Request $request Incoming request. * @return bool */ function aim_be_rest_wake_permitted( $request ) { if ( ! get_option( AIM_BE_OPT_SITE_KEY, '' ) ) { return false; } $presented = (string) $request->get_header( 'x-aim-wake-token' ); if ( '' === $presented ) { return false; } // hash_equals: constant time, so the comparison leaks nothing by duration. return hash_equals( aim_be_wake_token(), $presented ); } /** * Run the poll now. * * Rate-limited to once a minute. AiM calls this once per post, but a queue * that lands together should cost one sync, and a caller that repeats itself * must not be able to turn this into work. * * @return WP_REST_Response */ function aim_be_rest_wake() { if ( get_transient( 'aim_be_wake_lock' ) ) { return new WP_REST_Response( array( 'ok' => true, 'skipped' => 'recent' ), 200 ); } set_transient( 'aim_be_wake_lock', 1, MINUTE_IN_SECONDS ); aim_be_run_poll(); return new WP_REST_Response( array( 'ok' => true ), 200 ); } /** * Make sure AiM has this site's wake token, even when cron is dead. * * The token normally travels on the heartbeat, which rides along with the * scheduled poll — but the sites that need waking most are exactly the ones * whose poll never runs, so waiting for it would be circular. One outbound * call on the first wp-admin page load after an update breaks the loop: from * then on AiM can reach the site directly and the dead cron stops mattering. * * @return void */ function aim_be_report_wake_token() { if ( ! get_option( AIM_BE_OPT_SITE_KEY, '' ) ) { return; // Not connected — we never phone home before the member opts in. } if ( AIM_BE_VERSION === get_option( AIM_BE_OPT_WAKE_SENT, '' ) ) { return; } if ( get_transient( 'aim_be_wake_report_lock' ) ) { return; // A failed attempt must not retry on every admin page load. } set_transient( 'aim_be_wake_report_lock', 1, 15 * MINUTE_IN_SECONDS ); $api = new Aim_Be_Api(); $api->heartbeat(); update_option( AIM_BE_OPT_WAKE_SENT, AIM_BE_VERSION, false ); } add_action( 'admin_init', 'aim_be_report_wake_token' ); /** * Register a 15-minute cron cadence for the pull loop. * * @param array $schedules Existing cron schedules. * @return array */ function aim_be_cron_schedules( $schedules ) { $schedules[ AIM_BE_CRON_SCHEDULE ] = array( 'interval' => 15 * MINUTE_IN_SECONDS, 'display' => __( 'Every 15 minutes (AiM Blog Engine)', 'aim-blog-engine' ), ); return $schedules; } add_filter( 'cron_schedules', 'aim_be_cron_schedules' ); /** * The scheduled pull: fetch pending posts from AiM and publish them as drafts. * Guideline 7: this only runs once the member has connected (opted in) — a * missing site key means we make no outbound call at all. */ function aim_be_run_poll() { $site_key = get_option( AIM_BE_OPT_SITE_KEY, '' ); if ( empty( $site_key ) ) { return; } $api = new Aim_Be_Api(); $publisher = new Aim_Be_Publisher(); $api->heartbeat(); // report version/health each run $pending = $api->get_pending(); if ( is_wp_error( $pending ) || empty( $pending ) ) { return; } foreach ( $pending as $item ) { $result = $publisher->upsert_draft( $item ); $api->ack( $result ); // report post id / url / status back (outbound) } update_option( AIM_BE_OPT_LAST_SYNC, time() ); } add_action( AIM_BE_CRON_HOOK, 'aim_be_run_poll' ); /** * Make sure the poll is on the schedule. * * Shared by activation and by every request (see the init hook below). The * event used to be scheduled ONLY at activation, which meant anything that * cleared the cron array — a cron manager, a security plugin, a staging-to- * production copy, an interrupted update — silently ended the site's syncing * forever, with no symptom except posts that never arrived. * * @return void */ function aim_be_ensure_scheduled() { if ( ! wp_next_scheduled( AIM_BE_CRON_HOOK ) ) { wp_schedule_event( time() + MINUTE_IN_SECONDS, AIM_BE_CRON_SCHEDULE, AIM_BE_CRON_HOOK ); } } /** * Re-arm the schedule on every load. wp_next_scheduled() reads the cron option * WordPress has already loaded, so this costs nothing on a healthy site and * repairs one where the event has gone missing. */ add_action( 'init', 'aim_be_ensure_scheduled' ); /** * Activation: schedule the poll. No outbound call here (Guideline 7 — we don't * phone home until the member connects). */ function aim_be_activate() { aim_be_ensure_scheduled(); } register_activation_hook( __FILE__, 'aim_be_activate' ); /** * How far past due the scheduled poll is, in seconds. * * WP-Cron is not a real cron: it fires on inbound page loads. On a site with * no traffic, or one whose host blocks loopback requests to wp-cron.php, the * event sits on the schedule and never runs. That is indistinguishable from a * healthy site unless somebody looks at the clock, which is why this exists. * * @return int Seconds overdue, or 0 when the event is not scheduled or not due. */ function aim_be_cron_overdue_seconds() { $next = wp_next_scheduled( AIM_BE_CRON_HOOK ); if ( ! $next ) { return 0; } return max( 0, time() - (int) $next ); } /** * Is WordPress's own scheduler evidently not running? * * Four missed runs. A single missed run means nobody visited the site in the * last quarter of an hour, which is normal and not worth a word. * * @return bool */ function aim_be_cron_looks_broken() { return aim_be_cron_overdue_seconds() > HOUR_IN_SECONDS; } /** * Deactivation: clear the scheduled poll. */ function aim_be_deactivate() { $timestamp = wp_next_scheduled( AIM_BE_CRON_HOOK ); if ( $timestamp ) { wp_unschedule_event( $timestamp, AIM_BE_CRON_HOOK ); } wp_clear_scheduled_hook( AIM_BE_CRON_HOOK ); } register_deactivation_hook( __FILE__, 'aim_be_deactivate' ); // Admin settings page (connect + status + "Sync now"). add_action( 'plugins_loaded', array( 'Aim_Be_Settings', 'init' ) ); /** * Add a shortcut on the Plugins list row: "Connect" until the site is linked, * "Settings" afterward — both open Settings → AiM Blog Engine. * * @param array $links Existing action links. * @return array */ function aim_be_plugin_action_links( $links ) { $connected = (bool) get_option( AIM_BE_OPT_SITE_KEY, '' ); $label = $connected ? __( 'Settings', 'aim-blog-engine' ) : __( 'Connect', 'aim-blog-engine' ); $link = sprintf( '%s', esc_url( admin_url( 'options-general.php?page=aim-blog-engine' ) ), esc_html( $label ) ); array_unshift( $links, $link ); return $links; } add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), 'aim_be_plugin_action_links' ); // BETA channel only: enable one-click self-updates when the `.selfupdate` marker // ships in the zip. The WordPress.org build omits the marker (updates flow // through WordPress.org there, as its guidelines require). // Both the marker AND the class must be present: the WordPress.org build ships // neither, so this stays inert there and never references a missing file. if ( file_exists( AIM_BE_DIR . '.selfupdate' ) && file_exists( AIM_BE_DIR . 'includes/class-aim-be-updater.php' ) ) { require_once AIM_BE_DIR . 'includes/class-aim-be-updater.php'; Aim_Be_Updater::boot(); }