Three values in the entry added a few commits ago were wrong, all for the
same reason: I read them off http status codes, in the entry whose own
comment says http status codes cannot be trusted here. Re-derived with
amcrest_probe.pl, which decides every flag by comparing frames.
CanMoveDiag was 1 because LeftUp returns 200. It shifts the picture by
1.8 against a 6.0 threshold, so nothing moves. Now 0.
Pan and tilt speed were 1..8 from the Dahua documentation. Measured
displacement is 31.5, 36.2, 41.4 for speeds 1, 2, 4 and then flat through
8, 16, 32 and 64, so the usable range is 1..4 even though the firmware
accepts far higher.
NumPresets was 255, the firmware ceiling. That is the wrong thing to
measure: the classic skin draws one button per preset, and the camera
refuses GotoPreset for any index not yet stored, so 255 gives a wall of
buttons where all but the defined ones error. Now 25, matching the
sibling Dahua/Amcrest RPC entries.
CanMoveAbs stays 0 but for a better reason than before. PositionABS is
accepted, reaches distinct positions and reproduces a revisited
coordinate, so it passes the obvious tests. Its targets are 90 degrees
apart though, and the median step shifts the view 16.4 where a single one
second nudge shifts it 42.5 - the pan argument is being clamped to a
fraction of what was asked, so a moveMap click would not land where it
was aimed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzHrvNtF13vyYBAJjktam6
The 49 control protocol definitions were sitting inline in
zm_create.sql.in, which makes adding a model-specific entry a scroll
through a wall of positional INSERTs. Move them to db/controls.sql and
pull them in with
source @PKGDATADIR@/db/controls.sql
which is how User_Preferences.sql, manufacturers.sql, models.sql,
triggers.sql, Object_Types.sql, AI_Models.sql and coco_dataset.sql are
already handled, and the existing install(FILES ${dbfileslist}) glob puts
the new file where that path resolves to. The 49 rows move across
byte-identical.
The new entry is the Amcrest IP5M-1190EW, verified against firmware
2.810.00AC004.0.R. The generic 'Amcrest HTTP API' entry advertises zoom
and continuous zoom and no presets, which is backwards for this model:
pan, tilt, the diagonals and continuous move all physically move it, arg2
really is the speed, and GotoPreset works, while zoom, focus and iris do
not. ZoomTele, ZoomWide, FocusNear and FocusFar answer OK and do
nothing; autoFocus, getFocusStatus, IrisLarge and AutoPanOn 400.
PositionABS answers OK and is inert, so CanMoveAbs and CanMoveMap stay 0.
The white light exists in Lighting_V2 but is not drivable, so CanLight
stays 0.
Worth recording for anyone rechecking this: ptz.cgi?action=getStatus on
that firmware always reports Postion=0/16.65 MoveStatus=Idle no matter
what the camera is doing, so status cannot tell you whether a move
worked. All of the above was confirmed by diffing snapshots.
zm_update-1.39.20.sql carries the same row to existing installs, guarded
with NOT EXISTS so it can be re-run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzHrvNtF13vyYBAJjktam6
Verified every added endpoint against an Amcrest IP5M-1190EW running
2.810.00AC004.0.R. A full get_config() returns 52 sections in ~24s and
the set_config() key form round-trips, but several guesses were wrong:
networkInterface.cgi does not exist, netApp.cgi?action=getInterfaces is
the real endpoint.
userManager.cgi reports a real per-user Id starting at 1, so get_users
was handing callers the array position instead of the number the camera
actually recognises.
modifyUser rejects a partial record with 400. update_user now reads the
user back and sends the whole record with the changes merged over it.
getSoftwareVersion and getHardwareVersion both answer with a bare
'version=', so device_info's flat merge lost one of them. The software
one is a single line with the build appended - '2.810.00AC004.0.R,
build:2023-09-04' - so getVersion splits it and returns the build
separately in list context.
The configManager list is now the set that model actually implements.
UPnP and Login are gone, they answer 400; Lighting_V2, WLan, NAS, Record,
Alarm and the VideoIn* sections are added.
That model has no getCurrentProtocolCaps and no coaxialControlIO, and it
answers OK to Lighting/Lighting_V2 writes and then ignores them, so its
WhiteLight is not drivable over http. Documented rather than papered
over. update_firmware stays the one unverified path, deliberately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzHrvNtF13vyYBAJjktam6
The monitor, user and options views all put a material-icons eye beside
their password fields; the login form was the one place without it. Wrap
the password input in an input-group with the same
toggle_password_visibility control so a mistyped password can be checked
before submitting.
Lay the toggle over the field instead of beside it. An addon of its own
would have to keep its fill in step with the field's, and it cannot: the
input's background comes from the theme (#f8f8f8 in base, dark elsewhere),
changes again on focus, and the browser paints its own autofill highlight
over the whole thing. Positioned on top, the toggle stays transparent and
the field shows through in every one of those states, and Bootstrap's own
focus ring still wraps the pair.
The material-icons rule is forced past the font loader's
'display: inline-block !important' so the icon centres vertically. The
input-group also drops the field's own bottom margin, so the login CSS
moves that spacing onto the group.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNbLurNrXEffGRVfj9yHf2
A preference belongs to the user who set it, so ownership is the permission.
The controller instead required System, which got it wrong in both directions:
any System account could read and overwrite another user's preferences, and an
ordinary user could not save their own at all, which is what the montage layout
in montage_common.js does through this controller.
Reads and writes are now scoped to the caller. index lists only the caller's
rows, view, edit and delete refuse a row belonging to someone else, and add pins
UserId to the authenticated user: the client sends its own id in the body, so
taking that on trust let anyone write a preference onto another account. A
System Edit account may still manage anyone's, so an administrator can clear a
broken one.
The find conditions named the table, User_Preferences, where CakePHP wanted the
model alias, UserPreference, so view and edit returned a 500 for everyone. That
is fixed here because it otherwise hides whether the ownership check works.
Neither checked anything, so any enabled, API-enabled account could read them.
Control definitions describe how to drive a camera and are edited under Options,
which requires System. Manufacturers, CameraModels and EncoderTemplates already
gate their reference data that way; Controls was left open.
getLoad returns the system load average, which getSysLoadHTML() in the web ui
declines to render without canView('System'), so the API no longer hands the
same figure to an account without it.
getDiskPercent reported usage per monitor, keyed by monitor name, telling an
account which monitors exist and how much disk each uses even when it was not
permitted to view any of them. It now reports only on monitors the caller can
view, and asking about one specific monitor requires view on that monitor.
daemonCheck is deliberately left alone: it reports only whether ZoneMinder is
running, which the web ui's navbar shows to every logged-in user, and gating it
here would make the API stricter than the interface it serves.
daemonStatus() and daemonControl() checked nothing at all. Any enabled,
API-enabled account could read the state of, and start or stop, the capture and
analysis daemons of any monitor, including monitors it was not permitted to see.
index() and view() have always filtered on ZM\Monitor::canView(), so these two
were the odd ones out.
Confirmed on a live instance with a user whose every permission was None:
monitors.json returned an empty list, while
monitors/daemonControl/1/status.json ran zmdc against that same monitor. On a
running system the equivalent stop call would have halted recording.
Reporting state now needs view on the monitor, and starting or stopping it needs
edit, matching the rest of the controller.
zmdc.pl takes the daemon and the command as separate arguments and they were
interpolated into the command line, escaped only by escapeshellcmd() over the
whole string. That stops metacharacters but not extra arguments, so both are now
checked against the set of names zmdc accepts.
add() and delete() call this internally, having already made their own
permission decision, and a System Edit account need not hold Monitors Edit. They
call an ungated private runner so that re-checking here cannot stop them.
Any enabled, API-enabled account could read the effective configuration,
including secrets, whatever its permissions. Confirmed on a live instance: a
user with every area set to None retrieved ZM_DB_PASS from
/api/configs/viewByName/ZM_DB_PASS.json.
The controller already required System Edit to change a config but checked
nothing at all to read one, so index, view, viewByName and categories were open
to anyone the API let in. They now require the same System permission the web
ui's Options page requires.
Permission alone is not enough, because nothing outside the server has any use
for these values. Two kinds are now withheld from every caller, administrators
included:
- Rows flagged Private in the Config table. That flag already existed and was
loaded into $zm_config, but nothing ever acted on it. It covers
ZM_AUTH_HASH_SECRET, which is enough to forge an authentication hash for any
user, and the reCaptcha secret.
- The database credentials. These are read from zm.conf and conf.d rather than
the table, so they have no row to flag and are listed by name. This is also
why filtering the table alone would not have been enough: index appends the
file-backed values to its response.
Requesting an unknown name logged print_r($zm_config, true), copying the whole
effective configuration into the log and anything collecting it. It now logs the
name at Debug.
Verified against a live instance, before and after, with a temporary System=None
API user: reading ZM_DB_PASS returned the password and now returns 401; an
administrator gets an empty Value for ZM_DB_PASS and ZM_AUTH_HASH_SECRET, the
293-entry index carries no secret values, and ordinary settings such as
ZM_WEB_TITLE still read normally.
The monitor, user and options views all put a material-icons eye beside
their password fields; the login form was the one place without it. Wrap
the password input in an input-group with the same
toggle_password_visibility control so a mistyped password can be checked
before submitting.
Lay the toggle over the field instead of beside it. An addon of its own
would have to keep its fill in step with the field's, and it cannot: the
input's background comes from the theme (#f8f8f8 in base, dark elsewhere),
changes again on focus, and the browser paints its own autofill highlight
over the whole thing. Positioned on top, the toggle stays transparent and
the field shows through in every one of those states, and Bootstrap's own
focus ring still wraps the pair.
The material-icons rule is forced past the font loader's
'display: inline-block !important' so the icon centres vertically. The
input-group also drops the field's own bottom margin, so the login CSS
moves that spacing onto the group.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNbLurNrXEffGRVfj9yHf2
std::quick_exit does not exist on OpenBSD, which is why the openbsd
branch replaced these calls with std::abort(). abort() raises SIGABRT,
so the child dumps core and the parent's wait status reports a signal
death for what is just a failed exec.
Both call sites are in a forked child whose execl/execlp failed. _exit
is POSIX, exists on OpenBSD, and is what a child in that state should
call: no atexit handlers, no flushing of stdio buffers inherited from
the parent.
Build: cmake --build . --target zmc -> clean, no warnings.
set_config prepended the section name to keys that already carried it,
so NTP.Address went out as NTP.NTP.Address. get_config only strips the
'table.' prefix, which leaves keys in exactly the form setConfig wants,
so drop the extra prefix and let reads and writes round-trip.
uri_encode was called but URI::Encode was never imported, and the plain
call leaves reserved characters alone, which corrupts passwords in a
query string. Import it and always escape reserved characters.
get_config now reads configManager.cgi sections as well as the handful
of dedicated cgis, retries once on a 401 so the fresh digest nonce gets
used, strips the 'table.' prefix, and tries any unrecognised name as a
configManager section so a template can pull in sections the module
doesn't list.
set_config fell off the end of its loop returning undef on success.
reboot POSTed system_reset=1 to setparam.cgi, a Vivotek endpoint, on a
relative url through the raw user agent. ZoneMinder::General was used
but never required.
Adds device info, time, users, snapshot/rtsp/profiles/probe, white light
and siren, and the remaining PTZ stops and auto pan, matching the method
names cameratool.pl probes for with ->can().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzHrvNtF13vyYBAJjktam6
The openbsd branch and master both changed the top fallback in CpuUsage
and diverged. Combine them:
- Keep master's /proc/stat diagnostics: report why /proc/stat could not
be read (ProcSubset=pid mount namespace vs open failure), default the
fields split out of top output so an empty result does not warn under
-w, and only complain once per process.
- Keep the openbsd branch's parse_bsd_top_cpu(): OpenBSD top prints CPU
states in a format neither the FreeBSD nor the Linux grep matches, so
when the greps produce no numbers, parse raw top output. It handles
both the aggregate "CPU states:" line and per-core "CPU0 states:"
lines, averaging over the lines seen, and does not depend on padding.
- Carry over scripts/ZoneMinder/t/server_cpu.t covering it.
Tests: prove -Iscripts/ZoneMinder/lib scripts/ZoneMinder/t/server_cpu.t
-> 16/16 pass. perl -c on Server.pm is clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kfiom9RefM75GTZFkqpJo2
Two problems on the console hover overlay:
Any go2rtc.events.error dropped the overlay to MJPEG, including errors from
a player mode that is not carrying playback (the webrtc candidate watchdog in
video-stream.js fires 3s after the first candidate even when MSE is already
playing). Ignore the error when the inner video is actually running.
The overlay's video-stream element sets background=true, so
disconnectedCallback() returns early and removing the element left the
websocket and RTCPeerConnection open. VideoStream.close() only pauses the
video. Every hover therefore leaked a consumer on the camera, so later hovers
got no media and fell back to MJPEG. Call ondisconnect() explicitly on
cleanup and on both fallback paths, matching what MonitorStream.stop() does
for the watch view.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Now that the daemons reliably land in this unit rather than inheriting the web
server's namespace, the unit is worth hardening. Add the protections that do
not interfere with capture: ProtectSystem=full, ProtectClock,
ProtectControlGroups, ProtectHostname, ProtectKernelLogs, ProtectKernelModules,
ProtectKernelTunables, LockPersonality, RestrictRealtime and RestrictSUIDSGID.
Set the options that would break us explicitly rather than leaving them to a
default a distribution might override, since each fails in a way that is not
visible from the web ui:
PrivateDevices and PrivateTmp, because zmc publishes frames in /dev/shm for a
zms that runs under the web server, and zmaudit.pl cleans up the swap images
zms writes under /var/tmp.
ProcSubset and ProtectProc, because zmstats reads /proc/stat, /proc/meminfo
and /proc/loadavg, and zmpkg.pl reads /proc/self/cgroup to decide whether
systemd started it.
Record why NoNewPrivileges, ProtectHome and MemoryDenyWriteExecute are absent,
and warn that the mount namespace these options create hides filesystems
mounted after ZoneMinder starts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019URmtYqza6Rzi6F7cmabSm
zmpkg.pl hands start/stop/restart to systemd via zmsystemctl.pl, so that pid 1
forks the daemons and they get zoneminder.service's cgroup and mount namespace
rather than the web server's. The detection defeated itself: systemdRunning()
read pid 1's name out of /proc, which a web server running with
ProtectProc=invisible hides from the web user. Starting from the web ui
therefore concluded systemd was absent, skipped the delegation and ran zmdc
inside the web server's namespace, where ProcSubset=pid hides /proc/stat and
every figure zmstats records is wrong.
Use -d /run/systemd/system, which is what sd_booted(3) does and stays readable
however /proc is mounted.
calledBysystem() had to change with it or the two would combine into a start
loop, systemd running zmpkg which asks systemd to run zmpkg. It read the
parent's name from /proc, equally hidden, and the parent is pid 1 only until
it re-parents us. Ask our own cgroup whether we are already the service
zmsystemctl.pl would start. Not INVOCATION_ID: systemd sets it for every
descendant of a unit, so anything forked from the web ui inherits the web
server's copy and would wrongly look systemd-started.
Also check the exit status of the zmsystemctl.pl call. It was discarded, so a
refused pkexec left the command cleared and nothing started, with nothing
logged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019URmtYqza6Rzi6F7cmabSm
Stop an applied filter coming up empty on the events list: stored filter
selections no longer override a filter given in the request, and a table whose
ajax request was skipped while the page was hidden now refreshes when it is
shown.
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.
/proc/stat is invisible to any process in a mount namespace set up with
ProcSubset=pid, which is systemd's default hardening for apache. Daemons
started from the web ui are forked off mod_php and inherit it, so zmstats
fell through to the top fallback, which reads /proc/stat as well and so
produced no output either.
Separate the missing-file case from the failed-open case so the message
names the likely cause instead of printing a $! that a failed -e never set.
Emit the warning once per process rather than once per
ZM_STATS_UPDATE_INTERVAL. The condition does not clear itself, so it was
filling zmstats.log with the same three lines a minute.
split() on an empty $top_output returns an empty list, leaving all four
values undef, so each fallback cycle also emitted four "Use of
uninitialized value" warnings under -w. Default them before use.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019URmtYqza6Rzi6F7cmabSm
Make montage review work for events that are still recording. zms could never
load a recording event, the API's DateTime filter was a containment test rather
than an overlap test, and montage review's own lookups could not handle an event
with no end.
loadInitialEventData() selected on unix_timestamp(`EndDateTime`) > t. An event
still being written has no EndDateTime, unix_timestamp(NULL) is NULL, and
NULL > t is never true, so the event currently recording could never be found.
zms.cpp:374 prefers the monitor_id form of setStreamStart whenever both
monitor and time are given, which is what montage review always sends, so its
`event` parameter never got a chance to help. Reviewing the present moment
returned the no-image placeholder, and every such request logged at ERR and
inserted a row into Logs.
Falls back to StartDateTime + Length, which zmc flushes every few seconds. An
event with neither ends where it starts, so a crash-orphaned event cannot match
every query and be picked ahead of the real one by the ORDER BY. Same
expression the API's Event model uses for EndTimeSecs.
Finding no event for a time is also no longer ERR. A client may ask for a time
the monitor was not recording, and it does so once per request.
Verified against a live instance: for a montage review request into the
recording event, the installed zms returned 2935 bytes (the placeholder) and
logged the failure, the rebuilt one returned a 134949 byte frame and logged
nothing.
A recording event has a null EndTimeSecs, and a bare `EndTimeSecs >= time`
is false against null. drawEventOnGraph() had been given the fallback but the
lookup paths had not, so the timeline drew a recording event while playback
reported no event over the same span. The linear fallback in getFrame() could
never match one, and the binary search broke out early instead of descending.
All five comparisons now share one helper.
Scrubbing to the present said "No event" while the camera was recording, and
the timeline stopped short of the live edge.
Three causes, all specific to an event with no EndDateTime:
The API reports EndTimeSecs for a recording event, derived from the Length
zmc last flushed, which trails what has been captured by seconds. This file
already handles an open-ended event wherever EndTimeSecs is falsy -
findEventByTime() and drawEventOnGraph() both test for it - but a value is
always present now, so none of that ran and findEventByTime() rejected the
newest part of the recording. EndTimeSecs is cleared on receipt instead, and
drawEventOnGraph() no longer writes the window edge back into the event, which
would have given it a concrete end for every later lookup.
loadFrames() treated any event whose frames had been read as final. That holds
for a closed event but not one still being written, so the frames captured
after page load were never fetched. It now reloads an open event, and getFrame()
asks for more once the cursor passes the newest frame held, throttled per event.
loadFrames() also dropped whole batches: it flushed the accumulated query only
when the remaining count hit zero, tested after the skip, so a trailing
already-loaded event discarded the query built for the events before it. The
events to fetch are now selected before batching.
DateTime is a pseudo-attribute meaning "the event was running then", so a
window over it should select events that overlap the window. The code applied
the term's own operator to both StartDateTime and EndDateTime, which turns the
upper bound into StartDateTime <= max AND EndDateTime <= max -- a containment
test. Any event spanning the end of the window was dropped, which under
continuous recording is most of them, and with events longer than the window,
all of them. Montage review showed an empty timeline as a result.
The lower bound now tests the event's end and the upper bound its start.
The EndDateTime IS NULL allowance is also bounded. It was there so an event
still being written stays visible, but as written it made every crash-orphaned
event ever recorded match every window: montage review was returning events
from two weeks earlier and nothing from the requested hour. An event with no
EndDateTime still has Length, flushed every few seconds by zmc, so
StartDateTime + Length is its effective end; only an event with neither falls
back to NOW(). This is the same expression the Event model already uses for
its EndTimeSecs virtual field.
Verified against a live instance: for a 09:43-10:43 window that had one
overlapping event per monitor, the API returned 8 events for monitor 1, all
crash orphans from 2026-07-26 and none from the window. It now returns the
overlapping event, and montage review draws it and renders its frames.
The 9-byte auth_prefix buffer tripped -Wformat-truncation because auth is
64 bytes. Use %.8s in the Warning format instead, which truncates at print
time and drops the buffer. Logged output is unchanged: first 8 chars of the
hash plus "..." when longer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
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
Unify the browser's auth credential into a single ZMAuth value and stop
ajax/stream.php withholding the auth hash, which together let a drifted
hash get baked into a stream <img> src and 403 against zms indefinitely.
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.
ajax/stream.php only included `auth` in its reply when it differed from
the hash the request carried. That hash comes from auth_relay, while the
browser stamps stream <img> URLs from a separate auth_hash global, so once
the two drifted the condition held permanently and the stale copy was
never corrected.
Observed on a montage page: a reload minted a new connkey and baked the
drifted hash into the src, after which every native <img> reconnect got a
403 from zms for nearly five hours.
Send it unconditionally; it is 32 bytes on a response that already carries
auth_relay.
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.