aboutsummaryrefslogblamecommitdiffstats
path: root/src/cowboy_compress_h.erl
blob: 9c2ae34ec51df18fcd92374d74ac7916ad22c4d0 (plain) (tree)



















                                                                           
                         


































                                                                                        




                                                                            












































































































                                                                                             
%% Copyright (c) 2017, Loïc Hoguin <[email protected]>
%%
%% 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]).
-export([early_error/5]).

-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).

-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 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}}.