diff --git a/.github/workflows/ci-homoglyphs.yml b/.github/workflows/ci-homoglyphs.yml new file mode 100644 index 000000000..b6ebea583 --- /dev/null +++ b/.github/workflows/ci-homoglyphs.yml @@ -0,0 +1,20 @@ +name: CI Homoglyphs + +on: + push: + branches: + - '*' + pull_request: + branches: [ master ] + +permissions: + contents: read + +jobs: + homoglyphs: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + - name: Check for Cyrillic/Greek homoglyphs in first-party source + run: python3 utils/check-homoglyphs.py diff --git a/distros/redhat/common/zoneminder.service.in b/distros/redhat/common/zoneminder.service.in index f4b3aad9b..e96ee2c94 100644 --- a/distros/redhat/common/zoneminder.service.in +++ b/distros/redhat/common/zoneminder.service.in @@ -13,6 +13,10 @@ ExecStart=@BINDIR@/zmpkg.pl start ExecReload=@BINDIR@/zmpkg.pl restart ExecStop=@BINDIR@/zmpkg.pl stop PIDFile=@ZM_RUNDIR@/zm.pid +# ZoneMinder shuts down many capture/analysis daemons on stop; allow ample time. +# Without this, Fedora's short DefaultTimeoutStopSec combined with its global +# TimeoutStopFailureMode=abort drop-in SIGABRTs zmpkg.pl mid-shutdown. +TimeoutSec=60 Environment=TZ=/etc/localtime RuntimeDirectory=zoneminder RuntimeDirectoryMode=0755 diff --git a/docs/userguide/definemonitor/definemonitor_recording.rst b/docs/userguide/definemonitor/definemonitor_recording.rst index bc3bdd050..d6e309197 100644 --- a/docs/userguide/definemonitor/definemonitor_recording.rst +++ b/docs/userguide/definemonitor/definemonitor_recording.rst @@ -32,5 +32,12 @@ Recording Tab - **Output Container**: Leaving at Auto allows ZoneMinder to use mp4. Other choices are mkv and webm. - **Optional Encoding Parameters**: Mostly useful when encoding as each encoder takes different parameters. Consult the `FFmpeg documentation `__ for available parameters for each encoder. - **Recording Audio**: Check the box in order to save audio (if available) when events are recorded. -- **Event Start Command**: When a recording event starts, you can run a system command. The parameters to the command will be the event id and the monitor id. -- **Event End Command**: When a recording event ends, you can run a system command. The parameters to the command will be the event id and the monitor id. +- **Event Start Command**: When a recording event starts, you can run a system command. +- **Event End Command**: When a recording event ends, you can run a system command. It runs after the event has been finalized, so the event's video file is complete and its database records are in place. + + How these commands are executed depends on whether the command string contains a ``%`` character: + + - **Without** ``%``, the whole string is treated as the path of a single executable (no shell is involved), and two arguments are appended to it: the event id and the monitor id. You cannot pass your own arguments this way — a value like ``/usr/local/bin/notify.sh start`` is looked up as one file named ``notify.sh start`` and fails. + - **With** ``%``, the string is run through ``/bin/sh -c`` after token substitution, and no arguments are appended automatically. Available tokens are ``%EID%`` (event id), ``%MID%`` (monitor id) and, for the start command only, ``%EC%`` (the shell-escaped event cause). So to combine your own arguments with the event id, write e.g. ``/usr/local/bin/notify.sh start %EID%``. + + The command runs in the background as the same user as the capture process (normally the web user), with all file descriptors closed — anything it prints goes nowhere, so redirect output inside your script if you need to see it. A failure to execute the command is logged by the capture process (zmc). diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Event.pm b/scripts/ZoneMinder/lib/ZoneMinder/Event.pm index 6a9b7ad15..30fd7a1b9 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Event.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Event.pm @@ -861,7 +861,7 @@ sub recover_timestamps { Debug('Have ' . @contents . ' files in '.$path); closedir(DIR); - my @mp4_files = grep(/^\d+\-video\.\w+\.mp4$/, @contents); + my @mp4_files = grep(/^\d+\-video\.(?:\w+\.)?mp4$/, @contents); if ( @mp4_files ) { $$Event{DefaultVideo} = $mp4_files[0]; } @@ -880,7 +880,6 @@ sub recover_timestamps { my $first_file = "$path/$capture_jpgs[0]"; ( $first_file ) = $first_file =~ /^(.*)$/; my $first_timestamp = (stat($first_file))[9]; - $starttime = $first_timestamp if $first_timestamp < $starttime; my $last_file = $path.'/'.$capture_jpgs[@capture_jpgs-1]; ( $last_file ) = $last_file =~ /^(.*)$/; @@ -922,24 +921,27 @@ sub recover_timestamps { my $file = $path.'/'.$mp4_files[0]; ( $file ) = $file =~ /^(.*)$/; # de-taint - my $first_timestamp = (stat($file))[9]; - $starttime = $first_timestamp if $first_timestamp < $starttime; - my $seconds = mp4_duration($file); if ( !defined $seconds ) { Warning("Unable to determine duration of $file from ffprobe. Defaulting Length to 0."); $seconds = 0; } - Debug("From mp4 have duration $seconds seconds, start: $first_timestamp"); + # The mp4 is written as the event records, so its mtime is when recording + # finished. The event therefore started $seconds before that. + my $last_timestamp = (stat($file))[9]; + my $first_timestamp = $last_timestamp - $seconds; + Debug("From mp4 have duration $seconds seconds, start: $first_timestamp end: $last_timestamp"); $Event->Length(sprintf('%.2f', $seconds)); $Event->StartDateTime( Date::Format::time2str('%Y-%m-%d %H:%M:%S', $first_timestamp) ); - $Event->EndDateTime( Date::Format::time2str('%Y-%m-%d %H:%M:%S', $first_timestamp+$seconds) ); + $Event->EndDateTime( Date::Format::time2str('%Y-%m-%d %H:%M:%S', $last_timestamp) ); + } else { + # Nothing to derive the times from, so fall back to the directory's mtime. + $Event->StartDateTime( Date::Format::time2str('%Y-%m-%d %H:%M:%S', $starttime) ); } if ( @mp4_files ) { $Event->DefaultVideo($mp4_files[0]); } - $Event->StartDateTime( Date::Format::time2str('%Y-%m-%d %H:%M:%S', $starttime) ); } # Return the duration of a video file in seconds (float), or undef if it @@ -988,7 +990,7 @@ sub fix_DefaultVideo { Debug('Have ' . @contents . ' files in '.$path); closedir(DIR); - my @mp4_files = grep(/^\d+\-video\.\w+\.mp4$/, @contents); + my @mp4_files = grep(/^\d+\-video\.(?:\w+\.)?mp4$/, @contents); if ( @mp4_files ) { $$event{DefaultVideo} = $mp4_files[0]; } diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Filter.pm b/scripts/ZoneMinder/lib/ZoneMinder/Filter.pm index dcc0db6e5..d71bf421c 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Filter.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Filter.pm @@ -214,7 +214,19 @@ sub Sql { } elsif ( $term->{attr} eq 'CurrentDate' ) { $self->{Sql} .= 'to_days(NOW())'; } elsif ( $term->{attr} eq 'DateTime' ) { - $self->{Sql} .= 'E.StartDateTime'; + # Mirror web/includes/FilterTerm.php: DateTime is an "event overlaps + # this instant/window" idiom, not a plain StartDateTime comparison. A + # lower bound (>=/>) is satisfied by an event still running at that + # time, so compare against EndDateTime (NULL end = ongoing = never + # ends). An upper bound (<=/{op}//'') eq '>=' or ($term->{op}//'') eq '>' ) { + $self->{Sql} .= "COALESCE(E.EndDateTime, '9999-12-31 23:59:59')"; + } else { + $self->{Sql} .= 'E.StartDateTime'; + } } elsif ( $term->{attr} eq 'Date' ) { # column emitted as part of range expression below } elsif ( $term->{attr} eq 'StartDate' ) { diff --git a/scripts/ZoneMinder/t/event_recover_timestamps.t b/scripts/ZoneMinder/t/event_recover_timestamps.t new file mode 100644 index 000000000..0cf44c063 --- /dev/null +++ b/scripts/ZoneMinder/t/event_recover_timestamps.t @@ -0,0 +1,36 @@ +use strict; +use warnings; +use Test::More; +use File::Temp qw(tempdir); +use Date::Parse; + +plan skip_all => 'ffmpeg not available' if system('ffmpeg -version >/dev/null 2>&1'); +eval { require ZoneMinder::Event; 1 } or plan skip_all => "cannot load ZoneMinder::Event: $@"; +plan tests => 6; + +# An event directory holding only an mp4 (no capture jpgs), as produced when +# ZM_SAVE_JPEGS is off. Duration is 3 seconds. +my $dir = tempdir(CLEANUP => 1); +my $mp4 = "$dir/1-video.h264.mp4"; +is(system("ffmpeg -v error -y -f lavfi -i testsrc=duration=3:size=64x64:rate=10 '$mp4' >/dev/null 2>&1"), + 0, 'built a 3 second test mp4'); + +# mtime of the mp4 is when recording finished. +my $end = 1700000000; +utime($end, $end, $mp4) or die "utime: $!"; +# Make the directory itself much older, so a fallback to its mtime is visible. +utime($end - 3600, $end - 3600, $dir) or die "utime: $!"; + +my $Event = new ZoneMinder::Event(); +$Event->recover_timestamps($dir); + +cmp_ok(abs($Event->Length() - 3), '<', 0.5, 'Length comes from the mp4 duration'); +is($Event->DefaultVideo(), '1-video.h264.mp4', 'DefaultVideo is the mp4 we found'); + +my $start = Date::Parse::str2time($Event->StartDateTime()); +my $stop = Date::Parse::str2time($Event->EndDateTime()); +is($stop, $end, 'EndDateTime is the mp4 mtime, when recording finished'); +cmp_ok(abs($start - ($end - 3)), '<=', 1, + 'StartDateTime is duration before the end, not the dir mtime'); +cmp_ok(abs(($stop - $start) - $Event->Length()), '<=', 1, + 'Length agrees with EndDateTime - StartDateTime'); diff --git a/src/zm_db.cpp b/src/zm_db.cpp index 195e0eacc..d1d5a3649 100644 --- a/src/zm_db.cpp +++ b/src/zm_db.cpp @@ -22,6 +22,7 @@ #include "zm_signal.h" #include #include +#include #include #include @@ -267,7 +268,7 @@ int zmDbDo(const std::string &query) { return 1; } -int zmDbDoInsert(const std::string &query) { +uint64_t zmDbDoInsert(const std::string &query) { std::lock_guard lck(db_mutex); if (!zmDbConnected and !zmDbConnect()) return 0; @@ -282,9 +283,8 @@ int zmDbDoInsert(const std::string &query) { return 0; } } - // Might not be an int... FIXME - int id = mysql_insert_id(&dbconn); - Debug(2, "Success running sql insert %s. Resulting id is %d", query.c_str(), id); + uint64_t id = mysql_insert_id(&dbconn); + Debug(2, "Success running sql insert %s. Resulting id is %" PRIu64, query.c_str(), id); return id; } diff --git a/src/zm_db.h b/src/zm_db.h index 5f1e75db5..eed3e1e08 100644 --- a/src/zm_db.h +++ b/src/zm_db.h @@ -21,6 +21,7 @@ #define ZM_DB_H #include +#include #include #include #include @@ -70,7 +71,7 @@ extern bool zmDbConnected; bool zmDbConnect(); void zmDbClose(); int zmDbDo(const std::string &query); -int zmDbDoInsert(const std::string &query); +uint64_t zmDbDoInsert(const std::string &query); int zmDbDoUpdate(const std::string &query); MYSQL_RES * zmDbFetch(const std::string &query); diff --git a/src/zm_event.cpp b/src/zm_event.cpp index 120d53498..1fb1b2ac6 100644 --- a/src/zm_event.cpp +++ b/src/zm_event.cpp @@ -153,24 +153,24 @@ Event::Event( /* None of these are crucial, and are simple when done as individual transactions */ sql = stringtf("INSERT INTO `Events_Hour` (EventId,MonitorId,StartDateTime,DiskSpace)" - " VALUES (%" PRId64 ",%u,'%s',NULL)", + " VALUES (%" PRIu64 ",%u,'%s',NULL)", id, monitor->Id(), now_str.c_str()); dbQueue.push(std::move(sql)); sql = stringtf("INSERT INTO `Events_Day` (EventId,MonitorId,StartDateTime,DiskSpace)" - " VALUES (%" PRId64 ",%u,'%s',NULL)", + " VALUES (%" PRIu64 ",%u,'%s',NULL)", id, monitor->Id(), now_str.c_str()); dbQueue.push(std::move(sql)); sql = stringtf("INSERT INTO `Events_Week` (EventId,MonitorId,StartDateTime,DiskSpace)" - " VALUES (%" PRId64 ",%u,'%s',NULL)", + " VALUES (%" PRIu64 ",%u,'%s',NULL)", id, monitor->Id(), now_str.c_str()); dbQueue.push(std::move(sql)); sql = stringtf("INSERT INTO `Events_Month` (EventId,MonitorId,StartDateTime,DiskSpace)" - " VALUES (%" PRId64 ",%u,'%s',NULL)", + " VALUES (%" PRIu64 ",%u,'%s',NULL)", id, monitor->Id(), now_str.c_str()); dbQueue.push(std::move(sql)); /* sql = stringtf("INSERT INTO `Events_Year` (EventId,MonitorId,StartDateTime,DiskSpace)" - " VALUES (%" PRId64 ",%u,'%s',NULL)", + " VALUES (%" PRIu64 ",%u,'%s',NULL)", id, monitor->Id(), now_str.c_str()); dbQueue.push(std::move(sql)); */ @@ -469,7 +469,7 @@ void Event::AddPacket_(const std::shared_ptrpacket) { tag->save(); Debug(1, "Created new Tag %s", cls.c_str()); tags.emplace(std::make_pair(cls, *tag)); - int tag_id = tag->Id(); + uint64_t tag_id = tag->Id(); delete tag; // Delete after copying into map tag = nullptr; diff --git a/src/zm_event_tag.cpp b/src/zm_event_tag.cpp index 3e4d6ac59..cd702f884 100644 --- a/src/zm_event_tag.cpp +++ b/src/zm_event_tag.cpp @@ -78,7 +78,7 @@ Event_Tag::Event_Tag(uint64_t p_tag_id, uint64_t p_event_id, SystemTimePoint p_a Event_Tag::~Event_Tag() { } -int Event_Tag::save() { +uint64_t Event_Tag::save() { std::string sql = stringtf("INSERT INTO `Events_Tags` (`TagId`, `EventId`, `AssignedDate`, `AssignedBy`)" " VALUES (%" PRIu64 ", %" PRIu64 ", from_unixtime(%" PRId64 "), %d)", tag_id, @@ -86,7 +86,7 @@ int Event_Tag::save() { static_cast(std::chrono::system_clock::to_time_t(assigned_on)), assigned_by ); - int rc; + uint64_t rc; //do { rc = zmDbDoInsert(sql); //} while (!rc and !zm_terminate); diff --git a/src/zm_event_tag.h b/src/zm_event_tag.h index 1f5c16089..fa855349d 100644 --- a/src/zm_event_tag.h +++ b/src/zm_event_tag.h @@ -56,7 +56,7 @@ class Event_Tag { unsigned int AssignedBy() { return assigned_by; }; unsigned int AssignedBy(unsigned int p_assigned_by) { return assigned_by = p_assigned_by; }; - int save(); + uint64_t save(); }; #endif // ZM_EVENT_TAG_H diff --git a/src/zm_ffmpeg.cpp b/src/zm_ffmpeg.cpp index 20cc6b8b4..e34ad8624 100644 --- a/src/zm_ffmpeg.cpp +++ b/src/zm_ffmpeg.cpp @@ -149,6 +149,33 @@ std::list get_decoder_data(int wanted_codec, const std::string return results; } +AVCodecContext *open_fallback_decoder(const AVCodecParameters *codecpar, const AVCodec **codec_out) { + const AVCodec *codec = avcodec_find_decoder(codecpar->codec_id); + if (!codec) { + Debug(1, "No fallback decoder available for codec %s", + avcodec_get_name(codecpar->codec_id)); + return nullptr; + } + Debug(1, "Trying fallback decoder %s for codec %s", + codec->name, avcodec_get_name(codecpar->codec_id)); + AVCodecContext *ctx = avcodec_alloc_context3(codec); + if (!ctx) { + Error("Failed to allocate context for fallback decoder %s", codec->name); + return nullptr; + } + avcodec_parameters_to_context(ctx, codecpar); + zm_dump_codec(ctx); + int ret = avcodec_open2(ctx, codec, nullptr); + if (ret < 0) { + Error("Could not open fallback decoder %s (error '%s')", + codec->name, av_make_error_string(ret).c_str()); + avcodec_free_context(&ctx); + return nullptr; + } + if (codec_out) *codec_out = codec; + return ctx; +} + #if HAVE_LIBAVUTIL_HWCONTEXT_H #if LIBAVCODEC_VERSION_CHECK(57, 89, 0, 89, 0) diff --git a/src/zm_ffmpeg.h b/src/zm_ffmpeg.h index 1879eea04..070f2c3ba 100644 --- a/src/zm_ffmpeg.h +++ b/src/zm_ffmpeg.h @@ -332,6 +332,11 @@ struct CodecData { }; std::list get_encoder_data(const std::string & wanted_codec, const std::string &wanted_coder) ; std::list get_decoder_data(int wanted_codec, const std::string &wanted_coder) ; +// When none of the preferred decoders in dec_codecs are usable, fall back to +// whatever decoder this ffmpeg build actually provides for codecpar->codec_id. +// Returns an opened AVCodecContext (caller frees with avcodec_free_context) or +// nullptr. When non-null, *codec_out receives the chosen AVCodec. +AVCodecContext *open_fallback_decoder(const AVCodecParameters *codecpar, const AVCodec **codec_out = nullptr); int setup_hwaccel(AVCodecContext *codec_ctx, const CodecData *codec_data,AVBufferRef * &hw_device_ctx, const std::string &device, int width, int height); int libjpeg_to_ffmpeg_qv(int libjpeg_quality); enum AVPixelFormat get_hw_format(AVCodecContext *ctx, const enum AVPixelFormat *pix_fmts); diff --git a/src/zm_ffmpeg_camera.cpp b/src/zm_ffmpeg_camera.cpp index 31eb51bec..6f3d620a0 100644 --- a/src/zm_ffmpeg_camera.cpp +++ b/src/zm_ffmpeg_camera.cpp @@ -27,6 +27,7 @@ #include "url.hpp" #include +#include extern "C" { #include @@ -671,73 +672,85 @@ int FfmpegCamera::OpenFfmpeg() { #if HAVE_LIBAVUTIL_HWCONTEXT_H // 3.2 doesn't seem to have all the bits in place, so let's require 3.4 and up #if LIBAVCODEC_VERSION_CHECK(57, 107, 0, 107, 0) - // Print out available types - enum AVHWDeviceType type = AV_HWDEVICE_TYPE_NONE; - while ((type = av_hwdevice_iterate_types(type)) != AV_HWDEVICE_TYPE_NONE) - Debug(1, "%s", av_hwdevice_get_type_name(type)); - - const char *hw_name = hwaccel_name.c_str(); - type = av_hwdevice_find_type_by_name(hw_name); - if (type == AV_HWDEVICE_TYPE_NONE) { - Debug(1, "Device type %s is not supported.", hw_name); - } else { - Debug(1, "Found hwdevice %s", av_hwdevice_get_type_name(type)); + // Build the list of hw device types to try. A DecoderHWAccelName of + // "auto" probes every hwaccel libav offers and uses the first that both + // the decoder supports and whose device can be created; any other value + // is a comma-separated priority list of device-type names, tried in + // order (e.g. "cuda,vaapi"; a single name like "vaapi" is just the + // one-element case and behaves as before). If nothing usable is found + // we transparently fall back to software. + std::vector candidate_types; + bool auto_detect = (hwaccel_name == "auto"); + enum AVHWDeviceType it = AV_HWDEVICE_TYPE_NONE; + while ((it = av_hwdevice_iterate_types(it)) != AV_HWDEVICE_TYPE_NONE) { + Debug(1, "Available hwdevice type %s", av_hwdevice_get_type_name(it)); + if (auto_detect) candidate_types.push_back(it); + } + if (!auto_detect) { + for (const std::string &token : Split(hwaccel_name, ',')) { + std::string name = TrimSpaces(token); + if (name.empty()) continue; + enum AVHWDeviceType named = av_hwdevice_find_type_by_name(name.c_str()); + if (named == AV_HWDEVICE_TYPE_NONE) + Warning("Unknown hwaccel device type '%s', skipping.", name.c_str()); + else + candidate_types.push_back(named); + } } + for (enum AVHWDeviceType type : candidate_types) { + Debug(1, "Trying hwdevice %s", av_hwdevice_get_type_name(type)); + hw_pix_fmt = AV_PIX_FMT_NONE; #if LIBAVUTIL_VERSION_CHECK(56, 22, 0, 14, 0) - // Get hw_pix_fmt - for (int i = 0;; i++) { - const AVCodecHWConfig *config = avcodec_get_hw_config(mVideoCodec, i); - if (!config) { - Debug(1, "Decoder %s does not support config %d.", - mVideoCodec->name, i); - break; - } - if ((config->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX) - && (config->device_type == type) - ) { - hw_pix_fmt = config->pix_fmt; - Debug(1, "Decoder %s does support our type %s.", - mVideoCodec->name, av_hwdevice_get_type_name(type)); - //break; - } else { - Debug(1, "Decoder %s hwConfig doesn't match our type: %s != %s, pix_fmt %s.", - mVideoCodec->name, - av_hwdevice_get_type_name(type), - av_hwdevice_get_type_name(config->device_type), - zm_get_pix_fmt_name(config->pix_fmt) - ); - } - } // end foreach hwconfig + // Does this decoder advertise a hw config for this device type? + for (int i = 0;; i++) { + const AVCodecHWConfig *config = avcodec_get_hw_config(mVideoCodec, i); + if (!config) break; + if ((config->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX) + && (config->device_type == type)) { + hw_pix_fmt = config->pix_fmt; + Debug(1, "Decoder %s supports type %s (pix_fmt %s).", + mVideoCodec->name, av_hwdevice_get_type_name(type), + zm_get_pix_fmt_name(hw_pix_fmt)); + } + } // end foreach hwconfig #else - hw_pix_fmt = find_fmt_by_hw_type(type); + hw_pix_fmt = find_fmt_by_hw_type(type); #endif - if (hw_pix_fmt != AV_PIX_FMT_NONE) { - Debug(1, "Selected hw_pix_fmt %d %s", - hw_pix_fmt, zm_get_pix_fmt_name(hw_pix_fmt)); - - mVideoCodecContext->hwaccel_flags |= AV_HWACCEL_FLAG_IGNORE_LEVEL; - //if (!lavc_param->check_hw_profile) - mVideoCodecContext->hwaccel_flags |= AV_HWACCEL_FLAG_ALLOW_PROFILE_MISMATCH; + if (hw_pix_fmt == AV_PIX_FMT_NONE) { + Debug(1, "Decoder %s has no hw_pix_fmt for %s, skipping.", + mVideoCodec->name, av_hwdevice_get_type_name(type)); + continue; + } ret = av_hwdevice_ctx_create(&hw_device_ctx, type, (hwaccel_device != "" ? hwaccel_device.c_str() : nullptr), nullptr, 0); - if (ret < 0 and hwaccel_device != "") { + if (ret < 0 and hwaccel_device != "") ret = av_hwdevice_ctx_create(&hw_device_ctx, type, nullptr, nullptr, 0); - } if (ret < 0) { - Error("Failed to create hwaccel device. %s", av_make_error_string(ret).c_str()); + Warning("Failed to create %s hwaccel device: %s", + av_hwdevice_get_type_name(type), av_make_error_string(ret).c_str()); hw_pix_fmt = AV_PIX_FMT_NONE; - use_hwaccel = false; - } else { - Debug(1, "Created hwdevice for %s", hwaccel_device.c_str()); - // Set opaque to point to our hw_pix_fmt so callback can access it - mVideoCodecContext->opaque = &hw_pix_fmt; - mVideoCodecContext->get_format = get_hw_format; - mVideoCodecContext->hw_device_ctx = av_buffer_ref(hw_device_ctx); + hw_device_ctx = nullptr; + continue; // try the next candidate } - } else { - Debug(1, "Failed to find suitable hw_pix_fmt."); + + // Success: wire up hardware decoding and stop searching. + Info("Using %s hardware decoding for %s", + av_hwdevice_get_type_name(type), mVideoCodec->name); + mVideoCodecContext->hwaccel_flags |= AV_HWACCEL_FLAG_IGNORE_LEVEL; + //if (!lavc_param->check_hw_profile) + mVideoCodecContext->hwaccel_flags |= AV_HWACCEL_FLAG_ALLOW_PROFILE_MISMATCH; + // Set opaque to point to our hw_pix_fmt so callback can access it + mVideoCodecContext->opaque = &hw_pix_fmt; + mVideoCodecContext->get_format = get_hw_format; + mVideoCodecContext->hw_device_ctx = av_buffer_ref(hw_device_ctx); + break; + } // end foreach candidate type + + if (hw_pix_fmt == AV_PIX_FMT_NONE) { + Debug(1, "No usable hardware decoder found; falling back to software decoding."); + use_hwaccel = false; } #else Debug(1, "AVCodec not new enough for hwaccel"); @@ -766,6 +779,11 @@ int FfmpegCamera::OpenFfmpeg() { break; } // end foreach codec + if (!mVideoCodecContext) { + Debug(1, "Failed with known codecs, trying harder"); + mVideoCodecContext = open_fallback_decoder(mVideoStream->codecpar, &mVideoCodec); + } + if (!mVideoCodecContext) { Warning("Failed to open codec"); return -1; diff --git a/src/zm_ffmpeg_input.cpp b/src/zm_ffmpeg_input.cpp index 51d6e7a37..5ba4b7a7d 100644 --- a/src/zm_ffmpeg_input.cpp +++ b/src/zm_ffmpeg_input.cpp @@ -129,20 +129,8 @@ int FFmpeg_Input::Open(const char *filepath) { if (!streams[i].context) { Debug(1, "Failed with known codecs, trying harder"); - if ((streams[i].codec = avcodec_find_decoder(input_format_context->streams[i]->codecpar->codec_id))) { - Debug(1, "Using codec (%s) for stream %d", streams[i].codec->name, i); - streams[i].context = avcodec_alloc_context3(streams[i].codec); - avcodec_parameters_to_context(streams[i].context, input_format_context->streams[i]->codecpar); - - zm_dump_codec(streams[i].context); - - error = avcodec_open2(streams[i].context, streams[i].codec, nullptr); - if (error < 0) { - Error("Could not open input codec (error '%s')", av_make_error_string(error).c_str()); - avcodec_free_context(&streams[i].context); - streams[i].context = nullptr; - } - } + streams[i].context = open_fallback_decoder( + input_format_context->streams[i]->codecpar, &streams[i].codec); } if (!streams[i].context) { diff --git a/src/zm_image.cpp b/src/zm_image.cpp index b54034227..15a107917 100644 --- a/src/zm_image.cpp +++ b/src/zm_image.cpp @@ -2392,6 +2392,15 @@ bool Image::Delta(const Image &image, Image* targetimage) const { return false; } + // DumpImgBuffer() nulls buffer while leaving width/height/colours intact, so + // the size check above is no guarantee the pixels are present. Without this a + // dumped reference image (e.g. dropped on resume) walks the delta helpers from + // a null base pointer and faults at address 0 on the first row (refs #4983). + if ( buffer == nullptr || image.buffer == nullptr ) { + Error("Attempt to get delta with a null buffer (this %p, other %p)", buffer, image.buffer); + return false; + } + uint8_t *pdiff = targetimage->WriteBuffer(width, height, ZM_COLOUR_GRAY8, ZM_SUBPIX_ORDER_NONE); if ( pdiff == nullptr ) { Error("Failed requesting writeable buffer for storing the delta image"); diff --git a/src/zm_monitor.cpp b/src/zm_monitor.cpp index 5ae420cc0..f6fd22dce 100644 --- a/src/zm_monitor.cpp +++ b/src/zm_monitor.cpp @@ -2004,7 +2004,9 @@ void Monitor::CheckAction() { if ( Enabled() && !Active() ) { Info("Received resume indication at count %d", shared_data->image_count); shared_data->analysing = analysing; - ref_image.DumpImgBuffer(); // Will get re-assigned by analysis thread + // Analysis thread owns ref_image; ask it to drop the pre-suspend + // reference rather than freeing the buffer under it here (refs #4983). + ref_image_reset_ = true; shared_data->alarm_x = shared_data->alarm_y = -1; } shared_data->action &= ~RESUME; @@ -2017,7 +2019,8 @@ void Monitor::CheckAction() { Info("Auto resuming at count %d", shared_data->image_count); auto_resume_time = {}; shared_data->analysing = analysing; - ref_image.DumpImgBuffer(); // Will get re-assigned by analysis thread + // See RESUME above: defer the reference-image reset to the analysis thread. + ref_image_reset_ = true; } } } @@ -2138,6 +2141,15 @@ bool Monitor::Analyse() { } std::shared_ptr packet = packet_lock.packet_; + // The capture thread requested that we drop the pre-suspend reference image. + // Do it here, on the thread that owns ref_image, so the buffer is never freed + // out from under an in-flight Delta/Blend (refs #4983). The subsequent + // !ref_image.Buffer() checks re-seed it from the next frame. + if (ref_image_reset_.exchange(false)) { + Debug(1, "Resetting reference image on resume"); + ref_image.DumpImgBuffer(); + } + // Is it possible for packet->score to be ! -1 ? Not if everything is working correctly if (packet->score != -1) { Error("Packet score was %d at index %d, should always be -1!", packet->score, packet->image_index); diff --git a/src/zm_monitor.h b/src/zm_monitor.h index dd9cb05e3..0078fb681 100644 --- a/src/zm_monitor.h +++ b/src/zm_monitor.h @@ -34,6 +34,7 @@ #include "zm_utils.h" #include "zm_zone.h" +#include #include #include #include @@ -727,6 +728,12 @@ class Monitor : public std::enable_shared_from_this { Image delta_image; Image ref_image; + // ref_image is owned by the analysis thread (Analyse/DetectMotion). The + // capture thread (CheckAction) must not touch its buffer directly; on + // suspend-resume it sets this flag instead and the analysis thread drops the + // stale reference itself on its next pass. Prevents a use-after-free/null + // deref race in Image::Delta (refs #4983). + std::atomic ref_image_reset_{false}; // Analysis image ring: the annotated/analysis image is published into a ring // of image_buffer_count slots living in the alarm_images SHM region. Readers // pick up the newest via shared_data->last_analysis_index. Replaces the diff --git a/src/zma.cpp b/src/zma.cpp index 9424bca87..8fef16a62 100644 --- a/src/zma.cpp +++ b/src/zma.cpp @@ -623,10 +623,10 @@ int main(int argc, char *argv[]) { monitor->Id(), storage_id, event_id, static_cast(std::chrono::duration_cast(fd.timestamp.time_since_epoch()).count()), scheme_str.c_str(), monitor->Width(), monitor->Height()); - int new_event_id = zmDbDoInsert(sql); - if (new_event_id > 0) { + uint64_t new_event_id = zmDbDoInsert(sql); + if (new_event_id) { current_event->db_event_id = new_event_id; - Info("Created new event %d from re-analysis of event %" PRIu64, new_event_id, event_id); + Info("Created new event %" PRIu64 " from re-analysis of event %" PRIu64, new_event_id, event_id); // Create directory and copy video files to new event std::string new_event_path = BuildNewEventPath( diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9007f279d..c7fc55499 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -29,8 +29,10 @@ set(TEST_SOURCES zm_utils.cpp zm_vector2.cpp zm_zone.cpp + zm_zone_stride.cpp zm_image_linesize.cpp - zm_ffmpeg_camera.cpp) + zm_ffmpeg_camera.cpp + zm_ffmpeg_fallback.cpp) add_executable(tests main.cpp ${TEST_SOURCES}) diff --git a/tests/php/test_filter_canedit_autoexecute.php b/tests/php/test_filter_canedit_autoexecute.php new file mode 100644 index 000000000..b2e6f186f --- /dev/null +++ b/tests/php/test_filter_canedit_autoexecute.php @@ -0,0 +1,135 @@ + RCE). +// * AutoExecute (arbitrary OS command via zmfilter.pl) requires System edit +// permission, not merely Events edit. +// * Ownership is enforced: a non-System user can only act on their own filter. +// +// Run as: php tests/php/test_filter_canedit_autoexecute.php +// +// The real Filter class pulls in database.php / FilterTerm.php / Monitor.php via +// require_once, but canEdit() itself only uses ZM_Object's magic accessors (the +// `defaults` array, no DB) plus the passed-in user. So we stub the require-only +// dependencies, keep the real Object.php + Filter.php, and drive canEdit() +// directly. + +namespace ZM; + +// ---- test harness (top-level namespace-free helpers) ----------------------- + +$failures = 0; +$passes = 0; + +function check($name, $got, $want) { + global $failures, $passes; + if ($got === $want) { + $passes++; + echo "ok - $name\n"; + } else { + $failures++; + echo "FAIL - $name (got ".var_export($got, true).", want ".var_export($want, true).")\n"; + } +} + +// ---- stub the require-only dependencies of Filter.php ---------------------- + +$stubdir = sys_get_temp_dir().'/zm_filter_canedit_stubs_'.getmypid(); +@mkdir($stubdir, 0700, true); +foreach (array('database.php', 'FilterTerm.php', 'Monitor.php') as $stub) { + file_put_contents($stubdir.'/'.$stub, " level ('None'|'View'|'Edit') + public function __construct($id, $perms) { $this->id = $id; $this->perms = $perms; } + public function Id() { return $this->id; } + private function level($area) { return isset($this->perms[$area]) ? $this->perms[$area] : 'None'; } + public function canView($area) { $l = $this->level($area); return $l == 'View' || $l == 'Edit'; } + public function canEdit($area) { return $this->level($area) == 'Edit'; } +} + +// Helper: build a Filter owned by $ownerId with the given auto-flags set. +function make_filter($ownerId, array $flags) { + $f = new Filter(); + $f->UserId($ownerId); + foreach ($flags as $k => $v) { + $f->$k($v); + } + return $f; +} + +$systemAdmin = new TestUser(1, array('System' => 'Edit')); +$eventsEditor = new TestUser(2, array('Events' => 'Edit')); +$eventsViewer = new TestUser(3, array('Events' => 'View')); + +// ---- the reported vulnerability: view-only user + AutoExecute -------------- + +$f = make_filter(3, array('AutoExecute' => 1, 'AutoExecuteCmd' => '/bin/sh -c "id"')); +check('Events=View owner CANNOT edit AutoExecute filter', $f->canEdit($eventsViewer), false); + +// Each side-effect alone must block a view-only user (the `and` -> `or` fix). +foreach (array('AutoExecute','AutoDelete','AutoUnarchive','AutoArchive', + 'AutoMove','AutoCopy','AutoVideo','AutoUpload', + 'AutoEmail','AutoMessage') as $flag) { + $f = make_filter(3, array($flag => 1)); + check("Events=View owner blocked when only $flag set", $f->canEdit($eventsViewer), false); +} + +// A pure query filter (no auto actions) is fine for a view-only owner. +$f = make_filter(3, array()); +check('Events=View owner CAN edit plain query filter', $f->canEdit($eventsViewer), true); + +// ---- AutoExecute requires System edit, not just Events edit ---------------- + +$f = make_filter(2, array('AutoExecute' => 1, 'AutoExecuteCmd' => '/bin/sh -c "id"')); +check('Events=Edit owner CANNOT edit AutoExecute filter', $f->canEdit($eventsEditor), false); + +// Events editor may still run event-changing filters. +$f = make_filter(2, array('AutoDelete' => 1)); +check('Events=Edit owner CAN edit AutoDelete filter', $f->canEdit($eventsEditor), true); +$f = make_filter(2, array('AutoMove' => 1, 'AutoMoveTo' => 5)); +check('Events=Edit owner CAN edit AutoMove filter', $f->canEdit($eventsEditor), true); + +// System editor can do anything, including AutoExecute. +$f = make_filter(999, array('AutoExecute' => 1, 'AutoExecuteCmd' => '/bin/sh -c "id"')); +check('System editor CAN edit AutoExecute filter (any owner)', $f->canEdit($systemAdmin), true); + +// ---- ownership enforcement ------------------------------------------------- + +$f = make_filter(1, array()); // owned by someone else +check('Events=Edit user CANNOT edit filter owned by another user', $f->canEdit($eventsEditor), false); +check('Events=View user CANNOT edit filter owned by another user', $f->canEdit($eventsViewer), false); + +// A user with no Events permission at all cannot edit even their own filter. +$noPerm = new TestUser(4, array()); +$f = make_filter(4, array()); +check('User with no Events perm CANNOT edit own plain filter', $f->canEdit($noPerm), false); + +// ---- cleanup --------------------------------------------------------------- + +@unlink($stubdir.'/database.php'); +@unlink($stubdir.'/FilterTerm.php'); +@unlink($stubdir.'/Monitor.php'); +@rmdir($stubdir); + +echo "\n$passes passed, $failures failed.\n"; +exit($failures ? 1 : 0); diff --git a/tests/zm_ffmpeg_fallback.cpp b/tests/zm_ffmpeg_fallback.cpp new file mode 100644 index 000000000..0c4737bed --- /dev/null +++ b/tests/zm_ffmpeg_fallback.cpp @@ -0,0 +1,83 @@ +/* + * This file is part of the ZoneMinder Project. See AUTHORS file for Copyright information + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "zm_catch2.h" + +#include "zm_ffmpeg.h" + +// open_fallback_decoder() is the "try whatever ffmpeg actually has" path used +// when none of the preferred decoders in dec_codecs are available. The +// hard-coded table is only a preference list; a codec present in the ffmpeg +// build but absent from the table must still open. + +static AVCodecParameters *make_video_par(enum AVCodecID id) { + AVCodecParameters *par = avcodec_parameters_alloc(); + par->codec_type = AVMEDIA_TYPE_VIDEO; + par->codec_id = id; + par->width = 1280; + par->height = 720; + par->format = AV_PIX_FMT_YUV420P; + return par; +} + +TEST_CASE("open_fallback_decoder: opens a decoder not listed in dec_codecs") { + // MPEG2VIDEO has a native ffmpeg decoder that is always compiled in, and it is + // deliberately not in the preferred dec_codecs table, so it only resolves via + // the fallback. If this build somehow lacks it, skip rather than false-fail. + if (!avcodec_find_decoder(AV_CODEC_ID_MPEG2VIDEO)) { + WARN("mpeg2video decoder not present in this ffmpeg build; skipping"); + return; + } + + AVCodecParameters *par = make_video_par(AV_CODEC_ID_MPEG2VIDEO); + const AVCodec *codec = nullptr; + AVCodecContext *ctx = open_fallback_decoder(par, &codec); + + REQUIRE(ctx != nullptr); + REQUIRE(codec != nullptr); + REQUIRE(codec->id == AV_CODEC_ID_MPEG2VIDEO); + + avcodec_free_context(&ctx); + avcodec_parameters_free(&par); +} + +TEST_CASE("open_fallback_decoder: codec_out is optional") { + if (!avcodec_find_decoder(AV_CODEC_ID_MPEG2VIDEO)) { + WARN("mpeg2video decoder not present in this ffmpeg build; skipping"); + return; + } + + AVCodecParameters *par = make_video_par(AV_CODEC_ID_MPEG2VIDEO); + AVCodecContext *ctx = open_fallback_decoder(par); // default nullptr codec_out + + REQUIRE(ctx != nullptr); + + avcodec_free_context(&ctx); + avcodec_parameters_free(&par); +} + +TEST_CASE("open_fallback_decoder: returns nullptr when no decoder exists") { + AVCodecParameters *par = make_video_par(AV_CODEC_ID_NONE); + const AVCodec *codec = reinterpret_cast(0x1); + AVCodecContext *ctx = open_fallback_decoder(par, &codec); + + REQUIRE(ctx == nullptr); + // codec_out must be left untouched on failure. + REQUIRE(codec == reinterpret_cast(0x1)); + + avcodec_parameters_free(&par); +} diff --git a/tests/zm_zone_stride.cpp b/tests/zm_zone_stride.cpp new file mode 100644 index 000000000..5fae44b58 --- /dev/null +++ b/tests/zm_zone_stride.cpp @@ -0,0 +1,243 @@ +/* + * This file is part of the ZoneMinder Project. See AUTHORS file for Copyright information + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +#include "zm_catch2.h" + +#include "zm_config.h" +#include "zm_image.h" +#include "zm_monitor.h" +#include "zm_rgb.h" +#include "zm_zone.h" + +#include + +// Motion analysis on a portrait monitor (refs #4983). ZM allocates images with +// linesize = FFALIGN(width, 32). Every width in the CaptureResolution dropdown +// (1920, 1280, 2560, 1440, 704, 640, ...) is already a multiple of 32, so +// linesize == width and any code that uses width as a row stride works by +// accident. A portrait monitor is 1080 wide; FFALIGN(1080,32) == 1088, so it is +// the first common config where linesize != width and those paths are exercised +// for real. Each case below runs the same assertion at a padded width and at an +// aligned width, so a failure tells us whether the stride is the culprit or +// whether the geometry is wrong independent of it. +namespace { + +// No zm.conf is loaded under the test harness, so config strings are null. +// Image::Initialise() dereferences config.font_file_location and the Monitor +// constructor strcmp()s config.event_close_mode; both segfault on null. +void EnsureConfig() { + if (!config.font_file_location) config.font_file_location = ""; + if (!config.event_close_mode) config.event_close_mode = "idle"; +} + +// Monitor's width/height are protected and normally come from the database. +// Expose them so a zone can be set up against arbitrary dimensions without a DB. +class TestMonitor : public Monitor { + public: + TestMonitor(unsigned int w, unsigned int h) : Monitor() { + width = w; + height = h; + colours = ZM_COLOUR_GRAY8; + // >1 is what gates the blob/filter masking path in Zone::CheckAlarms. + savejpegs = 2; + } +}; + +std::shared_ptr MakeMonitor(unsigned int w, unsigned int h) { + return std::static_pointer_cast(std::make_shared(w, h)); +} + +// Rectangle covering the whole frame, in pixel coordinates. +Polygon FullFramePolygon(unsigned int w, unsigned int h) { + std::vector vertices = { + Vector2(0, 0), + Vector2(w - 1, 0), + Vector2(w - 1, h - 1), + Vector2(0, h - 1), + }; + return Polygon(vertices); +} + +// A GRAY8 delta image where every pixel reads as maximum difference, written +// through Buffer(x, y) so the fill itself is stride-correct regardless of +// padding. +std::unique_ptr MakeSaturatedDelta(unsigned int w, unsigned int h) { + auto img = std::unique_ptr(new Image(w, h, ZM_COLOUR_GRAY8, ZM_SUBPIX_ORDER_NONE)); + for (unsigned int y = 0; y < h; y++) + for (unsigned int x = 0; x < w; x++) + *img->Buffer(x, y) = 255; + return img; +} + +} // namespace + +TEST_CASE("Image: a 1080-wide GRAY8 image is stride-padded, a 1920-wide one is not", "[Zone]") { + EnsureConfig(); + + // This is the premise the rest of the cases rest on. If FFALIGN ever stops + // padding 1080, these tests stop testing what they claim to. + Image portrait(1080, 1920, ZM_COLOUR_GRAY8, ZM_SUBPIX_ORDER_NONE); + REQUIRE(portrait.Width() == 1080); + REQUIRE(portrait.LineSize() == 1088); + + Image landscape(1920, 1080, ZM_COLOUR_GRAY8, ZM_SUBPIX_ORDER_NONE); + REQUIRE(landscape.Width() == 1920); + REQUIRE(landscape.LineSize() == 1920); +} + +// refs #4983. DumpImgBuffer() nulls buffer but leaves +// width/height/colours/subpixelorder/linesize intact, so Delta's "different +// sized images" guard still passes and, without a buffer check, it walks the +// delta helpers from a null base: buffer + 0*linesize is address 0 on the first +// row. That reproduced the reported "Fault address: (nil)" inside the gray8 +// delta helper. Delta must reject a null buffer on either operand instead of +// dereferencing it. (The originating race — the capture thread dumping +// ref_image under the analysis thread — is fixed separately in Monitor; this is +// the defensive backstop, and the only half unit-testable without a live race.) +TEST_CASE("Image::Delta rejects an operand whose buffer has been dumped", "[Image]") { + EnsureConfig(); + + auto make = []() { return Image(1080, 1920, ZM_COLOUR_GRAY8, ZM_SUBPIX_ORDER_NONE); }; + + SECTION("source (this) buffer dumped") { + Image ref = make(); + Image comp = make(); + Image delta; + + ref.DumpImgBuffer(); + + // Dimensions survive the dump, which is exactly why the size check is no + // protection here. + REQUIRE(ref.Buffer() == nullptr); + REQUIRE(ref.Width() == comp.Width()); + REQUIRE(ref.Height() == comp.Height()); + REQUIRE(ref.Colours() == comp.Colours()); + + REQUIRE(ref.Delta(comp, &delta) == false); + } + + SECTION("comparison (other) buffer dumped") { + Image ref = make(); + Image comp = make(); + Image delta; + + comp.DumpImgBuffer(); + + REQUIRE(ref.Delta(comp, &delta) == false); + } +} + +TEST_CASE("Zone::Setup derives per-row ranges correctly at a padded width", "[Zone]") { + EnsureConfig(); + + auto check = [](unsigned int w, unsigned int h) { + auto monitor = MakeMonitor(w, h); + Zone zone(monitor, 1, "full", Zone::ACTIVE, FullFramePolygon(w, h), kRGBRed, + Zone::ALARMED_PIXELS, + /*min_pixel_threshold*/ 10, /*max_pixel_threshold*/ 0, + /*min_alarm_pixels*/ 1, /*max_alarm_pixels*/ static_cast(w * h)); + + const Image *pg = zone.getPgImage(); + REQUIRE(pg != nullptr); + REQUIRE(pg->Width() == w); + REQUIRE(pg->Height() == h); + + // Setup() scans pg_image row by row to find each row's polygon extent. A + // full-frame polygon must mark every row from column 0 to the last column. + // A stride bug shows as a wrong extent on the rows past the padding + // boundary, so sample the first row, the last row, and a spread in between + // rather than asserting on all ~2000 (which bloats the suite and hides the + // failing row). Zone::Range is protected; getRanges() hands it out publicly, + // so let the type be deduced rather than naming it. + const auto *ranges = zone.getRanges(); + REQUIRE(ranges != nullptr); + for (unsigned int y : {0u, 1u, h / 2, h - 2, h - 1}) { + INFO("row y=" << y << " at " << w << "x" << h); + REQUIRE(ranges[y].lo_x == 0); + REQUIRE(ranges[y].hi_x == static_cast(w - 1)); + } + }; + + SECTION("1080x1920 portrait (linesize 1088 != width 1080)") { + check(1080, 1920); + } + SECTION("1920x1080 landscape (linesize == width)") { + check(1920, 1080); + } +} + +// ALARMED_PIXELS returns before the filter and blob stages. Real monitors +// commonly run BLOBS with SaveJPEGs>1, which additionally walks the diff buffer +// for filtering, runs blob detection, and builds an alarm highlight image. +TEST_CASE("Zone::CheckAlarms survives the blob path on a full-frame delta", "[Zone]") { + EnsureConfig(); + + auto check = [](unsigned int w, unsigned int h) { + const int frame_pixels = static_cast(w * h); + auto monitor = MakeMonitor(w, h); + // The filter/blob maxima default to values far below a full frame (50000), + // which would reject this delta before the code under test runs. Size every + // limit to the frame so the alarm survives to the blob stage. + Zone zone(monitor, 1, "full", Zone::ACTIVE, FullFramePolygon(w, h), kRGBRed, + Zone::BLOBS, + /*min_pixel_threshold*/ 10, /*max_pixel_threshold*/ 0, + /*min_alarm_pixels*/ 1, /*max_alarm_pixels*/ frame_pixels, + /*filter_box*/ Vector2(3, 3), + /*min_filter_pixels*/ 1, /*max_filter_pixels*/ frame_pixels, + /*min_blob_pixels*/ 1, /*max_blob_pixels*/ 0, + /*min_blobs*/ 1, /*max_blobs*/ 0); + + std::unique_ptr delta = MakeSaturatedDelta(w, h); + + REQUIRE(zone.CheckAlarms(delta.get()) == true); + REQUIRE(zone.GetStats().alarm_pixels_ == w * h); + }; + + SECTION("1080x1920 portrait (linesize 1088 != width 1080)") { + check(1080, 1920); + } + SECTION("1920x1080 landscape (linesize == width)") { + check(1920, 1080); + } +} + +TEST_CASE("Zone::CheckAlarms counts every pixel of a saturated full-frame delta", "[Zone]") { + EnsureConfig(); + + auto check = [](unsigned int w, unsigned int h) { + auto monitor = MakeMonitor(w, h); + Zone zone(monitor, 1, "full", Zone::ACTIVE, FullFramePolygon(w, h), kRGBRed, + Zone::ALARMED_PIXELS, + /*min_pixel_threshold*/ 10, /*max_pixel_threshold*/ 0, + /*min_alarm_pixels*/ 1, /*max_alarm_pixels*/ static_cast(w * h)); + + std::unique_ptr delta = MakeSaturatedDelta(w, h); + + REQUIRE(zone.CheckAlarms(delta.get()) == true); + // Every pixel is above threshold and every pixel is inside the polygon, so + // the alarmed count must be the whole frame. A stride mismatch shows up + // here as a short count. + REQUIRE(zone.GetStats().alarm_pixels_ == w * h); + }; + + SECTION("1080x1920 portrait (linesize 1088 != width 1080)") { + check(1080, 1920); + } + SECTION("1920x1080 landscape (linesize == width)") { + check(1920, 1080); + } +} diff --git a/utils/check-homoglyphs.py b/utils/check-homoglyphs.py new file mode 100755 index 000000000..34d9ef108 --- /dev/null +++ b/utils/check-homoglyphs.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Fail if first-party source contains Cyrillic/Greek homoglyphs. + +Contributors occasionally paste characters that look like ASCII letters but are +not, e.g. Cyrillic Es (U+0421) instead of Latin C. These break identifier and +translation-key lookups, and the multi-byte UTF-8 sequences are corrupted by +some reverse proxies (ZoneMinder discussions #4993, #4678). This scans the +git-tracked source tree for characters in the Cyrillic (U+0400-U+04FF) and +Greek (U+0370-U+03FF) blocks and exits non-zero if any are found. + +Translations (web/lang/) and vendored/minified assets legitimately contain +these characters and are excluded. Run from the repo root: + + python3 utils/check-homoglyphs.py +""" +import re +import subprocess +import sys + +# Only these extensions are scanned; everything else (images, fonts, binaries) +# is ignored. +SCANNED_EXT = ( + '.php', '.js', '.cpp', '.h', '.hpp', '.c', '.cc', + '.pl', '.pm', '.pl.in', '.pm.in', '.in', + '.css', '.sql', '.py', '.sh', +) + +# Paths where non-ASCII letters are legitimate (translations) or not ours to +# fix (vendored, minified, generated). Matched as a case-insensitive substring +# of the repo-relative path. +EXCLUDE = re.compile( + r'(?:' + r'/lang/' # translation catalogues + r'|\.min\.' # minified vendored bundles + r'|/assets/' # bundled third-party skin assets + r'|/vendor/|/dist/|/lib/' # vendored libraries + r'|node_modules/' + r'|jquery|bootstrap|chosen|moment|video\.js|audiomotion' + r'|pro-sidebar|dygraph|packery|flatpickr' + r')', + re.IGNORECASE, +) + +# Cyrillic (U+0400-U+04FF) and Greek (U+0370-U+03FF) letter blocks. These are +# what homoglyph attacks and accidental paste-ins draw from; ZoneMinder code is +# otherwise ASCII. Written with \u escapes so this script is itself ASCII and +# passes its own check. +HOMOGLYPH = re.compile("[\u0400-\u04ff\u0370-\u03ff]") + + +def tracked_files(): + out = subprocess.check_output(['git', 'ls-files'], text=True) + return out.splitlines() + + +def main(): + violations = [] + for path in tracked_files(): + if EXCLUDE.search(path): + continue + if not path.endswith(SCANNED_EXT): + continue + try: + with open(path, encoding='utf-8') as handle: + lines = handle.readlines() + except (OSError, UnicodeDecodeError): + continue + for lineno, line in enumerate(lines, 1): + for match in HOMOGLYPH.finditer(line): + char = match.group() + violations.append( + (path, lineno, match.start() + 1, ord(char), line.rstrip('\n')) + ) + + if not violations: + print('OK: no Cyrillic/Greek homoglyphs in first-party source.') + return 0 + + print('Found {} homoglyph character(s) in first-party source:\n'.format(len(violations))) + for path, lineno, col, cp, text in violations: + print(' {}:{}:{} U+{:04X}'.format(path, lineno, col, cp)) + print(' {}'.format(text.strip())) + print('\nReplace each with its ASCII equivalent (e.g. Cyrillic Es U+0421 -> Latin C).') + print('If a character is genuinely required, add its path to EXCLUDE in {}.'.format(sys.argv[0])) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/web/ajax/add_monitors.php b/web/ajax/add_monitors.php index 0e466bcca..786d03772 100644 --- a/web/ajax/add_monitors.php +++ b/web/ajax/add_monitors.php @@ -144,51 +144,80 @@ if (canEdit('Monitors')) { } // end case url_probe case 'import': { - + if ( !isset($_FILES['import_file']) ) { + ajaxError('No file was uploaded.'); + return; + } $file = $_FILES['import_file']; if ( $file['error'] > 0 ) { - ajaxError($file['error']); + ajaxError('File upload failed with error code '.$file['error']); return; - } else { - $filename = $file['name']; - - $available_streams = array(); - $row = 1; - if ( ($handle = fopen($file['tmp_name'], 'r')) !== FALSE ) { - while ( ($data = fgetcsv($handle, 1000, ',')) !== FALSE ) { - $name = $data[0]; - $url = $data[1]; - $group = $data[2]; - ZM\Info("Have the following line data $name $url $group"); - - $url_bits = null; - if ( preg_match('/(\d+)\.(\d+)\.(\d+)\.(\d+)/', $url) ) { - $url_bits = array('host'=>$url, 'scheme'=>'http'); - } else { - $url_bits = parse_url($url); - } - if ( ! $url_bits ) { - ZM\Info("Bad url, skipping line $name $url $group"); - continue; - } - - $available_streams += probe($url_bits); - - //$url_bits['url'] = unparse_url( $url_bits ); - //$url_bits['Monitor'] = $defaultMonitor; - //$url_bits['Monitor']->Name( $name ); - //$url_bits['Monitor']->merge( $_POST['newMonitor'] ); - //$available_streams[] = $url_bits; - - } // end while rows - fclose($handle); - ajaxResponse(array('Streams'=>$available_streams)); - } else { - ajaxError('Uploaded file does not exist'); - return; - } } + if ( !is_uploaded_file($file['tmp_name']) ) { + ajaxError('Uploaded file does not exist'); + return; + } + + if ( ($handle = fopen($file['tmp_name'], 'r')) === FALSE ) { + ajaxError('Unable to open uploaded file'); + return; + } + + $available_streams = array(); + $row = 0; + while ( ($data = fgetcsv($handle, 1000, ',')) !== FALSE ) { + $row ++; + if ( count($data) < 2 ) { + ZM\Info("Skipping line $row, expected at least Name,URL columns"); + continue; + } + $name = trim($data[0]); + $url = trim($data[1]); + $group = isset($data[2]) ? trim($data[2]) : ''; + + // Skip an optional header row (Name,URL,Group) + if ( $row == 1 and !strcasecmp($name, 'Name') and !strcasecmp($url, 'URL') ) { + ZM\Debug('Skipping header row'); + continue; + } + if ( $url === '' ) { + ZM\Info("Skipping line $row, no URL"); + continue; + } + ZM\Info("Importing line $row: $name $url $group"); + + $url_bits = parse_url($url); + $host = ($url_bits and isset($url_bits['host'])) ? $url_bits['host'] : ''; + + # If a monitor already exists for this url, offer to edit it instead of adding a duplicate. + $monitor = null; + $existing = ZM\Monitor::find(array('Path'=>$url)); + if ( count($existing) ) $monitor = $existing[0]; + + $available_streams[] = array( + 'mac' => '', + 'description' => $name.' - '.$url, + 'url' => $url, + 'IP' => $host, + 'Group' => $group, + 'camera' => array( + 'Name' => $name, + 'ip' => $host, + 'Manufacturer' => '', + 'Model' => '', + 'monitor' => array( + 'Type' => 'Ffmpeg', + 'Path' => $url, + 'Name' => $name, + ), + ), + 'Monitor' => $monitor, + ); + } // end while rows + fclose($handle); + + ajaxResponse(array('Streams'=>$available_streams)); break; } // end case import default: diff --git a/web/ajax/events.php b/web/ajax/events.php index d6eea18aa..06698a68c 100644 --- a/web/ajax/events.php +++ b/web/ajax/events.php @@ -29,11 +29,19 @@ if ($message) { } require_once('includes/Filter.php'); +require_once getSkinFile('views/_monitor_filters.php'); // getFilteredMonitorIds() $filter = isset($_REQUEST['filter']) ? ZM\Filter::parse($_REQUEST['filter']) : new ZM\Filter(); if (count( $user->unviewableMonitorIds())) { $filter = $filter->addTerm(array('cnj'=>'and', 'attr'=>'MonitorId', 'op'=>'IN', 'val'=>$user->viewableMonitorIds())); // $filter = $filter->addTerm(array('cnj'=>'and', 'attr'=>'MonitorId', 'op'=>'IN', 'val'=>'5')); } +# Constrain to the monitors selected by the shared monitor-attribute filters +# (Status/Capturing/Server/Storage/Name/Source) which have no Events column and so +# can't be expressed as event terms. Null = no such filter active. refs #4976 +$attr_monitor_ids = getFilteredMonitorIds(); +if ($attr_monitor_ids !== null) { + $filter = $filter->addTerm(array('cnj'=>'and', 'attr'=>'MonitorId', 'op'=>'IN', 'val'=>$attr_monitor_ids)); +} // TODO: Why is $user->viewableMonitorIds() returning $user->unviewableMonitorIds() // Error('$user->viewableMonitorIds(): '.print_r($user->viewableMonitorIds())); if (!empty($_REQUEST['StartDateTime'])) { @@ -224,20 +232,22 @@ function queryRequest($filter, $search, $advsearch, $sort, $offset, $order, $lim // For events that never wrote EndDateTime (zmc killed/crashed mid-event), // fall back to StartDateTime + Length. Length is flushed to the DB every // few seconds during recording, so it reflects the actual recorded - // duration even when zmc died without closing the event. Falling back to - // NOW() would otherwise extend the event across all the down-time. + // duration even when zmc died without closing the event. When Length is 0 + // too (an empty crash-orphaned event), fall back to StartDateTime so the + // event has no span; NOW() would otherwise extend it across all the + // down-time and overlap every later event. $col_str = ' E.*, UNIX_TIMESTAMP(E.StartDateTime) AS StartTimeSecs, CASE WHEN E.EndDateTime IS NOT NULL THEN E.EndDateTime WHEN E.Length > 0 THEN DATE_ADD(E.StartDateTime, INTERVAL FLOOR(E.Length) SECOND) - ELSE NOW() + ELSE E.StartDateTime END AS EndDateTime, CASE WHEN E.EndDateTime IS NOT NULL THEN UNIX_TIMESTAMP(E.EndDateTime) WHEN E.Length > 0 THEN UNIX_TIMESTAMP(E.StartDateTime) + E.Length - ELSE UNIX_TIMESTAMP(NOW()) + ELSE UNIX_TIMESTAMP(E.StartDateTime) END AS EndTimeSecs, M.Name AS Monitor, GROUP_CONCAT(T.Name SEPARATOR ", ") AS Tags'; diff --git a/web/ajax/modals/server.php b/web/ajax/modals/server.php index c6152458c..f902daecb 100644 --- a/web/ajax/modals/server.php +++ b/web/ajax/modals/server.php @@ -20,7 +20,7 @@ if ( $sid and ! $Server->Id() ) return;