Packages
ejabberd
26.4.0
26.4.0
26.3.0
26.2.0
26.1.0
25.10.0
25.8.0
25.7.0
25.4.0
25.3.0
24.12.0
24.10.0
24.7.0
24.6.0
24.2.6
23.10.0
23.4.0
23.1.0
22.10.0
22.5.0
21.12.0
21.7.0
21.4.0
21.1.0
20.12.0
20.7.0
20.4.0
20.3.0
20.2.0
20.1.0
19.9.1
19.9.0
19.8.0
19.5.0
19.2.0
18.12.1
18.12.0
18.6.0
18.4.0
18.3.0
18.1.0
17.11.0
17.9.0
17.6.0
17.3.0
17.1.0
16.12.0-beta1
16.9.0
16.8.0
16.6.2
16.6.1
16.6.0
16.4.1
16.4.0
16.3.0
16.2.0
16.1.0-beta1
Robust, Ubiquitous and Massively Scalable Messaging Platform (XMPP, MQTT, SIP Server)
Current section
Files
Jump to
Current section
Files
src/mod_invites.erl
%%%----------------------------------------------------------------------
%%% File : mod_invites.erl
%%% Author : Stefan Strigler <stefan@strigler.de>
%%% Purpose : Account and Roster Invitation (aka Great Invitations)
%%% Created : Mon Sep 15 2025 by Stefan Strigler <stefan@strigler.de>
%%%
%%%
%%% 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(), Invitee :: {User :: binary(), Host :: 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, {<<>>, _Server}) ->
{error, not_found};
get_invite_by_invitee_t(Host, {User, Server}) ->
db_call(Host, get_invite_by_invitee_t, [Host, {User, Server}]).
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;
_ ->
<<AccountName/binary, "@", Host/binary>>
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.