Files
zoneminder/tests/audit-filter-cookies.php
T
Isaac Connor edb4d7c202 fix: stop an applied filter coming up empty on the events list fixes #5026
Applying a named filter and clicking LIST MATCHES landed on Events showing
"No matching records found" until a manual refresh. Two independent defects
produce that, and both are fixed here.

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

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

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

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

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

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

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

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

Verified against a live instance with the stale cookies still set: a "last hour"
named filter queried 0 of 4 matching events before and 12 of 12 after; the
filter-less page still restores the stored date range; and a table deferred while
hidden repaints on becoming visible.
2026-08-12 22:48:20 -04:00

107 lines
4.1 KiB
PHP
Executable File

#!/usr/bin/php
<?php
# Audit two rules about the stored filter selections - the zmFilter_* and
# friends cookies shared by console, montage, montagereview and events.
#
# 1. Filter resolves nothing. It renders whatever value addTerm() was handed
# and never reads $_COOKIE or $_REQUEST for a term. Looking a value up
# while rendering puts the decision below the layer that knows what was
# requested, and it silently overrides the value the caller chose.
#
# 2. A caller may only seed a term from a stored selection when the request
# did not specify a filter of its own. Otherwise it widens or narrows the
# filter the user asked for.
#
# Both were broken in issue #5026: an applied filter was ANDed with a date range
# left over from an earlier visit to another view, so it matched nothing and the
# events list came up empty until a manual refresh.
#
# Run from the repo root: php tests/audit-filter-cookies.php
# Rule 1 applies here: this is the rendering layer.
$renderers = array('web/includes/Filter.php');
# Rule 2 applies here: these build terms and may consult stored selections.
$callers = array(
'web/skins/classic/views/events.php',
'web/skins/classic/views/montagereview.php',
);
# Cookie reads that feed a filter term. Anything else - $_COOKIE['zmWatchScale'],
# menu preferences and so on - is outside these rules.
$is_filter_cookie = '/\$term\[.cookie.\]|zmFilter_|eventsTags|eventsNotes|zmFilterArchived|_COOKIE\[.(Notes|Archived).\]|\$cookie\]/';
# Must be $use_stored used as a condition. A bare match would also hit the
# `use ($use_stored)` capture in a closure signature, which imports the flag
# without testing it - and would pass a closure that had dropped its guard.
# `if ($use_stored)` is a real guard, so it is listed explicitly.
$has_guard = '/if\s*\(\s*!?\s*\$use_stored|!\s*\$use_stored|\$use_stored\s*(\?|and\b|&&)/';
# Statements are rebuilt from lines because a value and its guard often span a
# line break; a line-based scan would report the wrapped half as though it were
# bare. Anything wider than a statement is too coarse: the enclosing block holds
# other guarded reads and would mask one that lost its own.
function statements($path) {
$out = array();
$buf = '';
$start = 0;
foreach (file($path) as $n => $line) {
if ($buf === '') $start = $n + 1;
$buf .= ' '.trim($line);
if (preg_match('/[;{}]\s*$/', rtrim($line))) {
$out[] = array($start, trim($buf));
$buf = '';
}
}
if (trim($buf) !== '') $out[] = array($start, trim($buf));
return $out;
}
function report($file, $line, $text, $why) {
return sprintf(" %s:%d %s\n %s", $file, $line,
strlen($text) > 100 ? substr($text, 0, 100).' ...' : $text, $why);
}
$status = 0;
$checked = 0;
foreach ($renderers as $file) {
if (!is_readable($file)) { echo "MISSING: $file\n"; $status = 1; continue; }
$findings = array();
foreach (statements($file) as $stmt) {
list($line, $text) = $stmt;
if (!preg_match('/_COOKIE|_REQUEST/', $text)) continue;
if (!preg_match($GLOBALS['is_filter_cookie'], $text)) continue;
$checked++;
$findings[] = report($file, $line, $text,
'Filter must not look this up; hand the resolved value to addTerm() instead.');
}
if ($findings) {
echo "RESOLVES IN THE RENDERING LAYER - $file:\n".implode("\n", $findings)."\n\n";
$status = 1;
}
}
foreach ($callers as $file) {
if (!is_readable($file)) { echo "MISSING: $file\n"; $status = 1; continue; }
$findings = array();
foreach (statements($file) as $stmt) {
list($line, $text) = $stmt;
if (strpos($text, '_COOKIE') === false) continue;
if (!preg_match($is_filter_cookie, $text)) continue;
$checked++;
if (!preg_match($has_guard, $text)) {
$findings[] = report($file, $line, $text,
'Gate this on $use_stored, so a filter given in the request wins.');
}
}
if ($findings) {
echo "UNGUARDED STORED SELECTION - $file:\n".implode("\n", $findings)."\n\n";
$status = 1;
}
}
if ($status === 0) {
echo "ok: Filter resolves nothing, and all $checked stored-selection reads are gated\n";
}
exit($status);