mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-09-22 18:45:43 -04:00
The Log view was the only way to read a log, and its toolbar, its bootstrap-table and the script behind them were written into the view, so a second view could only have a log by copying all three. Move the markup to skins/classic/includes/logpanel.php as getLogPanelHTML(), and the behaviour to skins/classic/js/logpanel.js, which picks up every .logPanel in the page and drives it. The script keeps its request state, its selection and its in-flight XHR in a closure, so two panels on one page no longer share them; the table gets its ajax function from that closure instead of the global that data-ajax="ajaxRequest" resolved to. Options cover what the call sites differ on: the element id prefix, a fixed set of components, the page size and whether the Back and Refresh page buttons are shown. A panel locked to components sends embedded=1, and ajax/log.php then leaves zmLogComponent and zmLogFilterLevel alone, so an embedded panel cannot overwrite what the user chose in the Log view. Such a panel also drops the component selector and column, and the unfiltered total, which counts every log row and means nothing once the panel is scoped. The level colours and the table's column rules move from the per-view log.css files to the matching skin.css, since they now apply wherever a panel is embedded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
305 lines
11 KiB
PHP
305 lines
11 KiB
PHP
<?php
|
|
$data = array();
|
|
$message = '';
|
|
|
|
//
|
|
// INITIALIZE AND CHECK SANITY
|
|
//
|
|
|
|
// task must be set
|
|
if (!isset($_REQUEST['task'])) {
|
|
$message = 'This request requires a task to be set';
|
|
} else if ($_REQUEST['task'] == 'query') {
|
|
if (!canView('System')) {
|
|
$message = 'Insufficient permissions to view log entries for user '.validHtmlStr($user->Username());
|
|
} else {
|
|
$data = queryRequest();
|
|
}
|
|
} else if ($_REQUEST['task'] == 'create' ) {
|
|
global $user;
|
|
if (!$user or (!canEdit('System') and !ZM_LOG_INJECT)) {
|
|
$message = 'Insufficient permissions to create log entries for user '.validHtmlStr($user->Username());
|
|
} else {
|
|
createRequest();
|
|
}
|
|
} else if ($_REQUEST['task'] == 'delete') {
|
|
global $user;
|
|
if (!canEdit('System')) {
|
|
$message = 'Insufficient permissions to delete log entries for user '.validHtmlStr($user->Username());
|
|
} else {
|
|
if (!empty($_REQUEST['ids'])) {
|
|
$ids = array_map('intval', (array)$_REQUEST['ids']);
|
|
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
|
dbQuery('DELETE FROM Logs WHERE Id IN (' . $placeholders . ')', $ids);
|
|
}
|
|
}
|
|
} else {
|
|
// Only the query and create tasks are supported at the moment
|
|
$message = 'Unrecognised task '.validHtmlStr($_REQUEST['task']);
|
|
}
|
|
|
|
if ($message) {
|
|
ajaxError($message);
|
|
return;
|
|
}
|
|
ajaxResponse($data);
|
|
|
|
//
|
|
// FUNCTION DEFINITIONS
|
|
//
|
|
|
|
function createRequest() {
|
|
// Every field here is attacker-controlled. Coerce each to a scalar and strip
|
|
// control characters (newlines, carriage returns, tabs, etc.) so nothing can
|
|
// forge additional lines in the text log file, and so non-scalar input (e.g.
|
|
// message[]=x) cannot trip a TypeError in logPrint(). A log message is a
|
|
// single line; the Log view stores it as one Message column regardless.
|
|
$sanitize = function($value) {
|
|
return is_scalar($value) ? preg_replace('/[\x00-\x1F\x7F]+/', ' ', (string)$value) : '';
|
|
};
|
|
|
|
$level = $sanitize($_POST['level'] ?? '');
|
|
$message = $sanitize($_POST['message'] ?? '');
|
|
// Strip the URL scheme/host from file, then the control characters.
|
|
$file = (isset($_POST['file']) && is_scalar($_POST['file']))
|
|
? $sanitize(preg_replace('/\w+:\/\/[\w.:]+\//', '', (string)$_POST['file']))
|
|
: '';
|
|
|
|
if ($level === '' || $message === '') {
|
|
ZM\Error('Invalid log create: level and message are required');
|
|
return;
|
|
}
|
|
|
|
ZM\logInit(array('id'=>'web_js'));
|
|
// Firefox reports the location as "line:column" (e.g. 1500:28). validInt()
|
|
// only strips non-digit characters, so it splices the two together into
|
|
// 150028, which overflows the smallint Logs.Line column and makes this
|
|
// endpoint die with a 500 -- the log request fails on the very error it was
|
|
// sent to report. Take the leading line number and clamp it to the column.
|
|
$line = NULL;
|
|
if (!empty($_POST['line']) and is_scalar($_POST['line'])
|
|
and preg_match('/\d+/', (string)$_POST['line'], $line_matches)) {
|
|
$line = min((int)$line_matches[0], 65535);
|
|
}
|
|
|
|
$levels = array_flip(ZM\Logger::$codes);
|
|
if (!isset($levels[$level])) {
|
|
ZM\Error('Unexpected logger level '.$level); // already sanitized above
|
|
$level = 'ERR';
|
|
}
|
|
ZM\Logger::fetch()->logPrint($levels[$level], $message, $file, $line);
|
|
}
|
|
|
|
function queryRequest() {
|
|
// Offset specifies the starting row to return, used for pagination
|
|
$offset = 0;
|
|
if (isset($_REQUEST['offset'])) {
|
|
if ((!is_int($_REQUEST['offset']) and !ctype_digit($_REQUEST['offset']))) {
|
|
ZM\Error('Invalid value for offset: ' . $_REQUEST['offset']);
|
|
} else {
|
|
$offset = $_REQUEST['offset'];
|
|
}
|
|
}
|
|
|
|
// Limit specifies the number of rows to return
|
|
$limit = 100;
|
|
if (isset($_REQUEST['limit'])) {
|
|
if ((!is_int($_REQUEST['limit']) and !ctype_digit($_REQUEST['limit']))) {
|
|
ZM\Error('Invalid value for limit: ' . $_REQUEST['limit']);
|
|
} else {
|
|
$limit = $_REQUEST['limit'];
|
|
}
|
|
}
|
|
// The table we want our data from
|
|
$table = 'Logs';
|
|
|
|
$nameMainQuery = 't1'; # To optimize queries using a subquery
|
|
$nameSubQuery = 't2';
|
|
|
|
// The names of the dB columns in the log table we are interested in
|
|
$columns = array('Id', 'TimeKey', 'Component', 'ServerId', 'Pid', 'Code', 'Message', 'File', 'Line');
|
|
$columnsContext = $columns;
|
|
// The names of columns shown in the log view that are NOT dB columns in the database
|
|
$col_alt = array('DateTime', 'Server');
|
|
|
|
$sort = 'TimeKey';
|
|
if (isset($_REQUEST['sort'])) {
|
|
$sort = $_REQUEST['sort'];
|
|
if ($sort == 'DateTime') $sort = 'TimeKey';
|
|
if ($sort == 'Server') $sort = 'ServerId';
|
|
}
|
|
if (!in_array($sort, array_merge($columns, $col_alt))) {
|
|
ZM\Error('Invalid sort field: ' . $sort);
|
|
return;
|
|
}
|
|
|
|
// Order specifies the sort direction, either asc or desc
|
|
$order = (isset($_REQUEST['order']) and (strtolower($_REQUEST['order']) == 'asc')) ? 'ASC' : 'DESC';
|
|
|
|
if ($nameMainQuery !== '' && $nameSubQuery !== '') {
|
|
array_walk($columnsContext, function(&$value, $key, $nameMainQuery) {
|
|
$value = $nameMainQuery . '.' . $value;
|
|
}, $nameMainQuery);
|
|
}
|
|
|
|
$col_str = implode(', ', $columns);
|
|
$col_str_context = implode(', ', $columnsContext);
|
|
$data = array();
|
|
$query = array();
|
|
$query['values'] = array();
|
|
$likes = array();
|
|
$where = '';
|
|
// There are two search bars in the log view, normal and advanced
|
|
// Making an exuctive decision to ignore the normal search, when advanced search is in use
|
|
// Alternatively we could try to do both
|
|
//
|
|
// Advanced search contains an array of "column name" => "search text" pairs
|
|
// Bootstrap table sends json_ecoded array, which we must decode
|
|
$advsearch = isset($_REQUEST['filter']) ? json_decode($_REQUEST['filter'], JSON_OBJECT_AS_ARRAY) : array();
|
|
// Search contains a user entered string to search on
|
|
$search = isset($_REQUEST['search']) ? $_REQUEST['search'] : '';
|
|
if (count($advsearch)) {
|
|
foreach ($advsearch as $col=>$text) {
|
|
if (!in_array($col, array_merge($columns, $col_alt))) {
|
|
ZM\Error("'$col' is not a searchable column name");
|
|
continue;
|
|
}
|
|
// Don't use wildcards on advanced search
|
|
//$text = '%' .$text. '%';
|
|
array_push($likes, $col.' LIKE ?');
|
|
array_push($query['values'], $text);
|
|
}
|
|
$where = '(' .implode(' OR ', $likes). ')';
|
|
|
|
} else if ($search != '') {
|
|
$search = '%' .$search. '%';
|
|
foreach ( $columns as $col ) {
|
|
array_push($likes, $col.' LIKE ?');
|
|
array_push($query['values'], $search);
|
|
}
|
|
$where = '(' .implode(' OR ', $likes). ')';
|
|
}
|
|
|
|
// Component is a multi-select: accept a scalar (legacy) or an array of
|
|
// component names and match any of them.
|
|
$requestComponents = array();
|
|
if (isset($_REQUEST['Component'])) {
|
|
foreach ((array)$_REQUEST['Component'] as $component) {
|
|
if (is_scalar($component) && (string)$component !== '') $requestComponents[] = (string)$component;
|
|
}
|
|
}
|
|
if (count($requestComponents)) {
|
|
if ($where) $where .= ' AND ';
|
|
$placeholders = implode(', ', array_fill(0, count($requestComponents), '?'));
|
|
$where .= 'Component IN (' . $placeholders . ')';
|
|
foreach ($requestComponents as $component) $query['values'][] = $component;
|
|
}
|
|
|
|
if (!empty($_REQUEST['ServerId'])) {
|
|
if ($where) $where .= ' AND ';
|
|
$where .= 'ServerId = ?';
|
|
$query['values'][] = $_REQUEST['ServerId'];
|
|
}
|
|
/* We have an indexed 'Level', not 'Code'.
|
|
if (!empty($_REQUEST['level'])) {
|
|
if ($where) $where .= ' AND ';
|
|
$where .= 'Code = ?';
|
|
$query['values'][] = $_REQUEST['level'];
|
|
}
|
|
*/
|
|
// Level is a multi-select: accept a scalar (legacy) or an array of level codes
|
|
// ('DBG', 'INF', ...). Keep only recognized codes and match any of them.
|
|
$level_codes = array_flip(ZM\Logger::$codes);
|
|
$requestLevels = array();
|
|
if (isset($_REQUEST['level'])) {
|
|
foreach ((array)$_REQUEST['level'] as $level) {
|
|
if (is_scalar($level) && isset($level_codes[(string)$level])) {
|
|
$requestLevels[(string)$level] = $level_codes[(string)$level];
|
|
}
|
|
}
|
|
}
|
|
if (count($requestLevels)) {
|
|
if ($where) $where .= ' AND ';
|
|
$placeholders = implode(', ', array_fill(0, count($requestLevels), '?'));
|
|
$where .= ' Level IN (' . $placeholders . ')';
|
|
foreach ($requestLevels as $code) $query['values'][] = $code;
|
|
}
|
|
|
|
if (!empty($_REQUEST['StartDateTime'])) {
|
|
$start_time = strtotime($_REQUEST['StartDateTime']);
|
|
if ($start_time) {
|
|
if ($where) $where .= ' AND ';
|
|
$where .= 'TimeKey >= ?';
|
|
$query['values'][] = $start_time;
|
|
} else {
|
|
ZM\Warning("Unable to parse StartDateTime ".$_REQUEST['StartDateTime']. " into a timestamp");
|
|
}
|
|
}
|
|
if (!empty($_REQUEST['EndDateTime'])) {
|
|
$end_time = strtotime($_REQUEST['EndDateTime']);
|
|
if ($end_time) {
|
|
if ($where) $where .= ' AND ';
|
|
$where .= 'TimeKey <= ?';
|
|
$query['values'][] = $end_time;
|
|
} else {
|
|
ZM\Warning("Unable to parse EndDateTime ".$_REQUEST['EndDateTime']. " into a timestamp");
|
|
}
|
|
}
|
|
|
|
# An embedded panel queries a component of its own choosing, so remembering
|
|
# what it asked for would overwrite the Log view's selection.
|
|
if (empty($_REQUEST['embedded'])) {
|
|
zm_session_start();
|
|
$_SESSION['zmLogComponent'] = $requestComponents;
|
|
$_SESSION['zmLogFilterLevel'] = array_keys($requestLevels);
|
|
session_write_close();
|
|
}
|
|
|
|
if ($where) $where = ' WHERE '.$where;
|
|
|
|
$data['totalNotFiltered'] = dbFetchOne('SELECT count(*) AS Total FROM `' .$table.'`', 'Total');
|
|
if ($where) {
|
|
$data['total'] = dbFetchOne('SELECT count(*) AS Total FROM `' .$table.'` '.$where, 'Total', $query['values']);
|
|
} else {
|
|
$data['total'] = $data['totalNotFiltered'];
|
|
}
|
|
|
|
if ($nameMainQuery !== '' && $nameSubQuery !== '') { # Optimized query
|
|
$query['sql'] = '
|
|
SELECT ' .$col_str_context. '
|
|
FROM `' .$table. '` ' .$nameMainQuery. '
|
|
JOIN (
|
|
SELECT Id
|
|
FROM `'.$table.'` '.$where. '
|
|
ORDER BY ' .$sort. ' ' .$order. '
|
|
LIMIT ?, ?
|
|
) AS ' .$nameSubQuery. '
|
|
ON ' .$nameMainQuery. '.Id=' .$nameSubQuery. '.Id
|
|
ORDER BY ' .$nameMainQuery. '.' .$sort. ' ' .$order;
|
|
} else {
|
|
$query['sql'] = 'SELECT ' .$col_str. ' FROM `' .$table. '` ' .$where. ' ORDER BY ' .$sort. ' ' .$order. ' LIMIT ?, ?';
|
|
}
|
|
|
|
array_push($query['values'], $offset, $limit);
|
|
|
|
$rows = array();
|
|
$results = dbFetchAll($query['sql'], NULL, $query['values']);
|
|
|
|
global $dateTimeFormatter;
|
|
foreach ($results as $row) {
|
|
$row['DateTime'] = empty($row['TimeKey']) ? '' : $dateTimeFormatter->format(intval($row['TimeKey']));
|
|
$Server = $row['ServerId'] ? ZM\Server::find_one(array('Id'=>$row['ServerId'])) : null;
|
|
|
|
$row['Server'] = $Server ? $Server->Name() : '';
|
|
// Strip out all characters that are not ASCII 32-126 (yes, 126)
|
|
$row['Message'] = preg_replace('/[^\x20-\x7E]/', '', htmlspecialchars($row['Message']));
|
|
$row['File'] = preg_replace('/[^\x20-\x7E]/', '', strip_tags($row['File']));
|
|
$rows[] = $row;
|
|
}
|
|
$data['rows'] = $rows;
|
|
$data['logstate'] = logState();
|
|
$data['updated'] = $dateTimeFormatter->format(time());
|
|
|
|
return $data;
|
|
}
|