diff --git a/root/etc/mailstats/db.php b/root/etc/mailstats/db.php new file mode 100644 index 0000000..2752475 --- /dev/null +++ b/root/etc/mailstats/db.php @@ -0,0 +1,7 @@ + 'localhost', + 'user' => 'mailstats', //Should be mailstat-ro + 'pass' => 'mailstats', //Will be randon strong password + 'name' => 'mailstats', +]; \ No newline at end of file diff --git a/root/opt/mailstats/html/showSummaryLogs.php b/root/opt/mailstats/html/showSummaryLogs.php index 9be82d0..061155f 100644 --- a/root/opt/mailstats/html/showSummaryLogs.php +++ b/root/opt/mailstats/html/showSummaryLogs.php @@ -1,102 +1,190 @@ connect_error) { - die("Connection failed: " . $conn->connect_error); +// Set security headers (must be sent before output) +header('Content-Type: text/html; charset=UTF-8'); +header("Content-Security-Policy: default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; object-src 'none'; frame-ancestors 'none'"); +header('X-Content-Type-Options: nosniff'); +header('Referrer-Policy: no-referrer'); +header('Permissions-Policy: geolocation=(), microphone=(), camera=()'); +header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); +header('Pragma: no-cache'); +if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') { + header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); } -// Prepare and execute the query -if ($hour == 99){ - $sql = "SELECT * FROM SummaryLogs WHERE Date = ?"; - $stmt = $conn->prepare($sql); - $stmt->bind_param("s", $date); -} else { - $sql = "SELECT * FROM SummaryLogs WHERE Date = ? AND Hour = ?"; - $stmt = $conn->prepare($sql); - $stmt->bind_param("si", $date, $hour); +// Helper for safe HTML encoding +function e($s) { + return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +// Configuration: read DB credentials from environment +$servername = getenv('MAILSTATS_DB_HOST') ?: ''; +$username = getenv('MAILSTATS_DB_USER') ?: ''; +$password = getenv('MAILSTATS_DB_PASS') ?: ''; +$dbname = getenv('MAILSTATS_DB_NAME') ?: ''; + +// Otherwise try config in /etc/mailstats +if ($username === '' || $password === '' || $dbname === '') { + $cfgPath = '/etc/mailstats/db.php'; + if (is_readable($cfgPath)) { + $cfg = include $cfgPath; + $servername = $cfg['host'] ?? $servername ?: 'localhost'; + $username = $cfg['user'] ?? $username; + $password = $cfg['pass'] ?? $password; + $dbname = $cfg['name'] ?? $dbname; + } +} + +// Fail fast if credentials are not provided via environment +if ($username === '' || $password === '' || $dbname === '') { + error_log('Configuration error: DB credentials not set via environment.'); + http_response_code(500); + exit('Service temporarily unavailable.'); +} + +// Robust input handling +$defaultDate = date('Y-m-d', strtotime('-1 day')); +$date = isset($_GET['date']) ? $_GET['date'] : $defaultDate; +if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + http_response_code(400); + exit('Invalid date'); +} + +// hour: allow 0–23 or special 99 meaning “all hours” +$hour = isset($_GET['hour']) ? filter_var($_GET['hour'], FILTER_VALIDATE_INT) : 99; +if ($hour === false || ($hour !== 99 && ($hour < 0 || $hour > 23))) { + http_response_code(400); + exit('Invalid hour'); +} + +// Pagination +$page = isset($_GET['page']) ? filter_var($_GET['page'], FILTER_VALIDATE_INT) : 1; +if ($page === false || $page < 1) { $page = 1; } +$pageSize = isset($_GET['page_size']) ? filter_var($_GET['page_size'], FILTER_VALIDATE_INT) : 50; +if ($pageSize === false) { $pageSize = 50; } +// Bound page size to prevent huge result sets +if ($pageSize < 1) { $pageSize = 1; } +if ($pageSize > 100) { $pageSize = 100; } +$limit = $pageSize; +$offset = ($page - 1) * $pageSize; + +// Use mysqli with exceptions and UTF-8 +mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); +try { + $conn = new mysqli($servername, $username, $password, $dbname); + $conn->set_charset('utf8mb4'); +} catch (mysqli_sql_exception $e) { + error_log('DB connect failed: ' . $e->getMessage()); + http_response_code(500); + exit('Service temporarily unavailable.'); +} + +// Build WHERE clause and bind parameters safely +$where = 'Date = ?'; +$bindTypesCount = 's'; +$bindValuesCount = [$date]; + +if ($hour !== 99) { + $where .= ' AND Hour = ?'; + $bindTypesCount .= 'i'; + $bindValuesCount[] = $hour; +} + +// Count query for total rows (for display/pagination info) +try { + $sqlCount = "SELECT COUNT(*) AS total FROM SummaryLogs WHERE $where"; + $stmtCount = $conn->prepare($sqlCount); + $stmtCount->bind_param($bindTypesCount, ...$bindValuesCount); + $stmtCount->execute(); + $resultCount = $stmtCount->get_result(); + $rowCount = $resultCount->fetch_assoc(); + $totalRows = (int)$rowCount['total']; + $stmtCount->close(); +} catch (mysqli_sql_exception $e) { + error_log('Count query failed: ' . $e->getMessage()); + http_response_code(500); + exit('Service temporarily unavailable.'); +} + +// Data query with ORDER and LIMIT/OFFSET +try { + $sql = "SELECT id, logData FROM SummaryLogs WHERE $where ORDER BY id DESC LIMIT ? OFFSET ?"; + // Bind types: existing where types + limit (i) + offset (i) + $bindTypesData = $bindTypesCount . 'ii'; + $bindValuesData = $bindValuesCount; + $bindValuesData[] = $limit; + $bindValuesData[] = $offset; + + $stmt = $conn->prepare($sql); + $stmt->bind_param($bindTypesData, ...$bindValuesData); + $stmt->execute(); + $result = $stmt->get_result(); +} catch (mysqli_sql_exception $e) { + error_log('Data query failed: ' . $e->getMessage()); + http_response_code(500); + exit('Service temporarily unavailable.'); } -$stmt->execute(); -$result = $stmt->get_result(); -$result_count = $result->num_rows; function generateLogDataTable($logData) { - $data = json_decode($logData, true); - if (is_null($data)) { - return "Invalid JSON data"; - } - - //// Remove entries with the key "logterse" - //if (isset($data['logterse'])) { - //unset($data['logterse']); - //} + // Defensive decode with substitution for invalid UTF-8 + $data = json_decode($logData, true, 512, JSON_INVALID_UTF8_SUBSTITUTE); - // Remove entries with the key "logterse" and remove entries with empty values + if (!is_array($data)) { + return 'Invalid JSON data'; + } + + // Remove entries with key 'logterse' and entries with empty values foreach ($data as $key => $value) { - if ($key === 'logterse' || empty($value)) { + if ($key === 'logterse' || $value === '' || $value === null) { unset($data[$key]); } } - // Handle adjacent duplicates by merging keys + // Merge adjacent duplicates by value $mergedData = []; $previousValue = null; foreach ($data as $key => $value) { - if ($value === $previousValue) { - // Merge the current key with the previous key + // Normalize non-scalar values for display + if (is_array($value) || is_object($value)) { + $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + $valueStr = (string)$value; + + if ($valueStr === $previousValue) { end($mergedData); $lastKey = key($mergedData); - $newKey = "$lastKey/$key"; - $mergedData[$newKey] = $value; - // Remove the old entry + $newKey = $lastKey . '/' . $key; + $mergedData[$newKey] = $valueStr; unset($mergedData[$lastKey]); } else { - // Otherwise, add a new entry - $mergedData[$key] = $value; + $mergedData[$key] = $valueStr; + } + $previousValue = $valueStr; + } + + // Optional truncation to keep rendering safe + $maxValueLen = 500; + foreach ($mergedData as $k => $v) { + if (mb_strlen($v, 'UTF-8') > $maxValueLen) { + $mergedData[$k] = mb_substr($v, 0, $maxValueLen, 'UTF-8') . '…'; } - $previousValue = $value; } - $keys = array_keys($mergedData); $values = array_values($mergedData); - - $output = ''; - #$output = '
'; + + $output = '
'; // Divide keys and values into sets of 6 $chunks = array_chunk($keys, 6); foreach ($chunks as $chunkIndex => $chunk) { - if ($chunkIndex > 0) { - // Add spacing between different sets - #$output .= ''; - } - $output .= ''; foreach ($chunk as $key) { - $output .= ''; + $output .= ''; } $output .= ''; foreach ($chunk as $i => $key) { - $val = htmlspecialchars($values[$chunkIndex * 6+ $i]); - if ($key == 'id'){ - $output .= '"; - } else { - $output .= ''; - } + $val = $values[$chunkIndex * 6 + $i]; + $output .= ''; } $output .= ''; } @@ -106,61 +194,87 @@ function generateLogDataTable($logData) { } ?> - - Summary Logs - + -
" -

Summary Logs for Date:

-

Found records.

-
' . htmlspecialchars($key) . '' . e($key) . '
' . "".$val."' . $val . '' . e($val) . '
- - - - - - - - - - num_rows > 0): ?> - fetch_assoc()): ?> - - - - - - +
+

+ Summary Logs for Date: + +

+ 0 ? ($offset + 1) : 0; + $endRow = min($offset + $limit, $totalRows); + ?> +

Found records. Showing .

+ +
IdLog Data
+ - + + + + + + num_rows > 0): ?> + fetch_assoc()): ?> + + + + + + + + + + + + + +
No records found for the specified date and hour.IdDetailsLog Data
View details
No records found for the specified date and hour.
+ + $date, + 'hour' => $hour, + 'page_size' => $pageSize + ]; + $prevPage = $page > 1 ? $page - 1 : null; + $nextPage = ($offset + $limit) < $totalRows ? $page + 1 : null; + ?> +
+ + + « Previous - - + + + + | + Next » + +
close(); - $conn->close(); + // Clean up + if (isset($stmt) && $stmt instanceof mysqli_stmt) { $stmt->close(); } + if (isset($conn) && $conn instanceof mysqli) { $conn->close(); } ?> - + \ No newline at end of file diff --git a/smeserver-mailstats.spec b/smeserver-mailstats.spec index 526d592..de8bbd1 100644 --- a/smeserver-mailstats.spec +++ b/smeserver-mailstats.spec @@ -6,7 +6,7 @@ Summary: Daily mail statistics for SME Server %define name smeserver-mailstats Name: %{name} %define version 11.1 -%define release 4 +%define release 5 Version: %{version} Release: %{release}%{?dist} License: GPL @@ -35,6 +35,11 @@ A script that via cron.d e-mails mail statistics to admin on a daily basis. See http://www.contribs.org/bugzilla/show_bug.cgi?id=819 %changelog +* Tue Sep 02 2025 Brian Read 11.1-5.sme +- Speed up Journal access [SME: 13121] +- Fix missing blacklist URL [SME: 13121] + + * Mon Sep 01 2025 Brian Read 11.1-4.sme - More fixes for Journal bytes instead of characters [SME: 13117]