mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-11-07 21:23:02 -05:00
dependabot/github_actions/codeql-60a275eb76
34
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
db4dd75e14 |
fix: make submitTab come back on the tab you were working in
submitTab() resubmits contentForm without saving, so the view re-renders for a
value that changes which fields it should show. It did
form.attr('tab', tab), which sets an attribute named "tab" on the <form>
element and has no effect whatever: an attribute on a form is not a submitted
value. The view reads $_REQUEST['tab'], which comes from the hidden field.
Writing it correctly would have been worse than leaving it broken. The value
it was being handed, data-tab-name, is the tab the page loaded on, while the
hidden field now holds the tab that is on screen. Assigning the attribute's
value would have undone that tracking and sent anyone who had switched tabs
back to the one they arrived on -- the bug this looks like it would fix. So
the tab is not written here at all, and the field is left to do its job.
Drop data-tab-name from the Method select, since nothing reads it now. An
attribute nothing reads is a trap: the next person here would reasonably
assume it was load bearing.
The submit stays on $j(...).submit() rather than the element's own submit(),
which is worth saying out loud in a comment because the difference is
invisible: jQuery triggers contentForm's submit handler, which validates the
form and blanks the password fields, and a native submit skips both.
Noted, not changed: skin.js also binds submitTab to ".pillList a" on click,
and no markup anywhere in the tree carries that class, so it matches nothing.
It was the only caller that would have wanted data-tab-name's meaning. Left
alone as dead but harmless, and in a file every view loads.
Tests: tests/js/monitor-tab-submit.test.js, 9 checks. skin.js and monitor.js
are loaded into one sandbox, as a page has them, because the behaviour needs
both: monitor.js tracks the tab into the hidden field and skin.js submits it.
The cases are built so that the loaded tab and the showing tab differ, since
any test where they agree would pass whether or not the bug is fixed.
Verified the checks fail when the old attr call is restored (2), when the
naive fix of writing data-tab-name into the field is applied (4), and when
the submit is changed to a native one (7).
Ran: tests/js/monitor-tab-submit.test.js (9 passed), monitor-tab-url.test.js
(14 passed), the rest of tests/js and tests/php, npx eslint . (clean),
utils/check-homoglyphs.py (clean). test_bandwidth_clamp.php fails on this box
for an unrelated reason: it cannot read /etc/zm/conf.d/03custom.conf and the
permission warning lands in its captured output.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
|
||
|
|
d884958e07 |
feat: keep the selected monitor tab in the url so a reload returns to it
The view already honoured ?tab=<name> on the way in: php marks that pill active and renders its pane shown. What was missing was the other half. The pills are switched client side, so the url kept saying whatever it said when the page loaded, and a reload -- or a shared link, or the browser restoring the page -- dropped you back on General. Write the tab into the url on shown.bs.tab. replaceState rather than pushState, as filter.js and event.js do: switching tabs in a settings form is not navigation, and making Back walk back through it would be unhelpful on a form holding unsaved edits. Only the tab parameter is touched, since the url carries mid and may carry more. Update the hidden tab field at the same time. Changing Type or Method resubmits the form without action=save to redraw the fields that type needs, and that field is what tells the view where to come back to; php writes it once with the tab the page loaded on, so that submit also used to land you back on whichever tab you arrived on rather than the one you were working in. Noted while reading, not changed here: submitTab() in skin.js sets a `tab` attribute on the form element rather than the hidden input's value, which does nothing. It is invisible today because its data-tab-name is the tab the page loaded on, the same value the input already held, and with this change the input is now correct anyway. It is shared by every view, so it is left alone. Tests: tests/js/monitor-tab-url.test.js, 14 checks, run against monitor.js in a vm sandbox as the other view script tests are. Most of them are about what the rewrite leaves alone: rebuilding a url to change one parameter is an easy way to drop the others, and mid is the one the whole page depends on. The handler finds the pills by a selector and reads the tab name out of an href, both of which are shapes in monitor.php, and if either changes the handler stops firing without erroring -- the tabs would keep working and only the url would be wrong -- so the markup is checked against the selector the handler registered rather than against a copy of it. Verified the checks fail when the pill list is renamed (1), when the no-op guard is removed (1) and when the query is rebuilt from scratch instead of amended (3). Ran: tests/js/monitor-tab-url.test.js (14 passed), the rest of tests/js and tests/php, npx eslint . (clean), utils/check-homoglyphs.py (clean). test_bandwidth_clamp.php fails on this box for an unrelated reason: it cannot read /etc/zm/conf.d/03custom.conf and the permission warning lands in its captured output. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME |
||
|
|
f90ada1958 |
feat: add a UI for scheduled tasks, backed by the crontab refs #4957
This is the rest of what #4957 asked for -- run Perl, PHP, bash and binaries on a flexible schedule, from a visual interface -- and it needs no daemon, because cron is the daemon. 1.39.37 already taught ZoneMinder to rewrite a marked block in the web user's crontab for scheduled monitor actions while leaving every line outside that block alone. A CronJobs row is another line in the same block. So the parts that would otherwise have to be written and then supervised -- timing, catch-up, restart on boot, running as a known user -- are cron's, and have been since 1975. What ZoneMinder adds is the table, the page, and the rule that the crontab is a projection of the table: rebuilt whenever a row changes, never the other way round, so the two cannot drift. The schedule uses the same pickers the monitor actions got, reading "Every Monday at 7pm", with the cron field beside them for anything the pickers cannot say. The pickers build the spec in the browser so the field fills in as you choose, and the server builds and validates it again on save, because the browser is not what keeps a crontab line honest. The two mappings are checked against each other in tests/js/cron-schedule.test.js, which reads the PHP map rather than restating it. The command is not judged on what it does; running things is the point. It is judged on whether it stays on its own line and means in cron what the operator meant: a newline is refused because it would end the entry and let the rest become a line with a schedule of its own, and an unescaped % is refused because cron stops the command there and feeds the remainder in as stdin, which would silently run less than was typed. Cron's own escape, \%, is accepted. Editing takes System edit rights, and every add, change and delete is logged with the command. Anyone who can edit here can run commands as the web user, which is worth saying plainly -- though it is the same trust Filters' AutoExecuteCmd has carried for years, not a new one. ServerId NULL means every server writes the job into its own crontab, which is what a single server install wants without having to say so; naming a server restricts it to that one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f223b38e04 |
fix: ignore MonitorStream replies for a connkey that was already replaced
When a monitor's zms exits, the status poll gets reason=no_socket and the stream is reloaded under a new connkey. A no_socket reply takes over a second (stream.php waits for the socket file to appear) while the poll runs every second, and every request on the page shares one ajaxQueue, so a second poll for the dead connkey is usually already queued when the first reply arrives. Its reply then reloaded the stream again: it sent CMD_QUIT to the zms the first reload had just started and replaced the connkey a second time. Each restart of an unreachable monitor therefore left an orphaned zms behind, and a poll queued for that orphan between the two reloads could find it shutting down. stream.php then waited the full MSG_TIMEOUT and logged "Timed out waiting for msg ... after waiting 5000 milliseconds", while every other monitor's poll on the page waited behind it in the shared queue. Bind each reply to the connkey its request was sent with and drop replies for a connkey we no longer hold, as EventStream already does. alarmCommand() passes its connkey explicitly, since binding getStreamCmdResponse directly would hand it jQuery's textStatus in that argument. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
53662582f0 |
fix: stop the event view resizing and wiping on every gapless event
Gapless playback loads a new event page for each event, and each load moved the video three times and ended in a page-wide wipe: - video.js sets the player up on DOMContentLoaded without a size, while the first changeScale() ran from initPage() on window load. The browser could paint a collapsed video with the controls drawn up under it, and changeScale() measured that collapsed layout (an empty inline-block still leaves a line box), which made the video too small by about 17px. - skin.js's ResizeObserver on the navbar statistics called endOfResize() for the size it reports when it starts observing, and for every width change of the figures, so changeScale() ran again 250ms later and corrected the size. - With the stats panel turned off, initPage() hid it and widened the video's column only after load. - The page-leave roll-up in skin.js animated the content to nothing on every reload, a wipe from the bottom up between events. Size the video on DOMContentLoaded, after video.js has the player, and remember the size per monitor (zmEventVideoSize<Id>) so event.php opens the next page at it; changeScale() still corrects it if the window or layout changed. Render the stats panel and the video's column width from the zmEventStats cookie in event.php. Rescale for the navbar only when its height changes. Move between events through goToEvent(), which sets skipPageLeaveAnimation so the roll-up is skipped for that reload. Measured in Chrome at 1920x915 on a 1280x720 event, each frame's video size and controls position: before, every page went 1x0, 1144x644, 1174x660 with the controls starting near the top of the video, and the content shrank from 851px to 363px before the reload. After, the page reached by gapless playback paints at 1174x660 with the controls in place and does not move, and there is no roll-up. The first event of a session still sizes once, as no size has been remembered yet. Window resize and the stats button still rescale, for both video.js and zms events. tests/js/event-gapless-layout.test.js covers goToEvent() and the DOMContentLoaded sizing; two of its three tests fail without this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
575e5484b3 |
fix: skip an unconfigured go2rtc under the Auto player
With its player left on Auto, a monitor with go2rtc enabled had selectNextPlayer() pick go2rtc_webrtc first, and with ZM_GO2RTC_PATH empty select_go2rtc() then alerted "ZM_GO2RTC_PATH is empty", on the montage for every such monitor, although nobody had chosen go2rtc. The fallback chain already skipped go2rtc for a monitor without it enabled; skip it when ZM_GO2RTC_PATH is empty too. A monitor or browser that chose go2rtc reaches select_go2rtc() directly and still gets the alert. A node test drives selectPlayer() through the restart chain for Auto without a path, Auto with one, and go2rtc chosen explicitly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0d6ebcd42c |
fix: accept ZM_GO2RTC_PATH with or without its /api suffix
go2rtc serves its API under /api, and its websocket at /api/ws. zmc appends /streams to ZM_GO2RTC_PATH and the browser appends /ws, so the setting only worked if it ended in /api. The natural value to enter is the server address, http://host:1984, and with that zmc reached /streams, which is go2rtc's RTSPtoWeb compatibility endpoint, so no stream was ever registered, and the browser's /ws was a 404. Where another player was available the failure went unnoticed. Normalise the setting where URLs are built from it: a trailing slash is dropped and /api is added unless the path already ends in it, which also covers go2rtc behind a reverse proxy at a sub-path. - Go2RTCApiEndpoint() in zm_utils, used by zmc's Go2RTCManager. - go2rtcApiUrl() in skin.js, used by MonitorStream and the console thumbnail stream. It assigns the path once, since stripping the slash from a bare host's "/" and appending gave "//api", as the old pathname += '/ws' gave "//ws". - The option's help says the suffix is optional. Catch2 and node tests cover bare hosts, trailing slashes, an existing /api, a proxy sub-path and credentials in the URL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
428c048bac |
feat: measure the audio level on demand, with a meter in the editor
Reverts the previous commit's always-on measurement. Decoding audio for every monitor that has it spends CPU on a number almost nothing reads, which is the wrong trade even though it did solve the chicken and egg of picking a threshold without ever seeing a level. Measure when something is actually going to use the reading instead: - AudioDetection is on, as before, so nothing changes for a monitor that scores on audio; or - somebody asked. SharedData gains audio_level_until, a wall clock second the capture thread keeps measuring up to. The monitor editor's new level meter pushes it forward while it is on screen and the measurement lapses a few seconds after the page is left, so nothing has to send a stop and a crashed browser cannot leave a monitor decoding forever. When the reading stops being wanted the decoder is released and the published level and peak are cleared, so a stale number is not left looking current and an old peak does not land on the next frame row written. audio_level_until is carved out of analysis_pad rather than appended, so SharedData stays 888 bytes and no existing offset moves; the static_asserts, Memory.pm and Monitor.php are updated together and all three now agree the field is at +880. The meter itself is on the audio settings, shown whether or not AudioDetection is checked, because the level is what you need in order to choose a threshold. It draws the threshold currently in the input as a mark on the bar so a reading can be judged against it before saving, and a monitor whose zmc is not running reads "no reading" rather than a confident 0, which would be indistinguishable from silence. Frames.AudioLevel is therefore 0 again on monitors that do not score on audio. That is what the graph already treats as "no audio data", so it draws no line rather than a flat one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME |
||
|
|
4619258293 |
feat: measure the audio level whatever AudioDetection is set to
Gating the measurement on AudioDetection made the graph useless for the job it is most wanted for. AudioThreshold is a per-device number -- the floor on one camera's mic is nothing like another's -- so it has to be measured before it can be set, but nothing was measured until it was already set. Enabling detection with a guessed threshold to find out what the real one should be is backwards. The level is now read for every monitor with decodable audio. AudioDetection governs only whether crossing the threshold contributes a score, which is what the setting is named for. shared_data->audio_alarm stays 0 when it is off, so nothing downstream changes for a monitor that does not want audio alarms. Nothing here depends on Analysing either. The measurement is in Monitor::Capture, which runs on whatever Analysing is set to, and frame rows come from Event::AddFrame, which a continuously recording monitor reaches through the RECORDING_ALWAYS path with motion detection off. So a monitor that only records continuously still gets levels on its rows. Since Monitor::Capture retries Open on every audio packet until it succeeds, and that now happens for every monitor with audio rather than the handful with detection on, AudioDetector remembers a codec it has already failed to find a decoder for. Without it a stream ZoneMinder cannot decode logs a warning at the audio packet rate for as long as the monitor runs. A reconnect bringing a different codec is still tried. The cost is one audio decode per monitor with audio, where before it was one per monitor with detection enabled. That is small next to the video path, but it is not nothing on a box with many cameras. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME |
||
|
|
4ea307a1cf |
feat: graph motion score and audio level under the event video
The cue strip under the video showed one flat red band per alarm period. It tried to encode the score as a bar height, 'height: '+frame.Score+'px', but never could: the frames ajax did not return Score, so every bar got 'height: undefinedpx' and fell back to the stylesheet's height: 100%. So the motion level it looks like it is drawing has never actually been drawn. Replace it with a line graph of both series over the length of the event. The alarm periods stay, as a pale wash behind the lines, so nothing that was readable before is lost. The two series do not share a vertical scale. Audio level is 0-100 by construction, but a motion score is a sum over zones with no upper bound, so pinning both to 0-100 would flatten the audio line against the floor on any event scoring above 100. Each is scaled to its own maximum, and hovering reads out the exact values, which is what the numbers are wanted for. An event whose rows are all zero -- recorded before the column existed, or by a monitor with AudioDetection off -- draws no audio line at all, rather than a flat line claiming silence was measured. The readout goes into the existing #indicator, which already tracks the mouse across the whole progress bar, instead of a second tooltip competing for the same pixels. The geometry lives in web/js/LevelGraph.js so it can be tested without a DOM, and so the polyline and the hover readout cannot disagree about where a given second sits. The bar grows from 1.25em to fit the graph, which needed two consequential CSS changes: #indicator now spans the taller bar, and .progressBox drops from 0.66 opacity to 0.25, because at full strength the played part of the graph is unreadable behind it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME |
||
|
|
40c4cd3c5d |
fix: run reverse playback at the rate the user picked
changeRate's reverse branch did not use the selected rate as the reverse speed. It looked it up as rates[rates.indexOf(-rate)-1]/100, stepping one entry further down the shared rate list, so every reverse rate ran a notch too slow: -1/2x played at 1/4x and -16x at 10x. -1/4x was worse than slow, because one step below 25 in that list is 0, so revSpeed came out 0 and the video sat still while the ui claimed it was rewinding. The rate the user picked is the speed, so use it. Leaving reverse through the dropdown also leaked the rewind interval, which only pauseClicked and vjsPlay ever stopped. Picking a forward rate after a reverse one left it running, so it went on dragging currentTime backwards and resetting playbackRate to 0 on every tick while the player was supposedly running forwards. The teardown is now stopRewind(), split out of stopFastRev() because stopFastRev rewrites the rate select to 1x, which would undo the choice changeRate is in the middle of applying. stopFastRev no longer reads the rate back out of the player to decide what to put in the select and the cookie, for the same reason streamFastFwd stopped doing it: videojs can defer the set until the tech is ready, so the getter still answers with the rate we just left. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME |
||
|
|
ef361dc3ab |
fix: stop the event playback rate buttons stepping off the end of the rate list
Clicking fast forward once too many at 16x threw out of video.js:
TypeError: HTMLMediaElement.playbackRate setter: Value being assigned is
not a finite floating-point value.
playbackRate@video.min.js
streamFastFwd@..._views_js_event-....js
streamFastFwd stepped the shared rate list by indexing it directly,
rates[rates.indexOf(current)+1]. At the top of the list that is rates[15],
undefined, and undefined/100 is NaN, which Firefox refuses outright.
The guard meant to prevent this ran after the assignment rather than before
it, and read the rate back from the player to decide, so it could only
disable the button once the bad value had already been sent. It was reachable
in normal use because streamPlay() re-enables the button whatever rate we are
at, so play-then-fast-forward at 16x throws every time; picking 16x from the
rate dropdown gets there too, since changeRate does not touch button state.
indexOf also answers -1 for a rate that is not in the list, and -1+1 indexes
rates[0], which is -1600: stepping forwards from an unlisted rate asked for
16x reverse.
streamFastRev had the same fault at the other end. rates[0-1] is undefined, so
revSpeed became NaN and every tick of the rewind interval then handed
currentTime a NaN.
Both now go through stepRate, which snaps an unlisted rate to the nearest
listed one and returns null rather than walking off either end, so the caller
disables the button and leaves the player alone instead of assigning
something it cannot use. streamFastFwd also sets the dropdown and cookie from
the rate it just asked for rather than reading it back, because the stack in
the report shows videojs deferring the set until the tech is ready, at which
point the getter still answers with the old rate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
|
||
|
|
f36645dfd9 |
fix: point the audioMotion-analyzer install instructions at the ES module
The library is AGPL-3.0-or-later so it is not shipped, and the admin installs it at skins/<skin>/assets/audioMotion-analyzer/src/audioMotion-analyzer.js. The instructions for doing that had drifted from what the code loads: - help.txt and the OPTIONS_WHATTODISPLAY help gave the bare https://cdn.jsdelivr.net/npm/audiomotion-analyzer@X.X.X URL, which resolves to the package's "main" entry, the minified UMD bundle dist/index.js. The install path is the package's src/ ES module, so the URL needs the explicit /src/audioMotion-analyzer.js suffix. Same for the download links in the AudioMotionVersionNotInstalled and AudioMotionVersionWrongVersion messages. - The install path was written as /skins/MySkin/..., a placeholder that does not correspond to any skin. - RequiresAudioMotionEnabled named only the file, not where it goes. - assets/version documented every other asset in that directory but not this one, leaving no explanation for the otherwise empty directory. help.txt no longer restates the required version, so 4.5.4 stays declared only by SUPPORTED_AUDIO_MOTION_ANALYZER_VERSION as intended, and assets/version points at that constant rather than duplicating it. Add tests/js/audiomotion-paths.test.js to hold the PHP feature probe, the dynamic import, help.txt and both lang catalogues to the same path, the same download URL and the same version. Also gitignore the installed library so a local install is not committed back into a GPL-2.0 tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME |
||
|
|
599627a940 |
fix: stop the cycle timer leaking an interval on every restore fixes #5135
cycleStart() took a fresh setInterval id straight into cycleIntervalId without clearing what was already there. Once overwritten the old id is unrecoverable, so the orphan keeps calling nextCycleView every second and cyclePause() can only ever stop the last one armed. Several callers reach cycleStart() with no cyclePause() in between: the play button, the are-you-still-watching modal closing, and startPage(). That last one is the routine path - it runs from visibilitychange, from resume and from pageshow, and a restore fires more than one of those, so a tab coming back while cycling was active armed two. The monitor restart in the same function is protected, since it nulls prevStateStarted on the way through, but the cycle branch below it never cleared prevStateCycle. Clear the interval at the top of cycleStart(), which makes every caller safe whatever order they arrive in, and clear prevStateCycle in startPage() the way prevStateStarted already is. cycle.js has the same shape in its own cycleStart() behind a play button, so it gets the same guard. Tests in tests/js/watch-cycle-interval.test.js drive watch.js under stubbed timers and count what is left running: two starts leave one interval, a pause after three starts leaves none, and a second startPage() does not re-arm. Three of the four fail against the unfixed file. Full JS suite passes, ESLint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UkQwahn9pi1y4wJe9BTxjM |
||
|
|
da99928235 |
fix: revalidate on every resume rather than trusting a time window
The staleness window was unsound. calculateAuthHash() keys the hash to the clock hour it was minted in and getAuthUser() accepts the last ZM_AUTH_HASH_TTL hourly buckets, so a hash dies at the top of an hour rather than at some age. generateAuthHash() then serves the cached one until it is half a TTL old, so what arrives can already be nearly spent: on the defaults a hash minted at 10:59 is still handed out at 11:58 and is refused at 12:00. A client stamping that arrival as fresh for an hour skips the probe until 12:58 and restarts its streams on a dead hash - the exact failure this was written to prevent. No fixed window is safe, because the remaining life of a hash we hold can be anything down to zero, and AUTH_STALE_MS also ignored the configured ZM_AUTH_HASH_TTL. So drop AUTH_STALE_MS, authIsStale() and authFreshAt, and have whenAuthFresh() revalidate. The one case that can still skip the probe is having no hash at all - authentication off, or a relay form that does not use one - where there is nothing that can expire and nothing a probe would report. revalidateAuth() already shares one request between concurrent callers, so a resume that wakes several of these still costs a single probe, and that is what the montage code did unconditionally before any of this. refreshTablesPendingVisibility() now returns as soon as it finds nothing was deferred. It is bound on every classic page including the unauthenticated ones, and the version before this ran the whole auth path on an empty queue, so merely becoming visible could fire a probe with no work behind it. Tests: two authIsStale cases removed with the function, two whenAuthFresh cases added - a probe is sent and the callback held until it answers, and no probe is sent when there is no hash. Reintroducing a fast path fails the first. Full JS suite green, ESLint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UkQwahn9pi1y4wJe9BTxjM |
||
|
|
4c878eeebe |
fix: send the auth revalidation probe without a credential
The probe went out as zmAuth.appendTo(...), carrying the very hash it existed to replace. zm_authenticate_request() resolves a request against exactly one source: a non-empty auth= in the URL enters the ZM_AUTH_HASH_LOGINS branch (on by default), and when getAuthUser() rejects it the chain has already been taken, so the userFromSession() arm below it never runs. A live session cookie then authenticates as nobody. Past ZM_AUTH_HASH_TTL - a tab hidden longer than two hours on the defaults, which is exactly the case this change is for - the probe was therefore the one request guaranteed to fail, and its failure is read as 'login', so the user was bounced to the login page with a perfectly good session. That is worse than the 403s in the log this set out to remove. Send the probe bare. The session cookie is what answers, which is the question being asked: who am I, and what is my current hash? That also makes the failure handling mean what its comment claimed. A rejection now really is a dead session rather than a dead hash, so redirecting to login on it is right - and both 401 and 403 reach it, which the comment now says. Also correct the AUTH_STALE_MS comment: authIsStale() is a strict comparison, so a credential confirmed exactly AUTH_STALE_MS ago is still fresh, as the test asserts. Tests: tests/js/auth-helpers.test.js, 51 passed (4 new for revalidateAuth, covering the bare probe, shared in-flight request, callbacks surviving a transient failure, and login on a rejected session). Reverting the probe to the credentialed form fails two of them. refs #5093 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nr76CednxtDt2nPuq6WrbL |
||
|
|
1d01c966d4 |
refactor: track credential age instead of hidden time, fold montage in
whenAuthFresh() gated on how long the tab had been hidden, which only covers one of the ways the page stops hearing about the credential. montage's idle timeout is another: hitting ZM_WEB_VIEWING_TIMEOUT stops every monitor, and their status polls with them, while the tab stays visible the whole time. The Are You Still Watching modal can then sit there for hours, so the auth hash baked into the monitor srcs is just as dead as after an overnight sleep, and a hidden-time gate would wave it straight through. Track the age of the credential itself instead. ZMAuth.update() stamps it whenever a reply carries auth_relay or auth - even when the value is unchanged, since the server has still just confirmed it - and revalidateAuth() stamps it too, so authentication being off doesn't leave every caller revalidating once an hour forever. authHiddenTooLong(hiddenAt, now) becomes authIsStale(freshAt, now); onAuthVisible() no longer keeps a timestamp. That subsumes montage's refreshAuthAndStartMonitors(), which duplicated revalidateAuth()'s navBar probe and fired a second one on every refocus. Both call sites are now whenAuthFresh(startVisibleMonitors), which shares the in-flight probe rather than racing it. Tested: node tests/js/auth-helpers.test.js (47 passed), node tests/js/table-helpers.test.js (8 passed), npx eslint on the changed files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkE45gkMjnkTiJiUySbe7V |
||
|
|
c83a15e048 |
fix: refresh auth hash before restarting streams after a long tab hide
Hidden tabs have their timers throttled, and frozen/slept ones stopped outright, so nothing refreshes the credential while the tab is in the background. The server rotates the auth hash at half of AUTH_HASH_TTL (generateAuthHash()), so the hash baked into a stream <img> src or a deferred table url is usually dead by the time the tab is refocused. Every restarted stream and table poll then fires a request that 403s and logs an auth error before anything gets around to revalidating. Record when the tab goes hidden and treat the credential as stale once more than an hour has passed. Add whenAuthFresh(cb), which runs cb straight away after a short alt-tab but queues it behind a revalidation after a long sleep, so nothing makes an authenticated request on the expired hash. revalidateAuth() now queues callbacks rather than dropping them when a probe is already in flight, clears the hidden timestamp only once a reply actually arrives, and still runs the queued callbacks on a transient failure - a network blip is no reason to leave the page's streams stopped. A 401 still redirects to login and drops the queue. Gate the two visibility-driven callers on it: watch.js startPage() and table-helpers.js refreshTablesPendingVisibility(). montage.js already refreshes auth before restarting its monitors. Tested: node tests/js/auth-helpers.test.js (48 passed), node tests/js/table-helpers.test.js (8 passed), npx eslint on the changed files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkE45gkMjnkTiJiUySbe7V |
||
|
|
1361f93804 |
fix: send the initial mode=paused CMD_PLAY that streamCommand was dropping
select_zms() has three ways out, and two of them send a stream command before the tail of the function sets started. streamCommand() drops anything sent while that is false, so such a branch reports success having sent nothing and the picture sits on its last keepalive frame. The resume branch was fixed on this branch already. The branch below it, for a page rendered with mode=paused, has the same shape and was missed. With auth on it needed a still-valid hash to reach, so it was intermittent; with auth off, where there is no hash that can go stale, the srcAuthCurrent change on this branch makes it the path every initial load takes. Set started there too. Add tests/js/monitorstream-resume.test.js, which asserts what reaches the wire rather than which branch ran: the resume and initial-paused paths each send exactly one CMD_PLAY on the connkey they are supposed to address, resuming leaves src and connkey alone, and a stale auth hash still rebuilds and quits the process the old connkey addressed. Removing the one-line fix fails the initial-paused case and leaves the other three passing. Full JS suite passes, ESLint clean on both files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UkQwahn9pi1y4wJe9BTxjM |
||
|
|
741de2d2e4 |
fix: finish the review items on the zone object size tool
The four points from the review on #5076 that the first round left open. Persist a ratio as the number that will be read back. zone.php reloads these cookies through validInt(), which strips everything that is not a digit, so 70.5 was stored and returned as 705 and 1e2 as 12, while the live calculation took the same field through parseFloat and used it as typed. The ratio therefore changed meaning on reload. Round and range-check before storing, and write the result into the field so what is shown, what is calculated and what is stored are one number. Clear the tool when the page's Reset is pressed. Reset restores every field from initialValues and redraws the polygon, so the measurement Undo would revert is already gone, but undoSnapshot, lastBox, the highlighting and the rectangle all survived it. The handler is wrapped rather than joined by a listener because ours has to run first: resetChanges calls updateArea, which now re-derives. Re-derive when the zone area changes. The thresholds are stored as percentages of the zone, so moving, adding or removing a vertex changes what they mean in pixels and the saved numbers stop describing the object that was measured. updateArea is the one funnel those edits go through, so wrap it and re-run the derivation from the box still on screen. apply() only reaches updateAllPixelDisplays, so there is no way back into updateArea from there. Draw with pointer events, and give the tool a keyboard-operable equivalent. pointerdown/move/up with pointer capture replaces the mouse-only gesture, which touch and pen could not drive at all, and touch-action is suppressed while armed so the browser does not claim the drag for a scroll. pointercancel ends the drag too, since a pointer taken away by the browser never sends pointerup. For the keyboard there is now an object size in capture pixels beside the ratios: it derives through the same path, draws the same box centred in the frame, and raises the same filter warning. A finished drag writes its size back into those fields, so the two ways in stay in step. Also re-check measurability at pointerdown. The type listener added in the first round covers the select, but applyPreset writes Type directly and fires no change event, so a preset that makes the zone Inactive or Privacy left the tool armed and apply() would re-enable the fields applyZoneType had disabled. tests/js/zone-object-size.test.js covers the two new pure functions: 46 assertions pass. ESLint clean on the module and the test; php -l clean on zone.php, zone.js.php and en_gb.php. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b82ee8b7dd | Merge branch 'master' into zone-object-size-helper | ||
|
|
461adbbd20 | fix: show the events table error and clear loading on both paths refs #3301 | ||
|
|
eff68f4103 | fix: stop the events table loading forever when the query fails refs #3301 | ||
|
|
eb0ad029f5 |
fix: correct zone rectangle measurement edge cases refs #5075
From review on #5076: - Arming the tool now discards the previous measurement, so a drag after re-arming snapshots the fields as they are then. Before, edits made between sessions were ignored by the next drag and reverted by undo. - Judge the filter warning per axis, comparing width against FilterX and height against FilterY. Comparing both against the larger of the two flagged shapes the filter keeps, and an object that exactly fits the kernel survives it. - Decode a chosen still before showing it. The MIME type alone lets a corrupt file pause the stream and display a broken image. - Stand down when the zone type changes to Inactive or Privacy while the tool is armed, rather than re-enabling fields applyZoneType disabled. - Move the undo snapshot on when a managed field is edited by hand, so undo no longer reverts work done after the measurement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019YkuGif56kchpHBHK3d65A |
||
|
|
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 |
||
|
|
420f2b92be |
feat: derive zone detection settings from a drawn rectangle
Adds a measuring tool to the zone editor. Drag a box around the smallest object that should trigger an alarm and the detection thresholds are derived from its area, replacing the guesswork of picking pixel counts by hand. Sizing follows docs/userguide/definezone.rst. [J] gives the rule directly, a minimum Alarmed Area of "25% of the area that an object of interest takes up in the Zone region", along with a worked example - 30% of the zone giving 7.5% - which the tests assert. The filtered and blob minimums step down from there by 70% and 86%, the ratios every shipped Blobs preset uses whatever its sensitivity, so the steps are a property of the pipeline rather than a sensitivity dial. All three are editable in a table beside the zone settings. Each stage is measured against the one it is drawn from: filtered pixels are a subset of alarmed ones, and a blob is built from pixels that survived filtering. Taking the blob threshold as a share of the filtered count rather than the alarmed count makes MinBlob <= MinFilter <= MinAlarm hold by construction, so no clamp is needed to keep validateForm satisfied whatever ratios are entered. Thresholds are computed as pixel counts and converted to percentages once, at the point of storage. Deriving the filtered and blob values from an already-rounded alarm percentage rounded twice, which for a 1104px box in a 100000px zone put the filtered minimum a whole step out. CheckMethod is set to Blobs, since MinBlobPixels is the only threshold tested per blob - the other two are zone-wide sums that several small movers can reach together. applyCheckMethod() is called explicitly because assigning to a select does not fire onchange, and the blob fields would otherwise stay disabled and never submit. Every Max is left blank. MaxAlarmPixels, MaxFilterPixels and MaxBlobs set overload_count, blinding the zone for OverloadFrames frames, and exceeding MaxBlobPixels deletes the blob outright. One rectangle cannot imply a ceiling when the same object is far larger close to the camera. MinPixelThreshold and FilterX/Y are camera properties rather than object sizes, so they are kept whenever already set, from a preset or by hand, and filled from the documentation's defaults only when blank. Interaction: - thresholds track the box live during the drag, so nothing on the mousemove path blocks - the tool stays armed and each new box replaces the last, all measured against the state before the first, so undo and highlighting stay anchored - mouseup redraws at the release point before measuring it, so the visible rectangle is always the one the numbers came from - a label on the box reports its size in capture pixels - rewritten fields are marked in the khaki the point rows already use, and the marks clear when a preset or a hand edit takes over - a still image can stand in for the live view, read locally as a data URL and drawn in exactly the box the stream occupies so coordinates still map Ratio defaults live only in zone.php, which reads the cookies and renders each field with its stored value as the player selector does with zmZonePlayer. Reuses maxX/maxY, constrainValue, streamCmdPause/Play, getCookie/setCookie and validInt rather than reimplementing them, and shares the existing highlight colour and Editing stroke width. zone.js is unchanged. Tests: tests/js/zone-object-size.test.js, 35 assertions, node+assert matching the existing tests/js pattern. Browser interaction is not covered. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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
|
||
|
|
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. |
||
|
|
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> |
||
|
|
ce1474667a |
fix: stop montage zms <img> reconnect storm on stale auth hash
A live multipart (mode=jpeg) stream <img> whose baked auth hash expires past AUTH_HASH_TTL is reconnected by the browser itself, reusing the same src (same connkey, same dead hash). Every native reconnect returns 403, so once the capture daemon drops the stream the client storms zms with auth failures for hours. Observed in production: a single connkey retried 84 times over 2.5h, 880 failures on one monitor whose zmc was timing out, while monitors with healthy zmc showed only baseline hash-rollover noise. img_onerror only blanked the <img> src inside its async refresh callback, which never ran once authRefreshAttempts reached the cap. On give-up the stale src stayed live and the browser kept native-retrying it, which is the storm. Blank src synchronously at the top of img_onerror so the browser's retry loop stops immediately, and reconnect with a fresh connkey (the zms process behind the old connkey has exited) after fetching a fresh hash. Extract the src rewrite into a pure rebuildStreamSrc() helper in auth-helpers.js with unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
a55d167b91 |
feat: refresh auth hash on tab resume, redirect to login on dead session refs #4748
When a console or stream tab is backgrounded or the machine sleeps past the auth hash TTL, the baked-in auth= on nph-zms <img> URLs expires. The browser keeps reconnecting with the stale hash, producing a burst of 403s from zms while the session-backed page still renders. Detect the tab becoming visible again (visibilitychange/pageshow) and probe auth once against the navBar status endpoint before letting streams reconnect: on success refresh the global auth_hash and repaint; on a dead session (401) go straight to login instead of retrying. Route the navBar poll, console table query, and per-stream error paths through a shared decision so 401/403 ends in a single login redirect rather than a retry storm. Put the auth functions (goToLogin, revalidateAuth, onAuthVisible) and the pure authFailureAction/loginRedirectUrl helpers in web/js/auth-helpers.js as named globals; skin.js only wires the visibility listeners. Node unit tests cover the pure helpers (tests/js/auth-helpers.test.js). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c4073c964c |
feat: encoder parameter templates with editor + REST API closes #4778 closes #4802
Adds a curated, per-encoder parameter-template library to ZoneMinder:
- Monitor edit page: a new Template row above the EncoderParameters
textarea offers per-encoder templates (Balanced / Archival / Low
Power / Low CPU). Apply merges the template's params into the
textarea, preserving user-only keys. Advisory lint flags option
keys that aren't recognised for the selected encoder. Switching
encoders offers a same-name template on the new encoder via a
native confirm.
- Options page: a new Encoder Templates tab with full CRUD —
list / edit / copy / delete — backed by a new CakePHP REST API
at /api/encoder_templates.
- Storage: a new EncoderTemplates DB table seeded with 14 shipped
defaults across libx264 / libx265 / h264_nvenc / hevc_nvenc /
h264_vaapi / hevc_vaapi. The table is mutable; ZM upgrades do not
re-seed user-edited rows.
- valid_keys (the lint allow-list) stays in PHP code as ffmpeg
vocabulary, not user data.
- Default params explicitly include pix_fmt to avoid the yuvj420p
HEVC HW-decode rejection issue we hit earlier.
No C++ change. The textarea content is parsed by the existing
av_dict_parse_string call in src/zm_videostore.cpp.
version.txt -> 1.39.6.
Specs: docs/superpowers/specs/2026-05-0{1,2}-*.md
Plans: docs/superpowers/plans/2026-05-0{1,2}-*.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|