PluginProbe
Activity Logs, User Activity Tracking, Multisite Activity Log from Logtivity / 3.3.10
Activity Logs, User Activity Tracking, Multisite Activity Log from Logtivity v3.3.10
3.3.10 3.3.9 3.3.8 trunk 1.0 1.1.0 1.10.0 1.11.0 1.11.1 1.12.0 1.13.0 1.14.0 1.15.0 1.16.0 1.17.0 1.17.1 1.18.0 1.19.0 1.2.0 1.20.0 1.20.1 1.3.0 1.3.1 1.4.0 1.5.0 All 68 releases
logtivity / Base / Logtivity_Api.php

Logtivity_Api.php in Activity Logs, User Activity Tracking, Multisite Activity Log from Logtivity 3.3.10, at Base/Logtivity_Api.php

482 lines 14.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * @package Logtivity
5 * @contact logtivity.io, [email protected]
6 * @copyright 2024-2026 Logtivity. All rights reserved
7 * @license https://www.gnu.org/licenses/gpl.html GNU/GPL
8 *
9 * This file is part of Logtivity.
10 *
11 * Logtivity is free software: you can redistribute it and/or modify
12 * it under the terms of the GNU General Public License as published by
13 * the Free Software Foundation, either version 2 of the License, or
14 * (at your option) any later version.
15 *
16 * Logtivity is distributed in the hope that it will be useful,
17 * but WITHOUT ANY WARRANTY; without even the implied warranty of
18 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 * GNU General Public License for more details.
20 *
21 * You should have received a copy of the GNU General Public License
22 * along with Logtivity. If not, see <https://www.gnu.org/licenses/>.
23 */
24
25 class Logtivity_Api
26 {
27 public const CURL_TIMEOUT = 28;
28
29 /**
30 * @var array
31 */
32 protected static $responseData = [
33 'code' => null,
34 'message' => null,
35 'error' => null,
36 'body' => null,
37 ];
38
39 /**
40 * Option class to access the plugin settings
41 *
42 * @var Logtivity_Options
43 */
44 protected Logtivity_Options $options;
45
46 /**
47 * Writes API call data to a file when debug mode is enabled.
48 *
49 * @var Logtivity_Debug_Log
50 */
51 protected Logtivity_Debug_Log $debugLog;
52
53 /**
54 * We generally don't want to wait for a response from the API.
55 *
56 * @var bool
57 */
58 protected bool $waitForResponse = false;
59
60 /**
61 * If the currrent status is not 'fail' or 'success'
62 * attempt to send anyway
63 *
64 * @var bool
65 */
66 protected bool $ignoreStatus = false;
67
68 /**
69 * The API Key for either the site or team
70 *
71 * @var ?string
72 */
73 protected ?string $api_key = null;
74
75 public function __construct()
76 {
77 $this->options = new Logtivity_Options();
78 $this->debugLog = new Logtivity_Debug_Log();
79 }
80
81 /**
82 * @return $this
83 */
84 public function ignoreStatus(): self
85 {
86 $this->ignoreStatus = true;
87
88 return $this;
89 }
90
91 /**
92 * @param string $url
93 * @param array $body
94 *
95 * @return ?array
96 */
97 public function post(string $url, array $body): ?array
98 {
99 return $this->makeRequest($url, $body);
100 }
101
102 /**
103 * Make a request to the Logtivity API
104 *
105 * @param string $url
106 * @param ?array $body
107 * @param string $method
108 *
109 * @return ?array
110 */
111 public function makeRequest(string $url, ?array $body = [], string $method = 'POST'): ?array
112 {
113 $body = $body ?: [];
114
115 $body['occurred_at'] = time();
116
117 if ($this->options->urlHash() == false) {
118 $this->options->update(['logtivity_url_hash' => md5(home_url())], false);
119 }
120
121 if ($this->ready()) {
122 if ($this->getOption('logtivity_app_verify_url')) {
123 $body['site_hash'] = $this->options->urlHash();
124 }
125
126 $waitForResponse = $this->waitForResponse || $this->options->shouldLogLatestResponse();
127 $requestBody = $body;
128
129 $request = [
130 'method' => $method,
131 'timeout' => ($waitForResponse ? 6 : 0.01),
132 'blocking' => $waitForResponse,
133 'redirection' => 5,
134 'httpversion' => '1.0',
135 'headers' => [
136 'Authorization' => 'Bearer ' . $this->getApiKey(),
137 ],
138 'body' => $body,
139 'cookies' => [],
140 ];
141
142 /**
143 * This request must not be cached. It writes a log entry to the API rather
144 * than reading a cacheable resource, so a cached response would silently
145 * drop logs. The default path is also non-blocking with a 0.01s timeout,
146 * so it does not hold up the page load.
147 *
148 * Settings check-ins are already rate limited by
149 * Logtivity_Options::shouldCheckInWithApi(), which gates on the
150 * logtivity_last_settings_check_in_at option.
151 *
152 * @TODO: Switch to Logtivity_Response class to get standardized responses
153 */
154 $requestStartedAt = microtime(true);
155 $response = wp_remote_request($this->getEndpoint($url), $request);
156 $responseTimeMs = (int)round((microtime(true) - $requestStartedAt) * 1000);
157
158 $responseData = null;
159
160 if ($waitForResponse && $this->notUpdatingWidgetInCustomizer()) {
161 // We waited and received a response
162 if ($response instanceof WP_Error) {
163 $parsedResponse = array_merge(static::$responseData, [
164 'code' => 500,
165 'message' => $response->get_error_code(),
166 'error' => $response->get_error_message() ?: $response->get_error_code(),
167 'body' => get_object_vars($response),
168 ]);
169
170 } else {
171 $responseCode = wp_remote_retrieve_response_code($response);
172 $responseMessage = wp_remote_retrieve_response_message($response);
173 $responseBody = json_decode(wp_remote_retrieve_body($response), true);
174
175 if ($responseCode < 400) {
176 $responseError = $responseBody['error'] ?? null;
177 } else {
178 $responseError = $responseMessage;
179 $responseMessage = (($responseBody['message'] ?? $responseMessage) ?: 'Unknown error');
180 }
181 $parsedResponse = [
182 'code' => $responseCode,
183 'message' => $responseMessage,
184 'error' => $responseError,
185 'body' => $responseBody,
186 ];
187 }
188
189 $newStatus = 'success';
190 if ($parsedResponse['code']) {
191 if ($parsedResponse['code'] < 400) {
192 // Successful request, check if api is telling us to pause
193 if ($parsedResponse['error']) {
194 $newStatus = 'paused';
195 }
196
197 $body = json_decode($response['body'] ?? 'null', true);
198 $this->updateSettings($body['settings'] ?? []);
199
200 } else {
201 if ($this->getCurlError($parsedResponse['error']) != static::CURL_TIMEOUT) {
202 // Something other than a timeout went wrong with the request
203 $newStatus = 'fail';
204 }
205
206 $this->updateLastCheckin();
207 }
208
209 $this->options->update([
210 'logtivity_api_key_check' => $newStatus,
211 'logtivity_latest_response' => array_merge(
212 ['date' => date('Y-m-d H:i:s')],
213 $parsedResponse
214 ),
215 ],
216 false
217 );
218
219 $responseData = $parsedResponse;
220 }
221 }
222
223 if ($this->getOption('logtivity_enable_debug_mode')) {
224 // GET responses (e.g. the paginated /logs listing) can be large
225 // and aren't useful to store in full; only capture the body for
226 // the more common POST calls (log/settings sends).
227 $responseBody = $method === 'GET' ? '(omitted for GET requests)' : ($responseData['body'] ?? null);
228
229 $this->debugLog->write([
230 'timestamp' => current_time('mysql'),
231 'endpoint' => $url,
232 'url' => $this->getEndpoint($url),
233 'method' => $method,
234 'request_body' => $requestBody,
235 'response_code' => $responseData['code'] ?? null,
236 'response_message' => $responseData['message'] ?? null,
237 'response_error' => $responseData['error'] ?? null,
238 'response_body' => $responseData ? $responseBody : null,
239 'response_time_ms' => $responseData ? $responseTimeMs : null,
240 ]);
241 }
242
243 return $responseData;
244 }
245
246 return null;
247 }
248
249 /**
250 * @return bool
251 */
252 protected function ready(): bool
253 {
254 return logtivity_url_environment_mismatch() == false
255 && ($this->getApiKey())
256 && logtivity_has_site_url_changed() == false
257 && (
258 $this->ignoreStatus
259 || $this->getOption('logtivity_api_key_check') == 'success'
260 || $this->options->shouldCheckInWithApi()
261 );
262 }
263
264 /**
265 * @return ?string
266 */
267 public function getApiKey(): ?string
268 {
269 if ($this->api_key == false) {
270 $this->api_key = $this->options->getApiKey();
271 }
272
273 return $this->api_key;
274 }
275
276 /**
277 * @param string $apikey
278 *
279 * @return $this
280 */
281 public function setApiKey(string $apikey): self
282 {
283 $this->api_key = $apikey;
284
285 return $this;
286 }
287
288 /**
289 * @param ?string $key
290 *
291 * @return Logtivity_Options|mixed
292 */
293 public function getOption(?string $key = null)
294 {
295 if ($key) {
296 return $this->options->getOption($key);
297 }
298
299 return $this->options;
300 }
301
302 /**
303 * @param string $endpoint
304 *
305 * @return string
306 */
307 public function getEndpoint(string $endpoint): string
308 {
309 return logtivity_get_api_url() . $endpoint;
310 }
311
312 /**
313 * You cannot call an extra update_option during a widget update so we make
314 * sure not to log the most recent log response in this case.
315 *
316 * @return bool
317 */
318 protected function notUpdatingWidgetInCustomizer(): bool
319 {
320 // phpcs:disable WordPress.Security.NonceVerification.Missing -- Passive check avoids option writes during core Customizer widget updates.
321 $customize = isset($_POST['wp_customize'])
322 ? sanitize_text_field(wp_unslash($_POST['wp_customize']))
323 : null;
324 $action = isset($_POST['action'])
325 ? sanitize_text_field(wp_unslash($_POST['action']))
326 : null;
327 // phpcs:enable WordPress.Security.NonceVerification.Missing
328
329 return ($action == 'update-widget' && $customize == 'on') == false;
330 }
331
332 /**
333 * @param array $settings
334 *
335 * @return void
336 */
337 public function updateSettings(array $settings): void
338 {
339 if ($settings) {
340 $verifiedSettings = [];
341 foreach ($settings as $key => $value) {
342 if ($key == 'disabled_logs') {
343 $key = 'global_' . $key;
344 }
345 $verifiedSettings['logtivity_' . $key] = is_null($value) ? '' : $value;
346 }
347
348 $this->options->update($verifiedSettings, false);
349 }
350
351 $this->updateLastCheckin();
352 }
353
354 /**
355 * @return void
356 */
357 protected function updateLastCheckin(): void
358 {
359 update_option('logtivity_last_settings_check_in_at', ['date' => date('Y-m-d H:i:s')]);
360 }
361
362 /**
363 * @param ?string $message
364 *
365 * @return int
366 */
367 protected function getCurlError(?string $message): ?int
368 {
369 preg_match('/curl\s+error\s+(\d+)/i', $message, $matches);
370
371 return $matches[1] ?? null;
372 }
373
374 /**
375 * @return ?array
376 */
377 public function getLatestResponse(): ?array
378 {
379 $response = $this->getOption('logtivity_latest_response');
380
381 return $response ?: null;
382 }
383
384 /**
385 * @return ?Exception
386 */
387 public function getLastError(): ?Exception
388 {
389 $latestResponse = $this->getLatestResponse();
390
391 $code = $latestResponse['code'] ?? null;
392
393 if ($code >= 400 && isset($latestResponse['body']['message'])) {
394 $message = $latestResponse['body']['message'];
395
396 } elseif (isset($latestResponse['body']['errors'])) {
397 $message = array_shift($latestResponse['body']['errors']);
398 while (is_array($message)) {
399 $message = array_shift($message);
400 }
401
402 } elseif (isset($latestResponse['body']['error'])) {
403 $message = $latestResponse['body']['error'];
404 } else {
405 $message = $latestResponse['error'] ?? $latestResponse['message'] ?? 'Unknown error';
406 }
407
408 if (($code == false || $message == false) && $this->getApiKey()) {
409 $message = 'Not connected. Please check API Key';
410 }
411
412 return new Exception($message, $code);
413 }
414
415 /**
416 * @param string $url
417 * @param ?array $body
418 *
419 * @return ?array
420 */
421 public function get(string $url, ?array $body = []): ?array
422 {
423 return $this->waitForResponse()->makeRequest($url, $body, 'GET');
424 }
425
426 /**
427 * @return $this
428 */
429 public function waitForResponse(): self
430 {
431 $this->waitForResponse = true;
432
433 return $this;
434 }
435
436 /**
437 * @TODO: This method is duplicated in the App.
438 *
439 * @return ?string
440 */
441 public function getConnectionMessage(): ?string
442 {
443 $status = $this->getConnectionStatus();
444
445 switch ($status) {
446 case 'success':
447 $message = 'Connected';
448 break;
449
450 case 'paused':
451 case 'fail':
452 $error = $this->getLastError();
453 $message = $error
454 ? sprintf('Disconnected (%s - %s)', $error->getCode(), $error->getMessage())
455 : null;
456 break;
457
458 case 'staging':
459 $message = 'Connection disabled. Site url has changed.';
460 break;
461
462 default:
463 $message = $this->getApiKey() ? null : 'API Key has not been set';
464 break;
465 }
466
467 return $message ?: 'Unknown Status';
468 }
469
470 /**
471 * @return ?string
472 */
473 public function getConnectionStatus(): ?string
474 {
475 if (logtivity_has_site_url_changed()) {
476 return 'staging';
477 }
478
479 return $this->getApiKey() ? $this->getOption('logtivity_api_key_check') : null;
480 }
481 }
482