Skip to content

Commit

Permalink
Add cowboy_decompress_h stream handler
Browse files Browse the repository at this point in the history
  • Loading branch information
jdamanalo committed Dec 5, 2023
1 parent 879a6b8 commit 0794fa1
Show file tree
Hide file tree
Showing 10 changed files with 681 additions and 2 deletions.
5 changes: 5 additions & 0 deletions doc/src/guide/streams.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ automatically compress responses when possible. It is not
enabled by default. It is a good example for writing your
own handlers that will modify responses.

link:man:cowboy_decompress_h(3)[cowboy_decompress_h] will
automatically decompress requests when possible. It is not
enabled by default. It is a good example for writing your
own handlers that will modify requests.

link:man:cowboy_metrics_h(3)[cowboy_metrics_h] gathers
metrics about a stream then passes them to a configurable
function. It is not enabled by default.
Expand Down
1 change: 1 addition & 0 deletions doc/src/manual/cowboy_compress_h.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ The compress stream handler does not produce any event.

link:man:cowboy(7)[cowboy(7)],
link:man:cowboy_stream(3)[cowboy_stream(3)],
link:man:cowboy_decompress_h(3)[cowboy_decompress_h(3)],
link:man:cowboy_metrics_h(3)[cowboy_metrics_h(3)],
link:man:cowboy_stream_h(3)[cowboy_stream_h(3)],
link:man:cowboy_tracer_h(3)[cowboy_tracer_h(3)]
58 changes: 58 additions & 0 deletions doc/src/manual/cowboy_decompress_h.asciidoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
= cowboy_decompress_h(3)

== Name

cowboy_decompress_h - Decompress stream handler

== Description

The module `cowboy_decompress_h` decompresses request bodies
automatically when the server supports it. Requests will
only be decompressed when their compression ratio is lower
than the configured limit. Mismatch of the content and
`content-encoding` is rejected with `400 Bad Request`.

== Options

[source,erlang]
----
opts() :: #{
decompress_ratio_limit => non_neg_integer(),
decompress_ignore => boolean()
}
----

Configuration for the decompress stream handler.

The default value is given next to the option name:

decompress_ratio_limit (20)::
The max ratio of the compressed and decompressed body
before it is rejected with `413 Payload Too Large`.
+
This option can be updated at any time using the
`set_options` stream handler command.

decompress_ignore (false)::

Whether the handler will be ignored.
+
This option can be updated at any time using the
`set_options` stream handler command.

== Events

The decompress stream handler does not produce any event.

== Changelog

* *2.11*: Module introduced.

== See also

link:man:cowboy(7)[cowboy(7)],
link:man:cowboy_stream(3)[cowboy_stream(3)],
link:man:cowboy_compress_h(3)[cowboy_compress_h(3)],
link:man:cowboy_metrics_h(3)[cowboy_metrics_h(3)],
link:man:cowboy_stream_h(3)[cowboy_stream_h(3)],
link:man:cowboy_tracer_h(3)[cowboy_tracer_h(3)]
1 change: 1 addition & 0 deletions doc/src/manual/cowboy_metrics_h.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -160,5 +160,6 @@ The metrics stream handler does not produce any event.
link:man:cowboy(7)[cowboy(7)],
link:man:cowboy_stream(3)[cowboy_stream(3)],
link:man:cowboy_compress_h(3)[cowboy_compress_h(3)],
link:man:cowboy_decompress_h(3)[cowboy_decompress_h(3)],
link:man:cowboy_stream_h(3)[cowboy_stream_h(3)],
link:man:cowboy_tracer_h(3)[cowboy_tracer_h(3)]
1 change: 1 addition & 0 deletions doc/src/manual/cowboy_stream_h.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,6 @@ may not work properly if they are executed
link:man:cowboy(7)[cowboy(7)],
link:man:cowboy_stream(3)[cowboy_stream(3)],
link:man:cowboy_compress_h(3)[cowboy_compress_h(3)],
link:man:cowboy_decompress_h(3)[cowboy_decompress_h(3)],
link:man:cowboy_metrics_h(3)[cowboy_metrics_h(3)],
link:man:cowboy_tracer_h(3)[cowboy_tracer_h(3)]
1 change: 1 addition & 0 deletions doc/src/manual/cowboy_tracer_h.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,6 @@ The tracer stream handler does not produce any event.
link:man:cowboy(7)[cowboy(7)],
link:man:cowboy_stream(3)[cowboy_stream(3)],
link:man:cowboy_compress_h(3)[cowboy_compress_h(3)],
link:man:cowboy_decompress_h(3)[cowboy_decompress_h(3)],
link:man:cowboy_metrics_h(3)[cowboy_metrics_h(3)],
link:man:cowboy_stream_h(3)[cowboy_stream_h(3)]
4 changes: 2 additions & 2 deletions ebin/cowboy.app
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{application, 'cowboy', [
{description, "Small, fast, modern HTTP server."},
{vsn, "2.10.0"},
{modules, ['cowboy','cowboy_app','cowboy_bstr','cowboy_children','cowboy_clear','cowboy_clock','cowboy_compress_h','cowboy_constraints','cowboy_handler','cowboy_http','cowboy_http2','cowboy_loop','cowboy_metrics_h','cowboy_middleware','cowboy_req','cowboy_rest','cowboy_router','cowboy_static','cowboy_stream','cowboy_stream_h','cowboy_sub_protocol','cowboy_sup','cowboy_tls','cowboy_tracer_h','cowboy_websocket']},
{modules, ['cowboy','cowboy_app','cowboy_bstr','cowboy_children','cowboy_clear','cowboy_clock','cowboy_compress_h','cowboy_constraints','cowboy_decompress_h','cowboy_handler','cowboy_http','cowboy_http2','cowboy_loop','cowboy_metrics_h','cowboy_middleware','cowboy_req','cowboy_rest','cowboy_router','cowboy_static','cowboy_stream','cowboy_stream_h','cowboy_sub_protocol','cowboy_sup','cowboy_tls','cowboy_tracer_h','cowboy_websocket']},
{registered, [cowboy_sup,cowboy_clock]},
{applications, [kernel,stdlib,crypto,cowlib,ranch]},
{optional_applications, []},
{mod, {cowboy_app, []}},
{env, []}
]}.
]}.
217 changes: 217 additions & 0 deletions src/cowboy_decompress_h.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
-module(cowboy_decompress_h).
-behavior(cowboy_stream).

-export([init/3]).
-export([data/4]).
-export([info/3]).
-export([terminate/3]).
-export([early_error/5]).

-record(state, {
next :: any(),
ratio_limit :: non_neg_integer() | undefined,
ignore = false :: boolean(),
compress = undefined :: undefined | gzip,
inflate = undefined :: undefined | zlib:zstream(),
is_reading = false :: boolean(),
read_body_buffer = <<>> :: binary(),
read_body_is_fin = nofin :: nofin | {fin, non_neg_integer()}
}).

-spec init(cowboy_stream:streamid(), cowboy_req:req(), cowboy:opts())
-> {cowboy_stream:commands(), #state{}}.
init(StreamID, Req, Opts) ->
RatioLimit = maps:get(decompress_ratio_limit, Opts, 20),
Ignore = maps:get(decompress_ignore, Opts, false),
State = check_req(Req),
Inflate = case State#state.compress of
undefined ->
undefined;
gzip ->
Z = zlib:open(),
zlib:inflateInit(Z, 31),
Z
end,
{Commands, Next} = cowboy_stream:init(StreamID, Req, Opts),
fold(Commands, State#state{next=Next, ratio_limit=RatioLimit, ignore=Ignore,
inflate=Inflate}).

-spec data(cowboy_stream:streamid(), cowboy_stream:fin(), cowboy_req:resp_body(), State)
-> {cowboy_stream:commands(), State} when State::#state{}.
data(StreamID, IsFin, Data, State=#state{next=Next0, inflate=undefined}) ->
{Commands, Next} = cowboy_stream:data(StreamID, IsFin, Data, Next0),
fold(Commands, State#state{next=Next, read_body_is_fin=IsFin});
data(StreamID, IsFin, Data, State=#state{next=Next0, ignore=true, read_body_buffer=Buffer}) ->
{Commands, Next} = cowboy_stream:data(StreamID, IsFin,
<< Buffer/binary, Data/binary >>, Next0),
fold(Commands, State#state{next=Next, read_body_is_fin=IsFin});
data(StreamID, IsFin, Data, State0=#state{next=Next0, ratio_limit=RatioLimit,
inflate=Z, is_reading=true, read_body_buffer=Buffer0}) ->
Buffer = << Buffer0/binary, Data/binary >>,
case inflate(Z, RatioLimit, Buffer) of
{error, Type} ->
Status = case Type of
data -> 400;
size -> 413
end,
Commands = [
{error_response, Status, #{<<"content-length">> => <<"0">>}, <<>>},
stop
],
fold(Commands, State0#state{inflate=undefined});
{ok, Inflated} ->
State = case IsFin of
nofin ->
State0;
fin ->
zlib:inflateEnd(Z),
zlib:close(Z),
State0#state{inflate=undefined}
end,
{Commands, Next} = cowboy_stream:data(StreamID, IsFin, Inflated, Next0),
fold(Commands, State#state{next=Next, read_body_buffer= <<>>,
read_body_is_fin=IsFin})
end;
data(_, IsFin, Data, State=#state{read_body_buffer=Buffer0}) ->
Buffer = << Buffer0/binary, Data/binary >>,
{[], State#state{read_body_buffer=Buffer, read_body_is_fin=IsFin}}.

-spec info(cowboy_stream:streamid(), any(), State)
-> {cowboy_stream:commands(), State} when State::#state{}.
info(StreamID, Info, State=#state{next=Next0, inflate=undefined}) ->
{Commands, Next} = cowboy_stream:info(StreamID, Info, Next0),
fold(Commands, State#state{next=Next});
info(StreamID, Info={CommandTag, _, _, _, _}, State=#state{next=Next0, read_body_is_fin=IsFin})
when CommandTag =:= read_body; CommandTag =:= read_body_timeout ->
{Commands0, Next1} = cowboy_stream:info(StreamID, Info, Next0),
{Commands, Next} = data(StreamID, IsFin, <<>>, State#state{next=Next1, is_reading=true}),
fold(Commands ++ Commands0, Next);
info(StreamID, Info={set_options, Opts}, State=#state{next=Next0,
ignore=Ignore0, ratio_limit=RatioLimit0}) ->
Ignore = maps:get(decompress_ignore, Opts, Ignore0),
RatioLimit = maps:get(decompress_ratio_limit, Opts, RatioLimit0),
{Commands, Next} = cowboy_stream:info(StreamID, Info, Next0),
fold(Commands, State#state{next=Next, ignore=Ignore, ratio_limit=RatioLimit});
info(StreamID, Info, State=#state{next=Next0}) ->
{Commands, Next} = cowboy_stream:info(StreamID, Info, Next0),
fold(Commands, State#state{next=Next}).

-spec terminate(cowboy_stream:streamid(), cowboy_stream:reason(), #state{}) -> any().
terminate(StreamID, Reason, #state{next=Next, inflate=Z}) ->
case Z of
undefined -> ok;
_ -> zlib:close(Z)
end,
cowboy_stream:terminate(StreamID, Reason, Next).

-spec early_error(cowboy_stream:streamid(), cowboy_stream:reason(),
cowboy_stream:partial_req(), Resp, cowboy:opts()) -> Resp
when Resp::cowboy_stream:resp_command().
early_error(StreamID, Reason, PartialReq, Resp, Opts) ->
cowboy_stream:early_error(StreamID, Reason, PartialReq, Resp, Opts).

%% Internal.

check_req(Req) ->
try cowboy_req:parse_header(<<"content-encoding">>, Req) of
undefined ->
#state{compress=undefined};
Encodings ->
case [E || E=(<<"gzip">>) <- Encodings] of
[] ->
#state{compress=undefined};
_ ->
#state{compress=gzip}
end
catch
_:_ ->
#state{compress=undefined}
end.

fold(Commands, State) ->
fold(Commands, State, []).

fold([], State, Acc) ->
{lists:reverse(Acc), State};
fold([{response, Status, Headers0, Body}|Tail], State=#state{ignore=false}, Acc) ->
Headers = add_accept_encoding(Headers0),
fold(Tail, State, [{response, Status, Headers, Body}|Acc]);
fold([{headers, Status, Headers0} | Tail], State=#state{ignore=false}, Acc) ->
Headers = add_accept_encoding(Headers0),
fold(Tail, State, [{headers, Status, Headers}|Acc]);
fold([Command|Tail], State, Acc) ->
fold(Tail, State, [Command|Acc]).

add_accept_encoding(Headers=#{<<"accept-encoding">> := AcceptEncoding}) ->
try cow_http_hd:parse_accept_encoding(iolist_to_binary(AcceptEncoding)) of
List ->
case lists:keyfind(<<"gzip">>, 1, List) of
%% gzip is excluded but this handler is not ignored; we replace.
{_, 0} ->
Replaced = lists:keyreplace(<<"gzip">>, 1, List, {<<"gzip">>, 1000}),
Codings = build_accept_encoding(Replaced),
Headers#{<<"accept-encoding">> => Codings};
{_, _} ->
Headers;
false ->
case lists:keyfind(<<"*">>, 1, List) of
%% Others are excluded along with gzip; we add.
{_, 0} ->
WithGzip = [{<<"gzip">>, 1000} | List],
Codings = build_accept_encoding(WithGzip),
Headers#{<<"accept-encoding">> => Codings};
{_, _} ->
Headers;
false ->
Headers#{<<"accept-encoding">> => [AcceptEncoding, <<", gzip">>]}
end
end
catch _:_ ->
Headers#{<<"accept-encoding">> => <<"gzip">>}
end;
add_accept_encoding(Headers) ->
Headers#{<<"accept-encoding">> => <<"gzip">>}.

%% From cowlib, maybe expose?
qvalue_to_iodata(0) -> <<"0">>;
qvalue_to_iodata(Q) when Q < 10 -> [<<"0.00">>, integer_to_binary(Q)];
qvalue_to_iodata(Q) when Q < 100 -> [<<"0.0">>, integer_to_binary(Q)];
qvalue_to_iodata(Q) when Q < 1000 -> [<<"0.">>, integer_to_binary(Q)];
qvalue_to_iodata(1000) -> <<"1">>.

build_accept_encoding([{ContentCoding, Q}|Tail]) ->
Weight = iolist_to_binary(qvalue_to_iodata(Q)),
Acc = <<ContentCoding/binary, ";q=", Weight/binary>>,
do_build_accept_encoding(Tail, Acc).

do_build_accept_encoding([{ContentCoding, Q}|Tail], Acc0) ->
Weight = iolist_to_binary(qvalue_to_iodata(Q)),
Acc = <<Acc0/binary, ", ", ContentCoding/binary, ";q=", Weight/binary>>,
do_build_accept_encoding(Tail, Acc);
do_build_accept_encoding([], Acc) ->
Acc.

inflate(Z, RatioLimit, Data) ->
try
{Status, Output} = zlib:safeInflate(Z, Data),
Size = iolist_size(Output),
do_inflate(Z, Size, byte_size(Data) * RatioLimit, Status, [Output])
catch
error:data_error ->
zlib:close(Z),
{error, data}
end.

do_inflate(Z, Size, Limit, Status, _) when Size > Limit ->
case Status of
continue -> ok;
finished -> zlib:inflateEnd(Z)
end,
zlib:close(Z),
{error, size};
do_inflate(Z, Size0, Limit, continue, Acc) ->
{Status, Output} = zlib:safeInflate(Z, []),
Size = Size0 + iolist_size(Output),
do_inflate(Z, Size, Limit, Status, [Output | Acc]);
do_inflate(_, _, _, finished, Acc) ->
{ok, iolist_to_binary(lists:reverse(Acc))}.
Loading

0 comments on commit 0794fa1

Please sign in to comment.