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 / services / LogCleanup.php

LogCleanup.php in Debug Log Viewer 2.2.3, at admin/services/LogCleanup.php

538 lines 15.7 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\Services;
4
5 if (! defined('ABSPATH')) {
6 exit; // Exit if accessed directly
7 }
8
9 use DebugLogViewer\Admin\Models\LogModel;
10 use function DebugLogViewer\Admin\Controllers\dbg_lv;
11
12
13 class LogCleanupService
14 {
15
16 // Maximum log file size in bytes (default: 50MB)
17 private $maxLogSize;
18 private $logFilePath;
19 private $logModel;
20 // Percentage of log to keep when truncating (default: 10%)
21 private $retentionPercentage = 0.10;
22
23 /**
24 * Option names for WordPress options table
25 */
26 const OPTION_MAX_LOG_SIZE = 'dbg_lv_max_log_size';
27 const OPTION_CLEANUP_ENABLED = 'dbg_lv_cleanup_enabled';
28 const OPTION_CLEANUP_METHOD = 'dbg_lv_cleanup_method';
29 const OPTION_LAST_CLEANUP_TIME = 'dbg_lv_last_cleanup_time';
30 const OPTION_LAST_CLEANUP_SIZE = 'dbg_lv_last_cleanup_size';
31 const OPTION_RETENTION_PERCENT = 'dbg_lv_retention_percentage';
32 const OPTION_CLEANUP_SCHEDULE = 'dbg_lv_cleanup_schedule';
33 const OPTION_CLEANUP_NOTIFICATIONS = 'dbg_lv_cleanup_notifications_enabled';
34 const OPTION_CLEANUP_HISTORY = 'dbg_lv_cleanup_history';
35 const OPTION_CLEANUP_NEXT_RUN = 'dbg_lv_cleanup_next_run';
36
37 /**
38 * Cleanup methods
39 */
40 const METHOD_TRUNCATE_KEEP_LAST = 'keep_last';
41 const METHOD_CLEAR_FILE = 'clear';
42 const METHOD_ARCHIVE_AND_ROTATE = 'archive';
43
44 /**
45 * Validation constants for settings
46 */
47 const DEFAULT_MAX_LOG_SIZE_MB = 50; // Default maximum log file size in megabytes
48 const MIN_MAX_LOG_SIZE_MB = 1; // Minimum allowed max log size
49 const MAX_MAX_LOG_SIZE_MB = 5000; // Maximum allowed max log size
50 const DEFAULT_RETENTION_PERCENT = 10; // Default percentage of log to keep when truncating
51 const MIN_RETENTION_PERCENT = 5; // Minimum retention percentage
52 const MAX_RETENTION_PERCENT = 50; // Maximum retention percentage
53
54 /**
55 * Backup rotation limits (for Archive & Rotate method)
56 */
57 const MAX_BACKUPS_FREE = 5; // Maximum backup files for Free plan
58 const MAX_BACKUPS_PRO = 20; // Maximum backup files for Pro plan
59
60 /**
61 * Constructor
62 */
63 public function __construct()
64 {
65 $this->logModel = new LogModel();
66 $this->logFilePath = $this->logModel->getLogFilePath();
67
68 // Get max log size from options (in MB), convert to bytes
69 $max_size_mb = get_option(self::OPTION_MAX_LOG_SIZE, 50);
70 $this->maxLogSize = $max_size_mb * 1024 * 1024; // Convert MB to bytes
71
72 // Get retention percentage (default 10%)
73 $retention_percent = get_option(self::OPTION_RETENTION_PERCENT, 10);
74 $this->retentionPercentage = $retention_percent / 100;
75 }
76
77 /**
78 * Main cleanup execution method
79 *
80 * Checks if cleanup is needed and executes it based on configured method
81 *
82 * @return array Array containing success status and message
83 */
84 public function executeCleanup()
85 {
86
87 // Check if file exists
88 if (! file_exists($this->logFilePath)) {
89 return [
90 'success' => false,
91 'message' => 'Log file does not exist: ' . $this->logFilePath,
92 ];
93 }
94
95 // Check if file is readable and writable
96 if (! is_readable($this->logFilePath) || ! is_writable($this->logFilePath)) {
97 return [
98 'success' => false,
99 'message' => 'Log file is not readable or writable: ' . $this->logFilePath,
100 ];
101 }
102
103 // Get current file size
104 $current_size = filesize($this->logFilePath);
105
106 // Check if cleanup is needed
107 if ($current_size <= $this->maxLogSize) {
108 return [
109 'success' => true,
110 'message' => __('Log file size is within limits. No cleanup needed.', 'debug-log-viewer'),
111 'size' => $current_size,
112 ];
113 }
114
115 // Perform cleanup based on configured method
116 $cleanup_method = get_option(self::OPTION_CLEANUP_METHOD, self::METHOD_TRUNCATE_KEEP_LAST);
117
118 switch ($cleanup_method) {
119 case self::METHOD_CLEAR_FILE:
120 $result = $this->clearLogFile();
121 break;
122
123 case self::METHOD_ARCHIVE_AND_ROTATE:
124 $result = $this->archiveAndRotate();
125 break;
126
127 case self::METHOD_TRUNCATE_KEEP_LAST:
128 default:
129 $result = $this->truncateKeepLast();
130 break;
131 }
132
133 // Update last cleanup metadata
134 if ($result['success']) {
135 update_option(self::OPTION_LAST_CLEANUP_TIME, current_time('mysql'));
136 update_option(self::OPTION_LAST_CLEANUP_SIZE, $current_size);
137 }
138
139 return $result;
140 }
141
142 /**
143 * Truncate the log file keeping only the last X% of data
144 *
145 * This method is memory-efficient and uses file streams with fseek
146 * to avoid loading the entire file into memory.
147 *
148 * @return array Result of the operation
149 */
150 private function truncateKeepLast()
151 {
152 $current_size = filesize($this->logFilePath);
153
154 // Calculate how much data to keep
155 $bytes_to_keep = (int) ($current_size * $this->retentionPercentage);
156 $start_offset = $current_size - $bytes_to_keep;
157
158 // Open files for reading and writing
159 $source_handle = fopen($this->logFilePath, 'r');
160 if (false === $source_handle) {
161 return array(
162 'success' => false,
163 'message' => 'Failed to open log file for reading',
164 );
165 }
166
167 // Create temporary file
168 $temp_file = $this->logFilePath . '.tmp';
169 $temp_handle = fopen($temp_file, 'w');
170
171 if (false === $temp_handle) {
172 fclose($source_handle);
173 return [
174 'success' => false,
175 'message' => 'Failed to create temporary file',
176 ];
177 }
178
179 // Seek to the position where we want to start keeping data
180 if (fseek($source_handle, $start_offset) !== 0) {
181 fclose($source_handle);
182 fclose($temp_handle);
183 wp_delete_file($temp_file);
184 return [
185 'success' => false,
186 'message' => 'Failed to seek in log file',
187 ];
188 }
189
190 // Skip partial lines - find the next newline
191 $first_line = fgets($source_handle);
192
193 // Write header indicating truncation
194 $truncation_notice = sprintf(
195 "[%s] === Log file truncated by Debug Log Viewer Auto-Cleanup ===\n",
196 gmdate('Y-m-d H:i:s')
197 );
198 fwrite($temp_handle, $truncation_notice);
199
200 // Copy remaining data in chunks to avoid memory issues
201 $chunk_size = 8192; // 8KB chunks
202 $bytes_copied = 0;
203
204 while (! feof($source_handle)) {
205 $chunk = fread($source_handle, $chunk_size);
206 if (false === $chunk) {
207 break;
208 }
209 fwrite($temp_handle, $chunk);
210 $bytes_copied += strlen($chunk);
211 }
212
213 // Close handles
214 fclose($source_handle);
215 fclose($temp_handle);
216
217 // Replace original file with truncated version
218 if (! rename($temp_file, $this->logFilePath)) {
219 $delete_result = $this->logModel->deleteFile($temp_file);
220 if (!$delete_result['success']) {
221 error_log('Debug Log Viewer: Failed to cleanup temp file: ' . $temp_file);
222 }
223 return [
224 'success' => false,
225 'message' => 'Failed to replace log file with truncated version',
226 ];
227 }
228
229 // Set proper permissions
230 $chmod_result = $this->logModel->setPermissions($this->logFilePath, 0644);
231 if (!$chmod_result['success']) {
232 // Log warning but don't fail the operation
233 error_log('Debug Log Viewer: ' . $chmod_result['message']);
234 }
235
236 clearstatcache(true, $this->logFilePath);
237
238 return [
239 'success' => true,
240 'message' => 'Log file truncated successfully',
241 'old_size' => $current_size,
242 'new_size' => filesize($this->logFilePath),
243 'bytes_kept' => $bytes_copied,
244 'method' => 'truncateKeepLast',
245 'retention' => ($this->retentionPercentage * 100) . '%',
246 ];
247 }
248
249 /**
250 * Clear the log file completely
251 *
252 * Most performance-efficient method for very large files
253 *
254 * @return array Result of the operation
255 */
256 private function clearLogFile()
257 {
258 $current_size = filesize($this->logFilePath);
259
260 // Write header
261 $header = sprintf(
262 "[%s] === Log file cleared by Debug Log Viewer Auto-Cleanup ===\n",
263 gmdate('Y-m-d H:i:s')
264 );
265
266 $write_result = $this->logModel->writeToFile($this->logFilePath, $header, false);
267
268 if (!$write_result['success']) {
269 return [
270 'success' => false,
271 'message' => $write_result['message'],
272 ];
273 }
274
275 // Set proper permissions
276 $chmod_result = $this->logModel->setPermissions($this->logFilePath, 0644);
277 if (!$chmod_result['success']) {
278 // Log warning but don't fail the operation
279 error_log('Debug Log Viewer: ' . $chmod_result['message']);
280 }
281
282 clearstatcache(true, $this->logFilePath);
283
284 return [
285 'success' => true,
286 'message' => 'Log file cleared successfully',
287 'old_size' => $current_size,
288 'new_size' => filesize($this->logFilePath),
289 'method' => 'clear',
290 ];
291 }
292
293 /**
294 * Archive the current log and create a new empty one
295 *
296 * Creates a backup with timestamp before clearing the log.
297 * Automatically rotates backups to prevent disk space bloat.
298 *
299 * @return array Result of the operation
300 */
301 private function archiveAndRotate()
302 {
303 $current_size = filesize($this->logFilePath);
304
305 // Cleanup old backups before creating new one
306 $cleanup_result = $this->cleanupOldBackups();
307 if (! $cleanup_result['success']) {
308 // Log warning but continue with archive
309 error_log('Debug Log Viewer: Failed to cleanup old backups: ' . $cleanup_result['message']);
310 }
311
312 // Create archive filename with timestamp
313 $archive_name = str_replace('.log', '', basename($this->logFilePath));
314 $archive_path = dirname($this->logFilePath) . '/' . $archive_name . '-' . gmdate('Y-m-d-His') . '.log';
315
316 // Copy current log to archive using safe method
317 $copy_result = $this->logModel->copyFile($this->logFilePath, $archive_path);
318 if (!$copy_result['success']) {
319 return [
320 'success' => false,
321 'message' => 'Failed to create archive: ' . $copy_result['message'],
322 ];
323 }
324
325 // Now clear the original file
326 $clear_result = $this->clearLogFile();
327
328 if (! $clear_result['success']) {
329 // Cleanup archive if clear failed
330 $delete_result = $this->logModel->deleteFile($archive_path);
331 if (!$delete_result['success']) {
332 error_log('Debug Log Viewer: Failed to cleanup archive after failed clear: ' . $archive_path);
333 }
334 return $clear_result;
335 }
336
337 clearstatcache(true, $this->logFilePath);
338
339 $message = 'Log file archived and rotated successfully';
340 if (isset($cleanup_result['deleted_count']) && $cleanup_result['deleted_count'] > 0) {
341 $message .= sprintf(' (Removed %d old backup(s))', $cleanup_result['deleted_count']);
342 }
343
344 return [
345 'success' => true,
346 'message' => $message,
347 'old_size' => $current_size,
348 'new_size' => filesize($this->logFilePath),
349 'archive_path' => $archive_path,
350 'method' => 'archiveAndRotate',
351 ];
352 }
353
354 /**
355 * Cleanup old backup files to prevent disk space issues
356 *
357 * Keeps only the most recent N backups (5 for Free, 20 for Pro).
358 * Only applies to Archive & Rotate method.
359 *
360 * @return array Result with success status and deleted count
361 */
362 private function cleanupOldBackups()
363 {
364 // Determine the maximum number of backups allowed
365 $is_premium = dbg_lv()->is_premium();
366 $max_backups = $is_premium ? self::MAX_BACKUPS_PRO : self::MAX_BACKUPS_FREE;
367
368 // Get directory path
369 $log_dir = dirname($this->logFilePath);
370 $log_basename = str_replace('.log', '', basename($this->logFilePath));
371
372 // Find all backup files matching the pattern: debug-2024-02-24-*.log
373 $pattern = $log_dir . '/' . $log_basename . '-????-??-??-??????.log';
374 $backup_files = glob($pattern);
375
376 if (false === $backup_files || empty($backup_files)) {
377 return [
378 'success' => true,
379 'message' => 'No backups to cleanup',
380 'deleted_count' => 0,
381 ];
382 }
383
384 // Check if we need to cleanup
385 if (count($backup_files) <= $max_backups) {
386 return [
387 'success' => true,
388 'message' => sprintf('Backup count (%d) within limit (%d)', count($backup_files), $max_backups),
389 'deleted_count' => 0,
390 ];
391 }
392
393 // Sort by modification time (oldest first)
394 usort($backup_files, function ($a, $b) {
395 return filemtime($a) - filemtime($b);
396 });
397
398 // Calculate how many files to delete
399 $files_to_delete = array_slice($backup_files, 0, count($backup_files) - $max_backups);
400 $deleted_count = 0;
401 $errors = [];
402
403 // Delete oldest backups
404 foreach ($files_to_delete as $file) {
405 if (file_exists($file)) {
406 $delete_result = $this->logModel->deleteFile($file);
407 if ($delete_result['success']) {
408 $deleted_count++;
409 } else {
410 $errors[] = basename($file) . ': ' . $delete_result['message'];
411 }
412 }
413 }
414
415 if (! empty($errors)) {
416 return [
417 'success' => false,
418 'message' => sprintf('Failed to delete %d backup file(s)', count($errors)),
419 'deleted_count' => $deleted_count,
420 'errors' => $errors,
421 ];
422 }
423
424 return [
425 'success' => true,
426 'message' => sprintf('Successfully deleted %d old backup(s)', $deleted_count),
427 'deleted_count' => $deleted_count,
428 ];
429 }
430
431 /**
432 * Get cleanup statistics for display
433 *
434 * @return array Statistics about cleanup operations
435 */
436 public function get_cleanup_stats()
437 {
438 $current_size = 0;
439 if (file_exists($this->logFilePath)) {
440 $current_size = filesize($this->logFilePath);
441 }
442
443 return [
444 'enabled' => get_option(self::OPTION_CLEANUP_ENABLED, false),
445 'max_size_mb' => get_option(self::OPTION_MAX_LOG_SIZE, 50),
446 'max_size_bytes' => $this->maxLogSize,
447 'current_size_bytes' => $current_size,
448 'current_size_mb' => round($current_size / 1024 / 1024, 2),
449 'percentage_used' => $this->maxLogSize > 0 ? round(($current_size / $this->maxLogSize) * 100, 2) : 0,
450 'cleanup_method' => get_option(self::OPTION_CLEANUP_METHOD, self::METHOD_TRUNCATE_KEEP_LAST),
451 'retention_percentage' => get_option(self::OPTION_RETENTION_PERCENT, 10),
452 'email_notifications' => get_option(self::OPTION_CLEANUP_NOTIFICATIONS, false),
453 ];
454 }
455
456 /**
457 * Check if cleanup is needed (for manual trigger or preview)
458 *
459 * @return bool True if cleanup is needed
460 */
461 public function is_cleanup_needed()
462 {
463 if (! file_exists($this->logFilePath)) {
464 return false;
465 }
466
467 $current_size = filesize($this->logFilePath);
468 return $current_size > $this->maxLogSize;
469 }
470
471 /**
472 * Validate and sanitize cleanup settings
473 *
474 * @param array $settings Settings to validate
475 * @return array Validated settings
476 */
477 public static function validate_settings($settings)
478 {
479 $validated = [];
480 $is_premium = dbg_lv()->is_premium();
481
482 // Validate max log size (1MB - 5000MB)
483 // Free: Fixed at 50MB, Pro: Configurable 1-5000MB
484 if (isset($settings['maxLogSize'])) {
485 if ($is_premium) {
486 $max_size = absint($settings['maxLogSize']);
487 $validated['maxLogSize'] = max(self::MIN_MAX_LOG_SIZE_MB, min(self::MAX_MAX_LOG_SIZE_MB, $max_size));
488 } else {
489 // Free plan: Force to default 50MB
490 $validated['maxLogSize'] = self::DEFAULT_MAX_LOG_SIZE_MB;
491 }
492 }
493
494 // Validate cleanup enabled
495 if (isset($settings['cleanupEnabled'])) {
496 $validated['cleanupEnabled'] = (bool) $settings['cleanupEnabled'];
497 }
498
499 // Validate cleanup method
500 // Free: Only Truncate, Pro: All methods
501 if (isset($settings['cleanupMethod'])) {
502 if ($is_premium) {
503 $allowed_methods = array(
504 self::METHOD_TRUNCATE_KEEP_LAST,
505 self::METHOD_CLEAR_FILE,
506 self::METHOD_ARCHIVE_AND_ROTATE,
507 );
508
509 $validated['cleanupMethod'] = in_array($settings['cleanupMethod'], $allowed_methods, true)
510 ? $settings['cleanupMethod']
511 : self::METHOD_TRUNCATE_KEEP_LAST;
512 } else {
513 // Free plan: Force to Truncate method only
514 $validated['cleanupMethod'] = self::METHOD_TRUNCATE_KEEP_LAST;
515 }
516 }
517
518 // Validate retention percentage (5% - 50%)
519 // Free: Fixed at 10%, Pro: Configurable 5-50%
520 if (isset($settings['retentionPercentage'])) {
521 if ($is_premium) {
522 $retention = absint($settings['retentionPercentage']);
523 $validated['retentionPercentage'] = max(self::MIN_RETENTION_PERCENT, min(self::MAX_RETENTION_PERCENT, $retention));
524 } else {
525 // Free plan: Force to default 10%
526 $validated['retentionPercentage'] = self::DEFAULT_RETENTION_PERCENT;
527 }
528 }
529
530 // Validate email notifications (Pro only)
531 if (isset($settings['emailNotifications'])) {
532 $validated['emailNotifications'] = $is_premium ? (bool) $settings['emailNotifications'] : false;
533 }
534
535 return $validated;
536 }
537 }
538