mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-10-14 08:47:29 -04:00
zmc runs a monitor's EventStartCommand and EventEndCommand at the start and end of each event. Both the classic save and bulk edit actions and the API monitor add and edit authorized only on the monitor, so a user with edit on one monitor and no System permission could set a command for zmc to run as the ZoneMinder service user. Changing either field now needs System=Edit, as Filter::canEdit() already requires for AutoExecuteCmd. Monitor::commandFieldChangesNotAllowed() reports the command fields a change would alter; submitting the current value is not a change, so other users can still save the rest of the monitor. The monitor form shows the fields read-only to them. Also close the EventEndCommand list item. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
454 lines
19 KiB
PHP
454 lines
19 KiB
PHP
<?php
|
|
//
|
|
// ZoneMinder web action file
|
|
// Copyright (C) 2019 ZoneMinder LLC
|
|
//
|
|
// This program is free software; you can redistribute it and/or
|
|
// modify it under the terms of the GNU General Public License
|
|
// as published by the Free Software Foundation; either version 2
|
|
// of the License, or (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program; if not, write to the Free Software
|
|
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
|
//
|
|
|
|
// Monitor edit actions, monitor id derived, require edit permissions for that monitor
|
|
|
|
require_once('includes/Monitor.php');
|
|
require_once('includes/Zone.php');
|
|
|
|
global $error_message;
|
|
|
|
if ($action == 'save') {
|
|
$mid = 0;
|
|
if (!empty($_REQUEST['mid'])) {
|
|
$mid = validInt($_REQUEST['mid']);
|
|
if (!canEdit('Monitors', $mid)) {
|
|
ZM\Warning('You do not have permission to edit this monitor');
|
|
return;
|
|
}
|
|
if (ZM_OPT_X10) {
|
|
$x10Monitor = dbFetchOne('SELECT * FROM TriggersX10 WHERE MonitorId=?', NULL, array($mid));
|
|
if (!$x10Monitor) $x10Monitor = array();
|
|
}
|
|
} else {
|
|
if (!canCreate('Monitors')) {
|
|
ZM\Warning('Monitor actions require Monitors Create Permissions');
|
|
return;
|
|
}
|
|
if ($user->unviewableMonitorIds()) {
|
|
ZM\Warning('You are restricted to certain monitors so cannot add a new one.');
|
|
return;
|
|
}
|
|
if (ZM_OPT_X10) {
|
|
$x10Monitor = array();
|
|
}
|
|
if (!empty($_REQUEST['newMonitor']['Id'])) {
|
|
// Reuse existing but deleted monitor
|
|
$mid = validCardinal($_REQUEST['newMonitor']['Id']);
|
|
}
|
|
}
|
|
|
|
# For convenience
|
|
$newMonitor = $_REQUEST['newMonitor'];
|
|
|
|
# Validate Device path to prevent command injection (CVE-worthy).
|
|
# Only Local monitors pass Device to a shell; for other Types the field
|
|
# is unused and may legitimately hold legacy values (e.g. an RTSP URL).
|
|
if (!empty($newMonitor['Device']) and isset($newMonitor['Type']) and $newMonitor['Type'] == 'Local') {
|
|
$newMonitor['Device'] = validDevicePath($newMonitor['Device']);
|
|
if ($newMonitor['Device'] === '') {
|
|
$error_message .= 'Invalid device path. Must be a valid /dev/ path (e.g. /dev/video0).</br>';
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!$newMonitor['ManufacturerId'] and ($newMonitor['Manufacturer'] != '')) {
|
|
# Need to add a new Manufacturer entry
|
|
$newManufacturer = ZM\Manufacturer::find_one(array('Name'=>$newMonitor['Manufacturer']));
|
|
if (!$newManufacturer) {
|
|
$newManufacturer = new ZM\Manufacturer();
|
|
if (!$newManufacturer->save(array('Name'=>$newMonitor['Manufacturer']))) {
|
|
$error_message .= "Error saving new Manufacturer: " . $newManufacturer->get_last_error().'</br>';
|
|
}
|
|
}
|
|
$newMonitor['ManufacturerId'] = $newManufacturer->Id();
|
|
unset($newMonitor['Manufacturer']);
|
|
}
|
|
|
|
if (!$newMonitor['ModelId'] and ($newMonitor['Model'] != '')) {
|
|
# Need to add a new Model entry
|
|
$newModel = ZM\Model::find_one(array('Name'=>$newMonitor['Model']));
|
|
if (!$newModel) {
|
|
$newModel = new ZM\Model();
|
|
if (!$newModel->save(array(
|
|
'Name'=>$newMonitor['Model'],
|
|
'ManufacturerId'=>$newMonitor['ManufacturerId']
|
|
))) {
|
|
$error_message .= "Error saving new Model: " . $newModel->get_last_error().'</br>';
|
|
}
|
|
}
|
|
$newMonitor['ModelId'] = $newModel->Id();
|
|
unset($newMonitor['Model']);
|
|
}
|
|
|
|
$monitor = new ZM\Monitor($mid);
|
|
|
|
// Define a field type for anything that's not simple text equivalent
|
|
$types = array(
|
|
'Triggers' => array(),
|
|
'Controllable' => 0,
|
|
'TrackMotion' => 0,
|
|
'ModectDuringPTZ' => 0,
|
|
'Enabled' => 0,
|
|
'DecodingEnabled' => 0,
|
|
'RTSP2WebEnabled' => 0,
|
|
'Go2RTCEnabled' => 0,
|
|
'JanusEnabled' => 0,
|
|
'JanusAudioEnabled' => 0,
|
|
'Restream' => 0,
|
|
// 'Janus_RTSP_Session_Timeout' => 0,
|
|
'Exif' => 0,
|
|
'RTSPDescribe' => 0,
|
|
'V4LMultiBuffer' => '',
|
|
'WallClockTimestamps' => '',
|
|
'RecordAudio' => 0,
|
|
'Method' => 'raw',
|
|
'GroupIds' => array(),
|
|
'LinkedMonitors' => array(),
|
|
'MQTT_Enabled' => 0,
|
|
'RTSPServer' => 0,
|
|
'SectionLengthWarn' => 0,
|
|
'SOAP_wsa_compl' => 0
|
|
);
|
|
|
|
# Checkboxes don't return an element in the POST data, so won't be present in newMonitor.
|
|
# So force a value for these fields.
|
|
# Deleted is deliberately NOT in this list: its checkbox is an "undelete"
|
|
# action, present only on a deleted monitor and only ever sending 0. Forcing
|
|
# a default of 0 when it is absent undeleted the monitor on every save.
|
|
foreach ($types as $field => $value) {
|
|
if (!isset($newMonitor[$field])) {
|
|
$newMonitor[$field] = $value;
|
|
}
|
|
} # end foreach type
|
|
|
|
if (isset($newMonitor['ServerId']) and ($newMonitor['ServerId'] == 'auto')) {
|
|
$newMonitor['ServerId'] = dbFetchOne(
|
|
'SELECT Id FROM Servers WHERE Status=\'Running\' ORDER BY FreeMem DESC, CpuLoad ASC LIMIT 1', 'Id');
|
|
ZM\Debug('Auto selecting server: Got ' . $newMonitor['ServerId']);
|
|
if ((!$newMonitor['ServerId']) and defined('ZM_SERVER_ID')) {
|
|
$newMonitor['ServerId'] = ZM_SERVER_ID;
|
|
ZM\Debug('Auto selecting server to '.ZM_SERVER_ID);
|
|
}
|
|
}
|
|
if (!empty($newMonitor['ManufacturerId']) and empty($newMonitor['Manufacturer']))
|
|
unset($newMonitor['Manufacturer']);
|
|
if (!empty($newMonitor['ModelId']) and empty($newMonitor['Model']))
|
|
unset($newMonitor['Model']);
|
|
|
|
$refused = $monitor->commandFieldChangesNotAllowed($newMonitor);
|
|
if (count($refused)) {
|
|
$error_message .= 'Changing '.implode(', ', $refused).' requires System edit permission.<br/>';
|
|
ZM\Warning('Refused to save monitor '.$mid.': '.implode(', ', $refused).' requires System edit permission');
|
|
return;
|
|
}
|
|
|
|
$changes = $monitor->changes($newMonitor);
|
|
ZM\Debug('Changes: '. print_r($changes, true));
|
|
$restart = false;
|
|
|
|
if (count($changes)) {
|
|
// monitor->Id() has a value when the db record exists
|
|
if ($monitor->Id()) {
|
|
if ($monitor->Deleted() and empty($_REQUEST['mid'])) {
|
|
# We are saving a new monitor with a specified Id and the Id is used in a deleted record.
|
|
# Undelete it so that it is visible.
|
|
# Editing an existing deleted monitor (mid in the request) must NOT
|
|
# undelete it - that only happens via the explicit undelete checkbox,
|
|
# which arrives as newMonitor[Deleted]=0 and so shows up in $changes.
|
|
$monitor->Deleted(false);
|
|
}
|
|
|
|
# If we change anything that changes the shared mem size, zma can complain. So let's stop first.
|
|
if ($monitor->Type() != 'WebSite') {
|
|
$monitor->zmcControl('stop');
|
|
if ($monitor->Controllable()) {
|
|
$monitor->sendControlCommand('stop');
|
|
}
|
|
}
|
|
|
|
$oldMonitor = clone $monitor;
|
|
|
|
if ($monitor->save($changes)) {
|
|
# If the name has changed, it must also be changed in all storage areas.
|
|
if (isset($changes['Name'])) {
|
|
$saferName = basename($newMonitor['Name']);
|
|
$oldLinkPath = $oldMonitor->Storage()->Path().'/'.basename($oldMonitor->Name());
|
|
$oldLinkPathFound = false;
|
|
require_once('includes/Storage.php');
|
|
foreach (ZM\Storage::find() as $Storage) {
|
|
# Let's remove old symlinks
|
|
$storagePath = realpath($Storage->Path()) .'/';
|
|
$dirStoragePath = opendir($storagePath);
|
|
if($dirStoragePath === false) {
|
|
ZM\Warning("Unable to check and delete old symlinks to monitor with ID=".$monitor->Id()." in the '".$Storage->Name()."' storage because the path to the storage is missing or unreadable.");
|
|
} else {
|
|
while (($file = readdir($dirStoragePath)) !== false) {
|
|
$linkPath = $storagePath . $file;
|
|
if (is_link($linkPath)) {
|
|
$absolutePath = realpath($linkPath);
|
|
if ($oldLinkPath == $linkPath) $oldLinkPathFound = true;
|
|
if ($absolutePath == $Storage->Path().'/'.$mid) {
|
|
ZM\Debug("Deleting old link in storage '" . $Storage->Name() . "' " . $linkPath);
|
|
unlink($linkPath);
|
|
}
|
|
}
|
|
}
|
|
closedir($dirStoragePath);
|
|
}
|
|
|
|
# Let's create new symlinks
|
|
$dir = $Storage->Path().'/'.$mid;
|
|
if (is_dir($dir) && (string)$saferName !== (string)$mid) { # Let's check if there is a folder with events in this storage
|
|
$linkPath = $Storage->Path().'/'.$saferName;
|
|
if (!is_link($linkPath) && !@symlink($mid, $linkPath)) {
|
|
# It is necessary to check is_link() to avoid unnecessary warnings, since different repositories can have the same path and this is not prohibited by the configuration.
|
|
ZM\Warning('Unable to symlink in storage "' . $Storage->Name() . '" ' . $dir . ' to ' . $linkPath);
|
|
}
|
|
}
|
|
}
|
|
if (!$oldLinkPathFound) {
|
|
ZM\Debug("Old link at '" . $oldLinkPath . "' was not found in any of the storages");
|
|
}
|
|
}
|
|
|
|
if (isset($changes['Width']) || isset($changes['Height'])) {
|
|
$newW = $newMonitor['Width'];
|
|
$newH = $newMonitor['Height'];
|
|
|
|
$zones = dbFetchAll('SELECT * FROM Zones WHERE MonitorId=?', NULL, array($mid));
|
|
|
|
// Zone coords are stored as percentages, so they don't need rescaling
|
|
// on resolution change. Only handle rotation (swap x/y) and rescale
|
|
// threshold pixel counts.
|
|
$newA = $newW * $newH;
|
|
$oldA = $oldMonitor->Width() * $oldMonitor->Height();
|
|
|
|
if ( ($newW == $oldMonitor->Height()) and ($newH == $oldMonitor->Width()) ) {
|
|
// Rotation: swap x,y percentage coords
|
|
foreach ( $zones as $zone ) {
|
|
$newZone = $zone;
|
|
$points = coordsToPoints($zone['Coords']);
|
|
for ( $i = 0; $i < count($points); $i++ ) {
|
|
$x = $points[$i]['x'];
|
|
$points[$i]['x'] = $points[$i]['y'];
|
|
$points[$i]['y'] = $x;
|
|
}
|
|
$newZone['Coords'] = pointsToCoords($points);
|
|
$changes = getFormChanges($zone, $newZone, $types);
|
|
|
|
if ( count($changes) ) {
|
|
dbQuery('UPDATE Zones SET '.implode(', ', $changes).' WHERE MonitorId=? AND Id=?',
|
|
array($mid, $zone['Id']));
|
|
}
|
|
} # end foreach zone
|
|
} else if ($oldA > 0 && $newA != $oldA) {
|
|
// Non-rotation resize: coords stay the same (percentages),
|
|
// but rescale threshold pixel counts by area ratio
|
|
foreach ( $zones as $zone ) {
|
|
$newZone = $zone;
|
|
$newZone['MinAlarmPixels'] = intval(round(($zone['MinAlarmPixels']*$newA)/$oldA));
|
|
$newZone['MaxAlarmPixels'] = intval(round(($zone['MaxAlarmPixels']*$newA)/$oldA));
|
|
$newZone['MinFilterPixels'] = intval(round(($zone['MinFilterPixels']*$newA)/$oldA));
|
|
$newZone['MaxFilterPixels'] = intval(round(($zone['MaxFilterPixels']*$newA)/$oldA));
|
|
$newZone['MinBlobPixels'] = intval(round(($zone['MinBlobPixels']*$newA)/$oldA));
|
|
$newZone['MaxBlobPixels'] = intval(round(($zone['MaxBlobPixels']*$newA)/$oldA));
|
|
|
|
$changes = getFormChanges($zone, $newZone, $types);
|
|
|
|
if ( count($changes) ) {
|
|
dbQuery('UPDATE Zones SET '.implode(', ', $changes).' WHERE MonitorId=? AND Id=?',
|
|
array($mid, $zone['Id']));
|
|
}
|
|
} // end foreach zone
|
|
} // end if rotation or just size change
|
|
} // end if changes in width or height
|
|
ZM\AuditAction('update', 'monitor', $mid, 'Changed: '.implode(', ', array_keys($changes)));
|
|
} else {
|
|
$error_message .= $monitor->get_last_error();
|
|
} // end if successful save
|
|
$restart = true;
|
|
} else { // new monitor
|
|
// Can only create new monitors if we are not restricted to specific monitors
|
|
# FIXME This is actually a race condition. Should lock the table.
|
|
$maxSeq = dbFetchOne('SELECT MAX(Sequence) AS MaxSequence FROM Monitors', 'MaxSequence');
|
|
$changes['Sequence'] = $maxSeq+1;
|
|
if ( $mid ) $changes['Id'] = $mid; # mid specified in request, doesn't exist in db, will re-use slot
|
|
|
|
|
|
if ( $monitor->insert($changes) ) {
|
|
$mid = $monitor->Id();
|
|
// Zone coords are now stored as percentages (0-100)
|
|
$zoneWidth = $newMonitor['Width'];
|
|
$zoneHeight = $newMonitor['Height'];
|
|
if (isset($newMonitor['Orientation']) &&
|
|
($newMonitor['Orientation'] == 'ROTATE_90' || $newMonitor['Orientation'] == 'ROTATE_270')) {
|
|
$zoneWidth = $newMonitor['Height'];
|
|
$zoneHeight = $newMonitor['Width'];
|
|
}
|
|
$zoneArea = $zoneWidth * $zoneHeight;
|
|
$zone = new ZM\Zone();
|
|
if (!$zone->save(['MonitorId'=>$monitor->Id(), 'Name'=>'All',
|
|
'Units'=>'Percent',
|
|
'Coords'=>'0.00,0.00 100.00,0.00 100.00,100.00 0.00,100.00',
|
|
'Area'=>10000,
|
|
'MinAlarmPixels'=>intval(($zoneArea*.05)/100),
|
|
'MaxAlarmPixels'=>intval(($zoneArea*75)/100),
|
|
'MinFilterPixels'=>intval(($zoneArea*.05)/100),
|
|
'MaxFilterPixels'=>intval(($zoneArea*75)/100),
|
|
'MinBlobPixels'=>intval(($zoneArea*.05)/100)
|
|
])) {
|
|
$error_message .= $zone->get_last_error();
|
|
ZM\Error('Error adding zone:' . $error_message);
|
|
}
|
|
ZM\AuditAction('create', 'monitor', $mid, 'Name: '.($newMonitor['Name'] ?? ''));
|
|
} else {
|
|
$dbError = $monitor->get_last_error();
|
|
$error_message .= 'Error saving new Monitor: '.($dbError ? $dbError : 'unknown database error').'<br/>';
|
|
ZM\Error('Error saving new Monitor: '.$dbError);
|
|
return;
|
|
}
|
|
}
|
|
|
|
$Storage = $monitor->Storage();
|
|
$mid_dir = $Storage->Path().'/'.$mid;
|
|
if (!file_exists($mid_dir)) {
|
|
if (!@mkdir($mid_dir, 0755)) {
|
|
ZM\Error('Unable to mkdir '.$Storage->Path().'/'.$mid);
|
|
}
|
|
}
|
|
|
|
$saferName = basename($newMonitor['Name']);
|
|
$link_path = $Storage->Path().'/'.$saferName;
|
|
if (((string)$saferName !== (string)$mid) and !@symlink($mid, $link_path)) {
|
|
if (!(file_exists($link_path) and is_link($link_path))) {
|
|
ZM\Warning('Unable to symlink ' . $Storage->Path().'/'.$mid . ' to ' . $link_path);
|
|
}
|
|
}
|
|
|
|
if ( isset($changes['GroupIds']) ) {
|
|
dbQuery('DELETE FROM Groups_Monitors WHERE MonitorId=?', array($mid));
|
|
foreach ( $changes['GroupIds'] as $group_id ) {
|
|
dbQuery('INSERT INTO Groups_Monitors (GroupId, MonitorId) VALUES (?,?)', array($group_id, $mid));
|
|
}
|
|
} // end if there has been a change of groups
|
|
|
|
$restart = true;
|
|
} else {
|
|
ZM\Debug('No action due to no changes to Monitor');
|
|
} # end if count(changes)
|
|
|
|
if ( !$mid ) {
|
|
ZM\Error("We should have a mid by now. Something went wrong.");
|
|
return;
|
|
}
|
|
|
|
if ( ZM_OPT_X10 ) {
|
|
$x10Changes = getFormChanges($x10Monitor, $_REQUEST['newX10Monitor']);
|
|
|
|
if ( count($x10Changes) ) {
|
|
if ( $x10Monitor && isset($_REQUEST['newX10Monitor']) ) {
|
|
dbQuery('UPDATE TriggersX10 SET '.implode(', ', $x10Changes).' WHERE MonitorId=?', array($mid));
|
|
} else if ( !$user->unviewableMonitorIds() ) {
|
|
if ( !$x10Monitor ) {
|
|
dbQuery('INSERT INTO TriggersX10 SET MonitorId = ?, '.implode(', ', $x10Changes), array($mid));
|
|
} else {
|
|
dbQuery('DELETE FROM TriggersX10 WHERE MonitorId = ?', array($mid));
|
|
}
|
|
}
|
|
$restart = true;
|
|
} # end if has x10Changes
|
|
} # end if ZM_OPT_X10
|
|
|
|
if ( isset($_REQUEST['newAction']) and is_array($_REQUEST['newAction']) ) {
|
|
require_once('includes/MonitorAction.php');
|
|
|
|
$submitted_ids = array();
|
|
foreach ( $_REQUEST['newAction'] as $posted ) {
|
|
# A row with no target is how the editor expresses "unused": the spare
|
|
# blank row on every page, or an existing action the operator cleared.
|
|
$target = empty($posted['TargetMonitorId']) ? 0 : validInt($posted['TargetMonitorId']);
|
|
$action_id = empty($posted['Id']) ? 0 : validInt($posted['Id']);
|
|
|
|
if ( !$target ) {
|
|
if ( $action_id )
|
|
dbQuery('DELETE FROM MonitorActions WHERE Id=? AND MonitorId=?', array($action_id, $mid));
|
|
continue;
|
|
}
|
|
|
|
# Never store an action the target cannot perform. The editor filters the
|
|
# dropdown, but the request is not to be trusted. A disabled select is
|
|
# not submitted at all, so the key may simply be absent.
|
|
$action_type = isset($posted['ActionType']) ? $posted['ActionType'] : '';
|
|
$trigger_on = isset($posted['TriggerOn']) ? $posted['TriggerOn'] : 'EventStart';
|
|
|
|
$target_monitor = ZM\Monitor::find_one(array('Id'=>$target));
|
|
$allowed = ZM\MonitorAction::typesForMonitor($target_monitor);
|
|
if ( !in_array($action_type, $allowed) ) {
|
|
ZM\Warning('Ignoring action: monitor '.$target.' cannot '.$action_type);
|
|
continue;
|
|
}
|
|
|
|
$values = array(
|
|
'MonitorId' => $mid,
|
|
'TargetMonitorId' => $target,
|
|
'TriggerOn' => $trigger_on,
|
|
'ActionType' => $action_type,
|
|
'AudioFile' => (!isset($posted['AudioFile']) or $posted['AudioFile'] === '')
|
|
? null : validInt($posted['AudioFile']),
|
|
'Enabled' => empty($posted['Enabled']) ? 0 : 1,
|
|
);
|
|
|
|
$action = $action_id ?
|
|
ZM\MonitorAction::find_one(array('Id'=>$action_id, 'MonitorId'=>$mid)) : new ZM\MonitorAction();
|
|
if ( !$action ) $action = new ZM\MonitorAction();
|
|
|
|
if ( $action->Id() ) {
|
|
$action->save($values);
|
|
$submitted_ids[] = $action->Id();
|
|
} else if ( $action->insert($values) ) {
|
|
$submitted_ids[] = $action->Id();
|
|
} else {
|
|
$error_message .= 'Error saving monitor action: '.$action->get_last_error().'<br/>';
|
|
}
|
|
} # end foreach posted action
|
|
|
|
# zmc re-reads the action list on restart, so a change here needs one.
|
|
$restart = true;
|
|
} # end if newAction
|
|
|
|
if ( $restart ) {
|
|
if ( $monitor->Capturing() != 'None' and $monitor->Type() != 'WebSite' and !$monitor->Deleted()) {
|
|
$monitor->zmcControl('start');
|
|
|
|
if ( $monitor->Controllable() ) {
|
|
$monitor->sendControlCommand('start');
|
|
}
|
|
}
|
|
// really should thump zmwatch and maybe zmtrigger too.
|
|
//daemonControl( 'restart', 'zmwatch.pl' );
|
|
} // end if restart
|
|
if (!$error_message)
|
|
$redirect = '?view=console';
|
|
} else {
|
|
ZM\Warning("Unknown action $action in Monitor");
|
|
} // end if action == Delete
|
|
?>
|