-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 <> -> 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(<>) 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, <>); _Bad -> error end; percent_decode(<<$%, _/binary>>, _Acc) -> error; percent_decode(<>, Acc) -> percent_decode(Rest, <>). -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) -> <>; 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.