Files
zoneminder/web/api/app/Controller/EventsController.php
T
Nic Boet 7a64e50618 fix(api): refuse events/view for an event on a monitor the user may not view
EventsController::view() looks the event up with the per-monitor
condition and then asks ZM\Event::canView(). For an event on a denied
monitor the restricted find() is empty, the empty ZM\Event has no
monitor, and canView() falls back to the user's global Monitors
permission, so it passes. The action then answered 200 with a stub
built from the id alone: the hidden event's Next/Prev ids, its max
score and alarm frame ids, fileSize and a FileSystemPath.

The event is known to exist at that point, so an empty result means
the monitor condition removed it: answer 401 as edit, delete and
archive do.

Test: tests/php/test_api_event_view_acl.php (2 of 6 checks fail
without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit cf62c055763afa8fbaaa5925abe1659fda1544ec)
2026-10-31 19:08:08 -04:00

645 lines
23 KiB
PHP

<?php
App::uses('AppController', 'Controller');
/**
* Events Controller
*
* @property Event $Event
*/
class EventsController extends AppController {
/**
* Components
*
* @var array
*/
public $components = array('RequestHandler', 'Scaler', 'Image', 'Paginator');
public function beforeRender() {
$this->set($this->Event->enumValues());
}
public function beforeFilter() {
parent::beforeFilter();
global $user;
$canView = (!$user) || ($user->canView('Events'));
if ( !$canView ) {
throw new UnauthorizedException(__('Insufficient Privileges'));
return;
}
}
/**
* index method
*
* @return void
* This also creates a thumbnail for each event.
*/
public function index() {
$this->Event->recursive = 0;
global $user;
require_once __DIR__ .'/../../../includes/Event.php';
$mon_options = $this->viewableMonitorCondition('Event.MonitorId');
$this->FilterComponent = $this->Components->load('Filter');
$named_params = $this->request->params['named'];
$tag_filter_value = null;
$tag_filter_field = null;
if ($named_params) {
if (isset($named_params['TagId'])) {
$tag_filter_value = $named_params['TagId'];
$tag_filter_field = 'Id';
unset($named_params['TagId']);
} else if (isset($named_params['Tag'])) {
$tag_filter_value = $named_params['Tag'];
$tag_filter_field = 'Name';
unset($named_params['Tag']);
} else if (isset($named_params['Tags'])) {
$tag_filter_value = $named_params['Tags'];
$tag_filter_field = 'Name';
unset($named_params['Tags']);
}
# In 1.35.13 we renamed StartTime and EndTime to StartDateTime and EndDateTime.
# This hack renames the query string params
foreach ( $named_params as $k=>$v ) {
if ( false !== strpos($k, 'StartTime') ) {
$new_k = preg_replace('/StartTime/', 'StartDateTime', $k);
$named_params[$new_k] = $named_params[$k];
unset($named_params[$k]);
}
if ( false !== strpos($k, 'EndTime') ) {
$new_k = preg_replace('/EndTime/', 'EndDateTime', $k);
$named_params[$new_k] = $named_params[$k];
unset($named_params[$k]);
}
}
// Events columns, plus the two names this action resolves itself: DateTime
// is a pseudo-attribute turned into an overlap test below, and GroupId is
// served by the Groups_Monitors join added further down. Anything else
// would reach MySQL as an unknown column and come back as a 500.
$valid_fields = array_keys($this->Event->schema());
$valid_fields[] = 'DateTime';
$valid_fields[] = 'GroupId';
$conditions = $this->FilterComponent->buildFilter($named_params, $valid_fields);
#ZM\Debug(print_r($conditions, true));
# DateTime is a pseudo-attribute meaning "the event was running then", so a
# window over it is an overlap test: the event started by the upper bound
# and had not finished by the lower bound. Applying the term's own operator
# to both StartDateTime and EndDateTime instead makes the upper bound a
# containment test, which drops every event that spans the end of the
# window -- with continuous recording that is most of them.
$datetime_terms = array();
foreach ($conditions as $k=>$v) {
if ( 0 === strpos($k, 'DateTime') ) {
$datetime_terms[$k] = $v;
unset($conditions[$k]);
}
}
if ($datetime_terms) {
# An event still being written has no EndDateTime, but it is not
# unbounded: zmc flushes Length every few seconds, so StartDateTime +
# Length is its effective end. Only an event with neither falls back to
# NOW(). Treating a missing EndDateTime as "matches any window" instead
# made every crash-orphaned event ever recorded match every query.
$effective_end = '(CASE'
.' WHEN Event.EndDateTime IS NOT NULL THEN Event.EndDateTime'
.' WHEN Event.Length > 0 THEN DATE_ADD(Event.StartDateTime, INTERVAL FLOOR(Event.Length) SECOND)'
.' ELSE NOW() END)';
$ds = $this->Event->getDataSource();
foreach ($datetime_terms as $k=>$v) {
$op = trim(substr($k, strlen('DateTime')));
if ($op == '<' or $op == '<=') {
# Upper bound: the event must have started by then.
$conditions[] = array('Event.StartDateTime '.$op => $v);
} else if ($op == '>' or $op == '>=') {
# Lower bound: the event must not have ended before then.
$conditions[] = $effective_end.' '.$op.' '.$ds->value($v, 'string');
} else {
# Anything else (=, !=) still matches against either end.
$conditions[] = array('OR' => array(
array('Event.StartDateTime '.$op => $v),
array('Event.EndDateTime '.$op => $v),
));
}
}
} // end if datetime terms
#ZM\Debug(print_r($conditions, true));
} else {
$raw_params = $_REQUEST;
if (isset($raw_params['TagId'])) {
$tag_filter_value = $raw_params['TagId'];
$tag_filter_field = 'Id';
unset($raw_params['TagId']);
} else if (isset($raw_params['Tag'])) {
$tag_filter_value = $raw_params['Tag'];
$tag_filter_field = 'Name';
unset($raw_params['Tag']);
} else if (isset($raw_params['Tags'])) {
$tag_filter_value = $raw_params['Tags'];
$tag_filter_field = 'Name';
unset($raw_params['Tags']);
}
$conditions = $this->FilterComponent->buildFilter($raw_params);
}
$settings = array(
// https://github.com/ZoneMinder/ZoneMinder/issues/995
// 'limit' => $limit['ZM_WEB_EVENTS_PER_PAGE'],
// 25 events per page which is what the above
// default is, is way too low for an API
// changing this to 100 so we don't kill ZM
// with many event APIs. In future, we can
// make a nice ZM_API_ITEMS_PER_PAGE for all pagination
// API
// ICON: 2023-11-16: MontageReview now uses API and unless we specifiy a limit in params, there should be no limit
// TODO: Implement request based limits.
'paramType' => 'querystring',
'joins'=>[],
'contain'=>[]
);
$settings['contain'] = [];
if ( isset($conditions['GroupId']) ) {
$settings['joins'] = array(
array(
'table' => 'Groups_Monitors',
'type' => 'inner',
'conditions' => array(
'Groups_Monitors.MonitorId = Event.MonitorId'
),
),
);
$settings['contain'][] = 'Group';
}
if ($tag_filter_value !== null) {
#$settings['contain'][] = 'Tag';
if (!isset($settings['joins'])) {
$settings['joins'] = array();
}
$settings['joins'][] = array(
'table' => 'Events_Tags',
'type' => 'inner',
'conditions' => array(
'Events_Tags.EventId = Event.Id'
),
);
$settings['joins'][] = array(
'table' => 'Tags',
'type' => 'inner',
'conditions' => array(
'Tags.Id = Events_Tags.TagId'
),
);
if ($tag_filter_field === 'Id') {
$tag_ids = is_array($tag_filter_value) ? $tag_filter_value : explode(',', $tag_filter_value);
$tag_ids = array_map('intval', $tag_ids);
$conditions[] = array('Tags.Id' => $tag_ids);
} else {
$conditions[] = array('Tags.Name' => $tag_filter_value);
}
$settings['group'] = 'Event.Id';
}
if (isset($conditions['Tags.Id'])) {
$settings['joins'][] = [
'table' => 'Events_Tags',
'type' => 'inner',
'conditions' => ['Events_Tags.EventId = Event.Id'],
];
$settings['joins'][] = [
'table' => 'Tags',
'type' => 'inner',
'conditions' => ['Tags.Id = Events_Tags.TagId'],
];
//$settings['contain'][] = 'Tag';
}
$settings['conditions'] = array($conditions, $mon_options);
$this->Paginator->settings = $settings;
if ($this->request->query('limit') or $this->request->query('page')) {
$events = $this->Paginator->paginate('Event');
} else {
$events = $this->Event->find('all', $settings);
}
#ZM\Debug(print_r($this->Event->getDataSource()->getLog(false, false), true));
// For each event, get the frameID which has the largest score also add FS path
foreach ( $events as $key => $value ) {
$EventObj = new ZM\Event($value['Event']);
if ($EventObj->MaxScoreFrameId() == NULL) {
$events[$key]['Event']['MaxScoreFrameId'] = $this->getMaxScoreAlarmFrameId($value['Event']['Id']);
}
$events[$key]['Event']['FileSystemPath'] = $EventObj->Path();
}
$this->set(compact('events'));
} // end public function index()
/**
* view method
*
* @throws NotFoundException
* @param string $id
* @return void
*/
public function view($id = null) {
$this->loadModel('Config');
$this->Event->recursive = 1;
if ( !$this->Event->exists($id) ) {
throw new NotFoundException(__('Invalid event'));
}
global $user;
$mon_options = $this->viewableMonitorCondition('Event.MonitorId');
$noFrames = $this->request->query('noframes');
if ($noFrames=='true')
$this->Event->unbindModel(array('hasMany' => array('Frame')));
$options = array('conditions' => array(array('Event.' . $this->Event->primaryKey => $id), $mon_options));
$event = $this->Event->find('first', $options);
# The event exists (checked above), so an empty result means the monitor
# condition filtered it out. Refuse here: an empty ZM\Event has no monitor
# and passes canView(), and the code below would then fill in the hidden
# event's neighbours and frame ids by $id alone.
if (!$event) {
throw new UnauthorizedException(__('Insufficient Privileges'));
}
$EventObj = new ZM\Event($event['Event']);
if (!$EventObj->canView()) {
throw new UnauthorizedException(__('Insufficient Privileges'));
return;
}
# Get the previous and next events for any monitor.
# Only Id is used below, so skip the wide SELECT + Monitor/Storage joins + Frames hasMany expansion
# that recursive=1 from above would otherwise pull in for each neighbor row.
$this->Event->id = $id;
$event_neighbors = $this->Event->find('neighbors', array(
'fields' => array('Event.Id'),
'recursive' => -1,
));
$event['Event']['Next'] = isset($event_neighbors['next']) ? $event_neighbors['next']['Event']['Id'] : 0;
$event['Event']['Prev'] = isset($event_neighbors['prev']) ? $event_neighbors['prev']['Event']['Id'] : 0;
$event['Event']['fileExists'] = $this->Event->fileExists($event['Event']);
$event['Event']['fileSize'] = $this->Event->fileSize($event['Event']);
$event['Event']['FileSystemPath'] = $EventObj->Path();
# Also get the previous and next events for the same monitor
$event_monitor_neighbors = $this->Event->find('neighbors', array(
'fields' => array('Event.Id'),
'recursive' => -1,
'conditions' => array('Event.MonitorId' => $event['Event']['MonitorId']),
));
$event['Event']['NextOfMonitor'] = isset($event_monitor_neighbors['next']) ? $event_monitor_neighbors['next']['Event']['Id'] : 0;
$event['Event']['PrevOfMonitor'] = isset($event_monitor_neighbors['prev']) ? $event_monitor_neighbors['prev']['Event']['Id'] : 0;
$this->loadModel('Frame');
$maxScoreFrame = $this->Frame->findByEventid($id, 'FrameId', array('Score'=>'desc','FrameId'=>'asc'));
$event['Event']['MaxScoreFrameId'] = $maxScoreFrame ? $maxScoreFrame['Frame']['FrameId'] : null;
$alarmFrame = $this->Frame->findByEventidAndType($id, 'Alarm');
ZM\Debug(print_r($alarmFrame, true));
$event['Event']['AlarmFrameId'] = $alarmFrame ? $alarmFrame['Frame']['FrameId'] : null;
$this->set(array(
'event' => $event,
'_serialize' => array('event')
));
} // end function view
/**
* add method
*
* @return void
*/
public function add() {
global $user;
$canEdit = (!$user) || ($user->canEdit('Events'));
if ( !$canEdit ) {
throw new UnauthorizedException(__('Insufficient privileges'));
return;
}
if ( $this->request->is('post') ) {
$monitorId = $this->requestField('Event', 'MonitorId');
if ($monitorId === null) {
throw new BadRequestException(__('MonitorId is required'));
}
$this->requireMonitorView($monitorId);
$this->pinRequestId($this->Event, null);
$this->Event->create();
if ( $this->Event->save($this->request->data) ) {
return $this->flash(__('The event has been saved.'), array('action' => 'index'));
}
}
$monitors = $this->Event->Monitor->find('list');
$this->set(compact('monitors'));
}
/**
* edit method
*
* @throws NotFoundException
* @param string $id
* @return void
*/
public function edit($id = null) {
global $user;
$canEdit = (!$user) || ($user->canEdit('Events'));
if ( !$canEdit ) {
throw new UnauthorizedException(__('Insufficient privileges'));
return;
}
$this->Event->id = $id;
if ( !$this->Event->exists($id) ) {
throw new NotFoundException(__('Invalid event'));
}
# Events=Edit is coarse. Enforce the per-monitor ACL too, otherwise a user
# denied a monitor can still mutate that monitor's events by direct Id.
$this->Event->recursive = -1;
$event = $this->Event->find('first', array(
'conditions' => array('Event.' . $this->Event->primaryKey => $id)
));
$EventObj = new ZM\Event($event['Event']);
if ( !$EventObj->canEdit() ) {
throw new UnauthorizedException(__('Insufficient Privileges'));
return;
}
$this->pinRequestId($this->Event, $id);
$monitorId = $this->requestField('Event', 'MonitorId');
if ($monitorId !== null and $monitorId != $event['Event']['MonitorId']) {
$this->requireMonitorView($monitorId);
}
if ( $this->Event->save($this->request->data) ) {
$message = 'Saved';
} else {
$message = 'Error';
}
$this->set(array(
'message' => $message,
'_serialize' => array('message')
));
}
/**
* delete method
*
* @throws NotFoundException
* @param string $id
* @return void
*/
public function delete($id = null) {
global $user;
$canEdit = (!$user) || ($user->canEdit('Events'));
if ( !$canEdit ) {
throw new UnauthorizedException(__('Insufficient privileges'));
return;
}
$this->Event->id = $id;
if ( !$this->Event->exists() ) {
throw new NotFoundException(__('Invalid event'));
}
$this->request->allowMethod('post', 'delete');
# Events=Edit is coarse. Enforce the per-monitor ACL too, otherwise a user
# denied a monitor can still delete that monitor's events by direct Id.
$this->Event->recursive = -1;
$event = $this->Event->find('first', array(
'conditions' => array('Event.' . $this->Event->primaryKey => $id)
));
$EventObj = new ZM\Event($event['Event']);
if ( !$EventObj->canEdit() ) {
throw new UnauthorizedException(__('Insufficient Privileges'));
return;
}
if ( $this->Event->delete() ) {
//$this->loadModel('Frame');
//$this->Event->Frame->delete();
return $this->flash(__('The event has been deleted.'), array('action' => 'index'));
} else {
return $this->flash(__('The event could not be deleted. Please, try again.'), array('action' => 'index'));
}
} // end public function delete
public function search() {
$this->Event->recursive = -1;
// Unmodified conditions to pass to find()
$find_conditions = array();
// Conditions to be filtered by buildFilter
$conditions = array();
foreach ($this->params['named'] as $param_name => $value) {
// Transform params into conditions
if ( preg_match('/^\s?interval\s?/i', $value) ) {
if (preg_match('/^[a-z0-9]+$/i', $param_name) !== 1) {
throw new Exception('Invalid field name: ' . $param_name);
}
$matches = NULL;
$value = preg_replace('/^\s?interval\s?/i', '', $value);
if (preg_match('/^(?P<expr>[ \-.:0-9]+)\s+(?P<unit>[_a-z]+)$/i', trim($value), $matches) !== 1) {
throw new Exception('Invalid interval: ' . $value);
}
$expr = trim($matches['expr']);
$unit = trim($matches['unit']);
array_push($find_conditions, "$param_name >= DATE_SUB(NOW(), INTERVAL $expr $unit)");
} else {
$conditions[$param_name] = $value;
}
}
$this->FilterComponent = $this->Components->load('Filter');
$conditions = $this->FilterComponent->buildFilter($conditions);
array_push($conditions, $find_conditions);
array_push($conditions, $this->viewableMonitorCondition('Event.MonitorId'));
$results = $this->Event->find('all', array(
'conditions' => $conditions
));
$this->set(array(
'results' => $results,
'_serialize' => array('results')
));
} // end public function search
// format expected:
// you can changed AlarmFrames to any other named params
// consoleEvents/1 hour/AlarmFrames >=: 1/AlarmFrames <=: 20.json
public function consoleEvents($interval = null) {
$matches = NULL;
// https://dev.mysql.com/doc/refman/5.5/en/expressions.html#temporal-intervals
// Examples: `'1-1' YEAR_MONTH`, `'-1 10' DAY_HOUR`, `'1.999999' SECOND_MICROSECOND`
if (preg_match('/^(?P<expr>[ \-.:0-9]+)\s+(?P<unit>[_a-z]+)$/i', trim($interval), $matches) !== 1) {
throw new Exception('Invalid interval: ' . $interval);
}
$expr = trim($matches['expr']);
$unit = trim($matches['unit']);
$this->Event->recursive = -1;
$results = array();
$this->FilterComponent = $this->Components->load('Filter');
if ( $this->request->params['named'] ) {
$conditions = $this->FilterComponent->buildFilter($this->request->params['named']);
} else {
$conditions = array();
}
array_push($conditions, array("StartDateTime >= DATE_SUB(NOW(), INTERVAL $expr $unit)"));
array_push($conditions, $this->viewableMonitorCondition('Event.MonitorId'));
$query = $this->Event->find('all', array(
'fields' => array('MonitorId', 'COUNT(*) AS Count'),
'conditions' => $conditions,
'group' => 'MonitorId',
));
foreach ($query as $result) {
$results[$result['Event']['MonitorId']] = $result[0]['Count'];
}
$this->set(array(
'results' => $results,
'_serialize' => array('results')
));
}
// Create a thumbnail and return the thumbnail's data for a given event id.
private function createThumbnail($id = null) {
$this->Event->recursive = -1;
if ( !$this->Event->exists($id) ) {
throw new NotFoundException(__('Invalid event'));
}
$event = $this->Event->find('first', array(
'conditions' => array('Id' => $id)
));
// Find the max Frame for this Event. Error out otherwise.
$this->loadModel('Frame');
if ( !( $frame = $this->Frame->find('first', array(
'conditions' => array(
'EventId' => $event['Event']['Id'],
'Score' => $event['Event']['MaxScore']
)
))) ) {
throw new NotFoundException(__('Can not find Frame for Event ' . $event['Event']['Id']));
}
$this->loadModel('Config');
// Get the config options required for reScale and getImageSrc
// The $bw, $thumbs and unset() code is a workaround / temporary
// until I have a better way of handing per-bandwidth config options
$bw = (isset($_COOKIE['zmBandwidth']) ? strtoupper(substr($_COOKIE['zmBandwidth'], 0, 1)) : 'L');
$thumbs = "ZM_WEB_{$bw}_SCALE_THUMBS";
$config = $this->Config->find('list', array(
'conditions' => array('OR' => array(
'Name' => array(
'ZM_WEB_LIST_THUMB_WIDTH',
'ZM_WEB_LIST_THUMB_HEIGHT',
'ZM_EVENT_IMAGE_DIGITS',
$thumbs,
'ZM_DIR_EVENTS'
)
)),
'fields' => array('Name', 'Value')
));
$config['ZM_WEB_SCALE_THUMBS'] = $config[$thumbs];
unset($config[$thumbs]);
// reScale based on either the width, or the hight, of the event.
if ( $config['ZM_WEB_LIST_THUMB_WIDTH'] ) {
$thumbWidth = $config['ZM_WEB_LIST_THUMB_WIDTH'];
$scale = (100 * $thumbWidth) / $event['Event']['Width'];
$thumbHeight = $this->Scaler->reScale( $event['Event']['Height'], $scale );
} elseif ( $config['ZM_WEB_LIST_THUMB_HEIGHT'] ) {
$thumbHeight = $config['ZM_WEB_LIST_THUMB_HEIGHT'];
$scale = (100*$thumbHeight)/$event['Event']['Height'];
$thumbWidth = $this->Scaler->reScale( $event['Event']['Width'], $scale );
} else {
throw new NotFoundException(__('No thumbnail width or height specified, please check in Options->Web'));
}
$imageData = $this->Image->getImageSrc( $event, $frame, $scale, $config );
$thumbData['Path'] = $imageData['thumbPath'];
$thumbData['Width'] = (int)$thumbWidth;
$thumbData['Height'] = (int)$thumbHeight;
return $thumbData;
}
public function archive($id = null) {
$this->Event->recursive = -1;
if ( !$this->Event->exists($id) ) {
throw new NotFoundException(__('Invalid event'));
}
// Toggling Archived mutates state, so restrict to state-changing verbs (not CSRF-able GET).
$this->request->allowMethod('post', 'put');
$archived = $this->Event->find('first', array(
'conditions' => array('Event.Id' => $id)
));
$EventObj = new ZM\Event($archived['Event']);
// If 0, 1, if 1, 0
$archiveVal = (($archived['Event']['Archived'] == 0) ? 1 : 0);
// Archiving protects an event from purge, so any user who can view the event may do it.
// Un-archiving makes it eligible for purge again, so that requires edit permission.
// Both canView() and canEdit() enforce the per-monitor object-level ACL.
$allowed = $archiveVal ? $EventObj->canView() : $EventObj->canEdit();
if ( !$allowed ) {
throw new UnauthorizedException(__('Insufficient Privileges'));
return;
}
// Save the new value
$this->Event->id = $id;
$this->Event->saveField('Archived', $archiveVal);
$this->set(array(
'archived' => $archiveVal,
'_serialize' => array('archived')
));
}
private function getMaxScoreAlarmFrameId($id = null) {
$this->Event->recursive = -1;
if ( !$this->Event->exists($id) ) {
throw new NotFoundException(__('Invalid event'));
}
$event = $this->Event->find('first', array(
'conditions' => array('Id' => $id)
));
// Find the max Frame for this Event. Error out otherwise.
$this->loadModel('Frame');
$frame = $this->Frame->find('first', array(
'conditions' => array(
'EventId' => $event['Event']['Id'],
'Score' => $event['Event']['MaxScore']
)));
return empty($frame)?null:$frame['Frame']['Id'];
}
} // end class EventsController