409 lines
12 KiB
PHP
409 lines
12 KiB
PHP
<?php
|
|
|
|
/**
|
|
* LogsController
|
|
* Manages application logs viewing and management
|
|
* Admin-only access to application log files
|
|
*/
|
|
class LogsController {
|
|
|
|
private const LOGS_DIR = __DIR__ . '/../logs';
|
|
private const MAX_FILE_SIZE = 10485760; // 10 MB
|
|
private const ALLOWED_EXTENSIONS = ['log', 'txt'];
|
|
|
|
/**
|
|
* List and view application logs
|
|
* GET /admin/logs
|
|
*/
|
|
public function index() {
|
|
requireAdmin();
|
|
|
|
$logFiles = $this->getLogFiles();
|
|
$selectedFile = $_GET['file'] ?? null;
|
|
$logContent = '';
|
|
$logLines = [];
|
|
$fileSize = 0;
|
|
$lineCount = 0;
|
|
|
|
if ($selectedFile && $this->isValidLogFile($selectedFile)) {
|
|
$filePath = self::LOGS_DIR . '/' . basename($selectedFile);
|
|
|
|
if (file_exists($filePath)) {
|
|
$fileSize = filesize($filePath);
|
|
|
|
// Read log file (last 1000 lines or complete if small)
|
|
$logContent = $this->readLogFile($filePath);
|
|
$logLines = array_filter(explode("\n", $logContent));
|
|
$lineCount = count($logLines);
|
|
}
|
|
}
|
|
|
|
View::render('settings/logs.twig', [
|
|
'log_files' => $logFiles,
|
|
'selected_file' => $selectedFile,
|
|
'log_content' => $logContent,
|
|
'log_lines' => $logLines,
|
|
'line_count' => $lineCount,
|
|
'file_size' => $fileSize,
|
|
'section' => 'logs'
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Get list of available log files
|
|
*/
|
|
private function getLogFiles(): array {
|
|
$files = [];
|
|
|
|
if (!is_dir(self::LOGS_DIR)) {
|
|
return $files;
|
|
}
|
|
|
|
$dirContents = @scandir(self::LOGS_DIR, SCANDIR_SORT_DESCENDING);
|
|
|
|
if ($dirContents === false) {
|
|
return $files;
|
|
}
|
|
|
|
foreach ($dirContents as $filename) {
|
|
// Skip . and ..
|
|
if ($filename === '.' || $filename === '..') {
|
|
continue;
|
|
}
|
|
|
|
$filePath = self::LOGS_DIR . '/' . $filename;
|
|
|
|
// Only regular files
|
|
if (!is_file($filePath)) {
|
|
continue;
|
|
}
|
|
|
|
// Check extension
|
|
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
|
if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
|
continue;
|
|
}
|
|
|
|
$size = filesize($filePath);
|
|
$modified = filemtime($filePath);
|
|
|
|
$files[] = [
|
|
'name' => $filename,
|
|
'path' => $filename,
|
|
'size' => $size,
|
|
'size_formatted' => $this->formatBytes($size),
|
|
'modified' => $modified,
|
|
'modified_formatted' => date('Y-m-d H:i:s', $modified),
|
|
'readable' => is_readable($filePath)
|
|
];
|
|
}
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* Read log file content (last N lines)
|
|
*/
|
|
private function readLogFile(string $filePath, int $maxLines = 1000): string {
|
|
if (!is_readable($filePath)) {
|
|
return '';
|
|
}
|
|
|
|
$fileSize = filesize($filePath);
|
|
|
|
// If file is small enough, read completely
|
|
if ($fileSize <= self::MAX_FILE_SIZE) {
|
|
return file_get_contents($filePath) ?: '';
|
|
}
|
|
|
|
// For large files, read last N lines
|
|
$handle = fopen($filePath, 'r');
|
|
if ($handle === false) {
|
|
return '';
|
|
}
|
|
|
|
// Seek to end and read backwards
|
|
fseek($handle, 0, SEEK_END);
|
|
$lines = [];
|
|
$lineCount = 0;
|
|
|
|
while ($lineCount < $maxLines && ftell($handle) > 0) {
|
|
$chunk = '';
|
|
$pos = ftell($handle);
|
|
|
|
// Read backwards in chunks
|
|
$chunkSize = min(4096, $pos);
|
|
fseek($handle, -$chunkSize, SEEK_CUR);
|
|
$chunk = fread($handle, $chunkSize);
|
|
fseek($handle, -$chunkSize, SEEK_CUR);
|
|
|
|
$parts = explode("\n", $chunk);
|
|
$lines = array_merge($parts, $lines);
|
|
$lineCount = count($lines);
|
|
|
|
if ($pos <= $chunkSize) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
fclose($handle);
|
|
|
|
// Take last N lines and rejoin
|
|
$lines = array_slice($lines, -$maxLines);
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
/**
|
|
* Download log file
|
|
* GET /admin/logs/download?file=filename
|
|
*/
|
|
public function download() {
|
|
requireAdmin();
|
|
|
|
$file = $_GET['file'] ?? null;
|
|
|
|
if (!$file || !$this->isValidLogFile($file)) {
|
|
http_response_code(400);
|
|
echo 'Invalid file';
|
|
return;
|
|
}
|
|
|
|
$filePath = self::LOGS_DIR . '/' . basename($file);
|
|
|
|
if (!file_exists($filePath) || !is_readable($filePath)) {
|
|
http_response_code(404);
|
|
echo 'File not found';
|
|
return;
|
|
}
|
|
|
|
header('Content-Type: text/plain; charset=utf-8');
|
|
header('Content-Disposition: attachment; filename="' . basename($file) . '"');
|
|
header('Content-Length: ' . filesize($filePath));
|
|
|
|
readfile($filePath);
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Delete log file
|
|
* POST /admin/logs/delete
|
|
*/
|
|
public function delete() {
|
|
requireAdmin();
|
|
|
|
header('Content-Type: application/json');
|
|
|
|
$file = $_POST['file'] ?? null;
|
|
|
|
if (!$file || !$this->isValidLogFile($file)) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'message' => 'Invalid file']);
|
|
return;
|
|
}
|
|
|
|
$filePath = self::LOGS_DIR . '/' . basename($file);
|
|
|
|
if (!file_exists($filePath)) {
|
|
http_response_code(404);
|
|
echo json_encode(['success' => false, 'message' => 'File not found']);
|
|
return;
|
|
}
|
|
|
|
if (@unlink($filePath)) {
|
|
echo json_encode([
|
|
'success' => true,
|
|
'message' => 'Log file deleted successfully',
|
|
'redirect' => '/admin/logs'
|
|
]);
|
|
} else {
|
|
http_response_code(500);
|
|
echo json_encode(['success' => false, 'message' => 'Failed to delete file']);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all log files
|
|
* POST /admin/logs/clear-all
|
|
*/
|
|
public function clearAll() {
|
|
requireAdmin();
|
|
|
|
header('Content-Type: application/json');
|
|
|
|
$logFiles = $this->getLogFiles();
|
|
$deleted = 0;
|
|
$failed = 0;
|
|
|
|
foreach ($logFiles as $file) {
|
|
$filePath = self::LOGS_DIR . '/' . $file['path'];
|
|
if (@unlink($filePath)) {
|
|
$deleted++;
|
|
} else {
|
|
$failed++;
|
|
}
|
|
}
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'deleted' => $deleted,
|
|
'failed' => $failed,
|
|
'message' => "Deleted $deleted log files" . ($failed > 0 ? ", failed to delete $failed" : ''),
|
|
'redirect' => '/admin/logs'
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Search logs
|
|
* POST /admin/logs/search
|
|
*/
|
|
public function search() {
|
|
requireAdmin();
|
|
|
|
header('Content-Type: application/json');
|
|
|
|
$query = $_POST['query'] ?? '';
|
|
$file = $_POST['file'] ?? null;
|
|
$caseSensitive = isset($_POST['case_sensitive']) && $_POST['case_sensitive'] === 'on';
|
|
|
|
if (empty($query) || strlen($query) < 2) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'message' => 'Search query too short']);
|
|
return;
|
|
}
|
|
|
|
if (!$file || !$this->isValidLogFile($file)) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'message' => 'Invalid file']);
|
|
return;
|
|
}
|
|
|
|
$filePath = self::LOGS_DIR . '/' . basename($file);
|
|
|
|
if (!file_exists($filePath) || !is_readable($filePath)) {
|
|
http_response_code(404);
|
|
echo json_encode(['success' => false, 'message' => 'File not found']);
|
|
return;
|
|
}
|
|
|
|
$content = file_get_contents($filePath);
|
|
if ($content === false) {
|
|
http_response_code(500);
|
|
echo json_encode(['success' => false, 'message' => 'Failed to read file']);
|
|
return;
|
|
}
|
|
|
|
$lines = explode("\n", $content);
|
|
$results = [];
|
|
$flags = $caseSensitive ? 0 : PREG_GREP_INVERT;
|
|
|
|
foreach ($lines as $lineNum => $line) {
|
|
if (empty($line)) {
|
|
continue;
|
|
}
|
|
|
|
// Case-sensitive or case-insensitive search
|
|
$matches = $caseSensitive
|
|
? (strpos($line, $query) !== false)
|
|
: (stripos($line, $query) !== false);
|
|
|
|
if ($matches) {
|
|
$results[] = [
|
|
'line' => $lineNum + 1,
|
|
'content' => $line
|
|
];
|
|
}
|
|
}
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'query' => $query,
|
|
'results_count' => count($results),
|
|
'results' => array_slice($results, 0, 100) // Limit to 100 results
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Get log statistics
|
|
* POST /admin/logs/stats
|
|
*/
|
|
public function stats() {
|
|
requireAdmin();
|
|
|
|
header('Content-Type: application/json');
|
|
|
|
$file = $_POST['file'] ?? null;
|
|
|
|
if (!$file || !$this->isValidLogFile($file)) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'message' => 'Invalid file']);
|
|
return;
|
|
}
|
|
|
|
$filePath = self::LOGS_DIR . '/' . basename($file);
|
|
|
|
if (!file_exists($filePath) || !is_readable($filePath)) {
|
|
http_response_code(404);
|
|
echo json_encode(['success' => false, 'message' => 'File not found']);
|
|
return;
|
|
}
|
|
|
|
$content = file_get_contents($filePath);
|
|
if ($content === false) {
|
|
http_response_code(500);
|
|
echo json_encode(['success' => false, 'message' => 'Failed to read file']);
|
|
return;
|
|
}
|
|
|
|
$lines = array_filter(explode("\n", $content));
|
|
$errorCount = count(preg_grep('/error|exception|fatal|fail/i', $lines));
|
|
$warningCount = count(preg_grep('/warning|warn/i', $lines));
|
|
$successCount = count(preg_grep('/success|completed|ok/i', $lines));
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'total_lines' => count($lines),
|
|
'errors' => $errorCount,
|
|
'warnings' => $warningCount,
|
|
'success' => $successCount,
|
|
'file_size' => filesize($filePath),
|
|
'file_size_formatted' => $this->formatBytes(filesize($filePath)),
|
|
'last_modified' => date('Y-m-d H:i:s', filemtime($filePath))
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Validate log file name
|
|
*/
|
|
private function isValidLogFile(string $filename): bool {
|
|
// Prevent directory traversal
|
|
if (strpos($filename, '/') !== false || strpos($filename, '\\') !== false) {
|
|
return false;
|
|
}
|
|
|
|
if (strpos($filename, '..') !== false) {
|
|
return false;
|
|
}
|
|
|
|
// Check extension
|
|
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
|
if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Format bytes to human readable
|
|
*/
|
|
private function formatBytes(int $bytes, int $precision = 2): string {
|
|
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
|
|
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
|
|
$bytes /= 1024;
|
|
}
|
|
|
|
return round($bytes, $precision) . ' ' . $units[$i];
|
|
}
|
|
}
|