%% Copyright (c) 2013-2024, Loïc Hoguin <essen@ninenines.eu>
%% Copyright (c) 2011, Magnus Klaar <magnus.klaar@gmail.com>
%%
%% 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(cowboy_static).

-export([init/2]).
-export([malformed_request/2]).
-export([forbidden/2]).
-export([content_types_provided/2]).
-export([charsets_provided/2]).
-export([ranges_provided/2]).
-export([resource_exists/2]).
-export([last_modified/2]).
-export([generate_etag/2]).
-export([get_file/2]).

-type extra_charset() :: {charset, module(), function()} | {charset, binary()}.
-type extra_etag() :: {etag, module(), function()} | {etag, false}.
-type extra_mimetypes() :: {mimetypes, module(), function()}
	| {mimetypes, binary() | {binary(), binary(), [{binary(), binary()}]}}.
-type extra() :: [extra_charset() | extra_etag() | extra_mimetypes()].
-type opts() :: {file | dir, string() | binary()}
	| {file | dir, string() | binary(), extra()}
	| {priv_file | priv_dir, atom(), string() | binary()}
	| {priv_file | priv_dir, atom(), string() | binary(), extra()}.
-export_type([opts/0]).

-include_lib("kernel/include/file.hrl").

-type state() :: {binary(), {direct | archive, #file_info{}}
	| {error, atom()}, extra()}.

%% Resolve the file that will be sent and get its file information.
%% If the handler is configured to manage a directory, check that the
%% requested file is inside the configured directory.

-spec init(Req, opts()) -> {cowboy_rest, Req, error | state()} when Req::cowboy_req:req().
init(Req, {Name, Path}) ->
	init_opts(Req, {Name, Path, []});
init(Req, {Name, App, Path})
		when Name =:= priv_file; Name =:= priv_dir ->
	init_opts(Req, {Name, App, Path, []});
init(Req, Opts) ->
	init_opts(Req, Opts).

init_opts(Req, {priv_file, App, Path, Extra}) ->
	{PrivPath, HowToAccess} = priv_path(App, Path),
	init_info(Req, absname(PrivPath), HowToAccess, Extra);
init_opts(Req, {file, Path, Extra}) ->
	init_info(Req, absname(Path), direct, Extra);
init_opts(Req, {priv_dir, App, Path, Extra}) ->
	{PrivPath, HowToAccess} = priv_path(App, Path),
	init_dir(Req, PrivPath, HowToAccess, Extra);
init_opts(Req, {dir, Path, Extra}) ->
	init_dir(Req, Path, direct, Extra).

priv_path(App, Path) ->
	case code:priv_dir(App) of
		{error, bad_name} ->
			error({badarg, "Can't resolve the priv_dir of application "
				++ atom_to_list(App)});
		PrivDir when is_list(Path) ->
			{
				PrivDir ++ "/" ++ Path,
				how_to_access_app_priv(PrivDir)
			};
		PrivDir when is_binary(Path) ->
			{
				<< (list_to_binary(PrivDir))/binary, $/, Path/binary >>,
				how_to_access_app_priv(PrivDir)
			}
	end.

how_to_access_app_priv(PrivDir) ->
	%% If the priv directory is not a directory, it must be
	%% inside an Erlang application .ez archive. We call
	%% how_to_access_app_priv1() to find the corresponding archive.
	case filelib:is_dir(PrivDir) of
		true  -> direct;
		false -> how_to_access_app_priv1(PrivDir)
	end.

how_to_access_app_priv1(Dir) ->
	%% We go "up" by one path component at a time and look for a
	%% regular file.
	Archive = filename:dirname(Dir),
	case Archive of
		Dir ->
			%% filename:dirname() returned its argument:
			%% we reach the root directory. We found no
			%% archive so we return 'direct': the given priv
			%% directory doesn't exist.
			direct;
		_ ->
			case filelib:is_regular(Archive) of
				true  -> {archive, Archive};
				false -> how_to_access_app_priv1(Archive)
			end
	end.

absname(Path) when is_list(Path) ->
	filename:absname(list_to_binary(Path));
absname(Path) when is_binary(Path) ->
	filename:absname(Path).

init_dir(Req, Path, HowToAccess, Extra) when is_list(Path) ->
	init_dir(Req, list_to_binary(Path), HowToAccess, Extra);
init_dir(Req, Path, HowToAccess, Extra) ->
	Dir = fullpath(filename:absname(Path)),
	case cowboy_req:path_info(Req) of
		%% When dir/priv_dir are used and there is no path_info
		%% this is a configuration error and we abort immediately.
		undefined ->
			{ok, cowboy_req:reply(500, Req), error};
		PathInfo ->
			case validate_reserved(PathInfo) of
				error ->
					{cowboy_rest, Req, error};
				ok ->
					Filepath = filename:join([Dir|PathInfo]),
					Len = byte_size(Dir),
					case fullpath(Filepath) of
						<< Dir:Len/binary, $/, _/binary >> ->
							init_info(Req, Filepath, HowToAccess, Extra);
						<< Dir:Len/binary >> ->
							init_info(Req, Filepath, HowToAccess, Extra);
						_ ->
							{cowboy_rest, Req, error}
					end
			end
	end.

validate_reserved([]) ->
	ok;
validate_reserved([P|Tail]) ->
	case validate_reserved1(P) of
		ok -> validate_reserved(Tail);
		error -> error
	end.

%% We always reject forward slash, backward slash and NUL as
%% those have special meanings across the supported platforms.
%% We could support the backward slash on some platforms but
%% for the sake of consistency and simplicity we don't.
validate_reserved1(<<>>) ->
	ok;
validate_reserved1(<<$/, _/bits>>) ->
	error;
validate_reserved1(<<$\\, _/bits>>) ->
	error;
validate_reserved1(<<0, _/bits>>) ->
	error;
validate_reserved1(<<_, Rest/bits>>) ->
	validate_reserved1(Rest).

fullpath(Path) ->
	fullpath(filename:split(Path), []).
fullpath([], Acc) ->
	filename:join(lists:reverse(Acc));
fullpath([<<".">>|Tail], Acc) ->
	fullpath(Tail, Acc);
fullpath([<<"..">>|Tail], Acc=[_]) ->
	fullpath(Tail, Acc);
fullpath([<<"..">>|Tail], [_|Acc]) ->
	fullpath(Tail, Acc);
fullpath([Segment|Tail], Acc) ->
	fullpath(Tail, [Segment|Acc]).

init_info(Req, Path, HowToAccess, Extra) ->
	Info = read_file_info(Path, HowToAccess),
	{cowboy_rest, Req, {Path, Info, Extra}}.

read_file_info(Path, direct) ->
	case file:read_file_info(Path, [{time, universal}]) of
		{ok, Info} -> {direct, Info};
		Error      -> Error
	end;
read_file_info(Path, {archive, Archive}) ->
	case file:read_file_info(Archive, [{time, universal}]) of
		{ok, ArchiveInfo} ->
			%% The Erlang application archive is fine.
			%% Now check if the requested file is in that
			%% archive. We also need the file_info to merge
			%% them with the archive's one.
			PathS = binary_to_list(Path),
			case erl_prim_loader:read_file_info(PathS) of
				{ok, ContainedFileInfo} ->
					Info = fix_archived_file_info(
						ArchiveInfo,
						ContainedFileInfo),
					{archive, Info};
				error ->
					{error, enoent}
			end;
		Error ->
			Error
	end.

fix_archived_file_info(ArchiveInfo, ContainedFileInfo) ->
	%% We merge the archive and content #file_info because we are
	%% interested by the timestamps of the archive, but the type and
	%% size of the contained file/directory.
	%%
	%% We reset the access to 'read', because we won't rewrite the
	%% archive.
	ArchiveInfo#file_info{
		size = ContainedFileInfo#file_info.size,
		type = ContainedFileInfo#file_info.type,
		access = read
	}.

-ifdef(TEST).
fullpath_test_() ->
	Tests = [
		{<<"/home/cowboy">>, <<"/home/cowboy">>},
		{<<"/home/cowboy">>, <<"/home/cowboy/">>},
		{<<"/home/cowboy">>, <<"/home/cowboy/./">>},
		{<<"/home/cowboy">>, <<"/home/cowboy/./././././.">>},
		{<<"/home/cowboy">>, <<"/home/cowboy/abc/..">>},
		{<<"/home/cowboy">>, <<"/home/cowboy/abc/../">>},
		{<<"/home/cowboy">>, <<"/home/cowboy/abc/./../.">>},
		{<<"/">>, <<"/home/cowboy/../../../../../..">>},
		{<<"/etc/passwd">>, <<"/home/cowboy/../../etc/passwd">>}
	],
	[{P, fun() -> R = fullpath(P) end} || {R, P} <- Tests].

good_path_check_test_() ->
	Tests = [
		<<"/home/cowboy/file">>,
		<<"/home/cowboy/file/">>,
		<<"/home/cowboy/./file">>,
		<<"/home/cowboy/././././././file">>,
		<<"/home/cowboy/abc/../file">>,
		<<"/home/cowboy/abc/../file">>,
		<<"/home/cowboy/abc/./.././file">>
	],
	[{P, fun() ->
		case fullpath(P) of
			<< "/home/cowboy/", _/bits >> -> ok
		end
	end} || P <- Tests].

bad_path_check_test_() ->
	Tests = [
		<<"/home/cowboy/../../../../../../file">>,
		<<"/home/cowboy/../../etc/passwd">>
	],
	[{P, fun() ->
		error = case fullpath(P) of
			<< "/home/cowboy/", _/bits >> -> ok;
			_ -> error
		end
	end} || P <- Tests].

good_path_win32_check_test_() ->
	Tests = case os:type() of
		{unix, _} ->
			[];
		{win32, _} ->
			[
				<<"c:/home/cowboy/file">>,
				<<"c:/home/cowboy/file/">>,
				<<"c:/home/cowboy/./file">>,
				<<"c:/home/cowboy/././././././file">>,
				<<"c:/home/cowboy/abc/../file">>,
				<<"c:/home/cowboy/abc/../file">>,
				<<"c:/home/cowboy/abc/./.././file">>
			]
	end,
	[{P, fun() ->
		case fullpath(P) of
			<< "c:/home/cowboy/", _/bits >> -> ok
		end
	end} || P <- Tests].

bad_path_win32_check_test_() ->
	Tests = case os:type() of
		{unix, _} ->
			[];
		{win32, _} ->
			[
				<<"c:/home/cowboy/../../secretfile.bat">>,
				<<"c:/home/cowboy/c:/secretfile.bat">>,
				<<"c:/home/cowboy/..\\..\\secretfile.bat">>,
				<<"c:/home/cowboy/c:\\secretfile.bat">>
			]
	end,
	[{P, fun() ->
		error = case fullpath(P) of
			<< "c:/home/cowboy/", _/bits >> -> ok;
			_ -> error
		end
	end} || P <- Tests].
-endif.

%% Reject requests that tried to access a file outside
%% the target directory, or used reserved characters.

-spec malformed_request(Req, State)
	-> {boolean(), Req, State}.
malformed_request(Req, State) ->
	{State =:= error, Req, State}.

%% Directories, files that can't be accessed at all and
%% files with no read flag are forbidden.

-spec forbidden(Req, State)
	-> {boolean(), Req, State}
	when State::state().
forbidden(Req, State={_, {_, #file_info{type=directory}}, _}) ->
	{true, Req, State};
forbidden(Req, State={_, {error, eacces}, _}) ->
	{true, Req, State};
forbidden(Req, State={_, {_, #file_info{access=Access}}, _})
		when Access =:= write; Access =:= none ->
	{true, Req, State};
forbidden(Req, State) ->
	{false, Req, State}.

%% Detect the mimetype of the file.

-spec content_types_provided(Req, State)
	-> {[{binary(), get_file}], Req, State}
	when State::state().
content_types_provided(Req, State={Path, _, Extra}) when is_list(Extra) ->
	case lists:keyfind(mimetypes, 1, Extra) of
		false ->
			{[{cow_mimetypes:web(Path), get_file}], Req, State};
		{mimetypes, Module, Function} ->
			{[{Module:Function(Path), get_file}], Req, State};
		{mimetypes, Type} ->
			{[{Type, get_file}], Req, State}
	end.

%% Detect the charset of the file.

-spec charsets_provided(Req, State)
	-> {[binary()], Req, State}
	when State::state().
charsets_provided(Req, State={Path, _, Extra}) ->
	case lists:keyfind(charset, 1, Extra) of
		%% We simulate the callback not being exported.
		false ->
			no_call;
		{charset, Module, Function} ->
			{[Module:Function(Path)], Req, State};
		{charset, Charset} when is_binary(Charset) ->
			{[Charset], Req, State}
	end.

%% Enable support for range requests.

-spec ranges_provided(Req, State)
	-> {[{binary(), auto}], Req, State}
	when State::state().
ranges_provided(Req, State) ->
	{[{<<"bytes">>, auto}], Req, State}.

%% Assume the resource doesn't exist if it's not a regular file.

-spec resource_exists(Req, State)
	-> {boolean(), Req, State}
	when State::state().
resource_exists(Req, State={_, {_, #file_info{type=regular}}, _}) ->
	{true, Req, State};
resource_exists(Req, State) ->
	{false, Req, State}.

%% Generate an etag for the file.

-spec generate_etag(Req, State)
	-> {{strong | weak, binary()}, Req, State}
	when State::state().
generate_etag(Req, State={Path, {_, #file_info{size=Size, mtime=Mtime}},
		Extra}) ->
	case lists:keyfind(etag, 1, Extra) of
		false ->
			{generate_default_etag(Size, Mtime), Req, State};
		{etag, Module, Function} ->
			{Module:Function(Path, Size, Mtime), Req, State};
		{etag, false} ->
			{undefined, Req, State}
	end.

generate_default_etag(Size, Mtime) ->
	{strong, integer_to_binary(erlang:phash2({Size, Mtime}, 16#ffffffff))}.

%% Return the time of last modification of the file.

-spec last_modified(Req, State)
	-> {calendar:datetime(), Req, State}
	when State::state().
last_modified(Req, State={_, {_, #file_info{mtime=Modified}}, _}) ->
	{Modified, Req, State}.

%% Stream the file.

-spec get_file(Req, State)
	-> {{sendfile, 0, non_neg_integer(), binary()}, Req, State}
	when State::state().
get_file(Req, State={Path, {direct, #file_info{size=Size}}, _}) ->
	{{sendfile, 0, Size, Path}, Req, State};
get_file(Req, State={Path, {archive, _}, _}) ->
	PathS = binary_to_list(Path),
	{ok, Bin, _} = erl_prim_loader:get_file(PathS),
	{Bin, Req, State}.
