Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
* Static files: serve an ETag and a Last-Modified header, and answer
conditional requests (If-None-Match / If-Modified-Since) with 304 Not
Modified (RFC 7232).
* Static files: support single byte-range requests. Responses advertise
Accept-Ranges, a satisfiable Range yields 206 Partial Content with a
Content-Range header (honouring If-Range), and an unsatisfiable range gives
416 (RFC 7233). This enables media streaming and resumable downloads.
* Documentation: the manual now lives in the main branch as odoc pages
(doc/*.mld), compiled in place by `dune build @doc`, replacing the
wikicreole manual of the wikidoc branch. doc/index.mld is the package
Expand Down
5 changes: 5 additions & 0 deletions doc/staticmod.mld
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ and size) and a [Last-Modified] header. Conditional requests made with
[If-None-Match] or [If-Modified-Since] are answered with [304 Not Modified]
when the file is unchanged (RFC 7232), saving bandwidth on revalidation.

Single byte-range requests are supported (RFC 7233): responses advertise
[Accept-Ranges: bytes], a [Range] request yields [206 Partial Content] with a
[Content-Range] header (and honours [If-Range]), enabling media streaming and
resumable downloads.

{1 Using as a library}
To use that extension as a library for your OCaml programs,
load OCamlfind package [ocsigenserver.ext.staticmod],
Expand Down
2 changes: 2 additions & 0 deletions src/extensions/staticmod.ml
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ let gen ~usermode ?cache dir = function
?if_none_match:(header Ocsigen_http.Header.Name.if_none_match)
?if_modified_since:
(header Ocsigen_http.Header.Name.if_modified_since)
?range:(header Ocsigen_http.Header.Name.range)
?if_range:(header Ocsigen_http.Header.Name.if_range)
fname
| Ocsigen.Local_files.RDir dname ->
respond_dir pathstring dname >>= fun answer ->
Expand Down
153 changes: 118 additions & 35 deletions src/server/Ocsigen/response.ml
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,39 @@ let if_none_match_matches if_none_match etag =
(fun t -> String.equal (String.trim t) etag)
(String.split_on_char ',' if_none_match)

(* Parse a single byte-range request against the total [size] (RFC 7233).
Returns the inclusive bounds [`Range (first, last)], [`Full] when there is no
usable single range (absent, malformed or multi-range), or [`Unsatisfiable]. *)
let parse_range size value =
match String.split_on_char '=' (String.trim value) with
| ["bytes"; spec] when not (String.contains spec ',') -> (
match String.split_on_char '-' (String.trim spec) with
| [first; last] -> (
let parse s =
let s = String.trim s in
if String.equal s "" then None else int_of_string_opt s
in
match parse first, parse last with
| None, None -> `Full
| Some first, None ->
if first < size then `Range (first, size - 1) else `Unsatisfiable
| None, Some suffix ->
if suffix <= 0 || size = 0
then `Unsatisfiable
else `Range (max 0 (size - suffix), size - 1)
| Some first, Some last ->
let last = min last (size - 1) in
if first <= last then `Range (first, last) else `Unsatisfiable)
| _ -> `Full)
| _ -> `Full

let respond_file
?headers
?(status = `OK)
?if_none_match
?if_modified_since
?range
?if_range
fname
=
let exception Isnt_a_file in
Expand All @@ -88,11 +116,10 @@ let respond_file
if Unix.(s.st_kind <> S_REG)
then raise Isnt_a_file
else
let size = s.Unix.st_size in
let last_modified = Ocsigen_base.Lib.Date.to_string s.Unix.st_mtime in
let etag =
weak_etag ~mtime:(int_of_float s.Unix.st_mtime) ~size:s.Unix.st_size
in
(* Validators sent with both [200] and [304] responses (RFC 7232). *)
let etag = weak_etag ~mtime:(int_of_float s.Unix.st_mtime) ~size in
(* Validators sent with [200], [206] and [304] responses (RFC 7232). *)
let add_validators h =
Http.Header.add
(Http.Header.add h "etag" etag)
Expand All @@ -115,40 +142,96 @@ let respond_file
Lwt.return
(respond ~headers:(add_validators headers) ~status:`Not_modified ())
else
let count = 16384 in
let* ic =
Lwt_io.open_file ~buffer:(Lwt_bytes.create count)
~mode:Lwt_io.input fname
(* A [Range] request is honoured only if [If-Range] (when present)
matches the current validators (RFC 7233). *)
let if_range_ok =
match if_range with
| None -> true
| Some v ->
let v = String.trim v in
String.equal v etag || String.equal v last_modified
in
let* len = Lwt_io.length ic in
let encoding = Http.Transfer.Fixed len in
let stream write =
let rec cat_loop () =
Lwt.bind (Lwt_io.read ~count ic) (function
| "" -> Lwt.return_unit
| buf -> Lwt.bind (write buf) cat_loop)
in
let* () =
Lwt.catch cat_loop (fun exn ->
Logs.warn (fun m ->
m "Error resolving file %s (%s)" fname
(Printexc.to_string exn));
Lwt.return_unit)
in
Lwt.catch
(fun () -> Lwt_io.close ic)
(fun e ->
Logs.warn (fun f ->
f "Closing channel failed: %s" (Printexc.to_string e));
Lwt.return_unit)
let range =
match range with
| Some r when if_range_ok -> parse_range size r
| _ -> `Full
in
let body = Body.make encoding stream in
let mime_type = Magic_mime.lookup fname in
let headers =
add_validators
(Http.Header.add_unless_exists headers "content-type" mime_type)
let add_common h =
add_validators (Http.Header.add h "accept-ranges" "bytes")
in
Lwt.return (respond ~headers ~status ~body ()))
match range with
| `Unsatisfiable ->
let headers =
Http.Header.add (add_common headers) "content-range"
(Printf.sprintf "bytes */%d" size)
in
Lwt.return
(respond ~headers ~status:`Requested_range_not_satisfiable ())
| (`Full | `Range _) as range ->
let first, length, status, add_content_range =
match range with
| `Full -> 0, size, status, fun h -> h
| `Range (first, last) ->
( first
, last - first + 1
, `Partial_content
, fun h ->
Http.Header.add h "content-range"
(Printf.sprintf "bytes %d-%d/%d" first last size) )
in
let count = 16384 in
let* ic =
Lwt_io.open_file ~buffer:(Lwt_bytes.create count)
~mode:Lwt_io.input fname
in
let encoding = Http.Transfer.Fixed (Int64.of_int length) in
let stream write =
let send () =
let* () =
if first = 0
then Lwt.return_unit
else Lwt_io.set_position ic (Int64.of_int first)
in
let remaining = ref length in
let rec cat_loop () =
if !remaining <= 0
then Lwt.return_unit
else
let* buf =
Lwt_io.read ~count:(min count !remaining) ic
in
if String.equal buf ""
then Lwt.return_unit
else (
remaining := !remaining - String.length buf;
let* () = write buf in
cat_loop ())
in
cat_loop ()
in
let* () =
Lwt.catch send (fun exn ->
Logs.warn (fun m ->
m "Error resolving file %s (%s)" fname
(Printexc.to_string exn));
Lwt.return_unit)
in
Lwt.catch
(fun () -> Lwt_io.close ic)
(fun e ->
Logs.warn (fun f ->
f "Closing channel failed: %s" (Printexc.to_string e));
Lwt.return_unit)
in
let body = Body.make encoding stream in
let mime_type = Magic_mime.lookup fname in
let headers =
add_content_range
(add_common
(Http.Header.add_unless_exists headers "content-type"
mime_type))
in
Lwt.return (respond ~headers ~status ~body ()))
(function
| Unix.Unix_error (Unix.ENOENT, _, _) | Isnt_a_file ->
Lwt.return (respond_not_found ())
Expand Down
13 changes: 10 additions & 3 deletions src/server/Ocsigen/response.mli
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,20 @@ val respond_file :
-> ?status:Http.Status.t
-> ?if_none_match:string
-> ?if_modified_since:string
-> ?range:string
-> ?if_range:string
-> string
-> t Lwt.t
(** Respond with the content of a file. The content type is guessed using
[Magic_mime]. An [ETag] (weak, derived from the file's modification time and
size) and a [Last-Modified] header are always added. If the request's
[If-None-Match] or [If-Modified-Since] header is passed and matches, the
response is [304 Not Modified] with an empty body (RFC 7232). *)
size), a [Last-Modified] and an [Accept-Ranges: bytes] header are added. If
the request's [If-None-Match] or [If-Modified-Since] header is passed and
matches, the response is [304 Not Modified] with an empty body (RFC 7232).
If a [range] (the [Range] header) is passed and satisfiable, a single byte
range is returned as [206 Partial Content] with a [Content-Range] header
(RFC 7233); an unsatisfiable range gives [416]. A passed [if_range] (the
[If-Range] header) must match the current validators for the range to be
honoured. *)

val update :
?response:Cohttp.Response.t
Expand Down
51 changes: 51 additions & 0 deletions test/range-requests.t/run.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
Static files support single byte-range requests (RFC 7233).

$ mkdir www
$ printf '0123456789ABCDEF' > www/data.txt
$ ocsigenserver --serve www --port 8066 >server.log 2>&1 &
$ SERVER_PID=$!
$ trap 'kill $SERVER_PID 2>/dev/null' EXIT

A full response advertises range support:

$ curl -s -D headers -o /dev/null --retry 20 --retry-delay 1 \
> --retry-connrefused http://127.0.0.1:8066/data.txt
$ grep -io '^accept-ranges: bytes' headers
accept-ranges: bytes

A byte range returns 206 with the right Content-Range and only those bytes:

$ curl -s -D h -o body -w '%{http_code}\n' -H 'Range: bytes=0-3' \
> http://127.0.0.1:8066/data.txt
206
$ grep -i '^content-range:' h | tr -d '\r'
content-range: bytes 0-3/16
$ cat body; echo
0123

A suffix range counts from the end:

$ curl -s -o body -w '%{http_code}\n' -H 'Range: bytes=-4' \
> http://127.0.0.1:8066/data.txt
206
$ cat body; echo
CDEF

An open-ended range runs to the last byte:

$ curl -s -o body -H 'Range: bytes=10-' http://127.0.0.1:8066/data.txt
ABCDEF (no-eol)

An unsatisfiable range gives 416:

$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Range: bytes=100-200' \
> http://127.0.0.1:8066/data.txt
416

When If-Range does not match the current ETag, the whole file is served:

$ curl -s -o body -w '%{http_code}\n' -H 'Range: bytes=0-3' \
> -H 'If-Range: "stale"' http://127.0.0.1:8066/data.txt
200
$ cat body; echo
0123456789ABCDEF
Loading