%%%---------------------------------------------------------------------- %%% File : mod_shared_roster.erl %%% Author : Alexey Shchepin %%% Purpose : Shared roster management %%% Created : 5 Mar 2005 by Alexey Shchepin %%% %%% %%% ejabberd, Copyright (C) 2002-2020 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_shared_roster). -author('alexey@process-one.net'). -behaviour(gen_mod). -export([start/2, stop/1, reload/3, export/1, import_info/0, webadmin_menu/3, webadmin_page/3, get_user_roster/2, get_jid_info/4, import/5, process_item/2, import_start/2, in_subscription/2, out_subscription/1, c2s_self_presence/1, unset_presence/4, register_user/2, remove_user/2, list_groups/1, create_group/2, create_group/3, delete_group/2, get_group_opts/2, set_group_opts/3, get_group_users/2, get_group_explicit_users/2, is_user_in_group/3, add_user_to_group/3, opts_to_binary/1, remove_user_from_group/3, mod_opt_type/1, mod_options/1, mod_doc/0, depends/2]). -include("logger.hrl"). -include("xmpp.hrl"). -include("mod_roster.hrl"). -include("ejabberd_http.hrl"). -include("ejabberd_web_admin.hrl"). -include("mod_shared_roster.hrl"). -include("translate.hrl"). -type group_options() :: [{atom(), any()}]. -callback init(binary(), gen_mod:opts()) -> any(). -callback import(binary(), binary(), [binary()]) -> ok. -callback list_groups(binary()) -> [binary()]. -callback groups_with_opts(binary()) -> [{binary(), group_options()}]. -callback create_group(binary(), binary(), group_options()) -> {atomic, any()}. -callback delete_group(binary(), binary()) -> {atomic, any()}. -callback get_group_opts(binary(), binary()) -> group_options() | error. -callback set_group_opts(binary(), binary(), group_options()) -> {atomic, any()}. -callback get_user_groups({binary(), binary()}, binary()) -> [binary()]. -callback get_group_explicit_users(binary(), binary()) -> [{binary(), binary()}]. -callback get_user_displayed_groups(binary(), binary(), group_options()) -> [{binary(), group_options()}]. -callback is_user_in_group({binary(), binary()}, binary(), binary()) -> boolean(). -callback add_user_to_group(binary(), {binary(), binary()}, binary()) -> any(). -callback remove_user_from_group(binary(), {binary(), binary()}, binary()) -> {atomic, any()}. -callback use_cache(binary()) -> boolean(). -callback cache_nodes(binary()) -> [node()]. -optional_callbacks([use_cache/1, cache_nodes/1]). -define(GROUP_OPTS_CACHE, shared_roster_group_opts_cache). -define(USER_GROUPS_CACHE, shared_roster_user_groups_cache). -define(GROUP_EXPLICIT_USERS_CACHE, shared_roster_group_explicit_cache). -define(SPECIAL_GROUPS_CACHE, shared_roster_special_groups_cache). start(Host, Opts) -> Mod = gen_mod:db_mod(Opts, ?MODULE), Mod:init(Host, Opts), init_cache(Mod, Host, Opts), ejabberd_hooks:add(webadmin_menu_host, Host, ?MODULE, webadmin_menu, 70), ejabberd_hooks:add(webadmin_page_host, Host, ?MODULE, webadmin_page, 50), ejabberd_hooks:add(roster_get, Host, ?MODULE, get_user_roster, 70), ejabberd_hooks:add(roster_in_subscription, Host, ?MODULE, in_subscription, 30), ejabberd_hooks:add(roster_out_subscription, Host, ?MODULE, out_subscription, 30), ejabberd_hooks:add(roster_get_jid_info, Host, ?MODULE, get_jid_info, 70), ejabberd_hooks:add(roster_process_item, Host, ?MODULE, process_item, 50), ejabberd_hooks:add(c2s_self_presence, Host, ?MODULE, c2s_self_presence, 50), ejabberd_hooks:add(unset_presence_hook, Host, ?MODULE, unset_presence, 50), ejabberd_hooks:add(register_user, Host, ?MODULE, register_user, 50), ejabberd_hooks:add(remove_user, Host, ?MODULE, remove_user, 50). stop(Host) -> ejabberd_hooks:delete(webadmin_menu_host, Host, ?MODULE, webadmin_menu, 70), ejabberd_hooks:delete(webadmin_page_host, Host, ?MODULE, webadmin_page, 50), ejabberd_hooks:delete(roster_get, Host, ?MODULE, get_user_roster, 70), ejabberd_hooks:delete(roster_in_subscription, Host, ?MODULE, in_subscription, 30), ejabberd_hooks:delete(roster_out_subscription, Host, ?MODULE, out_subscription, 30), ejabberd_hooks:delete(roster_get_jid_info, Host, ?MODULE, get_jid_info, 70), ejabberd_hooks:delete(roster_process_item, Host, ?MODULE, process_item, 50), ejabberd_hooks:delete(c2s_self_presence, Host, ?MODULE, c2s_self_presence, 50), ejabberd_hooks:delete(unset_presence_hook, Host, ?MODULE, unset_presence, 50), ejabberd_hooks:delete(register_user, Host, ?MODULE, register_user, 50), ejabberd_hooks:delete(remove_user, Host, ?MODULE, remove_user, 50). reload(Host, NewOpts, OldOpts) -> NewMod = gen_mod:db_mod(NewOpts, ?MODULE), OldMod = gen_mod:db_mod(OldOpts, ?MODULE), if NewMod /= OldMod -> NewMod:init(Host, NewOpts); true -> ok end, ok. depends(_Host, _Opts) -> []. -spec init_cache(module(), binary(), gen_mod:opts()) -> ok. init_cache(Mod, Host, Opts) -> case use_cache(Mod, Host) of true -> CacheOpts = cache_opts(Opts), ets_cache:new(?GROUP_OPTS_CACHE, CacheOpts), ets_cache:new(?USER_GROUPS_CACHE, CacheOpts), ets_cache:new(?GROUP_EXPLICIT_USERS_CACHE, CacheOpts), ets_cache:new(?SPECIAL_GROUPS_CACHE, CacheOpts); false -> ets_cache:delete(?GROUP_OPTS_CACHE), ets_cache:delete(?USER_GROUPS_CACHE), ets_cache:delete(?GROUP_EXPLICIT_USERS_CACHE), ets_cache:delete(?SPECIAL_GROUPS_CACHE) end. -spec cache_opts(gen_mod:opts()) -> [proplists:property()]. cache_opts(Opts) -> MaxSize = mod_private_opt:cache_size(Opts), CacheMissed = mod_private_opt:cache_missed(Opts), LifeTime = mod_private_opt:cache_life_time(Opts), [{max_size, MaxSize}, {cache_missed, CacheMissed}, {life_time, LifeTime}]. -spec use_cache(module(), binary()) -> boolean(). use_cache(Mod, Host) -> case erlang:function_exported(Mod, use_cache, 1) of true -> Mod:use_cache(Host); false -> mod_shared_roster_opt:use_cache(Host) end. -spec cache_nodes(module(), binary()) -> [node()]. cache_nodes(Mod, Host) -> case erlang:function_exported(Mod, cache_nodes, 1) of true -> Mod:cache_nodes(Host); false -> ejabberd_cluster:get_nodes() end. -spec get_user_roster([#roster{}], {binary(), binary()}) -> [#roster{}]. get_user_roster(Items, US) -> {U, S} = US, DisplayedGroups = get_user_displayed_groups(US), SRUsers = lists:foldl(fun (Group, Acc1) -> GroupLabel = get_group_label(S, Group), %++ lists:foldl(fun (User, Acc2) -> if User == US -> Acc2; true -> dict:append(User, GroupLabel, Acc2) end end, Acc1, get_group_users(S, Group)) end, dict:new(), DisplayedGroups), {NewItems1, SRUsersRest} = lists:mapfoldl(fun (Item, SRUsers1) -> {_, _, {U1, S1, _}} = Item#roster.usj, US1 = {U1, S1}, case dict:find(US1, SRUsers1) of {ok, GroupLabels} -> {Item#roster{subscription = both, groups = Item#roster.groups ++ GroupLabels, ask = none}, dict:erase(US1, SRUsers1)}; error -> {Item, SRUsers1} end end, SRUsers, Items), SRItems = [#roster{usj = {U, S, {U1, S1, <<"">>}}, us = US, jid = {U1, S1, <<"">>}, name = get_rosteritem_name(U1, S1), subscription = both, ask = none, groups = GroupLabels} || {{U1, S1}, GroupLabels} <- dict:to_list(SRUsersRest)], SRItems ++ NewItems1. get_rosteritem_name(U, S) -> case gen_mod:is_loaded(S, mod_vcard) of true -> SubEls = mod_vcard:get_vcard(U, S), get_rosteritem_name_vcard(SubEls); false -> <<"">> end. -spec get_rosteritem_name_vcard([xmlel()]) -> binary(). get_rosteritem_name_vcard([Vcard|_]) -> case fxml:get_path_s(Vcard, [{elem, <<"NICKNAME">>}, cdata]) of <<"">> -> fxml:get_path_s(Vcard, [{elem, <<"FN">>}, cdata]); Nickname -> Nickname end; get_rosteritem_name_vcard(_) -> <<"">>. %% This function rewrites the roster entries when moving or renaming %% them in the user contact list. -spec process_item(#roster{}, binary()) -> #roster{}. process_item(RosterItem, Host) -> USFrom = {UserFrom, ServerFrom} = RosterItem#roster.us, {UserTo, ServerTo, ResourceTo} = RosterItem#roster.jid, NameTo = RosterItem#roster.name, USTo = {UserTo, ServerTo}, DisplayedGroups = get_user_displayed_groups(USFrom), CommonGroups = lists:filter(fun (Group) -> is_user_in_group(USTo, Group, Host) end, DisplayedGroups), case CommonGroups of [] -> RosterItem; %% Roster item cannot be removed: We simply reset the original groups: _ when RosterItem#roster.subscription == remove -> GroupLabels = lists:map(fun (Group) -> get_group_label(Host, Group) end, CommonGroups), RosterItem#roster{subscription = both, ask = none, groups = GroupLabels}; %% Both users have at least a common shared group, %% So each user can see the other _ -> case lists:subtract(RosterItem#roster.groups, CommonGroups) of %% If it doesn't, then remove this user from any %% existing roster groups. [] -> Pres = #presence{from = jid:make(UserTo, ServerTo), to = jid:make(UserFrom, ServerFrom), type = unsubscribe}, mod_roster:out_subscription(Pres), mod_roster:in_subscription(false, Pres), RosterItem#roster{subscription = both, ask = none}; %% If so, it means the user wants to add that contact %% to his personal roster PersonalGroups -> set_new_rosteritems(UserFrom, ServerFrom, UserTo, ServerTo, ResourceTo, NameTo, PersonalGroups) end end. build_roster_record(User1, Server1, User2, Server2, Name2, Groups) -> USR2 = {User2, Server2, <<"">>}, #roster{usj = {User1, Server1, USR2}, us = {User1, Server1}, jid = USR2, name = Name2, subscription = both, ask = none, groups = Groups}. set_new_rosteritems(UserFrom, ServerFrom, UserTo, ServerTo, ResourceTo, NameTo, GroupsFrom) -> RIFrom = build_roster_record(UserFrom, ServerFrom, UserTo, ServerTo, NameTo, GroupsFrom), set_item(UserFrom, ServerFrom, ResourceTo, RIFrom), JIDTo = jid:make(UserTo, ServerTo), JIDFrom = jid:make(UserFrom, ServerFrom), RITo = build_roster_record(UserTo, ServerTo, UserFrom, ServerFrom, UserFrom, []), set_item(UserTo, ServerTo, <<"">>, RITo), mod_roster:out_subscription( #presence{from = JIDFrom, to = JIDTo, type = subscribe}), mod_roster:in_subscription( false, #presence{to = JIDTo, from = JIDFrom, type = subscribe}), mod_roster:out_subscription( #presence{from = JIDTo, to = JIDFrom, type = subscribed}), mod_roster:in_subscription( false, #presence{to = JIDFrom, from = JIDTo, type = subscribed}), mod_roster:out_subscription( #presence{from = JIDTo, to = JIDFrom, type = subscribe}), mod_roster:in_subscription( false, #presence{to = JIDFrom, from = JIDTo, type = subscribe}), mod_roster:out_subscription( #presence{from = JIDFrom, to = JIDTo, type = subscribed}), mod_roster:in_subscription( false, #presence{to = JIDTo, from = JIDFrom, type = subscribed}), RIFrom. set_item(User, Server, Resource, Item) -> ResIQ = #iq{from = jid:make(User, Server, Resource), to = jid:make(Server), type = set, id = <<"push", (p1_rand:get_string())/binary>>, sub_els = [#roster_query{ items = [mod_roster:encode_item(Item)]}]}, ejabberd_router:route(ResIQ). -spec get_jid_info({subscription(), ask(), [binary()]}, binary(), binary(), jid()) -> {subscription(), ask(), [binary()]}. get_jid_info({Subscription, Ask, Groups}, User, Server, JID) -> LUser = jid:nodeprep(User), LServer = jid:nameprep(Server), US = {LUser, LServer}, {U1, S1, _} = jid:tolower(JID), US1 = {U1, S1}, DisplayedGroups = get_user_displayed_groups(US), SRUsers = lists:foldl(fun (Group, Acc1) -> GroupLabel = get_group_label(LServer, Group), %++ lists:foldl(fun (User1, Acc2) -> dict:append(User1, GroupLabel, Acc2) end, Acc1, get_group_users(LServer, Group)) end, dict:new(), DisplayedGroups), case dict:find(US1, SRUsers) of {ok, GroupLabels} -> NewGroups = if Groups == [] -> GroupLabels; true -> Groups end, {both, none, NewGroups}; error -> {Subscription, Ask, Groups} end. -spec in_subscription(boolean(), presence()) -> boolean(). in_subscription(Acc, #presence{to = To, from = JID, type = Type}) -> #jid{user = User, server = Server} = To, process_subscription(in, User, Server, JID, Type, Acc). -spec out_subscription(presence()) -> boolean(). out_subscription(#presence{from = From, to = To, type = unsubscribed} = Pres) -> #jid{user = User, server = Server} = From, mod_roster:out_subscription(Pres#presence{type = unsubscribe}), mod_roster:in_subscription(false, xmpp:set_from_to( Pres#presence{type = unsubscribe}, To, From)), process_subscription(out, User, Server, To, unsubscribed, false); out_subscription(#presence{from = From, to = To, type = Type}) -> #jid{user = User, server = Server} = From, process_subscription(out, User, Server, To, Type, false). process_subscription(Direction, User, Server, JID, _Type, Acc) -> LUser = jid:nodeprep(User), LServer = jid:nameprep(Server), US = {LUser, LServer}, {U1, S1, _} = jid:tolower(jid:remove_resource(JID)), US1 = {U1, S1}, DisplayedGroups = get_user_displayed_groups(US), SRUsers = lists:usort(lists:flatmap(fun (Group) -> get_group_users(LServer, Group) end, DisplayedGroups)), case lists:member(US1, SRUsers) of true -> case Direction of in -> {stop, false}; out -> stop end; false -> Acc end. list_groups(Host) -> Mod = gen_mod:db_mod(Host, ?MODULE), Mod:list_groups(Host). groups_with_opts(Host) -> Mod = gen_mod:db_mod(Host, ?MODULE), Mod:groups_with_opts(Host). create_group(Host, Group) -> create_group(Host, Group, []). create_group(Host, Group, Opts) -> Mod = gen_mod:db_mod(Host, ?MODULE), case use_cache(Mod, Host) of true -> ets_cache:insert(?GROUP_OPTS_CACHE, {Host, Group}, Opts, cache_nodes(Mod, Host)), ets_cache:clear(?SPECIAL_GROUPS_CACHE, cache_nodes(Mod, Host)); _ -> ok end, Mod:create_group(Host, Group, Opts). delete_group(Host, Group) -> Mod = gen_mod:db_mod(Host, ?MODULE), case use_cache(Mod, Host) of true -> ets_cache:delete(?GROUP_OPTS_CACHE, {Host, Group}, cache_nodes(Mod, Host)), ets_cache:clear(?USER_GROUPS_CACHE, cache_nodes(Mod, Host)), ets_cache:clear(?GROUP_EXPLICIT_USERS_CACHE, cache_nodes(Mod, Host)), ets_cache:clear(?SPECIAL_GROUPS_CACHE, cache_nodes(Mod, Host)); _ -> ok end, Mod:delete_group(Host, Group). get_group_opts(Host1, Group1) -> {Host, Group} = split_grouphost(Host1, Group1), Mod = gen_mod:db_mod(Host, ?MODULE), Res = case use_cache(Mod, Host) of true -> ets_cache:lookup( ?GROUP_OPTS_CACHE, {Host, Group}, fun() -> case Mod:get_group_opts(Host, Group) of error -> error; V -> {cache, V} end end); false -> Mod:get_group_opts(Host, Group) end, case Res of {ok, Opts} -> Opts; error -> error end. set_group_opts(Host, Group, Opts) -> Mod = gen_mod:db_mod(Host, ?MODULE), case use_cache(Mod, Host) of true -> ets_cache:delete(?GROUP_OPTS_CACHE, {Host, Group}, cache_nodes(Mod, Host)), ets_cache:insert(?GROUP_OPTS_CACHE, {Host, Group}, Opts, cache_nodes(Mod, Host)), ets_cache:clear(?SPECIAL_GROUPS_CACHE, cache_nodes(Mod, Host)); _ -> ok end, Mod:set_group_opts(Host, Group, Opts). get_user_groups(US) -> Host = element(2, US), Mod = gen_mod:db_mod(Host, ?MODULE), UG = case use_cache(Mod, Host) of true -> ets_cache:lookup( ?USER_GROUPS_CACHE, {Host, US}, fun() -> {cache, Mod:get_user_groups(US, Host)} end); false -> Mod:get_user_groups(US, Host) end, UG ++ get_special_users_groups(Host). is_group_enabled(Host1, Group1) -> {Host, Group} = split_grouphost(Host1, Group1), case get_group_opts(Host, Group) of error -> false; Opts -> not lists:member(disabled, Opts) end. %% @spec (Host::string(), Group::string(), Opt::atom(), Default) -> OptValue | Default get_group_opt(Host, Group, Opt, Default) -> case get_group_opts(Host, Group) of error -> Default; Opts -> case lists:keysearch(Opt, 1, Opts) of {value, {_, Val}} -> Val; false -> Default end end. get_online_users(Host) -> lists:usort([{U, S} || {U, S, _} <- ejabberd_sm:get_vh_session_list(Host)]). get_group_users(Host1, Group1) -> {Host, Group} = split_grouphost(Host1, Group1), get_group_users(Host, Group, get_group_opts(Host, Group)). get_group_users(Host, Group, GroupOpts) -> case proplists:get_value(all_users, GroupOpts, false) of true -> ejabberd_auth:get_users(Host); false -> [] end ++ case proplists:get_value(online_users, GroupOpts, false) of true -> get_online_users(Host); false -> [] end ++ get_group_explicit_users(Host, Group). get_group_explicit_users(Host, Group) -> Mod = gen_mod:db_mod(Host, ?MODULE), case use_cache(Mod, Host) of true -> ets_cache:lookup( ?GROUP_EXPLICIT_USERS_CACHE, {Host, Group}, fun() -> {cache, Mod:get_group_explicit_users(Host, Group)} end); false -> Mod:get_group_explicit_users(Host, Group) end. get_group_label(Host1, Group1) -> {Host, Group} = split_grouphost(Host1, Group1), get_group_opt(Host, Group, label, Group). %% Get list of names of groups that have @all@/@online@/etc in the memberlist get_special_users_groups(Host) -> Extract = fun() -> lists:filtermap( fun({Group, Opts}) -> case proplists:get_value(all_users, Opts, false) orelse proplists:get_value(online_users, Opts, false) of true -> {true, Group}; false -> false end end, groups_with_opts(Host)) end, Mod = gen_mod:db_mod(Host, ?MODULE), case use_cache(Mod, Host) of true -> ets_cache:lookup( ?SPECIAL_GROUPS_CACHE, {Host, false}, fun() -> {cache, Extract()} end); false -> Extract() end. %% Get list of names of groups that have @online@ in the memberlist get_special_users_groups_online(Host) -> Extract = fun() -> lists:filtermap( fun({Group, Opts}) -> case proplists:get_value(online_users, Opts, false) of true -> {true, Group}; false -> false end end, groups_with_opts(Host)) end, Mod = gen_mod:db_mod(Host, ?MODULE), case use_cache(Mod, Host) of true -> ets_cache:lookup( ?SPECIAL_GROUPS_CACHE, {Host, true}, fun() -> {cache, Extract()} end); false -> Extract() end. %% Given two lists of groupnames and their options, %% return the list of displayed groups to the second list displayed_groups(GroupsOpts, SelectedGroupsOpts) -> DisplayedGroups = lists:usort(lists:flatmap(fun ({_Group, Opts}) -> [G || G <- proplists:get_value(displayed_groups, Opts, []), not lists:member(disabled, Opts)] end, SelectedGroupsOpts)), [G || G <- DisplayedGroups, not lists:member(disabled, proplists:get_value(G, GroupsOpts, []))]. %% Given a list of group names with options, %% for those that have @all@ in memberlist, %% get the list of groups displayed get_special_displayed_groups(GroupsOpts) -> Groups = lists:filter(fun ({_Group, Opts}) -> proplists:get_value(all_users, Opts, false) end, GroupsOpts), displayed_groups(GroupsOpts, Groups). %% Given a username and server, and a list of group names with options, %% for the list of groups of that server that user is member %% get the list of groups displayed get_user_displayed_groups(LUser, LServer, GroupsOpts) -> Mod = gen_mod:db_mod(LServer, ?MODULE), Groups = Mod:get_user_displayed_groups(LUser, LServer, GroupsOpts), displayed_groups(GroupsOpts, Groups). %% @doc Get the list of groups that are displayed to this user get_user_displayed_groups(US) -> Host = element(2, US), DisplayedGroups1 = lists:usort(lists:flatmap(fun (Group) -> case is_group_enabled(Host, Group) of true -> get_group_opt(Host, Group, displayed_groups, []); false -> [] end end, get_user_groups(US))), [Group || Group <- DisplayedGroups1, is_group_enabled(Host, Group)]. is_user_in_group(US, Group, Host) -> Mod = gen_mod:db_mod(Host, ?MODULE), case Mod:is_user_in_group(US, Group, Host) of false -> lists:member(US, get_group_users(Host, Group)); true -> true end. %% @spec (Host::string(), {User::string(), Server::string()}, Group::string()) -> {atomic, ok} | error add_user_to_group(Host, US, Group) -> {_LUser, LServer} = US, case lists:member(LServer, ejabberd_config:get_option(hosts)) of true -> add_user_to_group2(Host, US, Group); false -> ?INFO_MSG("Attempted adding to shared roster user of inexistent vhost ~ts", [LServer]), error end. add_user_to_group2(Host, US, Group) -> {LUser, LServer} = US, case ejabberd_regexp:run(LUser, <<"^@.+@\$">>) of match -> GroupOpts = get_group_opts(Host, Group), MoreGroupOpts = case LUser of <<"@all@">> -> [{all_users, true}]; <<"@online@">> -> [{online_users, true}]; _ -> [] end, set_group_opts(Host, Group, GroupOpts ++ MoreGroupOpts); nomatch -> DisplayedToGroups = displayed_to_groups(Group, Host), DisplayedGroups = get_displayed_groups(Group, LServer), push_user_to_displayed(LUser, LServer, Group, Host, both, DisplayedToGroups), push_displayed_to_user(LUser, LServer, Host, both, DisplayedGroups), Mod = gen_mod:db_mod(Host, ?MODULE), case use_cache(Mod, Host) of true -> ets_cache:delete(?USER_GROUPS_CACHE, {Host, US}, cache_nodes(Mod, Host)), ets_cache:delete(?GROUP_EXPLICIT_USERS_CACHE, {Host, Group}, cache_nodes(Mod, Host)); false -> ok end, Mod:add_user_to_group(Host, US, Group) end. get_displayed_groups(Group, LServer) -> get_group_opt(LServer, Group, displayed_groups, []). push_displayed_to_user(LUser, LServer, Host, Subscription, DisplayedGroups) -> [push_members_to_user(LUser, LServer, DGroup, Host, Subscription) || DGroup <- DisplayedGroups]. remove_user_from_group(Host, US, Group) -> {LUser, LServer} = US, case ejabberd_regexp:run(LUser, <<"^@.+@\$">>) of match -> GroupOpts = get_group_opts(Host, Group), NewGroupOpts = case LUser of <<"@all@">> -> lists:filter(fun (X) -> X /= {all_users, true} end, GroupOpts); <<"@online@">> -> lists:filter(fun (X) -> X /= {online_users, true} end, GroupOpts) end, set_group_opts(Host, Group, NewGroupOpts); nomatch -> Mod = gen_mod:db_mod(Host, ?MODULE), case use_cache(Mod, Host) of true -> ets_cache:delete(?USER_GROUPS_CACHE, {Host, US}, cache_nodes(Mod, Host)), ets_cache:delete(?GROUP_EXPLICIT_USERS_CACHE, {Host, Group}, cache_nodes(Mod, Host)); false -> ok end, Result = Mod:remove_user_from_group(Host, US, Group), DisplayedToGroups = displayed_to_groups(Group, Host), DisplayedGroups = get_displayed_groups(Group, LServer), push_user_to_displayed(LUser, LServer, Group, Host, remove, DisplayedToGroups), push_displayed_to_user(LUser, LServer, Host, remove, DisplayedGroups), Result end. push_members_to_user(LUser, LServer, Group, Host, Subscription) -> GroupOpts = get_group_opts(LServer, Group), GroupLabel = proplists:get_value(label, GroupOpts, Group), %++ Members = get_group_users(Host, Group), lists:foreach(fun ({U, S}) -> push_roster_item(LUser, LServer, U, S, GroupLabel, Subscription) end, Members). -spec register_user(binary(), binary()) -> ok. register_user(User, Server) -> Groups = get_user_groups({User, Server}), [push_user_to_displayed(User, Server, Group, Server, both, displayed_to_groups(Group, Server)) || Group <- Groups], ok. -spec remove_user(binary(), binary()) -> ok. remove_user(User, Server) -> push_user_to_members(User, Server, remove). push_user_to_members(User, Server, Subscription) -> LUser = jid:nodeprep(User), LServer = jid:nameprep(Server), GroupsOpts = groups_with_opts(LServer), SpecialGroups = get_special_displayed_groups(GroupsOpts), UserGroups = get_user_displayed_groups(LUser, LServer, GroupsOpts), lists:foreach(fun (Group) -> remove_user_from_group(LServer, {LUser, LServer}, Group), GroupOpts = proplists:get_value(Group, GroupsOpts, []), GroupLabel = proplists:get_value(label, GroupOpts, Group), lists:foreach(fun ({U, S}) -> push_roster_item(U, S, LUser, LServer, GroupLabel, Subscription) end, get_group_users(LServer, Group, GroupOpts)) end, lists:usort(SpecialGroups ++ UserGroups)). push_user_to_displayed(LUser, LServer, Group, Host, Subscription, DisplayedToGroupsOpts) -> GroupLabel = get_group_opt(Host, Group, label, Group), %++ [push_user_to_group(LUser, LServer, GroupD, Host, GroupLabel, Subscription) || GroupD <- DisplayedToGroupsOpts]. push_user_to_group(LUser, LServer, Group, Host, GroupLabel, Subscription) -> lists:foreach(fun ({U, S}) when (U == LUser) and (S == LServer) -> ok; ({U, S}) -> case lists:member(S, ejabberd_option:hosts()) of true -> push_roster_item(U, S, LUser, LServer, GroupLabel, Subscription); _ -> ok end end, get_group_users(Host, Group)). %% Get list of groups to which this group is displayed displayed_to_groups(GroupName, LServer) -> GroupsOpts = groups_with_opts(LServer), Gs = lists:filter(fun ({_Group, Opts}) -> lists:member(GroupName, proplists:get_value(displayed_groups, Opts, [])) end, GroupsOpts), [Name || {Name, _} <- Gs]. push_item(User, Server, Item) -> mod_roster:push_item(jid:make(User, Server), Item#roster{subscription = none}, Item). push_roster_item(User, Server, ContactU, ContactS, GroupLabel, Subscription) -> Item = #roster{usj = {User, Server, {ContactU, ContactS, <<"">>}}, us = {User, Server}, jid = {ContactU, ContactS, <<"">>}, name = <<"">>, subscription = Subscription, ask = none, groups = [GroupLabel]}, push_item(User, Server, Item). -spec c2s_self_presence({presence(), ejabberd_c2s:state()}) -> {presence(), ejabberd_c2s:state()}. c2s_self_presence(Acc) -> Acc. -spec unset_presence(binary(), binary(), binary(), binary()) -> ok. unset_presence(LUser, LServer, Resource, Status) -> Resources = ejabberd_sm:get_user_resources(LUser, LServer), ?DEBUG("Unset_presence for ~p @ ~p / ~p -> ~p " "(~p resources)", [LUser, LServer, Resource, Status, length(Resources)]), case length(Resources) of 0 -> OnlineGroups = get_special_users_groups_online(LServer), lists:foreach( fun(OG) -> DisplayedToGroups = displayed_to_groups(OG, LServer), push_user_to_displayed(LUser, LServer, OG, LServer, remove, DisplayedToGroups), push_displayed_to_user(LUser, LServer, LServer, remove, DisplayedToGroups) end, OnlineGroups); _ -> ok end. %%--------------------- %% Web Admin %%--------------------- webadmin_menu(Acc, _Host, Lang) -> [{<<"shared-roster">>, translate:translate(Lang, ?T("Shared Roster Groups"))} | Acc]. webadmin_page(_, Host, #request{us = _US, path = [<<"shared-roster">>], q = Query, lang = Lang} = _Request) -> Res = list_shared_roster_groups(Host, Query, Lang), {stop, Res}; webadmin_page(_, Host, #request{us = _US, path = [<<"shared-roster">>, Group], q = Query, lang = Lang} = _Request) -> Res = shared_roster_group(Host, Group, Query, Lang), {stop, Res}; webadmin_page(Acc, _, _) -> Acc. list_shared_roster_groups(Host, Query, Lang) -> Res = list_sr_groups_parse_query(Host, Query), SRGroups = list_groups(Host), FGroups = (?XAE(<<"table">>, [], [?XE(<<"tbody">>, [?XE(<<"tr">>, [?X(<<"td">>), ?XE(<<"td">>, [?CT(?T("Name:"))]) ])]++ (lists:map(fun (Group) -> ?XE(<<"tr">>, [?XE(<<"td">>, [?INPUT(<<"checkbox">>, <<"selected">>, Group)]), ?XE(<<"td">>, [?AC(<>, Group)])]) end, lists:sort(SRGroups)) ++ [?XE(<<"tr">>, [?X(<<"td">>), ?XE(<<"td">>, [?INPUT(<<"text">>, <<"namenew">>, <<"">>), ?C(<<" ">>), ?INPUTT(<<"submit">>, <<"addnew">>, ?T("Add New"))])])]))])), (?H1GL((translate:translate(Lang, ?T("Shared Roster Groups"))), <<"modules/#mod-shared-roster">>, <<"mod_shared_roster">>)) ++ case Res of ok -> [?XREST(?T("Submitted"))]; error -> [?XREST(?T("Bad format"))]; nothing -> [] end ++ [?XAE(<<"form">>, [{<<"action">>, <<"">>}, {<<"method">>, <<"post">>}], [FGroups, ?BR, ?INPUTT(<<"submit">>, <<"delete">>, ?T("Delete Selected"))])]. list_sr_groups_parse_query(Host, Query) -> case lists:keysearch(<<"addnew">>, 1, Query) of {value, _} -> list_sr_groups_parse_addnew(Host, Query); _ -> case lists:keysearch(<<"delete">>, 1, Query) of {value, _} -> list_sr_groups_parse_delete(Host, Query); _ -> nothing end end. list_sr_groups_parse_addnew(Host, Query) -> case lists:keysearch(<<"namenew">>, 1, Query) of {value, {_, Group}} when Group /= <<"">> -> create_group(Host, Group), ok; _ -> error end. list_sr_groups_parse_delete(Host, Query) -> SRGroups = list_groups(Host), lists:foreach(fun (Group) -> case lists:member({<<"selected">>, Group}, Query) of true -> delete_group(Host, Group); _ -> ok end end, SRGroups), ok. shared_roster_group(Host, Group, Query, Lang) -> Res = shared_roster_group_parse_query(Host, Group, Query), GroupOpts = get_group_opts(Host, Group), Label = get_opt(GroupOpts, label, <<"">>), %%++ Description = get_opt(GroupOpts, description, <<"">>), AllUsers = get_opt(GroupOpts, all_users, false), OnlineUsers = get_opt(GroupOpts, online_users, false), DisplayedGroups = get_opt(GroupOpts, displayed_groups, []), Members = get_group_explicit_users(Host, Group), FMembers = iolist_to_binary( [if AllUsers -> <<"@all@\n">>; true -> <<"">> end, if OnlineUsers -> <<"@online@\n">>; true -> <<"">> end, [[us_to_list(Member), $\n] || Member <- Members]]), FDisplayedGroups = [<> || DG <- DisplayedGroups], DescNL = length(ejabberd_regexp:split(Description, <<"\n">>)), FGroup = (?XAE(<<"table">>, [{<<"class">>, <<"withtextareas">>}], [?XE(<<"tbody">>, [?XE(<<"tr">>, [?XCT(<<"td">>, ?T("Name:")), ?XE(<<"td">>, [?C(Group)]), ?XE(<<"td">>, [?C(<<"">>)])]), ?XE(<<"tr">>, [?XCT(<<"td">>, ?T("Label:")), ?XE(<<"td">>, [?INPUT(<<"text">>, <<"label">>, Label)]), ?XE(<<"td">>, [?CT(?T("Name in the rosters where this group will be displayed"))])]), ?XE(<<"tr">>, [?XCT(<<"td">>, ?T("Description:")), ?XE(<<"td">>, [?TEXTAREA(<<"description">>, integer_to_binary(lists:max([3, DescNL])), <<"20">>, Description)]), ?XE(<<"td">>, [?CT(?T("Only admins can see this"))]) ]), ?XE(<<"tr">>, [?XCT(<<"td">>, ?T("Members:")), ?XE(<<"td">>, [?TEXTAREA(<<"members">>, integer_to_binary(lists:max([3, length(Members)+3])), <<"20">>, FMembers)]), ?XE(<<"td">>, [?C(<<"JIDs, @all@, @online@">>)]) ]), ?XE(<<"tr">>, [?XCT(<<"td">>, ?T("Displayed:")), ?XE(<<"td">>, [?TEXTAREA(<<"dispgroups">>, integer_to_binary(lists:max([3, length(FDisplayedGroups)])), <<"20">>, list_to_binary(FDisplayedGroups))]), ?XE(<<"td">>, [?CT(?T("Groups that will be displayed to the members"))]) ])])])), (?H1GL((translate:translate(Lang, ?T("Shared Roster Groups"))), <<"modules/#mod-shared-roster">>, <<"mod_shared_roster">>)) ++ [?XC(<<"h2">>, translate:translate(Lang, ?T("Group")))] ++ case Res of ok -> [?XREST(?T("Submitted"))]; {error_elements, NonAddedList1, NG1} -> make_error_el(Lang, ?T("Members not added (inexistent vhost!): "), [jid:encode({U,S,<<>>}) || {U,S} <- NonAddedList1]) ++ make_error_el(Lang, ?T("'Displayed groups' not added (they do not exist!): "), NG1); error -> [?XREST(?T("Bad format"))]; nothing -> [] end ++ [?XAE(<<"form">>, [{<<"action">>, <<"">>}, {<<"method">>, <<"post">>}], [FGroup, ?BR, ?INPUTT(<<"submit">>, <<"submit">>, ?T("Submit"))])]. make_error_el(_, _, []) -> []; make_error_el(Lang, Message, BinList) -> NG2 = str:join(BinList, <<", ">>), NG3 = translate:translate(Lang, Message), NG4 = str:concat(NG3, NG2), [?XRES(NG4)]. shared_roster_group_parse_query(Host, Group, Query) -> case lists:keysearch(<<"submit">>, 1, Query) of {value, _} -> {value, {_, Label}} = lists:keysearch(<<"label">>, 1, Query), %++ {value, {_, Description}} = lists:keysearch(<<"description">>, 1, Query), {value, {_, SMembers}} = lists:keysearch(<<"members">>, 1, Query), {value, {_, SDispGroups}} = lists:keysearch(<<"dispgroups">>, 1, Query), LabelOpt = if Label == <<"">> -> []; true -> [{label, Label}] %++ end, DescriptionOpt = if Description == <<"">> -> []; true -> [{description, Description}] end, DispGroups1 = str:tokens(SDispGroups, <<"\r\n">>), {DispGroups, WrongDispGroups} = filter_groups_existence(Host, DispGroups1), DispGroupsOpt = if DispGroups == [] -> []; true -> [{displayed_groups, DispGroups}] end, OldMembers = get_group_explicit_users(Host, Group), SJIDs = str:tokens(SMembers, <<", \r\n">>), NewMembers = lists:foldl(fun (_SJID, error) -> error; (SJID, USs) -> case SJID of <<"@all@">> -> USs; <<"@online@">> -> USs; _ -> try jid:decode(SJID) of JID -> [{JID#jid.luser, JID#jid.lserver} | USs] catch _:{bad_jid, _} -> error end end end, [], SJIDs), AllUsersOpt = case lists:member(<<"@all@">>, SJIDs) of true -> [{all_users, true}]; false -> [] end, OnlineUsersOpt = case lists:member(<<"@online@">>, SJIDs) of true -> [{online_users, true}]; false -> [] end, CurrentDisplayedGroups = get_displayed_groups(Group, Host), AddedDisplayedGroups = DispGroups -- CurrentDisplayedGroups, RemovedDisplayedGroups = CurrentDisplayedGroups -- DispGroups, displayed_groups_update(OldMembers, RemovedDisplayedGroups, remove), displayed_groups_update(OldMembers, AddedDisplayedGroups, both), set_group_opts(Host, Group, LabelOpt ++ DispGroupsOpt ++ DescriptionOpt ++ AllUsersOpt ++ OnlineUsersOpt), if NewMembers == error -> error; true -> AddedMembers = NewMembers -- OldMembers, RemovedMembers = OldMembers -- NewMembers, lists:foreach( fun(US) -> remove_user_from_group(Host, US, Group) end, RemovedMembers), NonAddedMembers = lists:filter( fun(US) -> error == add_user_to_group(Host, US, Group) end, AddedMembers), case (NonAddedMembers /= []) or (WrongDispGroups /= []) of true -> {error_elements, NonAddedMembers, WrongDispGroups}; false -> ok end end; _ -> nothing end. get_opt(Opts, Opt, Default) -> case lists:keysearch(Opt, 1, Opts) of {value, {_, Val}} -> Val; false -> Default end. us_to_list({User, Server}) -> jid:encode({User, Server, <<"">>}). split_grouphost(Host, Group) -> case str:tokens(Group, <<"@">>) of [GroupName, HostName] -> {HostName, GroupName}; [_] -> {Host, Group} end. filter_groups_existence(Host, Groups) -> lists:partition( fun(Group) -> error /= get_group_opts(Host, Group) end, Groups). displayed_groups_update(Members, DisplayedGroups, Subscription) -> lists:foreach( fun({U, S}) -> push_displayed_to_user(U, S, S, Subscription, DisplayedGroups) end, Members). opts_to_binary(Opts) -> lists:map( fun({label, Label}) -> {label, iolist_to_binary(Label)}; ({name, Label}) -> % For SQL backwards compat with ejabberd 20.03 and older {label, iolist_to_binary(Label)}; ({description, Desc}) -> {description, iolist_to_binary(Desc)}; ({displayed_groups, Gs}) -> {displayed_groups, [iolist_to_binary(G) || G <- Gs]}; (Opt) -> Opt end, Opts). export(LServer) -> Mod = gen_mod:db_mod(LServer, ?MODULE), Mod:export(LServer). import_info() -> [{<<"sr_group">>, 3}, {<<"sr_user">>, 3}]. import_start(LServer, DBType) -> Mod = gen_mod:db_mod(DBType, ?MODULE), Mod:init(LServer, []). import(LServer, {sql, _}, DBType, Tab, L) -> Mod = gen_mod:db_mod(DBType, ?MODULE), Mod:import(LServer, Tab, L). mod_opt_type(db_type) -> econf:db_type(?MODULE); mod_opt_type(use_cache) -> econf:bool(); mod_opt_type(cache_size) -> econf:pos_int(infinity); mod_opt_type(cache_missed) -> econf:bool(); mod_opt_type(cache_life_time) -> econf:timeout(second, infinity). mod_options(Host) -> [{db_type, ejabberd_config:default_db(Host, ?MODULE)}, {use_cache, ejabberd_option:use_cache(Host)}, {cache_size, ejabberd_option:cache_size(Host)}, {cache_missed, ejabberd_option:cache_missed(Host)}, {cache_life_time, ejabberd_option:cache_life_time(Host)}]. mod_doc() -> #{desc => [?T("This module enables you to create shared roster groups: " "groups of accounts that can see members from (other) groups " "in their rosters."), "", ?T("The big advantages of this feature are that end users do not " "need to manually add all users to their rosters, and that they " "cannot permanently delete users from the shared roster groups. " "A shared roster group can have members from any XMPP server, " "but the presence will only be available from and to members of " "the same virtual host where the group is created. It still " "allows the users to have / add their own contacts, as it does " "not replace the standard roster. Instead, the shared roster " "contacts are merged to the relevant users at retrieval time. " "The standard user rosters thus stay unmodified."), "", ?T("Shared roster groups can be edited via the Web Admin, " "and some API commands called 'srg_*'. " "Each group has a unique name and those parameters:"), "", ?T("- Label: Used in the rosters where this group is displayed."),"", ?T("- Description: of the group, which has no effect."), "", ?T("- Members: A list of JIDs of group members, entered one per " "line in the Web Admin. The special member directive '@all@' " "represents all the registered users in the virtual host; " "which is only recommended for a small server with just a few " "hundred users. The special member directive '@online@' " "represents the online users in the virtual host. With those " "two directives, the actual list of members in those shared " "rosters is generated dynamically at retrieval time."), "", ?T("- Displayed: A list of groups that will be in the " "rosters of this group's members. A group of other vhost can " "be identified with 'groupid@vhost'."), "", ?T("This module depends on 'mod_roster'. " "If not enabled, roster queries will return 503 errors.")], opts => [{db_type, #{value => "mnesia | sql", desc => ?T("Define the type of storage where the module will create " "the tables and store user information. The default is " "the storage defined by the global option 'default_db', " "or 'mnesia' if omitted. If 'sql' value is defined, " "make sure you have defined the database.")}}, {use_cache, #{value => "true | false", desc => ?T("Same as top-level 'use_cache' option, but applied to this module only.")}}, {cache_size, #{value => "pos_integer() | infinity", desc => ?T("Same as top-level 'cache_size' option, but applied to this module only.")}}, {cache_missed, #{value => "true | false", desc => ?T("Same as top-level 'cache_missed' option, but applied to this module only.")}}, {cache_life_time, #{value => "timeout()", desc => ?T("Same as top-level 'cache_life_time' option, but applied to this module only.")}}], example => [{?T("Take the case of a computer club that wants all its members " "seeing each other in their rosters. To achieve this, they " "need to create a shared roster group similar to this one:"), ["Name: club_members", "Label: Club Members", "Description: Members from the computer club", "Members: member1@example.org, member2@example.org, member3@example.org", "Displayed Groups: club_members"]}, {?T("In another case we have a company which has three divisions: " "Management, Marketing and Sales. All group members should see " "all other members in their rosters. Additionally, all managers " "should have all marketing and sales people in their roster. " "Simultaneously, all marketeers and the whole sales team " "should see all managers. This scenario can be achieved by " "creating shared roster groups as shown in the following lists:"), ["First list:", "Name: management", "Label: Management", "Description: Management", "Members: manager1@example.org, manager2@example.org", "Displayed: management, marketing, sales", "", "Second list:", "Name: marketing", "Label: Marketing", "Description: Marketing", "Members: marketeer1@example.org, marketeer2@example.org, marketeer3@example.org", "Displayed: management, marketing", "", "Third list:", "Name: sales", "Label: Sales", "Description: Sales", "Members: salesman1@example.org, salesman2@example.org, salesman3@example.org", "Displayed: management, sales" ]} ]}.