diff --git a/src/mod_muc_rtbl.erl b/src/mod_muc_rtbl.erl new file mode 100644 index 000000000..82fad1fb1 --- /dev/null +++ b/src/mod_muc_rtbl.erl @@ -0,0 +1,227 @@ +%%%---------------------------------------------------------------------- +%%% File : mod_muc_rtbl.erl +%%% Author : Paweł Chmielowski +%%% Purpose : +%%% Created : 17 kwi 2023 by Paweł Chmielowski +%%% +%%% +%%% ejabberd, Copyright (C) 2002-2023 ProcessOne +%%% +%%% This program is free software; you can redistribute it and/or +%%% modify it under the terms of the GNU General Public License as +%%% published by the Free Software Foundation; either version 2 of the +%%% License, or (at your option) any later version. +%%% +%%% This program is distributed in the hope that it will be useful, +%%% but WITHOUT ANY WARRANTY; without even the implied warranty of +%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +%%% General Public License for more details. +%%% +%%% You should have received a copy of the GNU General Public License along +%%% with this program; if not, write to the Free Software Foundation, Inc., +%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +%%% +%%%---------------------------------------------------------------------- +-module(mod_muc_rtbl). +-author("pawel@process-one.net"). + +-behaviour(gen_mod). + +-include_lib("xmpp/include/xmpp.hrl"). +-include("logger.hrl"). +-include("translate.hrl"). +-include("mod_muc_room.hrl"). + +%% API +-export([start/2, stop/1, mod_opt_type/1, mod_options/1, mod_doc/0, depends/2]). +-export([pubsub_event_handler/1, muc_presence_filter/3, muc_process_iq/2]). + +-record(muc_rtbl, {host_id, blank = blank}). + +start(Host, _Opts) -> + ejabberd_mnesia:create(?MODULE, muc_rtbl, + [{ram_copies, [node()]}, + {local_content, true}, + {attributes, record_info(fields, muc_rtbl)}, + {type, set}]), + ejabberd_hooks:add(local_send_to_resource_hook, Host, + ?MODULE, pubsub_event_handler, 50), + ejabberd_hooks:add(muc_filter_presence, Host, + ?MODULE, muc_presence_filter, 50), + ejabberd_hooks:add(muc_process_iq, Host, + ?MODULE, muc_process_iq, 50), + request_initial_items(Host). + +stop(Host) -> + Jid = service_jid(Host), + IQ = #iq{type = set, from = Jid, to = jid:make(mod_muc_rtbl_opt:rtbl_server(Host)), + sub_els = [ + #pubsub{unsubscribe = + #ps_unsubscribe{jid = Jid, node = mod_muc_rtbl_opt:rtbl_node(Host)}}]}, + ejabberd_router:route_iq(IQ, fun parse_subscribe_result/1). + +request_initial_items(Host) -> + IQ = #iq{type = get, from = service_jid(Host), + to = jid:make(mod_muc_rtbl_opt:rtbl_server(Host)), + sub_els = [ + #pubsub{items = #ps_items{node = mod_muc_rtbl_opt:rtbl_node(Host)}}]}, + ejabberd_router:route_iq(IQ, fun parse_initial_items/1). + +parse_initial_items(#iq{type = error} = IQ) -> + ?WARNING_MSG("Fetching initial list failed: ~p", [xmpp:format_stanza_error(xmpp:get_error(IQ))]); +parse_initial_items(#iq{from = From, to = #jid{lserver = Host} = To, type = result} = IQ) -> + case xmpp:get_subtag(IQ, #pubsub{}) of + #pubsub{items = #ps_items{node = Node, items = Items}} -> + Added = lists:foldl( + fun(#ps_item{id = ID}, Acc) -> + mnesia:dirty_write(#muc_rtbl{host_id = {Host, ID}}), + maps:put(ID, true, Acc) + end, #{}, Items), + SubIQ = #iq{type = set, from = To, to = From, + sub_els = [ + #pubsub{subscribe = #ps_subscribe{jid = To, node = Node}}]}, + ejabberd_router:route_iq(SubIQ, fun parse_subscribe_result/1), + notify_rooms(Host, Added); + _ -> + ?WARNING_MSG("Fetching initial list failed: invalid result payload", []) + end. + +parse_subscribe_result(#iq{type = error} = IQ) -> + ?WARNING_MSG("Subscription error: ~p", [xmpp:format_stanza_error(xmpp:get_error(IQ))]); +parse_subscribe_result(_) -> + ok. + +pubsub_event_handler(#message{from = #jid{luser = <<>>, lserver = SServer}, + to = #jid{luser = <<>>, lserver = Server, + lresource = <<"rtbl-", _/binary>>}} = Msg) -> + + SServer2 = mod_muc_rtbl_opt:rtbl_server(Server), + SNode = mod_muc_rtbl_opt:rtbl_node(Server), + if SServer == SServer2 -> + case xmpp:get_subtag(Msg, #ps_event{}) of + #ps_event{items = #ps_items{node = Node, retract = Retract}} when Node == SNode, + is_binary(Retract) -> + mnesia:dirty_delete(muc_rtbl, {Server, Retract}); + #ps_event{items = #ps_items{node = Node, items = Items}} when Node == SNode -> + Added = lists:foldl( + fun(#ps_item{id = ID}, Acc) -> + mnesia:dirty_write(#muc_rtbl{host_id = {Server, ID}}), + maps:put(ID, true, Acc) + end, #{}, Items), + case maps:size(Added) of + 0 -> ok; + _ -> notify_rooms(Server, Added) + end; + _ -> + ok + end; + true -> + ok + end, + stop; +pubsub_event_handler(_) -> + ok. + +muc_presence_filter(#presence{from = #jid{lserver = Server} = From, lang = Lang} = Packet, _State, _Nick) -> + Blocked = + case mnesia:dirty_read(muc_rtbl, {Server, sha256(Server)}) of + [] -> + JIDs = sha256(jid:encode(jid:tolower(jid:remove_resource(From)))), + case mnesia:dirty_read(muc_rtbl, {Server, JIDs}) of + [] -> false; + _ -> true + end; + _ -> true + end, + case Blocked of + false -> Packet; + _ -> + ErrText = ?T("You have been banned from this room"), + Err = xmpp:err_forbidden(ErrText, Lang), + ejabberd_router:route_error(Packet, Err), + drop + end. + +muc_process_iq(#iq{type = set, sub_els = [{rtbl_update, Items}]}, #state{users = Users} = State0) -> + {NewState, _} = + maps:fold( + fun(_, #user{role = moderator}, {State, HostHashes}) -> + {State, HostHashes}; + ({_, S, _} = LJid, #user{jid = JID}, {State, HostHashes}) -> + {Ban, HH2} = + case maps:find(S, HostHashes) of + {ok, Sha} -> + {maps:is_key(Sha, Items), HostHashes}; + _ -> + Sha = sha256(S), + {maps:is_key(Sha, Items), maps:put(S, Sha, HostHashes)} + end, + Ban2 = + case Ban of + false -> + Sha2 = sha256(jid:encode(jid:remove_resource(LJid))), + maps:is_key(Sha2, Items); + _ -> + true + end, + case Ban2 of + true -> + {_, _, State2} = mod_muc_room:handle_event({process_item_change, + {JID, role, none, <<"Banned by RTBL">>}, + undefined}, + normal_state, State), + {State2, HH2}; + _ -> + {State, HH2} + end + end, {State0, #{}}, Users), + {stop, {ignore, NewState}}; +muc_process_iq(IQ, _State) -> + IQ. + +sha256(Data) -> + Bin = crypto:hash(sha256, Data), + str:to_hexlist(Bin). + +notify_rooms(Host, Items) -> + IQ = #iq{type = set, to = jid:make(Host), sub_els = [{rtbl_update, Items}]}, + lists:foreach( + fun(CHost) -> + lists:foreach( + fun({_, _, Pid}) -> + mod_muc_room:route(Pid, IQ) + end, mod_muc:get_online_rooms(CHost)) + end, mod_muc_admin:find_hosts(Host)). + + +service_jid(Host) -> + jid:make(<<>>, Host, <<"rtbl-", (ejabberd_cluster:node_id())/binary>>). + +mod_opt_type(rtbl_server) -> + econf:domain(); +mod_opt_type(rtbl_node) -> + econf:non_empty(econf:binary()). + +mod_options(_Host) -> + [{rtbl_server, <<"xmppbl.org">>}, + {rtbl_node, <<"muc_bans_sha256">>}]. + +mod_doc() -> + #{desc => + [?T("This module implement Real-time blocklists for MUC rooms."), "", + ?T("It works by observing remote pubsub node conforming with " + "specification described in https://xmppbl.org/.")], + opts => + [{rtbl_server, + #{value => ?T("Domain"), + desc => + ?T("Domain of xmpp server that serves block list. " + "The default value is 'xmppbl.org'")}}, + {rtbl_node, + #{value => "PubsubNodeName", + desc => + ?T("Name of pubsub node that should be used to track blocked users. " + "The default value is 'muc_bans_sha256'.")}}]}. + +depends(_, _) -> + [{mod_muc, hard}, {mod_pubsub, soft}]. diff --git a/src/mod_muc_rtbl_opt.erl b/src/mod_muc_rtbl_opt.erl new file mode 100644 index 000000000..b9394bd39 --- /dev/null +++ b/src/mod_muc_rtbl_opt.erl @@ -0,0 +1,20 @@ +%% Generated automatically +%% DO NOT EDIT: run `make options` instead + +-module(mod_muc_rtbl_opt). + +-export([rtbl_node/1]). +-export([rtbl_server/1]). + +-spec rtbl_node(gen_mod:opts() | global | binary()) -> binary(). +rtbl_node(Opts) when is_map(Opts) -> + gen_mod:get_opt(rtbl_node, Opts); +rtbl_node(Host) -> + gen_mod:get_module_opt(Host, mod_muc_rtbl, rtbl_node). + +-spec rtbl_server(gen_mod:opts() | global | binary()) -> binary(). +rtbl_server(Opts) when is_map(Opts) -> + gen_mod:get_opt(rtbl_server, Opts); +rtbl_server(Host) -> + gen_mod:get_module_opt(Host, mod_muc_rtbl, rtbl_server). +