PluginProbe
Debug Log Viewer / 2.2.3
Debug Log Viewer v2.2.3
2.5 2.5.1 2.5.2 2.4 trunk 1.0.2 1.0.3 1.1 1.1.1 1.2 1.2.1 1.3 1.4 1.4.1 1.4.2 1.4.3 2.0.1 2.0.3 2.0.4 2.0.5 2.1 2.2.3
debug-log-viewer / admin / models / LogModel.php

LogModel.php in Debug Log Viewer 2.2.3, at admin/models/LogModel.php

567 lines 15.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace DebugLogViewer\Admin\Models;
4
5 if (!defined('ABSPATH')) {
6 exit; // Exit if accessed directly
7 }
8
9 use DebugLogViewer\Admin\Translations\Phrases;
10 use DebugLogViewer\Admin\Helpers\Utils;
11
12 class LogModel
13 {
14
15 // Log level constants
16 const LOG_LEVEL_NOTICE = 'Notice';
17 const LOG_LEVEL_WARNING = 'Warning';
18 const LOG_LEVEL_FATAL = 'Fatal';
19 const LOG_LEVEL_DATABASE = 'Database';
20 const LOG_LEVEL_PARSE = 'Parse';
21 const LOG_LEVEL_DEPRECATED = 'Deprecated';
22 const LOG_LEVEL_CUSTOM = 'Custom';
23
24 const LAST_POSITION_OPTION_NAME = 'dbg_lv_log_last_position';
25 const LOG_UPDATES_MODE_OPTION_NAME = 'dbg_lv_log_updates_mode';
26 const DATETIME_FORMAT_OPTION_NAME = 'dbg_lv_datetime_format';
27 const TIMEZONE_OPTION_NAME = 'dbg_lv_timezone';
28 const GROUP_ENTRIES_OPTION_NAME = 'dbg_lv_group_entries';
29 const DATETIME_FORMAT_ABSOLUTE = 'ABSOLUTE';
30 const DATETIME_FORMAT_RELATIVE = 'RELATIVE';
31 const LOG_FILE_LIMIT = 10 * 1024 * 1024; // 10 MB
32 const LOG_UPDATES_INTERVAL = 10; // 10 seconds
33
34 // File size units constants
35 const UNIT_BYTES = 'bytes';
36 const UNIT_KILOBYTES = 'kilobytes';
37 const UNIT_MEGABYTES = 'megabytes';
38 const UNIT_GIGABYTES = 'gigabytes';
39
40 public static function isCustomLoggingPath() {
41 return defined('WP_DEBUG_LOG') && is_string(WP_DEBUG_LOG) && !in_array(WP_DEBUG_LOG, [ '1', '0', 'true', 'false' ], true) && !empty(WP_DEBUG_LOG);
42 }
43
44 public function getLogFilePath() {
45 return self::isCustomLoggingPath()
46 ? WP_DEBUG_LOG
47 : WP_CONTENT_DIR . '/debug.log';
48 }
49
50 public function getWpConfigPath() {
51 // Starting from the current directory
52 $dir = __DIR__;
53
54 // Traverse up to 10 levels to avoid infinite loops
55 for ($i = 0; $i < 10; $i++) {
56 if (file_exists($dir . '/wp-config.php')) {
57 return realpath($dir . '/wp-config.php');
58 }
59 // Move up one directory level
60 $dir = dirname($dir);
61 }
62 return 'wp-config.php not found!';
63 }
64
65 public function getLogLimit($unit = self::UNIT_MEGABYTES, $with_units = false) {
66 // Validate unit parameter
67 $allowed_units = [
68 self::UNIT_BYTES,
69 self::UNIT_KILOBYTES,
70 self::UNIT_MEGABYTES,
71 self::UNIT_GIGABYTES
72 ];
73
74 if (!in_array($unit, $allowed_units, true)) {
75 // Fallback to default unit if invalid value provided
76 $unit = self::UNIT_MEGABYTES;
77 }
78
79 $limit_in_bytes = defined('DBG_LV_USER_DEFINED_LOG_FILE_LIMIT')
80 ? constant('DBG_LV_USER_DEFINED_LOG_FILE_LIMIT')
81 : static::LOG_FILE_LIMIT;
82
83 $converted_value = $this->convertBytesToUnit($limit_in_bytes, $unit);
84
85 if ($with_units) {
86 $phrases = Phrases::getParsedContentPhrases();
87 $unit_key = $unit;
88 $unit_label = isset($phrases[$unit_key]) ? $phrases[$unit_key] : $unit;
89 return $converted_value . ' ' . $unit_label;
90 }
91
92 return $converted_value;
93 }
94
95 public function getInitialLogPosition() {
96 // Calculate the initial log position based on the file size and the log limit
97
98 $initial_position = $this->getLogSize([ 'raw' => true ]) - $this->getLogLimit(self::UNIT_BYTES);
99
100 return $initial_position > 0 ? $initial_position : 0;
101 }
102
103 private function convertBytesToUnit($bytes, $unit) {
104 switch ($unit) {
105 case self::UNIT_BYTES:
106 return $bytes;
107 case self::UNIT_KILOBYTES:
108 return round($bytes / 1024, 2);
109 case self::UNIT_MEGABYTES:
110 return round($bytes / (1024 * 1024), 2);
111 case self::UNIT_GIGABYTES:
112 return round($bytes / (1024 * 1024 * 1024), 2);
113 default:
114 return $bytes;
115 }
116 }
117
118 public function isOverlimited() {
119 return $this->getLogSize([ 'raw' => true ]) > $this->getLogLimit(self::UNIT_BYTES);
120 }
121
122 private function readFromFileEnd($file_handle, $limit) {
123 fseek($file_handle, -$limit, SEEK_END);
124 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
125 $content = fread($file_handle, $limit);
126 return $content;
127 }
128
129 public function getRawContent( $filename ) {
130 if ($this->isOverlimited()) {
131 $actual_limit = $this->getLogLimit(self::UNIT_BYTES);
132 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
133 $file_handle = fopen($filename, 'r');
134 $content = $this->readFromFileEnd($file_handle, $actual_limit);
135 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
136 fclose($file_handle);
137 return $content;
138 } else {
139 return file_get_contents($filename);
140 }
141 }
142
143 public function getNewEntries() {
144 $path = $this->getLogFilePath();
145 // Handle missing file
146 if (!file_exists($path)) {
147 return $this->resetLog();
148 }
149
150 $file_size = filesize($path);
151 // Handle empty file
152 if ($file_size === 0) {
153 return $this->resetLog();
154 }
155
156 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
157 $file_handle = fopen($path, 'r');
158 if (!$file_handle) {
159 return $this->resetLog();
160 }
161 $last_position = get_option(self::LAST_POSITION_OPTION_NAME, 0);
162
163 // Handle new or truncated content
164 if ($last_position === 0 || $file_size > $last_position) {
165 fseek($file_handle, $last_position, SEEK_SET);
166 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
167 $content = fread($file_handle, $file_size - $last_position);
168 $new_position = ftell($file_handle);
169 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
170 fclose($file_handle);
171
172 update_option(self::LAST_POSITION_OPTION_NAME, $new_position);
173
174 return [
175 'action' => [],
176 'data' => $this->splitLogToRows($content),
177 ];
178 }
179
180 // Handle file truncation
181 if ($file_size < $last_position) {
182 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
183 fclose($file_handle);
184 return $this->resetLog();
185 }
186
187 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
188 fclose($file_handle);
189 }
190
191 private static function resetLog() {
192 update_option(self::LAST_POSITION_OPTION_NAME, 0);
193 return [
194 'action' => 'clear',
195 'data' => [],
196 ];
197 }
198
199 public function getParsedContent() {
200 $path = $this->getLogFilePath();
201
202 if (!file_exists($path) || !is_file($path)) {
203 return false;
204 }
205
206 $content = $this->getRawContent($path);
207 return $this->splitLogToRows($content);
208 }
209
210 private function splitLogToRows( $content ) {
211 $pattern = '/\[[^\]]+\].*?(?=\n\[|$)/s';
212 $count = preg_match_all($pattern, $content, $matches);
213
214 if (!$count) {
215 return [];
216 }
217
218 return array_reverse($matches[0]);
219 }
220
221 public static function getDatetime( $row ) {
222 preg_match_all('/\[(.*?)\]/m', $row, $matches, PREG_SET_ORDER, 0);
223 return isset($matches[0][1]) ? $matches[0][1] : '';
224 }
225
226 public static function getDateTimeZone( $timezone_string = '' ) {
227 if ( empty( $timezone_string ) ) {
228 return wp_timezone();
229 }
230
231 try {
232 return new \DateTimeZone( $timezone_string );
233 } catch ( \Exception $e ) {
234 if ( preg_match( '/^UTC([+-])?([0-9]+(?:\.[0-9]+)?)$/', $timezone_string, $matches ) ) {
235 $offset = (float) $matches[2];
236 if ( isset( $matches[1] ) && '-' === $matches[1] ) {
237 $offset = -$offset;
238 }
239
240 $hours = (int) $offset;
241 $minutes = abs( ( $offset - $hours ) * 60 );
242 $sign = $offset >= 0 ? '+' : '-';
243
244 $offset_string = sprintf( '%s%02d:%02d', $sign, abs( $hours ), $minutes );
245
246 try {
247 return new \DateTimeZone( $offset_string );
248 } catch ( \Exception $e2 ) {
249 return false;
250 }
251 }
252 return false;
253 }
254 }
255
256 public static function formatDatetimeWithTimezone( $datetime ) {
257 if ( empty( $datetime ) ) {
258 return $datetime;
259 }
260
261 $selected_timezone = get_user_meta( get_current_user_id(), self::TIMEZONE_OPTION_NAME, true );
262 $timezone_string = $selected_timezone ?: wp_timezone_string();
263
264 try {
265 $dt = new \DateTime( $datetime );
266
267 $target_timezone = self::getDateTimeZone( $timezone_string );
268 if ( false === $target_timezone ) {
269 return $datetime;
270 }
271
272 $dt->setTimezone( $target_timezone );
273
274 $date_format = get_option( 'date_format' );
275 $time_format = get_option( 'time_format' );
276
277 return $dt->format( $date_format . ' ' . $time_format );
278 } catch ( \Exception $e ) {
279 return $datetime;
280 }
281 }
282
283 public static function getLine( $row ) {
284 preg_match_all('/(on line |php:)(\d{1,})/m', $row, $matches, PREG_SET_ORDER, 0);
285 return isset($matches[0][2]) ? $matches[0][2] : '';
286 }
287
288 public static function getFile( $row ) {
289 preg_match_all('/ in ' . preg_quote(Utils::getDocumentRoot(), '/') . '(.*?)( on line |:)\d{1,}/m', $row, $matches, PREG_SET_ORDER, 0);
290 return isset($matches[0][1]) ? $matches[0][1] : '';
291 }
292
293 public static function getType( $row ) {
294 if (strpos($row, 'PHP Notice:') !== false) {
295 return self::LOG_LEVEL_NOTICE;
296 } elseif (strpos($row, 'PHP Warning:') !== false) {
297 return self::LOG_LEVEL_WARNING;
298 } elseif (strpos($row, 'PHP Fatal error:') !== false) {
299 return self::LOG_LEVEL_FATAL;
300 } elseif (strpos($row, 'WordPress database error') !== false) {
301 return self::LOG_LEVEL_DATABASE;
302 } elseif (strpos($row, 'PHP Parse error:') !== false) {
303 return self::LOG_LEVEL_PARSE;
304 } elseif (strpos($row, 'PHP Deprecated:') !== false) {
305 return self::LOG_LEVEL_DEPRECATED;
306 } else {
307 return self::LOG_LEVEL_CUSTOM;
308 }
309 }
310
311 public static function getStackTrace( $row ) {
312 $re = '/Stack trace:\n(.*?)thrown in/s';
313 preg_match_all($re, $row, $matches, PREG_SET_ORDER, 0);
314 if (isset($matches[0])) {
315 return $matches[0][1];
316 }
317 return null;
318 }
319
320 /**
321 * Build a hash key from type, description, file and line.
322 *
323 * @param string $type Log level / type.
324 * @param string $description Description text.
325 * @param string $file Source file path.
326 * @param string $line Line number.
327 * @return string MD5 hash.
328 */
329 public static function buildEntryHash( string $type, string $description, string $file, string $line ): string {
330 return md5( $type . '::' . $description . '::' . $file . '::' . $line );
331 }
332
333 /**
334 * Group formatted log entries by a hash of type + description + file + line.
335 *
336 * @param array $entries Formatted entry arrays (as returned by LiveUpdatesController::getUpdates).
337 * @return array Grouped entries, each with additional `hash` and `count` keys.
338 */
339 public static function groupEntries( array $entries ): array {
340 $groups = [];
341
342 foreach ($entries as $entry) {
343 if (empty($entry)) {
344 continue;
345 }
346
347 $hash = self::buildEntryHash(
348 $entry['type'] ?? '',
349 $entry['description']['text'] ?? '',
350 $entry['file'] ?? '',
351 $entry['line'] ?? ''
352 );
353
354 if (isset($groups[ $hash ])) {
355 $groups[ $hash ]['count']++;
356
357 // Keep most-recent timestamp / datetime as the representative value.
358 if ($entry['timestamp'] > $groups[ $hash ]['timestamp']) {
359 $groups[ $hash ]['timestamp'] = $entry['timestamp'];
360 $groups[ $hash ]['datetime'] = $entry['datetime'];
361 }
362 } else {
363 $entry['hash'] = $hash;
364 $entry['count'] = 1;
365 $groups[ $hash ] = $entry;
366 }
367 }
368
369 return array_values($groups);
370 }
371
372 public static function getDescription( $row ) {
373 if (self::getType($row) === self::LOG_LEVEL_DATABASE) {
374
375 $re = '/WordPress database error (.*)/m';
376 preg_match_all($re, $row, $matches, PREG_SET_ORDER, 0);
377 return isset($matches[0]) && $matches[0][1] ? $matches[0][1] : __('N/A', 'debug-log-viewer');
378 }
379
380 $re = '/ (PHP Notice:|PHP Warning:|PHP Fatal error:|PHP Parse error:|PHP Deprecated:)(.*?)(\[ | in |on line)/m';
381 preg_match_all($re, $row, $matches, PREG_SET_ORDER, 0);
382 return isset($matches[0]) && $matches[0][2] ? $matches[0][2] : trim(str_replace('[' . self::getDatetime($row) . ']', '', $row));
383 }
384
385 public function getLogSize( $params ) {
386 $withUnits = isset($params['with_measure_units']) ? $params['with_measure_units'] : null;
387 $raw = isset($params['raw']) ? $params['raw'] : null;
388
389 $path = $this->getLogFilePath();
390
391 if (is_file($path) && filesize($path)) {
392 $filesizeInBytes = filesize($path);
393 if ($raw) {
394 return $filesizeInBytes;
395 }
396 $filesizeInMegabytes = $filesizeInBytes / 1024 / 1024;
397 return $withUnits
398 ? round($filesizeInMegabytes, 2) . ' ' . __('Mb', 'debug-log-viewer')
399 : round($filesizeInMegabytes, 2);
400 } else {
401 return 0;
402 }
403 }
404
405 /**
406 * Write content to log file safely
407 *
408 * @param string $filename File path
409 * @param string $content Content to write
410 * @param bool $append Whether to append or truncate
411 * @return array Result with success status and message
412 */
413 public function writeToFile($filename, $content, $append = false) {
414 if (!file_exists($filename)) {
415 return [
416 'success' => false,
417 'message' => 'File does not exist: ' . $filename,
418 ];
419 }
420
421 if (!is_writable($filename)) {
422 return [
423 'success' => false,
424 'message' => 'File is not writable: ' . $filename,
425 ];
426 }
427
428 $mode = $append ? 'a' : 'w';
429 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
430 $handle = fopen($filename, $mode);
431
432 if (false === $handle) {
433 return [
434 'success' => false,
435 'message' => 'Failed to open file for writing',
436 ];
437 }
438
439 $bytes_written = fwrite($handle, $content);
440 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
441 fclose($handle);
442
443 if (false === $bytes_written) {
444 return [
445 'success' => false,
446 'message' => 'Failed to write to file',
447 ];
448 }
449
450 return [
451 'success' => true,
452 'bytes_written' => $bytes_written,
453 ];
454 }
455
456 /**
457 * Copy file safely with error handling
458 *
459 * @param string $source Source file path
460 * @param string $destination Destination file path
461 * @return array Result with success status and message
462 */
463 public function copyFile($source, $destination) {
464 if (!file_exists($source)) {
465 return [
466 'success' => false,
467 'message' => 'Source file does not exist: ' . $source,
468 ];
469 }
470
471 if (!is_readable($source)) {
472 return [
473 'success' => false,
474 'message' => 'Source file is not readable: ' . $source,
475 ];
476 }
477
478 try {
479 if (!copy($source, $destination)) {
480 return [
481 'success' => false,
482 'message' => 'Failed to copy file',
483 ];
484 }
485
486 // Set proper permissions on the copied file
487 $chmod_result = chmod($destination, 0644);
488 if (!$chmod_result) {
489 // Log warning but don't fail the operation
490 error_log('Debug Log Viewer: Warning - Failed to set permissions on copied file: ' . $destination);
491 }
492
493 return [
494 'success' => true,
495 'destination' => $destination,
496 ];
497 } catch (\Exception $e) {
498 return [
499 'success' => false,
500 'message' => 'Exception during file copy: ' . $e->getMessage(),
501 ];
502 }
503 }
504
505 /**
506 * Delete file safely with error handling
507 *
508 * @param string $filename File path to delete
509 * @return array Result with success status and message
510 */
511 public function deleteFile($filename) {
512 if (!file_exists($filename)) {
513 return [
514 'success' => true,
515 'message' => 'File does not exist (already deleted)',
516 ];
517 }
518
519 if (!is_writable($filename)) {
520 return [
521 'success' => false,
522 'message' => 'File is not writable/deletable: ' . $filename,
523 ];
524 }
525
526 if (wp_delete_file($filename)) {
527 return [
528 'success' => true,
529 'message' => 'File deleted successfully',
530 ];
531 } else {
532 return [
533 'success' => false,
534 'message' => 'Failed to delete file: ' . $filename,
535 ];
536 }
537 }
538
539 /**
540 * Set file permissions safely
541 *
542 * @param string $filename File path
543 * @param int $permissions Octal permissions (e.g., 0644)
544 * @return array Result with success status and message
545 */
546 public function setPermissions($filename, $permissions = 0644) {
547 if (!file_exists($filename)) {
548 return [
549 'success' => false,
550 'message' => 'File does not exist: ' . $filename,
551 ];
552 }
553
554 if (chmod($filename, $permissions)) {
555 return [
556 'success' => true,
557 'permissions' => decoct($permissions),
558 ];
559 } else {
560 return [
561 'success' => false,
562 'message' => 'Failed to set permissions on file: ' . $filename,
563 ];
564 }
565 }
566 }
567