-module(livery_static).
-moduledoc """
Static-directory file handler.
Returns a handler that serves files from a root directory, inferring
`Content-Type` from the extension, emitting a weak ETag for conditional
GET, honoring `Range`, and confining every path under the root so a
request can never traverse out of it.
Mount it on a router wildcard route and read the captured sub-path:
```erlang
Router = livery_router:add(
'_', <<"/assets/*path">>, livery_static:handler("priv/assets"), #{}, Router0
).
```
Without a router binding it falls back to stripping a configured
`prefix` from the request path. Options (all optional): `binding`
(default `<<"path">>`), `prefix`, `index` (default `<<"index.html">>`,
or `false`), `cache_control` (binary | directive list | `undefined`),
`etag` (default `true`), `range` (default `true`).
Security: the sub-path is percent-decoded and then confined - any `..`
segment, control byte, or escape is rejected - and only regular files
are served (directories and symlinks yield `404`).
""".
-include_lib("kernel/include/file.hrl").
-export([handler/1, handler/2]).
-type opts() :: #{
binding => binary(),
prefix => binary(),
index => binary() | false,
cache_control => binary() | [livery_resp:cache_directive()] | undefined,
etag => boolean(),
range => boolean()
}.
-export_type([opts/0]).
-doc "Static handler serving regular files under `Root`.".
-spec handler(file:name_all()) -> livery_middleware:handler().
handler(Root) ->
handler(Root, #{}).
-doc "`handler/1` with options.".
-spec handler(file:name_all(), opts()) -> livery_middleware:handler().
handler(Root, Opts) ->
NormRoot = normalize_root(Root),
Cfg = config(Opts),
fun(Req) -> serve(NormRoot, Cfg, Req) end.
%%====================================================================
%% Request handling
%%====================================================================
-spec serve(string(), map(), livery_req:req()) -> livery_resp:resp().
serve(Root, Cfg, Req) ->
case livery_req:method(Req) of
<<"GET">> -> locate(Root, Cfg, Req, <<"GET">>);
<<"HEAD">> -> locate(Root, Cfg, Req, <<"HEAD">>);
_Other -> method_not_allowed()
end.
-spec locate(string(), map(), livery_req:req(), binary()) -> livery_resp:resp().
locate(Root, Cfg, Req, Method) ->
case resolve(Root, sub_path(Req, Cfg), maps:get(index, Cfg)) of
{ok, Path, Size, Mtime} ->
serve_file(Req, Method, Path, Size, Mtime, Cfg);
error ->
not_found()
end.
-spec serve_file(
livery_req:req(), binary(), string(), non_neg_integer(), integer(), map()
) -> livery_resp:resp().
serve_file(Req, Method, Path, Size, Mtime, Cfg) ->
ETag = etag(Size, Mtime),
case maps:get(etag, Cfg) andalso livery_etag:if_none_match(Req, ETag) of
true ->
decorate(livery_resp:new(304, [], empty), ETag, Cfg);
false ->
body_response(Req, Method, Path, Size, ETag, Cfg)
end.
-spec body_response(
livery_req:req(), binary(), string(), non_neg_integer(), binary(), map()
) -> livery_resp:resp().
body_response(_Req, <<"HEAD">>, Path, Size, ETag, Cfg) ->
Resp = livery_resp:new(
200,
[
{<<"content-type">>, mime_type(Path)},
{<<"content-length">>, integer_to_binary(Size)}
],
empty
),
decorate(Resp, ETag, Cfg);
body_response(Req, <<"GET">>, Path, Size, ETag, Cfg) ->
Base =
case maps:get(range, Cfg) andalso parse_range(Req, Size) of
{Offset, Length} -> livery_resp:file(206, Path, {Offset, Length});
_NoRange -> livery_resp:file(200, Path)
end,
Resp = livery_resp:with_header(<<"content-type">>, mime_type(Path), Base),
decorate(Resp, ETag, Cfg).
-spec decorate(livery_resp:resp(), binary(), map()) -> livery_resp:resp().
decorate(Resp, ETag, #{etag := EtagOn, cache_control := CacheControl}) ->
R1 =
case EtagOn of
true -> livery_resp:with_header(<<"etag">>, ETag, Resp);
false -> Resp
end,
case CacheControl of
undefined -> R1;
_ -> livery_resp:with_cache_control(CacheControl, R1)
end.
-spec method_not_allowed() -> livery_resp:resp().
method_not_allowed() ->
livery_resp:with_header(
<<"allow">>, <<"GET, HEAD">>, livery_resp:text(405, <<"method not allowed">>)
).
-spec not_found() -> livery_resp:resp().
not_found() ->
livery_resp:text(404, <<"not found">>).
%%====================================================================
%% Path resolution + confinement
%%====================================================================
-spec sub_path(livery_req:req(), map()) -> binary().
sub_path(Req, #{binding := Binding, prefix := Prefix}) ->
case livery_req:binding(Binding, Req) of
undefined -> strip_prefix(livery_req:path(Req), Prefix);
Sub -> Sub
end.
-spec strip_prefix(binary(), binary() | undefined) -> binary().
strip_prefix(Path, undefined) ->
strip_leading_slash(Path);
strip_prefix(Path, Prefix) ->
Size = byte_size(Prefix),
case Path of
<<Prefix:Size/binary, Rest/binary>> -> Rest;
_Other -> strip_leading_slash(Path)
end.
-spec strip_leading_slash(binary()) -> binary().
strip_leading_slash(<<"/", Rest/binary>>) -> Rest;
strip_leading_slash(Path) -> Path.
-spec resolve(string(), binary(), binary() | false) ->
{ok, string(), non_neg_integer(), integer()} | error.
resolve(Root, Sub, Index) ->
case confine(Root, Sub) of
error -> error;
{ok, Path} -> stat(Path, Index)
end.
-spec stat(string(), binary() | false) ->
{ok, string(), non_neg_integer(), integer()} | error.
stat(Path, Index) ->
case file:read_link_info(Path, [{time, posix}]) of
{ok, #file_info{type = regular, size = Size, mtime = Mtime}} ->
{ok, Path, Size, Mtime};
{ok, #file_info{type = directory}} ->
stat_index(Path, Index);
_Other ->
error
end.
-spec stat_index(string(), binary() | false) ->
{ok, string(), non_neg_integer(), integer()} | error.
stat_index(_Path, false) ->
error;
stat_index(Path, Index) when is_binary(Index) ->
stat(filename:join(Path, binary_to_list(Index)), false).
%% Percent-decode, then split and reject any unsafe segment.
-spec confine(string(), binary()) -> {ok, string()} | error.
confine(Root, Sub) ->
case percent_decode(Sub) of
error ->
error;
{ok, Decoded} ->
case clean_segments(binary:split(Decoded, <<"/">>, [global]), []) of
{ok, Segments} -> {ok, filename:join([Root | Segments])};
error -> error
end
end.
-spec clean_segments([binary()], [string()]) -> {ok, [string()]} | error.
clean_segments([], Acc) ->
{ok, lists:reverse(Acc)};
clean_segments([Segment | Rest], Acc) ->
case Segment of
<<>> ->
clean_segments(Rest, Acc);
<<".">> ->
clean_segments(Rest, Acc);
<<"..">> ->
error;
_ ->
case has_bad_byte(Segment) of
true -> error;
false -> clean_segments(Rest, [binary_to_list(Segment) | Acc])
end
end.
-spec has_bad_byte(binary()) -> boolean().
has_bad_byte(<<>>) -> false;
has_bad_byte(<<C, _/binary>>) when C < 32; C =:= 127 -> true;
has_bad_byte(<<_, Rest/binary>>) -> has_bad_byte(Rest).
-spec percent_decode(binary()) -> {ok, binary()} | error.
percent_decode(Bin) ->
percent_decode(Bin, <<>>).
-spec percent_decode(binary(), binary()) -> {ok, binary()} | error.
percent_decode(<<>>, Acc) ->
{ok, Acc};
percent_decode(<<$%, H1, H2, Rest/binary>>, Acc) ->
case {hex(H1), hex(H2)} of
{{ok, A}, {ok, B}} -> percent_decode(Rest, <<Acc/binary, (A * 16 + B)>>);
_Bad -> error
end;
percent_decode(<<$%, _/binary>>, _Acc) ->
error;
percent_decode(<<C, Rest/binary>>, Acc) ->
percent_decode(Rest, <<Acc/binary, C>>).
-spec hex(byte()) -> {ok, 0..15} | error.
hex(C) when C >= $0, C =< $9 -> {ok, C - $0};
hex(C) when C >= $a, C =< $f -> {ok, C - $a + 10};
hex(C) when C >= $A, C =< $F -> {ok, C - $A + 10};
hex(_C) -> error.
%%====================================================================
%% ETag, MIME, Range
%%====================================================================
-spec etag(non_neg_integer(), integer()) -> binary().
etag(Size, Mtime) ->
S = integer_to_binary(Size),
M = integer_to_binary(Mtime),
<<"W/\"", S/binary, "-", M/binary, "\"">>.
%% Look up the MIME type via `mimerl` (it expects a binary path so that
%% `filename:extension/1` yields a binary), then add a UTF-8 charset to
%% `text/*` types.
-spec mime_type(string()) -> binary().
mime_type(Path) ->
with_charset(mimerl:filename(iolist_to_binary(Path))).
-spec with_charset(binary()) -> binary().
with_charset(<<"text/", _/binary>> = Type) -> <<Type/binary, "; charset=utf-8">>;
with_charset(Type) -> Type.
%% Parse a single `Range: bytes=A-B | A- | -N` into `{Offset, Length|eof}`;
%% `undefined` when absent, multi-range, or malformed (emit yields 416 on
%% an unsatisfiable but well-formed range).
-spec parse_range(livery_req:req(), non_neg_integer()) ->
{non_neg_integer(), non_neg_integer() | eof} | undefined.
parse_range(Req, Size) ->
case livery_req:header(<<"range">>, Req) of
<<"bytes=", Spec/binary>> -> range_spec(Spec, Size);
_Other -> undefined
end.
-spec range_spec(binary(), non_neg_integer()) ->
{non_neg_integer(), non_neg_integer() | eof} | undefined.
range_spec(Spec, Size) ->
case binary:split(Spec, <<"-">>) of
[<<>>, SuffixBin] -> suffix_range(SuffixBin, Size);
[StartBin, <<>>] -> open_range(StartBin);
[StartBin, EndBin] -> closed_range(StartBin, EndBin);
_Other -> undefined
end.
-spec suffix_range(binary(), non_neg_integer()) ->
{non_neg_integer(), eof} | undefined.
suffix_range(SuffixBin, Size) ->
case to_int(SuffixBin) of
{ok, N} when N > 0 -> {max(0, Size - N), eof};
_Other -> undefined
end.
-spec open_range(binary()) -> {non_neg_integer(), eof} | undefined.
open_range(StartBin) ->
case to_int(StartBin) of
{ok, Start} -> {Start, eof};
error -> undefined
end.
-spec closed_range(binary(), binary()) ->
{non_neg_integer(), non_neg_integer()} | undefined.
closed_range(StartBin, EndBin) ->
case {to_int(StartBin), to_int(EndBin)} of
{{ok, Start}, {ok, End}} when Start =< End -> {Start, End - Start + 1};
_Other -> undefined
end.
-spec to_int(binary()) -> {ok, non_neg_integer()} | error.
to_int(Bin) ->
case string:to_integer(Bin) of
{Int, <<>>} when is_integer(Int), Int >= 0 -> {ok, Int};
_Other -> error
end.
%%====================================================================
%% Config
%%====================================================================
-spec config(opts()) -> map().
config(Opts) ->
#{
binding => maps:get(binding, Opts, <<"path">>),
prefix => maps:get(prefix, Opts, undefined),
index => maps:get(index, Opts, <<"index.html">>),
cache_control => maps:get(cache_control, Opts, undefined),
etag => maps:get(etag, Opts, true),
range => maps:get(range, Opts, true)
}.
-spec normalize_root(file:name_all()) -> string().
normalize_root(Root) when is_binary(Root) -> binary_to_list(Root);
normalize_root(Root) -> Root.