PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backup, Restore, Migration & Clone / 4.9.2
WP STAGING – WordPress Backup, Restore, Migration & Clone v4.9.2
4.9.2 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 / Logger / BackgroundLogger.php
wp-staging / Framework / Logger Last commit date
BackgroundLogger.php 1 month ago EventLoggerConst.php 2 months ago SseEventCache.php 1 month ago
BackgroundLogger.php
331 lines
1 <?php
2
3 namespace WPStaging\Framework\Logger;
4
5 use WP_REST_Request;
6 use WPStaging\Core\Utils\Logger;
7 use WPStaging\Framework\Job\JobTransientCache;
8 use WPStaging\Framework\Rest\Rest;
9 use WPStaging\Framework\Traits\SetTimeLimitTrait;
10
11 /**
12 * This class is used to push the stored sse events for the background jobs.
13 * Providing a feel of realtime logger for the background jobs in the UI.
14 */
15 class BackgroundLogger
16 {
17 use SetTimeLimitTrait;
18
19 /**
20 * @var SseEventCache
21 */
22 private $sseEventCache;
23
24 /**
25 * @var JobTransientCache
26 */
27 private $jobTransientCache;
28
29 /**
30 * If a pre-initialized job hasn't been picked up by the background queue
31 * within this many seconds, consider it stale and report an error.
32 */
33 const STALE_JOB_THRESHOLD_SECONDS = 60;
34
35 /**
36 * @var int
37 */
38 private $lastPercentage = 0;
39
40 /**
41 * @var string
42 */
43 private $lastTaskTitle = '';
44
45 public function __construct(SseEventCache $sseEventCache, JobTransientCache $jobTransientCache)
46 {
47 $this->sseEventCache = $sseEventCache;
48 $this->jobTransientCache = $jobTransientCache;
49 }
50
51 /**
52 * Let set headers for the sse stream for sse route only, this is done to make sure that wordpress itself does not
53 * send any headers before we do.
54 * @param mixed $result
55 * @param \WP_REST_Server $server
56 * @param \WP_REST_Request $request
57 *
58 * @return mixed
59 */
60 public function maybePrepareSseStream($result, \WP_REST_Server $server, WP_REST_Request $request)
61 {
62 // Get the route being requested
63 $route = trim($request->get_route(), '/');
64 if ($route !== Rest::WPSTG_ROUTE_NAMESPACE_V1 . '/sse-logs') {
65 return $result;
66 }
67
68 $this->setHeaders();
69
70 return $result;
71 }
72
73 public function verifyRestRequest()
74 {
75 $token = isset($_GET['token']) ? sanitize_text_field($_GET['token']) : '';
76 $jobId = $this->jobTransientCache->getJobId();
77
78 // Fail-closed when either side is empty — otherwise an attacker sending
79 // no token can open an SSE stream whenever no job is running (both
80 // strings compare equal to ''). hash_equals keeps the compare
81 // timing-safe once an active job ID is known.
82 if ($token === '' || $jobId === '' || !hash_equals((string)$jobId, $token)) {
83 return new \WP_Error('rest_forbidden', __('You are not allowed to access this resource.', 'wp-staging'), ['status' => 403]);
84 }
85
86 return true;
87 }
88
89 /**
90 * Don't use return, use exit/die to end the script, otherwise wordpress will try to change the header and generate warnings
91 * @param \WP_REST_Request $request
92 */
93 public function restEventStream(WP_REST_Request $request)
94 {
95 // Origin-side compression buffers the whole response and prevents Cloudflare from
96 // streaming any bytes before the connection closes.
97 @ini_set('zlib.output_compression', '0');
98 @ini_set('output_buffering', 'off');
99 @ini_set('implicit_flush', '1');
100 $this->setTimeLimit(0);
101 @ignore_user_abort(true);
102 if (function_exists('apache_setenv')) {
103 @apache_setenv('no-gzip', '1');
104 @apache_setenv('dont-vary', '1');
105 }
106
107 while (ob_get_level() > 0) {
108 ob_end_clean();
109 }
110
111 // PHP 8.0+ expects bool, earlier versions expect int
112 if (PHP_VERSION_ID >= 80000) {
113 // @phpstan-ignore-next-line - PHPStan stubs may expect int for compatibility
114 @ob_implicit_flush(true);
115 } else {
116 // @phpstan-ignore-next-line - PHP < 8.0 expects int, not bool
117 @ob_implicit_flush(1);
118 }
119 flush();
120
121 $this->setHeaders();
122
123 // 2 KiB padding flushes past Cloudflare's response-buffer threshold for first bytes.
124 echo ":" . str_repeat(' ', 2048) . "\n\n"; // phpcs:ignore
125 echo "retry: 3000\n\n"; // phpcs:ignore
126 echo ": connected\n\n"; // phpcs:ignore
127 flush();
128
129 if (!$this->isJobRunning()) {
130 $this->closeStream();
131 }
132
133 $end = microtime(true) + 5;
134 $jobId = $this->jobTransientCache->getJobId();
135 if (empty($jobId)) {
136 $data = [
137 'retry' => true,
138 'message' => esc_html__('No job ID found', 'wp-staging'),
139 ];
140
141 $this->output($jobId, 'error', json_encode($data));
142 $this->closeStream();
143 }
144
145 $offset = intval($request->get_param('offset') ?? 0);
146 $exists = $this->sseEventCache->setJobId($jobId, true);
147 if (!$exists) {
148 $data = [
149 'retry' => false,
150 'error' => esc_html__('Log file not found', 'wp-staging'),
151 ];
152
153 $this->output($jobId, 'error', json_encode($data));
154 $this->closeStream();
155 }
156
157 $lastHeartbeat = microtime(true);
158 while (microtime(true) < $end) {
159 if (connection_aborted()) {
160 $this->closeStream();
161 }
162
163 if (!$this->isJobRunning()) {
164 $this->closeStream();
165 }
166
167 $this->sseEventCache->load();
168 $total = $this->sseEventCache->getCount();
169 $events = $this->sseEventCache->getEvents($offset);
170
171 foreach ($events as $event) {
172 if ($event['type'] === SseEventCache::EVENT_TYPE_TASK) {
173 $this->pushTaskProgress($jobId, $event['data']);
174 continue;
175 }
176
177 if ($event['type'] === SseEventCache::EVENT_TYPE_COMPLETE) {
178 $this->output($jobId, $event['data']['status'], json_encode($event['data']['data']));
179 continue;
180 }
181
182 if ($event['type'] === SseEventCache::EVENT_TYPE_MEMORY_EXHAUST) {
183 $this->output($jobId, SseEventCache::EVENT_TYPE_MEMORY_EXHAUST, json_encode($event['data']));
184 $this->output($jobId, '', json_encode([
185 'type' => Logger::TYPE_ERROR,
186 'date' => $event['data']['time'],
187 'message' => "Memory exceed allowed size! Allowed memory: {$event['data']['allowedMemoryLimit']} bytes. Exceeded memory: {$event['data']['exhaustedMemorySize']} bytes",
188 ]));
189 continue;
190 }
191
192 if ($event['type'] === SseEventCache::EVENT_TYPE_FATAL_ERROR) {
193 $this->output($jobId, SseEventCache::EVENT_TYPE_FATAL_ERROR, json_encode($event['data']));
194 $this->output($jobId, '', json_encode([
195 'type' => Logger::TYPE_ERROR,
196 'date' => $event['data']['time'],
197 'message' => "Job failed due to a fatal error! Error data: " . print_r($event['data'], true),
198 ]));
199 continue;
200 }
201
202 $this->output($jobId, '', json_encode($event));
203 }
204
205 // 1 s heartbeat to keep proxies/CDNs from dropping the connection during idle phases.
206 $now = microtime(true);
207 if ($now - $lastHeartbeat >= 1.0) {
208 echo ": ping " . $now . "\n\n"; // phpcs:ignore
209 flush();
210 $lastHeartbeat = $now;
211 }
212
213 $offset = $total;
214 if (!$this->isJobRunning()) {
215 $this->closeStream();
216 }
217
218 usleep(200000); // Sleep for 0.2 seconds
219 }
220
221 $this->output($jobId, 'offset', $offset);
222 $this->closeStream();
223 }
224
225 protected function output(string $id, string $name, string $data)
226 {
227 echo "id: $id" . "\n"; // phpcs:ignore
228 if (!empty($name)) {
229 echo "event: $name" . "\n"; // phpcs:ignore
230 }
231
232 //use \n instead of PHP_EOL for add another line data: if is the same data object https://www.html5rocks.com/en/tutorials/eventsource/basics/#toc-js-api
233 echo "data: $data" . "\n"; // phpcs:ignore
234 echo "\n";
235
236 // Flush all active output buffers
237 while (ob_get_level() > 0) {
238 @ob_end_flush(); // Use @ only to suppress harmless warnings
239 }
240
241 flush();
242 }
243
244 protected function isJobRunning(): bool
245 {
246 $status = $this->jobTransientCache->getJobStatus();
247 $jobData = $this->jobTransientCache->getJob();
248 if ($status === JobTransientCache::STATUS_RUNNING) {
249 // Detect stale pre-initialized jobs where the background queue never started.
250 // preInitAt is set during pre-initialization and removed when the queue calls startJob().
251 if (!empty($jobData['preInitAt']) && (time() - $jobData['preInitAt']) > self::STALE_JOB_THRESHOLD_SECONDS) {
252 $message = esc_html__('The background process could not start. This usually means the server cannot send HTTP requests to itself (loopback). Please check your server configuration, firewall rules, and DNS settings.', 'wp-staging');
253 $this->jobTransientCache->failJob(
254 esc_html__('Background process failed to start', 'wp-staging'),
255 $message
256 );
257 $this->output($jobData['jobId'], SseEventCache::EVENT_TYPE_FATAL_ERROR, json_encode(['message' => $message]));
258 return false;
259 }
260
261 return true;
262 }
263
264 $data = [];
265
266 if ($status === JobTransientCache::STATUS_CANCELLED) {
267 $this->output($jobData['jobId'], SseEventCache::EVENT_TYPE_TASK, json_encode([
268 'percentage' => 60, // We don't show logs for cancelling jobs, this is just a placeholder
269 'title' => esc_html__('Processing...', 'wp-staging'),
270 ]));
271 $data['title'] = $jobData['title'];
272 } elseif ($status === JobTransientCache::STATUS_FAILED) {
273 $data['message'] = !empty($jobData['message']) ? esc_html((string) $jobData['message']) : esc_html__('Job failed', 'wp-staging');
274 // Optional UX classification; JS defaults to 'error' when absent.
275 if (!empty($jobData['severity'])) {
276 $data['severity'] = $jobData['severity'];
277 }
278 } elseif ($status === JobTransientCache::STATUS_SUCCESS) {
279 $data['message'] = esc_html__('Job completed successfully', 'wp-staging');
280 }
281
282 $this->output('', $status, json_encode($data));
283 return false;
284 }
285
286 protected function pushTaskProgress(string $jobId, array $taskData)
287 {
288 if ($taskData['percentage'] === $this->lastPercentage && $taskData['title'] === $this->lastTaskTitle) {
289 return;
290 }
291
292 $this->lastPercentage = $taskData['percentage'];
293 $this->lastTaskTitle = $taskData['title'];
294
295 $this->output($jobId, SseEventCache::EVENT_TYPE_TASK, json_encode($taskData));
296 }
297
298 /**
299 * Close the stream and exit the script
300 * @return never
301 */
302 protected function closeStream()
303 {
304 echo ": stream closed\n\n"; // phpcs:ignore
305 flush();
306 exit();
307 }
308
309 protected function setHeaders()
310 {
311 if (headers_sent()) {
312 return;
313 }
314
315 header('Content-Type: text/event-stream; charset=UTF-8');
316
317 // no-store + no-transform are load-bearing for Cloudflare (no-cache alone won't disable
318 // Auto Minify / Polish / Brotli rewriting on the edge).
319 header('Cache-Control: private, no-cache, no-store, no-transform, must-revalidate, max-age=0');
320 header('Pragma: no-cache');
321 header('Expires: 0');
322 header('CDN-Cache-Control: no-store');
323 header('Cloudflare-CDN-Cache-Control: no-store');
324 header('Surrogate-Control: no-store');
325 header('X-LiteSpeed-Cache-Control: no-cache');
326 header('X-Accel-Buffering: no');
327 header('Connection: keep-alive');
328 header('Keep-Alive: timeout=300');
329 }
330 }
331