./ct_report/coverage/mod_http_upload.COVER.html

1 %%==============================================================================
2 %% Copyright 2016 Erlang Solutions Ltd.
3 %%
4 %% Licensed under the Apache License, Version 2.0 (the "License");
5 %% you may not use this file except in compliance with the License.
6 %% You may obtain a copy of the License at
7 %%
8 %% http://www.apache.org/licenses/LICENSE-2.0
9 %%
10 %% Unless required by applicable law or agreed to in writing, software
11 %% distributed under the License is distributed on an "AS IS" BASIS,
12 %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 %% See the License for the specific language governing permissions and
14 %% limitations under the License.
15 %%==============================================================================
16
17 -module(mod_http_upload).
18 -author('konrad.zemek@erlang-solutions.com').
19 -behaviour(gen_mod).
20 -behaviour(mongoose_module_metrics).
21
22 -xep([{xep, 363}, {version, "0.3.0"}]).
23
24 -include("jlib.hrl").
25 -include("mongoose.hrl").
26 -include("mongoose_config_spec.hrl").
27
28 -define(DEFAULT_TOKEN_BYTES, 32).
29 -define(DEFAULT_MAX_FILE_SIZE, 10 * 1024 * 1024). % 10 MB
30 -define(DEFAULT_SUBHOST, <<"upload.@HOST@">>).
31
32 %% gen_mod callbacks
33 -export([start/2,
34 stop/1,
35 config_spec/0,
36 supported_features/0]).
37
38 %% IQ and hooks handlers
39 -export([process_iq/5,
40 process_disco_iq/5,
41 disco_local_items/1]).
42
43 %% API
44 -export([get_urls/5]).
45
46 %% mongoose_module_metrics callbacks
47 -export([config_metrics/1]).
48
49 -ignore_xref([behaviour_info/1, disco_local_items/1, process_disco_iq/5, process_iq/5]).
50
51 %%--------------------------------------------------------------------
52 %% API
53 %%--------------------------------------------------------------------
54
55 -spec start(HostType :: mongooseim:host_type(), Opts :: gen_mod:module_opts()) -> ok.
56 start(HostType, Opts) ->
57 4 IQDisc = gen_mod:get_opt(iqdisc, Opts, one_queue),
58 4 SubdomainPattern = subdomain_pattern(HostType),
59 4 PacketHandler = mongoose_packet_handler:new(ejabberd_local),
60
61 4 mongoose_domain_api:register_subdomain(HostType, SubdomainPattern, PacketHandler),
62 4 [gen_iq_handler:add_iq_handler_for_subdomain(HostType, SubdomainPattern, Namespace,
63 Component, Fn, #{}, IQDisc) ||
64 4 {Component, Namespace, Fn} <- iq_handlers()],
65 4 mod_http_upload_backend:init(HostType, with_default_backend(Opts)),
66 4 ejabberd_hooks:add(hooks(HostType)),
67 4 ok.
68
69 -spec stop(HostType :: mongooseim:host_type()) -> ok.
70 stop(HostType) ->
71 4 SubdomainPattern = subdomain_pattern(HostType),
72
73 4 ejabberd_hooks:delete(hooks(HostType)),
74 4 [gen_iq_handler:remove_iq_handler_for_subdomain(HostType, SubdomainPattern, Namespace,
75 Component) ||
76 4 {Component, Namespace, _Fn} <- iq_handlers()],
77
78 4 mongoose_domain_api:unregister_subdomain(HostType, SubdomainPattern),
79 4 ok.
80
81 iq_handlers() ->
82 8 [{ejabberd_local, ?NS_HTTP_UPLOAD_030, fun ?MODULE:process_iq/5},
83 {ejabberd_local, ?NS_DISCO_INFO, fun ?MODULE:process_disco_iq/5}].
84
85 hooks(HostType) ->
86 8 [{disco_local_items, HostType, ?MODULE, disco_local_items, 90}].
87
88 %%--------------------------------------------------------------------
89 %% config_spec
90 %%--------------------------------------------------------------------
91
92 -spec config_spec() -> mongoose_config_spec:config_section().
93 config_spec() ->
94 146 #section{
95 items = #{<<"iqdisc">> => mongoose_config_spec:iqdisc(),
96 <<"host">> => #option{type = string,
97 validate = subdomain_template,
98 process = fun mongoose_subdomain_utils:make_subdomain_pattern/1},
99 <<"backend">> => #option{type = atom,
100 validate = {module, mod_http_upload}},
101 <<"expiration_time">> => #option{type = integer,
102 validate = positive},
103 <<"token_bytes">> => #option{type = integer,
104 validate = positive},
105 <<"max_file_size">> => #option{type = integer,
106 validate = positive},
107 <<"s3">> => s3_spec()
108 },
109 required = [<<"s3">>]
110 }.
111
112 s3_spec() ->
113 146 #section{
114 items = #{<<"bucket_url">> => #option{type = string,
115 validate = url},
116 <<"add_acl">> => #option{type = boolean},
117 <<"region">> => #option{type = string},
118 <<"access_key_id">> => #option{type = string},
119 <<"secret_access_key">> => #option{type = string}
120 },
121 required = [<<"bucket_url">>, <<"region">>, <<"access_key_id">>, <<"secret_access_key">>]
122 }.
123
124 -spec supported_features() -> [atom()].
125 supported_features() ->
126 4 [dynamic_domains].
127
128 %%--------------------------------------------------------------------
129 %% IQ and hook handlers
130 %%--------------------------------------------------------------------
131
132 -spec process_iq(Acc :: mongoose_acc:t(), From :: jid:jid(), To :: jid:jid(),
133 IQ :: jlib:iq(), map()) ->
134 {mongoose_acc:t(), jlib:iq() | ignore}.
135 process_iq(Acc, _From, _To, IQ = #iq{type = set, lang = Lang, sub_el = SubEl}, _Extra) ->
136 1 Error = mongoose_xmpp_errors:not_allowed(Lang, <<"IQ set is not allowed for HTTP upload">>),
137 1 {Acc, IQ#iq{type = error, sub_el = [SubEl, Error]}};
138 process_iq(Acc, _From, _To, IQ = #iq{type = get, sub_el = Request}, _Extra) ->
139 9 HostType = mongoose_acc:host_type(Acc),
140 9 Res = case parse_request(Request) of
141 {Filename, Size, ContentType} ->
142 6 MaxFileSize = max_file_size(HostType),
143 6 case MaxFileSize =:= undefined orelse Size =< MaxFileSize of
144 true ->
145 5 UTCDateTime = calendar:universal_time(),
146 5 Token = generate_token(HostType),
147 5 Opts = module_opts(HostType),
148
149 5 {PutUrl, GetUrl, Headers} =
150 mod_http_upload_backend:create_slot(HostType, UTCDateTime, Token, Filename,
151 ContentType, Size, Opts),
152
153 5 compose_iq_reply(IQ, PutUrl, GetUrl, Headers);
154
155 false ->
156 1 IQ#iq{type = error, sub_el = [file_too_large_error(MaxFileSize)]}
157 end;
158
159 bad_request ->
160 3 IQ#iq{type = error, sub_el = [Request, mongoose_xmpp_errors:bad_request()]}
161 end,
162 9 {Acc, Res}.
163
164 -spec process_disco_iq(Acc :: mongoose_acc:t(), From :: jid:jid(), To :: jid:jid(),
165 IQ :: jlib:iq(), map()) ->
166 {mongoose_acc:t(), jlib:iq()}.
167 process_disco_iq(Acc, _From, _To, #iq{type = set, lang = Lang, sub_el = SubEl} = IQ, _Extra) ->
168 1 ErrorMsg = <<"IQ set is not allowed for service discovery">>,
169 1 Error = mongoose_xmpp_errors:not_allowed(Lang, ErrorMsg),
170 1 {Acc, IQ#iq{type = error, sub_el = [SubEl, Error]}};
171 process_disco_iq(Acc, _From, _To, #iq{type = get, lang = Lang, sub_el = SubEl} = IQ, _Extra) ->
172 4 Node = xml:get_tag_attr_s(<<"node">>, SubEl),
173 4 case Node of
174 <<>> ->
175 3 Identity = mongoose_disco:identities_to_xml(disco_identity(Lang)),
176 3 Info = disco_info(mongoose_acc:host_type(Acc)),
177 3 Features = mongoose_disco:features_to_xml([?NS_HTTP_UPLOAD_030]),
178 3 {Acc, IQ#iq{type = result,
179 sub_el = [#xmlel{name = <<"query">>,
180 attrs = [{<<"xmlns">>, ?NS_DISCO_INFO}],
181 children = Identity ++ Info ++ Features}]}};
182 _ ->
183 1 ErrorMsg = <<"Node is not supported by HTTP upload">>,
184 1 Error = mongoose_xmpp_errors:item_not_found(Lang, ErrorMsg),
185 1 {Acc, IQ#iq{type = error, sub_el = [SubEl, Error]}}
186 end.
187
188 -spec disco_local_items(mongoose_disco:item_acc()) -> mongoose_disco:item_acc().
189 disco_local_items(Acc = #{host_type := HostType, to_jid := #jid{lserver = Domain}, node := <<>>, lang := Lang}) ->
190 1 mongoose_disco:add_items([#{jid => subdomain(HostType, Domain), name => my_disco_name(Lang)}], Acc);
191 disco_local_items(Acc) ->
192
:-(
Acc.
193
194 -spec get_urls(HostType :: mongooseim:host_type(), Filename :: binary(), Size :: pos_integer(),
195 ContentType :: binary() | undefined, Timeout :: pos_integer()) ->
196 {PutURL :: binary(), GetURL :: binary(), Headers :: #{binary() => binary()}}.
197 get_urls(HostType, Filename, Size, ContentType, Timeout) ->
198 4 UTCDateTime = calendar:universal_time(),
199 4 Token = generate_token(HostType),
200 4 Opts = module_opts(HostType),
201 4 NewOpts = gen_mod:set_opt(expiration_time, Opts, Timeout),
202 4 mod_http_upload_backend:create_slot(HostType, UTCDateTime, Token, Filename,
203 ContentType, Size, NewOpts).
204
205 %%--------------------------------------------------------------------
206 %% Helpers
207 %%--------------------------------------------------------------------
208
209 -spec disco_identity(ejabberd:lang()) -> [mongoose_disco:identity()].
210 disco_identity(Lang) ->
211 3 [#{category => <<"store">>,
212 type => <<"file">>,
213 name => my_disco_name(Lang)}].
214
215 -spec disco_info(mongooseim:host_type()) -> [exml:element()].
216 disco_info(HostType) ->
217 3 case max_file_size(HostType) of
218 undefined ->
219 1 [];
220 MaxFileSize ->
221 2 MaxFileSizeBin = integer_to_binary(MaxFileSize),
222 2 [get_disco_info_form(MaxFileSizeBin)]
223 end.
224
225 -spec subdomain(mongooseim:host_type(), mongooseim:domain_name()) -> mongooseim:domain_name().
226 subdomain(HostType, Domain) ->
227 1 SubdomainPattern = subdomain_pattern(HostType),
228 1 mongoose_subdomain_utils:get_fqdn(SubdomainPattern, Domain).
229
230 -spec subdomain_pattern(mongooseim:host_type()) ->
231 mongoose_subdomain_utils:subdomain_pattern().
232 subdomain_pattern(HostType) ->
233 9 DefaultSubdomainPattern =
234 mongoose_subdomain_utils:make_subdomain_pattern(?DEFAULT_SUBHOST),
235 9 gen_mod:get_module_opt(HostType, ?MODULE, host, DefaultSubdomainPattern).
236
237 -spec my_disco_name(ejabberd:lang()) -> binary().
238 my_disco_name(Lang) ->
239 4 translate:translate(Lang, <<"HTTP File Upload">>).
240
241
242 -spec compose_iq_reply(IQ :: jlib:iq(),
243 PutUrl :: binary(),
244 GetUrl :: binary(),
245 Headers :: #{binary() => binary()}) ->
246 Reply :: jlib:iq().
247 compose_iq_reply(IQ, PutUrl, GetUrl, Headers) ->
248 5 Slot = #xmlel{
249 name = <<"slot">>,
250 attrs = [{<<"xmlns">>, ?NS_HTTP_UPLOAD_030}],
251 children = [create_url_xmlel(<<"put">>, PutUrl, Headers),
252 create_url_xmlel(<<"get">>, GetUrl, #{})]},
253 5 IQ#iq{type = result, sub_el =[Slot]}.
254
255
256 -spec token_bytes(mongooseim:host_type()) -> pos_integer().
257 token_bytes(HostType) ->
258 9 gen_mod:get_module_opt(HostType, ?MODULE, token_bytes, ?DEFAULT_TOKEN_BYTES).
259
260
261 -spec max_file_size(mongooseim:host_type()) -> pos_integer() | undefined.
262 max_file_size(HostType) ->
263 9 gen_mod:get_module_opt(HostType, ?MODULE, max_file_size, ?DEFAULT_MAX_FILE_SIZE).
264
265
266 -spec with_default_backend(Opts :: proplists:proplist()) -> proplists:proplist().
267 with_default_backend(Opts) ->
268 4 case lists:keyfind(backend, 1, Opts) of
269 4 false -> [{backend, s3} | Opts];
270
:-(
_ -> Opts
271 end.
272
273
274 -spec module_opts(mongooseim:host_type())-> proplists:proplist().
275 module_opts(HostType) ->
276 9 gen_mod:get_module_opts(HostType, ?MODULE).
277
278
279 -spec generate_token(mongooseim:host_type()) -> binary().
280 generate_token(HostType) ->
281 9 base16:encode(crypto:strong_rand_bytes(token_bytes(HostType))).
282
283
284 -spec file_too_large_error(MaxFileSize :: non_neg_integer()) -> exml:element().
285 file_too_large_error(MaxFileSize) ->
286 1 MaxFileSizeBin = integer_to_binary(MaxFileSize),
287 1 MaxSizeEl = #xmlel{name = <<"max-file-size">>,
288 children = [#xmlcdata{content = MaxFileSizeBin}]},
289 1 FileTooLargeEl = #xmlel{name = <<"file-too-large">>,
290 attrs = [{<<"xmlns">>, ?NS_HTTP_UPLOAD_030}],
291 children = [MaxSizeEl]},
292 1 Error0 = mongoose_xmpp_errors:not_acceptable(),
293 1 Error0#xmlel{children = [FileTooLargeEl | Error0#xmlel.children]}.
294
295
296 -spec parse_request(Request :: exml:element()) ->
297 {Filename :: binary(), Size :: integer(), ContentType :: binary() | undefined} | bad_request.
298 parse_request(Request) ->
299 9 Keys = [<<"filename">>, <<"size">>, <<"content-type">>],
300 9 [Filename, SizeBin, ContentType] = [exml_query:attr(Request, K) || K <- Keys],
301 9 Size = (catch erlang:binary_to_integer(SizeBin)),
302 9 case is_nonempty_binary(Filename) andalso is_positive_integer(Size) of
303 3 false -> bad_request;
304 6 true -> {Filename, Size, ContentType}
305 end.
306
307
308 -spec get_disco_info_form(MaxFileSizeBin :: binary()) -> exml:element().
309 get_disco_info_form(MaxFileSizeBin) ->
310 2 #xmlel{name = <<"x">>,
311 attrs = [{<<"xmlns">>, ?NS_XDATA}, {<<"type">>, <<"result">>}],
312 children = [jlib:form_field({<<"FORM_TYPE">>, <<"hidden">>, ?NS_HTTP_UPLOAD_030}),
313 jlib:form_field({<<"max-file-size">>, MaxFileSizeBin})]}.
314
315
316 -spec header_to_xmlel({Key :: binary(), Value :: binary()}) -> exml:element().
317 header_to_xmlel({Key, Value}) ->
318
:-(
#xmlel{name = <<"header">>,
319 attrs = [{<<"name">>, Key}],
320 children = [#xmlcdata{content = Value}]}.
321
322
323 -spec create_url_xmlel(Name :: binary(), Url :: binary(), Headers :: #{binary() => binary()}) ->
324 exml:element().
325 create_url_xmlel(Name, Url, Headers) ->
326 10 HeadersXml = [header_to_xmlel(H) || H <- maps:to_list(Headers)],
327 10 #xmlel{name = Name, attrs = [{<<"url">>, Url}], children = HeadersXml}.
328
329
330 -spec is_nonempty_binary(term()) -> boolean().
331 8 is_nonempty_binary(<<_, _/binary>>) -> true;
332 1 is_nonempty_binary(_) -> false.
333
334
335 -spec is_positive_integer(term()) -> boolean().
336 7 is_positive_integer(X) when is_integer(X) -> X > 0;
337 1 is_positive_integer(_) -> false.
338
339 config_metrics(HostType) ->
340
:-(
OptsToReport = [{backend, s3}], % list of tuples {option, default_value}
341
:-(
mongoose_module_metrics:opts_for_module(HostType, ?MODULE, OptsToReport).
Line Hits Source