%%%---------------------------------------------------------------------- %%% File : mod_invites.erl %%% Author : Stefan Strigler %%% Purpose : Account and Roster Invitation (aka Great Invitations) %%% Created : Mon Sep 15 2025 by Stefan Strigler %%% %%% %%% ejabberd, Copyright (C) 2026 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_invites). -author('stefan@strigler.de'). -protocol({xep, 379, '0.3.3', '26.01', "complete", ""}). -protocol({xep, 401, '0.5.0', '26.01', "complete", ""}). -protocol({xep, 445, '0.2.0', '26.01', "complete", ""}). -behaviour(gen_mod). %% gen_mod -export([depends/2, mod_doc/0, mod_options/1, mod_opt_type/1, reload/3, start/2, stop/1]). %% hooks and callbacks -export([adhoc_commands/4, adhoc_items/4, c2s_unauthenticated_packet/2, remove_user/2, s2s_receive_packet/1, sm_receive_packet/1, stream_feature_register/2]). %% Service Discovery -export([get_local_identity/5, get_local_features/5]). %% commands -export([cleanup_expired/0, expire_tokens/2, generate_invite/1, generate_invite/2, list_invites/1]). %% helpers -export([create_account_allowed/2, get_invite/2, get_invites_tree_t/2, get_max_invites/2, is_create_allowed/2, is_expired/1, is_reserved/3, is_token_valid/2, roster_add/2, send_presence/3, set_invitee/3, set_invitee/5, token_uri/1, transaction/2, xdata_field/3]). %% ejabberd_http -export([process/2]). -ifdef(TEST). -export([create_roster_invite/2, create_account_invite/4, find_invites_tree_root_t/4, gen_invite/1, gen_invite/2, get_invites/2, get_invites_tree_as_root_t/2, is_token_valid/3]). -endif. -include("logger.hrl"). -include_lib("xmpp/include/xmpp.hrl"). -include("ejabberd_commands.hrl"). -include("mod_invites.hrl"). -include("translate.hrl"). -type invite_token() :: #invite_token{}. -export_type([invite_token/0]). -callback cleanup_expired(Host :: binary()) -> non_neg_integer(). -callback create_invite_t(Invite :: invite_token()) -> invite_token(). -callback expire_tokens(User :: binary(), Server :: binary()) -> non_neg_integer(). -callback get_invite(Host :: binary(), Token :: binary()) -> invite_token() | {error, not_found}. -callback get_invite_by_invitee_t(Host :: binary(), InviteeJid :: binary()) -> invite_token() | {error, not_found}. -callback get_invites_t(Host :: binary(), Inviter :: {User :: binary(), Host :: binary()}) -> [invite_token()]. -callback init(Host :: binary(), gen_mod:opts()) -> any(). -callback is_reserved(Host :: binary(), Token :: binary(), User :: binary()) -> boolean(). -callback is_token_valid(Host :: binary(), binary(), {binary(), binary()}) -> boolean(). -callback list_invites(Host :: binary()) -> [tuple()]. -callback remove_user(User :: binary(), Server :: binary()) -> any(). -callback set_invitee(Fun :: fun(() -> OkOrError), Host :: binary(), Token :: binary(), Invitee :: binary(), AccountName :: binary()) -> OkOrError | {error, conflict} when OkOrError :: ok | {error, term()}. -callback transaction(Host:: binary(), fun(() -> T)) -> {atomic, T} | {aborted, any()}. %% @format-begin %%-------------------------------------------------------------------- %%| gen_mod callbacks depends(_Host, _Opts) -> [{mod_adhoc, soft}, {mod_register, soft}, {mod_roster, soft}]. mod_doc() -> #{desc => [?T("Allow User Invitation and Account Creation to create out-of-band " "links to onboard others onto the XMPP network and establish " "a mutual subscription."), ?T("This implements " "https://xmpp.org/extensions/xep-0379.html" "[XEP-0379: Pre-Authenticated Roster Subscription], " "https://xmpp.org/extensions/xep-0401.html" "[XEP-0401: Ad-hoc Account Invitation Generation], and " "https://xmpp.org/extensions/xep-0445.html" "[XEP-0445: Pre-Authenticated In-Band Registration]."), "", ?T("These invitations are created as XMPP URIs either via ad-hoc " "commands or via API commands (like _`generate_invite`_ API and " " _`generate_invite_with_username`_ API), are then meant to be " "sent out-of-band."), "", ?T("The receiving user should have installed a client that supports " "those invitations. Since this has proven to be a common obstacle " "for easy adoption, this module comes with an optional landing " "page parameter, that can either be some external service like an " "installation of " "https://github.com/modernxmpp/easy-xmpp-invitation[easy-xmpp-invitation], " "a third-party service like " "https://invite.joinjabber.org[JoinJabber] " "or for convenience a built-in service. This landing page will " "then guide the recipient with setting up a client " "and creating an account if required."), "", ?T("In order to use the included landing page feature, you have to" " set `landing_page` to either `auto` or an URL template like " "`https://{{ host }}/invites/{{ invite.token }}` " " if your server setup includes a so called reverse proxy."), "", ?T("If you'd rather want to use an external service, set `landing_page` " "to something like " "`http://{{ host }}:8080/easy-xmpp-invites/#{{ invite.uri|strip_protocol }}` " "or `https://invites.joinjabber.org/#{{ invite.uri|strip_protocol }}`.")], note => "improved in 26.03", opts => [{access_create_account, #{value => ?T("Access Rule Name"), desc => ?T("This is the name of an access rule that specifies who is allowed to create " "invites of `create account`. The default value is `none`, i.e. nobody is able to" " create such invites. Furthermore it applies to 'roster invites' and allows " "to do in-band registration (IBR) if the sending user is allowed by this rule. " "Users from the `admin` ACL are always allowed to create those invites."), example => ["mod_invites:", " access_create_account: local"]}}, {db_type, #{value => "mnesia | sql", desc => ?T("Same as top-level _`default_db`_ option, but applied to this " "module only.")}}, {landing_page, #{value => "none | auto | LandingPageURLTemplate", desc => ?T("Whether or not to use a landing page for the invites that " "are being created. If using a template URL this can be " "either be external or internal. Template variables include " "`host`, `invite.token` and `invite.uri`, there are also " "filters defined, most notably `strip_protocol`. Here's an example: " "`http://{{ host }}:8080/easy-xmpp-invites/#{{ invite.uri|strip_protocol }}`. " "For convenience you can choose `auto` here and the " "`ejabberd_http` handler for `mod_invites` will be used to " "construct the landing page URL. Default is `none`.")}}, {max_invites, #{value => "pos_integer() | infinity", desc => ?T("Maximum number of 'create account' invites that can be created " "by an individual user. Users that match the `admin` ACL are " "exempt from this limitation. Furthermore it restricts the use of " "`roster invites` for account creation. Default is `infinity`.")}}, {site_name, #{value => ?T("Site Name"), desc => ?T("A human readable name for your site. E.g. `\"My Beautiful Laundrette\"`. " "Used in landing page templates.")}}, {templates_dir, #{value => ?T("Path"), desc => ?T("The directory containing templates and static files used " "for landing page and web registration form. Only needs to " "be set if you want to ship your own set of templates or " "list of recommended apps.")}}, {token_expire_seconds, #{value => "pos_integer()", desc => ?T("Number of seconds until token expires. Default value " "is `432000` (that is five days: `5 * 24 * 60 * 60`)")}}, {webchat_url, #{value => "none | auto | Webchat URL", note => "added in 26.03", desc => ?T("URL to a webchat client. Upon manual registration through web-form this will be " "recommended in order to get started. If `auto` is chosen, we pick the " "`mod_conversejs` from the listeners section. Default is `auto`.")}}], example => [{?T("Basic configuration with landing page but without creating " "accounts, just roster invites:"), ["listen: ", " -", " port: 5281", " module: ejabberd_http", " request_handlers:", " /invites: mod_invites", "# [...]", "modules:", " mod_invites:", " landing_page: auto"]}, {?T("To allow only admin users to create invites of 'create account' and " "disable regular in-band registration, you would have a config like this:"), ["acl:", " admin:", " - user: \"my_admin_user@example.com\"", "", "access_rules:", " register:", " allow: admin", "", "modules:", " mod_invites:", " landing_page: auto", " mod_register:", " allow_modules:", " - mod_invites"]}, {?T("If you want all your users to be able to send 'create account' " "invites, you would configure your server like this instead. " "Note that the names of the access rules are just examples and " "you're free to change them."), ["acl:", " local:", " user_regexp: \"\"", "access_rules:", " create_account_invite:", " allow: local", "", "modules:", " mod_invites:", " access_create_account: create_account_invite", " landing_page: auto", " mod_register:", " allow_modules:", " - mod_invites"]}]}. -spec mod_options(binary()) -> [{landing_page, none | auto | binary()} | {atom(), any()}]. mod_options(Host) -> [{access_create_account, none}, {db_type, ejabberd_config:default_db(Host, ?MODULE)}, {landing_page, none}, {max_invites, infinity}, {site_name, Host}, {templates_dir, filename:join([code:priv_dir(ejabberd), ?MODULE, <<>>])}, {token_expire_seconds, ?INVITE_TOKEN_EXPIRE_SECONDS_DEFAULT}, {webchat_url, auto}]. reload(ServerHost, NewOpts, OldOpts) -> NewMod = gen_mod:db_mod(NewOpts, ?MODULE), OldMod = gen_mod:db_mod(OldOpts, ?MODULE), if NewMod /= OldMod -> NewMod:init(ServerHost, NewOpts); true -> ok end. start(Host, Opts) -> Mod = gen_mod:db_mod(Opts, ?MODULE), Mod:init(Host, Opts), {ok, [{hook, remove_user, remove_user, 50}, {hook, adhoc_local_items, adhoc_items, 50}, {hook, adhoc_local_commands, adhoc_commands, 50}, {hook, disco_local_features, get_local_features, 50}, {hook, disco_local_identity, get_local_identity, 50}, {hook, s2s_receive_packet, s2s_receive_packet, 50}, {hook, sm_receive_packet, sm_receive_packet, 50}, {hook, c2s_pre_auth_features, stream_feature_register, 50}, %% note the sequence below is important {hook, c2s_unauthenticated_packet, c2s_unauthenticated_packet, 10}, {commands, get_commands_spec()}]}. stop(_Host) -> ok. mod_opt_type(access_create_account) -> econf:acl(); mod_opt_type(db_type) -> econf:db_type(?MODULE); mod_opt_type(landing_page) -> econf:either( econf:enum([none, auto]), econf:and_then( econf:binary(), fun(Tmpl) -> try mod_invites_http:tmpl_to_renderer(Tmpl) of R when is_atom(R) -> Tmpl catch error:{badmatch, error} -> T = <<"There is some problem in the value you configured " "for option 'landing_page' in 'mod_invites'. " "Please consult the documentation and fix it: ", Tmpl/binary>>, ?CRITICAL_MSG(T, []), throw({error, T}) end end)); mod_opt_type(max_invites) -> econf:pos_int(infinity); mod_opt_type(site_name) -> econf:binary(); mod_opt_type(templates_dir) -> econf:directory(); mod_opt_type(token_expire_seconds) -> econf:pos_int(); mod_opt_type(webchat_url) -> econf:either( econf:enum([none, auto]), econf:url()). %%-------------------------------------------------------------------- %%| ejabberd command callbacks -spec get_commands_spec() -> [ejabberd_commands()]. get_commands_spec() -> [#ejabberd_commands{name = cleanup_expired_invite_tokens, tags = [purge], desc = "Delete invite tokens that have expired", module = ?MODULE, function = cleanup_expired, note = "added in 26.01", args = [], result_example = 42, result = {num_deleted, integer}}, #ejabberd_commands{name = expire_invite_tokens, tags = [purge], desc = "Sets expiration to a date in the past for all tokens belonging " "to user", module = ?MODULE, function = expire_tokens, note = "added in 26.01", args = [{username, binary}, {host, binary}], result_example = 42, result = {num_deleted, integer}}, #ejabberd_commands{name = generate_invite, tags = [accounts], desc = "Create a new 'create account' invite", module = ?MODULE, function = generate_invite, note = "added in 26.01", args = [{host, binary}], args_desc = ["Hostname to generate 'create account' invite for."], args_example = [<<"example.com">>], result_example = {<<"xmpp:example.com?register;preauth=4bsdpwVrRDQYnF9aQQKXGbF7">>, <<"https://example.com/invites/4bsdpwVrRDQYnF9aQQKXGbF7">>}, result = {invite, {tuple, [{invite_uri, string}, {landing_page, string}]}}}, #ejabberd_commands{name = generate_invite_with_username, tags = [accounts], desc = "Create a new 'create account' invite token with a preselected " "username", module = ?MODULE, function = generate_invite, note = "added in 26.01", args = [{username, binary}, {host, binary}], args_desc = ["Preselected Username", "hostname to generate 'create account' invite for."], args_example = [<<"juliet">>, <<"example.com">>], result_example = {<<"xmpp:juliet@example.com?register;preauth=4bsdpwVrRDQYnF9aQQKXGbF7">>, <<"https://example.com/invites/4bsdpwVrRDQYnF9aQQKXGbF7">>}, result = {invite, {tuple, [{invite_uri, string}, {landing_page, string}]}}}, #ejabberd_commands{name = list_invites, tags = [accounts], desc = "List invite tokens", module = ?MODULE, function = list_invites, note = "added in 26.01", args = [{host, binary}], args_desc = ["Hostname tokens are valid for"], args_example = [<<"example.com">>], %result_example = [{invite_token, invite}], result = {invites, {list, {invite, {tuple, [{token, string}, {valid, atom}, {created_at, string}, {expires, string}, {type, atom}, {inviter, string}, {invitee, string}, {account_name, string}, {token_uri, string}, {landing_page, string}]}}}}}]. cleanup_expired() -> lists:foldl(fun(Host, Count) -> case gen_mod:is_loaded(Host, ?MODULE) of true -> Count + db_call(Host, cleanup_expired, [Host]); false -> Count end end, 0, ejabberd_option:hosts()). -spec expire_tokens(binary(), binary()) -> non_neg_integer(). expire_tokens(User0, Server0) -> User = jid:nodeprep(User0), Server = jid:nameprep(Server0), pretty_format_command_result(try_db_call(Server, expire_tokens, [User, Server])). -spec generate_invite(binary()) -> binary() | {error, any()}. generate_invite(Host) -> generate_invite(<<>>, Host). -spec generate_invite(binary(), binary()) -> binary() | {error, any()}. generate_invite(AccountName, Host) -> pretty_format_command_result(gen_invite(AccountName, Host)). -ifdef(TEST). -spec gen_invite(binary()) -> binary() | {error, any()}. gen_invite(Host) -> gen_invite(<<>>, Host). -endif. -spec gen_invite(binary(), binary()) -> binary() | {error, any()}. gen_invite(AccountName, Host0) -> Host = jid:nameprep(Host0), case create_account_invite(Host, {<<>>, Host}, AccountName, false) of {error, _Reason} = Error -> Error; Invite -> {token_uri(Invite), landing_page(Host, Invite)} end. list_invites(Host) -> Res = maybe {ok, Invites} ?= try_db_call(Host, list_invites, [Host]), [format_invite(Host, Invite) || Invite <- Invites] end, pretty_format_command_result(Res). format_invite(Host, #invite_token{token = TO, inviter = {IU, IS}, invitee = IE, created_at = CA, expires = Exp, type = TY, account_name = AN} = Invite) -> {TO, is_token_valid(Host, TO), encode_datetime(CA), encode_datetime(Exp), TY, jid:encode( jid:make(IU, IS)), IE, AN, token_uri(Invite), landing_page(Host, Invite)}. %%-------------------------------------------------------------------- %%| hooks and callbacks remove_user(User, Server) -> LUser = jid:nodeprep(User), LServer = jid:nameprep(Server), db_call(Server, remove_user, [LUser, LServer]). %% --- -spec adhoc_items(empty | {error, stanza_error()} | {result, [disco_item()]}, jid(), jid(), binary()) -> {error, stanza_error()} | {result, [disco_item()]} | empty. adhoc_items(Acc, #jid{lserver = LServer} = From, #jid{lserver = LServer, server = Server} = _To, Lang) -> InviteUser = #disco_item{jid = jid:make(Server), node = ?NS_INVITE_INVITE, name = trans(Lang, ?T("Invite User"))}, CreateAccount = #disco_item{jid = jid:make(Server), node = ?NS_INVITE_CREATE_ACCOUNT, name = trans(Lang, ?T("Create Account"))}, MyItems = case create_account_allowed(LServer, From) of ok -> [InviteUser, CreateAccount]; {error, not_allowed} -> [InviteUser] end, case Acc of {result, AccItems} -> {result, AccItems ++ MyItems}; _ -> {result, MyItems} end; adhoc_items(Acc, _From, _To, _Lang) -> Acc. %% --- -spec adhoc_commands(empty | adhoc_command(), jid(), jid(), adhoc_command()) -> adhoc_command() | {error, stanza_error()}. adhoc_commands(_Acc, #jid{luser = LUser, lserver = LServer}, #jid{lserver = LServer}, #adhoc_command{node = ?NS_INVITE_INVITE = Node, action = execute, sid = SID, lang = Lang}) -> Invite = create_roster_invite(LServer, {LUser, LServer}), XData = #xdata{type = result, title = trans(Lang, <<"New Invite Token Created">>), fields = maybe_add_landing_url(LServer, Invite, Lang, [#xdata_field{var = <<"uri">>, label = trans(Lang, <<"Invite URI">>), type = 'text-single', values = [token_uri(Invite)]}, #xdata_field{var = <<"expire">>, label = trans(Lang, <<"Invite token valid until">>), type = 'text-single', values = [encode_datetime(Invite#invite_token.expires)]}])}, Result = #adhoc_command{status = completed, node = Node, xdata = XData, sid = SID}, {stop, Result}; adhoc_commands(_Acc, #jid{luser = LUser, lserver = LServer} = From, #jid{lserver = LServer}, #adhoc_command{node = ?NS_INVITE_CREATE_ACCOUNT = Node, sid = SID, lang = Lang, xdata = #xdata{type = submit, fields = Fields}}) -> check(fun create_account_allowed/2, [LServer, From], fun() -> AccountName = xdata_field(<<"username">>, Fields, <<>>), Invite = create_account_invite(LServer, {LUser, LServer}, AccountName, to_boolean(xdata_field(<<"roster-subscription">>, Fields, false))), case Invite of {error, Reason} -> {stop, {error, to_stanza_error(Lang, Reason)}}; _Invite -> ResultFields = maybe_add_landing_url(LServer, Invite, Lang, [#xdata_field{var = <<"uri">>, label = trans(Lang, <<"Invite URI">>), type = 'text-single', values = [token_uri(Invite)]}, #xdata_field{var = <<"expire">>, label = trans(Lang, <<"Invite token valid until">>), type = 'text-single', values = [encode_datetime(Invite#invite_token.expires)]}]), ResultXData = #xdata{type = result, fields = ResultFields}, Result = #adhoc_command{status = completed, sid = SID, node = Node, xdata = ResultXData}, {stop, Result} end end, fun(Reason) -> {stop, {error, to_stanza_error(Lang, Reason)}} end); adhoc_commands(_Acc, #jid{lserver = LServer} = From, #jid{lserver = LServer}, #adhoc_command{node = ?NS_INVITE_CREATE_ACCOUNT = Node, action = execute, sid = SID, lang = Lang}) -> check(fun create_account_allowed/2, [LServer, From], fun() -> XData = #xdata{type = form, title = trans(Lang, <<"Account Creation Invite">>), fields = [#xdata_field{var = <<"username">>, label = trans(Lang, <<"Username">>), type = 'text-single'}, #xdata_field{var = <<"roster-subscription">>, label = trans(Lang, <<"Roster Subscription">>), type = boolean}]}, Actions = #adhoc_actions{execute = complete, complete = true}, Result = #adhoc_command{status = executing, node = Node, sid = maybe_gen_sid(SID), actions = Actions, xdata = XData}, {stop, Result} end, fun(Reason) -> {stop, {error, to_stanza_error(Lang, Reason)}} end); adhoc_commands(Acc, _From, _To, _Command) -> Acc. -spec s2s_receive_packet({stanza() | drop, State}) -> {stanza() | drop, State} | {stop, {drop, State}} when State :: ejabberd_s2s_in:state(). s2s_receive_packet({Stanza, State}) -> case sm_receive_packet(Stanza) of {stop, drop} -> {stop, {drop, State}}; Res -> {Res, State} end. -spec sm_receive_packet(stanza() | drop) -> stanza() | drop | {stop, drop}. sm_receive_packet(#presence{from = From, to = To, type = subscribe, sub_els = Els} = Presence) -> case handle_pre_auth_token(Els, To, From) of true -> {stop, drop}; false -> Presence end; sm_receive_packet(Other) -> Other. handle_pre_auth_token([], _To, _From) -> false; handle_pre_auth_token([El | Els], #jid{luser = LUser, lserver = LServer} = To, FromFullJid) -> From = jid:remove_resource(FromFullJid), try xmpp:decode(El) of #preauth{token = Token} = PreAuth -> ?DEBUG("got preauth token: ~p", [PreAuth]), case is_token_valid(LServer, Token, {LUser, LServer}) of true -> roster_add(To, From), send_presence(To, From, subscribed), send_presence(To, From, subscribe), set_invitee(LServer, Token, From), true; false -> ?INFO_MSG("Got invalid preauth token from ~s: ~p", [jid:encode(From), PreAuth]), false end; _Other -> handle_pre_auth_token(Els, To, From) catch _:{xmpp_codec, _} -> handle_pre_auth_token(Els, To, From) end. %%-------------------------------------------------------------------- %%| Service Disco -define(INFO_IDENTITY(Category, Type, Name, Lang), [#identity{category = Category, type = Type, name = trans(Lang, Name)}]). -define(INFO_COMMAND(Name, Lang), ?INFO_IDENTITY(<<"automation">>, <<"command-node">>, Name, Lang)). -spec get_local_identity([identity()], jid(), jid(), binary(), binary()) -> [identity()]. get_local_identity(_Acc, _From, _To, ?NS_INVITE_CREATE_ACCOUNT, Lang) -> ?INFO_COMMAND(?T("Create Account"), Lang); get_local_identity(_Acc, _From, _To, ?NS_INVITE_INVITE, Lang) -> ?INFO_COMMAND(?T("Invite User"), Lang); get_local_identity(Acc, _From, _To, _NS, _Lang) -> Acc. -spec get_local_features(mod_disco:features_acc(), jid(), jid(), binary(), binary()) -> mod_disco:features_acc(). get_local_features(Acc, From, #jid{lserver = LServer} = _To, Ns, Lang) -> maybe allow ?= case Ns of ?NS_INVITE_CREATE_ACCOUNT -> Access = mod_invites_opt:access_create_account(LServer), acl:match_rule(LServer, Access, From); ?NS_INVITE_INVITE -> allow; _ -> false end, {result, [?NS_COMMANDS]} else false -> Acc; deny -> {error, xmpp:err_forbidden(?T("Access denied by service policy"), Lang)} end. %%-------------------------------------------------------------------- %%| ibr hooks stream_feature_register(Acc, Host) -> case gen_mod:is_loaded(Host, ?MODULE) of true -> mod_invites_register:stream_feature_register(Acc, Host); false -> Acc end. c2s_unauthenticated_packet(State, IQ) -> mod_invites_register:c2s_unauthenticated_packet(State, IQ). %%-------------------------------------------------------------------- %%| ejabberd_http process(LocalPath, Request) -> mod_invites_http:process(LocalPath, Request). %%-------------------------------------------------------------------- %%| helpers get_invite(Host, Token) -> db_call(Host, get_invite, [Host, Token]). -ifdef(TEST). get_invites(Host, Inviter) -> transaction(Host, fun() -> get_invites_t(Host, Inviter) end). -endif. get_invites_t(Host, Inviter) -> db_call(Host, get_invites_t, [Host, Inviter]). is_expired(#invite_token{expires = Expires}) -> Now = erlang:timestamp(), calendar:datetime_to_gregorian_seconds(Expires) < calendar:datetime_to_gregorian_seconds( calendar:now_to_universal_time(Now)). is_reserved(Host, Token, User) -> db_call(Host, is_reserved, [Host, Token, User]). -spec is_token_valid(binary(), binary()) -> boolean(). is_token_valid(Host, Token) -> is_token_valid(Host, Token, {<<>>, Host}). -spec is_token_valid(binary(), binary(), {binary(), binary()}) -> boolean(). is_token_valid(Host, Token, Inviter) -> db_call(Host, is_token_valid, [Host, Token, Inviter]). -spec set_invitee(binary(), binary(), jid() | binary()) -> ok. set_invitee(Host, Token, #jid{} = InviteeJid) -> set_invitee(Host, Token, jid:encode( jid:remove_resource(InviteeJid)), <<>>); set_invitee(Host, Token, Invitee) -> set_invitee(Host, Token, Invitee, <<>>). set_invitee(Host, Token, Invitee, AccountName) -> set_invitee(fun() -> ok end, Host, Token, Invitee, AccountName). -spec set_invitee(binary(), binary(), binary(), binary()) -> ok. set_invitee(F, Host, Token, Invitee, AccountName) -> %% This invalidates the invite token if Invitee isn't empty db_call(Host, set_invitee, [F, Host, Token, Invitee, AccountName]). create_roster_invite(Host, Inviter) -> create_invite(roster_only, Host, Inviter, <<>>). create_account_invite(Host, Inviter, AccountName, _Subscribe = true) -> create_invite(account_subscription, Host, Inviter, AccountName); create_account_invite(Host, Inviter, AccountName, _Subcribe = false) -> create_invite(account_only, Host, Inviter, AccountName). create_invite(Type, Host, Inviter, AccountName) -> F = fun() -> create_invite_t(Type, Host, Inviter, AccountName) end, transaction(Host, F). create_invite_t(Type, Host, Inviter, AccountName) -> try invite_token_t(Type, Host, Inviter, AccountName) of Invite -> db_call(Host, create_invite_t, [Invite]) catch _:({error, _Reason} = Error) -> Error; _:Error -> {error, Error} end. check_account_name(<<>>, _) -> <<>>; check_account_name(error, _) -> {error, account_name_invalid}; check_account_name(_, error) -> {error, hostname_invalid}; check_account_name(AccountName, Host) -> MyHosts = ejabberd_option:hosts(), case lists:member(Host, MyHosts) of false -> {error, host_unknown}; true -> case ejabberd_auth:user_exists(AccountName, Host) of true -> {error, user_exists}; false -> case is_reserved(Host, <<>>, AccountName) of true -> {error, reserved}; false -> AccountName end end end. check_max_invites_t(roster_only, _) -> ok; check_max_invites_t(_Type, {User, Host}) -> case is_create_allowed_t(User, Host) of true -> ok; false -> {error, num_invites_exceeded} end. is_create_allowed(User, Host) -> transaction(Host, fun() -> is_create_allowed_t(User, Host) end). is_create_allowed_t(User, Host) -> case get_max_invites(User, Host) of infinity -> true; MaxInvites -> Invites = get_invites_t(Host, {User, Host}), NumCreated = lists:foldl(fun (#invite_token{type = roster_only, account_name = <<>>}, Num) -> Num; (#invite_token{type = roster_only}, Num) -> %% We make sure to set account_name to the registered name when %% creating the account. This field is not used in roster_only %% scenario otherwise. Num + 1; (#invite_token{invitee = <<>>} = Invite, Num) -> %% account create tokens count unless they haven't been used and %% are expired case mod_invites:is_expired(Invite) of true -> Num; false -> Num + 1 end; (_, Num) -> %% account create token where invitee is not empty Num + 1 end, 0, Invites), NumCreated < MaxInvites end. get_max_invites(<<>>, _Server) -> infinity; get_max_invites(User, Server) -> case {mod_invites_opt:max_invites(Server), acl:match_acl(Server, {acl, admin}, #{usr => {User, Server, <<>>}})} of {infinity, _} -> infinity; {_, true} -> infinity; {MaxInvites, false} -> MaxInvites end. check_overuse_t(roster_only, {User, Host}) -> NumInvites = length(get_invites_t(Host, {User, Host})), case NumInvites >= ?OVERUSE_LIMIT of true -> {error, num_invites_exceeded}; false -> ok end; check_overuse_t(_Type, {User, Host}) -> NumInvites = length(get_invites_tree_t(Host, {User, Host})), case NumInvites >= ?OVERUSE_LIMIT of true -> {error, num_invites_exceeded}; false -> ok end. get_invites_tree_t(Host, Inviter) -> Now = calendar:datetime_to_gregorian_seconds( calendar:now_to_datetime( erlang:timestamp())), Root = find_invites_tree_root_t(Now, Host, Inviter, 0), get_invites_tree_as_root_t(Host, Root). find_invites_tree_root_t(Now, Host, Invitee, Lvl) -> case get_invite_by_invitee_t(Host, Invitee) of #invite_token{inviter = Inviter, created_at = CreatedAt} -> maybe_block_speedy_goat(Now, CreatedAt, Lvl), find_invites_tree_root_t(Now, Host, Inviter, Lvl + 1); {error, not_found} -> Invitee end. -spec get_invite_by_invitee_t(binary(), {binary(), binary()}) -> invite_token() | {error, not_found}. get_invite_by_invitee_t(Host, {User, Server}) -> InviteeJid = jid:encode( jid:make(User, Server)), db_call(Host, get_invite_by_invitee_t, [Host, InviteeJid]). maybe_block_speedy_goat(Now, CreatedAt, Lvl) when Lvl == ?SPEEDY_GOAT_LEVELS -> Then = calendar:datetime_to_gregorian_seconds(CreatedAt), if Now - Then < ?SPEEDY_GOAT_SECONDS -> throw(speedy_goat); true -> ok end; maybe_block_speedy_goat(_, _, _) -> ok. -spec get_invites_tree_as_root_t(binary(), {binary(), binary()}) -> [invite_token()]. get_invites_tree_as_root_t(Host, Inviter) -> Invites = get_invites_t(Host, Inviter), get_invites_tree_as_root_t(Host, Inviter, Invites, []). get_invites_tree_as_root_t(_Host, _Inviter, [], Acc) -> Acc; get_invites_tree_as_root_t(Host, Inviter, [#invite_token{type = roster_only, account_name = <<>>} | Invites], Acc) -> get_invites_tree_as_root_t(Host, Inviter, Invites, Acc); get_invites_tree_as_root_t(Host, Inviter, [#invite_token{invitee = <<>>} = Invite | Invites], Acc) -> get_invites_tree_as_root_t(Host, Inviter, Invites, [Invite | Acc]); get_invites_tree_as_root_t(Host, Inviter, [#invite_token{invitee = InviteeJID} = Invite | Invites], Acc) -> case jid:decode(InviteeJID) of #jid{luser = Invitee, lserver = Host} -> get_invites_tree_as_root_t(Host, Inviter, Invites, [Invite | Acc] ++ get_invites_tree_as_root_t(Host, {Invitee, Host})); _Nomatch -> get_invites_tree_as_root_t(Host, Inviter, Invites, [Invite | Acc]) end. maybe_throw({error, _} = Error) -> throw(Error); maybe_throw(Good) -> Good. invite_token_t(Type, Host, Inviter, AccountName0) -> maybe_throw(check_max_invites_t(Type, Inviter)), maybe_throw(check_overuse_t(Type, Inviter)), Token = p1_rand:get_alphanum_string(?INVITE_TOKEN_LENGTH_DEFAULT), AccountName = maybe_throw(check_account_name(jid:nodeprep(AccountName0), Host)), set_token_expires(#invite_token{token = Token, inviter = Inviter, type = Type, account_name = AccountName}, mod_invites_opt:token_expire_seconds(Host)). token_uri(#invite_token{type = Type, token = Token, account_name = AccountName, inviter = {_User, Host}}) when Type =:= account_only; Type =:= account_subscription -> Invitee = case AccountName of <<>> -> Host; _ -> <> end, <<"xmpp:", Invitee/binary, "?register;preauth=", Token/binary>>; token_uri(#invite_token{type = roster_only, token = Token, inviter = {User, Host}}) -> IBR = maybe_add_ibr_allowed(User, Host), Inviter = jid:encode( jid:make(User, Host)), <<"xmpp:", Inviter/binary, "?roster;preauth=", Token/binary, IBR/binary>>. maybe_add_ibr_allowed(User, Host) -> case create_account_allowed(Host, jid:make(User, Host)) of ok -> <<";ibr=y">>; {error, not_allowed} -> <<>> end. landing_page(Host, Invite) -> mod_invites_http:landing_page(Host, Invite). -spec db_call(binary(), atom(), [any()]) -> any(). db_call(Host, Fun, Args) -> try gen_mod:db_mod(Host, ?MODULE) of Mod -> apply(Mod, Fun, Args) catch _:{module_not_loaded, ?MODULE, Host} -> throw({error, host_unknown}) end. %% father forgive me lift({error, _R} = E) -> E; lift({ok, _V} = R) -> R; lift(Res) -> {ok, Res}. -spec try_db_call(Host :: binary(), Fun :: atom(), Args :: [any()]) -> {ok, any()} | {error, any()}. try_db_call(Host, Fun, Args) -> try lift(db_call(Host, Fun, Args)) catch _:({error, _Reason} = Error) -> Error; error:Error -> {error, Error} end. transaction(Host, F) -> try db_call(Host, transaction, [Host, F]) of {atomic, Result} -> Result; {aborted, Reason} -> {error, Reason} catch _:Error -> Error end. -spec trans(binary(), binary()) -> binary(). trans(Lang, Msg) -> translate:translate(Lang, Msg). -spec encode_datetime(calendar:datetime()) -> binary(). encode_datetime({{Year, Month, Day}, {Hour, Minute, Second}}) -> list_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Year, Month, Day, Hour, Minute, Second])). set_token_expires(#invite_token{created_at = CreatedAt} = Invite, ExpireSecs) -> Invite#invite_token{expires = calendar:gregorian_seconds_to_datetime(calendar:datetime_to_gregorian_seconds(CreatedAt) + ExpireSecs)}. xdata_field(_Field, [], Default) -> Default; xdata_field(Field, [#xdata_field{var = Field, values = [<<>> | _]} | _], Default) -> Default; xdata_field(Field, [#xdata_field{var = Field, values = [Result | _]} | _], _Default) -> Result; xdata_field(Field, [_NoMatch | Fields], Default) -> xdata_field(Field, Fields, Default). maybe_add_landing_url(Host, Invite, Lang, XData) -> case landing_page(Host, Invite) of <<>> -> XData; LandingPage -> [#xdata_field{var = <<"landing-url">>, values = [LandingPage], label = trans(Lang, <<"Invite Landing Page URL">>), type = 'text-single'} | XData] end. check(Check, Args, Fun, Else) -> case erlang:apply(Check, Args) of ok -> Fun(); {error, Reason} -> Else(Reason) end. create_account_allowed(Host, User) -> case mod_invites_opt:access_create_account(Host) of none -> {error, not_allowed}; Access -> case acl:match_rule(Host, Access, User) of deny -> {error, not_allowed}; allow -> ok end end. to_boolean(Boolean) when is_boolean(Boolean) -> Boolean; to_boolean(True) when True == <<"1">>; True == <<"true">> -> true; to_boolean(False) when False == <<"0">>; False == <<"false">> -> false. to_stanza_error(Lang, not_allowed) -> Text = trans(Lang, <<"Access forbidden">>), xmpp:err_forbidden(Text, Lang); to_stanza_error(Lang, Reason) -> Text = trans(Lang, reason_to_text(Reason)), xmpp:err_bad_request(Text, Lang). reason_to_text(account_name_invalid) -> ?T("Username invalid"); reason_to_text(host_unknown) -> ?T("Host unknown"); reason_to_text(hostname_invalid) -> ?T("Hostname invalid"); reason_to_text(num_invites_exceeded) -> ?T("Maximum number of invites reached"); reason_to_text(reserved) -> ?T("Username is reserved"); reason_to_text(user_exists) -> ?T("User already exists"). maybe_gen_sid(<<>>) -> p1_rand:get_alphanum_string(?INVITE_TOKEN_LENGTH_DEFAULT); maybe_gen_sid(SID) -> SID. roster_add(UserJID, RosterItemJID) -> RosterItem = #roster_item{jid = RosterItemJID, subscription = from, ask = subscribe}, mod_roster:set_item_and_notify_clients(UserJID, RosterItem, true). send_presence(From, To, Type) -> Presence = #presence{from = From, to = To, type = Type}, ejabberd_router:route(Presence). pretty_format_command_result({error, {module_not_loaded, ?MODULE, Host}}) -> {error, lists:flatten( io_lib:format("Virtual host not known: ~s", [binary_to_list(Host)]))}; pretty_format_command_result({error, host_unknown}) -> {error, "Virtual host not known"}; pretty_format_command_result({error, user_exists}) -> {error, "Username already taken"}; pretty_format_command_result({ok, Result}) -> Result; pretty_format_command_result(Result) -> Result.