|
@@ -0,0 +1,167 @@
|
|
|
+%% Copyright (c) 2017, Loïc Hoguin <essen@ninenines.eu>
|
|
|
+%%
|
|
|
+%% 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_compress_h).
|
|
|
+-behavior(cowboy_stream).
|
|
|
+
|
|
|
+-export([init/3]).
|
|
|
+-export([data/4]).
|
|
|
+-export([info/3]).
|
|
|
+-export([terminate/3]).
|
|
|
+
|
|
|
+-record(state, {
|
|
|
+ next :: any(),
|
|
|
+ compress = undefined :: undefined | gzip,
|
|
|
+ deflate = undefined :: undefined | zlib:zstream()
|
|
|
+}).
|
|
|
+
|
|
|
+-spec init(cowboy_stream:streamid(), cowboy_req:req(), cowboy:opts())
|
|
|
+ -> {cowboy_stream:commands(), #state{}}.
|
|
|
+init(StreamID, Req, Opts) ->
|
|
|
+ State0 = check_req(Req),
|
|
|
+ {Commands0, Next} = cowboy_stream:init(StreamID, Req, Opts),
|
|
|
+ fold(Commands0, State0#state{next=Next}).
|
|
|
+
|
|
|
+-spec data(cowboy_stream:streamid(), cowboy_stream:fin(), cowboy_req:resp_body(), State)
|
|
|
+ -> {cowboy_stream:commands(), State} when State::#state{}.
|
|
|
+data(StreamID, IsFin, Data, State0=#state{next=Next0}) ->
|
|
|
+ {Commands0, Next} = cowboy_stream:data(StreamID, IsFin, Data, Next0),
|
|
|
+ fold(Commands0, State0#state{next=Next}).
|
|
|
+
|
|
|
+-spec info(cowboy_stream:streamid(), any(), State)
|
|
|
+ -> {cowboy_stream:commands(), State} when State::#state{}.
|
|
|
+info(StreamID, Info, State0=#state{next=Next0}) ->
|
|
|
+ {Commands0, Next} = cowboy_stream:info(StreamID, Info, Next0),
|
|
|
+ fold(Commands0, State0#state{next=Next}).
|
|
|
+
|
|
|
+-spec terminate(cowboy_stream:streamid(), cowboy_stream:reason(), #state{}) -> any().
|
|
|
+terminate(StreamID, Reason, #state{next=Next, deflate=Z}) ->
|
|
|
+ %% Clean the zlib:stream() in case something went wrong.
|
|
|
+ %% In the normal scenario the stream is already closed.
|
|
|
+ case Z of
|
|
|
+ undefined -> ok;
|
|
|
+ _ -> zlib:close(Z)
|
|
|
+ end,
|
|
|
+ cowboy_stream:terminate(StreamID, Reason, Next).
|
|
|
+
|
|
|
+%% Internal.
|
|
|
+
|
|
|
+%% Check if the client supports decoding of gzip responses.
|
|
|
+check_req(Req) ->
|
|
|
+ case cowboy_req:parse_header(<<"accept-encoding">>, Req) of
|
|
|
+ %% Client doesn't support any compression algorithm.
|
|
|
+ undefined ->
|
|
|
+ #state{compress=undefined};
|
|
|
+ Encodings ->
|
|
|
+ %% We only support gzip so look for it specifically.
|
|
|
+ %% @todo A recipient SHOULD consider "x-gzip" to be
|
|
|
+ %% equivalent to "gzip". (RFC7230 4.2.3)
|
|
|
+ case [E || E={<<"gzip">>, Q} <- Encodings, Q =/= 0] of
|
|
|
+ [] ->
|
|
|
+ #state{compress=undefined};
|
|
|
+ _ ->
|
|
|
+ #state{compress=gzip}
|
|
|
+ end
|
|
|
+ end.
|
|
|
+
|
|
|
+%% Do not compress responses that contain the content-encoding header.
|
|
|
+check_resp_headers(#{<<"content-encoding">> := _}, State) ->
|
|
|
+ State#state{compress=undefined};
|
|
|
+check_resp_headers(_, State) ->
|
|
|
+ State.
|
|
|
+
|
|
|
+fold(Commands, State=#state{compress=undefined}) ->
|
|
|
+ {Commands, State};
|
|
|
+fold(Commands, State) ->
|
|
|
+ fold(Commands, State, []).
|
|
|
+
|
|
|
+fold([], State, Acc) ->
|
|
|
+ {lists:reverse(Acc), State};
|
|
|
+%% We do not compress sendfile bodies.
|
|
|
+fold([Response={response, _, _, {sendfile, _, _, _}}|Tail], State, Acc) ->
|
|
|
+ fold(Tail, State, [Response|Acc]);
|
|
|
+%% We compress full responses directly, unless they are lower than
|
|
|
+%% 300 bytes or we find we are not able to by looking at the headers.
|
|
|
+%% @todo It might be good to allow this size to be configured?
|
|
|
+fold([Response0={response, _, Headers, Body}|Tail], State0, Acc) ->
|
|
|
+ case check_resp_headers(Headers, State0) of
|
|
|
+ State=#state{compress=undefined} ->
|
|
|
+ fold(Tail, State, [Response0|Acc]);
|
|
|
+ State1 ->
|
|
|
+ BodyLength = iolist_size(Body),
|
|
|
+ if
|
|
|
+ BodyLength =< 300 ->
|
|
|
+ fold(Tail, State1, [Response0|Acc]);
|
|
|
+ true ->
|
|
|
+ {Response, State} = gzip_response(Response0, State1),
|
|
|
+ fold(Tail, State, [Response|Acc])
|
|
|
+ end
|
|
|
+ end;
|
|
|
+%% Check headers and initiate compression...
|
|
|
+fold([Response0={headers, _, Headers}|Tail], State0, Acc) ->
|
|
|
+ case check_resp_headers(Headers, State0) of
|
|
|
+ State=#state{compress=undefined} ->
|
|
|
+ fold(Tail, State, [Response0|Acc]);
|
|
|
+ State1 ->
|
|
|
+ {Response, State} = gzip_headers(Response0, State1),
|
|
|
+ fold(Tail, State, [Response|Acc])
|
|
|
+ end;
|
|
|
+%% then compress each data commands individually.
|
|
|
+fold([Data0={data, _, _}|Tail], State0=#state{compress=gzip}, Acc) ->
|
|
|
+ {Data, State} = gzip_data(Data0, State0),
|
|
|
+ fold(Tail, State, [Data|Acc]);
|
|
|
+%% Otherwise, we either have an unrelated command, or a data command
|
|
|
+%% with compression disabled.
|
|
|
+fold([Command|Tail], State, Acc) ->
|
|
|
+ fold(Tail, State, [Command|Acc]).
|
|
|
+
|
|
|
+gzip_response({response, Status, Headers, Body}, State) ->
|
|
|
+ %% We can't call zlib:gzip/1 because it does an
|
|
|
+ %% iolist_to_binary(GzBody) at the end to return
|
|
|
+ %% a binary(). Therefore the code here is largely
|
|
|
+ %% a duplicate of the code of that function.
|
|
|
+ Z = zlib:open(),
|
|
|
+ GzBody = try
|
|
|
+ %% 31 = 16+?MAX_WBITS from zlib.erl
|
|
|
+ %% @todo It might be good to allow them to be configured?
|
|
|
+ zlib:deflateInit(Z, default, deflated, 31, 8, default),
|
|
|
+ Gz = zlib:deflate(Z, Body, finish),
|
|
|
+ zlib:deflateEnd(Z),
|
|
|
+ Gz
|
|
|
+ after
|
|
|
+ zlib:close(Z)
|
|
|
+ end,
|
|
|
+ {{response, Status, Headers#{
|
|
|
+ <<"content-length">> => integer_to_binary(iolist_size(GzBody)),
|
|
|
+ <<"content-encoding">> => <<"gzip">>
|
|
|
+ }, GzBody}, State}.
|
|
|
+
|
|
|
+gzip_headers({headers, Status, Headers0}, State) ->
|
|
|
+ Z = zlib:open(),
|
|
|
+ %% We use the same arguments as when compressing the body fully.
|
|
|
+ %% @todo It might be good to allow them to be configured?
|
|
|
+ zlib:deflateInit(Z, default, deflated, 31, 8, default),
|
|
|
+ Headers = maps:remove(<<"content-length">>, Headers0),
|
|
|
+ {{headers, Status, Headers#{
|
|
|
+ <<"content-encoding">> => <<"gzip">>
|
|
|
+ }}, State#state{deflate=Z}}.
|
|
|
+
|
|
|
+gzip_data({data, nofin, Data0}, State=#state{deflate=Z}) ->
|
|
|
+ Data = zlib:deflate(Z, Data0),
|
|
|
+ {{data, nofin, Data}, State};
|
|
|
+gzip_data({data, fin, Data0}, State=#state{deflate=Z}) ->
|
|
|
+ Data = zlib:deflate(Z, Data0, finish),
|
|
|
+ zlib:deflateEnd(Z),
|
|
|
+ zlib:close(Z),
|
|
|
+ {{data, fin, Data}, State#state{deflate=undefined}}.
|