$plan[$trimIndex]--; } return $plan; } /** * Perform a single loopback self-request attempt over a given scheme * * The probe mirrors a real worker kickoff exactly (same endpoint, same args, same * non-blocking semantics) and carries the scheme's probe code. The worker confirms * receipt by writing the code to the database before doing any build work; this method * polls for that marker. This catches both hosts that drop non-blocking requests once the * client disconnects and anything in the WordPress bootstrap that kills real worker * requests — failure modes a blocking early-answered test cannot see. The caller owns the * marker lifecycle: this method neither registers nor clears the markers. * * @param string|null $scheme Scheme to probe ('http'/'https'), or null when using a custom URL * @param string $code Probe code registered by the caller for this scheme * @param string $failureReason Out-param: failure reason, empty on success * @param array $context Out-param: diagnostic context (url, wp_error_code) * @param string $customUrl Full base URL to use instead of admin_url() (when scheme is null) * * @return bool True if the worker endpoint confirmed receiving the probe */ private static function attemptSelfRequest( ?string $scheme, string $code, string &$failureReason, array &$context, string $customUrl = '' ): bool { $failureReason = ''; $context = []; try { if ($customUrl !== '') { $ajax_url = SnapURL::appendQueryValue($customUrl, 'action', 'duplicator_process_worker'); } else { $ajax_url = admin_url('admin-ajax.php', (string) $scheme); $ajax_url = SnapURL::appendQueryValue($ajax_url, 'action', 'duplicator_process_worker'); } $ajax_url = SnapURL::appendQueryValue($ajax_url, self::LOOPBACK_CHECK_CODE_PARAM, $code); $context['url'] = $ajax_url; DupLog::trace('KICKOFF TEST: START TEST self-request URL: ' . $ajax_url); $args = self::buildWorkerRequestArgs(['blocking' => false]); $response = wp_remote_get($ajax_url, $args); if (is_wp_error($response)) { $context['wp_error_code'] = $response->get_error_code(); throw new RuntimeException('WP_Error: ' . $response->get_error_message()); } if (!self::waitForProbeMarker($code)) { throw new RuntimeException('Worker probe confirmation not received within ' . self::LOOPBACK_TIMEOUT . ' seconds'); } return true; } catch (Throwable $e) { $failureReason = $e->getMessage(); return false; } } /** * Check once whether the worker has written the probe confirmation marker * * @param string $code Probe code to look for * * @return bool */ private static function isProbeMarkerPresent(string $code): bool { // Fresh read on purpose: the marker is written by another PHP process $isPresent = ExpireOptions::getFresh(self::LOOPBACK_RECEIVED_KEY, '') === $code; return (bool) apply_filters('duplicator_loopback_probe_marker_present', $isPresent, $code); } /** * Poll the database until the worker writes the probe confirmation marker * * @param string $code Probe code to wait for * * @return bool True if the marker with the given code appeared within the timeout */ private static function waitForProbeMarker(string $code): bool { $start = microtime(true); $deadline = $start + self::LOOPBACK_TIMEOUT; do { usleep(self::LOOPBACK_POLL_INTERVAL_US); if (self::isProbeMarkerPresent($code)) { DupLog::trace('KICKOFF TEST: probe confirmed in ' . round(microtime(true) - $start, 2) . 's' . ' [KICK OFF DISABLED]'); return true; } } while (microtime(true) < $deadline); DupLog::trace('KICKOFF TEST: probe NOT confirmed within ' . self::LOOPBACK_TIMEOUT . 's'); return false; } /** * Worker-side probe confirmation: write the received code so the caller's poll sees it * * The marker is written only when the received code matches the one the caller registered * for the scheme currently being probed (kept for the whole scheme run), so this * unauthenticated endpoint cannot be used to write arbitrary values and a code from an * already finished scheme is rejected. * * @param string $code Probe code received with the request * * @return bool True when the code matched and the marker was written */ public static function confirmLoopbackProbe(string $code): bool { $expected = ExpireOptions::get(self::LOOPBACK_EXPECTED_KEY, ''); if (!is_string($expected) || $expected === '' || !hash_equals($expected, $code)) { DupLog::trace('KICKOFF TEST: probe rejected (no pending probe or code mismatch)'); return false; } ExpireOptions::set(self::LOOPBACK_RECEIVED_KEY, $code, self::LOOPBACK_MARKER_TTL); DupLog::trace('KICKOFF TEST: probe received, marker written'); return true; } /** * Clear the ephemeral probe markers and the detected loopback scheme. * * @return void */ public static function resetLoopbackCache(): void { ExpireOptions::delete(self::LOOPBACK_EXPECTED_KEY); ExpireOptions::delete(self::LOOPBACK_RECEIVED_KEY); DynamicGlobalEntity::getInstance()->removeVal(self::LOOPBACK_SCHEME_DGLOBAL_KEY, true); } /** * Check if client-side kickoff is enabled for the currently active package * * @param bool $refresh If true, re-read the active package instead of using the request cache * * @return bool True if there's an active package that requires client-side kickoff */ public static function isClientSideKickoffEnabled(bool $refresh = false): bool { $package = DupPackage::getNextActive($refresh); return $package !== null && $package->isClientSideKickoff(); } /** * Enqueues the JavaScript for client-side kickoff * * @return void */ public static function enqueueKickoffScript(): void { $callPeriodInMs = 10000; // How often client calls into the service // Client-side kickoff runs in the visitor's browser, which already reached the site through // any proxy/SSL terminator. Use WordPress' standard admin-ajax URL so the request matches the // host and scheme of the current page, not the backend loopback URL. $ajax_url = admin_url('admin-ajax.php'); $gateway = [ 'ajaxurl' => $ajax_url, 'client_call_frequency' => $callPeriodInMs, ]; wp_register_script('dupli-kick', DUPLICATOR_PLUGIN_URL . 'assets/js/dupli-kick.js', [], DUPLICATOR_VERSION); wp_localize_script('dupli-kick', 'dupli_gateway', $gateway); DupLog::trace('KICKOFF: Client-side kickoff script enqueued (dupli-kick.js)'); wp_enqueue_script('dupli-kick'); } }