Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for range request queries #526

Merged
merged 22 commits into from
Jun 18, 2017
Merged

Support for range request queries #526

merged 22 commits into from
Jun 18, 2017

Conversation

scouten
Copy link
Contributor

@scouten scouten commented Mar 6, 2017

As mentioned in #523, we would like to serve some light static video content through a Phoenix-based web site and are encountering the issue described in http://stackoverflow.com/questions/36576801/serving-http-range-request-with-phoenix.

This PR adds part of the range request protocol defined in RFC 7233 to Plug.Static.

This implementation supports requests specifying a single range of bytes, which appears to be sufficient to enable video playback in Safari. A client may request multiple byte ranges in the same request, but I am not proposing to implement that in this PR.

Submitting now for preliminary / architectural review. IMHO it is not yet ready for merge.

My to do list (I will probably get back to this later in the week):

  • Double-check implementation against RFC 7233 specs for compliance
  • Review tests for error conditions
  • Check code coverage
  • (Possibly?) Add support for cache mechanisms

encoding = file_encoding(conn, path, false, false)
serve_range(encoding, range, segments, options)
end
defp serve_range(_conn, _path, _segments, range, _gzip?, _brotli?, _options) do

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function takes too many parameters (arity is 7, max is 5).

encoding = file_encoding(conn, path, gzip?, brotli?)
serve_static(encoding, segments, options)
end
defp serve_range(conn, path, segments, [range], _gzip?, _brotli?, options) do

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function takes too many parameters (arity is 7, max is 5).

@@ -177,6 +181,100 @@ defmodule Plug.Static do
h in only or match?({0, _}, prefix != [] and :binary.match(h, prefix))
end

defp serve_range(conn, path, segments, [], gzip?, brotli?, options) do

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function takes too many parameters (arity is 7, max is 5).

@scouten
Copy link
Contributor Author

scouten commented Apr 21, 2017

@josevalim et al: I believe this is ready for review and merge.

raise InvalidRangeError
end

@byte_range_pattern ~r/^\s*bytes=([0-9]+)?-([0-9]+)?\s*$/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please parse the bytes accordingly instead of relying on regexes. See the functions in Plug.Conn.Utils that can help you parse params. If necessary, add new functions there.


defp start_and_end([_, range_start], file_size)
when is_binary(range_start)
do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make the code style consistent with the remaining of the Plug codebase. In this particular case, everything can fit a single line.

defp send_range(conn, path, range_start, range_end, file_size, segments, options) do
%{headers: headers} = options
length = (range_end - range_start) + 1
content_type = segments |> List.last |> MIME.from_path
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make sure to add a test for this too.

test "returns 416 if range is contains non-integers" do
exception = assert_raise Plug.Static.InvalidRangeError,
"invalid range for static asset",
fn ->
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please start fn -> in the previous line and outdent the whole function 2 spaces. The same for the examples below.

@josevalim
Copy link
Member

Sorry for the delay in reviewing this. I have added some comments.

@byte_range_pattern ~r/^\s*bytes=([0-9]+)?-([0-9]+)?\s*$/

defp serve_range({:ok, conn, file_info, path}, range, segments, options) do
file_size = elem(file_info, 1)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use record matching: file_info(size: size) = file_info

:entire_file ->
serve_static({:ok, conn, file_info, path}, segments, options)
:invalid ->
raise InvalidRangeError
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is raising the reasonable thing to do here? You said we don't support all syntaxes, so maybe we should rather send the whole file instead of crashing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't find this exact scenario in the spec but it seems like, generally, if there's a range request the server does not understand the header should be ignored.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, reading the spec, I can find support for both approaches:

Section 3.1:

A server that supports range requests MAY ignore or reject a Range header field that consists of more than two overlapping ranges, or a set of many small ranges that are not listed in ascending order, since both are indications of either a broken client or a deliberate denial-of-service attack (Section 6.1).

or (later in same section):

If all of the preconditions are true, the server supports the Range header field for the target resource, and the specified range(s) are invalid or unsatisfiable, the server SHOULD send a 416 (Range Not Satisfiable) response.

Section 4.4:

The 416 (Range Not Satisfiable) status code indicates that none of the ranges in the request's Range header field (Section 3.1) overlap the current extent of the selected resource or that the set of ranges requested has been rejected due to invalid ranges or an excessive request of small or overlapping ranges.

For byte ranges, failing to overlap the current extent means that the first-byte-pos of all of the byte-range-spec values were greater than the current length of the selected representation. When this status code is generated in response to a byte-range request, the sender SHOULD generate a Content-Range header field specifying the current length of the selected representation (Section 4.2).

I think the argument being made here is that returning the entire file may be "friendlier" than returning a 416, and I could probably go along with that. I'll put it in a separate commit within this PR so we can revert it if you disagree.

serve_range(encoding, range, segments, options)
end
defp serve_range(_conn, _path, _segments, _range, _options) do
raise InvalidRangeError
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is raising the reasonable thing to do here? You said we don't support all syntaxes, so maybe we should rather send the whole file instead of crashing.

@scouten
Copy link
Contributor Author

scouten commented Jun 12, 2017

@josevalim @ericmj this feedback all seems reasonable. I'll work on this over the next couple of weeks and send you a ping when I think it's ready for re-review.

@scouten
Copy link
Contributor Author

scouten commented Jun 12, 2017

@scouten
Copy link
Contributor Author

scouten commented Jun 12, 2017

@josevalim @ericmj Ping. I think this is ready for re-review.


conn
|> put_resp_header("content-type", content_type)
|> put_resp_header("accept-ranges", "bytes")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This header should be set for all responses from Plug.Static, no?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, do we not support caching or compression for range requests?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ericmj I'm open to working up a variant that supports caching, but I don't think I have the time or knowledge to properly implement compression in this context. Given that the primary use case that I'm familiar with (video playback) applies to content that is already compressed, I'm not sure that adding more compression makes sense.

file_info(size: file_size) = file_info

parsed_range = range
|> parse_range
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add parenthesis to all function calls.

@josevalim
Copy link
Member

Also paging @TheSquad which implemented a plug that does this.

@scouten
Copy link
Contributor Author

scouten commented Jun 14, 2017

FYI this is derived from @TheSquad's plug, but ties in here so as to provide the same guarantees about access, etc., that the rest of plug_static offers.

I'll work on addressing the new feedback from @ericmj in the next couple of days.

@scouten
Copy link
Contributor Author

scouten commented Jun 15, 2017

OK, I've added parens and accept-ranges header as requested.

Still looking into caching implementation.

@elixir-plug elixir-plug deleted a comment from sourcelevel-bot bot Jun 15, 2017
@elixir-plug elixir-plug deleted a comment from sourcelevel-bot bot Jun 15, 2017
defp check_bounds({range_start, range_end}, _file_size), do: {range_start, range_end}

defp to_integer(str) do
{num, _} = Integer.parse(str)
Copy link
Member

@josevalim josevalim Jun 15, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can return :error and that would cause an exception. I would attempt to write it like this:

defp start_and_end("-" <> rest, file_size) do
  case Integer.parse(rest) do
    {last, ""} -> {file_size - last, file_size - 1}
    _ -> :error
  end
end
defp start_and_end(range, file_size) do
  case Integer.parse(range) do
    {first, "-"} ->
      {first, file_size - 1}
    {first, "-" <> rest} ->
      case Integer.parse(rest) do
       {last, ""} -> {first, last}
        _ -> :error
      end
    _ ->
      :error
  end
end

and then in serve_range you could drive through all of those decisions:

  with %{"bytes" => bytes} <- Plug.Conn.Utils.params(range),
       {first, last} <- start_and_end(bytes, file_size),
       :ok <- check_bounds(first, last) do
    send_range(conn, path, range_start, range_end, file_size, segments, options)
  else
    _ -> serve_static({:ok, conn, file_info, path}, segments, options)
  end

Note it uses a check_bounds implementation that returns :ok or :error.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can probably add a test that makes sure it doesn't crash if someone sends a bad range such as 00-FF.

@scouten
Copy link
Contributor Author

scouten commented Jun 17, 2017

@josevalim @ericmj Ping. Ready for re-review. There was a fairly substantial refactor since prior versions to make caching work properly with range requests.

@ericmj ericmj merged commit 01df787 into elixir-plug:master Jun 18, 2017
@ericmj
Copy link
Member

ericmj commented Jun 18, 2017

Thank you @scouten! This is a great addition.

@scouten
Copy link
Contributor Author

scouten commented Jun 18, 2017

@josevalim @ericmj thanks for your patient reviews and thanks for accepting this contribution!

@scouten scouten deleted the range-request branch June 18, 2017 21:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants