From ea7ae14df888ce6b37e3e219c6dae557c37d44f3 Mon Sep 17 00:00:00 2001 From: Magnus Klaar Date: Wed, 28 Dec 2011 18:02:32 +0100 Subject: Add built-in cowboy_http_static handler. --- src/cowboy_http_static.erl | 349 +++++++++++++++++++++++++++++++++++++++++++++ test/http_SUITE.erl | 83 +++++++++-- 2 files changed, 421 insertions(+), 11 deletions(-) create mode 100644 src/cowboy_http_static.erl diff --git a/src/cowboy_http_static.erl b/src/cowboy_http_static.erl new file mode 100644 index 0000000..547c043 --- /dev/null +++ b/src/cowboy_http_static.erl @@ -0,0 +1,349 @@ +%% Copyright (c) 2011, Magnus Klaar +%% +%% 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. + +%% @doc Static resource handler. +%% +%% This built in HTTP handler provides a simple file serving capability for +%% cowboy applications. It should be considered an experimental feature because +%% of it's dependency on the experimental REST handler. It's recommended to be +%% used for small or temporary environments where it is not preferrable to set +%% up a second server just to serve files. +%% +%% == Base configuration == +%% +%% The handler must be configured with a request path prefix to serve files +%% under and the path to a directory to read files from. The request path prefix +%% is defined in the path pattern of the cowboy dispatch rule for the handler. +%% The request path pattern must end with a ``'...''' token. +%% The directory path can be set to either an absolute or relative path in the +%% form of a list or binary string representation of a file system path. A list +%% of binary path segments, as is used throughout cowboy, is also a valid +%% directory path. +%% +%% The directory path can also be set to a relative path within the `priv/' +%% directory of an application. This is configured by setting the value of the +%% directory option to a tuple of the form `{priv_dir, Application, Relpath}'. +%% +%% ==== Examples ==== +%% ``` +%% %% Serve files from /var/www/ under http://example.com/static/ +%% {[<<"static">>, '...'], cowboy_http_static, +%% [{directory, "/var/www"}]} +%% +%% %% Serve files from the current working directory under http://example.com/static/ +%% {[<<"static">>, '...'], cowboy_http_static, +%% [{directory, <<"./">>}]} +%% +%% %% Serve files from cowboy/priv/www under http://example.com/ +%% {['...'], cowboy_http_static, +%% [{directory, {priv_dir, cowboy, [<<"www">>]}}]} +%% ''' +%% +%% == Content type configuration == +%% +%% By default the content type of all static resources will be set to +%% `application/octet-stream'. This can be overriden by supplying a list +%% of filename extension to mimetypes pairs in the `mimetypes' option. +%% The filename extension should be a binary string including the leading dot. +%% The mimetypes must be of a type that the `cowboy_http_rest' protocol can +%% handle. +%% +%% ==== Example ==== +%% ``` +%% {[<<"static">>, '...'], cowboy_http_static, +%% [{directory, {priv_dir, cowboy, []}}, +%% {mimetypes, [ +%% {<<".css">>, [<<"text/css">>]}, +%% {<<".js">>, [<<"application/javascript">>]}]}]} +%% ''' +-module(cowboy_http_static). + +%% include files +-include("http.hrl"). +-include_lib("kernel/include/file.hrl"). + +%% cowboy_http_protocol callbacks +-export([init/3]). + +%% cowboy_http_rest callbacks +-export([rest_init/2, allowed_methods/2, malformed_request/2, resource_exists/2, + forbidden/2, last_modified/2, content_types_provided/2, file_contents/2]). + +%% internal +-export([path_to_mimetypes/2]). + +%% types +-type dirpath() :: string() | binary() | [binary()]. +-type dirspec() :: dirpath() | {priv, atom(), dirpath()}. +-type mimedef() :: {binary(), binary(), [{binary(), binary()}]}. + +%% handler state +-record(state, { + filepath :: binary() | error, + fileinfo :: {ok, #file_info{}} | {error, _} | error, + mimetypes :: {fun((binary(), T) -> [mimedef()]), T} | undefined}). + + +%% @private Upgrade from HTTP handler to REST handler. +init({_Transport, http}, _Req, _Opts) -> + {upgrade, protocol, cowboy_http_rest}. + + +%% @private Set up initial state of REST handler. +-spec rest_init(#http_req{}, list()) -> {ok, #http_req{}, #state{}}. +rest_init(Req, Opts) -> + Directory = proplists:get_value(directory, Opts), + Directory1 = directory_path(Directory), + Mimetypes = proplists:get_value(mimetypes, Opts, []), + Mimetypes1 = case Mimetypes of + {_, _} -> Mimetypes; + [] -> {fun path_to_mimetypes/2, []}; + [_|_] -> {fun path_to_mimetypes/2, Mimetypes} + end, + {Filepath, Req1} = cowboy_http_req:path_info(Req), + State = case check_path(Filepath) of + error -> + #state{filepath=error, fileinfo=error, mimetypes=undefined}; + ok -> + Filepath1 = join_paths(Directory1, Filepath), + Fileinfo = file:read_file_info(Filepath1), + #state{filepath=Filepath1, fileinfo=Fileinfo, mimetypes=Mimetypes1} + end, + {ok, Req1, State}. + + +%% @private Only allow GET and HEAD requests on files. +-spec allowed_methods(#http_req{}, #state{}) -> + {[atom()], #http_req{}, #state{}}. +allowed_methods(Req, State) -> + {['GET', 'HEAD'], Req, State}. + +%% @private +-spec malformed_request(#http_req{}, #state{}) -> + {boolean(), #http_req{}, #state{}}. +malformed_request(Req, #state{filepath=error}=State) -> + {true, Req, State}; +malformed_request(Req, State) -> + {false, Req, State}. + + +%% @private Check if the resource exists under the document root. +-spec resource_exists(#http_req{}, #state{}) -> + {boolean(), #http_req{}, #state{}}. +resource_exists(Req, #state{fileinfo={error, _}}=State) -> + {false, Req, State}; +resource_exists(Req, #state{fileinfo={ok, Fileinfo}}=State) -> + {Fileinfo#file_info.type =:= regular, Req, State}. + + +%% @private +%% Access to a file resource is forbidden if it exists and the local node does +%% not have permission to read it. Directory listings are always forbidden. +-spec forbidden(#http_req{}, #state{}) -> {boolean(), #http_req{}, #state{}}. +forbidden(Req, #state{fileinfo={_, #file_info{type=directory}}}=State) -> + {true, Req, State}; +forbidden(Req, #state{fileinfo={error, eacces}}=State) -> + {true, Req, State}; +forbidden(Req, #state{fileinfo={error, _}}=State) -> + {false, Req, State}; +forbidden(Req, #state{fileinfo={ok, #file_info{access=Access}}}=State) -> + {not (Access =:= read orelse Access =:= read_write), Req, State}. + + +%% @private Read the time a file system system object was last modified. +-spec last_modified(#http_req{}, #state{}) -> + {cowboy_clock:datetime(), #http_req{}, #state{}}. +last_modified(Req, #state{fileinfo={ok, #file_info{mtime=Modified}}}=State) -> + {Modified, Req, State}. + + +%% @private Return the content type of a file. +-spec content_types_provided(#http_req{}, #state{}) -> tuple(). +content_types_provided(Req, #state{filepath=Filepath, + mimetypes={MimetypesFun, MimetypesData}}=State) -> + Mimetypes = [{T, file_contents} + || T <- MimetypesFun(Filepath, MimetypesData)], + {Mimetypes, Req, State}. + + +%% @private Return a function that writes a file directly to the socket. +-spec file_contents(#http_req{}, #state{}) -> tuple(). +file_contents(Req, #state{filepath=Filepath, + fileinfo={ok, #file_info{size=Filesize}}}=State) -> + {ok, Transport, Socket} = cowboy_http_req:transport(Req), + Writefile = content_function(Transport, Socket, Filepath), + {{stream, Filesize, Writefile}, Req, State}. + + +%% @private Return a function writing the contents of a file to a socket. +%% The function returns the number of bytes written to the socket to enable +%% the calling function to determine if the expected number of bytes were +%% written to the socket. +-spec content_function(module(), inet:socket(), binary()) -> + fun(() -> {sent, non_neg_integer()}). +content_function(Transport, Socket, Filepath) -> + %% `file:sendfile/2' will only work with the `cowboy_tcp_transport' + %% transport module. SSL or future SPDY transports that require the + %% content to be encrypted or framed as the content is sent. + case erlang:function_exported(file, sendfile, 2) of + false -> + fun() -> sfallback(Transport, Socket, Filepath) end; + _ when Transport =/= cowboy_tcp_transport -> + fun() -> sfallback(Transport, Socket, Filepath) end; + true -> + fun() -> sendfile(Socket, Filepath) end + end. + + +%% @private Sendfile fallback function. +-spec sfallback(module(), inet:socket(), binary()) -> {sent, non_neg_integer()}. +sfallback(Transport, Socket, Filepath) -> + {ok, File} = file:open(Filepath, [read,binary,raw]), + sfallback(Transport, Socket, File, 0). + +-spec sfallback(module(), inet:socket(), file:io_device(), + non_neg_integer()) -> {sent, non_neg_integer()}. +sfallback(Transport, Socket, File, Sent) -> + case file:read(File, 16#1FFF) of + eof -> + ok = file:close(File), + {sent, Sent}; + {ok, Bin} -> + ok = Transport:send(Socket, Bin), + sfallback(Transport, Socket, File, Sent + byte_size(Bin)) + end. + + +%% @private Wrapper for sendfile function. +-spec sendfile(inet:socket(), binary()) -> {sent, non_neg_integer()}. +sendfile(Socket, Filepath) -> + {ok, Sent} = file:sendfile(Filepath, Socket), + {sent, Sent}. + +-spec directory_path(dirspec()) -> dirpath(). +directory_path({priv_dir, App, []}) -> + priv_dir_path(App); +directory_path({priv_dir, App, [H|_]=Path}) when is_integer(H) -> + filename:join(priv_dir_path(App), Path); +directory_path({priv_dir, App, [H|_]=Path}) when is_binary(H) -> + filename:join(filename:split(priv_dir_path(App)) ++ Path); +directory_path({priv_dir, App, Path}) when is_binary(Path) -> + filename:join(priv_dir_path(App), Path); +directory_path(Path) -> + Path. + + +%% @private Validate a request path for unsafe characters. +%% There is no way to escape special characters in a filesystem path. +-spec check_path(Path::[binary()]) -> ok | error. +check_path([]) -> ok; +check_path([<<"">>|_T]) -> error; +check_path([<<".">>|_T]) -> error; +check_path([<<"..">>|_T]) -> error; +check_path([H|T]) -> + case binary:match(H, <<"/">>) of + {_, _} -> error; + nomatch -> check_path(T) + end. + + +%% @private Join the the directory and request paths. +-spec join_paths(dirpath(), [binary()]) -> binary(). +join_paths([H|_]=Dirpath, Filepath) when is_integer(H) -> + filename:join(filename:split(Dirpath) ++ Filepath); +join_paths([H|_]=Dirpath, Filepath) when is_binary(H) -> + filename:join(Dirpath ++ Filepath); +join_paths(Dirpath, Filepath) when is_binary(Dirpath) -> + filename:join([Dirpath] ++ Filepath); +join_paths([], Filepath) -> + filename:join(Filepath). + + +%% @private Return the path to the priv/ directory of an application. +-spec priv_dir_path(atom()) -> string(). +priv_dir_path(App) -> + case code:priv_dir(App) of + {error, bad_name} -> priv_dir_mod(App); + Dir -> Dir + end. + +-spec priv_dir_mod(atom()) -> string(). +priv_dir_mod(Mod) -> + case code:which(Mod) of + File when not is_list(File) -> "../priv"; + File -> filename:join([filename:dirname(File),"../priv"]) + end. + + +%% @private Use application/octet-stream as the default mimetype. +%% If a list of extension - mimetype pairs are provided as the mimetypes +%% an attempt to find the mimetype using the file extension. If no match +%% is found the default mimetype is returned. +-spec path_to_mimetypes(binary(), [{binary(), [mimedef()]}]) -> + [mimedef()]. +path_to_mimetypes(Filepath, Extensions) when is_binary(Filepath) -> + Ext = filename:extension(Filepath), + case Ext of + <<>> -> default_mimetype(); + _Ext -> path_to_mimetypes_(Ext, Extensions) + end. + +-spec path_to_mimetypes_(binary(), [{binary(), [mimedef()]}]) -> [mimedef()]. +path_to_mimetypes_(Ext, Extensions) -> + case lists:keyfind(Ext, 1, Extensions) of + {_, MTs} -> MTs; + _Unknown -> default_mimetype() + end. + +-spec default_mimetype() -> [mimedef()]. +default_mimetype() -> + [{<<"application">>, <<"octet-stream">>, []}]. + + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-define(_eq(E, I), ?_assertEqual(E, I)). + +check_path_test_() -> + C = fun check_path/1, + [?_eq(error, C([<<>>])), + ?_eq(ok, C([<<"abc">>])), + ?_eq(error, C([<<".">>])), + ?_eq(error, C([<<"..">>])), + ?_eq(error, C([<<"/">>])) + ]. + +join_paths_test_() -> + P = fun join_paths/2, + [?_eq(<<"a">>, P([], [<<"a">>])), + ?_eq(<<"a/b/c">>, P(<<"a/b">>, [<<"c">>])), + ?_eq(<<"a/b/c">>, P("a/b", [<<"c">>])), + ?_eq(<<"a/b/c">>, P([<<"a">>, <<"b">>], [<<"c">>])) + ]. + +directory_path_test_() -> + P = fun directory_path/1, + PL = fun(I) -> length(filename:split(P(I))) end, + Base = PL({priv_dir, cowboy, []}), + [?_eq(Base + 1, PL({priv_dir, cowboy, "a"})), + ?_eq(Base + 1, PL({priv_dir, cowboy, <<"a">>})), + ?_eq(Base + 1, PL({priv_dir, cowboy, [<<"a">>]})), + ?_eq(Base + 2, PL({priv_dir, cowboy, "a/b"})), + ?_eq(Base + 2, PL({priv_dir, cowboy, <<"a/b">>})), + ?_eq(Base + 2, PL({priv_dir, cowboy, [<<"a">>, <<"b">>]})), + ?_eq("a/b", P("a/b")) + ]. + + +-endif. diff --git a/test/http_SUITE.erl b/test/http_SUITE.erl index b7fd551..bc8d1f7 100644 --- a/test/http_SUITE.erl +++ b/test/http_SUITE.erl @@ -22,7 +22,8 @@ keepalive_nl/1, max_keepalive/1, nc_rand/1, nc_zero/1, pipeline/1, raw/1, set_resp_header/1, set_resp_overwrite/1, set_resp_body/1, stream_body_set_resp/1, response_as_req/1]). %% http. --export([http_200/1, http_404/1]). %% http and https. +-export([http_200/1, http_404/1, file_200/1, file_403/1, + dir_403/1, file_404/1, file_400/1]). %% http and https. -export([http_10_hostless/1]). %% misc. -export([rest_simple/1, rest_keepalive/1]). %% rest. @@ -32,7 +33,8 @@ all() -> [{group, http}, {group, https}, {group, misc}, {group, rest}]. groups() -> - BaseTests = [http_200, http_404], + BaseTests = [http_200, http_404, file_200, file_403, dir_403, file_404, + file_400], [{http, [], [chunked_response, headers_dupe, headers_huge, keepalive_nl, max_keepalive, nc_rand, nc_zero, pipeline, raw, set_resp_header, set_resp_overwrite, @@ -53,14 +55,16 @@ end_per_suite(_Config) -> init_per_group(http, Config) -> Port = 33080, + Config1 = init_static_dir(Config), cowboy:start_listener(http, 100, cowboy_tcp_transport, [{port, Port}], cowboy_http_protocol, [{max_keepalive, 50}, - {dispatch, init_http_dispatch()}] + {dispatch, init_http_dispatch(Config1)}] ), - [{scheme, "http"}, {port, Port}|Config]; + [{scheme, "http"}, {port, Port}|Config1]; init_per_group(https, Config) -> Port = 33081, + Config1 = init_static_dir(Config), application:start(crypto), application:start(public_key), application:start(ssl), @@ -69,9 +73,9 @@ init_per_group(https, Config) -> cowboy_ssl_transport, [ {port, Port}, {certfile, DataDir ++ "cert.pem"}, {keyfile, DataDir ++ "key.pem"}, {password, "cowboy"}], - cowboy_http_protocol, [{dispatch, init_https_dispatch()}] + cowboy_http_protocol, [{dispatch, init_https_dispatch(Config1)}] ), - [{scheme, "https"}, {port, Port}|Config]; + [{scheme, "https"}, {port, Port}|Config1]; init_per_group(misc, Config) -> Port = 33082, cowboy:start_listener(misc, 100, @@ -89,19 +93,21 @@ init_per_group(rest, Config) -> ]}]}]), [{port, Port}|Config]. -end_per_group(https, _Config) -> +end_per_group(https, Config) -> cowboy:stop_listener(https), application:stop(ssl), application:stop(public_key), application:stop(crypto), + end_static_dir(Config), ok; -end_per_group(Listener, _Config) -> +end_per_group(Listener, Config) -> cowboy:stop_listener(Listener), + end_static_dir(Config), ok. %% Dispatch configuration. -init_http_dispatch() -> +init_http_dispatch(Config) -> [ {[<<"localhost">>], [ {[<<"chunked_response">>], chunked_handler, []}, @@ -117,12 +123,37 @@ init_http_dispatch() -> [{body, <<"A flameless dance does not equal a cycle">>}]}, {[<<"stream_body">>, <<"set_resp">>], http_handler_stream_body, [{reply, set_resp}, {body, <<"stream_body_set_resp">>}]}, + {[<<"static">>, '...'], cowboy_http_static, + [{directory, ?config(static_dir, Config)}, + {mimetypes, [{<<".css">>, [<<"text/css">>]}]}]}, {[], http_handler, []} ]} ]. -init_https_dispatch() -> - init_http_dispatch(). +init_https_dispatch(Config) -> + init_http_dispatch(Config). + + +init_static_dir(Config) -> + Dir = filename:join(?config(priv_dir, Config), "static"), + Level1 = fun(Name) -> filename:join(Dir, Name) end, + ok = file:make_dir(Dir), + ok = file:write_file(Level1("test_file"), "test_file\n"), + ok = file:write_file(Level1("test_file.css"), "test_file.css\n"), + ok = file:write_file(Level1("test_noread"), "test_noread\n"), + ok = file:change_mode(Level1("test_noread"), 8#0333), + ok = file:make_dir(Level1("test_dir")), + [{static_dir, Dir}|Config]. + +end_static_dir(Config) -> + Dir = ?config(static_dir, Config), + Level1 = fun(Name) -> filename:join(Dir, Name) end, + ok = file:delete(Level1("test_file")), + ok = file:delete(Level1("test_file.css")), + ok = file:delete(Level1("test_noread")), + ok = file:del_dir(Level1("test_dir")), + ok = file:del_dir(Dir), + Config. %% http. @@ -355,6 +386,36 @@ http_404(Config) -> {ok, {{"HTTP/1.1", 404, "Not Found"}, _Headers, _Body}} = httpc:request(build_url("/not/found", Config)). +file_200(Config) -> + {ok, {{"HTTP/1.1", 200, "OK"}, Headers, "test_file\n"}} = + httpc:request(build_url("/static/test_file", Config)), + "application/octet-stream" = ?config("content-type", Headers), + + {ok, {{"HTTP/1.1", 200, "OK"}, Headers1, "test_file.css\n"}} = + httpc:request(build_url("/static/test_file.css", Config)), + "text/css" = ?config("content-type", Headers1). + +file_403(Config) -> + {ok, {{"HTTP/1.1", 403, "Forbidden"}, _Headers, _Body}} = + httpc:request(build_url("/static/test_noread", Config)). + +dir_403(Config) -> + {ok, {{"HTTP/1.1", 403, "Forbidden"}, _Headers, _Body}} = + httpc:request(build_url("/static/test_dir", Config)), + {ok, {{"HTTP/1.1", 403, "Forbidden"}, _Headers, _Body}} = + httpc:request(build_url("/static/test_dir/", Config)). + +file_404(Config) -> + {ok, {{"HTTP/1.1", 404, "Not Found"}, _Headers, _Body}} = + httpc:request(build_url("/static/not_found", Config)). + +file_400(Config) -> + {ok, {{"HTTP/1.1", 400, "Bad Request"}, _Headers, _Body}} = + httpc:request(build_url("/static/%2f", Config)), + {ok, {{"HTTP/1.1", 400, "Bad Request"}, _Headers1, _Body1}} = + httpc:request(build_url("/static/%2e", Config)), + {ok, {{"HTTP/1.1", 400, "Bad Request"}, _Headers2, _Body2}} = + httpc:request(build_url("/static/%2e%2e", Config)). %% misc. http_10_hostless(Config) -> -- cgit v1.2.3