Commit Graph
284 Commits
Author SHA1 Message Date
Isaac ConnorandClaude Opus 5.5 7603a7458f fix: read yes/no, true/false and on/off in config files as booleans in PHP and Perl
A boolean option set in zm.conf or conf.d wins over the Config table, and a
person editing a file writes yes or off rather than 1 or 0. The C++ daemons
now read those words (ConfigFileValue, 4d7609d36), but PHP and Perl took the
value as written, and a non-empty string is true in both. So
ZM_STRICT_VIDEO_CONFIG=off in conf.d left the option on in the web UI and the
Perl scripts while zmc turned it off.

Read a file value for a boolean option the same way in all three: yes, true
and on are 1; no, false and off are 0; anything else stays as written. Only
booleans are read this way, so a string option such as a title keeps "Yes".

PHP defines the file values as constants before it reads the Config table,
since it needs the database settings to connect, and only the table says
which options are booleans. So a word now waits until the table has been
read, apart from ZM_DB_* settings, whose words database.php reads itself.
Perl reads the type with each row.

Tested on 1.38's zm138 database with a conf.d file setting
ZM_OPT_FAST_DELETE=yes (0 in the table), ZM_STRICT_VIDEO_CONFIG=off (1)
and ZM_WEB_TITLE=Yes. The installed PHP and Perl gave 'yes', 'off' and
'Yes'; these give '1', '0' and 'Yes'. tests/php/test_config_file_booleans.php
checks the rule.

refs #5289

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:37:07 -04:00
Isaac ConnorandClaude Opus 5.5 4d7609d36e fix: let options in zm.conf and conf.d win over the database in the daemons
zm.conf and conf.d are set by the owner of the box; the Config table by the
ZoneMinder admin through the Options page. The owner has to be able to limit
the admin, so a file setting must win. PHP and Perl already let it.
process_configfile() kept only a fixed list of names (database credentials,
paths, server identity, stream socket) and dropped the rest, so zmc, zms and
zmu read every other option from the database alone.

So with ZM_OPT_USE_AUTH=1 in conf.d and 0 in Options, the web UI asked for a
login while nph-zms served streams and snapshots to anyone. The same split
applied to every option, ZM_AUTH_RELAY and ZM_AUTH_HASH_IPS among them.

process_configfile() now records every name = value in
StaticConfig::file_options, later files winning, and zmLoadStaticConfig()
clears it first so a reload forgets a removed line. Config::Load() applies
those values over the database ones, also when the database can't be read.
A boolean may be written yes/no, true/false or on/off as well as 1/0
(ConfigFileValue), since the Config table's 1/0 convention is not what
people type into a file. Overrides are logged by name only, as the value may
be a secret.

Tested live on 1.38: with ZM_OPT_USE_AUTH=1 in conf.d and 0 in the database,
an unauthenticated nph-zms snapshot request answered 200 with an image
before and 403 after. Without the override it still answers 200.

refs #5289

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:27:19 -04:00
Isaac ConnorandClaude Opus 5.5 cdb74426bb fix: index an event MP4 whose last fragment lasts 0 ticks
An event that closes on a keyframe ends in a fragment holding that frame
alone. The muxer gives the file's last sample the packet's duration, which
camera packets leave at 0, so the last sidx reference would last 0 ticks
and build_sidx_region refused the whole index ("Could not index ...; it
will play, but a player must read it all before the first frame").

Fold a zero-length last reference into the one before it. That reference
still points at a real moof, so seeking is unchanged. A lone zero-length
fragment, or one mid-file, is still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:33:10 -04:00
Isaac ConnorandClaude Opus 5.5 514254b967 fix: report a linked monitor's alarm only from its alarm state
MonitorLink::score() reported an alarm whenever the linked monitor's
last_event_id changed (4356a6dea), to catch an alarm that went from ALARM
through ALERT to IDLE between two checks. A new event is not an alarm,
though. A monitor recording continuously starts a new event at every section
boundary, so each one gave its linked monitors a "Linked" alarm, and an
on-demand one a camera reconnect, with no motion anywhere. A monitor with
Recording=None never opens events, so for it the latch did nothing.

Report alarm status only: the frame score in ALARM, and 1 in ALERT so a linked
event stays open through the source's post-event frames (8fc1992e4). The state
stays in shared memory until the source analyses its next frame, and the
receiving monitor checks the link on every packet (every 100 ms while an
on-demand one is paused), so an alarm is still seen.

tests/zm_monitorlink_score.cpp points a MonitorLink at a local SharedData and
checks IDLE, ALARM and ALERT, and that a new event while idle scores 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:51:35 -04:00
Isaac ConnorandClaude Opus 5.5 34084a229d fix: wake an on-demand monitor when a linked monitor alarms
A monitor with Capturing=Ondemand and LinkedMonitors never recorded a linked
alarm unless someone happened to be watching it. Links are evaluated in
Analyse(), per captured packet, and zmc pauses an on-demand camera, stopping
its analysis thread, 10 s after the last viewer. While paused nothing looked
at the links, so every alarm on the linked monitor was missed. This is the
low-bandwidth setup of a substream that analyses and a main stream that
records on demand.

zmc's on-demand decision moves into Monitor::CheckOndemand(), around a
DecideOndemand() table that can be tested:

- While paused, the links are checked on every pass. The analysis thread is
  stopped then, so the capture thread reads them without racing it. An alarm
  reopens the camera.
- An unwatched monitor keeps capturing while it is alarmed or in its
  post-event frames, instead of pausing in the middle of the event.
- A link only reports an alarm while it lasts, and reopening the camera can
  take longer than that (2.5 s against a local camera). So the wake is
  remembered for up to 30 s, and the first packet Analyse() scores counts it
  as a linked alarm, then clears it.

Tested live on 1.38 with a paused on-demand passthrough monitor linked to an
analysing one. Before: an 8 s forced alarm on the source recorded nothing.
After: the monitor woke within 0.1 s, alarmed 2.5 s later and recorded the
event. A 1 s alarm, over before the camera reopened, was also recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:33:11 -04:00
Isaac ConnorandClaude Opus 5.5 0960b2064b fix: log the database error when dbQuery's prepare or query returns false
dbQuery()'s two error paths for a prepare() or query() that returns false
logged $pdo->errorInfo. $pdo is not defined there, so PHP warned twice and
the message ended without the error. They also read dbLastError() from the
connection only after ZM\Error() had logged; with a database log target that
inserts into Logs through the same connection, which replaces its errorInfo,
so dbLastError() came back as "00000" instead of the error.

Read the error once, before logging, and use it for both. The query() path
also said "Error preparing" for a statement it ran directly.

These paths run when PDO's error mode doesn't throw; dbConnect() sets
ERRMODE_EXCEPTION, so normally the catch below handles the failure.

tests/php/test_database_error_paths.php drives both paths with
ERRMODE_SILENT and checks for no PHP warning and the real error text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:58:26 -04:00
Nic Boet 35a8daf99e perf: emulate prepares for ZoneMinder's own queries on PHP 8.1+
dbConnect() turns emulated prepares off, so every query of the web UI, the
ajax calls and the includes the API runs (config, servers, users,
permissions) is a server-side prepare: prepare, execute and close, three
round trips to the database where one would do. From PHP 8.1, pdo_mysql
returns integers and floats as PHP ints and floats with emulated prepares
too, so values keep the types they have now. Emulate prepares there and keep
server-side prepares on older PHP, where every value would become a string.

Emulated prepares quote parameters bound as strings, which breaks LIMIT ?.
dbQuery() now binds integer values as integers (everything else as strings,
as execute() did), and the log and watch ajax calls cast their validated
offset and limit to int. With emulation, PDO would also run several
statements given in one string; MYSQL_ATTR_MULTI_STATEMENTS is turned off so
that is refused as before. CakePHP's own connection is not changed: it
already uses PDO's default, emulated prepares.

tests/php/test_database_emulated_prepares.php compares every column of every
table read through both kinds of prepare (types and values), checks
aggregates, LIMIT with positional and named integers, string and null
parameters, and that two statements in one string are refused.
2026-10-14 22:41:42 -05:00
Isaac ConnorandClaude Opus 5.5 1ca880359e fix: start a planar crop on the chroma grid refs #5265
A YUV420P chroma sample covers 2x2 luma pixels. Image::Crop started the
chroma planes at lo_x >> 1 and lo_y >> 1, so a crop starting on an odd
column or row had its chroma half a pixel off its luma. nph-zms zoom picks
any position, so a zoomed live view could show colour fringes.

For planar formats, move the crop window back to the chroma grid before
copying, keeping its size. Moving left or up can't leave the image.

Also use the existing ceil_rshift() for the chroma copy sizes instead of
writing the same calculation inline, moving it above Crop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:16:05 -04:00
Isaac Connor a5b1ce3bee Merge pull request #5266 from nabbi/fix/b79-image-crop-planar
fix-high: crop into a buffer laid out the way Image::AssignDirect expects
2026-10-14 20:15:46 -04:00
Isaac ConnorandClaude Opus 5.5 2d353fe43c fix: run the shared-memory reload action through zmc's SIGHUP reload fixes #5239
Monitor::CheckAction() handled the RELOAD action (zmu --reload, or a script
setting it through ZoneMinder::Memory) by calling Reload() in place. That
runs on zmc's capture thread, and Monitor::Load() rewrites settings such as
label_format, orientation and the image sizes while the decoder and analysis
threads read them unlocked.

SIGHUP already reloads safely: zmc leaves its capture loop, Close()s and
disconnect()s each monitor, which stops those threads, then Reload()s and
reconnects. Have the action set zm_reload, as the SIGHUP handler does, so
both take that path.

zmc reloads every monitor it runs this way, not only the one the action was
sent to; zmc normally runs one.

Tested live on 1.38 with zmu --reload against a running zmc: the event closes,
the same process reloads and reconnects, capture resumes and a new event
opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:07:40 -04:00
Isaac ConnorandClaude Opus 5.5 31eeb486ca fix: reject LinkedMonitors ids that don't fit an unsigned int refs #5273
std::stoul returns an unsigned long, and Token assigned it to an unsigned
int monitor or zone id. On 64-bit, 99999999999 parsed and was truncated
to 1215752191, so a LinkedMonitors value could quietly link a different
monitor than the one typed. It only failed because that id didn't exist.

Move the parsing into Token::parse_monitor_link(), which rejects an id
above UINT_MAX like any other value that isn't a number, so the token is
ignored with the same warning. The function is static so the test can
check the parsed ids without a database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:40:37 -04:00
Isaac Connor c5d0ac2fb2 Merge pull request #5274 from nabbi/fix/b83-monitorlink-bad-token
fix-high: ignore LinkedMonitors tokens zmc can't link instead of crashing
2026-10-14 18:36:18 -04:00
Isaac Connor af039e8091 Merge pull request #5272 from nabbi/fix/b81-monitorlink-own-expression
fix-medium: keep the linked-monitor expression's own copy of its string
2026-10-14 18:31:14 -04:00
Isaac Connor 503a1c0c74 Merge pull request #5270 from nabbi/fix/b82-poll-thread-stop
fix-medium: let PollThread::Stop end the wait between polls
2026-10-14 18:25:18 -04:00
Isaac Connor a7b8d3a075 Merge pull request #5208 from ZoneMinder/db-log-pause-queue-only
fix: keep only the database queue thread out of database logging
2026-10-13 18:18:58 -04:00
Isaac Connor 6e06dc31ab Merge pull request #5203 from nabbi/fix/b52-logger-reload
fix-medium: re-initialise the logger in place on reload instead of deleting it
2026-10-13 18:18:15 -04:00
Isaac Connor 4581360cf7 Merge pull request #5201 from nabbi/fix/b53-close-orphaned-events
fix-high: close events left open by a capture daemon that died
2026-10-13 18:17:38 -04:00
Isaac Connor bf447c96a2 Merge pull request #5247 from nabbi/fix/b11-reolink-uniview-json
fix-medium: load JSON through JSON::MaybeXS in Reolink_HTTP and Uniview, add a Reolink_HTTP Controls row
2026-10-13 18:16:40 -04:00
Isaac Connor 24c1c6ab98 Merge pull request #5243 from nabbi/fix/b16-user-self-edit-api
fix-medium: honour ZM_USER_SELF_EDIT in the users API
2026-10-13 18:15:54 -04:00
Isaac Connor 969069eacf Merge pull request #5241 from nabbi/fix/b68-signal-colour-luma
fix-medium: match SignalCheckColour by its luma on 1-byte-per-pixel captures
2026-10-13 18:14:42 -04:00
Isaac Connor 7e02d9a5d4 Merge pull request #5214 from nabbi/fix/b76-zone-pixel-clamp
fix-high: clamp pixel zone coordinates to the monitor before rasterising
2026-10-13 18:14:03 -04:00
Isaac Connor e5ee92ed5e Merge pull request #5249 from nabbi/fix/b61-packet-trylock
fix-low: don't let a failed ZMPacket::trylock overwrite the lock flag
2026-10-13 18:11:50 -04:00
Isaac Connor 089cf224bb Merge pull request #5234 from nabbi/fix/b69-zero-frame-event-close
fix-medium: store MaxScore 0 when an event closes before its first frame
2026-10-13 18:09:40 -04:00
Isaac Connor 3ecadeb66d Merge pull request #5231 from nabbi/fix/b71-zms-command-thread-race
fix-low: stop the nph-zms command thread racing runStream on frame bookkeeping
2026-10-13 18:08:22 -04:00
Nic BoetandClaude Opus 5.5 ed1d12494d test: list zm_monitorlink_token.cpp after zm_monitorstream.cpp
Placement only, away from the lines other open branches add to the test
list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:18:44 -05:00
Nic BoetandClaude Opus 5.5 6d3e869949 test: have the logger reload test's workers just log
The workers copied what zmDbDo() did before #5197: hold the Logger
pointer across a query and set the database level to NOLOG around it.
#5197 removed that. The workers now fetch and log, as the threads that
still log during a reload do (the dbQueue thread, the stream socket
threads), and the comment names those.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:38:07 -05:00
Nic BoetandClaude Opus 5.5 e472cd1533 fix: keep a name the user gave an event that zmwatch closes
Event::Close() renamed the event to <prefix><Id>(r) every time. That
was fine while only zmeventtool.pl close and zmaudit called it, by hand
or a day later; with zmwatch closing events left open by a zmc that
died, it would overwrite a name the user set while the event recorded.

Rename only when the name is still the placeholder 'New Event', as
zmc's own close does (~Event: Name = IF(Name='New Event', ...)). The
'Recovered.' note still marks the event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:37:20 -05:00
Nic BoetandClaude Opus 5.5 f0a8e41127 fix: ignore a LinkedMonitors token that isn't a monitor id
Token's constructors parse a monitor link with std::stoul, and the one
MonitorLinkExpression::parse() uses is noexcept, so a LinkedMonitors
value with a token that isn't a number ("abc", "1:x", a space before a
parenthesis, a number past unsigned long) ended zmc in std::terminate
every time it started. LinkedMonitors is a free-text field and neither
the web UI nor the API checks it.

Catch the parse error, log the token and leave it without a link, which
scores 0 like a linked monitor that isn't connected. The two
constructors had the same body; it is now one helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:30:34 -05:00
Nic BoetandClaude Opus 5.5 87a9db816e Merge master into fix/b59-signal-single-writer
Resolves the conflict with dad9da272 (refs #4242) in Monitor::Capture():
keep the timestamp continuity block from master, followed by this
branch's signal handling. Same tree as the branch rebased onto
233a15828, which passed the holodeck checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:23:06 -05:00
Nic BoetandClaude Opus 5.5 2f7ace2c2f fix: keep the linked-monitor expression's own copy of its string
MonitorLinkExpression kept a string_view of the string it was built
from, Monitor::linked_monitors_string, and the tokens parse() builds are
views into it. Monitor::Reload() calls Load(), which reassigns that
string without re-parsing the expression, so once the new value no
longer fits the old buffer every token points into freed memory. They
are read by the Debug(1, ...) calls in MonitorLinkExpression::visit(),
which runs on every analysed frame of a monitor with LinkedMonitors set
when its debug level is 1 or more.

Copy the string into the expression and point the views at the copy.

fixes #5271

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:57:16 -05:00
Nic BoetandClaude Opus 5.5 dcca007124 fix: let PollThread::Stop end the wait between polls
Monitor::Poll() ended by sleeping until 10 s after it started, and
PollThread::Stop() joins the thread, so Monitor::Close() waited for the
rest of that sleep: up to 10 s on every stop, reconnect and reload of a
zmc whose monitor has an ONVIF event listener, Janus, RTSP2Web, Go2RTC
or the Amcrest API enabled.

Wait for the next poll in PollThread::Run() on a condition variable
that Stop() notifies. Polls still start every 10 s.

fixes #5269

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:53:51 -05:00
Nic BoetandClaude Opus 5.5 6e4fb3b35f fix: crop into a buffer laid out the way Image::AssignDirect expects
Image::Crop copied the cropped rows into a tightly packed
width * colours buffer. Since AssignDirect checks the size against
av_image_get_buffer_size(..., 32), it refused that buffer whenever a
cropped row was not a multiple of 32 bytes, and always for YUV420P,
whose chroma planes Crop did not copy at all. It logged "Attempt to
directly assign buffer from an undersized buffer", the image stayed
uncropped and the new buffer leaked. nph-zms crops every frame of a
zoomed live stream: zoom had no effect and the process grew by one
cropped frame per frame sent (about 39 MB in 10 s at 1280x720, 5 fps).

Allocate the result with av_image_get_buffer_size at 32-byte alignment
and copy each plane with its own offset and stride, as Image::Rotate and
Image::Flip do.

fixes #5265

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:49:42 -05:00
Isaac Connor 233a158282 Merge pull request #5227 from nabbi/fix/b74-zms-raw-frame-boundary
fix: write the multipart boundary for stored JPEGs sent by nph-zms
2026-10-12 15:16:04 -04:00
Isaac Connor 805a37b557 Merge pull request #5217 from nabbi/fix/b55-usr-level-signal-safe
fix: apply SIGUSR1/SIGUSR2 log level changes outside the signal handler
2026-10-12 15:09:34 -04:00
Isaac ConnorandClaude Opus 5.5 dad9da272a fix: take a jump back in a camera's clock out of the timestamps at capture refs #4242
When a camera's clock steps back by under 10 s (an NTP sync, a clock reset),
the packets after the step carry dts values behind the ones before it. The
video store patched each one to last_dts + last_duration and logged "non
increasing dts, fixing." for every packet until the event ended, thousands
per event. Splitting the event at the step doesn't help on its own: in
passthrough an event has to start on a keyframe, and the walk back for one
crosses the step again.

Handle it the way ffmpeg's demuxer does, where packets enter the pipeline:
the Monitor keeps each stream's last dts and one offset shared by all the
camera's streams. When a stream steps back by more than 1 s, the offset moves
so the stream continues one frame after where it was, and every packet gets
the offset from then on. Monitor::Capture applies it before the stream
socket, the packet queue or anything else sees the packet, and Pause() resets
it when the capture session ends. The packet queue, the event's choice of
first keyframe and the video store then see one continuous timeline: nothing
is split, flagged or patched downstream, and no video is dropped. Steps under
1 s are reordering jitter, left to the video store's reorder queue; steps of
10 s or more keep FfmpegCamera's existing handling.

tests/zm_monitor_dts.cpp covers a continuous stream, jitter, the 8.9 s and
1.048 s steps, audio following the offset video set, a missing duration and
packets without a dts.

Alternative to #5212 and #5068.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:45:19 -04:00
nicandClaude Opus 5.5 07179d7eae fix: set the WS-Discovery probe's interface before sending (#5257)
WSDiscovery::TransportUDP::send_multi opened its socket with PeerAddr,
which connects it, and called mcast_if afterwards. A connected UDP
socket keeps the route chosen at connect time, so on a host with more
than one network the Probe left through the default route whatever
interface was chosen: the web UI's ONVIF probe (zmonvif-probe.pl probe
<version> <interface>) found no camera on a second network.

Open the socket unconnected, set mcast_if first, and send to the group
with mcast_send. The destination is passed as a packed sockaddr_in:
given as "addr:port", mcast_send in IO::Socket::Multicast 1.12 (Debian
trixie) ends up with port 0 and the send fails with EINVAL. Without an
interface nothing changes: the probe goes out the default route as
before.

tests/perl/test_wsdiscovery_mcast_if.pl records the socket calls and
checks the order and the destination.

fixes #5256

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:26:40 -04:00
Nic BoetandClaude Opus 5.5 4eca3d7ee4 fix: don't let a failed ZMPacket::trylock overwrite the lock flag
ZMPacket::trylock() stored the result of try_lock() in the shared
`locked` member and returned that member. A failed attempt wrote false
while another thread held the mutex, and the thread that had just
taken the lock could return the other thread's false. ThreadSanitizer
reports the write (zm_packet.cpp:397) against unlock() (:404).

Return the local result and set `locked` only on success. The member is
read in Debug lines without the mutex, so make it std::atomic<bool>.

fixes #5248

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:45:37 -05:00
Nic BoetandClaude Opus 5.5 110d599f75 fix: honour ZM_USER_SELF_EDIT in the users API
UsersController::view() and edit() assigned their permission check with
`$canX = A or B;`. `=` binds tighter than `or`, so only A was stored and the
self check was dropped: with ZM_USER_SELF_EDIT on, a user without System=Edit
got 401 on PUT users/<own id>.json. beforeFilter() also refused every user
with System=None before view() or edit() ran.

Let beforeFilter() through view and edit of the user's own record while
ZM_USER_SELF_EDIT is on, use || in both checks, and limit what such a user
saves to the fields the web UI allows for self edit
(includes/actions/user.php): Password, Language and HomeView. The request
data is rebuilt from those three fields, so Id, System, permissions and the
rest are never saved; a Language without a lang file is refused as in the UI.
Users with System=Edit save as before.

Self view follows the web UI too: a user without System permission sees their
own record only while ZM_USER_SELF_EDIT is on.

fixes #5242

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:28:40 -05:00
Nic BoetandClaude Opus 5.5 fb534131f8 fix: load JSON through JSON::MaybeXS in Reolink_HTTP, Uniview and jsonLoad
Reolink_HTTP.pm and Uniview.pm said `use JSON`, and
ZoneMinder::General::jsonLoad required JSON. The Debian and Ubuntu
packages depend on libjson-maybexs-perl, not libjson-perl, so on a
packaged install neither control module loads ("Can't locate JSON.pm in
@INC") and jsonLoad, which reads MacVendors.json for the network scan,
logs that error and returns nothing.

Use JSON::MaybeXS as the other control modules (AMLink, Dahua_RPC,
IPSpeaker) and the rest of ZoneMinder::General already do. encode_json
and decode_json behave the same; JSON::MaybeXS picks Cpanel::JSON::XS,
JSON::XS or the core JSON::PP.

tests/perl/test_json_maybexs.pl hides JSON.pm from @INC and loads both
modules and runs jsonLoad.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:24:24 -05:00
Nic BoetandClaude Opus 5.5 0c6e18c427 fix: match SignalCheckColour by its luma on 1-byte-per-pixel captures
On GRAY8 and planar YUV images CheckSignal compared the Y byte with the
low byte of the RGB setting, which is its blue component. A camera
showing the default #0000BE decodes to Y=35 and never counted as lost;
only a colour whose blue byte happened to equal the luma matched.

Compare with the BT.601 luma of the colour in limited range (YUV420P,
YUV422P) and in full range (J formats, GRAY8). The low byte still
matches, so setups that stored the luma there (#000023, as the V4L2
probe does for GREY) keep working.

fixes #5240

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:20:58 -05:00
Nic BoetandClaude Opus 5.5 68aa214ff7 fix: leave the signal flag to the decoder when it checks SignalCheckPoints
Capture set shared_data->signal to true for every packet while the
decoder set it to CheckSignal() for every decoded frame. With
SignalCheckPoints on and a camera showing its lost-signal picture,
Analyse sometimes read Capture's true between two decoded frames, so the
signal flipped back for one frame: the event was closed for signal loss
and a zero-frame "Signal: Reacquired" event opened, about once a minute.

Capture now writes true only when no decoder measures the signal: no
SignalCheckPoints, Decoding=None, or on-demand decoding with no viewer.
It still writes true for the first packet after connect(), as before, so
Analyse doesn't see a signal loss before the first decoded frame and the
first event keeps its cause. The capture-failure path still writes false.

fixes #5235

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:15:52 -05:00
Nic BoetandClaude Opus 5.5 cd441866d1 fix: store MaxScore 0 when an event closes before its first frame
Event::max_score starts at -1 and only AddPacket_ raises it. An event
closed before the event thread wrote a frame (a signal flap, a reload or
an alarm cancelled right after the event opened) sent MaxScore = -1 in
its closing UPDATE. Events.MaxScore is smallint unsigned, so in strict
SQL mode MariaDB refused the whole statement ("Out of range value for
column 'MaxScore'"): EndDateTime, Frames and DefaultVideo stayed unset
and the event looked as if it was still recording, while the video had
already been renamed to its final name.

Clamp the value to 0 in the closing UPDATE. The -1 start stays, so the
first frame still becomes the max-score frame.

fixes #5233

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:13:47 -05:00
Nic BoetandClaude Opus 5.5 22d02ce066 fix: stop the nph-zms command thread racing runStream on frame bookkeeping
checkCommandQueue runs processCommand on its own thread, which read
`now` and frame_count and wrote last_frame_sent (CMD_PAUSE) and
got_command while the streaming loop writes or reads them.
ThreadSanitizer reports these as data races (zm_monitorstream.cpp
processCommand and runStream's got_command read against the command
thread, and checkCommandQueue's `now` read against EventStream::runStream).

Make frame_count, last_frame_sent and got_command atomic, as #4939 did
for the playback state, and have the command thread read the clock
itself instead of the loop's `now`. The status reply's fps and delay are
then measured against the current time rather than the start of the
loop's last pass. Test: [races] case for the frame bookkeeping, for TSan
builds.

fixes #5230

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:55:01 -05:00
Nic BoetandClaude Opus 5.5 2e1c97b100 fix: write the multipart boundary for stored JPEGs sent by nph-zms
MonitorStream::sendFrame(filepath) sends a swap file as it is when the
stream is JPEG at scale 100 without zoom, which is what buffered live
playback (rewind, fast reverse, step) uses. That path wrote only
Content-Length and X-Timestamp, so the part had no --ZoneMinderFrame
boundary and no Content-Type, and the multipart/x-mixed-replace stream
broke. The boundary and Content-Type were written here until the merge
in dbb2535a6 dropped them.

Write them as sendFrame(Image *) does. Test: the raw path's output
starts with the boundary and Content-Type.

fixes #5226

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:50:53 -05:00
Nic BoetandClaude Opus 5.5 367e19d4f6 fix: apply SIGUSR1/SIGUSR2 log level changes outside the signal handler
Logger::usrHandler changed the level and called Info() from the handler.
Info() takes log_mutex, glibc's syslog and tzset locks (syslog,
localtime_r) and the database queue's mutex, and allocates. A SIGUSR1 or
SIGUSR2 delivered to a thread already inside one of those waits on
itself, and the process hangs until zmwatch/zmdc kill it.

The handler now only adds +1/-1 to a lock-free atomic, as the SIGWINCH
branch already sets a flag. Logger::fetch(), which every log macro calls
before its level check, applies a pending change under log_mutex and logs
"Level changed" from normal context, so the new level is in effect at the
next log call on any thread. Several signals before that add up to one
change, clamped to NOLOG..DEBUG9. logInit() drops a change still pending
for the logger it replaces.

fixes #5216

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:34:23 -05:00
Nic BoetandClaude Opus 5.5 4daa163e70 fix: clamp pixel zone coordinates to the monitor before rasterising
Zone::Load uses integer (pixel) zone coordinates as they are, while
percentage coordinates are scaled and clamped to the monitor
(e1eb7876c). Zone::Setup then fills the polygon into a monitor-sized
image, and Image::Fill(Polygon) does not check bounds, so a pixel zone
larger than the monitor wrote past the end of that image on every zmc
start. AddressSanitizer: heap-buffer-overflow WRITE in Image::Fill
(zm_image.cpp) from Zone::Setup, Zone::Load, Monitor::connect, for a
zone of 0,0 1279,0 1279,719 0,719 on a 640x360 monitor.

Pixel zones remain where a zone was saved with integer coordinates,
through the API or before the upgrade to percentages, and the monitor
is then made smaller. Negative coordinates wrote before the buffer.

Clamp each vertex to [0, width-1] x [0, height-1] in Zone::Load, as
ParsePercentagePolygon does, and log a warning naming the zone.
Image::Outline already clamps its vertices the same way, so the filled
area and the drawn outline now agree.

fixes #5213

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:20:31 -05:00
Isaac ConnorandClaude Opus 5.5 c3d2e542bc fix: never build an object from an id the request sent as an array
ZM_Object's constructor treats an array as the object's row, and validInt()
and validCardinal() returned an array as they got it (preg_replace() does).
So wherever a request id reached `new ZM\X(...)`, raw or through those
validators, a crafted array built an object whose Id, UserId or MonitorId
the sender chose. The permission check then looked at those, and the delete
or update that followed used the Id. A user with Events=View deleted another
user's filter by posting Id[Id]=<filter>&Id[UserId]=<self> to the filter
delete action; the same shape reached events (MonitorId), users, reports,
snapshots and storage.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:07:08 -04:00
Isaac ConnorandClaude Opus 5.5 4bc4c6e4e0 fix: pause database logging only around the queue's inserts into Logs
Holding a DatabaseLogPause on the dbQueue thread for its whole life kept the
errors of every queued write out of Logs too: Monitor_Status updates, Events,
Frames and Stats rows. Under lock contention most "Giving up on query" errors
were only in the log files.

Mark each queued row with whether the logger queued it, and pause database
logging only around those inserts into Logs and the backlog warning. The loop
stays closed: a line about a queued write queues one Logs insert, which logs
nothing to the database.

Test: an error from a queued write reaches Logs.

From review by @nabbi on #5208.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:12:11 -04:00
Isaac ConnorandClaude Opus 5.5 26acd8db0f fix: keep only the database queue thread out of database logging
zmDbDo() kept its calling thread out of the database for the whole query,
which stopped its own "Success running sql query" line from queueing another
Logs insert. But the loop only starts where the Logs rows are inserted, on
the dbQueue thread, and pausing every zmDbDo() also kept the calling thread's
errors about its query out of Logs: "Can't run query" and the lock contention
retries were only in the log files.

Hold one Logger::DatabaseLogPause on the dbQueue thread for its whole life,
in zmDbQueue::process(), and none in zmDbDo(). Lines the queue thread logs,
the backlog warning included, still stay out of the database; every other
thread's query lines reach it, errors and, at a debug database level, the
success lines too.

Tests: the queue thread's inserts into Logs log nothing and the row count
settles, a query on another thread logs its success line at debug, and an
error from a failed query reaches Logs. The last two fail with the pause in
zmDbDo().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:10:14 -04:00
nicandClaude Opus 5.5 51b04e94a4 fix: validate alarm packets in zmalarm-server.py (#5211)
* fix: validate alarm packets in zmalarm-server.py

The alarm server accepted any packet from any host and mapped it to a
monitor by the Address field inside the packet, so a host on the network
could raise an alarm or a zmtrigger event on any configured camera.
Every malformed packet (short header, non-JSON body, unknown address)
raised an uncaught exception that ended the process, and a client that
connected without sending blocked the accept loop indefinitely.

- only accept an alarm whose Address matches the address it came from
  and a monitor that uses that address
- read the header and body with a 5 s timeout, cap the body at 64 KiB,
  log and drop bad packets instead of exiting
- resolve camera hostnames in monitor Paths and skip monitors without a
  camera address instead of failing at startup
- pass the alarm description to the zmtrigger thread instead of reading
  a global, and talk to zmtrigger over a plain socket (telnetlib was
  removed in Python 3.13)
- honour the bare port number zmpkg.pl passes from ZM_OPT_ALS_PORT and
  add --bind= to choose the listening address (default unchanged)
- move the pyzm imports into main() so the parser can be tested; add
  tests/python/test_zmalarm_server.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: accept alarms from cameras behind NAT in zmalarm-server.py

The source check required the packet's Address field to equal the address
the packet came from. A camera behind NAT reports its LAN address there
while its packets arrive from the NAT address, so its alarms were dropped.

The Address field is no credential, so the monitor is now chosen by the
sender: an alarm is accepted only from an address that belongs to a
monitor, the camera address in its Path or one listed for it under
[alarmserver] in secrets.ini, and it goes to one of that address's
monitors. The Address field only picks among several monitors on one
address, for cameras behind a shared NAT address. A host that belongs to no
monitor still cannot raise an alarm, and a camera cannot raise one on
another camera's monitor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: cancel the forced alarms zmalarm-server.py raised when it stops

An alarm packet armed the monitor, slept 60 s in a thread and disarmed it.
When zmdc stopped or restarted the server inside that window (SIGTERM, which
Python's default handler ends at once) the disarm never ran and the monitor
stayed in forced alarm, recording, until someone cancelled it by hand.

Keep the forced alarms the server raised in one place, one per monitor: a
new alarm for a monitor in forced alarm arms it again, as every packet did,
and moves its end instead of starting a second timer that would cut it
short. On the way out, SIGTERM included, the server cancels every alarm
still running and exits 0, so zmdc no longer logs the stop as abnormal. An
arm that fails is logged instead of ending the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:08:59 -04:00