Skip to content

Commit

Permalink
live snapshot from camera (#299)
Browse files Browse the repository at this point in the history
* get live snapshot using device snapshot uri

* fix digest auth + refactor fetch snapshot

* add tests

* refactor code
  • Loading branch information
SidAli-Belho authored Dec 23, 2023
1 parent e2c1bdb commit 0ce9336
Show file tree
Hide file tree
Showing 8 changed files with 235 additions and 51 deletions.
35 changes: 34 additions & 1 deletion apps/ex_nvr/lib/ex_nvr/devices.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ defmodule ExNVR.Devices do
Context to manipulate devices
"""

require Logger

alias Ecto.Multi
alias ExNVR.Model.{Device, Recording, Run}
alias ExNVR.{Repo, DeviceSupervisor}
alias ExNVR.{HTTP, Repo, DeviceSupervisor}
alias Ecto.Multi

import Ecto.Query
Expand Down Expand Up @@ -79,6 +81,37 @@ defmodule ExNVR.Devices do
Device.update_changeset(device, attrs)
end

@spec fetch_snapshot(Device.t()) :: binary()
def fetch_snapshot(%{stream_config: %{snapshot_uri: nil}}) do
{:error, :no_snapshot_uri}
end

def fetch_snapshot(%Device{credentials: credentials} = device) do
opts = [username: credentials.username, password: credentials.password]
url = device.stream_config.snapshot_uri

case HTTP.get(url, opts) do
{:ok, %{status: 200, body: body}} ->
{:ok, body}

{:ok, response} ->
Logger.error("""
Devices: could not fetch live snapshot for device #{inspect(device)}
#{inspect(response)}
""")

{:error, response}

error ->
Logger.error("""
Devices: could not fetch live snapshot for device #{inspect(device)}
#{inspect(error)}
""")

error
end
end

defp create_device_directories(device) do
File.mkdir_p!(Device.base_dir(device))
File.mkdir_p!(Device.recording_dir(device))
Expand Down
109 changes: 109 additions & 0 deletions apps/ex_nvr/lib/ex_nvr/http.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
defmodule ExNVR.HTTP do
@moduledoc false

@spec get(binary(), Keyword.t()) ::
{:ok, Finch.Response.t()} | {:error, term()}
def get(url, opts \\ []) do
username = opts[:username] || ""
password = opts[:password] || ""

opts = opts ++ [method: :get, url: url]

if username == "" or password == "" do
do_call(:get, url)
else
http_headers = [{"Authorization", "Basic " <> Base.encode64(username <> ":" <> password)}]
result = do_call(:get, url, http_headers)

with {:ok, %{status: 401} = resp} <- result,
{:ok, digest_header} <- build_digest_auth_header(resp, opts) do
do_call(:get, url, [digest_header])
else
_other ->
result
end
end
end

@spec build_digest_auth_header(map(), Keyword.t()) :: {:ok, tuple()} | {:error, binary()}
def build_digest_auth_header(resp, opts) do
with digest_opts when is_map(digest_opts) <- digest_auth_opts(resp, opts),
digest_header <- {"Authorization", encode_digest(digest_opts)} do
{:ok, digest_header}
end
end

defp digest_auth_opts(%{headers: headers}, opts) do
headers
|> Enum.map(fn {key, value} -> {String.downcase(key), value} end)
|> Map.new()
|> Map.fetch("www-authenticate")
|> case do
{:ok, "Digest " <> digest} ->
%{
nonce: match_pattern("nonce", digest),
realm: match_pattern("realm", digest),
qop: match_pattern("qop", digest),
username: opts[:username],
password: opts[:password],
method: Atom.to_string(opts[:method]) |> String.upcase(),
path: URI.parse(opts[:url]).path
}

_other ->
nil
end
end

defp match_pattern(pattern, digest) do
case Regex.run(~r/#{pattern}=\"(?<value>.*)\"/U, digest) do
[_match, value] -> value
_other -> nil
end
end

defp encode_digest(opts) do
ha1 = md5([opts.username, opts.realm, opts.password])
ha2 = md5([opts.method, opts.path])
nonce_count = "00000001"
client_nonce = :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower)
response = digest_response_attribute(ha1, ha2, opts, nonce_count, client_nonce)

digest_token =
%{
"username" => opts.username,
"realm" => opts.realm,
"nonce" => opts.nonce,
"uri" => opts.path,
"response" => response,
"qop" => opts.qop,
"cnonce" => client_nonce,
"nc" => nonce_count
}
|> Map.filter(fn {_key, value} -> value != nil end)
|> Enum.map(fn {key, value} -> ~s(#{key}="#{value}") end)
|> Enum.join(", ")

"Digest " <> digest_token
end

defp digest_response_attribute(ha1, ha2, %{qop: nil} = opts, _nonce_count, _client_nonce) do
md5([ha1, opts.nonce, ha2])
end

defp digest_response_attribute(ha1, ha2, opts, nonce_count, client_nonce) do
md5([ha1, opts.nonce, nonce_count, client_nonce, opts.qop, ha2])
end

defp do_call(method, url, headers \\ [], body \\ nil, opts \\ []) do
Finch.build(method, url, headers, body) |> Finch.request(ExNVR.Finch, opts)
end

defp md5(value) do
value
|> Enum.join(":")
|> IO.iodata_to_binary()
|> :erlang.md5()
|> Base.encode16(case: :lower)
end
end
49 changes: 5 additions & 44 deletions apps/ex_nvr/lib/ex_nvr/onvif/http.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ defmodule ExNVR.Onvif.Http do

import Mockery.Macro

alias ExNVR.HTTP

@onvif_path Path.join([Application.app_dir(:ex_nvr), "priv", "onvif"])
@device_wsdl Soap.init_model(Path.join(@onvif_path, "devicemgmt.wsdl")) |> elem(1)
@media_wsdl Soap.init_model(Path.join(@onvif_path, "media2.wsdl")) |> elem(1)
Expand All @@ -14,6 +16,7 @@ defmodule ExNVR.Onvif.Http do

username = opts[:username] || ""
password = opts[:password] || ""
opts = opts ++ [method: :post, url: url]

if username == "" or password == "" do
mockable(Soap).call(wsdl, operation, body)
Expand All @@ -22,56 +25,14 @@ defmodule ExNVR.Onvif.Http do
result = mockable(Soap).call(wsdl, operation, body, http_headers)

with {:ok, %{status_code: 401} = resp} <- result,
digest_opts when is_map(digest_opts) <- digest_auth_opts(resp, opts),
http_headers <- [{"Authorization", encode_digest(digest_opts)}] do
mockable(Soap).call(wsdl, operation, body, http_headers)
{:ok, digest_header} <- HTTP.build_digest_auth_header(resp, opts) do
mockable(Soap).call(wsdl, operation, body, [digest_header])
else
_other -> result
end
end
end

@spec encode_digest(map()) :: String.t()
def encode_digest(opts) do
uri = "/onvif/device_service"
ha1 = md5([opts.username, opts.realm, opts.password])
ha2 = md5(["POST", "/onvif/device_service"])
response = md5([ha1, opts.nonce, ha2])

Enum.join(
[
"Digest",
~s(username="#{opts.username}",),
~s(realm="#{opts.realm}",),
~s(nonce="#{opts.nonce}",),
~s(uri="#{uri}",),
~s(response="#{response}")
],
" "
)
end

@spec md5([String.t()]) :: String.t()
def md5(value) do
value
|> Enum.join(":")
|> IO.iodata_to_binary()
|> :erlang.md5()
|> Base.encode16(case: :lower)
end

defp digest_auth_opts(%{headers: headers}, opts) do
case Map.fetch(Map.new(headers), "WWW-Authenticate") do
{:ok, "Digest " <> digest} ->
[_match, nonce] = Regex.run(~r/nonce=\"(?<nonce>.*)\"/U, digest)
[_match, realm] = Regex.run(~r/realm=\"(?<realm>.*)\"/U, digest)
%{nonce: nonce, realm: realm, username: opts[:username], password: opts[:password]}

_other ->
nil
end
end

defp get_model(:media, endpoint), do: %{@media_wsdl | endpoint: endpoint}
defp get_model(:device, endpoint), do: %{@device_wsdl | endpoint: endpoint}
end
3 changes: 2 additions & 1 deletion apps/ex_nvr/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ defmodule ExNVR.MixProject do
{:turbojpeg, github: "BinaryNoggin/elixir-turbojpeg", ref: "14e2b36"},
{:flop, "~> 0.22.1"},
{:soap, github: "gBillal/soap", branch: "parse-attributes"},
{:faker, "~> 0.17", only: :test}
{:faker, "~> 0.17", only: :test},
{:bypass, "~> 2.1", only: :test}
]
end

Expand Down
38 changes: 38 additions & 0 deletions apps/ex_nvr/test/ex_nvr/http_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
defmodule ExNVR.HttpTest do
use ExNVR.DataCase

alias ExNVR.HTTP
alias Plug.Conn

setup do
bypass = Bypass.open()
{:ok, bypass: bypass}
end

test "client handle digest authentication", %{bypass: bypass} do
Bypass.expect(bypass, "GET", "/resource", fn conn ->
case Conn.get_req_header(conn, "authorization") do
["Basic " <> _token] ->
conn
|> Conn.put_resp_header(
"www-authenticate",
"Digest realm=\"realm\", nonce=\"1fd54f4d5f5d4sfdsf\", qop=\"auth\""
)
|> Conn.resp(401, "")

["Digest " <> _token] ->
conn
|> Conn.put_resp_content_type("application/json")
|> Conn.resp(200, ~s(success))
end
end)

opts = [username: "admin", password: "password"]
response = HTTP.get("#{url(bypass.port)}/resource", opts)
assert {:ok, %{status: 200, body: ~s<success>}} = response
end

defp url(port) do
"http://localhost:#{port}"
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ defmodule ExNVRWeb.API.DeviceStreamingController do

alias Ecto.Changeset
alias ExNVR.Pipelines.{HlsPlayback, Main}
alias ExNVR.{HLS, Model.Recording, Recordings, Utils}
alias ExNVR.{Devices, HLS, Model.Recording, Recordings, Utils}

@type return_t :: Plug.Conn.t() | {:error, Changeset.t()}

Expand Down Expand Up @@ -71,12 +71,17 @@ defmodule ExNVRWeb.API.DeviceStreamingController do
defp serve_live_snapshot(conn, params) do
device = conn.assigns.device

case device.state do
:recording ->
{:ok, snapshot} = Main.live_snapshot(device, params.format)
with {:error, _details} <- Devices.fetch_snapshot(device),
:recording <- device.state do
{:ok, snapshot} = Main.live_snapshot(device, params.format)

conn
|> put_resp_content_type("image/#{params.format}")
|> send_resp(:ok, snapshot)
else
{:ok, snapshot} ->
conn
|> put_resp_content_type("image/#{params.format}")
|> put_resp_content_type("image/jpeg")
|> send_resp(:ok, snapshot)

_ ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ defmodule ExNVRWeb.API.DeviceStreamingControllerTest do

import ExNVR.{AccountsFixtures, DevicesFixtures, RecordingsFixtures}

alias Plug.Conn

@moduletag :tmp_dir
@moduletag :device

Expand Down Expand Up @@ -83,6 +85,11 @@ defmodule ExNVRWeb.API.DeviceStreamingControllerTest do
%{recording: recording}
end

setup do
bypass = Bypass.open()
{:ok, bypass: bypass}
end

test "Get snapshot from recorded videos", %{conn: conn, device: device, recording: recording} do
conn = get(conn, "/api/devices/#{device.id}/snapshot?time=#{recording.start_date}")

Expand All @@ -106,6 +113,35 @@ defmodule ExNVRWeb.API.DeviceStreamingControllerTest do
|> get("/api/devices/#{device.id}/snapshot")
|> response(404)
end

test "Get live snapshot from camera using snapshot uri", %{
conn: conn,
tmp_dir: tmp_dir,
bypass: bypass
} do
device =
device_fixture(%{
stream_config: %{
stream_uri: "rtsp://localhost:8541",
snapshot_uri: "http://localhost:#{bypass.port}/snapshot"
},
settings: %{storage_address: tmp_dir}
})

Bypass.expect(bypass, "GET", "/snapshot", fn conn ->
conn
|> Conn.put_resp_content_type("image/jpeg")
|> Conn.resp(200, <<20, 12, 23>>)
end)

conn = get(conn, "/api/devices/#{device.id}/snapshot")
assert get_resp_header(conn, "content-type") == ["image/jpeg; charset=utf-8"]

assert %{
status: 200,
resp_body: <<20, 12, 23>>
} = conn
end
end

describe "GET /api/devices/:device_id/footage" do
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"bundlex": {:hex, :bundlex, "1.2.0", "a89869208a019376a38e8a10e1bd573dcbeae8addd381c2cd74e2817010bef8f", [:mix], [{:bunch, "~> 1.0", [hex: :bunch, repo: "hexpm", optional: false]}, {:qex, "~> 0.5", [hex: :qex, repo: "hexpm", optional: false]}, {:req, "~> 0.4.0", [hex: :req, repo: "hexpm", optional: false]}, {:secure_random, "~> 0.5", [hex: :secure_random, repo: "hexpm", optional: false]}, {:zarex, "~> 1.0", [hex: :zarex, repo: "hexpm", optional: false]}], "hexpm", "d2182b91a2a53847baadf4745ad2291853e786ad28671f474a611e7703dbca9b"},
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
"castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"},
"bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.8", "933a5f4da3b19ee56539a076076ce4d7716d64efc8db46fd066996a7e46e2bfd", [:mix], [{:elixir_make, "~> 0.7.3", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "176bdf4366956e456bf761b54ad70bc4103d0269ca9558fd7cee93d1b3f116db"},
"certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
"coerce": {:hex, :coerce, "1.0.1", "211c27386315dc2894ac11bc1f413a0e38505d808153367bd5c6e75a4003d096", [:mix], [], "hexpm", "b44a691700f7a1a15b4b7e2ff1fa30bebd669929ac8aa43cffe9e2f8bf051cf1"},
Expand Down

0 comments on commit 0ce9336

Please sign in to comment.