Commit Graph
322 Commits
Author SHA1 Message Date
Isaac ConnorandClaude Opus 5.5 c3d2e542bc fix: never build an object from an id the request sent as an array
ZM_Object's constructor treats an array as the object's row, and validInt()
and validCardinal() returned an array as they got it (preg_replace() does).
So wherever a request id reached `new ZM\X(...)`, raw or through those
validators, a crafted array built an object whose Id, UserId or MonitorId
the sender chose. The permission check then looked at those, and the delete
or update that followed used the Id. A user with Events=View deleted another
user's filter by posting Id[Id]=<filter>&Id[UserId]=<self> to the filter
delete action; the same shape reached events (MonitorId), users, reports,
snapshots and storage.

validInt() and validCardinal() now give '' for anything that isn't a single
value. The places that built an object from a raw $_REQUEST value (the
control and event ajax handlers, the event, filter, report, snapshot and
storage actions, view_hls, and the download and event detail modals) take
the id through validCardinal().

tests/php/test_request_ids_scalar.php covers the validators with arrays and
fails if web code builds an object straight from $_REQUEST, $_GET or $_POST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:07:08 -04:00
Isaac ConnorandClaude Opus 5.5 13138a0afc fix: check each event's monitor when editing event details
actions/eventdetail.php checked only the global Events=Edit permission and
then updated the Name, Cause and Notes of every id in markEids, so a user
denied some monitors could rewrite those monitors' events. The event detail
modal likewise showed the name, cause and notes of any event id it was given.

Load each event and require $event->canEdit(), which adds the per-monitor
view check, as archiving and unarchiving in actions/events.php already do.
The action skips events that fail it; the modal leaves them out, and shows
nothing for a single event that fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:21:55 -04:00
Isaac ConnorandClaude Opus 5.5 7eb7dc4bff fix: only redirect back to the Referer from control and event detail actions when it is on this server
actions/control.php and actions/eventdetail.php redirected to
$_SERVER['HTTP_REFERER'] as is. That is the Referer of the POST itself, which
normally comes from a ZoneMinder page, and the CSRF token keeps other sites
from making the POST, so it isn't the open redirect the zone save had. But it
is the same pattern, and a missing Referer warned about an undefined index and
rendered the action's view instead of redirecting.

Use localRedirectUrl() as the zone save does, falling back to the monitor's
control view and to the events list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:19:01 -04:00
Isaac ConnorandClaude Opus 5.5 16817e4ffc fix: only redirect back to a zone's Referer when it is on this server
After saving a zone, actions/zone.php redirected to the REFERER the zone form
posted, which the form fills from the Referer its page was loaded with. A
zone editor who reached the zone page through a link on another site was sent
to that site after saving: an open redirect, usable to lead someone from a
trusted ZoneMinder page to a look-alike login. Forging the POST itself is
blocked by the CSRF token, so it needs an editor who followed such a link.

localRedirectUrl() returns the target only if it is a query-only URL or an
absolute http(s) URL on the host and port of the current request, and the
fallback otherwise: another host, //host, backslashes, user@host, other
schemes and control characters all give the fallback. The zone save falls
back to the zones list, as it already does with no Referer (#5207).

tests/php/test_local_redirect_url.php covers 21 cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:16:14 -04:00
nicandClaude Opus 5.5 6a35e89c14 fix: go to the zones list after saving a zone opened without a Referer (#5207)
The zone form posts its Referer back as REFERER, and posts it empty when
the form was opened from a typed URL or a bookmark. The action used it
whenever it was set, so $redirect was empty, index.php rendered the zone
view with the action's $zone (a database row, or array() for a new zone,
neither with Points) and ksort() at views/zone.php:105 failed with
'Argument #1 must be of type array, null given'. The zone was saved but
the browser got a 500.

Use REFERER only when it is not empty and fall back to the monitor's
zones list.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:15:08 -04:00
Isaac ConnorandClaude Opus 5 af0d6ddc32 refactor: remove RTSPDescribe, which lost its reader with the rtsp client
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
2026-10-10 09:32:17 -04:00
Isaac ConnorandClaude Opus 5.5 1b2bc60936 fix: give ZM\User real canView/canEdit/canCreate area checks refs GHSA-ff93-w6fm-vqxr
Filter::canView(), canEdit() and canDelete() ask the user object
$u->canEdit('System'), $u->canView('Events') and so on, but ZM\User had no such
methods. The calls fell through to ZM_Object::__call(), which stored the argument
as a property and returned it, so every check returned a truthy area name. Any
account with Events=View passed as a System editor and could save and run a
filter with AutoExecute, an OS command run by zmfilter.pl. The same fall-through
made the filter ownership and filterdebug checks pass for everyone.

Add canView(), canEdit() and canCreate() to ZM\User. They take the level from the
user's own field and from their role, the way the global functions in auth.php do
for the logged-in user, and refuse anything that is not a permission area.
Monitor::commandFieldChangesNotAllowed() now uses canEdit('System') so a role's
System=Edit counts there too.

In the filter action, require edit on a stored filter before applying the
submitted fields when saving over it, since the fields can name a new owner;
stop after a refused save instead of starting the filter in the background; and
require edit on the filter for start/stop/restart, which had no check.

tests/php/test_user_permissions.php drives the real ZM\User, ZM\User_Role and
ZM\Filter classes. The older filter tests stubbed the user with its own
canView/canEdit, which is why they passed while the real class had neither.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:14:17 -04:00
Isaac ConnorandClaude Opus 5.5 1677470af1 fix: require System edit to change a monitor's event commands refs GHSA-vcrj-35c6-g97c
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>
2026-10-07 08:09:33 -04:00
Isaac ConnorandClaude Opus 5.5 7c09ebd9e2 fix: require edit on each monitor a group change moves
Group membership feeds per-monitor access through Groups_Permissions, but
the classic group actions and the Groups API checked only the global
Groups permission. A Groups editor could add a monitor they are denied to
a group they have access through, or remove it from, or delete, the group
that denies it.

Add Group::canEditMembership() and require it, in both the classic UI and
the API, for every monitor added to or removed from a group, for all of a
group's monitors when it is re-parented, and for all of them when it is
deleted. The API also pins the record id and omits monitors the user may
not view from the groups it lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:54:25 -04:00
Isaac ConnorandClaude Opus 5.5 44339b0fe7 fix: authorize event delete, archive and edits on the event's monitor refs GHSA-34x2-mw89-c52f
The classic event actions checked only the global Events permission and
then acted on whatever event ids the request supplied, so a user denied a
monitor could still change that monitor's events:

- deleteEvent() deleted when the user had Events=Edit. It now requires
  Event::canEdit(), which also requires access to the event's monitor.
  This covers the events form, the event form and monitor deletion.
- The events form archive/unarchive updated Events by id. Each event now
  needs canEdit().
- ajax events archiveRequest() updated by id under the page-wide Events
  view check. Archive now needs canView() on the event and unarchive
  canEdit(), keeping the intent that viewers may archive.
- actions/event.php returned early whenever an eid was supplied, so none
  of its actions ran. Fix that inverted test, and require canEdit() on the
  event for rename, detail edits, archive, unarchive and delete, rather
  than the global permission.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:34:36 -04:00
Isaac Connor eafdffcd95 feat: add per-monitor actions that drive a light or a speaker on alarm
A camera that detects motion should be able to sound a speaker, and the
speaker is rarely the camera: it is a separate device with its own address,
credentials, stream and Controls entry. Model it as a monitor and let a
monitor's alarm drive actions on other monitors.

Add Monitors.DeviceClass enum('Camera','Speaker'). This is what the device
is, as distinct from Type, which selects the capture backend - an IP speaker
still captures over Ffmpeg like any other RTSP device, so Type could not
carry the distinction.

Add the MonitorActions table: MonitorId is the monitor that triggers,
TargetMonitorId the device acted on, and the two are frequently different.
TriggerOn covers EventStart, EventEnd, Alarm and Manual.

Which actions a device is offered is decided by its Controls row - CanLight,
CanIndicatorLight, CanAudioPlay - so a device can only be asked to do what it
has been measured to do. The editor filters on this and the save path
re-checks it, because the request is not to be trusted.

Execution goes straight to the target's zmcontrol socket rather than forking
zmcontrol.pl per action: the daemon already accepts a line of JSON there, and
it is the same path the control panel uses. ActionCommandName maps the DB
enum onto a method name as a whitelist, so nothing out of the database
reaches the control daemon uninspected. Actions are fire-and-forget - a
speaker that is offline is logged and skipped, never allowed to hold up event
handling.

Alarm actions fire only on the genuine entry into alarm, not on the
ALERT->ALARM re-entry, which would re-sound a speaker within one incident.
EventEnd runs on the calling thread before the event is handed to the closing
thread, which does not capture `this`.

Manual actions appear as buttons on the watch page, and are the reason the
control panel is now shown for a monitor that has actions but no control of
its own. Firing one sends only the action id; the command and target are
rebuilt server side, and Control rights are required on the target device and
not merely on the monitor the action hangs off.

Also add --file to zmcontrol.pl, without which audioPlay could not be driven
from the command line. The web path was unaffected as it bypasses GetOptions.

Tests: tests/zm_monitor_action.cpp covers the command whitelist, the message
format (including that file id 0 is a real id and that a stale AudioFile is
never passed to a command that takes none), trigger names, and that every
value of the ActionType enum maps to a command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
(cherry picked from commit b561341e988af69c3a46db854da410309f7cfa82)
2026-09-11 20:13:02 -05:00
Isaac Connor a6bb811e97 feat: add audio playback control capability and an ONVIF IP speaker module
An IP speaker is controllable but has none of the capabilities the Controls
table models: nothing moves, focuses or lights up. It plays a sound file it
already holds, selected by a numeric id, and carries an output volume.

Add CanAudioPlay, MinAudioFile, MaxAudioFile and CanAudioVolume to Controls,
with migration zm_update-1.39.21.sql. CanAudioPlay renders one button per
sound id plus a stop button; MinAudioFile/MaxAudioFile bound that range;
CanAudioVolume renders the volume pair. The sound id travels in the command
name (audioPlay12), the way presets already do, because a control button has
no way to attach a separate parameter.

Add ZoneMinder::Control::IPSpeaker driving the device's own HTTP interface,
and a Controls entry for it. Playback goes over the vendor interface rather
than ONVIF because the ONVIF audio output service on this firmware only
describes the output and offers no way to start a stored file.

Measured against a device reporting ONVIF Manufacturer "IPSpeaker" and
firmware CS20-V3.3.45N:

- File ids fall in two windows, 10-14 built-in and 20-30 operator uploads.
  Anything else is refused with result -2, and an id in a window with no file
  uploaded with result -3, so valid_fileid refuses only the former: an empty
  slot is a legitimate id the operator may fill later.
- config=audio.set replaces the whole audio section, so a partial write
  reverts the microphone, codec list and echo-cancellation settings. Every
  volume change reads the section and echoes it back with the new level.
  audio.get also returns outmute, which audio.set rejects, so it is excluded
  from the field list.
- The volume reads back and round-trips, but the device's RTSP audio is a
  pre-volume tap of the playback signal: it is unchanged with outvolume at 0,
  so it cannot confirm the loudspeaker's acoustic output. CanAudioVolume is
  set on the strength of the setting persisting, and both the POD and the
  seed row say the acoustic effect is unconfirmed.

The seed row covers the built-in window only, because a fresh device has
nothing uploaded and the firmware refuses an empty slot.

Adding columns to Controls extends the 43 positional INSERTs in
db/controls.sql, which carry no column list and so must match the column
count exactly.

Tests: scripts/ZoneMinder/t/ip_speaker.t, 32 assertions covering file id
windows, volume clamping and stepping, request building and the whole-section
write. All pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
(cherry picked from commit d07e552a3c58373f7f0ccebf9aa28adb4d51baa3)
2026-09-11 20:13:02 -05:00
Jash a0ef89d1f3 fix: report a failed monitor delete instead of silently ignoring it fixes #4215 2026-08-30 01:37:45 +05:30
Isaac ConnorandClaude Opus 5 465c3e2dfc fix: reject unrecognised zmBandwidth values instead of undefining every ZM_WEB_ constant
web/skins/classic/includes/config.php defines all 18 ZM_WEB_* constants inside
a switch on $_COOKIE['zmBandwidth'] with cases for high, medium and low and no
default. On any other value none of them are defined, and skin.js.php - emitted
in the footer of every page - reads ZM_WEB_VIEWING_TIMEOUT, ZM_WEB_AJAX_TIMEOUT
and ZM_WEB_REFRESH_NAVBAR. On PHP 8 an undefined constant is a fatal Error, so
every page including login stops rendering until the cookie is cleared, which
cannot be done from inside the interface.

skin.php only tested the value for empty, and nothing else validated it:

- the cookie is set client side by skin.js, so any value survives
- action=bandwidth put $_REQUEST['newBandwidth'] through validStr, which is
  only strip_tags, and persisted it
- ZM_BANDWIDTH_DEFAULT is a free-form string in ConfigData. The Options UI
  renders it as a select, but loadConfig lets a conf.d file override the
  database, so a typo there locks out everyone with no cookie yet

skin.php now validates both the cookie and ZM_BANDWIDTH_DEFAULT before falling
back to low, and the action rejects a value it does not recognise rather than
storing it.

tests/php/test_bandwidth_clamp.php checks the whitelist against the switch it
guards - the two must name the same profiles, since a value in one and not the
other reopens this - and that no arm of the switch defines a constant the
others do not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
2026-08-29 14:01:25 -04:00
Isaac ConnorandClaude Opus 5 3d8fdf8677 fix: stop the monitor edit form from undeleting a deleted monitor
Saving a deleted monitor undeleted it even though the undelete checkbox was
never ticked.

Two causes. The $types map, whose entries exist so that unticked checkboxes
(which submit nothing) get forced to a default, listed Deleted => 0. But the
Deleted checkbox is not a state toggle, it is an undelete action: it is only
rendered for a deleted monitor and only ever submits 0. Forcing it to 0 when
absent put Deleted => 0 into every $changes set. Remove it from $types so an
absent Deleted simply is not a change.

Second, the guard meant to catch the "new monitor reusing a deleted record's
Id" case tested isset($_REQUEST['newMonitor[Deleted]']), a literal-bracket key
that never exists, so $monitor->Deleted(false) ran on every save of a deleted
record. Since save() writes every field, that persisted. Test empty(mid)
instead, which is exactly the case the comment describes: mid is absent when
adding a new monitor whose requested Id collides with a deleted row. Editing an
existing monitor by mid now only undeletes via the checkbox, which arrives as
newMonitor[Deleted]=0 and shows up in $changes on its own.

Manual test on a deleted monitor: editing and saving with the checkbox
unticked keeps Deleted set and persists the other edits; ticking the checkbox
and saving clears Deleted as before. The restart block was already guarded by
!$monitor->Deleted(), so no capture daemon is started for a monitor that stays
deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDhTBPj9xEaT52pRmAufaP
2026-08-05 22:52:36 -04:00
Isaac ConnorandClaude Opus 4.8 38a817353b fix: enforce Storage containment before deleting files
The delete action computed $is_ok_path to confirm the requested path sits
below a configured Storage area, but never consulted it, so the check was
dead code and unlink() ran on whatever path was supplied. The path comes
from detaintPathAllowAbsolute($_REQUEST['path']), which deliberately
permits absolute paths, so nothing else constrained the target.

Return with an error when the path is not below a Storage area. Deleting
already requires System Edit, so this is not reachable by a low privilege
user, but the containment check should do what it was written to do.

The adjacent $path_parts assignment is also unused; left in place as it
predates this change.

Refs GHSA-g355-3rf6-f38v.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 13:14:12 -04:00
Isaac ConnorandClaude Opus 4.8 787722d103 feat: add AI dataset/model/class management UI to Options
Add three Options tabs (AI Datasets, AI Models, AI Classes) with full
CRUD, backed by the AI_* tables:

- List views (_options_ai_{datasets,models,classes}.php), edit modals
  (ajax/modals/ai_{dataset,model,class}.php) and action handlers
  (actions/ai_{dataset,model,class}.php).
- options.js loads the modals over ajax, wires the Add/edit buttons and
  the AI Classes dataset filter.
- options.php dispatches the new tab includes; functions.php registers
  the three tabs in the Options sub-menu with readable labels (they are
  not Config categories, so they need explicit entries).
- actions/options.php routes object=ai_* deletes to the matching
  handler; saves post directly to view=ai_*.

All tabs and actions are gated on System permission.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:24:50 -04:00
Isaac Connor f04b3613ac Merge branch 'menu-customization' 2026-06-27 12:59:15 -04:00
Isaac ConnorandClaude Opus 4.8 48fadaebbf feat: customizable menu entries with links, icons and delete on options menu tab
Port the menu customization work from the ai_server branch onto master:
- Add/edit/delete custom navbar/sidebar menu entries via Options > Menu
- Per-entry Link column (new Menu_Items.Link) with ?view= fallback derived
  the same way as built-in items; custom entries render via buildMenuItem
- Live icon preview (material/font awesome) with fixed-width preview cell
- Per-row delete icon; add/reset buttons with tooltips
- Surface DB errors when saving options config: capture the swallowed PDO
  error via dbLastError() and show it in the page error banner instead of
  redirecting past it

Menu_Items.Link is added to zm_create.sql.in and as an idempotent ALTER in
zm_update-1.39.16.sql.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:13:49 -04:00
Isaac ConnorandClaude Opus 4.7 f26dee598e fix: surface DB error when new monitor insert fails fixes #4944
The insert branch only logged via ZM\Error and returned, leaving
$error_message empty. The dispatcher then re-rendered views/monitor.php
with no banner and the user saw an apparent success that silently
discarded their new monitor.

Append $monitor->get_last_error() to $error_message so the existing
<div id="error"> in getBodyTopHTML() shows the actual DB message, and
keep the early return so the user stays on the edit view with the form
values preserved by views/monitor.php's $_REQUEST['newMonitor'] handling.
Matches the pattern already used on the update path at line 273.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-27 12:07:22 -04:00
IgorA100 fa744743e5 Canonicalize the storage path (monitor.php) 2026-06-13 17:23:02 +03:00
IgorA100 c2820d1317 - Don't create symlinks if the monitor name is equal to its ID. This was previously broken in https://github.com/IgorA100/zoneminder/commit/9561ed894738a707814f0b25a11420241b89b29d
- Moved $oldLinkPathFound analysis outside the storages loop
- More correct check for an empty $_REQUEST['monitorName'] value
2026-05-27 12:12:38 +03:00
IgorA100andCopilot Autofix powered by AI d4bbf23c98 Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-27 09:57:42 +03:00
IgorA100 37ed304994 To find old symlinks, compare $oldLinkPath with $linkPath, not $absolutePath. 2026-05-26 16:35:56 +03:00
IgorA100 3de3dc7d21 - Corrected English and Russian translations
- Check opendir($storagePath) before reading files.
- Use strict validation when creating symlinks
2026-05-25 00:35:40 +03:00
IgorA100 7eb4ae012e Merge branch 'master' into patch-401263 2026-05-15 15:08:45 +03:00
IgorA100 a95efdbd42 When changing the monitor name, delete all symlinks in all storages (monitor.php)
Now we scan directories in all storages for any symbolic links to the events folder of the renamed monitor and delete all found symbolic links in all storages. This is necessary to remove any possible symbolic link garbage from the previous operating algorithm or after other failures.

After deleting all old symbolic links, we create a new one with the new monitor name.
2026-05-14 16:49:18 +03:00
IgorA100 9561ed8947 If the monitor name has changed, you must change the symbolic links in all repositories that contain folders for recording events for this monitor. (monitor.php) 2026-05-14 14:13:22 +03:00
IgorA100 4453d15f82 Update monitor.php 2026-05-11 22:09:20 +03:00
Isaac ConnorandClaude Opus 4.7 2630d55ffb feat: add Report::canEdit() and CreatedBy column
Add a CreatedBy column to the Reports table and a canEdit() method on
the Report class so $report->canEdit() (already called from
web/ajax/reports.php) resolves to a real check. canEdit() permits the
report owner (CreatedBy == user) or any user/role with System=Edit.
Wire actions/report.php to stamp CreatedBy on first save and refuse
save/delete on existing reports the current user cannot edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:24:15 -04:00
IgorA100 dcbb7b65da - When changing the monitor name, also change the symlink, instead of simply deleting the old one.
- Condition adjustment. Changed from:
if (($saferName != $newMonitor['Name']) and !@symlink($mid, $link_path)) {
to:
if (($saferName != $newMonitor['Id']) and !@symlink($mid, $link_path)) {
This condition was changed in https://github.com/ZoneMinder/zoneminder/commit/203418d45ed6fb2a96a77202a7b20c79bc373ef4
But there was probably an error in the condition, since comparing one variable to itself is pointless, unless we're looking for prohibited characters in the name.
2026-05-03 00:58:00 +03:00
Isaac ConnorandClaude Opus 4.6 bb9e74a2f9 fix: only validate Device path for Local monitors
Follow-up to 419846c87 (GHSA-g66m-77fq-79v9). The Device path check was
applied to all monitor Types in three places, but the Device column is
only passed to a shell for Type='Local'. Non-Local monitors (Ffmpeg,
Remote, Libvlc, cURL, VNC) may legitimately hold legacy values such as
an RTSP URL in that column and should not be rejected or warned about.

- scripts/ZoneMinder/lib/ZoneMinder/Monitor.pm: control() dropped the
  spurious Warning for non-Local monitors that was flooding zmwatch
  logs. The Error/early-return path is preserved for Local.
- web/includes/actions/monitor.php: save action only runs
  validDevicePath() when Type=='Local'.
- web/api/app/Model/Monitor.php: replaced the unconditional regex rule
  with a validDevicePath() method that checks Type before enforcing
  the /dev/ pattern.

Also add client-side validation matching the server rule, so Local
monitors get immediate feedback instead of a round-trip error:

- web/skins/classic/views/monitor.php: HTML5 pattern attribute on the
  Device input. Escaped for the v-flag regex engine used by pattern=.
- web/skins/classic/views/js/monitor.js.php: validateForm() now also
  rejects Device values that don't match the /dev/ pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 08:44:07 -04:00
Isaac ConnorandClaude Opus 4.6 8044c80f9d feat: make Remember Me a tri-state option (None/Yes/No)
Change ZM_OPT_USE_REMEMBER_ME from a boolean to a tri-state string:
- None: checkbox hidden, sessions persist for ZM_COOKIE_LIFETIME (old disabled)
- Yes: checkbox shown and pre-checked by default
- No: checkbox shown and unchecked by default (old enabled behavior)

Update ConfigData.pm.in with new type definition, login.php to honor the
checked state, and session/action handlers to recognize the new values.
Migration in zm_update-1.39.4.sql maps old '1' to 'No' and '0' to 'None'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:35:41 -04:00
Isaac ConnorandClaude Opus 4.6 cd33af3ad9 fix: use text input for menu item image icon path instead of file upload
Replace the file upload input with a text input for image icon type,
accepting a URL path relative to the web root. Remove multipart form
encoding, file upload handling, and uploaded file cleanup on reset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:15:46 -04:00
Isaac ConnorandClaude Opus 4.6 c2b2de4a86 feat: show both percentage and pixel values in zone edit UI
Remove the Units selector and always store/display thresholds as
percentages. Add editable pixel inputs alongside each percentage field
with bidirectional sync. Zone area is now displayed as plain text
instead of readonly inputs. Pixel inputs are disabled/enabled in sync
with their percentage counterparts based on zone type and check method.

Server-side enforcement ensures values > 100 are converted from pixel
counts to percentages before storage and clamped to 0-100.

Add aspect-ratio to imageFeed container to prevent layout flash on load.
Hide number input spinners in zone settings panel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:14:53 -04:00
Isaac ConnorandClaude Opus 4.6 29d371313a fix: store zone thresholds as percentages and convert to pixels at runtime
Zone threshold fields (MinAlarmPixels, MaxAlarmPixels, MinFilterPixels,
MaxFilterPixels, MinBlobPixels, MaxBlobPixels) were being corrupted on
every save because the PHP conversion used monitor pixel area instead of
zone pixel area. This caused values to inflate progressively, breaking
motion detection.

The fix changes the storage model: thresholds are now stored as
percentages of zone area (DECIMAL(7,2) columns) matching the percentage
coordinate system from zm_update-1.39.2. The C++ Zone::Load() converts
percentages to pixel counts at runtime using polygon.Area(). Legacy
pixel-coordinate zones pass through unchanged.

JS changes:
- submitForm() converts to percentages when in Pixels display mode
- Init skips applyZoneUnits() since DB values are already percentages
- limitRange() only updates field.value when constrained value differs,
  preventing oninput from stripping decimal points mid-keystroke

fixes #4690

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 17:16:56 -04:00
Isaac ConnorandClaude Opus 4.6 47cdb699ba feat: add 'none' icon type, show icons in navbar, fix console column dropdown
- Add 'none' as icon type option to hide icons on individual menu items
- Display custom icons in top navbar to match left sidebar appearance
- Fix console table Columns dropdown showing raw HTML by moving icon/link
  rendering to JS post-init (bootstrap-table captures th innerHTML for
  dropdown labels, so icons must be injected after initialization)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:59:40 -04:00
Isaac ConnorandClaude Opus 4.6 7364ba036a feat: add custom icon support to menu items (material, font-awesome, image upload)
Add Icon and IconType columns to Menu_Items table allowing admins to
override the default material icon for each menu entry. Supports three
icon types: Material Icons, Font Awesome 4.7 classes, and uploaded
image files (stored in graphics/menu/).

Add renderMenuIcon() helper and $menuIconOverride global to pass
custom icons through to buildMenuItem() and getOptionsHTML() without
changing every get*HTML() function signature.

Options > Menu tab now shows icon preview, type selector, and
text/file input per row. Upload handler validates file type and
cleans up old images on change or reset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:01:05 -04:00
Isaac ConnorandClaude Opus 4.6 4bd56c22d9 feat: add customizable navbar/sidebar menu via Options > Menu tab
Add Menu_Items table to store per-item enabled state, custom labels,
and sort order. Admins can enable/disable menu items, set custom
labels, and reorder via drag-and-drop in Options > Menu tab.

Refactor hardcoded get*HTML() calls in buildSidebarMenu(),
getNormalNavBarHTML(), and getCollapsedNavBarHTML() to data-driven
renderMenuItems() that reads from DB with fallback for empty table.

Remove deprecated Cycle view from menu (functionality merged into
Watch view). Add reset button to restore default menu configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:37:14 -04:00
Isaac ConnorandClaude Opus 4.6 419846c875 fix: sanitize monitor Device path to prevent command injection (GHSA-g66m-77fq-79v9)
The Device field from the Monitors table was interpolated directly into
shell commands (qx(), backticks, exec()) without sanitization, allowing
authenticated users with monitor-edit permissions to execute arbitrary
commands as www-data via the Device Path field.

Defense in depth:
- Input validation: reject Device values not matching /^\/dev\/[\w\/.\-]+$/
  at save time in both web UI and REST API
- Output sanitization: use escapeshellarg() in PHP and quote validated
  values in Perl at every shell execution point

Affected locations:
- scripts/ZoneMinder/lib/ZoneMinder/Monitor.pm (control, zmcControl)
- scripts/zmpkg.pl.in (system startup)
- web/includes/Monitor.php (zmcControl)
- web/includes/functions.php (zmcStatus, zmcCheck, validDevicePath)
- web/includes/actions/monitor.php (save action)
- web/api/app/Model/Monitor.php (daemonControl, validation rules)
- web/api/app/Controller/MonitorsController.php (daemonStatus)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:19:03 -04:00
Isaac ConnorandClaude Opus 4.6 3432e39c45 feat: add Remember Me checkbox to login page
Add ZM_OPT_USE_REMEMBER_ME config option (auth section, requires
ZM_OPT_USE_AUTH) that controls whether a Remember Me checkbox appears
on the login form. When enabled and unchecked, the session cookie
lifetime is set to 0 so the browser discards it on close, logging the
user out. When checked, the session persists for ZM_COOKIE_LIFETIME.
When the option is disabled, behavior is unchanged.

- ConfigData.pm.in: new ZM_OPT_USE_REMEMBER_ME boolean option
- login.php: checkbox between password field and reCAPTCHA/submit
- session.php: use lifetime=0 when remember me is off
- actions/login.php: set/clear ZM_REMEMBER_ME cookie on login, also
  update $_COOKIE so zm_session_start sees it in the same request
- auth.php: clear ZM_REMEMBER_ME cookie on logout
- en_gb.php: add RememberMe translation string

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:41:25 -05:00
Isaac ConnorandClaude Opus 4.6 e6ace6fcf4 feat: add AUDIT logging level for tracking administrative changes
Add a new AUDIT logging level (-5) between PANIC (-4) and NOLOG (shifted
to -6) across C++, PHP, and Perl loggers. AUDIT entries use code 'AUD'
and syslog priority LOG_NOTICE. They record who changed what, from where,
for monitors, filters, users, config, roles, groups, zones, states,
servers, storage, events, snapshots, control caps, and login/logout.

AUDIT entries have their own retention period (ZM_LOG_AUDIT_DATABASE_LIMIT,
default 1 year) separate from regular log pruning. The log pruning in
zmstats.pl and zmaudit.pl now excludes AUDIT rows from regular pruning
and prunes them independently.

Critical safety: the C++ termination logic is changed from
'if (level <= FATAL)' to 'if (level == FATAL || level == PANIC)' to
prevent AUDIT-level log calls from killing the process.

Includes db migration zm_update-1.39.1.sql to shift any stored NOLOG
config values from -5 to -6.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 18:19:20 -05:00
Isaac ConnorandClaude Opus 4.6 c0016fa00b feat: store zone coordinates as percentages for resolution independence
Convert zone coordinates from absolute pixel values to percentages
(0.00-100.00) so zones automatically adapt when monitor resolution
changes. This eliminates the need to manually reconfigure zones after
resolution adjustments.

Changes:
- Add DB migration (zm_update-1.37.81.sql) to convert existing pixel
  coords to percentages, recalculate area, and update Units default
- Add Zone::ParsePercentagePolygon() in C++ to parse percentage coords
  and convert to pixels at runtime using monitor dimensions
- Backwards compat: C++ Zone::Load() checks Units column and uses old
  pixel parser for legacy 'Pixels' zones
- Update PHP coordsToPoints/mapCoords/getPolyArea for float coords,
  replace scanline area algorithm with shoelace formula
- Update JS zone editor to work in percentage coordinate space with
  SVG viewBox "0 0 100 100" and non-scaling-stroke for consistent
  line thickness
- Position zone SVG overlay inside imageFeed container via JS to align
  with image only (not status bar)
- Support array of zone IDs in Monitor::getStreamHTML zones option
- Update monitor resize handler: percentage coords don't need rescaling,
  only threshold pixel counts are adjusted
- Add 8 Catch2 unit tests for ParsePercentagePolygon

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 18:19:20 -05:00
Isaac ConnorandClaude Opus 4.6 e1effdb1c7 fix: restore filter Name and UserId after save/reload
The post-save redirect included filter query params in the URL, which
caused the view to populate from request data instead of loading from
the database. Since the querystring omitted Name and UserId, both
fields appeared empty/wrong after reload. Remove the querystring from
the redirect so the view loads the saved filter from the database.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 12:33:46 -05:00
Isaac ConnorandClaude Opus 4.5 d89f2e59db refactor: rename Janus_Use_RTSP_Restream to Restream
Rename Janus-specific restream fields to be more generic since they are
now used by Go2RTC and RTSP2Web as well:
- Janus_Use_RTSP_Restream → Restream
- Janus_RTSP_User → RTSP_User

Update visibility logic so the Restream checkbox appears when RTSPServer
is enabled AND any streaming service (Janus, Go2RTC, or RTSP2Web) is
selected, rather than only when Janus is enabled.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 18:47:37 -05:00
Isaac ConnorandClaude Opus 4.5 4e60cb96a7 feat: add User Roles feature for reusable permission templates
Add a User Roles system where roles define reusable permission templates.
When a user has a role assigned, the role provides fallback permissions
(user's direct permissions take precedence; role is used when user has 'None').

Database changes:
- Add User_Roles table with same permission fields as Users
- Add Role_Groups_Permissions table for per-role group overrides
- Add Role_Monitors_Permissions table for per-role monitor overrides
- Add RoleId foreign key to Users table

Permission resolution order:
1. User's direct Monitor/Group permissions (if not 'Inherit')
2. Role's Monitor/Group permissions (if user has role)
3. Role's base permission (if user's is 'None')
4. User's base permission (fallback)

Includes:
- PHP models: User_Role, Role_Group_Permission, Role_Monitor_Permission
- Role management UI in Options > Roles tab
- Role selector in user edit form
- REST API endpoints for roles CRUD
- Translation strings for en_gb

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 13:34:27 -05:00
Steve GilvarryandClaude Sonnet 4.5 c4385768ad Fix default zone creation to account for monitor rotation
When creating a new monitor with Orientation set to ROTATE_90 or
ROTATE_270, the default "All" zone dimensions are now correctly swapped
to match the rotated image dimensions. This prevents zm_zone.cpp from
reporting that zones extend outside of image dimensions and having to
fix them at runtime.

Fixes issue where monitors created with Rotate Right or Rotate Left
would generate warnings like:
"Zone 1/All for monitor X extends outside of image dimensions,
(0,0), (3839,2159) != (2160,3840), fixing"

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-08 22:58:59 +11:00
Isaac Connor 0ccc604380 Fix \n\r to \n preg_replace. Must use double quotes to do it right. 2025-10-15 17:37:29 -04:00
Isaac Connor 203418d45e Only make symlink if name is different than id 2025-09-06 11:37:07 -04:00
Isaac Connor b3d5fb765b Fix dhcp-option handling 2025-07-30 11:16:09 -05:00