PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backup, Restore, Migration & Clone / 4.9.0
WP STAGING – WordPress Backup, Restore, Migration & Clone v4.9.0
4.9.1 4.9.0 4.8.1 trunk 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.10.0 3.2.0 3.3.1 3.3.2 3.3.3 3.4.1 3.4.3 3.5.0 3.6.0 3.7.1 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.8.6 3.8.7 3.9.0 3.9.1 3.9.2 3.9.3 3.9.4 4.0.0 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4 4.2.0 4.2.1 4.3.0 4.3.1 4.3.2 4.4.0 4.5.0 4.6.0 4.7.0 4.7.1 4.7.2 4.7.3 4.8.0
wp-staging / Framework / BackgroundProcessing / WithQueueAwareness.php
wp-staging / Framework / BackgroundProcessing Last commit date
Exceptions 5 years ago Job 1 month ago Action.php 2 years ago BackgroundProcessingServiceProvider.php 1 week ago Demo.php 4 years ago FeatureDetection.php 3 months ago Queue.php 1 month ago QueueActionAware.php 6 months ago QueueProcessor.php 1 month ago WithQueueAwareness.php 1 month ago
WithQueueAwareness.php
298 lines
1 <?php
2
3 /**
4 * Provides methods to be aware of the queue system and its inner workings.
5 *
6 * @package WPStaging\Framework\BackgroundProcessing
7 */
8
9 namespace WPStaging\Framework\BackgroundProcessing;
10
11 use WP_Error;
12 use WPStaging\Framework\Facades\Hooks;
13 use WPStaging\Framework\Network\HttpBasicAuth;
14
15 use function WPStaging\functions\debug_log;
16
17 /**
18 * Trait WithQueueAwareness
19 *
20 * @package WPStaging\Framework\BackgroundProcessing
21 */
22 trait WithQueueAwareness
23 {
24 use HttpBasicAuth;
25
26 /**
27 * Whether this Queue instance did fire the AJAX action request or not.
28 *
29 * @var bool
30 */
31 private $didFireAjaxAction = false;
32
33 /**
34 * Returns the Queue default priority that will be used to schedule actions when the
35 * priority is not specified or is specified as an invalid value.
36 *
37 * @return int The Queue default priority.
38 */
39 public static function getDefaultPriority()
40 {
41 return 0;
42 }
43
44 /**
45 * Fires a non-blocking request to the WordPress admin AJAX endpoint that will,
46 * in turn, trigger the processing of more Actions.
47 *
48 * @param mixed|null $bodyData An optional set of data to customize the processing request
49 * for. If not provided, then the request will be fired for the
50 * next available Actions (normal operations).
51 *
52 * @return bool A value that will indicate whether the request was correctly dispatched
53 * or not.
54 */
55 public function fireAjaxAction($bodyData = null)
56 {
57 if ($this->didFireAjaxAction) {
58 // Let's not fire the AJAX request more than once per HTTP request, per Queue.
59 return false;
60 }
61
62 $ajaxUrl = add_query_arg([
63 'action' => QueueProcessor::ACTION_QUEUE_PROCESS,
64 '_ajax_nonce' => wp_create_nonce(QueueProcessor::ACTION_QUEUE_PROCESS),
65 ], admin_url('admin-ajax.php'));
66
67 $useGetMethod = false;
68 $requestSent = false;
69 // If we are in a cron job, check if GET/POST method works and set it in a transient for caching
70 $useGetMethod = get_site_transient(QueueProcessor::TRANSIENT_REQUEST_GET_METHOD);
71 // Transient return false for non existing or expired values, for type safety we will use string 'Yes' or 'No' for GET method usage
72 if ($useGetMethod === false) {
73 // By default we use POST method, so if that doesn't work we will use GET method
74 $useGetMethod = $this->checkGetRequestNeededForQueue($ajaxUrl, $bodyData);
75 // We already sent the POST method request. Let not double sent request if we continue use POST method
76 $requestSent = !$useGetMethod;
77
78 set_site_transient(QueueProcessor::TRANSIENT_REQUEST_GET_METHOD, $useGetMethod ? 'Yes' : 'No', HOUR_IN_SECONDS);
79 debug_log('[WPSTG Fire Ajax] GET method is ' . ($useGetMethod ? 'needed' : 'not needed') . ' for Queue AJAX request.', 'info', false);
80 } else {
81 $useGetMethod = $useGetMethod === 'Yes';
82 }
83
84 // If request already sent let early bail
85 if ($requestSent) {
86 $this->didFireAjaxAction = true;
87
88 Hooks::doAction('wpstg_queue_fire_ajax_request', $this);
89
90 return true;
91 }
92
93 // If filter is present lets override it!
94 $useGetMethod = Hooks::applyFilters(QueueProcessor::FILTER_REQUEST_FORCE_GET_METHOD, $useGetMethod);
95
96 $blocking = $this->useBlockingRequest();
97 debug_log('[WPSTG Fire Ajax] Firing AJAX request to process Queue actions. GET method: ' . ($useGetMethod ? 'Yes' : 'No'), 'debug', false);
98
99 $response = wp_remote_request(esc_url_raw($ajaxUrl), [
100 'headers' => array_merge(
101 ['X-WPSTG-Request' => QueueProcessor::ACTION_QUEUE_PROCESS],
102 $this->getHttpAuthHeaders()
103 ),
104 'method' => $useGetMethod ? 'GET' : 'POST',
105 'blocking' => $blocking,
106 'timeout' => $blocking ? 30 : 0.01, // 0.01 for a non-blocking request
107 'cookies' => $this->getLoginRelatedCookies(),
108 'sslverify' => apply_filters(FeatureDetection::FILTER_HTTPS_LOCAL_SSL_VERIFY, false),
109 'body' => $this->normalizeAjaxRequestBody($bodyData),
110 ]);
111
112 /*
113 * A non-blocking request will either return a WP_Error instance, or
114 * a mock response. The response is a mock as we cannot really build
115 * a good response without waiting for it to be processed from the server.
116 */
117 if ($response instanceof WP_Error) {
118 \WPStaging\functions\debug_log(json_encode([
119 'root' => 'Queue processing admin-ajax request failed.',
120 'class' => get_class($this),
121 'code' => $response->get_error_code(),
122 'message' => $response->get_error_message(),
123 'data' => $response->get_error_data(),
124 'blocking' => $blocking,
125 'method' => $useGetMethod ? 'GET' : 'POST',
126 ], JSON_PRETTY_PRINT));
127
128 delete_site_transient(QueueProcessor::TRANSIENT_REQUEST_GET_METHOD);
129 $this->recordFireFailure();
130
131 return false;
132 }
133
134 if ($blocking && is_array($response)) {
135 $code = isset($response['response']['code']) ? (int)$response['response']['code'] : 0;
136 if ($code < 200 || $code >= 300) {
137 $failures = (int)get_site_transient(QueueProcessor::TRANSIENT_FIRE_FAILURE_COUNT) + 1;
138 debug_log('[BG Queue] fire failed: HTTP code=' . $code . ' (failure ' . $failures . ')', 'info', false);
139 delete_site_transient(QueueProcessor::TRANSIENT_REQUEST_GET_METHOD);
140 $this->recordFireFailure();
141 return false;
142 }
143
144 if ((int)get_site_transient(QueueProcessor::TRANSIENT_FIRE_FAILURE_COUNT) > 0) {
145 debug_log('[BG Queue] fire mode -> non-blocking (loopback healthy, code=' . $code . ')', 'info', false);
146 delete_site_transient(QueueProcessor::TRANSIENT_FIRE_FAILURE_COUNT);
147 }
148 }
149
150 // Stamped only after error checks so a failed fire cannot spoof itself as acknowledged.
151 set_site_transient(QueueProcessor::TRANSIENT_LAST_FIRE_TIMESTAMP, time(), QueueProcessor::TRANSIENT_FIRE_STATE_TTL);
152
153 $this->didFireAjaxAction = true;
154
155 /**
156 * Fires an Action to indicate the Queue did fire the AJAX request that will
157 * trigger side-processing in another PHP process.
158 *
159 * @param Queue $this A reference to the instance of the Queue that actually fired
160 * the AJAX request.
161 */
162 do_action('wpstg_queue_fire_ajax_request', $this);
163
164 return true;
165 }
166
167 /**
168 * Normalizes the data to be sent along the non-blocking AJAX request
169 * that will trigger the Queue processing of an Action.
170 *
171 * @param mixed|null $bodyData The data to normalize to a format suitable for
172 * the remote request.
173 *
174 * @return array The normalized body data to be sent along the non-blocking
175 * AJAX request.
176 */
177 private function normalizeAjaxRequestBody($bodyData)
178 {
179 $normalized = (array)$bodyData;
180
181 $normalized['_referer'] = __CLASS__;
182
183 return $normalized;
184 }
185
186 /**
187 * @param string $ajaxUrl
188 * @param mixed|null $bodyData
189 * @return bool
190 */
191 private function checkGetRequestNeededForQueue(string $ajaxUrl, $bodyData = null): bool
192 {
193 // 5s keeps admin UI responsive on broken loopbacks; the stall detector picks up the slack.
194 $response = wp_remote_post(esc_url_raw($ajaxUrl), [
195 'headers' => array_merge(
196 ['X-WPSTG-Request' => QueueProcessor::ACTION_QUEUE_PROCESS],
197 $this->getHttpAuthHeaders()
198 ),
199 'blocking' => true,
200 'timeout' => 5,
201 'cookies' => $this->getLoginRelatedCookies(),
202 'sslverify' => apply_filters(FeatureDetection::FILTER_HTTPS_LOCAL_SSL_VERIFY, false),
203 'body' => $this->normalizeAjaxRequestBody($bodyData),
204 ]);
205
206 if ($response instanceof WP_Error) {
207 debug_log('[WPSTG Fire Ajax] checkGetRequestNeededForQueue POST failed: code=' . $response->get_error_code() . ' message=' . $response->get_error_message(), 'debug', false);
208 } elseif (is_array($response) && isset($response['response']['code'])) {
209 debug_log('[WPSTG Fire Ajax] checkGetRequestNeededForQueue POST response code=' . $response['response']['code'], 'debug', false);
210 }
211
212 // If we get WP_Error, then we can assume that POST method doesn't work
213 if ($response instanceof WP_Error) {
214 return true;
215 }
216
217 if (!is_array($response)) {
218 return false;
219 }
220
221 // If we get 404 response code, then we can assume that POST method doesn't work
222 if (
223 array_key_exists('response', $response) &&
224 array_key_exists('code', $response['response']) &&
225 $response['response']['code'] === 404
226 ) {
227 return true;
228 }
229
230 return false;
231 }
232
233 private function useBlockingRequest(): bool
234 {
235 if (defined('DOING_AJAX') && DOING_AJAX) {
236 return false;
237 }
238
239 if (function_exists('wp_get_environment_type') && wp_get_environment_type() === 'local') {
240 return true;
241 }
242
243 return (int)get_site_transient(QueueProcessor::TRANSIENT_FIRE_FAILURE_COUNT) >= QueueProcessor::ADAPTIVE_BLOCKING_THRESHOLD;
244 }
245
246 /**
247 * @return void
248 */
249 private function recordFireFailure()
250 {
251 $failures = (int)get_site_transient(QueueProcessor::TRANSIENT_FIRE_FAILURE_COUNT);
252 if ($failures >= 10) {
253 return;
254 }
255
256 $newFailures = $failures + 1;
257 set_site_transient(QueueProcessor::TRANSIENT_FIRE_FAILURE_COUNT, $newFailures, QueueProcessor::TRANSIENT_FIRE_STATE_TTL);
258
259 if ($failures < QueueProcessor::ADAPTIVE_BLOCKING_THRESHOLD && $newFailures >= QueueProcessor::ADAPTIVE_BLOCKING_THRESHOLD) {
260 debug_log('[BG Queue] fire mode -> blocking (consecutive silent failures=' . $newFailures . ')', 'info', false);
261 }
262 }
263
264 /**
265 * Keep only the WordPress login-related cookies to avoid oversized headers.
266 * Kept:
267 * - wordpress_[hash]
268 * - wordpress_sec_[hash]
269 * - wordpress_logged_in_[hash]
270 *
271 * @return array<string,string>
272 */
273 private function getLoginRelatedCookies(): array
274 {
275 if (empty($_COOKIE) || !is_array($_COOKIE)) {
276 return [];
277 }
278
279 $allowed = [];
280 foreach ($_COOKIE as $name => $value) {
281 if (!is_string($name)) {
282 continue;
283 }
284
285 // Matches: wordpress_[32hex], wordpress_sec_[32hex], wordpress_logged_in_[32hex]
286 if (!preg_match('/^wordpress_(?:logged_in_|sec_)?[a-f0-9]{32}$/', $name)) {
287 continue;
288 }
289
290 if (is_scalar($value)) {
291 $allowed[$name] = (string)$value;
292 }
293 }
294
295 return $allowed;
296 }
297 }
298