mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-10-12 23:03:52 -04:00
RTSPDescribe chose whether to take the media url from the DESCRIBE response rather than the configured path. That was a quirk of ZoneMinder's own rtsp implementation; ffmpeg decides it itself. Removing that client left the setting with nothing behind it -- the monitor form still drew the checkbox and the save path still wrote the column, but no code read it, so the only thing it could do was mislead whoever ticked it. Drops the form row and the Remote-only block that existed to hold it, the javascript that showed and hid it as the protocol changed, the field in the php and perl monitor models, the default in the save action, the column from the schema, and the label and help text from all the language files carrying them. The 1.39.37 migration drops the column behind an information_schema guard scoped to DATABASE(), so the file stays re-runnable. Verified by building a pre-upgrade database from upstream/master's own schema and applying the migration: the column goes, and Monitors matches a fresh install of this branch column for column. Migration applied three times with no error. php -l clean on the four edited files and all 28 language files, perl -c clean, build clean, 154 test cases pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
453 lines
18 KiB
PHP
453 lines
18 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,
|
|
'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
|
|
?>
|