mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-08-03 08:46:44 -04:00
web/ajax/events.php and the montagereview.php event query carried the same EndTimeSecs fallback just fixed in the Event API model: an event with no EndDateTime and Length 0 (an empty crash-orphaned event) reported its end as NOW(), so it appeared to span from its start until now and overlapped every later event. Change the ELSE branch of both the EndDateTime and EndTimeSecs CASE expressions from NOW() to StartDateTime, so an empty event has no span. Both columns are changed together so a single row stays consistent. Verified against the database: for a Length-0 event with no EndDateTime the CASE now yields EndTimeSecs == StartTimeSecs, while length-based and properly-closed events are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
410 lines
15 KiB
PHP
410 lines
15 KiB
PHP
<?php
|
|
ini_set('display_errors', '');
|
|
$message = '';
|
|
$data = array();
|
|
|
|
//
|
|
// INITIALIZE AND CHECK SANITY
|
|
//
|
|
|
|
if (!canView('Events'))
|
|
$message = 'Insufficient permissions for user '.validHtmlStr($user->Username()).'<br/>';
|
|
|
|
if (empty($_REQUEST['task'])) {
|
|
$message = 'Must specify a task<br/>';
|
|
} else {
|
|
$task = $_REQUEST['task'];
|
|
}
|
|
|
|
if (empty($_REQUEST['eids'])) {
|
|
if (isset($_REQUEST['task']) && $_REQUEST['task'] != 'query')
|
|
$message = 'No event id(s) supplied<br/>';
|
|
} else {
|
|
$eids = $_REQUEST['eids'];
|
|
}
|
|
|
|
if ($message) {
|
|
ajaxError($message);
|
|
return;
|
|
}
|
|
|
|
require_once('includes/Filter.php');
|
|
$filter = isset($_REQUEST['filter']) ? ZM\Filter::parse($_REQUEST['filter']) : new ZM\Filter();
|
|
if (count( $user->unviewableMonitorIds())) {
|
|
$filter = $filter->addTerm(array('cnj'=>'and', 'attr'=>'MonitorId', 'op'=>'IN', 'val'=>$user->viewableMonitorIds()));
|
|
// $filter = $filter->addTerm(array('cnj'=>'and', 'attr'=>'MonitorId', 'op'=>'IN', 'val'=>'5'));
|
|
}
|
|
// TODO: Why is $user->viewableMonitorIds() returning $user->unviewableMonitorIds()
|
|
// Error('$user->viewableMonitorIds(): '.print_r($user->viewableMonitorIds()));
|
|
if (!empty($_REQUEST['StartDateTime'])) {
|
|
$filter->addTerm(array('cnj'=>'and', 'attr'=>'StartDateTime', 'op'=> '>=', 'val'=>$_REQUEST['StartDateTime']));
|
|
}
|
|
if (!empty($_REQUEST['EndDateTime'])) {
|
|
$filter->addTerm(array('cnj'=>'and', 'attr'=>'EndDateTime', 'op'=> '<=', 'val'=>$_REQUEST['EndDateTime']));
|
|
}
|
|
if (!empty($_REQUEST['MonitorId'])) {
|
|
$filter->addTerm(array('cnj'=>'and', 'attr'=>'MonitorId', 'op'=> '=', 'val'=>$_REQUEST['MonitorId']));
|
|
}
|
|
if (!empty($_REQUEST['Tag'])) {
|
|
$filter->addTerm(array('cnj'=>'and', 'attr'=>'Tag', 'op'=>'=', 'val'=>$_REQUEST['Tag']));
|
|
}
|
|
|
|
// Search contains a user entered string to search on
|
|
$search = isset($_REQUEST['search']) ? $_REQUEST['search'] : '';
|
|
|
|
// Advanced search contains an array of "column name" => "search text" pairs
|
|
// Bootstrap table sends json_ecoded array, which we must decode
|
|
$advsearch = isset($_REQUEST['advsearch']) ? json_decode($_REQUEST['advsearch'], JSON_OBJECT_AS_ARRAY) : array();
|
|
|
|
// Order specifies the sort direction, either asc or desc
|
|
$order = $filter->sort_asc() ? 'ASC' : 'DESC';
|
|
if (isset($_REQUEST['order'])) {
|
|
if (strtolower($_REQUEST['order']) == 'asc') {
|
|
$order = 'ASC';
|
|
} else if (strtolower($_REQUEST['order']) == 'desc') {
|
|
$order = 'DESC';
|
|
} else {
|
|
Warning('Invalid value for order ' . $_REQUEST['order']);
|
|
}
|
|
}
|
|
|
|
// Sort specifies the name of the column to sort on
|
|
$sort = $filter->sort_field();
|
|
if (isset($_REQUEST['sort'])) {
|
|
$sort = $_REQUEST['sort'];
|
|
}
|
|
|
|
// Offset specifies the starting row to return, used for pagination
|
|
$offset = 0;
|
|
if (isset($_REQUEST['offset']) and ($_REQUEST['offset'] != 'NaN')) {
|
|
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
|
|
// Set the default to 0 for events view, to prevent an issue with ALL pagination
|
|
$limit = 0;
|
|
if (isset($_REQUEST['limit']) and ($_REQUEST['limit'] != 'NaN')) {
|
|
if ((!is_int($_REQUEST['limit']) and !ctype_digit($_REQUEST['limit']))) {
|
|
ZM\Error('Invalid value for limit: ' . $_REQUEST['limit']);
|
|
} else {
|
|
$limit = $_REQUEST['limit'];
|
|
}
|
|
}
|
|
|
|
//
|
|
// MAIN LOOP
|
|
//
|
|
|
|
switch ($task) {
|
|
case 'archive' :
|
|
foreach ($eids as $eid) archiveRequest($task, $eid);
|
|
break;
|
|
case 'unarchive' :
|
|
# The idea is that anyone can archive, but only people with Event Edit permission can unarchive..
|
|
if (!canEdit('Events')) {
|
|
ajaxError('Insufficient permissions for user '.validHtmlStr($user->Username()));
|
|
return;
|
|
}
|
|
foreach ($eids as $eid) archiveRequest($task, $eid);
|
|
break;
|
|
case 'delete' :
|
|
if (!canEdit('Events')) {
|
|
ajaxError('Insufficient permissions for user '.validHtmlStr($user->Username()));
|
|
return;
|
|
}
|
|
foreach ($eids as $eid) {
|
|
$message = deleteRequest($eid);
|
|
if ($message) {
|
|
if (empty($data['message'])) $data['message'] = [];
|
|
$data['message'][] = $message;
|
|
}
|
|
}
|
|
break;
|
|
case 'query' :
|
|
$data = queryRequest($filter, $search, $advsearch, $sort, $offset, $order, $limit);
|
|
break;
|
|
default :
|
|
ajaxError("Unrecognised task '".validHtmlStr($task)."'");
|
|
} // end switch task
|
|
|
|
ajaxResponse($data);
|
|
|
|
//
|
|
// FUNCTION DEFINITIONS
|
|
//
|
|
|
|
function archiveRequest($task, $eid) {
|
|
$archiveVal = ($task == 'archive') ? 1 : 0;
|
|
dbQuery(
|
|
'UPDATE Events SET Archived = ? WHERE Id = ?',
|
|
array($archiveVal, $eid)
|
|
);
|
|
}
|
|
|
|
function deleteRequest($eid) {
|
|
$event = new ZM\Event($eid);
|
|
if (!$event->Id()) {
|
|
return 'Event '.$eid.' not found.';
|
|
} else if ( $event->Archived() ) {
|
|
return 'Event '.$eid.' is archived, cannot delete it.';
|
|
} else if (!$event->canEdit()) {
|
|
return 'You do not have permission to delete event '.$event->Id();
|
|
} else {
|
|
$event->delete();
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function queryRequest($filter, $search, $advsearch, $sort, $offset, $order, $limit) {
|
|
global $dateTimeFormatter;
|
|
$data = array(
|
|
'total' => 0,
|
|
'totalNotFiltered' => 0,
|
|
'rows' => array(),
|
|
'updated' => $dateTimeFormatter->format(time())
|
|
);
|
|
|
|
if (!$filter->test_pre_sql_conditions()) {
|
|
ZM\Debug('Pre conditions failed, not doing sql');
|
|
return $data;
|
|
}
|
|
|
|
// Put server pagination code here
|
|
// The table we want our data from
|
|
$table = 'Events';
|
|
|
|
// The names of the dB columns in the events table we are interested in
|
|
$columns = array('Id', 'MonitorId', 'StorageId', 'Name', 'Cause', 'StartDateTime', 'EndDateTime', 'Length', 'Frames', 'AlarmFrames', 'TotScore', 'AvgScore', 'MaxScore', 'Archived', 'Emailed', 'Notes', 'DiskSpace');
|
|
|
|
// The names of columns shown in the event view that are NOT dB columns in the database
|
|
$col_alt = array('Monitor', 'Tags', 'Storage');
|
|
|
|
if ( $sort != '' ) {
|
|
// Canonicalize the global direction once so the EndDateTime rewrite below
|
|
// (which branches on $order) and buildSortSql see the same value.
|
|
$order = strtoupper(trim($order));
|
|
// Resolve a whitelisted event column name to its SQL (alias), or null.
|
|
$whitelist = array_merge($columns, $col_alt);
|
|
$resolve = function($col) use ($whitelist) {
|
|
if (!in_array($col, $whitelist)) return null;
|
|
if ($col == 'Tags') return 'Tags';
|
|
if ($col == 'Monitor') return 'M.Name';
|
|
return 'E.'.$col;
|
|
};
|
|
// Implicit NULLs-last rewrite when sorting solely by EndDateTime, so events
|
|
// without a recorded end (zmc crashed) don't bunch unpredictably. Emitted
|
|
// with explicit directions so the IS NULL key keeps ASC ordering even when
|
|
// the global order is DESC, reproducing the historical SQL.
|
|
if (trim($sort) == 'EndDateTime') {
|
|
$sort = ($order == 'ASC')
|
|
? 'EndDateTime IS NULL ASC, EndDateTime ASC'
|
|
: 'EndDateTime IS NOT NULL ASC, EndDateTime DESC';
|
|
}
|
|
// Build the per-part directional ORDER BY body. Parts without an explicit
|
|
// ASC/DESC inherit $order. An invalid/non-whitelisted spec yields '' and is
|
|
// dropped (no ORDER BY) rather than risking an injected fragment.
|
|
$sort = ZM\Filter::buildSortSql($sort, $order, $resolve);
|
|
if ($sort === '') {
|
|
ZM\Warning('Invalid sort field, ignoring');
|
|
}
|
|
}
|
|
|
|
$values = array();
|
|
$likes = array();
|
|
// ZM\Error($filter->sql());
|
|
$where = $filter->sql()?' WHERE ('.$filter->sql().')' : '';
|
|
$has_post_sql_conditions = count($filter->post_sql_conditions());
|
|
|
|
|
|
// For events that never wrote EndDateTime (zmc killed/crashed mid-event),
|
|
// fall back to StartDateTime + Length. Length is flushed to the DB every
|
|
// few seconds during recording, so it reflects the actual recorded
|
|
// duration even when zmc died without closing the event. When Length is 0
|
|
// too (an empty crash-orphaned event), fall back to StartDateTime so the
|
|
// event has no span; NOW() would otherwise extend it across all the
|
|
// down-time and overlap every later event.
|
|
$col_str = '
|
|
E.*,
|
|
UNIX_TIMESTAMP(E.StartDateTime) AS StartTimeSecs,
|
|
CASE
|
|
WHEN E.EndDateTime IS NOT NULL THEN E.EndDateTime
|
|
WHEN E.Length > 0 THEN DATE_ADD(E.StartDateTime, INTERVAL FLOOR(E.Length) SECOND)
|
|
ELSE E.StartDateTime
|
|
END AS EndDateTime,
|
|
CASE
|
|
WHEN E.EndDateTime IS NOT NULL THEN UNIX_TIMESTAMP(E.EndDateTime)
|
|
WHEN E.Length > 0 THEN UNIX_TIMESTAMP(E.StartDateTime) + E.Length
|
|
ELSE UNIX_TIMESTAMP(E.StartDateTime)
|
|
END AS EndTimeSecs,
|
|
M.Name AS Monitor,
|
|
GROUP_CONCAT(T.Name SEPARATOR ", ") AS Tags';
|
|
|
|
$sql = 'SELECT '.$col_str.' FROM `Events` AS E
|
|
INNER JOIN Monitors AS M ON E.MonitorId = M.Id
|
|
LEFT JOIN Events_Tags AS ET ON E.Id = ET.EventId
|
|
LEFT JOIN Tags AS T ON T.Id = ET.TagId
|
|
'.$where.'
|
|
GROUP BY E.Id, Monitor
|
|
'.($sort?' ORDER BY '.$sort:'');
|
|
|
|
if ((int)($filter->limit()) and !$has_post_sql_conditions) {
|
|
$sql .= ' LIMIT '.(int)($filter->limit());
|
|
}
|
|
|
|
$storage_areas = ZM\Storage::find();
|
|
$StorageById = array();
|
|
foreach ($storage_areas as $S) {
|
|
$StorageById[$S->Id()] = $S;
|
|
}
|
|
|
|
$unfiltered_rows = array();
|
|
$event_ids = array();
|
|
|
|
ZM\Debug('Calling the following sql query: ' .$sql);
|
|
$query = dbQuery($sql, $values);
|
|
if (!$query) {
|
|
ZM\Error(dbError($sql));
|
|
ajaxError('Database query failed');
|
|
return;
|
|
}
|
|
while ($row = dbFetchNext($query)) {
|
|
if ($has_post_sql_conditions) {
|
|
$event = new ZM\Event($row);
|
|
$event->remove_from_cache();
|
|
if (!$filter->test_post_sql_conditions($event)) {
|
|
continue;
|
|
}
|
|
}
|
|
$event_ids[] = $row['Id'];
|
|
$unfiltered_rows[] = $row;
|
|
} # end foreach row
|
|
|
|
# Filter limits come before pagination limits.
|
|
if ($filter->limit() and ($filter->limit() < count($unfiltered_rows))) {
|
|
ZM\Debug("Filtering rows due to filter->limit " . count($unfiltered_rows)." limit: ".$filter->limit());
|
|
$unfiltered_rows = array_slice($unfiltered_rows, 0, $filter->limit());
|
|
}
|
|
|
|
ZM\Debug('Have ' . count($unfiltered_rows) . ' events matching base filter.');
|
|
|
|
$filtered_rows = null;
|
|
|
|
if (count($advsearch) or $search != '') {
|
|
$search_filter = new ZM\Filter();
|
|
$search_filter = $search_filter->addTerm(array('cnj'=>'and', 'attr'=>'Id', 'op'=>'IN', 'val'=>$event_ids));
|
|
|
|
// 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
|
|
if (count($advsearch)) {
|
|
$terms = array();
|
|
foreach ($advsearch as $col=>$text) {
|
|
$terms[] = array('cnj'=>'and', 'attr'=>$col, 'op'=>'LIKE', 'val'=>$text);
|
|
} # end foreach col in advsearch
|
|
$terms[0]['obr'] = 1;
|
|
$terms[count($terms)-1]['cbr'] = 1;
|
|
$search_filter->addTerms($terms);
|
|
} else if ($search != '') {
|
|
$search = '%' .$search. '%';
|
|
$terms = array();
|
|
foreach ($columns as $col) {
|
|
$terms[] = array('cnj'=>'or', 'attr'=>$col, 'op'=>'LIKE', 'val'=>strtolower($search), 'collate'=>'utf8mb4_general_ci');
|
|
}
|
|
$terms[0]['obr'] = 1;
|
|
$terms[0]['cnj'] = 'and';
|
|
$terms[count($terms)-1]['cbr'] = 1;
|
|
$search_filter = $search_filter->addTerms($terms, array('obr'=>1, 'cbr'=>1, 'op'=>'OR'));
|
|
} # end if search
|
|
|
|
$sql = 'SELECT '.$col_str.' FROM `Events` AS E
|
|
INNER JOIN Monitors AS M ON E.MonitorId = M.Id
|
|
LEFT JOIN Events_Tags AS ET ON E.Id = ET.EventId
|
|
LEFT JOIN Tags AS T ON T.Id = ET.TagId
|
|
WHERE '.$search_filter->sql().'
|
|
GROUP BY E.Id'
|
|
.($sort ? ' ORDER BY '.$sort : '');
|
|
|
|
$filtered_rows = dbFetchAll($sql);
|
|
ZM\Debug('Have ' . count($filtered_rows) . ' events matching search filter: '.$sql);
|
|
} else {
|
|
$filtered_rows = $unfiltered_rows;
|
|
} # end if search_filter->terms() > 1
|
|
|
|
if ($limit and ($limit < count($filtered_rows))) {
|
|
ZM\Debug("Filtering rows due to limit rows: " . count($filtered_rows)." offset: $offset limit: $limit");
|
|
$filtered_rows = array_slice($filtered_rows, $offset, $limit);
|
|
}
|
|
|
|
$returned_rows = array();
|
|
foreach ($filtered_rows as $row) {
|
|
$event = new ZM\Event($row);
|
|
$event->remove_from_cache();
|
|
if (!$event->canView()) continue;
|
|
if ($event->Monitor()->Deleted()) continue;
|
|
|
|
$scale = $event->Width() ? intval(5*100*ZM_WEB_LIST_THUMB_WIDTH / $event->Width()) : 100;
|
|
$imgSrc = $event->getThumbnailSrc(array(), '&');
|
|
$streamSrc = $event->getStreamSrc(array(
|
|
'mode'=>'jpeg', 'scale'=>$scale, 'maxfps'=>ZM_WEB_VIDEO_MAXFPS, 'replay'=>'single', 'rate'=>'400'), '&');
|
|
|
|
$videoAttr = '';
|
|
if ($event->DefaultVideo()) {
|
|
$videoSrc = $event->getStreamSrc(array('mode'=>'mp4'), '&');
|
|
$videoDuration = isset($row['Length']) ? (int)$row['Length'] : 0;
|
|
if ($videoDuration === 0) $videoDuration = $event->Duration();
|
|
$videoAttr = ' video_src="' .$videoSrc. '" data-event-start="'.htmlspecialchars($event->StartDateTime()).'"';
|
|
// HLS manifest is written progressively during recording. Always advertise
|
|
// it for events with video; JS falls back to MP4/MJPEG on load failure
|
|
// (handles legacy events recorded before HLS support was added).
|
|
$videoAttr .= ' data-video-hls-src="'.$event->getStreamSrc(array('mode'=>'mp4hls'), '&').'"';
|
|
$videoAttr .= ' data-video-duration-secs="'.$videoDuration.'"';
|
|
}
|
|
|
|
// Modify the row data as needed
|
|
$row['imgHtml'] = '<img id="thumbnail' .$event->Id(). '" src="' .$imgSrc. '" alt="Event '.$event->Id().'" width="' .validInt($event->ThumbnailWidth()). '" height="' .validInt($event->ThumbnailHeight()).'" stream_src="' .$streamSrc. '" still_src="' .$imgSrc. '"' .$videoAttr. ' data-monitor-width="'.$event->Width().'" data-monitor-height="'.$event->Height().'" loading="lazy" />';
|
|
$row['imgWidth'] = validInt($event->ThumbnailWidth());
|
|
$row['imgHeight'] = validInt($event->ThumbnailHeight());
|
|
|
|
$row['Name'] = validHtmlStr($row['Name']);
|
|
$row['Archived'] = $row['Archived'] ? translate('Yes') : translate('No');
|
|
$row['Emailed'] = $row['Emailed'] ? translate('Yes') : translate('No');
|
|
$row['Cause'] = validHtmlStr($row['Cause']);
|
|
$row['Tags'] = validHtmlStr($row['Tags']);
|
|
$row['Storage'] = ( $row['StorageId'] and isset($StorageById[$row['StorageId']]) ) ? $StorageById[$row['StorageId']]->Name() : 'Default';
|
|
$row['Notes'] = $row['Notes'] ? nl2br(htmlspecialchars($row['Notes'])) : '';
|
|
$row['DiskSpace'] = human_filesize($event->DiskSpace());
|
|
$returned_rows[] = $row;
|
|
} # end foreach row matching search
|
|
|
|
$data['rows'] = &$returned_rows;
|
|
|
|
# totalNotFiltered must equal total, except when either search bar has been used
|
|
$data['totalNotFiltered'] = count($unfiltered_rows);
|
|
if ( $search != '' || count($advsearch) ) {
|
|
$data['total'] = count($filtered_rows);
|
|
} else {
|
|
$data['total'] = $data['totalNotFiltered'];
|
|
}
|
|
|
|
# Calculate totals for footer display
|
|
$totalDiskSpace = 0;
|
|
$totalLength = 0;
|
|
foreach ($filtered_rows as $row) {
|
|
$totalDiskSpace += isset($row['DiskSpace']) ? $row['DiskSpace'] : 0;
|
|
$totalLength += isset($row['Length']) ? $row['Length'] : 0;
|
|
}
|
|
$data['footerData'] = array(
|
|
'DiskSpace' => human_filesize($totalDiskSpace),
|
|
'Length' => $totalLength,
|
|
);
|
|
|
|
ZM\Debug("Done");
|
|
return $data;
|
|
}
|
|
?>
|