Commit Graph
143 Commits
Author SHA1 Message Date
Isaac Connor 9f6f8338e6 Merge pull request #5078 from AJ0070/fix/4215-report-monitor-delete-error
fix: report a failed monitor delete instead of silently ignoring it
2026-08-31 23:10:12 -04:00
Jash Ambaliya af9c3c8d10 Merge branch 'master' into fix/3301-events-ajax-error 2026-08-31 18:03:02 +05:30
Jash 461adbbd20 fix: show the events table error and clear loading on both paths refs #3301 2026-08-31 17:40:09 +05:30
Isaac Connor 859af3a8f4 Merge pull request #5070 from AJ0070/fix/4423-zmcontrol-lock
fix: lock to stop a second zmcontrol server per monitor
2026-08-30 15:39:17 -04:00
Jash 386a12a174 fix: record the lock holder pid and separate held from unopenable refs #4423 2026-08-30 03:47:14 +05:30
Jash eff68f4103 fix: stop the events table loading forever when the query fails refs #3301 2026-08-30 01:52:22 +05:30
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 8044af78e2 refactor: derive the ZM_WEB_ bandwidth aliases from the profile prefix
The three profiles' settings were aliased by a switch with one arm per
profile, each repeating the same 18 define() calls against a different
ZM_WEB_<H|M|L>_ prefix. Sixty three lines in which only a single letter
differed, and nothing held the arms in step: a setting added to one and not
the others is undefined for two thirds of users, which is the same blank page
the missing default case caused, just narrower.

Name the settings once and build both sides from the prefix. The two settings
that carried a defined() guard keep it, as a separate list with the fallback
each one uses, so a genuinely absent setting is still distinguishable from a
mistyped one - the rest go through constant() and fail loudly.

Looking up an unknown profile now yields the low prefix instead of skipping
every define, so the skin config no longer depends on skin.php having clamped
the cookie first; that clamp remains the place a bad value is corrected.

Verified by diffing every resulting ZM_WEB_ constant against the previous
implementation for each of the three profiles: 49 constants, identical values.

The test drops the checks that only made sense against the switch and gains
ones for the new shape. It loads the skin config in a child process, once per
profile probed, because constants cannot be redefined and loading it is itself
what can fail. It now catches a mistyped setting name, a removed fallback, a
profile the whitelist does not know, and a setting dropped from the alias list
- the last by way of the per-profile config options, which are the authority
on which settings exist and are independent of the lists under test.

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 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 94f41aab27 fix: write Config.DefaultValue in the same form as Config.Value
The C++ loader reads back only the rows where Value differs from DefaultValue
and takes compiled-in defaults for the rest, so the two columns have to be
written in the same form. Both writers passed Value through a boolean
conversion and DefaultValue through none: a boolean left alone was stored as
Value '1' against DefaultValue 'yes', never compared equal, and was read back
on every start. The filter did nothing for the 77 boolean rows.

Both columns now go through ConfigData::dbValue. It lives there because the
two writers - saveConfigToDB for an existing install and zmconfgen for the
zm_create.sql of a fresh one - have to agree, and drifting apart is what
caused this. An option with no default is stored as its type's empty value,
matching the empty string initialiseConfig already gives it.

The generated zm_create.sql now has Value equal to DefaultValue for all 259
rows, so a fresh install reads no Config rows at all. The generated
zm_config_defines.h is byte identical, confirming the compiled-in defaults
are unaffected by the representation change.

tests/perl/test_config_default_value.pl checks the invariant over every
option in ConfigData; it needs no database, since ConfigData does 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 7002a6e516 test: cover name-based config lookup and best-effort type conversion
Adds Catch2 coverage for the two behaviours the config redesign
introduced: ConfigItem converting each declared Type, the non-fatal
best-effort conversion when the accessor and the stored Type disagree,
and a freshly constructed Config carrying compiled-in defaults for every
member before any database read.

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 Connor 268dab15dc Merge pull request #5073 from AJ0070/fix/4850-show-progress
fix: honour ZM_WEB_SHOW_PROGRESS in the event view
2026-08-29 11:26:33 -04:00
Isaac ConnorandClaude Opus 5 cbd68bb323 fix: bind the EventStream status poll to the connkey it belongs to
Montage review created 86 stream connkeys in five minutes while only two of
them were ever polled, and each stream painted a single frame before being
replaced about 1.3s later.

The status poll for a connkey whose zms had exited returned result=Error,
recover() restarted the stream under a new connkey, and the reply already
queued for the old one arrived afterwards and restarted the new stream too.
Every restart painted its first frame, which reset consecutiveErrors and
recoveryDelay in img.onload, so the backoff never grew and the attempt limit
was never reached.

- Tag each ajax/stream.php exchange with the connkey it was issued for and
  ignore a reply, success or failure, once that connkey has been replaced.
  stream.php does not echo the connkey back, so the client tracks it.
- Restart only on reason=no_socket, the one class that means zms is gone,
  matching streamErrorIsFatal() in MonitorStream.js. The helper is duplicated
  as EventStream.errorIsFatal() because montagereview.php does not load
  MonitorStream.js.
- Start the poll timer next to the src that created the connkey instead of in
  img.onload, which is not a dependable per-restart signal for a
  multipart/x-mixed-replace img, and let the first query wait a full interval
  so a stream that is still starting is not read as a missing socket.
- Reset the recovery counters only on a successful status reply, so the
  exponential backoff and the attempt limit both work.

Also stop appending a second '?' to UrlToZMS, which already carries
'?monitor=N', so the logs no longer show monitor=24?source=event.

tests/js/eventstream-connkey.test.js covers the stale-reply rules, the fatal
classification and the URL separator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YEe1M3PWVMqYWihUeAa5N
2026-08-27 22:39:45 -04:00
Jash a2ac2eaa0c fix: honour ZM_WEB_SHOW_PROGRESS in the event view fixes #4850 2026-08-26 20:07:00 +05:30
Isaac Connor f8677463ea Merge pull request #5071 from AJ0070/fix/3681-double-scale
perf: scale during colour conversion in zms playback
2026-08-26 09:22:29 -04:00
Jash 5e0d6afaf4 fix: send pre-scaled frames as built when scale or zoom changes refs #3681 2026-08-26 18:22:39 +05:30
Jash 1e7d8e1dd9 fix: cast tv_usec for %jd so armhf varargs stay aligned refs #4580 2026-08-26 11:23:11 +05:30
Jash e48ae03275 perf: scale during colour conversion in zms playback fixes #3681 2026-08-26 11:15:19 +05:30
Jash d70fdeb0c9 fix: lock to stop a second zmcontrol server per monitor fixes #4423 2026-08-26 11:05:42 +05:30
Isaac ConnorandClaude Opus 5 bde61e7af6 fix: rotate logs on SIGWINCH instead of SIGHUP fixes #5063
SIGHUP means reload. zmc responds by closing its events, disconnecting the
camera and reconnecting; the perl daemons respond by exiting so zmdc restarts
them. zmdc.pl logrot hupped every managed process, so the nightly logrotate
run cost about 8 seconds of capture on a default install.

Rotating a log file only needs the daemon to drop its file handle, so use a
separate signal for it. SIGWINCH is otherwise unused, is ignored by default and
exists on every supported platform.

- Logger (C++) installs a SIGWINCH handler beside its USR1/USR2 handler. The
  handler only sets a flag; the next logPrint closes the file and the write
  reopens it at the original path. This covers every C++ binary without
  touching any daemon's main loop.
- Logger.pm registers WINCH alongside HUP in logSetSignal, which logInit
  already calls, so the scripts that install their own HUP handler still
  rotate.
- zmdc.pl logrot sends WINCH. The logrotate config is unchanged - it still
  calls zmpkg.pl logrot.

Filter.pm and FilterTerm.php justified MAX_EVENT_DAYS by events not outliving
the nightly HUP, which is no longer what bounds them; cite SectionLength.

Tests: tests/zm_logger_rotate.cpp and tests/perl/test_log_rotate_signal.pl both
log, rename the file out from under the process, confirm writes still land in
the renamed file, signal WINCH and confirm the original path is written again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDhTBPj9xEaT52pRmAufaP
2026-08-22 15:30:46 -04:00
Isaac Connor e0b9f59c26 Merge pull request #5050 from AJ0070/fix/comms-test-ports
test: bind comms tests to ephemeral ports and unique socket paths
2026-08-17 21:21:00 -04:00
Isaac Connor 7c8bf8dbaa Merge pull request #5057 from AJ0070/fix/5048-followup
fix: do not treat EPERM as missing hard link support on FreeBSD
2026-08-17 21:19:50 -04:00
Jash aef5b1302f fix: do not treat EPERM as missing hard link support on FreeBSD refs #5048 2026-08-18 06:31:14 +05:30
Jash 691ab8ab66 test: handle IPv6 in getBoundPort, reserve the real socket path, drop the last hardcoded port 2026-08-18 06:22:31 +05:30
Isaac ConnorandClaude Opus 5 9c06eb6ba2 refactor: connect to the database on first use, not on include
database.php called dbConnect() at file scope, so including it opened a socket,
and on failure rendered views/no_database_connection.php and exit()ed from
inside a library include. Every model in web/includes requires this file, so
merely loading a class did both.

Connect on first use instead. $dbConn becomes tri-state - false for "not
attempted", null for "attempt failed", a PDO for connected - and two accessors
sit on top of it:

  zmDbConn()        opens if needed; on failure renders the error view and
                    stops, which is what the include used to do, just at the
                    point a query is actually attempted.
  zmDbConnOrNull()  opens if needed but returns null instead of ending the
                    request, for callers with a fallback.

dbQuery() is the funnel every fetch helper goes through, so routing it plus
dbEscape(), dbError() and dbInsertId() through the accessors covers the library.
The five callers that reached for the raw global are updated: config.php.in,
Event.php and ajax/console.php need a connection and take zmDbConn(); logger.php
takes zmDbConnOrNull() and falls through to its error_log target, so a logging
call can no longer end the request or open a connection by itself.

ZMSessionHandler captured $dbConn in its constructor. It is constructed while
session.php is being included, before anything has needed the database, so with
a lazy connection that captured false. It now resolves per call and its methods
return "no session" rather than dereferencing a bool.

Two smaller fixes fall out. The error view was included by a relative path that
only resolved when the cwd was web/, so it never worked for requests served out
of web/api/; it is now anchored with __DIR__. And dbDisconnect() set $dbConn to
null, which in the new tri-state means "connecting failed" and would send the
next query to the error page; it sets false so a later query can reconnect.
Nothing calls dbDisconnect() today.

This does NOT make database.php includable without a database. It requires
logger.php, which requires config.php, which reads ZoneMinder's configuration
out of the Config table at include time. Until that cycle is broken the
connection still happens during bootstrap, just from config.php rather than from
here.

Tests: tests/php/test_database_lazy_connect.php, 7 assertions, all pass. It
tokenises database.php and asserts nothing runs at include time, that dbQuery()
goes through the accessor, and that only the connection plumbing touches the
global. Verified it reports the pre-refactor file's `if ( !dbConnect() )` - an
earlier version of the check skipped tokens inside parentheses and so passed on
exactly the code it exists to reject.

Not covered by tests: behaviour when the database is genuinely unreachable, and
the session handler against a live database. Needs manual testing on an
installed tree, including stopping mysql to confirm the error view still renders
for both a web request and an API request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01477mR97vfnK6zczbHgzq6T
2026-08-17 20:52:10 -04:00
Isaac ConnorandClaude Opus 5 4b79fac6cd test: detect calls nested in file-scope conditions
The side-effect detector skipped tokens inside parentheses, so a call in a
condition - `if ( !dbConnect() )`, the shape database.php had - was invisible to
it. Only the enclosing control-flow keyword was reported, and a file whose sole
include-time work sat inside a condition would have passed.

Drop the parenthesis rule and add a fixture for that shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01477mR97vfnK6zczbHgzq6T
2026-08-17 20:52:10 -04:00
Isaac ConnorandClaude Opus 5 b9f565854f refactor: stop authenticating the request when auth.php is included
auth.php ended in a 130-line block at file scope, so merely including the file
authenticated the current request: it read $_REQUEST, opened a session, queried
the database, and on a login could rewrite the user's stored password hash and
populate $_SESSION. Any caller that wanted one of the functions in the file got
all of that as a side effect, and the order of includes decided when it ran.
HostController requires auth.php twice purely to reach generateAuthHash() and
validateToken().

Move the block into zm_authenticate_request() and call it explicitly from the
two places that want it, web/index.php and AppController::beforeFilter(). The
function returns the ZM\User or null and still sets the global $user, so the
views, ajax handlers and API controllers that read that global are unaffected.
HostController now gets only the function definitions from its requires, which
is all it ever wanted.

Inside a function the five `unset($user)` calls would drop the local binding
and leave the global set, so they become `$user = null` - the idiom the rest of
the file already uses for this, and one that keeps isset($user) false for the
gate at index.php:255. The block's other locals ($ret, $username, $password,
$sql) no longer leak into the caller's scope, which in beforeFilter() means they
can no longer collide with the variables of the same name it assigns just after.

The body is otherwise unchanged; `git diff -w` shows only the wrapper, those
five assignments and the return.

Tests: tests/php/test_auth_no_include_side_effects.php tokenises auth.php and
asserts nothing executes at file scope, with a fixture check so a broken
detector cannot pass vacuously. 4 assertions, all pass. Verified it reports the
pre-refactor file's file-scope block, so it would have caught this.

Not covered by tests: the login, logout, auth-hash and API token flows this
touches. auth.php cannot be included without a database (User.php pulls in
database.php, which connects at include time), so the check is structural.
Needs manual testing on an installed tree before merging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01477mR97vfnK6zczbHgzq6T
2026-08-17 20:52:10 -04:00
Isaac ConnorandClaude Opus 5 935d0cf385 fix: keep an auth hash valid when the client address changes refs #4921
ZM_AUTH_HASH_IPS binds the auth hash to the client address. When that address
changes mid-session - a phone moving between wifi and cellular is the common
case - the hash the browser is still holding no longer matches the address we
now see, and the user is bounced to the login page. The usual workaround is to
turn ZM_AUTH_HASH_IPS off entirely.

Accept the address the request arrives from plus the one it arrived from
immediately before, so an in-flight hash validates once and generateAuthHash()
then reissues against the new address. Addresses are matched exactly. A netmask
was considered and rejected: accepting a whole subnet would let any other host
on the client's network replay a stolen hash, which on a home LAN includes the
cameras themselves.

The previous address is only accepted for as long as a hash issued to it would
itself still be valid (ZM_AUTH_HASH_TTL), so this widens which address is
accepted without extending how long any hash lives. A login clears it, since
nothing from before a privilege boundary should stay acceptable, and only one
previous address is ever retained.

userFromSession() needed the same treatment: it looks the cached hash up by the
live address, so after a change the slot does not exist yet and the user was
reported as not logged in regardless of what getAuthUser() would have accepted.

Also centralises the X-Forwarded-For/REMOTE_ADDR handling in getRemoteAddr(),
replacing four duplicated copies across session.php and auth.php. Those copies
sat on both the generation and validation sides, so any drift between them broke
authentication outright behind a reverse proxy. Network.php holds only that
address parsing; which addresses an auth hash is accepted from is auth policy
and lives in auth.php.

This is web-side only; zms has no session, so a stream request still fails once
on an address change and recovers through the existing auth-refresh path in
MonitorStream.js.

Tests: tests/php/test_remote_addr.php covers getRemoteAddr() parsing and the
session address rotation, and needs no config or database - 18 assertions, all
pass. tests/php/test_auth_hash_candidate_addrs.php covers the acceptance window
including both sides of the TTL boundary; it bootstraps config.php as the other
tests in that directory do and so needs an installed tree to run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01477mR97vfnK6zczbHgzq6T
2026-08-17 20:52:10 -04:00
Isaac Connor c7bca2af3f Merge pull request #5051 from AJ0070/fix/go2rtc-encoding
fix: percent-encode non-ASCII bytes in UriEncode and escape go2rtc JSON fields
2026-08-17 18:03:18 -04:00
Jash bd3097be58 fix: escape JSON strings in a single pass so quotes are not double-escaped 2026-08-17 04:39:24 +05:30
Isaac Connor 33c94153fe Merge pull request #5055 from AJ0070/fix/4939-monitorstream-races
fix: make MonitorStream playback state shared with the command thread atomic
2026-08-16 18:37:55 -04:00
Jash 1b5929d1d2 test: add ThreadSanitizer harness for MonitorStream playback state races refs #4939 2026-08-16 17:46:27 +05:30
Jash 5740f8a270 fix: fall back to rename when the filesystem has no hard links fixes #5048 2026-08-16 16:49:57 +05:30
Jash f5cfbcd54c fix: make MonitorStream playback state shared with the command thread atomic fixes #4939 2026-08-16 16:46:47 +05:30
Jash 3983db5357 fix: percent-encode non-ASCII bytes in UriEncode and escape go2rtc JSON fields 2026-08-16 15:37:21 +05:30
Jash ed0119b451 test: bind comms tests to ephemeral ports and unique socket paths 2026-08-16 15:25:37 +05:30
Isaac Connor c6d05782b3 Merge branch 'master' of github.com:ZoneMinder/zoneminder 2026-08-15 11:05:49 -04:00
Isaac Connor edb4d7c202 fix: stop an applied filter coming up empty on the events list fixes #5026
Applying a named filter and clicking LIST MATCHES landed on Events showing
"No matching records found" until a manual refresh. Two independent defects
produce that, and both are fixed here.

Stored filter selections overriding the applied filter
------------------------------------------------------
The zmFilter_* cookies remember what was last selected in console, montage and
montagereview. They are a convenience for the default, filter-less page, but
they were also applied on top of a filter the request had specified, silently
widening or narrowing it: the applied filter was ANDed with a date range left
over from an earlier visit to another view, so it matched nothing. Refreshing
appeared to fix it because the bar's reset control blanks those inputs.

The cause was a layering one. Filter::simple_widget() refilled any empty term
value from that term's cookie as it rendered the input, so it overrode the value
addTerm() had been given, from below the layer that knows what the request asked
for. An earlier attempt to fix this in events.php could not hold for that
reason: it blanked the values and they were refilled during rendering, and
ajaxRequest() sends the live inputs rather than the URL.

Filter now resolves nothing. It renders the value it was handed, and the six
cookie fallbacks are gone; terms keep their cookie name so edits still persist
client-side. The callers that build those terms decide instead, next to
getFilterSelection(), which already resolved request-before-cookie this way.
Each resolves its value into a local before the addTerm() calls, testing
$use_stored in the same statement that reads the cookie, so the rule is visible
at the point it applies.

montagereview needs a window to draw whatever happens, so with a filter present
it derives one from the filter's own terms and otherwise defaults to the last
hour, rather than reaching for the stored window; its Notes term no longer seeds
from a cookie inside the branch that already has an explicit filter.

Tables never retrying a request skipped while hidden
----------------------------------------------------
The table views skip their ajax request while the page is hidden so a background
tab does not poll. Bootstrap-table calls that function on init as well as on
refresh, though, and a skipped request was never re-issued: the table rendered
"No matching records found" over a result it never asked for, with nothing to
bring it back. A page can be hidden for the whole of its load - opened in a
background tab, restored, or behind another window - and the same guard is in
seven views: events, console, log, frames, reports, snapshots and watch.

deferTableRequestWhileHidden() skips the request as before and records the table,
so it is refreshed the moment the page becomes visible. The queue is drained
before refreshing, because refresh() calls the ajax function synchronously and
would otherwise re-add a table that is still hidden.

Tests
-----
tests/audit-filter-cookies.php checks both halves of the first rule: that Filter
resolves nothing, and that every stored-selection read in a caller tests
$use_stored. It works a statement at a time, joining continuation lines, since a
value and its guard often span a line break. Anything wider is too coarse: the
enclosing block holds other guarded reads and would mask one that lost its own.

tests/js/table-helpers.test.js covers the deferral queue, including a table
re-deferred during its own refresh.

Verified against a live instance with the stale cookies still set: a "last hour"
named filter queried 0 of 4 matching events before and 12 of 12 after; the
filter-less page still restores the stored date range; and a table deferred while
hidden repaints on becoming visible.
2026-08-12 22:48:20 -04:00
Isaac Connor 69ce84504e Merge branch 'master' of github.com:ZoneMinder/zoneminder 2026-08-09 17:19:46 -04:00
Isaac Connor ea4f0c5f9a fix: return empty from zmAuth.applyTo for a blank stream src
applyTo appended the relay to an empty src, yielding a bare '?auth=...'.
Callers that treat a blank src as "nothing to load" would then set it on an
<img>, where it resolves against the current page and loads the surrounding
HTML as an image. montagereview's loadImage2Monitor is one such caller.

Introduced with ZMAuth; the code it replaced returned the src untouched.
2026-08-09 16:11:29 -04:00
Isaac ConnorandClaude Opus 5 12cb7d9601 fix: don't clear onerror/onload on the go2rtc video-stream element in kill() refs #5025
MonitorStream.kill() unconditionally did `stream.onerror = null` and
`stream.onload = null` on whatever element the monitor was using.  That was
written for the zms <img>, whose onerror/onload are inherited event-handler
accessors.

With go2rtc the element is <video-stream>, where onerror is a method on
VideoRTC.prototype (video-rtc.js) overridden by VideoStream (video-stream.js).
Assigning null there finds a writable data property on the prototype chain and
so creates an *own* property on the instance, shadowing the method for as long
as the element lives.  replaceDOMElement() returns the same node when the tag
already matches, so select_go2rtc() handed the poisoned element back on the
next start, and the listener VideoRTC.onconnect() registers,

  this.ws.addEventListener('error', (ev) => this.onerror(ev));

threw "TypeError: this.onerror is not a function" on the next websocket
failure.  Any kill()-then-start() path reached it: switching monitors on watch,
the stop/play buttons, montage viewport handling.  It also meant the restart
that VideoStream.onerror performs was silently dead after a kill().

Guard the assignments on the element actually being an IMG.

Add tests/js/monitorstream-kill.test.js, which evaluates the real
MonitorStream.js in a vm context and checks that kill() leaves a prototype
onerror callable on a <video-stream>, adds no own onerror/onload to it, and
still clears both on an <img>.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXtwyhzssj24Xwjmx8EA2z
2026-08-09 16:11:29 -04:00
Isaac Connor 40129a5564 refactor: replace the auth_hash/auth_relay globals with one ZMAuth credential
The page kept two copies of the same secret: auth_relay, the query fragment
every AJAX call is authenticated with, and auth_hash, the bare hash stamped
into stream <img> URLs. Different responses updated different copies, so
they could drift, and a drifted auth_hash produced stream URLs that zms
rejects.

ZMAuth stores only the relay and derives the hash from it, so the two cannot
disagree. Its helpers cover the four shapes the call sites used:

  zmAuth.hash          derived, '' under the plain/none relay forms
  zmAuth.update(data)  absorb the auth fields of any response
  zmAuth.appendTo(url) authenticate a url, no-op when auth is off
  zmAuth.applyTo(src)  point a stream url at the current credential

appendTo also removes the `x ? '&'+x : ''` guard repeated at every call
site, some of which had omitted it and emitted a dangling '?'.

Migrates all call sites across web/js and the classic skin, and drops both
globals from skin.js.php.

Tests: tests/js/auth-helpers.test.js, 44 passing.
2026-08-09 10:29:46 -04:00
Isaac Connor e6befbd4a5 feat: list deleted monitors from the console Status filter
Deleted monitors are excluded from every monitor listing, so once a monitor
is deleted there is no way to find it again from the ui - which matters
because deleting is reversible, the monitor edit form has an undelete
checkbox for exactly that.

Add Deleted as a pseudo status in the Status filter. Selected on its own it
lists only the deleted monitors; selected alongside real statuses it adds
them to that selection rather than intersecting with it, which would always
be empty; not selected, listings stay restricted to live monitors as before.

Deleted is deliberately not matched against Monitor_Status. Whatever row a
deleted monitor left behind is stale - its daemons were stopped when it was
deleted - so filtering on it would drop the monitors we are trying to find.
For the same reason a deleted monitor is reported as Deleted rather than the
status on that row, is drawn with the error dot, is labelled in the list,
and does not get a link to a stream that is not running.

The three queries that hardcoded Deleted=false now share one function, so
the console page, the console ajax endpoint and getFilteredMonitorIds()
cannot disagree about what the filter means. Each passes its own status
column expression, which differ: the ajax endpoint coalesces a WebSite
monitor to Running.

tests/php/test_monitor_status_filter.php covers the sql and the bind value
ordering for all four cases, including a bare string from a cookie written
before the filter became a multi-select. Verified against a live install:
13 deleted and 19 live monitors return 19 with no filter, 13 for Deleted,
and 20 for Deleted plus NotRunning.
2026-08-08 15:41:20 -04:00
Isaac Connor 706d9324f2 feat: allow newlines to separate entries in the monitor Options field
The Options field was a single line text input, so a list of options had to
be typed as one comma separated run. Make it a textarea, sized to the number
of entries it already holds, so each option can go on its own line.

Nothing needs to parse this: both consumers already take a set of separator
characters, they were just never given the newlines.
av_dict_parse_string() (Ffmpeg) and Split() (Libvlc) both skip empty
entries, so a blank line, crlf, or a trailing newline all work, and comma
separated values keep working unchanged.

The separator set is a named constant rather than a literal at each call
site so the tests exercise the value the cameras actually pass - with a
literal they would keep passing if a call site were reverted. Verified by
setting it back to "," which fails both test cases.
2026-08-08 11:56:43 -04:00
Isaac Connor 10359bc011 fix: address the review comments on the stream error classification refs #5038
Three points raised on #5038 after it was merged.

ajaxError() documents the reason field as included "only when set", but
tested it for truthiness, which would also drop '' and '0'. None of the
four STREAM_ERR_ constants are falsy so nothing changed behaviour, but
the check now matches the documented contract. The client already treats
an empty reason as fatal, so a caller that does pass one still gets the
old handling.

The case 0 branch called ajaxError() twice in sequence and relied on the
first one exiting to keep the second from running on a timeout. Made the
two paths mutually exclusive so it no longer depends on that.

The test's "every ajaxError call is classified" assertion compared the
number of call sites to the number of STREAM_ERR_ occurrences anywhere in
the file. The four define()s are part of that count, so up to four calls
could lose their classification and the test would still pass - verified
by dropping the argument from one call, which the old assertion accepted.
It now matches each call to the end of its statement and requires every
one to carry a constant.
2026-08-07 21:06:58 -04:00
Isaac Connor 15b913725d Merge pull request #5038 from connortechnology/fix-connkey-regeneration
fix: stop orphaning zms when a stream command fails
2026-08-07 19:44:50 -04:00
Isaac ConnorandClaude Opus 5 6420571c04 fix: do not start or restart zmc for a monitor that has been deleted
zmwatch fetches its monitor list once at the top of a pass and then walks it.
Deleting a monitor mid-pass stops its zmc from the web ui, so by the time
zmwatch reaches that monitor its shared memory is gone, zmMemVerify fails and
zmwatch calls control('restart') from the now-stale list. zmc comes back for a
monitor that is marked Deleted, so no later pass ever looks at it again and
nothing stops it until zmpkg.pl restart. That is the orphaned zmc left behind
after deleting a monitor from the console.

Re-read Deleted from the database in ZoneMinder::Monitor::control() before
running a start or a restart, and skip the command if the monitor has been
deleted or the row is gone. control() is the single path every restart goes
through, so this covers any caller working from a list it fetched earlier.
Stopping is deliberately still allowed, since that is how an orphan gets
cleaned up.

Tests: tests/perl/test_monitor_control_deleted.pl stubs zmDbFetchOne and
runCommand and checks that start/restart still run for a live monitor, are
skipped for a deleted one and for one removed from the database, and that stop
is unaffected.

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 5 74a489991e feat: add ZM_WEB_LOGIN_MESSAGE to show a notice on the login page
Adds a web config option whose contents are rendered on the login view,
between the title and the username and password fields. Useful for a site
notice, an acceptable use or legal warning, or a note identifying which
installation this is when running more than one.

Defaults to empty, and nothing is emitted when it is empty or whitespace, so
existing installs look exactly as they do now.

The text is escaped rather than interpreted. The login page is served before
anyone has authenticated, so it is not somewhere to emit admin-supplied markup,
and no other config value in the skin is output unescaped either. Escaping runs
before nl2br so the only tags reaching the browser are the line breaks we add
ourselves; reversing that order would turn the setting into stored XSS.

Uses the text type, so the Options UI renders a textarea and the value can span
lines. The Config.Value column is already text and options.php normalises CRLF
to LF on save, so no schema change is needed and nl2br sees consistent
newlines.

Guarded with defined() to match the surrounding code, so the view still renders
where the database predates the option.

Styled in base, classic and dark. Text contrast is 7.0:1 light and 7.5:1 dark,
both above WCAG AA, and long unbroken tokens wrap rather than widening the
fixed-width form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 22:08:23 -04:00
Isaac ConnorandClaude Opus 5 a5cd44df35 fix: map deprecated YUVJ pixel formats on the swscale output side fixes #5037
A monitor whose source signals full range (Dahua h264) decodes to
AV_PIX_FMT_YUVJ420P, and Monitor passes that format through to shared memory
unchanged. When zms scales such a frame for montage or console thumbnails,
SWScale::Convert mapped only the input format through fix_deprecated_pix_fmt()
and handed the still-deprecated YUVJ format to swscale as the destination, so
libswscale logged

  deprecated pixel format used, make sure you did set range correctly

for every context it built. Watch view at 100% scale never hit it because
Image::Scale returns early when the dimensions already match.

Map deprecated formats on both sides in SWScale::Convert (both the buffer and
the AVFrame overload), in Image::Assign(AVFrame*), in
Monitor::setupConvertContext and in the LocalCamera conversion context.

Mapping the destination YUVJ format to its non-J equivalent makes swscale
default that side to limited range, which would compress full-range output.
Replace zm_sws_set_input_range() with zm_sws_set_ranges(), which takes the
original pre-fix formats for both sides and sets srcRange/dstRange accordingly.

Image::Assign(AVFrame*) now compares the mapped source and destination formats,
so a YUVJ420P frame into a YUVJ420P image takes the av_image_copy fast path
instead of running through swscale.

Image::Scale built a fresh SWScale, and therefore a fresh sws context, on every
call. That is a full sws_init_context per scaled frame per stream, and it is
what turned the warning into a per-frame flood rather than a one-off. Reuse a
thread_local SWScale; sws_getCachedContext re-inits itself when the geometry
changes.

Tests: zm_swscale_range.cpp installs an av_log callback and asserts that
SWScale::Convert with a YUVJ destination and Image::Scale on a YUVJ420P image
emit no deprecated-format message, plus a luma check that the range handling
still survives the mapping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDhTBPj9xEaT52pRmAufaP
2026-08-05 17:44:37 -04:00
Isaac ConnorandClaude Opus 5 ae8c7db488 fix: stop orphaning zms when a stream command fails refs #5029
getStreamCmdResponse() responded to every ajax/stream.php failure the same way:
mint a fresh connkey and reload the img src. ajaxError() returns HTTP 200 with
result=Error, so these arrive in jQuery's done() rather than fail(), and all
twelve error paths in stream.php took that branch.

Only one of them means zms is gone. For the rest the process is still running
and streaming, and replacing the connkey makes it unaddressable: CMD_STOP,
CMD_QUIT and mode=single all then go to the new key, so nothing can reach the
old process and only SIGPIPE can stop it, which we know is unreliable. That is
why the reports of lingering zms after switching monitors were unaffected by
changes to what the stop path sends.

The timeout path made this routine rather than rare. On select() expiry
ajaxError is commented out, so the script carries on to socket_recvfrom() on a
now non-blocking socket. That returns false, and false == 0 under switch's loose
comparison, so a merely slow zms was reported as 'No data to read from socket'
and torn down.

stream.php now classifies each failure as no_socket, timeout, transient or
invalid, and sends it as 'reason'. The client restarts the stream only for
no_socket. A missing reason is still treated as fatal, so a php that predates
this keeps the old behaviour.

Before replacing the connkey the client now sends CMD_QUIT to the old one, so
the process we are about to lose track of is asked to exit. That is deliberately
not routed through streamCommand(): it must name its target explicitly, since
this.connKey is about to change, and its response must not feed back into
getStreamCmdResponse(), or a QUIT that also failed would re-enter the error path
and loop.

ajaxError() takes the classification as a third argument, named $reason because
$code is already the HTTP status, and only includes it when set, so the other
131 callers are unaffected.

Tests: tests/js covers the fatal/non-fatal decision including the no-reason
fallback, tests/php pins the classification mapping and the switch(false)
semantics the timeout branch depends on. Both verified to fail when the
behaviour is reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 06:43:11 -04:00