aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--doc/src/guide/static_files.asciidoc4
-rw-r--r--src/cowboy.erl2
-rw-r--r--src/cowboy_http.erl12
-rw-r--r--src/cowboy_http2.erl49
-rw-r--r--src/cowboy_rest.erl31
-rw-r--r--src/cowboy_router.erl25
-rw-r--r--src/cowboy_static.erl19
-rw-r--r--src/cowboy_stream_h.erl66
-rw-r--r--test/cowboy_test.erl19
-rw-r--r--test/static_handler_SUITE.erl761
10 files changed, 950 insertions, 38 deletions
diff --git a/doc/src/guide/static_files.asciidoc b/doc/src/guide/static_files.asciidoc
index 39197a8..ef27119 100644
--- a/doc/src/guide/static_files.asciidoc
+++ b/doc/src/guide/static_files.asciidoc
@@ -124,8 +124,8 @@ a binary string is also allowed (but will require extra
processing). If the function can't figure out the mimetype,
then it should return `{<<"application">>, <<"octet-stream">>, []}`.
-When the static handler fails to find the extension in the
-list, it will send the file as `application/octet-stream`.
+When the static handler fails to find the extension,
+it will send the file as `application/octet-stream`.
A browser receiving such file will attempt to download it
directly to disk.
diff --git a/src/cowboy.erl b/src/cowboy.erl
index d9c14ea..3387224 100644
--- a/src/cowboy.erl
+++ b/src/cowboy.erl
@@ -44,7 +44,7 @@
cowboy_protocol:opts()) -> {ok, pid()} | {error, any()}.
start_clear(Ref, NbAcceptors, TransOpts0, ProtoOpts)
when is_integer(NbAcceptors), NbAcceptors > 0 ->
- TransOpts = TransOpts0,%[connection_type(ProtoOpts)|TransOpts0],
+ TransOpts = [connection_type(ProtoOpts)|TransOpts0],
ranch:start_listener(Ref, NbAcceptors, ranch_tcp, TransOpts, cowboy_clear, ProtoOpts).
-spec start_tls(ranch:ref(), non_neg_integer(), ranch_ssl:opts(), opts()) -> {ok, pid()} | {error, any()}.
diff --git a/src/cowboy_http.erl b/src/cowboy_http.erl
index 4608839..e93045d 100644
--- a/src/cowboy_http.erl
+++ b/src/cowboy_http.erl
@@ -900,15 +900,15 @@ maybe_terminate(State=#state{last_streamid=StreamID}, StreamID, _Tail, fin) ->
maybe_terminate(State, StreamID, _Tail, fin) ->
stream_terminate(State, StreamID, normal).
-stream_reset(State=#state{socket=Socket, transport=Transport}, StreamID,
- StreamError={internal_error, _, _}) ->
+stream_reset(State, StreamID, StreamError={internal_error, _, _}) ->
%% @todo headers
%% @todo Don't send this if there are no streams left.
- Transport:send(Socket, cow_http:response(500, 'HTTP/1.1', [
- {<<"content-length">>, <<"0">>}
- ])),
+% Transport:send(Socket, cow_http:response(500, 'HTTP/1.1', [
+% {<<"content-length">>, <<"0">>}
+% ])),
%% @todo update IsFin local
- stream_terminate(State#state{out_state=done}, StreamID, StreamError).
+% stream_terminate(State#state{out_state=done}, StreamID, StreamError).
+ stream_terminate(State, StreamID, StreamError).
stream_terminate(State=#state{socket=Socket, transport=Transport, handler=Handler,
out_streamid=OutStreamID, out_state=OutState,
diff --git a/src/cowboy_http2.erl b/src/cowboy_http2.erl
index c5c7e0c..94581ac 100644
--- a/src/cowboy_http2.erl
+++ b/src/cowboy_http2.erl
@@ -365,18 +365,50 @@ commands(State=#state{socket=Socket, transport=Transport, encode_state=EncodeSta
[{response, StatusCode, Headers0, Body}|Tail]) ->
Headers = Headers0#{<<":status">> => integer_to_binary(StatusCode)},
{HeaderBlock, EncodeState} = headers_encode(Headers, EncodeState0),
- Transport:send(Socket, [
- cow_http2:headers(StreamID, nofin, HeaderBlock),
- cow_http2:data(StreamID, fin, Body)
- ]),
- commands(State#state{encode_state=EncodeState}, StreamID, Tail);
+ Response = cow_http2:headers(StreamID, nofin, HeaderBlock),
+ case Body of
+ {sendfile, O, B, P} ->
+ Transport:send(Socket, Response),
+ commands(State#state{encode_state=EncodeState}, StreamID,
+ [{sendfile, fin, O, B, P}|Tail]);
+ _ ->
+ Transport:send(Socket, [
+ Response,
+ cow_http2:data(StreamID, fin, Body)
+ ]),
+ commands(State#state{encode_state=EncodeState}, StreamID, Tail)
+ end;
%% Send a response body chunk.
%%
%% @todo WINDOW_UPDATE stuff require us to buffer some data.
+%%
+%% When the body is sent using sendfile, the current solution is not
+%% very good. The body could be too large, blocking the connection.
+%% Also sendfile technically only works over TCP, so it's not that
+%% useful for HTTP/2. At the very least the sendfile call should be
+%% split into multiple calls and flow control should be used to make
+%% sure we only send as fast as the client can receive and don't block
+%% anything.
commands(State=#state{socket=Socket, transport=Transport}, StreamID,
[{data, IsFin, Data}|Tail]) ->
Transport:send(Socket, cow_http2:data(StreamID, IsFin, Data)),
commands(State, StreamID, Tail);
+%% Send a file.
+%%
+%% @todo This implementation is terrible. A good implementation would
+%% need to check that Bytes is exact (or we need to document that we
+%% trust it to be exact), and would need to send the file asynchronously
+%% in many data frames. Perhaps a sendfile call should result in a
+%% process being created specifically for this purpose. Or perhaps
+%% the protocol should be "dumb" and the stream handler be the one
+%% to ensure the file is sent in chunks (which would require a better
+%% flow control at the stream handler level). One thing for sure, the
+%% implementation necessarily varies between HTTP/1.1 and HTTP/2.
+commands(State=#state{socket=Socket, transport=Transport}, StreamID,
+ [{sendfile, IsFin, Offset, Bytes, Path}|Tail]) ->
+ Transport:send(Socket, cow_http2:data_header(StreamID, IsFin, Bytes)),
+ Transport:sendfile(Socket, Path, Offset, Bytes),
+ commands(State, StreamID, Tail);
%% Send a push promise.
%%
%% @todo We need to keep track of what promises we made so that we don't
@@ -400,6 +432,10 @@ commands(State, StreamID, [{flow, _Size}|Tail]) ->
%% Supervise a child process.
commands(State=#state{children=Children}, StreamID, [{spawn, Pid, _Shutdown}|Tail]) -> %% @todo Shutdown
commands(State#state{children=[{Pid, StreamID}|Children]}, StreamID, Tail);
+%% Error handling.
+commands(State, StreamID, [Error = {internal_error, _, _}|Tail]) ->
+ %% @todo Only reset when the stream still exists.
+ commands(stream_reset(State, StreamID, Error), StreamID, Tail);
%% Upgrade to a new protocol.
%%
%% @todo Implementation.
@@ -447,8 +483,7 @@ stream_init(State0=#state{ref=Ref, socket=Socket, transport=Transport, decode_st
Host = Authority, %% @todo
Port = todo, %% @todo
- Path = PathWithQs, %% @todo
- Qs = todo, %% @todo
+ {Path, Qs} = cow_http:parse_fullpath(PathWithQs),
Req = #{
ref => Ref,
diff --git a/src/cowboy_rest.erl b/src/cowboy_rest.erl
index 914b273..55b4e22 100644
--- a/src/cowboy_rest.erl
+++ b/src/cowboy_rest.erl
@@ -198,7 +198,6 @@
%% End of REST callbacks. Whew!
-record(state, {
- env :: cowboy_middleware:env(),
method = undefined :: binary(),
%% Handler.
@@ -235,10 +234,11 @@
-spec upgrade(Req, Env, module(), any(), infinity, run)
-> {ok, Req, Env} when Req::cowboy_req:req(), Env::cowboy_middleware:env().
-upgrade(Req, Env, Handler, HandlerState, infinity, run) ->
- Method = cowboy_req:method(Req),
- service_available(Req, #state{env=Env, method=Method,
- handler=Handler, handler_state=HandlerState}).
+upgrade(Req0, Env, Handler, HandlerState, infinity, run) ->
+ Method = cowboy_req:method(Req0),
+ {ok, Req, Result} = service_available(Req0, #state{method=Method,
+ handler=Handler, handler_state=HandlerState}),
+ {ok, Req, [{result, Result}|Env]}.
service_available(Req, State) ->
expect(Req, State, service_available, true, fun known_methods/2, 503).
@@ -683,9 +683,12 @@ if_match_exists(Req, State) ->
if_match(Req, State, EtagsList) ->
try generate_etag(Req, State) of
+ %% Strong Etag comparison: weak Etag never matches.
+ {{weak, _}, Req2, State2} ->
+ precondition_failed(Req2, State2);
{Etag, Req2, State2} ->
case lists:member(Etag, EtagsList) of
- true -> if_unmodified_since_exists(Req2, State2);
+ true -> if_none_match_exists(Req2, State2);
%% Etag may be `undefined' which cannot be a member.
false -> precondition_failed(Req2, State2)
end
@@ -738,15 +741,23 @@ if_none_match(Req, State, EtagsList) ->
undefined ->
precondition_failed(Req2, State2);
Etag ->
- case lists:member(Etag, EtagsList) of
+ case is_weak_match(Etag, EtagsList) of
true -> precondition_is_head_get(Req2, State2);
- false -> if_modified_since_exists(Req2, State2)
+ false -> method(Req2, State2)
end
end
catch Class:Reason ->
error_terminate(Req, State, Class, Reason, generate_etag)
end.
+%% Weak Etag comparison: only check the opaque tag.
+is_weak_match(_, []) ->
+ false;
+is_weak_match({_, Tag}, [{_, Tag}|_]) ->
+ true;
+is_weak_match(Etag, [_|Tail]) ->
+ is_weak_match(Etag, Tail).
+
precondition_is_head_get(Req, State=#state{method=Method})
when Method =:= <<"HEAD">>; Method =:= <<"GET">> ->
not_modified(Req, State);
@@ -1156,6 +1167,6 @@ error_terminate(Req, #state{handler=Handler, handler_state=HandlerState},
{state, HandlerState}
]}).
-terminate(Req, #state{env=Env, handler=Handler, handler_state=HandlerState}) ->
+terminate(Req, #state{handler=Handler, handler_state=HandlerState}) ->
Result = cowboy_handler:terminate(normal, Req, HandlerState, Handler),
- {ok, Req, [{result, Result}|Env]}.
+ {ok, Req, Result}.
diff --git a/src/cowboy_router.erl b/src/cowboy_router.erl
index f07b307..f2f8fb4 100644
--- a/src/cowboy_router.erl
+++ b/src/cowboy_router.erl
@@ -322,9 +322,9 @@ split_path(Path, Acc) ->
try
case binary:match(Path, <<"/">>) of
nomatch when Path =:= <<>> ->
- lists:reverse([cow_qs:urldecode(S) || S <- Acc]);
+ remove_dot_segments(lists:reverse([cow_uri:urldecode(S) || S <- Acc]), []);
nomatch ->
- lists:reverse([cow_qs:urldecode(S) || S <- [Path|Acc]]);
+ remove_dot_segments(lists:reverse([cow_uri:urldecode(S) || S <- [Path|Acc]]), []);
{Pos, _} ->
<< Segment:Pos/binary, _:8, Rest/bits >> = Path,
split_path(Rest, [Segment|Acc])
@@ -334,6 +334,27 @@ split_path(Path, Acc) ->
badrequest
end.
+remove_dot_segments([], Acc) ->
+ lists:reverse(Acc);
+remove_dot_segments([<<".">>|Segments], Acc) ->
+ remove_dot_segments(Segments, Acc);
+remove_dot_segments([<<"..">>|Segments], Acc=[]) ->
+ remove_dot_segments(Segments, Acc);
+remove_dot_segments([<<"..">>|Segments], [_|Acc]) ->
+ remove_dot_segments(Segments, Acc);
+remove_dot_segments([S|Segments], Acc) ->
+ remove_dot_segments(Segments, [S|Acc]).
+
+-ifdef(TEST).
+remove_dot_segments_test_() ->
+ Tests = [
+ {[<<"a">>, <<"b">>, <<"c">>, <<".">>, <<"..">>, <<"..">>, <<"g">>], [<<"a">>, <<"g">>]},
+ {[<<"mid">>, <<"content=5">>, <<"..">>, <<"6">>], [<<"mid">>, <<"6">>]},
+ {[<<"..">>, <<"a">>], [<<"a">>]}
+ ],
+ [fun() -> R = remove_dot_segments(S, []) end || {S, R} <- Tests].
+-endif.
+
-spec list_match(tokens(), dispatch_match(), bindings())
-> {true, bindings(), undefined | tokens()} | false.
%% Atom '...' matches any trailing path, stop right now.
diff --git a/src/cowboy_static.erl b/src/cowboy_static.erl
index c771771..d13db62 100644
--- a/src/cowboy_static.erl
+++ b/src/cowboy_static.erl
@@ -81,15 +81,32 @@ init_dir(Req, Path, Extra) when is_list(Path) ->
init_dir(Req, Path, Extra) ->
Dir = fullpath(filename:absname(Path)),
PathInfo = cowboy_req:path_info(Req),
- Filepath = filename:join([Dir|PathInfo]),
+ Filepath = filename:join([Dir|[escape_reserved(P, <<>>) || P <- PathInfo]]),
Len = byte_size(Dir),
case fullpath(Filepath) of
<< Dir:Len/binary, $/, _/binary >> ->
init_info(Req, Filepath, Extra);
+ << Dir:Len/binary >> ->
+ init_info(Req, Filepath, Extra);
_ ->
{cowboy_rest, Req, error}
end.
+%% We escape the slash found in path segments because
+%% a segment corresponds to a directory entry, and
+%% therefore those slashes are expected to be part of
+%% the directory name.
+%%
+%% Note that on most systems the slash is prohibited
+%% and cannot appear in filenames, which means the
+%% requested file will end up being not found.
+escape_reserved(<<>>, Acc) ->
+ Acc;
+escape_reserved(<< $/, Rest/bits >>, Acc) ->
+ escape_reserved(Rest, << Acc/binary, $\\, $/ >>);
+escape_reserved(<< C, Rest/bits >>, Acc) ->
+ escape_reserved(Rest, << Acc/binary, C >>).
+
fullpath(Path) ->
fullpath(filename:split(Path), []).
fullpath([], Acc) ->
diff --git a/src/cowboy_stream_h.erl b/src/cowboy_stream_h.erl
index f924e28..b834c17 100644
--- a/src/cowboy_stream_h.erl
+++ b/src/cowboy_stream_h.erl
@@ -21,10 +21,12 @@
-export([info/3]).
-export([terminate/3]).
+-export([proc_lib_hack/3]).
-export([execute/3]).
-export([resume/5]).
-record(state, {
+ ref = undefined :: ranch:ref(),
pid = undefined :: pid(),
read_body_ref = undefined :: reference(),
read_body_length = 0 :: non_neg_integer(),
@@ -38,12 +40,12 @@
%% @todo proper specs
-spec init(_,_,_) -> _.
-init(_StreamID, Req, Opts) ->
+init(_StreamID, Req=#{ref := Ref}, Opts) ->
Env = maps:get(env, Opts, #{}),
Middlewares = maps:get(middlewares, Opts, [cowboy_router, cowboy_handler]),
Shutdown = maps:get(shutdown, Opts, 5000),
- Pid = proc_lib:spawn_link(?MODULE, execute, [Req, Env, Middlewares]),
- {[{spawn, Pid, Shutdown}], #state{pid=Pid}}.
+ Pid = proc_lib:spawn_link(?MODULE, proc_lib_hack, [Req, Env, Middlewares]),
+ {[{spawn, Pid, Shutdown}], #state{ref=Ref, pid=Pid}}.
%% If we receive data and stream is waiting for data:
%% If we accumulated enough data or IsFin=fin, send it.
@@ -64,8 +66,29 @@ data(_StreamID, IsFin, Data, State=#state{pid=Pid, read_body_ref=Ref, read_body_
-spec info(_,_,_) -> _.
info(_StreamID, {'EXIT', Pid, normal}, State=#state{pid=Pid}) ->
{[stop], State};
-info(_StreamID, Reason = {'EXIT', Pid, _}, State=#state{pid=Pid}) ->
- {[{internal_error, Reason, 'Stream process crashed.'}], State};
+%% @todo Transition.
+%% In the future it would be better to simplify things
+%% and only catch this at the stream level.
+%%
+%% Maybe we don't need specific error messages
+%% for every single callbacks anymore?
+info(_StreamID, Exit = {'EXIT', Pid, {cowboy_handler, _}}, State=#state{pid=Pid}) ->
+ %% No crash report; one has already been sent.
+ {[
+ {response, 500, #{<<"content-length">> => <<"0">>}, <<>>},
+ {internal_error, Exit, 'Stream process crashed.'}
+ ], State};
+info(_StreamID, {'EXIT', Pid, {_Reason, [_, {cow_http_hd, _, _, _}|_]}}, State=#state{pid=Pid}) ->
+ %% @todo Have an option to enable/disable this specific crash report?
+ %%report_crash(Ref, StreamID, Pid, Reason, Stacktrace),
+ %% @todo Headers? Details in body? More stuff in debug only?
+ {[{response, 400, #{}, <<>>}, stop], State};
+info(StreamID, Exit = {'EXIT', Pid, {Reason, Stacktrace}}, State=#state{ref=Ref, pid=Pid}) ->
+ report_crash(Ref, StreamID, Pid, Reason, Stacktrace),
+ {[
+ {response, 500, #{<<"content-length">> => <<"0">>}, <<>>},
+ {internal_error, Exit, 'Stream process crashed.'}
+ ], State};
%% Request body, no body buffer but IsFin=fin.
info(_StreamID, {read_body, Ref, _}, State=#state{pid=Pid, read_body_is_fin=fin, read_body_buffer= <<>>}) ->
Pid ! {request_body, Ref, fin, <<>>},
@@ -89,6 +112,7 @@ info(_StreamID, SwitchProtocol = {switch_protocol, _, _, _}, State) ->
{[SwitchProtocol], State};
%% Stray message.
info(_StreamID, _Msg, State) ->
+ %% @todo Error report.
%% @todo Cleanup if no reply was sent when stream ends.
{[], State}.
@@ -97,8 +121,40 @@ info(_StreamID, _Msg, State) ->
terminate(_StreamID, _Reason, _State) ->
ok.
+%% We use ~999999p here instead of ~w because the latter doesn't
+%% support printable strings.
+report_crash(_, _, _, normal, _) ->
+ ok;
+report_crash(_, _, _, shutdown, _) ->
+ ok;
+report_crash(_, _, _, {shutdown, _}, _) ->
+ ok;
+report_crash(Ref, StreamID, Pid, Reason, Stacktrace) ->
+ error_logger:error_msg(
+ "Ranch listener ~p, connection process ~p, stream ~p "
+ "had its request process ~p exit with reason "
+ "~999999p and stacktrace ~999999p~n",
+ [Ref, self(), StreamID, Pid, Reason, Stacktrace]).
+
%% Request process.
+%% This hack is necessary because proc_lib does not propagate
+%% stacktraces by default. This is ugly because we end up
+%% having two try/catch instead of one (the one in proc_lib),
+%% just to add the stacktrace information.
+%%
+%% @todo Remove whenever proc_lib propagates stacktraces.
+-spec proc_lib_hack(_, _, _) -> _.
+proc_lib_hack(Req, Env, Middlewares) ->
+ try
+ execute(Req, Env, Middlewares)
+ catch
+ _:Reason when element(1, Reason) =:= cowboy_handler ->
+ exit(Reason);
+ _:Reason ->
+ exit({Reason, erlang:get_stacktrace()})
+ end.
+
%% @todo
%-spec execute(cowboy_req:req(), #state{}, cowboy_middleware:env(), [module()])
% -> ok.
diff --git a/test/cowboy_test.erl b/test/cowboy_test.erl
index 44ffdf8..5b223d1 100644
--- a/test/cowboy_test.erl
+++ b/test/cowboy_test.erl
@@ -42,22 +42,33 @@ common_all() ->
[
{group, http},
{group, https},
- {group, http_compress},
- {group, https_compress}
+ {group, h2},
+ {group, h2c}%,
+%% @todo
+% {group, http_compress},
+% {group, https_compress}
].
common_groups(Tests) ->
[
{http, [parallel], Tests},
{https, [parallel], Tests},
- {http_compress, [parallel], Tests},
- {https_compress, [parallel], Tests}
+ {h2, [parallel], Tests},
+ {h2c, [parallel], Tests}%,
+%% @todo
+% {http_compress, [parallel], Tests},
+% {https_compress, [parallel], Tests}
].
init_common_groups(Name = http, Config, Mod) ->
init_http(Name, #{env => #{dispatch => Mod:init_dispatch(Config)}}, Config);
init_common_groups(Name = https, Config, Mod) ->
init_https(Name, #{env => #{dispatch => Mod:init_dispatch(Config)}}, Config);
+init_common_groups(Name = h2, Config, Mod) ->
+ init_http2(Name, #{env => #{dispatch => Mod:init_dispatch(Config)}}, Config);
+init_common_groups(Name = h2c, Config, Mod) ->
+ Config1 = init_http(Name, #{env => #{dispatch => Mod:init_dispatch(Config)}}, Config),
+ lists:keyreplace(protocol, 1, Config1, {protocol, http2});
init_common_groups(Name = http_compress, Config, Mod) ->
init_http(Name, #{
env => #{dispatch => Mod:init_dispatch(Config)},
diff --git a/test/static_handler_SUITE.erl b/test/static_handler_SUITE.erl
new file mode 100644
index 0000000..fdeae10
--- /dev/null
+++ b/test/static_handler_SUITE.erl
@@ -0,0 +1,761 @@
+%% Copyright (c) 2016, Loïc Hoguin <[email protected]>
+%%
+%% Permission to use, copy, modify, and/or distribute this software for any
+%% purpose with or without fee is hereby granted, provided that the above
+%% copyright notice and this permission notice appear in all copies.
+%%
+%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+-module(static_handler_SUITE).
+-compile(export_all).
+
+-import(ct_helper, [config/2]).
+-import(ct_helper, [doc/1]).
+-import(cowboy_test, [gun_open/1]).
+
+%% ct.
+
+all() ->
+ cowboy_test:common_all().
+
+groups() ->
+ AllTests = ct_helper:all(?MODULE),
+ %% The directory tests are shared between dir and priv_dir options.
+ DirTests = lists:usort([F || {F, 1} <- ?MODULE:module_info(exports),
+ string:substr(atom_to_list(F), 1, 4) =:= "dir_"
+ ]),
+ OtherTests = AllTests -- DirTests,
+ GroupTests = OtherTests ++ [
+ {dir, [parallel], DirTests},
+ {priv_dir, [parallel], DirTests}
+ ],
+ [
+ {http, [parallel], GroupTests},
+ {https, [parallel], GroupTests},
+ {h2, [parallel], GroupTests},
+ {h2c, [parallel], GroupTests}
+ %% @todo With compression enabled.
+ ].
+
+init_per_suite(Config) ->
+ %% @todo When we can chain stream handlers, write one
+ %% to hide these expected errors.
+ ct:print("This test suite will produce error reports. "
+ "The path for these expected errors begins with '/bad' or '/char'."),
+ %% Two static folders are created: one in ct_helper's private directory,
+ %% and one in the test run private directory.
+ PrivDir = code:priv_dir(ct_helper) ++ "/static",
+ StaticDir = config(priv_dir, Config) ++ "/static",
+ ct_helper:create_static_dir(PrivDir),
+ ct_helper:create_static_dir(StaticDir),
+ init_large_file(PrivDir ++ "/large.bin"),
+ init_large_file(StaticDir ++ "/large.bin"),
+ %% A special folder contains files of 1 character from 0 to 127.
+ CharDir = config(priv_dir, Config) ++ "/char",
+ ok = filelib:ensure_dir(CharDir ++ "/file"),
+ Chars = lists:flatten([case file:write_file(CharDir ++ [$/, C], [C]) of
+ ok -> C;
+ {error, _} -> []
+ end || C <- lists:seq(0, 127)]),
+ [{static_dir, StaticDir}, {char_dir, CharDir}, {chars, Chars}|Config].
+
+end_per_suite(Config) ->
+ ct:print("This test suite produced error reports. "
+ "The path for these expected errors begins with '/bad' or '/char'."),
+ %% Special directory.
+ CharDir = config(char_dir, Config),
+ _ = [file:delete(CharDir ++ [$/, C]) || C <- lists:seq(0, 127)],
+ file:del_dir(CharDir),
+ %% Static directories.
+ StaticDir = config(static_dir, Config),
+ PrivDir = code:priv_dir(ct_helper) ++ "/static",
+ ok = file:delete(StaticDir ++ "/large.bin"),
+ ok = file:delete(PrivDir ++ "/large.bin"),
+ ct_helper:delete_static_dir(StaticDir),
+ ct_helper:delete_static_dir(PrivDir).
+
+init_per_group(dir, Config) ->
+ [{prefix, "/dir"}|Config];
+init_per_group(priv_dir, Config) ->
+ [{prefix, "/priv_dir"}|Config];
+init_per_group(tttt, Config) ->
+ Config;
+init_per_group(Name, Config) ->
+ cowboy_test:init_common_groups(Name, Config, ?MODULE).
+
+end_per_group(Name, _) ->
+ cowboy:stop_listener(Name).
+
+%% Large file.
+
+init_large_file(Filename) ->
+ case os:type() of
+ {unix, _} ->
+ "" = os:cmd("truncate -s 512M " ++ Filename),
+ ok;
+ {win32, _} ->
+ ok
+ end.
+
+%% Routes.
+
+init_dispatch(Config) ->
+ cowboy_router:compile([{'_', [
+ {"/priv_dir/[...]", cowboy_static, {priv_dir, ct_helper, "static"}},
+ {"/dir/[...]", cowboy_static, {dir, config(static_dir, Config)}},
+ {"/priv_file/style.css", cowboy_static, {priv_file, ct_helper, "static/style.css"}},
+ {"/file/style.css", cowboy_static, {file, config(static_dir, Config) ++ "/style.css"}},
+ {"/index", cowboy_static, {file, config(static_dir, Config) ++ "/index.html"}},
+ {"/mime/all/[...]", cowboy_static, {priv_dir, ct_helper, "static",
+ [{mimetypes, cow_mimetypes, all}]}},
+ {"/mime/custom/[...]", cowboy_static, {priv_dir, ct_helper, "static",
+ [{mimetypes, ?MODULE, do_mime_custom}]}},
+ {"/mime/crash/[...]", cowboy_static, {priv_dir, ct_helper, "static",
+ [{mimetypes, ?MODULE, do_mime_crash}]}},
+ {"/mime/hardcode/binary-form", cowboy_static, {priv_file, ct_helper, "static/file.cowboy",
+ [{mimetypes, <<"application/vnd.ninenines.cowboy+xml;v=1">>}]}},
+ {"/mime/hardcode/tuple-form", cowboy_static, {priv_file, ct_helper, "static/file.cowboy",
+ [{mimetypes, {<<"application">>, <<"vnd.ninenines.cowboy+xml">>, [{<<"v">>, <<"1">>}]}}]}},
+ {"/etag/custom", cowboy_static, {file, config(static_dir, Config) ++ "/style.css",
+ [{etag, ?MODULE, do_etag_custom}]}},
+ {"/etag/crash", cowboy_static, {file, config(static_dir, Config) ++ "/style.css",
+ [{etag, ?MODULE, do_etag_crash}]}},
+ {"/etag/disable", cowboy_static, {file, config(static_dir, Config) ++ "/style.css",
+ [{etag, false}]}},
+ {"/bad", cowboy_static, bad},
+ {"/bad/priv_dir/app/[...]", cowboy_static, {priv_dir, bad_app, "static"}},
+ {"/bad/priv_dir/no-priv/[...]", cowboy_static, {priv_dir, cowboy, "static"}},
+ {"/bad/priv_dir/path/[...]", cowboy_static, {priv_dir, ct_helper, "bad"}},
+ {"/bad/priv_dir/route", cowboy_static, {priv_dir, ct_helper, "static"}},
+ {"/bad/dir/path/[...]", cowboy_static, {dir, "/bad/path"}},
+ {"/bad/dir/route", cowboy_static, {dir, config(static_dir, Config)}},
+ {"/bad/priv_file/app", cowboy_static, {priv_file, bad_app, "static/style.css"}},
+ {"/bad/priv_file/no-priv", cowboy_static, {priv_file, cowboy, "static/style.css"}},
+ {"/bad/priv_file/path", cowboy_static, {priv_file, ct_helper, "bad/style.css"}},
+ {"/bad/file/path", cowboy_static, {file, "/bad/path/style.css"}},
+ {"/bad/options", cowboy_static, {priv_file, ct_helper, "static/style.css", bad}},
+ {"/bad/options/mime", cowboy_static, {priv_file, ct_helper, "static/style.css", [{mimetypes, bad}]}},
+ {"/bad/options/etag", cowboy_static, {priv_file, ct_helper, "static/style.css", [{etag, true}]}},
+ {"/unknown/option", cowboy_static, {priv_file, ct_helper, "static/style.css", [{bad, option}]}},
+ {"/char/[...]", cowboy_static, {dir, config(char_dir, Config)}}
+ ]}]).
+
+%% Internal functions.
+
+do_etag_crash(_, _, _) ->
+ ct_helper_error_h:ignore(?MODULE, do_etag_crash, 3),
+ exit(crash).
+
+do_etag_custom(_, _, _) ->
+ {strong, <<"etag">>}.
+
+do_mime_crash(_) ->
+ ct_helper_error_h:ignore(?MODULE, do_mime_crash, 1),
+ exit(crash).
+
+do_mime_custom(Path) ->
+ case filename:extension(Path) of
+ <<".cowboy">> -> <<"application/vnd.ninenines.cowboy+xml;v=1">>;
+ <<".txt">> -> <<"text/plain">>;
+ _ -> {<<"application">>, <<"octet-stream">>, []}
+ end.
+
+do_get(Path, Config) ->
+ do_get(Path, [], Config).
+
+do_get(Path, ReqHeaders, Config) ->
+ ConnPid = gun_open(Config),
+ Ref = gun:get(ConnPid, Path, ReqHeaders),
+ {response, IsFin, Status, RespHeaders} = gun:await(ConnPid, Ref),
+ {ok, Body} = case IsFin of
+ nofin -> gun:await_body(ConnPid, Ref);
+ fin -> {ok, <<>>}
+ end,
+ gun:close(ConnPid),
+ {Status, RespHeaders, Body}.
+
+%% Tests.
+
+bad(Config) ->
+ doc("Bad cowboy_static options: not a tuple."),
+ {500, _, _} = do_get("/bad", Config),
+ ok.
+
+bad_dir_path(Config) ->
+ doc("Bad cowboy_static options: wrong path."),
+ {404, _, _} = do_get("/bad/dir/path/style.css", Config),
+ ok.
+
+bad_dir_route(Config) ->
+ doc("Bad cowboy_static options: missing [...] in route."),
+ {500, _, _} = do_get("/bad/dir/route", Config),
+ ok.
+
+bad_file_path(Config) ->
+ doc("Bad cowboy_static options: wrong path."),
+ {404, _, _} = do_get("/bad/file/path", Config),
+ ok.
+
+bad_options(Config) ->
+ doc("Bad cowboy_static extra options: not a list."),
+ {500, _, _} = do_get("/bad/options", Config),
+ ok.
+
+bad_options_etag(Config) ->
+ doc("Bad cowboy_static extra options: invalid etag option."),
+ {500, _, _} = do_get("/bad/options/etag", Config),
+ ok.
+
+bad_options_mime(Config) ->
+ doc("Bad cowboy_static extra options: invalid mimetypes option."),
+ {500, _, _} = do_get("/bad/options/mime", Config),
+ ok.
+
+bad_priv_dir_app(Config) ->
+ doc("Bad cowboy_static options: wrong application name."),
+ {500, _, _} = do_get("/bad/priv_dir/app/style.css", Config),
+ ok.
+
+bad_priv_dir_no_priv(Config) ->
+ doc("Bad cowboy_static options: application has no priv directory."),
+ {404, _, _} = do_get("/bad/priv_dir/no-priv/style.css", Config),
+ ok.
+
+bad_priv_dir_path(Config) ->
+ doc("Bad cowboy_static options: wrong path."),
+ {404, _, _} = do_get("/bad/priv_dir/path/style.css", Config),
+ ok.
+
+bad_priv_dir_route(Config) ->
+ doc("Bad cowboy_static options: missing [...] in route."),
+ {500, _, _} = do_get("/bad/priv_dir/route", Config),
+ ok.
+
+bad_priv_file_app(Config) ->
+ doc("Bad cowboy_static options: wrong application name."),
+ {500, _, _} = do_get("/bad/priv_file/app", Config),
+ ok.
+
+bad_priv_file_no_priv(Config) ->
+ doc("Bad cowboy_static options: application has no priv directory."),
+ {404, _, _} = do_get("/bad/priv_file/no-priv", Config),
+ ok.
+
+bad_priv_file_path(Config) ->
+ doc("Bad cowboy_static options: wrong path."),
+ {404, _, _} = do_get("/bad/priv_file/path", Config),
+ ok.
+
+dir_cowboy(Config) ->
+ doc("Get a .cowboy file."),
+ {200, Headers, <<"File with custom extension.\n">>}
+ = do_get(config(prefix, Config) ++ "/file.cowboy", Config),
+ {_, <<"application/octet-stream">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.
+
+dir_css(Config) ->
+ doc("Get a .css file."),
+ {200, Headers, <<"body{color:red}\n">>}
+ = do_get(config(prefix, Config) ++ "/style.css", Config),
+ {_, <<"text/css">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.
+
+dir_css_urlencoded(Config) ->
+ doc("Get a .css file with the extension dot urlencoded."),
+ {200, Headers, <<"body{color:red}\n">>}
+ = do_get(config(prefix, Config) ++ "/style%2ecss", Config),
+ {_, <<"text/css">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.
+
+dir_dot_file(Config) ->
+ doc("Get a file with extra dot segments in the path."),
+ %% All these are equivalent.
+ {200, _, _} = do_get(config(prefix, Config) ++ "/./style.css", Config),
+ {200, _, _} = do_get(config(prefix, Config) ++ "/././style.css", Config),
+ {200, _, _} = do_get(config(prefix, Config) ++ "/./././style.css", Config),
+ {200, _, _} = do_get("/./priv_dir/style.css", Config),
+ {200, _, _} = do_get("/././priv_dir/style.css", Config),
+ {200, _, _} = do_get("/./././priv_dir/style.css", Config),
+ ok.
+
+dir_dotdot_file(Config) ->
+ doc("Get a file with extra dotdot segments in the path."),
+ %% All these are equivalent.
+ {200, _, _} = do_get("/../priv_dir/style.css", Config),
+ {200, _, _} = do_get("/../../priv_dir/style.css", Config),
+ {200, _, _} = do_get("/../../../priv_dir/style.css", Config),
+ {200, _, _} = do_get(config(prefix, Config) ++ "/../priv_dir/style.css", Config),
+ {200, _, _} = do_get(config(prefix, Config) ++ "/../../priv_dir/style.css", Config),
+ {200, _, _} = do_get(config(prefix, Config) ++ "/../../../priv_dir/style.css", Config),
+ {200, _, _} = do_get("/../priv_dir/../priv_dir/style.css", Config),
+ {200, _, _} = do_get("/../../priv_dir/../../priv_dir/style.css", Config),
+ {200, _, _} = do_get("/../../../priv_dir/../../../priv_dir/style.css", Config),
+ %% Try with non-existing segments, which may correspond to real folders.
+ {200, _, _} = do_get("/anything/../priv_dir/style.css", Config),
+ {200, _, _} = do_get(config(prefix, Config) ++ "/anything/../style.css", Config),
+ {200, _, _} = do_get(config(prefix, Config) ++ "/directory/../style.css", Config),
+ {200, _, _} = do_get(config(prefix, Config) ++ "/static/../style.css", Config),
+ %% Try with segments corresponding to real files. It works because
+ %% URI normalization happens before looking at the filesystem.
+ {200, _, _} = do_get(config(prefix, Config) ++ "/style.css/../style.css", Config),
+ {200, _, _} = do_get(config(prefix, Config) ++ "/style.css/../../priv_dir/style.css", Config),
+ %% Try to fool the server to accept segments corresponding to real folders.
+ {404, _, _} = do_get(config(prefix, Config) ++ "/../static/style.css", Config),
+ {404, _, _} = do_get(config(prefix, Config) ++ "/directory/../../static/style.css", Config),
+ ok.
+
+dir_error_directory(Config) ->
+ doc("Try to get a directory."),
+ {403, _, _} = do_get(config(prefix, Config) ++ "/directory", Config),
+ ok.
+
+dir_error_directory_slash(Config) ->
+ doc("Try to get a directory with an extra slash in the path."),
+ {403, _, _} = do_get(config(prefix, Config) ++ "/directory/", Config),
+ ok.
+
+dir_error_doesnt_exist(Config) ->
+ doc("Try to get a file that does not exist."),
+ {404, _, _} = do_get(config(prefix, Config) ++ "/not.found", Config),
+ ok.
+
+dir_error_dot(Config) ->
+ doc("Try to get a file named '.'."),
+ {403, _, _} = do_get(config(prefix, Config) ++ "/.", Config),
+ ok.
+
+dir_error_dot_urlencoded(Config) ->
+ doc("Try to get a file named '.' percent encoded."),
+ {403, _, _} = do_get(config(prefix, Config) ++ "/%2e", Config),
+ ok.
+
+dir_error_dotdot(Config) ->
+ doc("Try to get a file named '..'."),
+ {404, _, _} = do_get(config(prefix, Config) ++ "/..", Config),
+ ok.
+
+dir_error_dotdot_urlencoded(Config) ->
+ doc("Try to get a file named '..' percent encoded."),
+ {404, _, _} = do_get(config(prefix, Config) ++ "/%2e%2e", Config),
+ ok.
+
+dir_error_empty(Config) ->
+ doc("Try to get the configured directory."),
+ {403, _, _} = do_get(config(prefix, Config) ++ "", Config),
+ ok.
+
+dir_error_slash(Config) ->
+ %% I know the description isn't that good considering / has a meaning in URIs.
+ doc("Try to get a file named '/'."),
+ {403, _, _} = do_get(config(prefix, Config) ++ "//", Config),
+ ok.
+
+dir_error_slash_urlencoded(Config) ->
+ doc("Try to get a file named '/' percent encoded."),
+ {404, _, _} = do_get(config(prefix, Config) ++ "/%2f", Config),
+ ok.
+
+dir_error_slash_urlencoded_dotdot_file(Config) ->
+ doc("Try to use a percent encoded slash to access an existing file."),
+ {200, _, _} = do_get(config(prefix, Config) ++ "/directory/../style.css", Config),
+ {404, _, _} = do_get(config(prefix, Config) ++ "/directory%2f../style.css", Config),
+ ok.
+
+dir_error_unreadable(Config) ->
+ doc("Try to get a file that can't be read."),
+ {403, _, _} = do_get(config(prefix, Config) ++ "/unreadable", Config),
+ ok.
+
+dir_html(Config) ->
+ doc("Get a .html file."),
+ {200, Headers, <<"<html><body>Hello!</body></html>\n">>}
+ = do_get(config(prefix, Config) ++ "/index.html", Config),
+ {_, <<"text/html">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.
+
+%% @todo This test results in a crash dump.
+%dir_large_file(Config) ->
+% doc("Get a large file."),
+% {200, Headers, _} = do_get(config(prefix, Config) ++ "/large.bin", Config),
+% {_, <<"text/html">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+%% @todo Receive body.
+% ok.
+
+dir_text(Config) ->
+ doc("Get a .txt file. The extension is unknown by default."),
+ {200, Headers, <<"Timeless space.\n">>}
+ = do_get(config(prefix, Config) ++ "/plain.txt", Config),
+ {_, <<"application/octet-stream">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.
+
+dir_unknown(Config) ->
+ doc("Get a file with no extension."),
+ {200, Headers, <<"File with no extension.\n">>}
+ = do_get(config(prefix, Config) ++ "/unknown", Config),
+ {_, <<"application/octet-stream">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.
+
+etag_crash(Config) ->
+ doc("Get a file with a crashing etag function."),
+ {500, _, _} = do_get("/etag/crash", Config),
+ ok.
+
+etag_custom(Config) ->
+ doc("Get a file with custom Etag function and make sure it is used."),
+ {200, Headers, _} = do_get("/etag/custom", Config),
+ {_, <<"\"etag\"">>} = lists:keyfind(<<"etag">>, 1, Headers),
+ ok.
+
+etag_default(Config) ->
+ doc("Get a file twice and make sure the Etag matches."),
+ {200, Headers1, _} = do_get("/dir/style.css", Config),
+ {200, Headers2, _} = do_get("/dir/style.css", Config),
+ {_, Etag} = lists:keyfind(<<"etag">>, 1, Headers1),
+ {_, Etag} = lists:keyfind(<<"etag">>, 1, Headers2),
+ ok.
+
+etag_default_change(Config) ->
+ doc("Get a file, modify it, get it again and make sure the Etag doesn't match."),
+ {200, Headers1, _} = do_get("/dir/index.html", Config),
+ {_, Etag1} = lists:keyfind(<<"etag">>, 1, Headers1),
+ ok = file:change_time(config(static_dir, Config) ++ "/index.html",
+ {{config(port, Config), 1, 1}, {1, 1, 1}}),
+ {200, Headers2, _} = do_get("/dir/index.html", Config),
+ {_, Etag2} = lists:keyfind(<<"etag">>, 1, Headers2),
+ true = Etag1 =/= Etag2,
+ ok.
+
+etag_disable(Config) ->
+ doc("Get a file with disabled Etag and make sure no Etag is provided."),
+ {200, Headers, _} = do_get("/etag/disable", Config),
+ false = lists:keyfind(<<"etag">>, 1, Headers),
+ ok.
+
+file(Config) ->
+ doc("Get a file with hardcoded route."),
+ {200, Headers, <<"body{color:red}\n">>} = do_get("/file/style.css", Config),
+ {_, <<"text/css">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.
+
+if_match(Config) ->
+ doc("Get a file with If-Match matching."),
+ {200, _, _} = do_get("/etag/custom", [
+ {<<"if-match">>, <<"\"etag\"">>}
+ ], Config),
+ ok.
+
+if_match_fail(Config) ->
+ doc("Get a file with If-Match not matching."),
+ {412, _, _} = do_get("/etag/custom", [
+ {<<"if-match">>, <<"\"invalid\"">>}
+ ], Config),
+ ok.
+
+if_match_invalid(Config) ->
+ doc("Try to get a file with an invalid If-Match header."),
+ {400, _, _} = do_get("/etag/custom", [
+ {<<"if-match">>, <<"bad input">>}
+ ], Config),
+ ok.
+
+if_match_list(Config) ->
+ doc("Get a file with If-Match matching."),
+ {200, _, _} = do_get("/etag/custom", [
+ {<<"if-match">>, <<"\"invalid\", \"etag\", \"cowboy\"">>}
+ ], Config),
+ ok.
+
+if_match_list_fail(Config) ->
+ doc("Get a file with If-Match not matching."),
+ {412, _, _} = do_get("/etag/custom", [
+ {<<"if-match">>, <<"\"invalid\", W/\"etag\", \"cowboy\"">>}
+ ], Config),
+ ok.
+
+if_match_weak(Config) ->
+ doc("Try to get a file with a weak If-Match header."),
+ {412, _, _} = do_get("/etag/custom", [
+ {<<"if-match">>, <<"W/\"etag\"">>}
+ ], Config),
+ ok.
+
+if_match_wildcard(Config) ->
+ doc("Get a file with a wildcard If-Match."),
+ {200, _, _} = do_get("/etag/custom", [
+ {<<"if-match">>, <<"*">>}
+ ], Config),
+ ok.
+
+if_modified_since(Config) ->
+ doc("Get a file with If-Modified-Since in the past."),
+ {200, _, _} = do_get("/etag/custom", [
+ {<<"if-modified-since">>, <<"Sat, 29 Oct 1994 19:43:31 GMT">>}
+ ], Config),
+ ok.
+
+if_modified_since_fail(Config) ->
+ doc("Get a file with If-Modified-Since equal to file modification time."),
+ LastModified = filelib:last_modified(config(static_dir, Config) ++ "/style.css"),
+ {304, _, _} = do_get("/etag/custom", [
+ {<<"if-modified-since">>, httpd_util:rfc1123_date(LastModified)}
+ ], Config),
+ ok.
+
+if_modified_since_future(Config) ->
+ doc("Get a file with If-Modified-Since in the future."),
+ {{Year, _, _}, {_, _, _}} = calendar:universal_time(),
+ {200, _, _} = do_get("/etag/custom", [
+ {<<"if-modified-since">>, [
+ <<"Sat, 29 Oct ">>,
+ integer_to_binary(Year + 1),
+ <<" 19:43:31 GMT">>]}
+ ], Config),
+ ok.
+
+if_modified_since_if_none_match(Config) ->
+ doc("Get a file with both If-Modified-Since and If-None-Match headers."
+ "If-None-Match takes precedence and If-Modified-Since is ignored. (RFC7232 3.3)"),
+ LastModified = filelib:last_modified(config(static_dir, Config) ++ "/style.css"),
+ {200, _, _} = do_get("/etag/custom", [
+ {<<"if-modified-since">>, httpd_util:rfc1123_date(LastModified)},
+ {<<"if-none-match">>, <<"\"not-etag\"">>}
+ ], Config),
+ ok.
+
+if_modified_since_invalid(Config) ->
+ doc("Get a file with an invalid If-Modified-Since header."),
+ {200, _, _} = do_get("/etag/custom", [
+ {<<"if-modified-since">>, <<"\"not a date\"">>}
+ ], Config),
+ ok.
+
+if_none_match(Config) ->
+ doc("Get a file with If-None-Match not matching."),
+ {200, _, _} = do_get("/etag/custom", [
+ {<<"if-none-match">>, <<"\"not-etag\"">>}
+ ], Config),
+ ok.
+
+if_none_match_fail(Config) ->
+ doc("Get a file with If-None-Match matching."),
+ {304, _, _} = do_get("/etag/custom", [
+ {<<"if-none-match">>, <<"\"etag\"">>}
+ ], Config),
+ ok.
+
+if_none_match_invalid(Config) ->
+ doc("Try to get a file with an invalid If-None-Match header."),
+ {400, _, _} = do_get("/etag/custom", [
+ {<<"if-none-match">>, <<"bad input">>}
+ ], Config),
+ ok.
+
+if_none_match_list(Config) ->
+ doc("Get a file with If-None-Match not matching."),
+ {200, _, _} = do_get("/etag/custom", [
+ {<<"if-none-match">>, <<"\"invalid\", W/\"not-etag\", \"cowboy\"">>}
+ ], Config),
+ ok.
+
+if_none_match_list_fail(Config) ->
+ doc("Get a file with If-None-Match matching."),
+ {304, _, _} = do_get("/etag/custom", [
+ {<<"if-none-match">>, <<"\"invalid\", \"etag\", \"cowboy\"">>}
+ ], Config),
+ ok.
+
+if_none_match_weak(Config) ->
+ doc("Try to get a file with a weak If-None-Match header matching."),
+ {304, _, _} = do_get("/etag/custom", [
+ {<<"if-none-match">>, <<"W/\"etag\"">>}
+ ], Config),
+ ok.
+
+if_none_match_wildcard(Config) ->
+ doc("Try to get a file with a wildcard If-None-Match."),
+ {304, _, _} = do_get("/etag/custom", [
+ {<<"if-none-match">>, <<"*">>}
+ ], Config),
+ ok.
+
+if_unmodified_since(Config) ->
+ doc("Get a file with If-Unmodified-Since equal to file modification time."),
+ LastModified = filelib:last_modified(config(static_dir, Config) ++ "/style.css"),
+ {200, _, _} = do_get("/etag/custom", [
+ {<<"if-unmodified-since">>, httpd_util:rfc1123_date(LastModified)}
+ ], Config),
+ ok.
+
+if_unmodified_since_fail(Config) ->
+ doc("Get a file with If-Unmodified-Since in the past."),
+ {412, _, _} = do_get("/etag/custom", [
+ {<<"if-unmodified-since">>, <<"Sat, 29 Oct 1994 19:43:31 GMT">>}
+ ], Config),
+ ok.
+
+if_unmodified_since_future(Config) ->
+ doc("Get a file with If-Unmodified-Since in the future."),
+ {{Year, _, _}, {_, _, _}} = calendar:universal_time(),
+ {200, _, _} = do_get("/etag/custom", [
+ {<<"if-unmodified-since">>, [
+ <<"Sat, 29 Oct ">>,
+ integer_to_binary(Year + 1),
+ <<" 19:43:31 GMT">>]}
+ ], Config),
+ ok.
+
+if_unmodified_since_if_match(Config) ->
+ doc("Get a file with both If-Unmodified-Since and If-Match headers."
+ "If-Match takes precedence and If-Unmodified-Since is ignored. (RFC7232 3.4)"),
+ {200, _, _} = do_get("/etag/custom", [
+ {<<"if-unmodified-since">>, <<"Sat, 29 Oct 1994 19:43:31 GMT">>},
+ {<<"if-match">>, <<"\"etag\"">>}
+ ], Config),
+ ok.
+
+if_unmodified_since_invalid(Config) ->
+ doc("Get a file with an invalid If-Unmodified-Since header."),
+ {200, _, _} = do_get("/etag/custom", [
+ {<<"if-unmodified-since">>, <<"\"not a date\"">>}
+ ], Config),
+ ok.
+
+index_file(Config) ->
+ doc("Get an index file."),
+ {200, Headers, <<"<html><body>Hello!</body></html>\n">>} = do_get("/index", Config),
+ {_, <<"text/html">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.
+
+index_file_slash(Config) ->
+ doc("Get an index file with extra slash."),
+ {200, Headers, <<"<html><body>Hello!</body></html>\n">>} = do_get("/index/", Config),
+ {_, <<"text/html">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.
+
+last_modified(Config) ->
+ doc("Get a file, modify it, get it again and make sure Last-Modified changes."),
+ {200, Headers1, _} = do_get("/dir/file.cowboy", Config),
+ {_, LastModified1} = lists:keyfind(<<"last-modified">>, 1, Headers1),
+ ok = file:change_time(config(static_dir, Config) ++ "/file.cowboy",
+ {{config(port, Config), 1, 1}, {1, 1, 1}}),
+ {200, Headers2, _} = do_get("/dir/file.cowboy", Config),
+ {_, LastModified2} = lists:keyfind(<<"last-modified">>, 1, Headers2),
+ true = LastModified1 =/= LastModified2,
+ ok.
+
+mime_all_cowboy(Config) ->
+ doc("Get a .cowboy file. The extension is unknown."),
+ {200, Headers, _} = do_get("/mime/all/file.cowboy", Config),
+ {_, <<"application/octet-stream">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.
+
+mime_all_css(Config) ->
+ doc("Get a .css file."),
+ {200, Headers, _} = do_get("/mime/all/style.css", Config),
+ {_, <<"text/css">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.
+
+mime_all_txt(Config) ->
+ doc("Get a .txt file."),
+ {200, Headers, _} = do_get("/mime/all/plain.txt", Config),
+ {_, <<"text/plain">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.
+
+mime_crash(Config) ->
+ doc("Get a file with a crashing mimetype function."),
+ {500, _, _} = do_get("/mime/crash/style.css", Config),
+ ok.
+
+mime_custom_cowboy(Config) ->
+ doc("Get a .cowboy file."),
+ {200, Headers, _} = do_get("/mime/custom/file.cowboy", Config),
+ {_, <<"application/vnd.ninenines.cowboy+xml;v=1">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.
+
+mime_custom_css(Config) ->
+ doc("Get a .css file. The extension is unknown."),
+ {200, Headers, _} = do_get("/mime/custom/style.css", Config),
+ {_, <<"application/octet-stream">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.
+
+mime_custom_txt(Config) ->
+ doc("Get a .txt file."),
+ {200, Headers, _} = do_get("/mime/custom/plain.txt", Config),
+ {_, <<"text/plain">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.
+
+mime_hardcode_binary(Config) ->
+ doc("Get a .cowboy file with hardcoded route."),
+ {200, Headers, _} = do_get("/mime/hardcode/binary-form", Config),
+ {_, <<"application/vnd.ninenines.cowboy+xml;v=1">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.
+
+mime_hardcode_tuple(Config) ->
+ doc("Get a .cowboy file with hardcoded route."),
+ {200, Headers, _} = do_get("/mime/hardcode/tuple-form", Config),
+ {_, <<"application/vnd.ninenines.cowboy+xml;v=1">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.
+
+priv_file(Config) ->
+ doc("Get a file with hardcoded route."),
+ {200, Headers, <<"body{color:red}\n">>} = do_get("/priv_file/style.css", Config),
+ {_, <<"text/css">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.
+
+unicode_basic_latin(Config) ->
+ doc("Get a file with non-urlencoded characters from Unicode Basic Latin block."),
+ _ = [case do_get("/char/" ++ [C], Config) of
+ {200, _, << C >>} -> ok;
+ Error -> exit({error, C, Error})
+ end || C <-
+ %% Excluding the dot which has a special meaning in URLs
+ %% when they are the only content in a path segment,
+ %% and is tested as part of filenames in other test cases.
+ "abcdefghijklmnopqrstuvwxyz"
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ "0123456789"
+ ":@-_~!$&'()*+,;="
+ ],
+ ok.
+
+unicode_basic_error(Config) ->
+ doc("Try to get a file with invalid non-urlencoded characters from Unicode Basic Latin block."),
+ Exclude = case config(protocol, Config) of
+ %% Some characters trigger different errors in HTTP/1.1
+ %% because they are used for the protocol.
+ %%
+ %% # and ? indicate fragment and query components
+ %% and are therefore not part of the path.
+ http -> "\r\s#?";
+ http2 -> "#?"
+ end,
+ _ = [case do_get("/char/" ++ [C], Config) of
+ {500, _, _} -> ok;
+ Error -> exit({error, C, Error})
+ end || C <- (config(chars, Config) -- Exclude) --
+ "abcdefghijklmnopqrstuvwxyz"
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ "0123456789"
+ ":@-_~!$&'()*+,;="
+ ],
+ ok.
+
+unicode_basic_latin_urlencoded(Config) ->
+ doc("Get a file with urlencoded characters from Unicode Basic Latin block."),
+ _ = [case do_get(lists:flatten(["/char/%", io_lib:format("~2.16.0b", [C])]), Config) of
+ {200, _, << C >>} -> ok;
+ Error -> exit({error, C, Error})
+ end || C <- config(chars, Config)],
+ ok.
+
+unknown_option(Config) ->
+ doc("Get a file configured with unknown extra options."),
+ {200, Headers, <<"body{color:red}\n">>} = do_get("/unknown/option", Config),
+ {_, <<"text/css">>} = lists:keyfind(<<"content-type">>, 1, Headers),
+ ok.