add module to handle downloads
This commit is contained in:
1 parent
b0da248ff0
commit
2df2aedb68
1 file changed
+249
@@ -0,0 +1,249 @@
|
|||||||
|
defmodule FeedReader.Downloader do
|
||||||
|
@moduledoc """
|
||||||
|
Streams remote response bodies into owned temporary files while computing the
|
||||||
|
decoded MD5 checksum. Gzip and raw deflate are decoded incrementally; stacked
|
||||||
|
and unknown content encodings are rejected. See `download/2` for its options
|
||||||
|
and resource budgets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias FeedReader.{Fetch, Transport}
|
||||||
|
|
||||||
|
@defaults [
|
||||||
|
max_compressed_bytes: 52_428_800,
|
||||||
|
max_decoded_bytes: 52_428_800,
|
||||||
|
max_temp_bytes: 52_428_800,
|
||||||
|
max_expansion_ratio: 100
|
||||||
|
]
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Downloads, decodes, checksums, and owns a temporary response file.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
- `:http_client` — module implementing `FeedReader.Transport`; defaults to
|
||||||
|
`FeedReader.ReqTransport`.
|
||||||
|
- `:http_options` — Req request options. The default transport streams raw
|
||||||
|
response chunks, disables automatic decompression, and limits redirects to
|
||||||
|
its configured limit. A supplied `:headers` list replaces its default
|
||||||
|
headers.
|
||||||
|
- `:previous_etag` — binary ETag sent verbatim as `If-None-Match`. A 304
|
||||||
|
response returns `{:ok, :not_modified}` without creating a temporary file.
|
||||||
|
- `:http_retry_attempts` — non-negative retry count after the initial request;
|
||||||
|
defaults to the `FeedReader.ReqTransport` setting. Invalid values use the
|
||||||
|
default.
|
||||||
|
- `:retry_delay` — non-negative delay in milliseconds or an arity-one retry
|
||||||
|
delay function; defaults to the `FeedReader.ReqTransport` setting. Invalid
|
||||||
|
values use the default.
|
||||||
|
- `:previous_checksum` — checksum of the previous decoded body. An exact match
|
||||||
|
returns `{:ok, :not_changed}` and removes the temporary file.
|
||||||
|
- `:max_compressed_bytes` — maximum cumulative raw response size; defaults to
|
||||||
|
`#{@defaults[:max_compressed_bytes]}` bytes.
|
||||||
|
- `:max_decoded_bytes` — maximum cumulative decompressed body size; defaults to
|
||||||
|
`#{@defaults[:max_decoded_bytes]}` bytes.
|
||||||
|
- `:max_temp_bytes` — maximum bytes written to the owned temporary file;
|
||||||
|
defaults to `#{@defaults[:max_temp_bytes]}` bytes.
|
||||||
|
- `:max_expansion_ratio` — maximum decoded-to-compressed byte ratio; defaults
|
||||||
|
to `#{@defaults[:max_expansion_ratio]}`.
|
||||||
|
|
||||||
|
Invalid resource-limit values use their defaults. Limits are checked as body
|
||||||
|
chunks are processed. Successful downloads return the owned path and a
|
||||||
|
`FeedReader.Fetch`; callers must pass the download result to `close/1` when
|
||||||
|
finished. Errors remove any temporary file created for the request.
|
||||||
|
"""
|
||||||
|
@spec download(String.t(), keyword()) ::
|
||||||
|
{:ok, %{path: Path.t(), fetch: Fetch.t()}}
|
||||||
|
| {:ok, :not_changed | :not_modified}
|
||||||
|
| {:error, term()}
|
||||||
|
def download(url, opts) when is_binary(url) do
|
||||||
|
with {:ok, response} <- Transport.request(url, opts) do
|
||||||
|
case response.status do
|
||||||
|
304 ->
|
||||||
|
{:ok, :not_modified}
|
||||||
|
|
||||||
|
status when status in 200..299 ->
|
||||||
|
download_body(url, response, opts)
|
||||||
|
|
||||||
|
status ->
|
||||||
|
{:error, {:http_status, status}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def download(_url, _opts), do: {:error, :invalid_url}
|
||||||
|
|
||||||
|
@doc "Removes a downloaded temporary file; safe to call more than once."
|
||||||
|
@spec close(%{path: Path.t()}) :: :ok
|
||||||
|
def close(%{path: path}) do
|
||||||
|
_ = File.rm(path)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp download_body(url, response, opts) do
|
||||||
|
with {:ok, encoding} <- content_encoding(response.headers),
|
||||||
|
{:ok, path, file} <- open_temp_file() do
|
||||||
|
result =
|
||||||
|
try do
|
||||||
|
consume_body(response.body, file, encoding, opts)
|
||||||
|
after
|
||||||
|
File.close(file)
|
||||||
|
end
|
||||||
|
|
||||||
|
finish_download(result, path, url, response, opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp finish_download({:ok, checksum}, path, url, response, opts) do
|
||||||
|
if checksum == Keyword.get(opts, :previous_checksum) do
|
||||||
|
File.rm(path)
|
||||||
|
{:ok, :not_changed}
|
||||||
|
else
|
||||||
|
fetch = %Fetch{
|
||||||
|
etag: header(response.headers, "etag"),
|
||||||
|
checksum: checksum,
|
||||||
|
final_url: response.final_url || url,
|
||||||
|
status: response.status
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, %{path: path, fetch: fetch}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp finish_download({:error, _} = error, path, _url, _response, _opts) do
|
||||||
|
File.rm(path)
|
||||||
|
error
|
||||||
|
end
|
||||||
|
|
||||||
|
defp consume_body(body, file, encoding, opts) do
|
||||||
|
initial = %{compressed: 0, decoded: 0, hash: :crypto.hash_init(:md5)}
|
||||||
|
|
||||||
|
try do
|
||||||
|
decoder = start_decoder(encoding)
|
||||||
|
|
||||||
|
try do
|
||||||
|
body
|
||||||
|
|> Enum.reduce_while({:ok, initial}, &consume_chunk(&1, &2, decoder, file, opts))
|
||||||
|
|> case do
|
||||||
|
{:ok, state} -> {:ok, Base.encode16(:crypto.hash_final(state.hash), case: :lower)}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
after
|
||||||
|
if decoder, do: :zlib.close(decoder)
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
error -> {:error, {:download_failed, error}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp consume_chunk(chunk, {:ok, state}, decoder, file, opts) when is_binary(chunk) do
|
||||||
|
with {:ok, state} <- count_compressed(state, byte_size(chunk), opts),
|
||||||
|
{:ok, outputs} <- decode_chunk(decoder, chunk),
|
||||||
|
{:ok, state} <- write_outputs(outputs, file, state, opts) do
|
||||||
|
{:cont, {:ok, state}}
|
||||||
|
else
|
||||||
|
{:error, reason} -> {:halt, {:error, reason}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp consume_chunk(_chunk, _state, _decoder, _file, _opts),
|
||||||
|
do: {:halt, {:error, :invalid_chunk}}
|
||||||
|
|
||||||
|
defp write_outputs(outputs, file, state, opts) do
|
||||||
|
Enum.reduce_while(outputs, {:ok, state}, fn output, {:ok, state} ->
|
||||||
|
decoded = state.decoded + byte_size(output)
|
||||||
|
decoded_limit = limit(opts, :max_decoded_bytes)
|
||||||
|
temp_limit = limit(opts, :max_temp_bytes)
|
||||||
|
ratio = limit(opts, :max_expansion_ratio)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
decoded > decoded_limit ->
|
||||||
|
{:halt, resource_error(:max_decoded_bytes, decoded_limit)}
|
||||||
|
|
||||||
|
decoded > temp_limit ->
|
||||||
|
{:halt, resource_error(:max_temp_bytes, temp_limit)}
|
||||||
|
|
||||||
|
decoded > max(state.compressed, 1) * ratio ->
|
||||||
|
{:halt, resource_error(:max_expansion_ratio, ratio)}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
:ok = IO.binwrite(file, output)
|
||||||
|
state = %{state | decoded: decoded, hash: :crypto.hash_update(state.hash, output)}
|
||||||
|
{:cont, {:ok, state}}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp count_compressed(state, bytes, opts) do
|
||||||
|
total = state.compressed + bytes
|
||||||
|
max_bytes = limit(opts, :max_compressed_bytes)
|
||||||
|
|
||||||
|
if total > max_bytes,
|
||||||
|
do: resource_error(:max_compressed_bytes, max_bytes),
|
||||||
|
else: {:ok, %{state | compressed: total}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_chunk(nil, chunk), do: {:ok, [chunk]}
|
||||||
|
|
||||||
|
defp decode_chunk(decoder, chunk) do
|
||||||
|
{:ok, inflate(decoder, chunk)}
|
||||||
|
rescue
|
||||||
|
_error -> {:error, :invalid_compressed_body}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp inflate(decoder, input) do
|
||||||
|
case :zlib.safeInflate(decoder, input) do
|
||||||
|
{status, output} when status in [:continue, :finished] ->
|
||||||
|
output = if output == [], do: [], else: [IO.iodata_to_binary(output)]
|
||||||
|
if status == :continue, do: output ++ inflate(decoder, []), else: output
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_decoder(nil), do: nil
|
||||||
|
defp start_decoder(:gzip), do: decoder(31)
|
||||||
|
defp start_decoder(:deflate), do: decoder(-15)
|
||||||
|
|
||||||
|
defp decoder(window_bits) do
|
||||||
|
zlib = :zlib.open()
|
||||||
|
:ok = :zlib.inflateInit(zlib, window_bits)
|
||||||
|
zlib
|
||||||
|
end
|
||||||
|
|
||||||
|
defp content_encoding(headers) do
|
||||||
|
case header(headers, "content-encoding") do
|
||||||
|
nil ->
|
||||||
|
{:ok, nil}
|
||||||
|
|
||||||
|
value when is_binary(value) ->
|
||||||
|
decode_encoding(String.downcase(String.trim(value)))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_encoding("identity"), do: {:ok, nil}
|
||||||
|
defp decode_encoding(encoding) when encoding in ["gzip", "x-gzip"], do: {:ok, :gzip}
|
||||||
|
defp decode_encoding("deflate"), do: {:ok, :deflate}
|
||||||
|
defp decode_encoding(_encoding), do: {:error, :unsupported_content_encoding}
|
||||||
|
|
||||||
|
defp header(headers, name) do
|
||||||
|
Enum.find_value(headers, fn {key, value} ->
|
||||||
|
if String.downcase(key) == name, do: value
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp open_temp_file do
|
||||||
|
path =
|
||||||
|
Path.join(
|
||||||
|
System.tmp_dir!(),
|
||||||
|
"feed_reader-download-#{System.unique_integer([:positive])}.xml"
|
||||||
|
)
|
||||||
|
|
||||||
|
case File.open(path, [:write, :binary, :exclusive]) do
|
||||||
|
{:ok, file} -> {:ok, path, file}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp limit(opts, key), do: valid_limit(Keyword.get(opts, key, @defaults[key]), @defaults[key])
|
||||||
|
defp valid_limit(value, _default) when is_integer(value) and value >= 0, do: value
|
||||||
|
defp valid_limit(_invalid, default), do: default
|
||||||
|
|
||||||
|
defp resource_error(key, limit), do: {:error, {:resource_limit, key, limit}}
|
||||||
|
end
|
||||||
Reference in new issue
Block a user