./ct_report/coverage/mod_smart_markers.COVER.html

1 %%%----------------------------------------------------------------------------
2 %%% @copyright (C) 2020, Erlang Solutions Ltd.
3 %%% @doc
4 %%% This module implements storage of the latest chat markers
5 %%% sent by the users. This can be used to optimize mod_offline
6 %%% functionality, or to implement custom fetching protocol and
7 %%% avoid storage of chat markers in MAM.
8 %%%
9 %%% Please be aware of the next implementation details:
10 %%%
11 %%% 1) Current implementation is based on user_send_packet hook.
12 %%% It doesn't work for s2s connections, but usage of another
13 %%% hook (e.g. filter_local_packet) makes implementation harder
14 %%% and results in multiple processing of one and the same
15 %%% chat marker notification (sent to different users by MUC).
16 %%% However that is the only possible way to deal with group
17 %%% chat messages sent from the room to the user over s2s.
18 %%%
19 %%% ```
20 %%% S2S
21 %%% +
22 %%% |
23 %%% +--------------------+ |
24 %%% | | | filter
25 %%% | +--------------->
26 %%% send | | | filter
27 %%% +------->+ ROOM +--------------->
28 %%% | | | filter
29 %%% | +--------------->
30 %%% | | |
31 %%% +--------------------+ |
32 %%% |
33 %%% +
34 %%% '''
35 %%%
36 %%% 2) DB backend requires us to provide host information, and
37 %%% the host is always the recipient's server in case one2one
38 %%% messages, and a master domain of the MUC service in case
39 %%% of groupchat.
40 %%%
41 %%% 3) It is the client application's responsibility to ensure that
42 %%% chat markers move only forward. There is no verification of
43 %%% chat markers in this module, it just stores the latest chat
44 %%% marker information sent by the user.
45 %%%
46 %%% 4) MUC light doesn't have message serialization! So it doesn't
47 %%% guarantee one and the same message order for different users.
48 %%% This can result in a race condition situation when different
49 %%% users track (and mark) different messages as the last in a
50 %%% chat history. However, this is a rare situation, and it self
51 %%% recovers on the next message in the room. Anyway storing chat
52 %%% markers in MAM doesn't fix this problem.
53 %%%
54 %%% @end
55 %%%----------------------------------------------------------------------------
56 -module(mod_smart_markers).
57
58 -include("jlib.hrl").
59 -include("mod_muc_light.hrl").
60 -include("mongoose_config_spec.hrl").
61
62 -xep([{xep, 333}, {version, "0.4"}]).
63 -behaviour(gen_mod).
64
65 %% gen_mod API
66 -export([start/2, stop/1, supported_features/0, config_spec/0]).
67
68 %% Internal API
69 -export([get_chat_markers/3]).
70
71 %% Hook handlers
72 -export([process_iq/5, user_send_packet/4, filter_local_packet/1,
73 remove_user/3, remove_domain/3, forget_room/4, room_new_affiliations/4]).
74 -ignore_xref([process_iq/5, user_send_packet/4, filter_local_packet/1,
75 remove_user/3, remove_domain/3, forget_room/4, room_new_affiliations/4]).
76
77 %%--------------------------------------------------------------------
78 %% Type declarations
79 %%--------------------------------------------------------------------
80 -type maybe_thread() :: undefined | binary().
81 -type chat_type() :: one2one | groupchat.
82 -type chat_marker() :: #{from := jid:jid(),
83 to := jid:jid(),
84 thread := maybe_thread(), % it is not optional!
85 type := mongoose_chat_markers:chat_marker_type(),
86 timestamp := integer(), % microsecond
87 id := binary()}.
88
89 -export_type([chat_marker/0]).
90
91 %% gen_mod API
92 -spec start(mongooseim:host_type(), gen_mod:module_opts()) -> any().
93 start(HostType, #{iqdisc := IQDisc, keep_private := Private} = Opts) ->
94 8 mod_smart_markers_backend:init(HostType, Opts),
95 8 gen_iq_handler:add_iq_handler_for_domain(
96 HostType, ?NS_ESL_SMART_MARKERS, ejabberd_sm,
97 fun ?MODULE:process_iq/5, #{keep_private => Private}, IQDisc),
98 8 ejabberd_hooks:add(hooks(HostType, Opts)).
99
100 -spec stop(mongooseim:host_type()) -> ok.
101 stop(HostType) ->
102 8 Opts = gen_mod:get_module_opts(HostType, ?MODULE),
103 8 case gen_mod:get_opt(backend, Opts) of
104 3 rdbms_async -> mod_smart_markers_rdbms_async:stop(HostType);
105 5 _ -> ok
106 end,
107 8 gen_iq_handler:remove_iq_handler_for_domain(HostType, ?NS_ESL_SMART_MARKERS, ejabberd_sm),
108 8 ejabberd_hooks:delete(hooks(HostType, Opts)).
109
110 -spec supported_features() -> [atom()].
111 supported_features() ->
112 8 [dynamic_domains].
113
114 -spec config_spec() -> mongoose_config_spec:config_section().
115 config_spec() ->
116 152 #section{
117 items = #{<<"keep_private">> => #option{type = boolean},
118 <<"backend">> => #option{type = atom, validate = {enum, [rdbms, rdbms_async]}},
119 <<"async_writer">> => async_config_spec(),
120 <<"iqdisc">> => mongoose_config_spec:iqdisc()},
121 defaults = #{<<"keep_private">> => false,
122 <<"backend">> => rdbms,
123 <<"iqdisc">> => no_queue},
124 format_items = map
125 }.
126
127 async_config_spec() ->
128 152 #section{
129 items = #{<<"pool_size">> => #option{type = integer, validate = non_negative}},
130 defaults = #{<<"pool_size">> => 2 * erlang:system_info(schedulers_online)},
131 format_items = map,
132 include = always
133 }.
134
135 %% IQ handlers
136 -spec process_iq(mongoose_acc:t(), jid:jid(), jid:jid(), jlib:iq(), map()) ->
137 {mongoose_acc:t(), jlib:iq()}.
138 process_iq(Acc, _From, _To, #iq{type = set, sub_el = SubEl} = IQ, _Extra) ->
139 2 {Acc, IQ#iq{type = error, sub_el = [SubEl, mongoose_xmpp_errors:not_allowed()]}};
140 process_iq(Acc, From, _To, #iq{type = get, sub_el = SubEl} = IQ, #{keep_private := Private}) ->
141 26 Req = maps:from_list(SubEl#xmlel.attrs),
142 26 MaybePeer = jid:from_binary(maps:get(<<"peer">>, Req, undefined)),
143 26 MaybeAfter = parse_ts(maps:get(<<"after">>, Req, undefined)),
144 26 MaybeThread = maps:get(<<"thread">>, Req, undefined),
145 26 Res = fetch_markers(IQ, Acc, From, MaybePeer, MaybeThread, MaybeAfter, Private),
146 26 {Acc, Res}.
147
148 -spec parse_ts(undefined | binary()) -> integer() | error.
149 parse_ts(undefined) ->
150 20 0;
151 parse_ts(BinTS) ->
152 6 try calendar:rfc3339_to_system_time(binary_to_list(BinTS))
153 2 catch error:_Error -> error
154 end.
155
156 -spec fetch_markers(jlib:iq(),
157 mongoose_acc:t(),
158 From :: jid:jid(),
159 MaybePeer :: error | jid:jid(),
160 maybe_thread(),
161 MaybeTS :: error | integer(),
162 MaybePrivate :: boolean()) -> jlib:iq().
163 fetch_markers(IQ, _, _, error, _, _, _) ->
164 4 IQ#iq{type = error,
165 sub_el = [mongoose_xmpp_errors:bad_request(<<"en">>, <<"invalid-peer">>)]};
166 fetch_markers(IQ, _, _, _, _, error, _) ->
167 2 IQ#iq{type = error,
168 sub_el = [mongoose_xmpp_errors:bad_request(<<"en">>, <<"invalid-timestamp">>)]};
169 fetch_markers(IQ, Acc, From, Peer, Thread, TS, Private) ->
170 20 HostType = mongoose_acc:host_type(Acc),
171 20 Markers = mod_smart_markers_backend:get_conv_chat_marker(HostType, From, Peer, Thread, TS, Private),
172 20 SubEl = #xmlel{name = <<"query">>,
173 attrs = [{<<"xmlns">>, ?NS_ESL_SMART_MARKERS},
174 {<<"peer">>, jid:to_binary(jid:to_lus(Peer))}],
175 children = build_result(Markers)},
176 20 IQ#iq{type = result, sub_el = SubEl}.
177
178 build_result(Markers) ->
179 20 [ #xmlel{name = <<"marker">>,
180 attrs = [{<<"id">>, MsgId},
181 {<<"from">>, jid:to_binary(From)},
182 {<<"type">>, atom_to_binary(Type)},
183 {<<"timestamp">>, ts_to_bin(MsgTS)}
184 | maybe_thread(MsgThread) ]}
185 20 || #{from := From, thread := MsgThread, type := Type, timestamp := MsgTS, id := MsgId} <- Markers ].
186
187 ts_to_bin(TS) ->
188 22 list_to_binary(calendar:system_time_to_rfc3339(TS, [{offset, "Z"}, {unit, microsecond}])).
189
190 maybe_thread(undefined) ->
191 18 [];
192 maybe_thread(Bin) ->
193 4 [{<<"thread">>, Bin}].
194
195 %% HOOKS
196 -spec hooks(mongooseim:host_type(), gen_mod:module_opts()) -> [ejabberd_hooks:hook()].
197 hooks(HostType, #{keep_private := KeepPrivate}) ->
198 16 [{user_send_packet, HostType, ?MODULE, user_send_packet, 90} |
199 private_hooks(HostType, KeepPrivate) ++ removal_hooks(HostType)].
200
201 private_hooks(_HostType, false) ->
202 12 [];
203 private_hooks(HostType, true) ->
204 4 [{filter_local_packet, HostType, ?MODULE, filter_local_packet, 20}].
205
206 removal_hooks(HostType) ->
207 16 [{remove_user, HostType, ?MODULE, remove_user, 60},
208 {remove_domain, HostType, ?MODULE, remove_domain, 60},
209 {forget_room, HostType, ?MODULE, forget_room, 85},
210 {room_new_affiliations, HostType, ?MODULE, room_new_affiliations, 60}].
211
212 -spec user_send_packet(mongoose_acc:t(), jid:jid(), jid:jid(), exml:element()) ->
213 mongoose_acc:t().
214 user_send_packet(Acc, From, To, Packet = #xmlel{name = <<"message">>}) ->
215 84 case has_valid_markers(Acc, From, To, Packet) of
216 {true, HostType, Markers} ->
217 45 update_chat_markers(Acc, HostType, Markers);
218 _ ->
219 39 Acc
220 end;
221 user_send_packet(Acc, _From, _To, _Packet) ->
222 103 Acc.
223
224 -spec filter_local_packet(mongoose_hooks:filter_packet_acc() | drop) ->
225 mongoose_hooks:filter_packet_acc() | drop.
226 filter_local_packet(Filter = {_From, _To, _Acc, Msg = #xmlel{name = <<"message">>}}) ->
227 44 case mongoose_chat_markers:has_chat_markers(Msg) of
228 38 false -> Filter;
229 6 true -> drop
230 end;
231 filter_local_packet(Filter) ->
232 52 Filter.
233
234 remove_user(Acc, User, Server) ->
235 64 HostType = mongoose_acc:host_type(Acc),
236 64 mod_smart_markers_backend:remove_user(HostType, jid:make_bare(User, Server)),
237 64 Acc.
238
239 -spec remove_domain(mongoose_hooks:simple_acc(),
240 mongooseim:host_type(), jid:lserver()) ->
241 mongoose_hooks:simple_acc().
242 remove_domain(Acc, HostType, Domain) ->
243 1 mod_smart_markers_backend:remove_domain(HostType, Domain),
244 1 Acc.
245
246 -spec forget_room(mongoose_hooks:simple_acc(), mongooseim:host_type(), jid:lserver(), jid:luser()) ->
247 mongoose_hooks:simple_acc().
248 forget_room(Acc, HostType, RoomS, RoomU) ->
249 4 mod_smart_markers_backend:remove_to(HostType, jid:make_noprep(RoomU, RoomS, <<>>)),
250 4 Acc.
251
252 %% The new affs can be found in the Acc:element, where we can scan for 'none' ones
253 -spec room_new_affiliations(mongoose_acc:t(), jid:jid(), mod_muc_light:aff_users(), binary()) ->
254 mongoose_acc:t().
255 room_new_affiliations(Acc, RoomJid, _NewAffs, _NewVersion) ->
256 6 HostType = mod_muc_light_utils:acc_to_host_type(Acc),
257 6 case mongoose_acc:element(Acc) of
258 4 undefined -> Acc;
259 Packet ->
260 2 case exml_query:paths(Packet, [{element_with_ns, ?NS_MUC_LIGHT_AFFILIATIONS},
261 {element_with_attr, <<"affiliation">>, <<"none">>},
262 cdata]) of
263
:-(
[] -> Acc;
264 Users ->
265 2 [begin
266 2 FromJid = jid:to_bare(jid:from_binary(User)),
267 2 mod_smart_markers_backend:remove_to_for_user(HostType, FromJid, RoomJid)
268 2 end || User <- Users ],
269 2 Acc
270 end
271 end.
272
273 %%--------------------------------------------------------------------
274 %% Other API
275 %%--------------------------------------------------------------------
276 -spec get_chat_markers(jid:jid(), maybe_thread(), integer()) ->
277 [chat_marker()].
278 get_chat_markers(#jid{lserver = LServer} = To, Thread, TS) ->
279 %% internal API, no room access rights verification here!
280 4 {ok, HostType} = mongoose_domain_api:get_host_type(LServer),
281 4 mod_smart_markers_backend:get_chat_markers(HostType, To, Thread, TS).
282
283 %%--------------------------------------------------------------------
284 %% Local functions
285 %%--------------------------------------------------------------------
286 -spec update_chat_markers(mongoose_acc:t(), mongooseim:host_type(), [chat_marker()]) ->
287 mongoose_acc:t().
288 update_chat_markers(Acc, HostType, Markers) ->
289 45 TS = mongoose_acc:timestamp(Acc),
290 45 [mod_smart_markers_backend:update_chat_marker(HostType, CM) || CM <- Markers],
291 45 mongoose_acc:set_permanent(?MODULE, timestamp, TS, Acc).
292
293 -spec has_valid_markers(mongoose_acc:t(), jid:jid(), jid:jid(), exml:element()) ->
294 false | {true, mongooseim:host_type(), Markers :: [chat_marker()]}.
295 has_valid_markers(Acc, From, To, Packet) ->
296 84 case extract_chat_markers(Acc, From, To, Packet) of
297 39 [] -> false;
298 Markers ->
299 45 case is_valid_host(Acc, From, To) of
300
:-(
false -> false;
301 45 {true, HostType} -> {true, HostType, Markers}
302 end
303 end.
304
305 -spec is_valid_host(mongoose_acc:t(), jid:jid(), jid:jid()) ->
306 false | {true, mongooseim:host_type()}.
307 is_valid_host(Acc, From, To) ->
308 45 case mongoose_acc:stanza_type(Acc) of
309 17 <<"groupchat">> -> get_host(Acc, From, To, groupchat);
310 28 _ -> get_host(Acc, From, To, one2one)
311 end.
312
313 -spec extract_chat_markers(mongoose_acc:t(), jid:jid(), jid:jid(), exml:element()) ->
314 [chat_marker()].
315 extract_chat_markers(Acc, From, To, Packet) ->
316 84 case mongoose_chat_markers:list_chat_markers(Packet) of
317 39 [] -> [];
318 ChatMarkers ->
319 45 TS = mongoose_acc:timestamp(Acc),
320 45 CM = #{from => From,
321 to => jid:to_bare(To),
322 thread => get_thread(Packet),
323 timestamp => TS,
324 type => undefined,
325 id => undefined},
326 45 [CM#{type => Type, id => Id} || {Type, Id} <- ChatMarkers]
327 end.
328
329 -spec get_thread(exml:element()) -> maybe_thread().
330 get_thread(El) ->
331 45 case exml_query:path(El, [{element, <<"thread">>}, cdata]) of
332 45 Thread when Thread =/= <<>> -> Thread;
333
:-(
_ -> undefined
334 end.
335
336 -spec get_host(mongoose_acc:t(), jid:jid(), jid:jid(), chat_type()) ->
337 false | {true, mongooseim:host_type()}.
338 get_host(Acc, From, To, groupchat) ->
339 17 HostType = mod_muc_light_utils:room_jid_to_host_type(To),
340 17 can_access_room(HostType, Acc, From, To) andalso {true, HostType};
341 get_host(_Acc, _From, To, one2one) ->
342 28 LServer = To#jid.lserver,
343 28 case mongoose_domain_api:get_domain_host_type(LServer) of
344 28 {ok, HostType} -> {true, HostType};
345
:-(
{error, not_found} -> false
346 end.
347
348 -spec can_access_room(HostType :: mongooseim:host_type(),
349 Acc :: mongoose_acc:t(),
350 User :: jid:jid(),
351 Room :: jid:jid()) -> boolean().
352 can_access_room(HostType, Acc, User, Room) ->
353 17 mongoose_hooks:can_access_room(HostType, Acc, Room, User).
Line Hits Source