From 058c67efe2effa2ba5dc06f570f615ef01eb5e06 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Mon, 15 Sep 2025 18:14:21 +0200 Subject: [PATCH 01/27] mod_invites: support invite tokens for roster subscriptions --- include/mod_invites.hrl | 14 + rebar.config | 2 +- src/mod_invites.erl | 664 +++++++++++++++++++ src/mod_invites_mnesia.erl | 119 ++++ src/mod_invites_opt.erl | 34 + src/mod_invites_sql.erl | 163 +++++ test/ejabberd_SUITE.erl | 6 + test/ejabberd_SUITE_data/ejabberd.mnesia.yml | 2 + test/ejabberd_SUITE_data/ejabberd.mysql.yml | 2 + test/ejabberd_SUITE_data/ejabberd.yml | 2 + test/invites_tests.erl | 352 ++++++++++ 11 files changed, 1359 insertions(+), 1 deletion(-) create mode 100644 include/mod_invites.hrl create mode 100644 src/mod_invites.erl create mode 100644 src/mod_invites_mnesia.erl create mode 100644 src/mod_invites_opt.erl create mode 100644 src/mod_invites_sql.erl create mode 100644 test/invites_tests.erl diff --git a/include/mod_invites.hrl b/include/mod_invites.hrl new file mode 100644 index 00000000000..7f7c25786ea --- /dev/null +++ b/include/mod_invites.hrl @@ -0,0 +1,14 @@ +-record(invite_token, {token :: binary(), + inviter :: {binary(), binary()}, + invitee = <<>> :: binary(), + created_at = calendar:now_to_datetime(erlang:timestamp()) :: calendar:datetime(), + expires :: calendar:datetime(), + type = roster_only :: roster_only | account_only | account_subscription, + account_name = undefined :: binary() | undefined + }). + +-define(INVITE_TOKEN_EXPIRE_SECONDS_DEFAULT, 5*86400). +-define(INVITE_TOKEN_LENGTH_DEFAULT, 16). + +-define(NS_INVITE_INVITE, <<"urn:xmpp:invite#invite">>). +-define(NS_INVITE_CREATE_ACCOUNT, <<"urn:xmpp:invite#create-account">>). diff --git a/rebar.config b/rebar.config index 2d240facc39..9346c78e9e6 100644 --- a/rebar.config +++ b/rebar.config @@ -66,7 +66,7 @@ {stringprep, "~> 1.0.33", {git, "https://github.com/processone/stringprep", {tag, "1.0.33"}}}, {if_var_true, stun, {stun, "~> 1.2.21", {git, "https://github.com/processone/stun", {tag, "1.2.21"}}}}, - {xmpp, ".*", {git, "https://github.com/processone/xmpp", "7285aa7802bfa90bcefafdad3a342fbb93ce7eea"}}, + {xmpp, "~> 1.11.2", {git, "https://github.com/sstrigler/xmpp", {branch, "great_invitations"}}}, {yconf, "~> 1.0.22", {git, "https://github.com/processone/yconf", {tag, "1.0.22"}}} ]}. diff --git a/src/mod_invites.erl b/src/mod_invites.erl new file mode 100644 index 00000000000..a5845e44f54 --- /dev/null +++ b/src/mod_invites.erl @@ -0,0 +1,664 @@ +%%%---------------------------------------------------------------------- +%%% File : mod_invites.erl +%%% Author : Stefan Strigler +%%% Purpose : Ad-hoc Account Invitation +%%% Created : Mon Sep 15 2025 by Stefan Strigler +%%% +%%% +%%% ejabberd, Copyright (C) 2025 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'). + +-xep({xep, 401, '0.5.0'}). % [TODO] + +-behaviour(gen_mod). + +-export([depends/2, mod_doc/0, mod_options/1, mod_opt_type/1, reload/3, start/2, stop/1]). +-export([adhoc_commands/4, adhoc_items/4, cleanup_expired/0, expire_tokens/2, + gen_invite/1, gen_invite/2, remove_user/2, s2s_receive_packet/1, sm_receive_packet/1]). + +-ifdef(TEST). +-export([create_roster_invite/2, create_account_invite/4, get_invite/2, is_token_valid/3, is_token_valid/2, + num_account_invites/2]). +-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{}. + +-callback cleanup_expired(Host :: binary()) -> non_neg_integer(). +-callback create_invite(Invitee :: binary()) -> 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 init(Host :: binary(), gen_mod:opts()) -> any(). +-callback is_token_valid(Host :: binary(), binary(), {binary(), binary()}) -> boolean(). +-callback num_account_invites(User :: binary(), Server :: binary()) -> non_neg_integer(). +-callback remove_user(User :: binary(), Server :: binary()) -> any(). +-callback set_invitee(Host :: binary(), Token :: binary(), Invitee :: binary()) -> ok. + +%% @format-begin + +%%-------------------------------------------------------------------- +%%| gen_mod callbacks + +depends(_Host, _Opts) -> + [{mod_adhoc, hard}]. + +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."), + opts => + [{access_create_account, + #{value => ?T("AccessRuleName"), + desc => + ?T("This option specifies who is allowed to send 'create account' " + "invites. The default value is 'none', i.e. nobody is able to " + "create such invites.")}}, + {db_type, + #{value => "mnesia | sql", + desc => + ?T("Same as top-level _`default_db`_ option, but applied to this " + "module only.")}}, + {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.")}}, + {token_expire_seconds, + #{value => "pos_integer()", + desc => ?T("Number of seconds until token expires (e.g.: '7 * 86400')")}}], + example => + [{?T("To allow only admin users to send such invites, you would have " + "a config like this:"), + ["acl:", + " admin:", + " - user: \"my_admin_user@example.com\"", + "", + "access_rules:", + " register:", + " allow: admin", + "", + "modules:", + " mod_invites:", + " access_create_account: register"]}, + {?T("If you want all your users to be able to send 'create account' " + "invites, you would configure your server like this instead:"), + ["acl:", + " local:", + " user_regexp: \"\"", + "access_rules:", + " create_account_invite:", + " allow: local", + "", + "modules:", + " mod_invites:", + " access_create_account: create_account_invite"]}, + ?T("Note that the names of the access rules are just examples and " + "you're free to change them.")]}. + +mod_options(Host) -> + [{access_create_account, none}, + {db_type, ejabberd_config:default_db(Host, ?MODULE)}, + {max_invites, infinity}, + {token_expire_seconds, ?INVITE_TOKEN_EXPIRE_SECONDS_DEFAULT}]. + +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, s2s_receive_packet, s2s_receive_packet, 50}, + {hook, sm_receive_packet, sm_receive_packet, 50}, + {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(max_invites) -> + econf:pos_int(infinity); +mod_opt_type(token_expire_seconds) -> + econf:pos_int(). + +%%-------------------------------------------------------------------- +%%| 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, + 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, + 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 = gen_invite, + args = [{host, binary}], + args_desc = ["Hostname to generate 'create account' invite for."], + args_example = [<<"example.com">>], + result_example = <<"xmpp:example.com?register;preauth=CJAi3TvpzuBJpmuf">>, + result = {invite_uri, 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 = gen_invite, + 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=CJAi3TvpzuBJpmuf">>, + result = {invite_uri, 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), + db_call(Server, expire_tokens, [User, Server]). + +-spec gen_invite(binary()) -> binary() | {error, any()}. +gen_invite(Host) -> + gen_invite(undefined, Host). + +-spec gen_invite(binary(), binary()) -> binary() | {error, any()}. +gen_invite(Username, Host0) -> + Host = jid:nameprep(Host0), + case create_account_invite(Host, {<<>>, Host}, Username, false) of + {error, {module_not_loaded, ?MODULE, Host}} -> + {error, host_unknown}; + {error, _Reason} = Error -> + Error; + Invite -> + token_uri(Invite) + end. + +%%-------------------------------------------------------------------- +%%| 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 = translate:translate(Lang, "Invite User")}, + CreateAccount = + #disco_item{jid = jid:make(Server), + node = ?NS_INVITE_CREATE_ACCOUNT, + name = translate:translate(Lang, "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 = + [#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, undefined), + 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 = + [#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 = 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 -> + RosterItem = + #roster_item{jid = From, + subscription = from, + ask = subscribe}, + mod_roster:set_item_and_notify_clients(To, RosterItem, true), + Subscribed = + #presence{from = To, + to = From, + type = subscribed}, + ejabberd_router:route(To, From, Subscribed), + Subscribe = + #presence{from = To, + to = From, + type = subscribe}, + ejabberd_router:route(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. + +%%-------------------------------------------------------------------- +%%| test API +-ifdef(TEST). +get_invite(Host, Token) -> + db_call(Host, get_invite, [Host, Token]). + +is_token_valid(Host, Token) -> + is_token_valid(Host, Token, {<<>>, Host}). +-endif. + +%%-------------------------------------------------------------------- +%%| helpers +-spec is_token_valid(binary(), binary(), {binary(), binary()}) -> boolean(). +is_token_valid(Host, Token, Inviter) -> + db_call(Host, is_token_valid, [Host, Token, Inviter]). + +set_invitee(Host, Token, InviteeJid) -> + %% This invalidates the invite token + db_call(Host, + set_invitee, + [Host, + Token, + jid:to_string( + jid:remove_resource(InviteeJid))]). + +create_roster_invite(Host, Inviter) -> + create_roster_invite(Host, Inviter, undefined). + +create_roster_invite(Host, Inviter, AccountName) -> + create_invite(roster_only, Host, Inviter, AccountName). + +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) -> + try invite_token(Type, Host, Inviter, AccountName) of + Invite -> + ?DEBUG("Created invite: ~p", [Invite]), + db_call(Host, create_invite, [Invite]) + catch + _:({error, _Reason} = Error) -> + Error; + _:Error -> + {error, Error} + end. + +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 -> + AccountName + end + end. + +num_account_invites(User, Server) -> + db_call(Server, num_account_invites, [User, Server]). + +check_max_invites(roster_only, _, _) -> + ok; +check_max_invites(_Type, Host, {User, Server}) -> + case {mod_invites_opt:max_invites(Host), + acl:match_acl(Host, {acl, admin}, #{usr => {User, Server, <<>>}})} + of + {infinity, _} -> + ok; + {_, true} -> + ok; + {MaxInvites, false} -> + case num_account_invites(User, Server) of + NumInvites when MaxInvites =< NumInvites -> + {error, num_invites_exceeded}; + _AllGood -> + ok + end + end. + +maybe_throw({error, _} = Error) -> + throw(Error); +maybe_throw(Good) -> + Good. + +invite_token(Type, Host, Inviter, AccountName0) -> + maybe_throw(check_max_invites(Type, Host, Inviter)), + Token = p1_rand:get_alphanum_string(?INVITE_TOKEN_LENGTH_DEFAULT), + AccountName = + case AccountName0 of + undefined -> + undefined; + _ -> + AccountName1 = jid:nodeprep(AccountName0), + maybe_throw(check_account_name(AccountName1, Host)) + end, + 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 = account_only, + token = Token, + account_name = AccountName, + inviter = {_User, Host}}) -> + Invitee = + case AccountName of + undefined -> + Host; + _ -> + <> + end, + <<"xmpp:", Invitee/binary, "?register;preauth=", Token/binary>>; +token_uri(#invite_token{type = account_subscription, + token = Token, + inviter = {User, Host}}) -> + Inviter = + jid:to_string( + jid:make(User, Host)), + <<"xmpp:", Inviter/binary, "?roster;preauth=", Token/binary, ";ibr=y">>; +token_uri(#invite_token{type = roster_only, + token = Token, + inviter = {User, Host}}) -> + Inviter = + jid:to_string( + jid:make(User, Host)), + <<"xmpp:", Inviter/binary, "?roster;preauth=", Token/binary>>. + +db_call(Host, Fun, Args) -> + Mod = gen_mod:db_mod(Host, ?MODULE), + apply(Mod, Fun, Args). + +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). + +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(host_unknown) -> + "Host unknown"; +reason_to_text(hostname_invalid) -> + "Hostname invalid"; +reason_to_text(account_name_invalid) -> + "Username invalid"; +reason_to_text(user_exists) -> + "User already exists"; +reason_to_text(num_invites_exceeded) -> + "Maximum number of invites reached". diff --git a/src/mod_invites_mnesia.erl b/src/mod_invites_mnesia.erl new file mode 100644 index 00000000000..2c27d6ac1f2 --- /dev/null +++ b/src/mod_invites_mnesia.erl @@ -0,0 +1,119 @@ +%%%---------------------------------------------------------------------- +%%% File : mod_invites_mnesia.erl +%%% Author : Stefan Strigler +%%% Created : Mon Sep 15 2025 by Stefan Strigler +%%% +%%% +%%% ejabberd, Copyright (C) 2025 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_mnesia). + +-author('stefan@strigler.de'). + +-behaviour(mod_invites). + +-export([cleanup_expired/1, create_invite/1, expire_tokens/2, get_invite/2, init/2, + is_token_valid/3, num_account_invites/2, remove_user/2, set_invitee/3]). + +-include("mod_invites.hrl"). + +%% @format-begin + +%%-------------------------------------------------------------------- +%%| mod_invite callbacks + +cleanup_expired(_Host) -> + Ts = erlang:timestamp(), + lists:foldl(fun(Token, Count) -> + [Invite] = mnesia:dirty_read(invite_token, Token), + case is_expired(Invite, Ts) of + true -> + ok = mnesia:dirty_delete(invite_token, Token), + Count + 1; + false -> + Count + end + end, + 0, + mnesia:dirty_all_keys(invite_token)). + +create_invite(Invite) -> + ok = mnesia:dirty_write(Invite), + Invite. + +expire_tokens(User, Server) -> + Ts = erlang:timestamp(), + length([mnesia:dirty_write(I#invite_token{expires = {{1970, 1, 1}, {0, 0, 1}}}) + || I <- mnesia:dirty_index_read(invite_token, {User, Server}, #invite_token.inviter), + not is_expired(I, Ts), + I#invite_token.type /= roster_only]). + +get_invite(_Host, Token) -> + case mnesia:dirty_read(invite_token, Token) of + [Invite] -> + Invite; + [] -> + {error, not_found} + end. + +init(_Host, _Opts) -> + ejabberd_mnesia:create(?MODULE, + invite_token, + [{disc_copies, [node()]}, + {attributes, record_info(fields, invite_token)}, + {index, [#invite_token.inviter]}]). + +is_token_valid(_Host, Token, Inviter) -> + case mnesia:dirty_read(invite_token, Token) of + [Invite = #invite_token{invitee = <<>>, inviter = Inviter}] -> + not is_expired(Invite, erlang:timestamp()); + [#invite_token{inviter = _WrongOwner}] -> + false; + [] -> + false + end. + +num_account_invites(User, Server) -> + length([I + || I = #invite_token{type = Type} + <- mnesia:dirty_index_read(invite_token, {User, Server}, #invite_token.inviter), + Type =/= roster_only]). + +remove_user(User, Server) -> + Inviter = {User, Server}, + [ok = mnesia:dirty_delete(invite_token, Token) + || #invite_token{token = Token} + <- mnesia:dirty_index_read(invite_token, Inviter, #invite_token.inviter)], + ok. + +set_invitee(_Host, Token, Invitee) -> + Transaction = + fun() -> + [Invite] = mnesia:read(invite_token, Token), + mnesia:write(Invite#invite_token{invitee = Invitee}) + end, + {atomic, ok} = mnesia:transaction(Transaction), + ok. + +%%-------------------------------------------------------------------- +%%| helpers + +is_expired(#invite_token{expires = Expires}, Now) -> + calendar:datetime_to_gregorian_seconds(Expires) + < calendar:datetime_to_gregorian_seconds( + calendar:now_to_universal_time(Now)). diff --git a/src/mod_invites_opt.erl b/src/mod_invites_opt.erl new file mode 100644 index 00000000000..fbfd13451aa --- /dev/null +++ b/src/mod_invites_opt.erl @@ -0,0 +1,34 @@ +%% Generated automatically +%% DO NOT EDIT: run `make options` instead + +-module(mod_invites_opt). + +-export([access_create_account/1]). +-export([db_type/1]). +-export([max_invites/1]). +-export([token_expire_seconds/1]). + +-spec access_create_account(gen_mod:opts() | global | binary()) -> 'none' | acl:acl(). +access_create_account(Opts) when is_map(Opts) -> + gen_mod:get_opt(access_create_account, Opts); +access_create_account(Host) -> + gen_mod:get_module_opt(Host, mod_invites, access_create_account). + +-spec db_type(gen_mod:opts() | global | binary()) -> atom(). +db_type(Opts) when is_map(Opts) -> + gen_mod:get_opt(db_type, Opts); +db_type(Host) -> + gen_mod:get_module_opt(Host, mod_invites, db_type). + +-spec max_invites(gen_mod:opts() | global | binary()) -> 'infinity' | pos_integer(). +max_invites(Opts) when is_map(Opts) -> + gen_mod:get_opt(max_invites, Opts); +max_invites(Host) -> + gen_mod:get_module_opt(Host, mod_invites, max_invites). + +-spec token_expire_seconds(gen_mod:opts() | global | binary()) -> pos_integer(). +token_expire_seconds(Opts) when is_map(Opts) -> + gen_mod:get_opt(token_expire_seconds, Opts); +token_expire_seconds(Host) -> + gen_mod:get_module_opt(Host, mod_invites, token_expire_seconds). + diff --git a/src/mod_invites_sql.erl b/src/mod_invites_sql.erl new file mode 100644 index 00000000000..14b1c48a8ed --- /dev/null +++ b/src/mod_invites_sql.erl @@ -0,0 +1,163 @@ +%%%---------------------------------------------------------------------- +%%% File : mod_invites_sql.erl +%%% Author : Stefan Strigler +%%% Created : Mon Sep 15 2025 by Stefan Strigler +%%% +%%% +%%% ejabberd, Copyright (C) 2025 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_sql). + +-author('stefan@strigler.de'). + +-behaviour(mod_invites). + +-export([cleanup_expired/1, create_invite/1, expire_tokens/2, get_invite/2, init/2, + is_token_valid/3, num_account_invites/2, remove_user/2, set_invitee/3]). + +-include("mod_invites.hrl"). +-include("ejabberd_sql_pt.hrl"). + +%% @format-begin + +%%-------------------------------------------------------------------- +%%| mod_invite callbacks +init(Host, _Opts) -> + ejabberd_sql_schema:update_schema(Host, ?MODULE, sql_schemas()). + +sql_schemas() -> + [#sql_schema{version = 1, + tables = + [#sql_table{name = <<"invite_token">>, + columns = + [#sql_column{name = <<"token">>, type = text}, + #sql_column{name = <<"username">>, type = text}, + #sql_column{name = <<"server_host">>, type = text}, + #sql_column{name = <<"invitee">>, + type = text, + default = true}, + #sql_column{name = <<"created_at">>, + type = timestamp, + default = true}, + #sql_column{name = <<"expires">>, type = timestamp}, + #sql_column{name = <<"type">>, type = text}, + #sql_column{name = <<"account_name">>, type = text}], + indices = + [#sql_index{columns = [<<"token">>], unique = true}, + #sql_index{columns = + [<<"username">>, <<"server_host">>]}]}]}]. + +cleanup_expired(Host) -> + {updated, Count} = + ejabberd_sql:sql_query(Host, "delete from invite_token where expires < now()"), + Count. + +create_invite(Invite) -> + #invite_token{inviter = {User, Host}, + token = Token, + account_name = AccountName0, + created_at = CreatedAt0, + expires = Expires0, + type = Type} = + Invite, + TypeBin = atom_to_binary(Type), + AccountName = + case AccountName0 of + undefined -> + <<>>; + _ -> + AccountName0 + end, + CreatedAt = datetime_to_sql_timestamp(CreatedAt0), + Expires = datetime_to_sql_timestamp(Expires0), + + Query = + ?SQL_INSERT("invite_token", + ["token=%(Token)s", + "username=%(User)s", + "server_host=%(Host)s", + "type=%(TypeBin)s", + "created_at=%(CreatedAt)s", + "expires=%(Expires)s", + "account_name=%(AccountName)s"]), + % ejabberd_sql:sql_transaction(Host, Queries) + {updated, 1} = ejabberd_sql:sql_query(Host, Query), + Invite. + +expire_tokens(User, Server) -> + {updated, Count} = + ejabberd_sql:sql_query(Server, + ?SQL("update invite_token set expires = '1970-01-01 00:00:01' where " + "username = %(User)s and %(Server)H and expires > now() and " + "type != 'roster_only'")), + Count. + +get_invite(Host, Token) -> + case ejabberd_sql:sql_query(Host, + ?SQL("select @(token)s, @(username)s, @(type)s, @(account_name)s, " + "@(expires)s, @(created_at)s from invite_token where token = " + "%(Token)s and %(Host)H")) + of + {selected, [{Token, User, Type, AccountName0, Expires, CreatedAt}]} -> + AccountName = + case AccountName0 of + <<>> -> + undefined; + _ -> + AccountName0 + end, + #invite_token{token = Token, + inviter = {User, Host}, + type = binary_to_existing_atom(Type), + account_name = AccountName, + expires = Expires, + created_at = CreatedAt}; + {selected, []} -> + {error, not_found} + end. + +is_token_valid(Host, Token, {User, Host}) -> + {selected, Rows} = + ejabberd_sql:sql_query(Host, + ?SQL("select @(token)s from invite_token where token = %(Token)s and username = %(User)s " + "and %(Host)H and invitee = '' and expires > now()")), + length(Rows) /= 0. + +num_account_invites(User, Server) -> + {selected, [{Count}]} = + ejabberd_sql:sql_query(Server, + ?SQL("select @(count(*))d from invite_token where username=%(User)s " + "and %(Server)H and type != 'roster_only'")), + Count. + +remove_user(User, Server) -> + ejabberd_sql:sql_query(Server, + ?SQL("delete from invite_token where username=%(User)s and %(Server)H")). + +set_invitee(Host, Token, Invitee) -> + {updated, 1} = + ejabberd_sql:sql_query(Host, + ?SQL("UPDATE invite_token SET invitee=%(Invitee)s WHERE token=%(Token)s")), + ok. + +%%-------------------------------------------------------------------- +%%| helpers + +datetime_to_sql_timestamp({{Year, Month, Day}, {Hour, Minute, Second}}) -> + list_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B ~2..0B:~2..0B:~2..0B", + [Year, Month, Day, Hour, Minute, Second])). diff --git a/test/ejabberd_SUITE.erl b/test/ejabberd_SUITE.erl index f40c08f666c..5f9aff18f03 100644 --- a/test/ejabberd_SUITE.erl +++ b/test/ejabberd_SUITE.erl @@ -349,6 +349,10 @@ init_per_testcase(TestCase, OrigConfig) -> Password = ?config(password, Config), ejabberd_auth:try_register(User, Server, Password), open_session(bind(auth(connect(Config)))); + "invites_" ++ _ -> + Password = ?config(password, Config), + ejabberd_auth:try_register(User, Server, Password), + open_session(bind(auth(connect(Config)))); _ when IsMaster or IsSlave -> Password = ?config(password, Config), ejabberd_auth:try_register(User, Server, Password), @@ -436,6 +440,7 @@ db_tests(DB) when DB == mnesia; DB == redis -> presence_broadcast, last, antispam_tests:single_cases(), + invites_tests:single_cases(), webadmin_tests:single_cases(), roster_tests:single_cases(), private_tests:single_cases(), @@ -477,6 +482,7 @@ db_tests(DB) -> offline_tests:single_cases(), mam_tests:single_cases(), push_tests:single_cases(), + invites_tests:single_cases(), test_pass_change, test_unregister]}, muc_tests:master_slave_cases(), diff --git a/test/ejabberd_SUITE_data/ejabberd.mnesia.yml b/test/ejabberd_SUITE_data/ejabberd.mnesia.yml index 56fdf5e6e4d..c2b9e9ec471 100644 --- a/test/ejabberd_SUITE_data/ejabberd.mnesia.yml +++ b/test/ejabberd_SUITE_data/ejabberd.mnesia.yml @@ -17,6 +17,8 @@ define_macro: mod_blocking: [] mod_caps: db_type: internal + mod_invites: + db_type: internal mod_last: db_type: internal mod_muc: diff --git a/test/ejabberd_SUITE_data/ejabberd.mysql.yml b/test/ejabberd_SUITE_data/ejabberd.mysql.yml index 91705ee681d..1cb1de6ef5a 100644 --- a/test/ejabberd_SUITE_data/ejabberd.mysql.yml +++ b/test/ejabberd_SUITE_data/ejabberd.mysql.yml @@ -16,6 +16,8 @@ define_macro: mod_blocking: [] mod_caps: db_type: sql + mod_invites: + db_type: sql mod_last: db_type: sql mod_muc: diff --git a/test/ejabberd_SUITE_data/ejabberd.yml b/test/ejabberd_SUITE_data/ejabberd.yml index b323ecfecc2..b560df2c94a 100644 --- a/test/ejabberd_SUITE_data/ejabberd.yml +++ b/test/ejabberd_SUITE_data/ejabberd.yml @@ -68,6 +68,8 @@ access_rules: allow: local register: allow: all + account_invite: + allow: all acl: local: diff --git a/test/invites_tests.erl b/test/invites_tests.erl new file mode 100644 index 00000000000..d29c3b4a235 --- /dev/null +++ b/test/invites_tests.erl @@ -0,0 +1,352 @@ +%%%------------------------------------------------------------------- +%%% Author : Stefan Strigler +%%% Created : 16 September 2025 by Stefan Strigler +%%% +%%% +%%% ejabberd, Copyright (C) 2025 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(invites_tests). + +-compile(export_all). + +-import(suite, [recv_presence/1, send_recv/2, my_jid/1, muc_room_jid/1, + send/2, recv_message/1, recv_iq/1, muc_jid/1, + alt_room_jid/1, wait_for_slave/1, wait_for_master/1, + disconnect/1, put_event/2, get_event/1, peer_muc_jid/1, + my_muc_jid/1, get_features/2, set_opt/3]). + +-include("suite.hrl"). +-include("mod_invites.hrl"). + +%% killme +-record(ejabberd_module, + {module_host = {undefined, <<"">>} :: {atom(), binary()}, + opts = [] :: any(), + registrations = [] :: [any()], + order = 0 :: integer()}). + +%% @format-begin + +%%%=================================================================== +%%% API +%%%=================================================================== +%%%=================================================================== +%%% Single tests +%%%=================================================================== + +single_cases() -> + {invites_single, + [sequence], + [single_test(gen_invite), + single_test(cleanup_expired), + single_test(adhoc_items), + single_test(adhoc_command_invite), + single_test(adhoc_command_create_account), + single_test(token_valid), + single_test(remove_user), + single_test(expire_tokens), + single_test(max_invites), + single_test(presence_with_preauth_token)]}. + +%%%=================================================================== + +gen_invite(Config) -> + Server = ?config(server, Config), + User = ?config(user, Config), + Res = mod_invites:gen_invite(<<"foo">>, Server), + ?match(<<"xmpp:", _/binary>>, Res), + Token = token_from_uri(Res), + #invite_token{inviter = {<<>>, Server}, + type = account_only, + account_name = <<"foo">>} = + mod_invites:get_invite(Server, Token), + Res2 = mod_invites:gen_invite(undefined, Server), + ?match(<<"xmpp:", _/binary>>, Res), + Token2 = token_from_uri(Res2), + #invite_token{inviter = {<<>>, Server}, + type = account_only, + account_name = undefined} = + mod_invites:get_invite(Server, Token2), + ?match({error, user_exists}, mod_invites:gen_invite(User, Server)), + ?match({error, account_name_invalid}, + mod_invites:gen_invite(<<"@bad_acccount_name">>, Server)), + ?match({error, host_unknown}, mod_invites:gen_invite(<<"foo">>, <<"non.existant.host">>)), + %% TooLongHostname = list_to_binary([$a || _ <- lists:seq(1, 1024)]), + %% ?match({error, hostname_invalid}, mod_invites:gen_invite(<<"foo">>, TooLongHostname)), + ok. + +cleanup_expired(Config) -> + Server = ?config(server, Config), + mod_invites:create_account_invite(Server, {<<"foo">>, Server}, undefined, false), + mod_invites:expire_tokens(<<"foo">>, Server), + Token = token_from_uri(mod_invites:gen_invite(<<"foobar">>, Server)), + ?match(1, mod_invites:cleanup_expired()), + ?match(#invite_token{}, mod_invites:get_invite(Server, Token)), + ?match(0, mod_invites:cleanup_expired()), + ok. + +adhoc_items(Config) -> + Server = ?config(server, Config), + ServerJID = jid:from_string(Server), + User = ?config(user, Config), + UserJID = jid:from_string(User), + Disco = #disco_items{node = ?NS_COMMANDS}, + #iq{type = result, sub_els = [#disco_items{node = ?NS_COMMANDS, items = Items}]} = + send_recv(Config, + #iq{type = get, + to = ServerJID, + sub_els = [Disco]}), + ?match(true, [I || I = #disco_item{node = ?NS_INVITE_INVITE} <- Items] /= []), + ?match(deny, + acl:match_rule(Server, + gen_mod:get_module_opt(Server, mod_invites, access_create_account), + UserJID)), + ?match(false, [I || I = #disco_item{node = ?NS_INVITE_CREATE_ACCOUNT} <- Items] /= []), + OldOpts = gen_mod:get_module_opts(Server, mod_invites), + NewOpts = gen_mod:set_opt(access_create_account, account_invite, OldOpts), + update_module_opts(Server, mod_invites, NewOpts), + ?match(allow, + acl:match_rule(Server, + gen_mod:get_module_opt(Server, mod_invites, access_create_account), + UserJID)), + #iq{type = result, sub_els = [#disco_items{node = ?NS_COMMANDS, items = NewItems}]} = + send_recv(Config, + #iq{type = get, + to = ServerJID, + sub_els = [Disco]}), + ?match(true, [I || I = #disco_item{node = ?NS_INVITE_INVITE} <- NewItems] /= []), + ?match(true, [I || I = #disco_item{node = ?NS_INVITE_CREATE_ACCOUNT} <- NewItems] /= []), + update_module_opts(Server, mod_invites, OldOpts), + ?match(deny, + acl:match_rule(Server, + gen_mod:get_module_opt(Server, mod_invites, access_create_account), + UserJID)), + ok. + +adhoc_command_invite(Config) -> + Server = ?config(server, Config), + ServerJID = jid:from_string(Server), + Command = #adhoc_command{node = ?NS_INVITE_INVITE}, + #iq{type = result, + sub_els = + [#adhoc_command{status = completed, + xdata = #xdata{type = result, fields = XdataFields}}]} = + send_recv(Config, + #iq{type = set, + to = ServerJID, + sub_els = [Command]}), + [Uri] = [V || #xdata_field{var = <<"uri">>, values = [V]} <- XdataFields], + ?match(<<"xmpp:", _/binary>>, Uri), + ?match(true, [V || #xdata_field{var = <<"expire">>, values = [V]} <- XdataFields] /= []), + Token = token_from_uri(Uri), + User = jid:nodeprep(?config(user, Config)), + ?match(true, mod_invites:is_token_valid(Server, Token, {User, Server})), + mod_invites:remove_user(User, Server), + ok. + +adhoc_command_create_account(Config) -> + Server = ?config(server, Config), + ServerJID = jid:from_string(Server), + Command = #adhoc_command{node = ?NS_INVITE_CREATE_ACCOUNT}, + ResForbidden = + send_recv(Config, + #iq{type = set, + to = ServerJID, + sub_els = [Command]}), + ?match(#iq{type = error}, ResForbidden), + #iq{sub_els = ForbiddenSubEls} = ResForbidden, + ?match(true, + [ok || #stanza_error{type = auth, reason = forbidden} <- ForbiddenSubEls] /= []), + OldOpts = gen_mod:get_module_opts(Server, mod_invites), + NewOpts = gen_mod:set_opt(access_create_account, account_invite, OldOpts), + update_module_opts(Server, mod_invites, NewOpts), + ResultXDataFields1 = test_create_account(Config, <<>>, <<"0">>), + ?match({match, [_, _]}, + re:run(xdata_field(<<"uri">>, ResultXDataFields1), + <<"xmpp:", Server/binary, "\\?register;preauth=(.+)">>)), + ResultXDataFields2 = test_create_account(Config, <<"foobar">>, <<"0">>), + ?match({match, [_, _]}, + re:run(xdata_field(<<"uri">>, ResultXDataFields2), + <<"xmpp:foobar@", Server/binary, "\\?register;preauth=(.+)">>)), + ResultXDataFields3 = test_create_account(Config, <<>>, <<"1">>), + Inviter = jid:nodeprep(?config(user, Config)), + ?match({match, [Inviter, _]}, + re:run(xdata_field(<<"uri">>, ResultXDataFields3), + <<"xmpp:(.+)", "@", Server/binary, "\\?roster;preauth=([a-zA-Z0-9]+);ibr=y">>, + [{capture, all_but_first, binary}])), + Token = token_from_uri(xdata_field(<<"uri">>, ResultXDataFields3, <<>>)), + #invite_token{account_name = undefined, type = account_subscription} = + mod_invites:get_invite(Server, Token), + ResultXDataFields4 = test_create_account(Config, <<"foobar">>, <<"1">>), + ?match({match, [Inviter, _]}, + re:run(xdata_field(<<"uri">>, ResultXDataFields4), + <<"xmpp:(.+)", "@", Server/binary, "\\?roster;preauth=([a-zA-Z0-9]+);ibr=y">>, + [{capture, all_but_first, binary}])), + update_module_opts(Server, mod_invites, OldOpts), + User = jid:nodeprep(?config(user, Config)), + mod_invites:remove_user(User, Server), + ok. + +token_valid(Config) -> + Server = ?config(server, Config), + User = jid:nodeprep(?config(user, Config)), + Res = mod_invites:gen_invite(<<"foobar">>, Server), + Token = token_from_uri(Res), + ?match(true, mod_invites:is_token_valid(Server, Token)), + Inviter = {<<"foo">>, Server}, + #invite_token{token = AccountToken} = + mod_invites:create_account_invite(Server, Inviter, undefined, false), + ?match(true, mod_invites:is_token_valid(Server, AccountToken, Inviter)), + ?match(false, mod_invites:is_token_valid(Server, <<"madeUptoken">>)), + ?match(false, + mod_invites:is_token_valid(Server, AccountToken, {<<"someoneElse">>, Server})), + mod_invites:expire_tokens(<<"foo">>, Server), + ?match(false, mod_invites:is_token_valid(Server, AccountToken, Inviter)), + mod_invites:cleanup_expired(), + mod_invites:remove_user(User, Server), + ok. + +remove_user(Config) -> + Server = ?config(server, Config), + User = jid:nodeprep(?config(user, Config)), + Inviter = {User, Server}, + #invite_token{} = mod_invites:create_account_invite(Server, Inviter, undefined, false), + ?match(1, mod_invites:num_account_invites(User, Server)), + mod_invites:remove_user(User, Server), + ?match(0, mod_invites:num_account_invites(User, Server)), + ok. + +expire_tokens(Config) -> + Server = ?config(server, Config), + User = jid:nodeprep(?config(user, Config)), + Inviter = {User, Server}, + #invite_token{token = RosterToken} = mod_invites:create_roster_invite(Server, Inviter), + #invite_token{token = AccountToken} = + mod_invites:create_account_invite(Server, Inviter, undefined, false), + ?match(true, mod_invites:is_token_valid(Server, RosterToken, Inviter)), + ?match(1, mod_invites:expire_tokens(User, Server)), + ?match(true, mod_invites:is_token_valid(Server, RosterToken, Inviter)), + ?match(false, mod_invites:is_token_valid(Server, AccountToken, Inviter)), + ?match(0, mod_invites:expire_tokens(User, Server)), + mod_invites:cleanup_expired(). + +max_invites(Config) -> + Server = ?config(server, Config), + User = jid:nodeprep(?config(user, Config)), + Inviter = {User, Server}, + OldOpts = gen_mod:get_module_opts(Server, mod_invites), + NewOpts = gen_mod:set_opt(max_invites, 3, OldOpts), + update_module_opts(Server, mod_invites, NewOpts), + #invite_token{} = mod_invites:create_account_invite(Server, Inviter, undefined, false), + #invite_token{} = mod_invites:create_account_invite(Server, Inviter, undefined, false), + #invite_token{} = mod_invites:create_account_invite(Server, Inviter, undefined, false), + ?match({error, num_invites_exceeded}, + mod_invites:create_account_invite(Server, Inviter, undefined, false)), + update_module_opts(Server, mod_invites, OldOpts), + #invite_token{} = mod_invites:create_account_invite(Server, Inviter, undefined, false), + ok. + +presence_with_preauth_token(Config) -> + Server = ?config(server, Config), + Inviter = {<<"inviter">>, Server}, + #invite_token{token = RosterToken} = mod_invites:create_roster_invite(Server, Inviter), + send(Config, + #presence{type = 'subscribe', + to = jid:make(<<"inviter">>, Server), + sub_els = [#preauth{token = RosterToken}]}), + ?recv2( + #iq{type = 'set', + sub_els = [#roster_query{items = [#roster_item{ask = 'subscribe'}]}]}, + #iq{type = 'set', + sub_els = [#roster_query{items = [#roster_item{subscription = 'to'}]}]}), + ?match( + false, + mod_invites:is_token_valid(Server, RosterToken, Inviter) + ). + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== +single_test(T) -> + list_to_atom("invites_" ++ atom_to_list(T)). + +token_from_uri(Uri) -> + {match, [Token]} = + re:run(Uri, ".+preauth=([a-zA-z0-9]+)", [{capture, all_but_first, binary}]), + Token. + +update_module_opts(Host, Module, Opts) -> + [EjabMod] = ets:lookup(ejabberd_modules, {Module, Host}), + ets:insert(ejabberd_modules, EjabMod#ejabberd_module{opts = Opts}). + +xdata_field(Var, Fields) -> + xdata_field(Var, Fields, undefined). + +xdata_field(_Var, [], Default) -> + Default; +xdata_field(Var, [#xdata_field{var = Var, values = [<<>> | _]} | _], Default) -> + Default; +xdata_field(Var, [#xdata_field{var = Var, values = [Result | _]} | _], _Default) -> + Result; +xdata_field(Var, [_NoMatch | Fields], Default) -> + xdata_field(Var, Fields, Default). + +xdata_field_set(Var, Val, Fields) -> + xdata_field_set(Var, Val, Fields, []). + +xdata_field_set(Var, _Val, [], _Result) -> + throw({error, {not_found, Var}}); +xdata_field_set(Var, Val, [#xdata_field{var = Var} = Field | Fields], Result) -> + Result ++ [Field#xdata_field{values = [Val]} | Fields]; +xdata_field_set(Var, Val, [Field | Tail], Result) -> + xdata_field_set(Var, Val, Tail, Result ++ [Field]). + +test_create_account(Config, Username, Subscription) -> + Server = ?config(server, Config), + ServerJID = jid:from_string(Server), + Command = #adhoc_command{node = ?NS_INVITE_CREATE_ACCOUNT}, + #iq{type = result, + sub_els = + [#adhoc_command{status = executing, + sid = SID, + node = ?NS_INVITE_CREATE_ACCOUNT, + actions = #adhoc_actions{execute = complete, complete = true}, + xdata = #xdata{type = form, fields = XdataFields0}}]} = + send_recv(Config, + #iq{type = set, + to = ServerJID, + sub_els = [Command]}), + XdataFields = + xdata_field_set(<<"username">>, + Username, + xdata_field_set(<<"roster-subscription">>, Subscription, XdataFields0)), + #iq{type = result, + sub_els = + [#adhoc_command{status = completed, + sid = SID, + node = ?NS_INVITE_CREATE_ACCOUNT, + xdata = #xdata{type = result, fields = ResultXDataFields}}]} = + send_recv(Config, + #iq{type = set, + to = ServerJID, + sub_els = + [Command#adhoc_command{sid = SID, + xdata = + #xdata{type = submit, + fields = XdataFields}}]}), + ResultXDataFields. From e08ef0f447d7d4c4541ef2c479f0d152c7b187ff Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Wed, 8 Oct 2025 15:59:43 +0200 Subject: [PATCH 02/27] ibr using token --- include/mod_invites.hrl | 2 +- src/mod_invites.erl | 322 +++++++++++++++++++++++++++++++------ src/mod_invites_mnesia.erl | 24 ++- src/mod_invites_sql.erl | 39 ++++- src/mod_register.erl | 17 +- test/invites_tests.erl | 295 ++++++++++++++++++++++++++++----- test/suite.erl | 4 + 7 files changed, 604 insertions(+), 99 deletions(-) diff --git a/include/mod_invites.hrl b/include/mod_invites.hrl index 7f7c25786ea..ab7f2f7687b 100644 --- a/include/mod_invites.hrl +++ b/include/mod_invites.hrl @@ -4,7 +4,7 @@ created_at = calendar:now_to_datetime(erlang:timestamp()) :: calendar:datetime(), expires :: calendar:datetime(), type = roster_only :: roster_only | account_only | account_subscription, - account_name = undefined :: binary() | undefined + account_name = <<>> :: binary() }). -define(INVITE_TOKEN_EXPIRE_SECONDS_DEFAULT, 5*86400). diff --git a/src/mod_invites.erl b/src/mod_invites.erl index a5845e44f54..bcdaf5120da 100644 --- a/src/mod_invites.erl +++ b/src/mod_invites.erl @@ -26,17 +26,20 @@ -author('stefan@strigler.de'). +-xep({xep, 379, ''}). -xep({xep, 401, '0.5.0'}). % [TODO] +-xep({xep, 445, ''}). -behaviour(gen_mod). -export([depends/2, mod_doc/0, mod_options/1, mod_opt_type/1, reload/3, start/2, stop/1]). --export([adhoc_commands/4, adhoc_items/4, cleanup_expired/0, expire_tokens/2, - gen_invite/1, gen_invite/2, remove_user/2, s2s_receive_packet/1, sm_receive_packet/1]). +-export([adhoc_commands/4, adhoc_items/4, c2s_unauthenticated_packet/2, cleanup_expired/0, + expire_tokens/2, gen_invite/1, gen_invite/2, list_invites/1, remove_user/2, + s2s_receive_packet/1, sm_receive_packet/1, stream_feature_register/2]). -ifdef(TEST). --export([create_roster_invite/2, create_account_invite/4, get_invite/2, is_token_valid/3, is_token_valid/2, - num_account_invites/2]). +-export([create_roster_invite/2, create_account_invite/4, get_invite/2, is_reserved/3, + is_token_valid/3, is_token_valid/2, num_account_invites/2, set_invitee/3]). -endif. -include("logger.hrl"). @@ -55,18 +58,34 @@ -callback get_invite(Host :: binary(), Token :: binary()) -> invite_token() | {error, not_found}. -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 num_account_invites(User :: binary(), Server :: binary()) -> non_neg_integer(). -callback remove_user(User :: binary(), Server :: binary()) -> any(). -callback set_invitee(Host :: binary(), Token :: binary(), Invitee :: binary()) -> ok. +-define(try_subtag(IQ, SUBTAG, F, Else), + try xmpp:try_subtag(IQ, SUBTAG) of + false -> + Else(); + SubTag -> + F(SubTag) + catch _:{xmpp_codec, Why} -> + Txt = xmpp:io_format_error(Why), + Lang = maps:get(lang, State), + Err = make_stripped_error(IQ, SUBTAG, xmpp:err_bad_request(Txt, Lang)), + {stop, ejabberd_c2s:send(State, Err)} + end). +-define(try_subtag(IQ, SUBTAG, F), ?try_subtag(IQ, SUBTAG, F, fun() -> State end)). + %% @format-begin %%-------------------------------------------------------------------- %%| gen_mod callbacks depends(_Host, _Opts) -> - [{mod_adhoc, hard}]. + [{mod_adhoc, soft}, {mod_register, soft}, {mod_roster, soft}]. mod_doc() -> #{desc => @@ -147,6 +166,11 @@ start(Host, Opts) -> {hook, adhoc_local_commands, adhoc_commands, 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}, + {hook, + c2s_unauthenticated_packet, + c2s_unauthenticated_packet, + 10}, % note that the sequence is important here {commands, get_commands_spec()}]}. stop(_Host) -> @@ -208,7 +232,20 @@ get_commands_spec() -> args_example = [<<"juliet">>, <<"example.com">>], result_example = <<"xmpp:juliet@example.com?register;preauth=CJAi3TvpzuBJpmuf">>, - result = {invite_uri, string}}]. + result = {invite_uri, string}}, + #ejabberd_commands{name = list_invites, + tags = [accounts], + desc = "List invite tokens", + module = ?MODULE, + function = list_invites, + 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}, {token_uri, string}, {inviter, string}, {invitee, string}, {account_name, string}, {created_at, string}, {expires, string}, {type, atom}, {valid, atom}]}}}} + }]. cleanup_expired() -> lists:foldl(fun(Host, Count) -> @@ -230,7 +267,7 @@ expire_tokens(User0, Server0) -> -spec gen_invite(binary()) -> binary() | {error, any()}. gen_invite(Host) -> - gen_invite(undefined, Host). + gen_invite(<<>>, Host). -spec gen_invite(binary(), binary()) -> binary() | {error, any()}. gen_invite(Username, Host0) -> @@ -244,6 +281,21 @@ gen_invite(Username, Host0) -> token_uri(Invite) end. +list_invites(Host) -> + Invites = db_call(Host, list_invites, [Host]), + Format = fun(#invite_token{token = TO, inviter = {IU, IS}, invitee = IE, created_at = CA, expires = Exp, type = TY, account_name = AN} = Invite) -> + {TO, + token_uri(Invite), + jid:encode(jid:make(IU, IS)), + IE, + AN, + encode_datetime(CA), + encode_datetime(Exp), + TY, + is_token_valid(Host, TO)} + end, + [Format(Invite) || Invite <- Invites]. + %%-------------------------------------------------------------------- %%| hooks and callbacks @@ -327,7 +379,7 @@ adhoc_commands(_Acc, check(fun create_account_allowed/2, [LServer, From], fun() -> - AccountName = xdata_field(<<"username">>, Fields, undefined), + AccountName = xdata_field(<<"username">>, Fields, <<>>), Invite = create_account_invite(LServer, {LUser, LServer}, @@ -382,7 +434,7 @@ adhoc_commands(_Acc, Result = #adhoc_command{status = executing, node = Node, - sid = SID, + sid = maybe_gen_sid(SID), actions = Actions, xdata = XData}, {stop, Result} @@ -419,28 +471,18 @@ sm_receive_packet(Other) -> handle_pre_auth_token([], _To, _From) -> false; -handle_pre_auth_token([El | Els], #jid{luser = LUser, lserver = LServer} = To, FromFullJid) -> +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 -> - RosterItem = - #roster_item{jid = From, - subscription = from, - ask = subscribe}, - mod_roster:set_item_and_notify_clients(To, RosterItem, true), - Subscribed = - #presence{from = To, - to = From, - type = subscribed}, - ejabberd_router:route(To, From, Subscribed), - Subscribe = - #presence{from = To, - to = From, - type = subscribe}, - ejabberd_router:route(To, From, Subscribe), + roster_add(To, From), + send_presence(To, From, subscribed), + send_presence(To, From, subscribe), set_invitee(LServer, Token, From), true; false -> @@ -454,18 +496,108 @@ handle_pre_auth_token([El | Els], #jid{luser = LUser, lserver = LServer} = To, F handle_pre_auth_token(Els, To, From) end. +-spec stream_feature_register([xmpp_element()], binary()) -> [xmpp_element()]. +stream_feature_register(Acc, Host) -> + case mod_invites_opt:access_create_account(Host) of + none -> + Acc; + _ -> + [#feature_register_ibr_token{} | Acc] + end. + +c2s_unauthenticated_packet(#{invite := Invite} = State, + #iq{type = get, sub_els = [_]} = IQ) -> + %% User requests registration form after processing token + ?try_subtag(IQ, + #register{}, + fun(Register) -> + #{server := Server} = State, + IQ1 = xmpp:set_els(IQ, [Register]), + User = Invite#invite_token.account_name, + IQ2 = xmpp:set_from_to(IQ1, jid:make(User, Server), jid:make(Server)), + ResIQ = mod_register:process_iq(IQ2), + ResIQ1 = xmpp:set_from_to(ResIQ, jid:make(Server), undefined), + {stop, ejabberd_c2s:send(State, ResIQ1)} + end); +c2s_unauthenticated_packet(#{invite := Invite, server := Server} = State, + #iq{type = set, sub_els = [_]} = IQ) -> + %% Process registration request after processing token + ?try_subtag(IQ, + #register{}, + fun (Register) -> + case check_captcha(mod_register_opt:captcha_protected(Server), Register, IQ) of + {ok, {Username, Password}} -> + Token = Invite#invite_token.token, + #{ip := IP} = State, + {Address, _} = IP, + case try_register(Token, + Username, + Server, + Password, + IQ, + Address) + of + #iq{type = result} = ResIQ -> + set_invitee(Server, + Invite#invite_token.token, + jid:make(Username, Server)), + NewInvite = get_invite(Server, Invite#invite_token.token), + ResState = State#{invite => NewInvite}, + maybe_create_mutual_subscription(NewInvite), + {stop, ejabberd_c2s:send(ResState, ResIQ)}; + #iq{type = error} = ResIQ -> + {stop, ejabberd_c2s:send(State, ResIQ)} + end; + {error, ResIQ} -> + {stop, ejabberd_c2s:send(State, ResIQ)} +end + end); +c2s_unauthenticated_packet(State, #iq{type = set, sub_els = [_]} = IQ) -> + %% Check for preauth token and process it + ?try_subtag(IQ, + #preauth{}, + fun(#preauth{token = Token}) -> + #{server := Server} = State, + IQ1 = xmpp:set_from_to(IQ, jid:make(<<>>), jid:make(Server)), + {ResState, ResIQ} = process_token(State, Token, IQ1), + ResIQ1 = xmpp:set_from_to(ResIQ, jid:make(Server), undefined), + {stop, ejabberd_c2s:send(ResState, ResIQ1)} + end, + fun() -> + ?try_subtag(IQ, + #register{}, + fun (#register{username = User, password = Password}) + when is_binary(User), is_binary(Password) -> + #{server := Server} = State, + case is_reserved(Server, <<>>, User) of + true -> + ResIQ = + make_stripped_error(IQ, + #register{}, + xmpp:err_not_allowed()), + {stop, ejabberd_c2s:send(State, ResIQ)}; + false -> + State + end; + (_) -> + State + end) + end); +c2s_unauthenticated_packet(State, _) -> + State. + %%-------------------------------------------------------------------- -%%| test API --ifdef(TEST). +%%| helpers get_invite(Host, Token) -> db_call(Host, get_invite, [Host, Token]). +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}). --endif. -%%-------------------------------------------------------------------- -%%| helpers -spec is_token_valid(binary(), binary(), {binary(), binary()}) -> boolean(). is_token_valid(Host, Token, Inviter) -> db_call(Host, is_token_valid, [Host, Token, Inviter]). @@ -476,11 +608,11 @@ set_invitee(Host, Token, InviteeJid) -> set_invitee, [Host, Token, - jid:to_string( + jid:encode( jid:remove_resource(InviteeJid))]). create_roster_invite(Host, Inviter) -> - create_roster_invite(Host, Inviter, undefined). + create_roster_invite(Host, Inviter, <<>>). create_roster_invite(Host, Inviter, AccountName) -> create_invite(roster_only, Host, Inviter, AccountName). @@ -502,6 +634,8 @@ create_invite(Type, Host, Inviter, AccountName) -> {error, Error} end. +check_account_name(<<>>, _) -> + <<>>; check_account_name(error, _) -> {error, account_name_invalid}; check_account_name(_, error) -> @@ -551,13 +685,7 @@ invite_token(Type, Host, Inviter, AccountName0) -> maybe_throw(check_max_invites(Type, Host, Inviter)), Token = p1_rand:get_alphanum_string(?INVITE_TOKEN_LENGTH_DEFAULT), AccountName = - case AccountName0 of - undefined -> - undefined; - _ -> - AccountName1 = jid:nodeprep(AccountName0), - maybe_throw(check_account_name(AccountName1, Host)) - end, + maybe_throw(check_account_name(jid:nodeprep(AccountName0), Host)), set_token_expires(#invite_token{token = Token, inviter = Inviter, type = Type, @@ -570,7 +698,7 @@ token_uri(#invite_token{type = account_only, inviter = {_User, Host}}) -> Invitee = case AccountName of - undefined -> + <<>> -> Host; _ -> <> @@ -579,16 +707,12 @@ token_uri(#invite_token{type = account_only, token_uri(#invite_token{type = account_subscription, token = Token, inviter = {User, Host}}) -> - Inviter = - jid:to_string( - jid:make(User, Host)), + Inviter = jid:to_string(jid:make(User, Host)), <<"xmpp:", Inviter/binary, "?roster;preauth=", Token/binary, ";ibr=y">>; token_uri(#invite_token{type = roster_only, token = Token, inviter = {User, Host}}) -> - Inviter = - jid:to_string( - jid:make(User, Host)), + Inviter = jid:to_string(jid:make(User, Host)), <<"xmpp:", Inviter/binary, "?roster;preauth=", Token/binary>>. db_call(Host, Fun, Args) -> @@ -662,3 +786,109 @@ reason_to_text(user_exists) -> "User already exists"; reason_to_text(num_invites_exceeded) -> "Maximum number of invites reached". + +make_stripped_error(IQ, SubTag, Err) -> + xmpp:make_error( + xmpp:remove_subtag(IQ, SubTag), Err). + +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(From, To, Presence). + +maybe_create_mutual_subscription(#invite_token{inviter = {User, _Server}, type = Type}) + when User == <<>>; % server token + Type /= account_subscription -> + noop; +maybe_create_mutual_subscription(#invite_token{inviter = {User, Server}, invitee = Invitee}) -> + InviterJID = jid:make(User, Server), + InviteeJID = jid:decode(Invitee), + roster_add(InviterJID, InviteeJID), + roster_add(InviteeJID, InviterJID), + send_presence(InviteeJID, InviterJID, subscribe), + send_presence(InviterJID, InviteeJID, subscribed), + send_presence(InviterJID, InviteeJID, subscribe), + send_presence(InviteeJID, InviterJID, subscribed), + ok. + +process_token(#{server := Host} = State, Token, #iq{lang = Lang} = IQ) -> + ?DEBUG("checking token (~s): ~s", [Host, Token]), + case is_token_valid(Host, Token) of + true -> + Invite = get_invite(Host, Token), + NewState = State#{invite => Invite}, + {NewState, xmpp:make_iq_result(IQ)}; + false -> + Text = ?T("The token provided is either invalid or expired."), + {State, make_stripped_error(IQ, #preauth{}, xmpp:err_item_not_found(Text, Lang))} + end. + +try_register(Token, + User, + Server, + Password, + #iq{lang = Lang} = IQ, + Source) -> + case {jid:nodeprep(User), not is_reserved(Server, Token, User)} of + {error, _} -> + Err = xmpp:err_jid_malformed( + mod_register:format_error(invalid_jid), Lang), + make_stripped_error(IQ, #register{}, Err); + {_, true} -> + case mod_register:try_register(User, Server, Password, Source, ?MODULE, Lang) of + ok -> + xmpp:make_iq_result(IQ); + {error, Error} -> + make_stripped_error(IQ, #register{}, Error) + end + end. + +check_captcha(true, #register{xdata = X}, #iq{lang = Lang} = IQ) -> + XdataC = xmpp_util:set_xdata_field( + #xdata_field{ + var = <<"FORM_TYPE">>, + type = hidden, values = [?NS_CAPTCHA]}, + X), + case ejabberd_captcha:process_reply(XdataC) of + ok -> + case process_xdata_submit(X) of + {ok, _} = Result -> + Result; + _ -> + Txt = ?T("Incorrect data form"), + make_stripped_error(IQ, #register{}, xmpp:err_bad_request(Txt, Lang)) + end; + {error, malformed} -> + Txt = ?T("Incorrect CAPTCHA submit"), + make_stripped_error(IQ, #register{}, xmpp:err_bad_request(Txt, Lang)); + _ -> + ErrText = ?T("The CAPTCHA verification has failed"), + make_stripped_error(IQ, #register{}, xmpp:err_not_allowed(ErrText, Lang)) + end; +check_captcha(false, #register{username = Username, password = Password}, _IQ) + when is_binary(Username), is_binary(Password) -> + {ok, {Username, Password}}; +check_captcha(_IsCaptchaEnabled, _Register, IQ) -> + ResIQ = make_stripped_error(IQ, #register{}, xmpp:err_bad_request()), + {error, ResIQ}. + +process_xdata_submit(X) -> + case {xdata_field(<<"username">>, X, undefined), xdata_field(<<"password">>, X, undefined)} of + {UndefU, UndefP} when UndefU == undefined; UndefP == undefined -> + error; + {Username, Password} -> + {ok, {Username, Password}} + end. diff --git a/src/mod_invites_mnesia.erl b/src/mod_invites_mnesia.erl index 2c27d6ac1f2..fbebfc46c93 100644 --- a/src/mod_invites_mnesia.erl +++ b/src/mod_invites_mnesia.erl @@ -28,7 +28,8 @@ -behaviour(mod_invites). -export([cleanup_expired/1, create_invite/1, expire_tokens/2, get_invite/2, init/2, - is_token_valid/3, num_account_invites/2, remove_user/2, set_invitee/3]). + is_reserved/3, is_token_valid/3, list_invites/1, num_account_invites/2, + remove_user/2, set_invitee/3]). -include("mod_invites.hrl"). @@ -78,16 +79,31 @@ init(_Host, _Opts) -> {attributes, record_info(fields, invite_token)}, {index, [#invite_token.inviter]}]). -is_token_valid(_Host, Token, Inviter) -> +is_reserved(_Host, Token, User) -> + Ts = erlang:timestamp(), + [T + || T <- mnesia:dirty_all_keys(invite_token), + not is_expired(I = hd(mnesia:dirty_read(invite_token, T)), Ts), + I#invite_token.token /= Token, + I#invite_token.invitee == <<>>, + I#invite_token.account_name == User] + =/= []. + +is_token_valid(Host, Token, Scope) -> case mnesia:dirty_read(invite_token, Token) of - [Invite = #invite_token{invitee = <<>>, inviter = Inviter}] -> + [Invite = #invite_token{invitee = <<>>, inviter = {_, Host} = Inviter}] + when Scope == Inviter; Scope == {<<>>, Host} -> not is_expired(Invite, erlang:timestamp()); - [#invite_token{inviter = _WrongOwner}] -> + [#invite_token{}] -> false; [] -> false end. +list_invites(Host) -> + [Invite || Token <- mnesia:dirty_all_keys(invite_token), + element(2,(Invite = hd(mnesia:dirty_read(invite_token, Token)))#invite_token.inviter) == Host]. + num_account_invites(User, Server) -> length([I || I = #invite_token{type = Type} diff --git a/src/mod_invites_sql.erl b/src/mod_invites_sql.erl index 14b1c48a8ed..67e73f3a910 100644 --- a/src/mod_invites_sql.erl +++ b/src/mod_invites_sql.erl @@ -27,8 +27,8 @@ -behaviour(mod_invites). --export([cleanup_expired/1, create_invite/1, expire_tokens/2, get_invite/2, init/2, - is_token_valid/3, num_account_invites/2, remove_user/2, set_invitee/3]). +-export([cleanup_expired/1, create_invite/1, expire_tokens/2, get_invite/2, init/2, is_reserved/3, + is_token_valid/3, list_invites/1, num_account_invites/2, remove_user/2, set_invitee/3]). -include("mod_invites.hrl"). -include("ejabberd_sql_pt.hrl"). @@ -131,12 +131,41 @@ get_invite(Host, Token) -> {error, not_found} end. +is_reserved(Host, Token, User) -> + {selected, [{Count}]} = + ejabberd_sql:sql_query(Host, + ?SQL("SELECT @(COUNT(*))d from invite_token WHERE %(Host)H AND token != %(Token)s AND " + "account_name = %(User)s AND invitee = '' AND expires > NOW()")), + Count > 0. + is_token_valid(Host, Token, {User, Host}) -> {selected, Rows} = ejabberd_sql:sql_query(Host, - ?SQL("select @(token)s from invite_token where token = %(Token)s and username = %(User)s " - "and %(Host)H and invitee = '' and expires > now()")), - length(Rows) /= 0. + ?SQL("SELECT @(token)s FROM invite_token WHERE %(Host)H AND token = %(Token)s AND " + "invitee = '' AND expires > now() AND (%(User)s = '' OR username = %(User)s)")), + Rows /= []. + +list_invites(Host) -> + {selected, Rows} = + ejabberd_sql:sql_query(Host, + ?SQL("SELECT @(token)s, @(username)s, @(type)s, @(account_name)s, " + "@(expires)s, @(created_at)s FROM invite_token WHERE %(Host)H")), + lists:map( + fun({Token, User, Type, AccountName0, Expires, CreatedAt}) -> + AccountName = + case AccountName0 of + <<>> -> + undefined; + _ -> + AccountName0 + end, + #invite_token{token = Token, + inviter = {User, Host}, + type = binary_to_existing_atom(Type), + account_name = AccountName, + expires = Expires, + created_at = CreatedAt} + end, Rows). num_account_invites(User, Server) -> {selected, [{Count}]} = diff --git a/src/mod_register.erl b/src/mod_register.erl index 793c3c54dee..f083dc4fec2 100644 --- a/src/mod_register.erl +++ b/src/mod_register.erl @@ -32,7 +32,7 @@ -behaviour(gen_mod). -export([start/2, stop/1, reload/3, stream_feature_register/2, - c2s_unauthenticated_packet/2, try_register/4, try_register/5, + c2s_unauthenticated_packet/2, try_register/4, try_register/5, try_register/6, process_iq/1, send_registration_notifications/3, mod_opt_type/1, mod_options/1, depends/2, format_error/1, mod_doc/0]). @@ -225,10 +225,12 @@ process_iq(#iq{type = get, from = From, to = To, id = ID, lang = Lang} = IQ, TopInstr = translate:translate( Lang, ?T("You need a client that supports x:data " "and CAPTCHA to register")), - UField = #xdata_field{type = 'text-single', - label = translate:translate(Lang, ?T("User")), - var = <<"username">>, - required = true}, + UField = maybe_add_xdata_value( + Username, + #xdata_field{type = 'text-single', + label = translate:translate(Lang, ?T("User")), + var = <<"username">>, + required = true}), PField = #xdata_field{type = 'text-private', label = translate:translate(Lang, ?T("Password")), var = <<"password">>, @@ -264,6 +266,11 @@ process_iq(#iq{type = get, from = From, to = To, id = ID, lang = Lang} = IQ, registered = IsRegistered}) end. +maybe_add_xdata_value(<<>>, XData) -> + XData; +maybe_add_xdata_value(Value, XData) -> + XData#xdata_field{values = [Value]}. + try_register_or_set_password(User, Server, Password, #iq{from = From, lang = Lang} = IQ, Source, CaptchaSucceed) -> diff --git a/test/invites_tests.erl b/test/invites_tests.erl index d29c3b4a235..4a553b81c73 100644 --- a/test/invites_tests.erl +++ b/test/invites_tests.erl @@ -24,14 +24,13 @@ -compile(export_all). --import(suite, [recv_presence/1, send_recv/2, my_jid/1, muc_room_jid/1, - send/2, recv_message/1, recv_iq/1, muc_jid/1, - alt_room_jid/1, wait_for_slave/1, wait_for_master/1, - disconnect/1, put_event/2, get_event/1, peer_muc_jid/1, - my_muc_jid/1, get_features/2, set_opt/3]). +-import(suite, [auth/1, bind/1, disconnect/1, get_features/2, init_stream/1, open_session/1, + recv_presence/1, recv_message/1, recv_iq/1, self_presence/2, send_recv/2, send/2, + set_opt/3, set_opts/2]). -include("suite.hrl"). -include("mod_invites.hrl"). +-include("mod_roster.hrl"). %% killme -record(ejabberd_module, @@ -61,7 +60,12 @@ single_cases() -> single_test(remove_user), single_test(expire_tokens), single_test(max_invites), - single_test(presence_with_preauth_token)]}. + single_test(presence_with_preauth_token), + single_test(is_reserved), + single_test(stream_feature), + single_test(ibr), + single_test(ibr_reserved), + single_test(ibr_subscription)]}. %%%=================================================================== @@ -75,12 +79,12 @@ gen_invite(Config) -> type = account_only, account_name = <<"foo">>} = mod_invites:get_invite(Server, Token), - Res2 = mod_invites:gen_invite(undefined, Server), + Res2 = mod_invites:gen_invite(Server), ?match(<<"xmpp:", _/binary>>, Res), Token2 = token_from_uri(Res2), #invite_token{inviter = {<<>>, Server}, type = account_only, - account_name = undefined} = + account_name = <<>>} = mod_invites:get_invite(Server, Token2), ?match({error, user_exists}, mod_invites:gen_invite(User, Server)), ?match({error, account_name_invalid}, @@ -92,7 +96,7 @@ gen_invite(Config) -> cleanup_expired(Config) -> Server = ?config(server, Config), - mod_invites:create_account_invite(Server, {<<"foo">>, Server}, undefined, false), + create_account_invite(Server, {<<"foo">>, Server}), mod_invites:expire_tokens(<<"foo">>, Server), Token = token_from_uri(mod_invites:gen_invite(<<"foobar">>, Server)), ?match(1, mod_invites:cleanup_expired()), @@ -136,7 +140,7 @@ adhoc_items(Config) -> acl:match_rule(Server, gen_mod:get_module_opt(Server, mod_invites, access_create_account), UserJID)), - ok. + disconnect(Config). adhoc_command_invite(Config) -> Server = ?config(server, Config), @@ -157,7 +161,7 @@ adhoc_command_invite(Config) -> User = jid:nodeprep(?config(user, Config)), ?match(true, mod_invites:is_token_valid(Server, Token, {User, Server})), mod_invites:remove_user(User, Server), - ok. + disconnect(Config). adhoc_command_create_account(Config) -> Server = ?config(server, Config), @@ -184,13 +188,13 @@ adhoc_command_create_account(Config) -> re:run(xdata_field(<<"uri">>, ResultXDataFields2), <<"xmpp:foobar@", Server/binary, "\\?register;preauth=(.+)">>)), ResultXDataFields3 = test_create_account(Config, <<>>, <<"1">>), - Inviter = jid:nodeprep(?config(user, Config)), + Inviter = ?config(user, Config), ?match({match, [Inviter, _]}, re:run(xdata_field(<<"uri">>, ResultXDataFields3), <<"xmpp:(.+)", "@", Server/binary, "\\?roster;preauth=([a-zA-Z0-9]+);ibr=y">>, [{capture, all_but_first, binary}])), Token = token_from_uri(xdata_field(<<"uri">>, ResultXDataFields3, <<>>)), - #invite_token{account_name = undefined, type = account_subscription} = + #invite_token{account_name = <<>>, type = account_subscription} = mod_invites:get_invite(Server, Token), ResultXDataFields4 = test_create_account(Config, <<"foobar">>, <<"1">>), ?match({match, [Inviter, _]}, @@ -200,17 +204,17 @@ adhoc_command_create_account(Config) -> update_module_opts(Server, mod_invites, OldOpts), User = jid:nodeprep(?config(user, Config)), mod_invites:remove_user(User, Server), - ok. + disconnect(Config). token_valid(Config) -> Server = ?config(server, Config), - User = jid:nodeprep(?config(user, Config)), + User = ?config(user, Config), Res = mod_invites:gen_invite(<<"foobar">>, Server), Token = token_from_uri(Res), ?match(true, mod_invites:is_token_valid(Server, Token)), Inviter = {<<"foo">>, Server}, #invite_token{token = AccountToken} = - mod_invites:create_account_invite(Server, Inviter, undefined, false), + create_account_invite(Server, Inviter), ?match(true, mod_invites:is_token_valid(Server, AccountToken, Inviter)), ?match(false, mod_invites:is_token_valid(Server, <<"madeUptoken">>)), ?match(false, @@ -223,9 +227,9 @@ token_valid(Config) -> remove_user(Config) -> Server = ?config(server, Config), - User = jid:nodeprep(?config(user, Config)), + User = ?config(user, Config), Inviter = {User, Server}, - #invite_token{} = mod_invites:create_account_invite(Server, Inviter, undefined, false), + #invite_token{} = create_account_invite(Server, Inviter), ?match(1, mod_invites:num_account_invites(User, Server)), mod_invites:remove_user(User, Server), ?match(0, mod_invites:num_account_invites(User, Server)), @@ -233,11 +237,11 @@ remove_user(Config) -> expire_tokens(Config) -> Server = ?config(server, Config), - User = jid:nodeprep(?config(user, Config)), + User = ?config(user, Config), Inviter = {User, Server}, #invite_token{token = RosterToken} = mod_invites:create_roster_invite(Server, Inviter), #invite_token{token = AccountToken} = - mod_invites:create_account_invite(Server, Inviter, undefined, false), + create_account_invite(Server, Inviter), ?match(true, mod_invites:is_token_valid(Server, RosterToken, Inviter)), ?match(1, mod_invites:expire_tokens(User, Server)), ?match(true, mod_invites:is_token_valid(Server, RosterToken, Inviter)), @@ -247,37 +251,207 @@ expire_tokens(Config) -> max_invites(Config) -> Server = ?config(server, Config), - User = jid:nodeprep(?config(user, Config)), + User = ?config(user, Config), Inviter = {User, Server}, OldOpts = gen_mod:get_module_opts(Server, mod_invites), NewOpts = gen_mod:set_opt(max_invites, 3, OldOpts), update_module_opts(Server, mod_invites, NewOpts), - #invite_token{} = mod_invites:create_account_invite(Server, Inviter, undefined, false), - #invite_token{} = mod_invites:create_account_invite(Server, Inviter, undefined, false), - #invite_token{} = mod_invites:create_account_invite(Server, Inviter, undefined, false), + #invite_token{} = create_account_invite(Server, Inviter), + #invite_token{} = create_account_invite(Server, Inviter), + #invite_token{} = create_account_invite(Server, Inviter), ?match({error, num_invites_exceeded}, - mod_invites:create_account_invite(Server, Inviter, undefined, false)), + create_account_invite(Server, Inviter)), update_module_opts(Server, mod_invites, OldOpts), - #invite_token{} = mod_invites:create_account_invite(Server, Inviter, undefined, false), + #invite_token{} = create_account_invite(Server, Inviter), ok. presence_with_preauth_token(Config) -> Server = ?config(server, Config), + User = ?config(user, Config), Inviter = {<<"inviter">>, Server}, + InviterJID = jid:make(<<"inviter">>, Server), #invite_token{token = RosterToken} = mod_invites:create_roster_invite(Server, Inviter), send(Config, - #presence{type = 'subscribe', - to = jid:make(<<"inviter">>, Server), + #presence{type = subscribe, + to = InviterJID, sub_els = [#preauth{token = RosterToken}]}), - ?recv2( - #iq{type = 'set', - sub_els = [#roster_query{items = [#roster_item{ask = 'subscribe'}]}]}, - #iq{type = 'set', - sub_els = [#roster_query{items = [#roster_item{subscription = 'to'}]}]}), + _ = + ?recv2(#iq{type = set, + sub_els = [#roster_query{items = [#roster_item{ask = subscribe}]}]}, + #iq{type = set, sub_els = [#roster_query{items = [#roster_item{subscription = to}]}]}), + ?match(false, mod_invites:is_token_valid(Server, RosterToken, Inviter)), + %% cleanup the mess + mod_roster:del_roster(User, Server, jid:tolower(InviterJID)), + #iq{type = set} = suite:recv_iq(Config), + disconnect(Config). + +is_reserved(Config) -> + Server = ?config(server, Config), + Inviter = {<<"inviter">>, Server}, + mod_invites:expire_tokens(<<"inviter">>, Server), + mod_invites:cleanup_expired(), + #invite_token{token = Token} = + mod_invites:create_account_invite(Server, Inviter, <<"reserved_user">>, false), + ?match(false, mod_invites:is_reserved(Server, Token, <<"some_other_username">>)), + ?match(false, mod_invites:is_reserved(Server, Token, <<"reserved_user">>)), + ?match(true, + mod_invites:is_reserved(Server, <<"some_other_token">>, <<"reserved_user">>)), + %% "use" token to create account under different name, then it should not be reserved anymore + mod_invites:set_invitee(Server, Token, jid:make(<<"some_other_username">>, Server)), + ?match(false, + mod_invites:is_reserved(Server, <<"some_other_token">>, <<"reserved_user">>)), + ok. + +stream_feature(Config0) -> + Server = ?config(server, Config0), + OldOpts = gen_mod:get_module_opts(Server, mod_invites), + Config1 = reconnect(Config0), + ?match(true, ?config(register, Config1)), + ?match(false, ?config(register_ibr_token, Config1)), + NewOpts = gen_mod:set_opt(access_create_account, account_invite, OldOpts), + update_module_opts(Server, mod_invites, NewOpts), + Config2 = reconnect(Config1), + ?match(true, ?config(register, Config2)), + ?match(true, ?config(register_ibr_token, Config2)), + update_module_opts(Server, mod_invites, OldOpts), + disconnect(Config2). + +ibr(Config0) -> + Server = ?config(server, Config0), + AccountName = <<"new_user">>, + + OldRegisterOpts = gen_mod:get_module_opts(Server, mod_register), + NewRegisterOpts = gen_mod:set_opt(allow_modules, [mod_invites], OldRegisterOpts), + update_module_opts(Server, mod_register, NewRegisterOpts), + + Config1 = reconnect(Config0), + + ?match(#iq{type = error}, send_iq_register(Config1, AccountName)), + + ?match(#iq{type = error}, send_pars(Config1, <<"bad_token">>)), + + #invite_token{token = Token} = + mod_invites:create_account_invite(Server, {<<>>, Server}, AccountName, false), + ?match(#iq{type = result}, send_pars(Config1, Token)), + ?match(#iq{type = result, sub_els = [#register{username = AccountName}]}, + send_get_iq_register(Config1)), + ?match(#iq{type = result}, send_iq_register(Config1, AccountName)), + + Config2 = reconnect(Config1), + ?match(#iq{type = error}, send_pars(Config2, Token)), + + #invite_token{token = Token2} = + mod_invites:create_account_invite(Server, + {<<>>, Server}, + <<"some_unfavorable_name">>, + false), + ?match(#iq{type = result}, send_pars(Config2, Token2)), + ?match(#iq{type = result, sub_els = [#register{username = <<"some_unfavorable_name">>}]}, + send_get_iq_register(Config2)), + ?match(#iq{type = result}, send_iq_register(Config2, <<"some_much_better_name">>)), + + Config3 = reconnect(Config2), + #invite_token{token = Token3} = create_account_invite(Server, {<<>>, Server}), + ?match(#iq{type = result}, send_pars(Config3, Token3)), + ?match(#iq{type = result, sub_els = [#register{username = <<>>}]}, + send_get_iq_register(Config3)), + ?match(#iq{type = result}, send_iq_register(Config3, <<"some_self_chosen_name">>)), + + update_module_opts(Server, mod_register, OldRegisterOpts), + disconnect(Config3). + +ibr_reserved(Config0) -> + Server = ?config(server, Config0), + Config1 = reconnect(Config0), + #invite_token{token = _ReservedToken} = + mod_invites:create_account_invite(Server, {<<>>, Server}, <<"reserved">>, false), + #invite_token{token = OtherToken} = + mod_invites:create_account_invite(Server, {<<>>, Server}, <<"some_other">>, false), + ?match(#iq{type = result}, send_iq_register(Config1, <<"check_registration_works">>)), + Config2 = reconnect(Config1), + ?match(#iq{type = error}, send_iq_register(Config2, <<"reserved">>)), + ?match(#iq{type = result}, send_pars(Config2, OtherToken)), + disconnect(Config2). + +ibr_subscription(Config0) -> + Server = ?config(server, Config0), + ServerJID = jid:from_string(Server), + User = ?config(user, Config0), + UserJID = jid:make(User, Server), + NewAccount = <<"new_friend">>, + NewAccountJID = jid:make(NewAccount, Server), + gen_mod:stop_module_keep_config(Server, mod_vcard_xupdate), + self_presence(Config0, available), + + #invite_token{token = Token} = + mod_invites:create_account_invite(Server, {User, Server}, NewAccount, true), + + Config1 = set_opts([{user, NewAccount}, + {password, <<"mySecret">>}, + {resource, <<"invite_tests">>}, + {receiver, undefined}], Config0), + Config = connect(Config1), + + ?match(#iq{type = result}, send_pars(Config, Token)), + ?match(#iq{type = result}, send_iq_register(Config, NewAccount)), + + open_session(bind(auth(Config))), + + _ = + ?recv3( + #iq{type = set, sub_els = [#roster_query{items = [#roster_item{jid = NewAccountJID, subscription = from}]}]}, + #iq{type = set, sub_els = [#roster_query{items = [#roster_item{jid = NewAccountJID, subscription = both}]}]}, + #presence{from = NewAccountJID, type = subscribed}), + ?match( - false, - mod_invites:is_token_valid(Server, RosterToken, Inviter) - ). + true, + [Friend || + Friend = #roster{jid = {RUser, RServer, <<>>}, subscription = both} + <- mod_roster:get_roster(User, Server), {RUser, RServer} == {NewAccount, Server}] /= [] + ), + ?match( + true, + [Friend || + Friend = #roster{jid = {RUser, RServer, <<>>}, subscription = both} + <- mod_roster:get_roster(NewAccount, Server), {RUser, RServer} == {User, Server}] /= [] + ), + UserFullJID = jid:make(User, Server, ?config(resource, Config0)), + NewAccountFullJID = jid:make(NewAccount, Server, ?config(resource, Config)), + send(Config, #presence{}), + + receive_subscription_stanzas(ServerJID, UserFullJID, NewAccountFullJID), + + mod_roster:del_roster(User, Server, jid:tolower(NewAccountJID)), + mod_roster:del_roster(NewAccount, Server, jid:tolower(UserJID)), + + disconnect(Config0), + disconnect(Config). + +receive_subscription_stanzas(ServerJID, UserFullJID, NewAccountFullJID) -> + Stanzas = [pres1, pres2, pres3, msg], + receive_subscription_stanzas(length(Stanzas), Stanzas, ServerJID, UserFullJID, NewAccountFullJID). + +receive_subscription_stanzas(_, {timeout, ElementsLeft}, _, _, _) -> + {error, {timeout, ElementsLeft}}; +receive_subscription_stanzas(0, [], _, _, _) -> + done; +receive_subscription_stanzas(0, NotEmpty, _, _, _) -> + {error, {elements_left, NotEmpty}}; +receive_subscription_stanzas(Count, Elements, ServerJID, UserFullJID, NewAccountFullJID) -> + Res = + receive + #presence{from = UserFullJID, to = NewAccountFullJID} -> + lists:delete(pres1, Elements); + #presence{from = NewAccountFullJID, to = UserFullJID} -> + lists:delete(pres2, Elements); + #presence{from = NewAccountFullJID, to = NewAccountFullJID} -> + lists:delete(pres3, Elements); + #message{from = ServerJID} -> + lists:delete(msg, Elements) + after 100 -> + {timeout, Elements} + end, + receive_subscription_stanzas(Count - 1, Res, ServerJID, UserFullJID, NewAccountFullJID). %%%=================================================================== %%% Internal functions @@ -290,6 +464,9 @@ token_from_uri(Uri) -> re:run(Uri, ".+preauth=([a-zA-z0-9]+)", [{capture, all_but_first, binary}]), Token. +create_account_invite(Server, Inviter) -> + mod_invites:create_account_invite(Server, Inviter, <<>>, false). + update_module_opts(Host, Module, Opts) -> [EjabMod] = ets:lookup(ejabberd_modules, {Module, Host}), ets:insert(ejabberd_modules, EjabMod#ejabberd_module{opts = Opts}). @@ -332,8 +509,7 @@ test_create_account(Config, Username, Subscription) -> to = ServerJID, sub_els = [Command]}), XdataFields = - xdata_field_set(<<"username">>, - Username, + xdata_field_set(<<"username">>, Username, xdata_field_set(<<"roster-subscription">>, Subscription, XdataFields0)), #iq{type = result, sub_els = @@ -350,3 +526,46 @@ test_create_account(Config, Username, Subscription) -> #xdata{type = submit, fields = XdataFields}}]}), ResultXDataFields. + +connect(Config) -> + process_stream_features(init_stream(Config)). + +reconnect(Config) -> + connect(disconnect(Config)). + +process_stream_features(Config) -> + receive + #stream_features{sub_els = Fs} -> + ct:pal("stream features: ~p", [Fs]), + lists:foldl(fun (#feature_register{}, Acc) -> + set_opt(register, true, Acc); + (#feature_register_ibr_token{}, Acc) -> + set_opt(register_ibr_token, true, Acc); + (_, Acc) -> + Acc + end, + set_opt(register, false, set_opt(register_ibr_token, false, Config)), + Fs) + end. + + +send_get_iq_register(Config) -> + ServerJID = jid:from_string(?config(server, Config)), + send_recv(Config, + #iq{type = get, + to = ServerJID, + sub_els = [#register{}]}). + +send_iq_register(Config, AccountName) -> + ServerJID = jid:from_string(?config(server, Config)), + send_recv(Config, + #iq{type = set, + to = ServerJID, + sub_els = [#register{username = AccountName, password = <<"mySecret">>}]}). + +send_pars(Config, Token) -> + ServerJID = jid:from_string(?config(server, Config)), + send_recv(Config, + #iq{type = set, + to = ServerJID, + sub_els = [#preauth{token = Token}]}). diff --git a/test/suite.erl b/test/suite.erl index 6b61296624d..706a38cec9f 100644 --- a/test/suite.erl +++ b/test/suite.erl @@ -791,6 +791,10 @@ is_feature_advertised(Config, Feature, To) -> set_opt(Opt, Val, Config) -> [{Opt, Val}|lists:keydelete(Opt, 1, Config)]. +set_opts([], Config) -> Config; +set_opts([{Opt, Val} | Opts], Config) -> + set_opts(Opts, set_opt(Opt, Val, Config)). + wait_for_master(Config) -> put_event(Config, peer_ready), case get_event(Config) of From 90534ce4176416e9a0f8764d8b1ef03df609910a Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Thu, 30 Oct 2025 19:21:01 +0100 Subject: [PATCH 03/27] add invite pages --- include/mod_invites.hrl | 2 +- priv/mod_invites/apps.html | 26 ++ priv/mod_invites/apps.json | 176 +++++++++ priv/mod_invites/base.html | 47 +++ priv/mod_invites/base_min.html | 35 ++ priv/mod_invites/client.html | 70 ++++ priv/mod_invites/invite.html | 18 + priv/mod_invites/invite_invalid.html | 9 + priv/mod_invites/register.html | 64 ++++ priv/mod_invites/register_error.html | 7 + priv/mod_invites/register_success.html | 101 ++++++ priv/mod_invites/roster.html | 18 + priv/mod_invites/static/illus-empty.svg | 1 + priv/mod_invites/static/invite.js | 87 +++++ priv/mod_invites/static/logos/beagle-im.svg | 1 + .../static/logos/conversations.svg | 105 ++++++ priv/mod_invites/static/logos/converse-js.svg | 1 + priv/mod_invites/static/logos/dino.svg | 1 + priv/mod_invites/static/logos/gajim.svg | 78 ++++ priv/mod_invites/static/logos/generic.svg | 267 ++++++++++++++ priv/mod_invites/static/logos/monal-tmp.svg | 28 ++ priv/mod_invites/static/logos/monal.png | Bin 0 -> 32890 bytes priv/mod_invites/static/logos/renga.svg | 36 ++ priv/mod_invites/static/logos/siskin-im.svg | 136 +++++++ priv/mod_invites/static/logos/yaxim.svg | 65 ++++ priv/mod_invites/static/platform.min.js | 100 ++++++ priv/mod_invites/static/qr-logo.png | Bin 0 -> 219 bytes priv/mod_invites/static/qrcode.min.js | 17 + priv/msgs/de.msg | 138 ++++++-- rebar.config | 1 + src/mod_invites.erl | 330 ++++++----------- src/mod_invites_http.erl | 334 ++++++++++++++++++ src/mod_invites_http_erlylib.erl | 49 +++ src/mod_invites_mnesia.erl | 2 +- src/mod_invites_opt.erl | 21 ++ src/mod_invites_register.erl | 234 ++++++++++++ test/ejabberd_SUITE_data/ejabberd.mnesia.yml | 1 + test/ejabberd_SUITE_data/ejabberd.yml | 1 + test/invites_tests.erl | 75 +++- tools/extract-erlydtl-templates.sh | 22 ++ tools/prepare-tr.sh | 5 +- 41 files changed, 2443 insertions(+), 266 deletions(-) create mode 100644 priv/mod_invites/apps.html create mode 100644 priv/mod_invites/apps.json create mode 100644 priv/mod_invites/base.html create mode 100644 priv/mod_invites/base_min.html create mode 100644 priv/mod_invites/client.html create mode 100644 priv/mod_invites/invite.html create mode 100644 priv/mod_invites/invite_invalid.html create mode 100644 priv/mod_invites/register.html create mode 100644 priv/mod_invites/register_error.html create mode 100644 priv/mod_invites/register_success.html create mode 100644 priv/mod_invites/roster.html create mode 100644 priv/mod_invites/static/illus-empty.svg create mode 100644 priv/mod_invites/static/invite.js create mode 100644 priv/mod_invites/static/logos/beagle-im.svg create mode 100644 priv/mod_invites/static/logos/conversations.svg create mode 100644 priv/mod_invites/static/logos/converse-js.svg create mode 100644 priv/mod_invites/static/logos/dino.svg create mode 100644 priv/mod_invites/static/logos/gajim.svg create mode 100644 priv/mod_invites/static/logos/generic.svg create mode 100644 priv/mod_invites/static/logos/monal-tmp.svg create mode 100644 priv/mod_invites/static/logos/monal.png create mode 100644 priv/mod_invites/static/logos/renga.svg create mode 100644 priv/mod_invites/static/logos/siskin-im.svg create mode 100644 priv/mod_invites/static/logos/yaxim.svg create mode 100644 priv/mod_invites/static/platform.min.js create mode 100644 priv/mod_invites/static/qr-logo.png create mode 100644 priv/mod_invites/static/qrcode.min.js create mode 100644 src/mod_invites_http.erl create mode 100644 src/mod_invites_http_erlylib.erl create mode 100644 src/mod_invites_register.erl create mode 100755 tools/extract-erlydtl-templates.sh diff --git a/include/mod_invites.hrl b/include/mod_invites.hrl index ab7f2f7687b..d5ef0d97450 100644 --- a/include/mod_invites.hrl +++ b/include/mod_invites.hrl @@ -8,7 +8,7 @@ }). -define(INVITE_TOKEN_EXPIRE_SECONDS_DEFAULT, 5*86400). --define(INVITE_TOKEN_LENGTH_DEFAULT, 16). +-define(INVITE_TOKEN_LENGTH_DEFAULT, 24). -define(NS_INVITE_INVITE, <<"urn:xmpp:invite#invite">>). -define(NS_INVITE_CREATE_ACCOUNT, <<"urn:xmpp:invite#create-account">>). diff --git a/priv/mod_invites/apps.html b/priv/mod_invites/apps.html new file mode 100644 index 00000000000..d0678966459 --- /dev/null +++ b/priv/mod_invites/apps.html @@ -0,0 +1,26 @@ +
+
+ {% for item in apps %} +
+
+
+ {{ item.imagetext }} +
+
+
+
{{ item.name }}
+
+ {% for platform in item.platforms %}{{ platform }}{% endfor %} +
+

{{ item.text }}

+ {% if item.select_text %}{{ item.select_text }}{% else %}{% trans "Select" %}{% endif %} +
+
+
+
+ {% endfor %} +
+
+
+ {% trans "Showing apps for your current platform only. You may also view all apps." %} +
diff --git a/priv/mod_invites/apps.json b/priv/mod_invites/apps.json new file mode 100644 index 00000000000..786faa72dd0 --- /dev/null +++ b/priv/mod_invites/apps.json @@ -0,0 +1,176 @@ +[ + { + "download": { + "buttons": [ + { + "image": "https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png", + "url": "https://play.google.com/store/apps/details?id=eu.siacs.conversations", + "magic_link_format": "https://play.google.com/store/apps/details?id=eu.siacs.conversations&referrer={{ uri }}" + } + ] + }, + "image": "logos/conversations.svg", + "link": "https://play.google.com/store/apps/details?id=eu.siacs.conversations", + "magic_link_format": "https://play.google.com/store/apps/details?id=eu.siacs.conversations&referrer={{ uri }}", + "name": "Conversations", + "platforms": [ + "Android" + ], + "supports_preauth_uri": true, + "text": "{% trans "Conversations is a Jabber/XMPP client for Android 6.0+ smartphones that has been optimized to provide a unique mobile experience." %}" + }, + { + "download": { + "buttons": [ + { + "image": "https://toolbox.marketingtools.apple.com/api/v2/badges/download-on-the-app-store/black/en-us?releaseDate=1245024000", + "target": "_blank", + "url": "https://apps.apple.com/app/id317711500" + } + ] + }, + "image": "logos/monal-tmp.svg", + "link": "https://monal-im.org/", + "name": "Monal", + "platforms": [ + "iOS", "iPadOS" + ], + "supports_preauth_uri": true, + "text": "{% trans "A modern open-source chat client for iPhone and iPad. It is easy to use and has a clean user interface." %}" + }, + { + "download": { + "buttons": [ + { + "image": "https://toolbox.marketingtools.apple.com/api/v2/badges/download-on-the-app-store/black/en-us?releaseDate=1245024000", + "target": "_blank", + "url": "https://apps.apple.com/app/id1637078500" + } + ] + }, + "image": "logos/monal-tmp.svg", + "link": "https://monal-im.org/", + "name": "Monal (macOS)", + "platforms": [ + "macOS" + ], + "supports_preauth_uri": true, + "text": "{% trans "A modern open-source chat client for Mac. It is easy to use and has a clean user interface." %}" + }, + { + "download": { + "buttons": [ + { + "image": "https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png", + "url": "https://play.google.com/store/apps/details?id=org.yaxim.androidclient", + "magic_link_format": "https://play.google.com/store/apps/details?id=org.yaxim.androidclient&referrer={{ invite.uri }}" + } + ] + }, + "image": "logos/yaxim.svg", + "link": "https://play.google.com/store/apps/details?id=org.yaxim.androidclient", + "magic_link_format": "https://play.google.com/store/apps/details?id=org.yaxim.androidclient&referrer={{ invite.uri }}", + "name": "yaxim", + "platforms": [ + "Android" + ], + "supports_preauth_uri": true, + "text": "{% trans "A lean Jabber/XMPP client for Android. It aims at usability, low overhead and security, and works on low-end Android devices starting with Android 4.0." %}" + }, + { + "download": { + "buttons": [ + { + "image": "https://toolbox.marketingtools.apple.com/api/v2/badges/download-on-the-app-store/black/en-us?releaseDate=1245024000", + "target": "_blank", + "url": "https://apps.apple.com/us/app/siskin-im/id1153516838" + } + ] + }, + "image": "logos/siskin-im.svg", + "link": "https://apps.apple.com/us/app/siskin-im/id1153516838", + "name": "Siskin IM", + "platforms": [ + "iOS", "iPadOS" + ], + "supports_preauth_uri": true, + "text": "{% trans "A lightweight and powerful XMPP client for iPhone and iPad. It provides an easy way to talk and share moments with your friends." %}" + }, + { + "download": { + "buttons": [ + { + "target": "_blank", + "text": "{% trans "Download from Mac App Store" %}", + "url": "https://apps.apple.com/us/app/beagle-im/id1445349494" + } + ] + }, + "image": "logos/beagle-im.svg", + "link": "https://apps.apple.com/us/app/beagle-im/id1445349494", + "name": "Beagle IM", + "platforms": [ + "macOS" + ], + "setup": { + "text": "{% trans "Launch Beagle IM, and select 'Yes' to add a new account. Click the '+' button under the empty account list and then enter your credentials." %}" + }, + "text": "{% trans "Beagle IM by Tigase, Inc. is a lightweight and powerful XMPP client for macOS." %}" + }, + { + "download": { + "buttons": [ + { + "target": "_blank", + "text": "{% trans "Download Dino for Linux" %}", + "url": "https://dino.im/#download" + } + ], + "text": "{% trans "Click the button to open the Dino website where you can download and install it on your PC." %}" + }, + "image": "logos/dino.svg", + "link": "https://dino.im/", + "name": "Dino", + "platforms": [ + "Linux" + ], + "text": "{% trans "A modern open-source chat client for the desktop. It focuses on providing a clean and reliable Jabber/XMPP experience while having your privacy in mind." %}" + }, + { + "download": { + "buttons": [ + { + "target": "_blank", + "text": "{% trans "Download Gajim" %}", + "url": "https://gajim.org/download/" + } + ] + }, + "image": "logos/gajim.svg", + "link": "https://gajim.org/", + "name": "Gajim", + "platforms": [ + "Windows", + "Linux" + ], + "text": "{% trans "A fully-featured desktop chat client for Windows and Linux." %}" + }, + { + "download": { + "buttons": [ + { + "target": "_blank", + "text": "{% trans "Download Renga for Haiku" %}", + "url": "https://depot.haiku-os.org/#!/pkg/renga?bcguid=bc233-PQIA" + } + ] + }, + "image": "logos/renga.svg", + "link": "https://pulkomandy.tk/projects/renga", + "name": "Renga", + "platforms": [ + "Haiku" + ], + "text": "{% trans "XMPP client for Haiku" %}" + } +] diff --git a/priv/mod_invites/base.html b/priv/mod_invites/base.html new file mode 100644 index 00000000000..9d61a16853c --- /dev/null +++ b/priv/mod_invites/base.html @@ -0,0 +1,47 @@ +{% extends "base_min.html" %} + +{% block rel_alternate %} + +{% endblock %} + +{% block qr_button %} +
+ {% trans "Tip: You can open this invite on your mobile device by scanning a barcode with your camera." %} + +
+{% endblock %} + +{% block qr_code %} + +{% endblock %} + +{% block extra_scripts %} + + + +{% endblock %} diff --git a/priv/mod_invites/base_min.html b/priv/mod_invites/base_min.html new file mode 100644 index 00000000000..7cda0451bde --- /dev/null +++ b/priv/mod_invites/base_min.html @@ -0,0 +1,35 @@ + + + + + + {% block title %}{% blocktrans %}Invite to {{ site_name }}{% endblocktrans %}{% endblock %} + {% block rel_alternate %}{% endblock %} + + + + + + + + + + +
+
+
+

+ {%block h1 %}{% blocktrans %}Invite to {{ site_name }}{% endblocktrans %}{% endblock %}
+

+
+ {% block qr_button %}{% endblock %} + {% block content %}{% endblock %} +
+
+
+ {% block qr_code %}{% endblock %} + {% block extra_scripts %}{% endblock %} + + + + diff --git a/priv/mod_invites/client.html b/priv/mod_invites/client.html new file mode 100644 index 00000000000..45a3dbf4a66 --- /dev/null +++ b/priv/mod_invites/client.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} + +{% block h1 %} + {% blocktrans with app_name=app.name %}Join {{ site_name }} with {{ app_name }}{% endblocktrans %} +{% endblock %} + +{% block content %} +

{% if invite.inviter|user %} + {% blocktrans with inviter=invite.inviter|user %}You have been invited to chat with {{ inviter }} on {{ site_name }}, part of the XMPP secure and decentralized messaging network.{% endblocktrans %} + {% else %} + {% blocktrans %}You have been invited to chat on {{ site_name }}, part of the XMPP secure and decentralized messaging network.{% endblocktrans %} + {% endif %} +

+ +

{% blocktrans with app_name=app.name %}You can start chatting right away with {{ app_name }}. Let's get started!{% endblocktrans %}

+ +
+
+
+ {{ app.imagetext }} +
+
+
+
{{ app.name }}
+
+ {% for item in app.platforms %}{{ item }} {% endfor %} +
+

{{ app.text }}

+
+
+
+
+ +

{% blocktrans with app_name=app.name %}Step 1: Install {{ app_name }}{% endblocktrans %}

+ +

{% if app.download.text %}{{ app.download.text }}{% else %}{% blocktrans with app_name=app.name %}Download and install {{ app_name }} below:{% endblocktrans %}{% endif %}

+ +
+ {% for button in app.download.buttons %} + {% if button.image %} + + + + {% endif %} + {% if button.text %} + + {{ button.text }} + + {% endif %} + {% endfor %} +
+ +

{% blocktrans with app_name=app.name %}After successfully installing {{ app_name }}, come back to this page and continue with Step 2.{% endblocktrans %}

+ +

{% trans "Step 2: Activate your account" %}

+ +

{% trans "Installed ok? Great! Click or tap the button below to accept your invite and continue with your account setup:" %}

+ + + +

{% blocktrans with app_name=app.name %}After clicking the button you will be taken to {{ app_name }} to finish setting up your new {{ site_name }} account.{% endblocktrans %}

+{% endblock %} + +{% block extra_scripts %} + + + +{% endblock %} diff --git a/priv/mod_invites/invite.html b/priv/mod_invites/invite.html new file mode 100644 index 00000000000..d59211bf248 --- /dev/null +++ b/priv/mod_invites/invite.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block content %} +

{% if invite.inviter|user %} + {% blocktrans with inviter=invite.inviter|user %}You have been invited to chat with {{ inviter }} on {{ site_name }}, part of the XMPP secure and decentralized messaging network.{% endblocktrans %} + {% else %} + {% blocktrans %}You have been invited to chat on {{ site_name }}, part of the XMPP secure and decentralized messaging network.{% endblocktrans %} + {% endif %}

+ +
{% trans "Get started" %}
+ +

{% trans "To get started, you need to install an app for your platform:" %}

+ + {% include "apps.html" %} + +
{% trans "Other software" %}
+

{% blocktrans %}You can connect to {{ site_name }} using any XMPP-compatible software. If your preferred software is not listed above, you may still register an account manually.{% endblocktrans %}

+{% endblock %} diff --git a/priv/mod_invites/invite_invalid.html b/priv/mod_invites/invite_invalid.html new file mode 100644 index 00000000000..ad40d8ba039 --- /dev/null +++ b/priv/mod_invites/invite_invalid.html @@ -0,0 +1,9 @@ +{% extends "base_min.html" %} +{% block form_class %}container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 mt-2 mt-md-5{% endblock %} +{% block content %} +
{% trans "Invite expired" %}
+ +

{% trans "Sorry, it looks like this invite code has expired!" %}

+ + {% trans +{% endblock %} diff --git a/priv/mod_invites/register.html b/priv/mod_invites/register.html new file mode 100644 index 00000000000..ed55781668d --- /dev/null +++ b/priv/mod_invites/register.html @@ -0,0 +1,64 @@ +{% extends "base_min.html" %} + +{% block title %}{% blocktrans %}Register on {{ site_name }}{% endblocktrans %}{% endblock %} +{% block h1 %}{% blocktrans %}Register on {{ site_name }}{% endblocktrans %}{% endblock %} + +{% block form_class %}container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 mt-2 mt-md-5{% endblock %} + +{% block content %} +

{% if app %}{% blocktrans with app_name=app.name %}{{ site_name }} is part of XMPP, a secure and decentralized messaging network. To begin chatting using {{ app_name }} you need to first register an account.{% endblocktrans %}{% else %}{% blocktrans %}{{ site_name }} is part of XMPP, a secure and decentralized messaging network. To begin chatting you need to first register an account.{% endblocktrans %}{% endif %}

+ +

{%if invite.inviter %}{% blocktrans with inviter=invite.inviter|user %}Creating an account will allow to communicate with {{ inviter }} and other people on {{ site_name }} and other services on the XMPP network.{% endblocktrans %}{% else %}{% blocktrans %}Creating an account will allow to communicate with other people on {{ site_name }} and other services on the XMPP network.{% endblocktrans %}{% endif %}

+ + {% if app %}{% if app.supports_preauth_uri %} +
+

{% blocktrans with app_name=app.name %}If you already have {{ app_name }} installed, we recommend that you continue the account creation process using the app by clicking on the button below:{% endblocktrans %}

+ +
{% blocktrans with app_name=app.name %}{{ app_name }} already installed?{% endblocktrans %}
+ +
+
+ {% trans "This button works only if you have the app installed already!" %} +
+
+
+ {% endif %}{% endif %} + +
{% trans "Create an account" %}
+ + {%if message %}{% endif %} + +
+
+ +
+
+ +
+ @{{ domain }} +
+
+ {% trans "Choose a username, this will become the first part of your new chat address." %} +
+
+
+ +
+ + {% trans "Enter a secure password that you do not use anywhere else." %} +
+
+
+ + {% if app %}{% endif %} + +
+
+{% endblock %} diff --git a/priv/mod_invites/register_error.html b/priv/mod_invites/register_error.html new file mode 100644 index 00000000000..804f89a837c --- /dev/null +++ b/priv/mod_invites/register_error.html @@ -0,0 +1,7 @@ +{% extends "base_min.html" %} +{% block form_class %}container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 mt-2 mt-md-5{% endblock %} +{% block content %} +
{% trans "Registration error" %}
+ +

{% if message %}{{ message }}{% else %}{% trans "Sorry, there was a problem registering your account." %}{% endif %}

+{% endblock%} diff --git a/priv/mod_invites/register_success.html b/priv/mod_invites/register_success.html new file mode 100644 index 00000000000..cc87593714a --- /dev/null +++ b/priv/mod_invites/register_success.html @@ -0,0 +1,101 @@ +{% extends "base_min.html" %} +{% block form_class %}container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 mt-2 mt-md-5{% endblock %} +{% block title %}{{site_name}}{% endblock %} +{% block h1 %}{{site_name}}{% endblock %} +{% block extra_scripts %} + +{% endblock %} +{% block content %} +
{% trans "Congratulations!" %}
+ +

{% blocktrans %}You have created an account on {{ site_name }}.{% endblocktrans %}

+ +

{% trans "To start chatting, you need to enter your new account credentials into your chosen XMPP software." %}

+ + {% if webchat_url %} +
+
+
+
+

{% trans "No suitable software installed right now? You can also log in to your account through our online web chat!" %}

+
+ +
+
+
+ {% endif %} + + {% if app %} +

{% blocktrans with app_name=app.name %}You can now set up {{ app_name }} and connect it to your new account.{% endblocktrans %}

+ +
{% blocktrans with app_name=app.name %}Step 1: Download and install {{ app_name }}{% endblocktrans %}
+ +

{% if app.download.text %}{{ app.download.text }}{% else %}{% blocktrans with app_name=app.name %}Download and install {{ app_name }} below:{% endblocktrans %}{% endif %}

+ +
+ {% for item in app.download.buttons %} + {% if item.image %} + + + + {% endif %} + {%if item.text %} + + + + {% endif %} + {% endfor %} +
+ +
{% blocktrans with app_name=app.name %}Step 2: Connect {{ app_name }} to your new account{% endblocktrans %}
+ +

{% if app.setup.text %}{{ app.setup.text }}{% else %}{% blocktrans with app_name=app.name %}Launch {{ app_name }} and sign in using your account credentials.{% endblocktrans %}{% endif %}

+ {% endif %} + +

{% trans "As a final reminder, your account details are shown below:" %}

+ + + + {% if password %} +

{% trans "Your password is stored encrypted on the server and will not be accessible after you close this page. Keep it safe and never share it with anyone." %}

+ {% endif %} +{% endblock %} diff --git a/priv/mod_invites/roster.html b/priv/mod_invites/roster.html new file mode 100644 index 00000000000..9a00d4045bb --- /dev/null +++ b/priv/mod_invites/roster.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block title %}{% blocktrans with inviter=invite.inviter|jid %}{{ inviter }} has invited you to connect!{% endblocktrans %}{% endblock %} +{% block h1 %}{% blocktrans with inviter=invite.inviter|user %}{{ inviter }} has invited you to connect!{% endblocktrans %}{% endblock %} + +{% block content %} +
+ {% blocktrans with inviter=invite.inviter|jid %}This is an invite from {{ inviter }} to connect and chat on the XMPP network. If you already have an XMPP client installed just press the button below!{% endblocktrans %} + +
+

{% trans "If you don't have an XMPP client installed yet, here's a list of suitable clients for your platform." %}

+ +{% include "apps.html" %} + +{% endblock %} diff --git a/priv/mod_invites/static/illus-empty.svg b/priv/mod_invites/static/illus-empty.svg new file mode 100644 index 00000000000..7a963020962 --- /dev/null +++ b/priv/mod_invites/static/illus-empty.svg @@ -0,0 +1 @@ +empty \ No newline at end of file diff --git a/priv/mod_invites/static/invite.js b/priv/mod_invites/static/invite.js new file mode 100644 index 00000000000..8c5d03d592c --- /dev/null +++ b/priv/mod_invites/static/invite.js @@ -0,0 +1,87 @@ +(function () { + // If QR lib loaded ok, show QR button on desktop devices + if(window.QRCode) { + new QRCode(document.getElementById("qr-invite-page"), document.location.href); + document.getElementById('qr-button-container').classList.add("d-md-block"); + } + + // Detect current platform and show/hide appropriate clients + if(window.platform) { + let platform_friendly = null; + let platform_classname = null; + switch(platform.os.family) { + case "Ubuntu": + case "Linux": + case "Fedora": + case "Red Hat": + case "SuSE": + platform_friendly = platform.os.family + " (Linux)"; + platform_classname = "linux"; + break; + case "Linux aarch64": + platform_friendly = "Linux mobile"; + platform_classname = "linux"; + break; + case "Haiku R1": + platform_friendly = "Haiku"; + platform_classname = "haiku"; + break; + case "Windows Phone": + platform_friendly = "Windows Phone"; + platform_classname = "windows-phone"; + break; + case "OS X": + if (navigator.maxTouchPoints > 1) { + // looks like iPad to me! + platform_friendly = "iPadOS"; + platform_classname = "ipados"; + } else { + platform_friendly = "macOS"; + platform_classname = "macos"; + } + break; + default: + if(platform.os.family.startsWith("Windows")) { + platform_friendly = "Windows"; + platform_classname = "windows"; + } else { + platform_friendly = platform.os.family; + platform_classname = platform_friendly.toLowerCase(); + } + } + + if(platform_friendly && platform_classname) { + if(document.querySelectorAll('.client-card .client-platform-badge-'+platform_classname).length == 0) { + // No clients recognised for this platform, do nothing + return; + } + // Hide clients not for this platform + const client_cards = document.getElementsByClassName('client-card'); + for (let card of client_cards) { + if (card.classList.contains('app-platform-'+platform_classname)) + card.classList.add('supported-platform'); + else if (!card.classList.contains('app-platform-web')) + card.hidden = true; + const badges = card.querySelectorAll('.client-platform-badge'); + for (let badge of badges) { + if (badge.classList.contains('client-platform-badge-'+platform_classname)) { + badge.classList.add("badge-success"); + badge.classList.remove("badge-info"); + } else { + badge.classList.add("badge-secondary"); + badge.classList.remove("badge-info"); + } + } + } + const show_all_clients_button_container = document.getElementById('show-all-clients-button-container'); + show_all_clients_button_container.querySelector('.platform-name').innerHTML = platform_friendly; + show_all_clients_button_container.classList.remove("d-none"); + document.getElementById('show-all-clients-button').addEventListener('click', function (e) { + for (let card of client_cards) + card.hidden = false; + show_all_clients_button_container.hidden = true; + e.preventDefault(); + }); + } + } +})(); diff --git a/priv/mod_invites/static/logos/beagle-im.svg b/priv/mod_invites/static/logos/beagle-im.svg new file mode 100644 index 00000000000..068df5ceffd --- /dev/null +++ b/priv/mod_invites/static/logos/beagle-im.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/priv/mod_invites/static/logos/conversations.svg b/priv/mod_invites/static/logos/conversations.svg new file mode 100644 index 00000000000..47b5ec79cb9 --- /dev/null +++ b/priv/mod_invites/static/logos/conversations.svg @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/mod_invites/static/logos/converse-js.svg b/priv/mod_invites/static/logos/converse-js.svg new file mode 100644 index 00000000000..e286482c073 --- /dev/null +++ b/priv/mod_invites/static/logos/converse-js.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/priv/mod_invites/static/logos/dino.svg b/priv/mod_invites/static/logos/dino.svg new file mode 100644 index 00000000000..a893b5b2471 --- /dev/null +++ b/priv/mod_invites/static/logos/dino.svg @@ -0,0 +1 @@ + diff --git a/priv/mod_invites/static/logos/gajim.svg b/priv/mod_invites/static/logos/gajim.svg new file mode 100644 index 00000000000..15a88fc7b69 --- /dev/null +++ b/priv/mod_invites/static/logos/gajim.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/mod_invites/static/logos/generic.svg b/priv/mod_invites/static/logos/generic.svg new file mode 100644 index 00000000000..7fc38d5c4f3 --- /dev/null +++ b/priv/mod_invites/static/logos/generic.svg @@ -0,0 +1,267 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/mod_invites/static/logos/monal-tmp.svg b/priv/mod_invites/static/logos/monal-tmp.svg new file mode 100644 index 00000000000..3cfaf1fc7c6 --- /dev/null +++ b/priv/mod_invites/static/logos/monal-tmp.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/mod_invites/static/logos/monal.png b/priv/mod_invites/static/logos/monal.png new file mode 100644 index 0000000000000000000000000000000000000000..936f5442df92f9ed18059cb996541923cb140bea GIT binary patch literal 32890 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 SV}c%tuSZ&30bC<$82EpRvMXEw literal 0 HcmV?d00001 diff --git a/priv/mod_invites/static/logos/renga.svg b/priv/mod_invites/static/logos/renga.svg new file mode 100644 index 00000000000..a51d9563a6d --- /dev/null +++ b/priv/mod_invites/static/logos/renga.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + diff --git a/priv/mod_invites/static/logos/siskin-im.svg b/priv/mod_invites/static/logos/siskin-im.svg new file mode 100644 index 00000000000..50fd76736b9 --- /dev/null +++ b/priv/mod_invites/static/logos/siskin-im.svg @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/priv/mod_invites/static/logos/yaxim.svg b/priv/mod_invites/static/logos/yaxim.svg new file mode 100644 index 00000000000..4c8c487d3e4 --- /dev/null +++ b/priv/mod_invites/static/logos/yaxim.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/mod_invites/static/platform.min.js b/priv/mod_invites/static/platform.min.js new file mode 100644 index 00000000000..2dcd14c3f31 --- /dev/null +++ b/priv/mod_invites/static/platform.min.js @@ -0,0 +1,100 @@ +/*!* Platform.js +* Copyright 2014-2018 Benjamin Tan +* Copyright 2011-2013 John-David Dalton +* Available under MIT license */;(function(){'use strict';var objectTypes={'function':true,'object':true};var root=(objectTypes[typeof window]&&window)||this;var oldRoot=root;var freeExports=objectTypes[typeof exports]&&exports;var freeModule=objectTypes[typeof module]&&module&&!module.nodeType&&module;var freeGlobal=freeExports&&freeModule&&typeof global=='object'&&global;if(freeGlobal&&(freeGlobal.global===freeGlobal||freeGlobal.window===freeGlobal||freeGlobal.self===freeGlobal)){root=freeGlobal;} +var maxSafeInteger=Math.pow(2,53)-1;var reOpera=/\bOpera/;var thisBinding=this;var objectProto=Object.prototype;var hasOwnProperty=objectProto.hasOwnProperty;var toString=objectProto.toString;function capitalize(string){string=String(string);return string.charAt(0).toUpperCase()+string.slice(1);} +function cleanupOS(os,pattern,label){var data={'10.0':'10','6.4':'10 Technical Preview','6.3':'8.1','6.2':'8','6.1':'Server 2008 R2 / 7','6.0':'Server 2008 / Vista','5.2':'Server 2003 / XP 64-bit','5.1':'XP','5.01':'2000 SP1','5.0':'2000','4.0':'NT','4.90':'ME'};if(pattern&&label&&/^Win/i.test(os)&&!/^Windows Phone /i.test(os)&&(data=data[/[\d.]+$/.exec(os)])){os='Windows '+data;} +os=String(os);if(pattern&&label){os=os.replace(RegExp(pattern,'i'),label);} +os=format(os.replace(/ ce$/i,' CE').replace(/\bhpw/i,'web').replace(/\bMacintosh\b/,'Mac OS').replace(/_PowerPC\b/i,' OS').replace(/\b(OS X) [^ \d]+/i,'$1').replace(/\bMac (OS X)\b/,'$1').replace(/\/(\d)/,' $1').replace(/_/g,'.').replace(/(?: BePC|[ .]*fc[ \d.]+)$/i,'').replace(/\bx86\.64\b/gi,'x86_64').replace(/\b(Windows Phone) OS\b/,'$1').replace(/\b(Chrome OS \w+) [\d.]+\b/,'$1').split(' on ')[0]);return os;} +function each(object,callback){var index=-1,length=object?object.length:0;if(typeof length=='number'&&length>-1&&length<=maxSafeInteger){while(++index3&&'WebKit'||/\bOpera\b/.test(name)&&(/\bOPR\b/.test(ua)?'Blink':'Presto')||/\b(?:Midori|Nook|Safari)\b/i.test(ua)&&!/^(?:Trident|EdgeHTML)$/.test(layout)&&'WebKit'||!layout&&/\bMSIE\b/i.test(ua)&&(os=='Mac OS'?'Tasman':'Trident')||layout=='WebKit'&&/\bPlayStation\b(?! Vita\b)/i.test(name)&&'NetFront')){layout=[data];} +if(name=='IE'&&(data=(/; *(?:XBLWP|ZuneWP)(\d+)/i.exec(ua)||0)[1])){name+=' Mobile';os='Windows Phone '+(/\+$/.test(data)?data:data+'.x');description.unshift('desktop mode');} +else if(/\bWPDesktop\b/i.test(ua)){name='IE Mobile';os='Windows Phone 8.x';description.unshift('desktop mode');version||(version=(/\brv:([\d.]+)/.exec(ua)||0)[1]);} +else if(name!='IE'&&layout=='Trident'&&(data=/\brv:([\d.]+)/.exec(ua))){if(name){description.push('identifying as '+name+(version?' '+version:''));} +name='IE';version=data[1];} +if(useFeatures){if(isHostType(context,'global')){if(java){data=java.lang.System;arch=data.getProperty('os.arch');os=os||data.getProperty('os.name')+' '+data.getProperty('os.version');} +if(rhino){try{version=context.require('ringo/engine').version.join('.');name='RingoJS';}catch(e){if((data=context.system)&&data.global.system==context.system){name='Narwhal';os||(os=data[0].os||null);}} +if(!name){name='Rhino';}} +else if(typeof context.process=='object'&&!context.process.browser&&(data=context.process)){if(typeof data.versions=='object'){if(typeof data.versions.electron=='string'){description.push('Node '+data.versions.node);name='Electron';version=data.versions.electron;}else if(typeof data.versions.nw=='string'){description.push('Chromium '+version,'Node '+data.versions.node);name='NW.js';version=data.versions.nw;}} +if(!name){name='Node.js';arch=data.arch;os=data.platform;version=/[\d.]+/.exec(data.version);version=version?version[0]:null;}}} +else if(getClassOf((data=context.runtime))==airRuntimeClass){name='Adobe AIR';os=data.flash.system.Capabilities.os;} +else if(getClassOf((data=context.phantom))==phantomClass){name='PhantomJS';version=(data=data.version||null)&&(data.major+'.'+data.minor+'.'+data.patch);} +else if(typeof doc.documentMode=='number'&&(data=/\bTrident\/(\d+)/i.exec(ua))){version=[version,doc.documentMode];if((data=+data[1]+4)!=version[1]){description.push('IE '+version[1]+' mode');layout&&(layout[1]='');version[1]=data;} +version=name=='IE'?String(version[1].toFixed(1)):version[0];} +else if(typeof doc.documentMode=='number'&&/^(?:Chrome|Firefox)\b/.test(name)){description.push('masking as '+name+' '+version);name='IE';version='11.0';layout=['Trident'];os='Windows';} +os=os&&format(os);} +if(version&&(data=/(?:[ab]|dp|pre|[ab]\d+pre)(?:\d+\+?)?$/i.exec(version)||/(?:alpha|beta)(?: ?\d)?/i.exec(ua+';'+(useFeatures&&nav.appMinorVersion))||/\bMinefield\b/i.test(ua)&&'a')){prerelease=/b/i.test(data)?'beta':'alpha';version=version.replace(RegExp(data+'\\+?$'),'')+ +(prerelease=='beta'?beta:alpha)+(/\d+\+?/.exec(data)||'');} +if(name=='Fennec'||name=='Firefox'&&/\b(?:Android|Firefox OS)\b/.test(os)){name='Firefox Mobile';} +else if(name=='Maxthon'&&version){version=version.replace(/\.[\d.]+/,'.x');} +else if(/\bXbox\b/i.test(product)){if(product=='Xbox 360'){os=null;} +if(product=='Xbox 360'&&/\bIEMobile\b/.test(ua)){description.unshift('mobile mode');}} +else if((/^(?:Chrome|IE|Opera)$/.test(name)||name&&!product&&!/Browser|Mobi/.test(name))&&(os=='Windows CE'||/Mobi/i.test(ua))){name+=' Mobile';} +else if(name=='IE'&&useFeatures){try{if(context.external===null){description.unshift('platform preview');}}catch(e){description.unshift('embedded');}} +else if((/\bBlackBerry\b/.test(product)||/\bBB10\b/.test(ua))&&(data=(RegExp(product.replace(/ +/g,' *')+'/([.\\d]+)','i').exec(ua)||0)[1]||version)){data=[data,/BB10/.test(ua)];os=(data[1]?(product=null,manufacturer='BlackBerry'):'Device Software')+' '+data[0];version=null;} +else if(this!=forOwn&&product!='Wii'&&((useFeatures&&opera)||(/Opera/.test(name)&&/\b(?:MSIE|Firefox)\b/i.test(ua))||(name=='Firefox'&&/\bOS X (?:\d+\.){2,}/.test(os))||(name=='IE'&&((os&&!/^Win/.test(os)&&version>5.5)||/\bWindows XP\b/.test(os)&&version>8||version==8&&!/\bTrident\b/.test(ua))))&&!reOpera.test((data=parse.call(forOwn,ua.replace(reOpera,'')+';')))&&data.name){data='ing as '+data.name+((data=data.version)?' '+data:'');if(reOpera.test(name)){if(/\bIE\b/.test(data)&&os=='Mac OS'){os=null;} +data='identify'+data;} +else{data='mask'+data;if(operaClass){name=format(operaClass.replace(/([a-z])([A-Z])/g,'$1 $2'));}else{name='Opera';} +if(/\bIE\b/.test(data)){os=null;} +if(!useFeatures){version=null;}} +layout=['Presto'];description.push(data);} +if((data=(/\bAppleWebKit\/([\d.]+\+?)/i.exec(ua)||0)[1])){data=[parseFloat(data.replace(/\.(\d)$/,'.0$1')),data];if(name=='Safari'&&data[1].slice(-1)=='+'){name='WebKit Nightly';prerelease='alpha';version=data[1].slice(0,-1);} +else if(version==data[1]||version==(data[2]=(/\bSafari\/([\d.]+\+?)/i.exec(ua)||0)[1])){version=null;} +data[1]=(/\bChrome\/([\d.]+)/i.exec(ua)||0)[1];if(data[0]==537.36&&data[2]==537.36&&parseFloat(data[1])>=28&&layout=='WebKit'){layout=['Blink'];} +if(!useFeatures||(!likeChrome&&!data[1])){layout&&(layout[1]='like Safari');data=(data=data[0],data<400?1:data<500?2:data<526?3:data<533?4:data<534?'4+':data<535?5:data<537?6:data<538?7:data<601?8:'8');}else{layout&&(layout[1]='like Chrome');data=data[1]||(data=data[0],data<530?1:data<532?2:data<532.05?3:data<533?4:data<534.03?5:data<534.07?6:data<534.10?7:data<534.13?8:data<534.16?9:data<534.24?10:data<534.30?11:data<535.01?12:data<535.02?'13+':data<535.07?15:data<535.11?16:data<535.19?17:data<536.05?18:data<536.10?19:data<537.01?20:data<537.11?'21+':data<537.13?23:data<537.18?24:data<537.24?25:data<537.36?26:layout!='Blink'?'27':'28');} +layout&&(layout[1]+=' '+(data+=typeof data=='number'?'.x':/[.+]/.test(data)?'':'+'));if(name=='Safari'&&(!version||parseInt(version)>45)){version=data;}} +if(name=='Opera'&&(data=/\bzbov|zvav$/.exec(os))){name+=' ';description.unshift('desktop mode');if(data=='zvav'){name+='Mini';version=null;}else{name+='Mobile';} +os=os.replace(RegExp(' *'+data+'$'),'');} +else if(name=='Safari'&&/\bChrome\b/.exec(layout&&layout[1])){description.unshift('desktop mode');name='Chrome Mobile';version=null;if(/\bOS X\b/.test(os)){manufacturer='Apple';os='iOS 4.3+';}else{os=null;}} +if(version&&version.indexOf((data=/[\d.]+$/.exec(os)))==0&&ua.indexOf('/'+data+'-')>-1){os=trim(os.replace(data,''));} +if(layout&&!/\b(?:Avant|Nook)\b/.test(name)&&(/Browser|Lunascape|Maxthon/.test(name)||name!='Safari'&&/^iOS/.test(os)&&/\bSafari\b/.test(layout[1])||/^(?:Adobe|Arora|Breach|Midori|Opera|Phantom|Rekonq|Rock|Samsung Internet|Sleipnir|Web)/.test(name)&&layout[1])){(data=layout[layout.length-1])&&description.push(data);} +if(description.length){description=['('+description.join('; ')+')'];} +if(manufacturer&&product&&product.indexOf(manufacturer)<0){description.push('on '+manufacturer);} +if(product){description.push((/^on /.test(description[description.length-1])?'':'on ')+product);} +if(os){data=/ ([\d.+]+)$/.exec(os);isSpecialCasedOS=data&&os.charAt(os.length-data[0].length-1)=='/';os={'architecture':32,'family':(data&&!isSpecialCasedOS)?os.replace(data[0],''):os,'version':data?data[1]:null,'toString':function(){var version=this.version;return this.family+((version&&!isSpecialCasedOS)?' '+version:'')+(this.architecture==64?' 64-bit':'');}};} +if((data=/\b(?:AMD|IA|Win|WOW|x86_|x)64\b/i.exec(arch))&&!/\bi686\b/i.test(arch)){if(os){os.architecture=64;os.family=os.family.replace(RegExp(' *'+data),'');} +if(name&&(/\bWOW64\b/i.test(ua)||(useFeatures&&/\w(?:86|32)$/.test(nav.cpuClass||nav.platform)&&!/\bWin64; x64\b/i.test(ua)))){description.unshift('32-bit');}} +else if(os&&/^OS X/.test(os.family)&&name=='Chrome'&&parseFloat(version)>=39){os.architecture=64;} +ua||(ua=null);var platform={};platform.description=ua;platform.layout=layout&&layout[0];platform.manufacturer=manufacturer;platform.name=name;platform.prerelease=prerelease;platform.product=product;platform.ua=ua;platform.version=name&&version;platform.os=os||{'architecture':null,'family':null,'version':null,'toString':function(){return 'null';}};platform.parse=parse;platform.toString=toStringPlatform;if(platform.version){description.unshift(version);} +if(platform.name){description.unshift(name);} +if(os&&name&&!(os==String(os).split(' ')[0]&&(os==name.split(' ')[0]||product))){description.push(product?'('+os+')':'on '+os);} +if(description.length){platform.description=description.join(' ');} +return platform;} +var platform=parse();if(typeof define=='function'&&typeof define.amd=='object'&&define.amd){root.platform=platform;define(function(){return platform;});} +else if(freeExports&&freeModule){forOwn(platform,function(value,key){freeExports[key]=value;});} +else{root.platform=platform;}}.call(this)); \ No newline at end of file diff --git a/priv/mod_invites/static/qr-logo.png b/priv/mod_invites/static/qr-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..668e8980a8bf36ae2e3eabf8295f24cf30ecb4c6 GIT binary patch literal 219 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@O3?%{XE)7O>#2`4+Bk!iwgktIN(Xipc%5RU7~ zKmPycXXcRqap-Wt`tQ7lmu|P>nZE6n!JXf~+8!PdlWY5b{J+5+EBV6(H=pqw{%n~q zv99K1;*b5efU?{RejRrBDr?6x{oM(JJG-S17bLurWLr_mPzopr00rJpq5uE@ literal 0 HcmV?d00001 diff --git a/priv/mod_invites/static/qrcode.min.js b/priv/mod_invites/static/qrcode.min.js new file mode 100644 index 00000000000..2ec2f64d3b2 --- /dev/null +++ b/priv/mod_invites/static/qrcode.min.js @@ -0,0 +1,17 @@ +/* +The MIT License (MIT) +--------------------- +Copyright (c) 2012 davidshimjs + +Permission is hereby granted, free of charge, +to any person obtaining a copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push('');g.push("")}g.push("
"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); diff --git a/priv/msgs/de.msg b/priv/msgs/de.msg index 7247d5f55b0..f0fdff22278 100644 --- a/priv/msgs/de.msg +++ b/priv/msgs/de.msg @@ -5,21 +5,39 @@ {" (Add * to the end of field to match substring)"," (Fügen Sie * am Ende des Feldes hinzu um nach Teilzeichenketten zu suchen)"}. {" has set the subject to: "," hat das Thema geändert auf: "}. +{"{{ app_name }} already installed?","{{ app_name }} bereits installiert?"}. +{"{{ inviter }} has invited you to connect!","{{ inviter }} will sich mit dir verbinden!"}. {"# participants","# Teilnehmer"}. +{"{{ site_name }} is part of XMPP, a secure and decentralized messaging network. To begin chatting using {{ app_name }} you need to first register an account.","{{ site_name }} ist Teil von XMPP, ein sicheres und dezentrales Sofortnachrichten-Netzwerk. Um mittels {{ app_name }} chatten zu können musst du zunächst ein Konto anlegen."}. +{"{{ site_name }} is part of XMPP, a secure and decentralized messaging network. To begin chatting you need to first register an account.","{{ site_name }} is Teil von XMPP, ein sicheres und dezentrales Sofortnachrichten-Netzwerk. Um chatten zu können musst du zunächst ein Konto anlegen."}. +{"No suitable software installed right now? You can also log in to your account through our online web chat!","Du hast keine passende Software zur Hand im Moment? Du kannst dich auch mit unserem online Webchat anmelden!"}. +{"Tip: You can open this invite on your mobile device by scanning a barcode with your camera.","Tipp: du kannst diese Einladung auf deinem mobilen Endgerät öffnen indem du einen Barcode mit deiner Kamera scannst."}. +{"~s invites you to the room ~s","~s lädt Sie in den Raum ~s ein"}. +{"~ts's MAM Archive","~ts's MAM Archiv"}. +{"~ts's Offline Messages Queue","Offline-Nachrichten-Warteschlange von ~ts"}. {"A description of the node","Eine Beschreibung des Knotens"}. {"A friendly name for the node","Ein benutzerfreundlicher Name für den Knoten"}. +{"A fully-featured desktop chat client for Windows and Linux.","Ein funktionsreicher Desktop-Client für Windows und Linux."}. +{"A lean Jabber/XMPP client for Android. It aims at usability, low overhead and security, and works on low-end Android devices starting with Android 4.0.","Ein schlanker Jabber/XMPP-Client für Android mit Fokus Benutzerfreundlichkeit und Sicherheit. Er funktioniert auch mit langsameren Android-Geräten mit Android 4.0 oder neuer."}. +{"A lightweight and powerful XMPP client for iPhone and iPad. It provides an easy way to talk and share moments with your friends.","Ein schlanker aber funktionsreicher XMPP-Client für iPhone und iPad. Ein einfacher Weg um mit Deinen Freunden zu kommunizieren und Erinnerungen zu teilen."}. +{"A modern open-source chat client for iPhone and iPad. It is easy to use and has a clean user interface.","Ein moderner open-source Chatclient für iPhone und iPad mit einfacher und übersichtlicher Benutzeroberfläche."}. +{"A modern open-source chat client for Mac. It is easy to use and has a clean user interface.","Ein moderner open-source Chatclient für Mac mit einfacher und übersichtlicher Benutzeroberfläche."}. +{"A modern open-source chat client for the desktop. It focuses on providing a clean and reliable Jabber/XMPP experience while having your privacy in mind.","Ein moderner open-source Jabber/XMPP Chatclient für den Desktop mit Fokus auf Einfachheit und Zuverlässigkeit, aber auch Schutz deiner Privatsphäre."}. {"A password is required to enter this room","Ein Passwort ist erforderlich um diesen Raum zu betreten"}. {"A Web Page","Eine Webseite"}. +{"Accept invite using {{ app_name }}","Einladung mittels {{ app_name }} annehmen!"}. {"Accept","Akzeptieren"}. {"Access denied by service policy","Zugriff aufgrund der Dienstrichtlinien verweigert"}. {"Access model","Zugriffsmodell"}. {"Account doesn't exist","Konto existiert nicht"}. {"Action on user","Aktion auf Benutzer"}. -{"Add a hat to a user","Funktion zu einem Benutzer hinzufügen"}. +{"Add {{ inviter }} to your contact list","Füge {{ inviter }} zu deiner Kontaktliste hinzu"}. {"Add User","Benutzer hinzufügen"}. {"Administration of ","Administration von "}. {"Administration","Verwaltung"}. {"Administrator privileges required","Administratorrechte erforderlich"}. +{"After clicking the button you will be taken to {{ app_name }} to finish setting up your new {{ site_name }} account.","Nach dem Drücken des Buttons wirst du nach {{ app_name }} umgeleitet um dort die Einrichtung deines neuen Kontos auf {{ site_name }} fertigzustellen."}. +{"After successfully installing {{ app_name }}, come back to this page and continue with Step 2.","Nachdem du {{ app_name }} erfolgreich installiert hast, komme zu dieser Seite zurück und fahre mit Schritt 2 fort!"}. {"All activity","Alle Aktivitäten"}. {"All Users","Alle Benutzer"}. {"Allow subscription","Abonnement erlauben"}. @@ -49,6 +67,7 @@ {"API Commands","API Befehle"}. {"April","April"}. {"Arguments","Argumente"}. +{"As a final reminder, your account details are shown below:","Zur Erinnerung hier nochmal deine Kontodetails:"}. {"Attribute 'channel' is required for this request","Attribut 'channel' ist für diese Anforderung erforderlich"}. {"Attribute 'id' is mandatory for MIX messages","Attribut 'id' ist verpflichtend für MIX-Nachrichten"}. {"Attribute 'jid' is not allowed here","Attribut 'jid' ist hier nicht erlaubt"}. @@ -61,6 +80,7 @@ {"Backup to File at ","Backup in Datei bei "}. {"Backup","Backup"}. {"Bad format","Ungültiges Format"}. +{"Beagle IM by Tigase, Inc. is a lightweight and powerful XMPP client for macOS.","Beagle IM von Tigase Inc. ist eine schlanker aber funktionsreicher XMPP-Client für macOS."}. {"Birthday","Geburtsdatum"}. {"Both the username and the resource are required","Sowohl der Benutzername als auch die Ressource sind erforderlich"}. {"Bytestream already activated","Bytestream bereits aktiviert"}. @@ -77,6 +97,7 @@ {"Channel JID","Kanal-JID"}. {"Channels","Kanäle"}. {"Characters not allowed:","Nicht erlaubte Zeichen:"}. +{"Chat address (JID)","Chat-Adresse (JID)"}. {"Chatroom configuration modified","Chatraum-Konfiguration geändert"}. {"Chatroom is created","Chatraum ist erstellt"}. {"Chatroom is destroyed","Chatraum ist entfernt"}. @@ -84,16 +105,24 @@ {"Chatroom is stopped","Chatraum ist beendet"}. {"Chatrooms","Chaträume"}. {"Choose a username and password to register with this server","Wählen Sie zum Registrieren auf diesem Server einen Benutzernamen und ein Passwort"}. +{"Choose a username, this will become the first part of your new chat address.","Wähle eine Benutzernamen! Dies wird zum ersten Teil deiner neuen Chat-Adresse."}. {"Choose storage type of tables","Wähle Speichertyp der Tabellen"}. {"Choose whether to approve this entity's subscription.","Wählen Sie, ob das Abonnement dieser Entität genehmigt werden soll."}. {"City","Stadt"}. +{"Click the button to open the Dino website where you can download and install it on your PC.","Klicke auf den Button um Dino's Webseite zu öffnen, wo du ihn für deinen PC herunterladen und installieren kannst."}. {"Client acknowledged more stanzas than sent by server","Client bestätigte mehr Stanzas als vom Server gesendet"}. +{"Close","Schließen"}. {"Commands","Befehle"}. {"Conference room does not exist","Konferenzraum existiert nicht"}. {"Configuration of room ~s","Konfiguration des Raumes ~s"}. {"Configuration","Konfiguration"}. +{"Congratulations!","Gratuliere!"}. {"Contact Addresses (normally, room owner or owners)","Kontaktadresse (normalerweise Raumbesitzer)"}. +{"Conversations is a Jabber/XMPP client for Android 6.0+ smartphones that has been optimized to provide a unique mobile experience.","Conversations ist ein Jabber/XMPP-Client für Android 6.0+, der für mobile Endgeräte optimiert wurde."}. {"Country","Land"}. +{"Create an account","Konto anlegen"}. +{"Creating an account will allow to communicate with {{ inviter }} and other people on {{ site_name }} and other services on the XMPP network.","Ein Konto erlaubt es dir mit {{ inviter }} und anderen Leuten auf {{ site_name }} und anderen Diensten des XMPP-Netwerkes zu kommunizieren."}. +{"Creating an account will allow to communicate with other people on {{ site_name }} and other services on the XMPP network.","Ein Konto erlaubt es dir mit anderen Leuten auf {{ site_name }} und anderen Diensten des XMPP-Netwerkes zu kommunizieren."}. {"Current Discussion Topic","Aktuelles Diskussionsthema"}. {"Database failure","Datenbankfehler"}. {"Database Tables Configuration at ","Datenbanktabellen-Konfiguration bei "}. @@ -107,6 +136,11 @@ {"Deliver payloads with event notifications","Nutzdaten mit Ereignisbenachrichtigungen zustellen"}. {"Disc only copy","Nur auf Festplatte"}. {"Don't tell your password to anybody, not even the administrators of the XMPP server.","Geben Sie niemandem Ihr Passwort, auch nicht den Administratoren des XMPP-Servers."}. +{"Download and install {{ app_name }} below:","Herunterladen und Installieren von {{ app_name }}:"}. +{"Download Dino for Linux","Dino für Linux herunterladen"}. +{"Download from Mac App Store","Vom Mac App Store herunterladen"}. +{"Download Gajim","Gajim herunterladen"}. +{"Download Renga for Haiku","Renga für Haiku herunterladen"}. {"Dump Backup to Text File at ","Gib Backup in Textdatei aus bei "}. {"Dump to Text File","Ausgabe in Textdatei"}. {"Duplicated groups are not allowed by RFC6121","Doppelte Gruppen sind laut RFC6121 nicht erlaubt"}. @@ -128,6 +162,7 @@ {"Enable message archiving","Nachrichtenarchivierung aktivieren"}. {"Enabling push without 'node' attribute is not supported","push ohne 'node'-Attribut zu aktivieren wird nicht unterstützt"}. {"End User Session","Benutzersitzung beenden"}. +{"Enter a secure password that you do not use anywhere else.","Gib bitte ein sicheres Passwort ein, das du nirgends sonst verwendest!"}. {"Enter nickname you want to register","Geben Sie den Spitznamen ein den Sie registrieren wollen"}. {"Enter path to backup file","Geben Sie den Pfad zur Backupdatei ein"}. {"Enter path to jabberd14 spool dir","Geben Sie den Pfad zum jabberd14-Spoolverzeichnis ein"}. @@ -161,6 +196,7 @@ {"Get Number of Online Users","Anzahl der angemeldeten Benutzer abrufen"}. {"Get Number of Registered Users","Anzahl der registrierten Benutzer abrufen"}. {"Get Pending","Ausstehende abrufen"}. +{"Get started","Leg los!"}. {"Get User Last Login Time","letzte Anmeldezeit des Benutzers abrufen"}. {"Get User Statistics","Benutzerstatistiken abrufen"}. {"Given Name","Vorname"}. @@ -174,9 +210,12 @@ {"Hat title","Funktionstitel"}. {"Hat URI","Funktions-URI"}. {"Hats limit exceeded","Funktionslimit wurde überschritten"}. +{"Hide","Verbergen"}. {"Host unknown","Host unbekannt"}. {"HTTP File Upload","HTTP-Dateiupload"}. {"Idle connection","Inaktive Verbindung"}. +{"If you already have {{ app_name }} installed, we recommend that you continue the account creation process using the app by clicking on the button below:","Solltest du {{ app_name }} bereits installiert haben, empfehlen wir dir die Einrichtung des Kontos mittels dieser App durchzuführen indem du auf den Button unten klickst:"}. +{"If you don't have an XMPP client installed yet, here's a list of suitable clients for your platform.","Solltest du noch keinen XMPP-Client installiert haben, haben wir hier eine Liste geegineter Clients für deine Platform."}. {"If you don't see the CAPTCHA image here, visit the web page.","Wenn Sie das CAPTCHA-Bild nicht sehen, besuchen Sie die Webseite."}. {"Import Directory","Verzeichnis importieren"}. {"Import File","Datei importieren"}. @@ -194,14 +233,17 @@ {"Incorrect value of 'action' attribute","Falscher Wert des 'action'-Attributs"}. {"Incorrect value of 'action' in data form","Falscher Wert von 'action' in Datenformular"}. {"Incorrect value of 'path' in data form","Falscher Wert von 'path' in Datenformular"}. -{"Installed Modules:","Installierte Module:"}. {"Install","Installieren"}. +{"Installed Modules:","Installierte Module:"}. +{"Installed ok? Great! Click or tap the button below to accept your invite and continue with your account setup:","Fertig mit der Installation? Prima! Drück auf den Button unten um deine Einaldung anzunehmen und mit der Einrichtung deines Kontos fortzufahren:"}. {"Insufficient privilege","Unzureichende Privilegien"}. {"Internal server error","Interner Serverfehler"}. {"Invalid 'from' attribute in forwarded message","Ungültiges 'from'-Attribut in weitergeleiteter Nachricht"}. -{"Invalid node name","Ungültiger Knotenname"}. {"Invalid 'previd' value","Ungültiger 'previd'-Wert"}. +{"Invalid node name","Ungültiger Knotenname"}. {"Invitations are not allowed in this conference","Einladungen sind in dieser Konferenz nicht erlaubt"}. +{"Invite expired","Die Einladung ist abgelaufen"}. +{"Invite to {{ site_name }}","Einladung für {{ site_name }}"}. {"IP addresses","IP-Adressen"}. {"is now known as","ist nun bekannt als"}. {"It is not allowed to send error messages to the room. The participant (~s) has sent an error message (~s) and got kicked from the room","Es ist nicht erlaubt Fehlermeldungen an den Raum zu senden. Der Teilnehmer (~s) hat eine Fehlermeldung (~s) gesendet und wurde aus dem Raum geworfen"}. @@ -211,6 +253,7 @@ {"January","Januar"}. {"JID normalization denied by service policy","JID-Normalisierung aufgrund der Dienstrichtlinien verweigert"}. {"JID normalization failed","JID-Normalisierung fehlgeschlagen"}. +{"Join {{ site_name }} with {{ app_name }}","Konto auf {{ site_name }} mittels {{ app_name }} anlegen"}. {"Joined MIX channels of ~ts","Beigetretene MIX-Channels von ~ts"}. {"Joined MIX channels:","Beigetretene MIX-Channels:"}. {"joins the room","betritt den Raum"}. @@ -222,10 +265,12 @@ {"Last message","Letzte Nachricht"}. {"Last month","Letzter Monat"}. {"Last year","Letztes Jahr"}. +{"Launch {{ app_name }} and sign in using your account credentials.","Starte {{ app_name }} und logge dich deinen Anmeldedaten ein."}. +{"Launch Beagle IM, and select 'Yes' to add a new account. Click the '+' button under the empty account list and then enter your credentials.","Starte Beagle IM und wähle 'Yes' um ein neues Konto hinzuzufügen. Drücke auf das '+' unter der leeren Account-Liste und gib dann deine Anmeldedaten ein!"}. {"Least significant bits of SHA-256 hash of text should equal hexadecimal label","Niederwertigstes Bit des SHA-256-Hashes des Textes sollte hexadezimalem Label gleichen"}. {"leaves the room","verlässt den Raum"}. -{"List of users with hats","Liste der Benutzer mit Funktionen"}. {"List users with hats","Benutzer mit Funktionen auflisten"}. +{"Log in via web","Via Web anmelden"}. {"Logged Out","Abgemeldet"}. {"Logging","Protokollierung"}. {"Make participants list public","Teilnehmerliste öffentlich machen"}. @@ -260,9 +305,9 @@ {"Moderators Only","nur Moderatoren"}. {"Module failed to handle the query","Modul konnte die Anfrage nicht verarbeiten"}. {"Monday","Montag"}. +{"Multi-User Chat","Mehrbenutzer-Chat (MUC)"}. {"Multicast","Multicast"}. {"Multiple elements are not allowed by RFC6121","Mehrere -Elemente sind laut RFC6121 nicht erlaubt"}. -{"Multi-User Chat","Mehrbenutzer-Chat (MUC)"}. {"Name","Vorname"}. {"Natural Language for Room Discussions","Natürliche Sprache für Raumdiskussionen"}. {"Natural-Language Room Name","Raumname in natürlicher Sprache"}. @@ -270,43 +315,43 @@ {"Neither 'role' nor 'affiliation' attribute found","Weder 'role'- noch 'affiliation'-Attribut gefunden"}. {"Never","Nie"}. {"New Password:","Neues Passwort:"}. +{"Nickname ~s does not exist in the room","Der Spitzname ~s existiert nicht im Raum"}. {"Nickname can't be empty","Spitzname darf nicht leer sein"}. {"Nickname Registration at ","Registrieren des Spitznamens auf "}. -{"Nickname ~s does not exist in the room","Der Spitzname ~s existiert nicht im Raum"}. {"Nickname","Spitzname"}. +{"No 'affiliation' attribute found","Kein 'affiliation'-Attribut gefunden"}. +{"No 'item' element found","Kein 'item'-Element gefunden"}. +{"No 'password' found in data form","Kein 'password' im Datenformular gefunden"}. +{"No 'password' found in this query","Kein 'password' in dieser Anfrage gefunden"}. +{"No 'path' found in data form","Kein 'path' im Datenformular gefunden"}. +{"No 'to' attribute found in the invitation","Kein 'to'-Attribut in der Einladung gefunden"}. +{"No element found","Kein -Element gefunden"}. {"No address elements found","Keine 'address'-Elemente gefunden"}. {"No addresses element found","Kein 'addresses'-Element gefunden"}. -{"No 'affiliation' attribute found","Kein 'affiliation'-Attribut gefunden"}. {"No available resource found","Keine verfügbare Ressource gefunden"}. {"No body provided for announce message","Kein Text für die Ankündigungsnachricht angegeben"}. {"No child elements found","Keine 'child'-Elemente gefunden"}. {"No data form found","Kein Datenformular gefunden"}. {"No Data","Keine Daten"}. {"No features available","Keine Eigenschaften verfügbar"}. -{"No element found","Kein -Element gefunden"}. {"No hook has processed this command","Kein Hook hat diesen Befehl verarbeitet"}. {"No info about last activity found","Keine Informationen über letzte Aktivität gefunden"}. -{"No 'item' element found","Kein 'item'-Element gefunden"}. {"No items found in this query","Keine Items in dieser Anfrage gefunden"}. {"No limit","Keine Begrenzung"}. {"No module is handling this query","Kein Modul verarbeitet diese Anfrage"}. {"No node specified","Kein Knoten angegeben"}. -{"No 'password' found in data form","Kein 'password' im Datenformular gefunden"}. -{"No 'password' found in this query","Kein 'password' in dieser Anfrage gefunden"}. -{"No 'path' found in data form","Kein 'path' im Datenformular gefunden"}. {"No pending subscriptions found","Keine ausstehenden Abonnements gefunden"}. {"No privacy list with this name found","Keine Privacy-Liste mit diesem Namen gefunden"}. {"No private data found in this query","Keine privaten Daten in dieser Anfrage gefunden"}. {"No running node found","Kein laufender Knoten gefunden"}. {"No services available","Keine Dienste verfügbar"}. {"No statistics found for this item","Keine Statistiken für dieses Item gefunden"}. -{"No 'to' attribute found in the invitation","Kein 'to'-Attribut in der Einladung gefunden"}. {"Nobody","Niemand"}. +{"Node ~p","Knoten ~p"}. {"Node already exists","Knoten existiert bereits"}. {"Node ID","Knoten-ID"}. {"Node index not found","Knotenindex nicht gefunden"}. {"Node not found","Knoten nicht gefunden"}. -{"Node ~p","Knoten ~p"}. {"Node","Knoten"}. {"Nodeprep has failed","Nodeprep fehlgeschlagen"}. {"Nodes","Knoten"}. @@ -332,10 +377,10 @@ {"Old Password:","Altes Passwort:"}. {"Online Users","Angemeldete Benutzer"}. {"Online","Angemeldet"}. -{"Only collection node owners may associate leaf nodes with the collection","Nur Sammlungsknoten-Besitzer dürfen Blattknoten mit der Sammlung verknüpfen"}. -{"Only deliver notifications to available users","Benachrichtigungen nur an verfügbare Benutzer schicken"}. {"Only or tags are allowed","Nur - oder -Tags sind erlaubt"}. {"Only element is allowed in this query","Nur -Elemente sind in dieser Anfrage erlaubt"}. +{"Only collection node owners may associate leaf nodes with the collection","Nur Sammlungsknoten-Besitzer dürfen Blattknoten mit der Sammlung verknüpfen"}. +{"Only deliver notifications to available users","Benachrichtigungen nur an verfügbare Benutzer schicken"}. {"Only members may query archives of this room","Nur Mitglieder dürfen den Verlauf dieses Raumes abrufen"}. {"Only moderators and participants are allowed to change the subject in this room","Nur Moderatoren und Teilnehmer dürfen das Thema in diesem Raum ändern"}. {"Only moderators are allowed to change the subject in this room","Nur Moderatoren dürfen das Thema in diesem Raum ändern"}. @@ -347,18 +392,20 @@ {"Only service administrators are allowed to send service messages","Nur Service-Administratoren dürfen Servicenachrichten senden"}. {"Only those on a whitelist may associate leaf nodes with the collection","Nur jemand auf einer Whitelist darf Blattknoten mit der Sammlung verknüpfen"}. {"Only those on a whitelist may subscribe and retrieve items","Nur jemand auf einer Whitelist darf Items abonnieren und abrufen"}. +{"Open the app","App öffnen"}. {"Organization Name","Name der Organisation"}. {"Organization Unit","Abteilung"}. {"Other Modules Available:","Andere Module verfügbar:"}. +{"Other software","Andere Software"}. {"Outgoing s2s Connections","Ausgehende s2s-Verbindungen"}. {"Owner privileges required","Besitzerrechte erforderlich"}. {"Packet relay is denied by service policy","Paket-Relay aufgrund der Dienstrichtlinien verweigert"}. {"Participant ID","Teilnehmer-ID"}. {"Participant","Teilnehmer"}. -{"Password Verification","Passwort bestätigen"}. {"Password Verification:","Passwort bestätigen:"}. -{"Password","Passwort"}. +{"Password Verification","Passwort bestätigen"}. {"Password:","Passwort:"}. +{"Password","Passwort"}. {"Path to Dir","Pfad zum Verzeichnis"}. {"Path to File","Pfad zur Datei"}. {"Period: ","Zeitraum: "}. @@ -383,6 +430,7 @@ {"PubSub subscriber request","PubSub-Abonnenten-Anforderung"}. {"Purge all items when the relevant publisher goes offline","Alle Items löschen, wenn der relevante Veröffentlicher offline geht"}. {"Push record not found","Push-Eintrag nicht gefunden"}. +{"QR code icon","QR-Code Icon"}. {"Queries to the conference members are not allowed in this room","Anfragen an die Konferenzteilnehmer sind in diesem Raum nicht erlaubt"}. {"Query to another users is forbidden","Anfrage an andere Benutzer ist verboten"}. {"RAM and disc copy","RAM und Festplatte"}. @@ -394,7 +442,9 @@ {"Receive notification of new nodes only","Benachrichtigung nur von neuen Knoten erhalten"}. {"Recipient is not in the conference room","Empfänger ist nicht im Konferenzraum"}. {"Register an XMPP account","Ein XMPP-Konto registrieren"}. +{"Register on {{ site_name }}","Registriere dich auf {{ site_name }}"}. {"Register","Anmelden"}. +{"Registration error","Fehler beim Registrieren"}. {"Remote copy","Fernkopie"}. {"Remove a hat from a user","Eine Funktion bei einem Benutzer entfernen"}. {"Remove User","Benutzer löschen"}. @@ -422,17 +472,21 @@ {"Roster groups allowed to subscribe","Kontaktlistengruppen die abonnieren dürfen"}. {"Roster size","Kontaktlistengröße"}. {"Running Nodes","Laufende Knoten"}. -{"~s invites you to the room ~s","~s lädt Sie in den Raum ~s ein"}. +{"Sad person holding empty box","Eine traurige Person mit einer leeren Schachtel"}. {"Saturday","Samstag"}. +{"Scan invite code","Einladungscode einscannen"}. +{"Scan with mobile device","Mit Mobilgerät einscannen"}. {"Search from the date","Suche ab Datum"}. {"Search Results for ","Suchergebnisse für "}. {"Search the text","Text durchsuchen"}. {"Search until the date","Suche bis Datum"}. {"Search users in ","Suche Benutzer in "}. +{"Select","Auswählen"}. {"Send announcement to all online users on all hosts","Ankündigung an alle angemeldeten Benutzer auf allen Hosts senden"}. {"Send announcement to all online users","Ankündigung an alle angemeldeten Benutzer senden"}. {"Send announcement to all users on all hosts","Ankündigung an alle Benutzer auf allen Hosts senden"}. {"Send announcement to all users","Ankündigung an alle Benutzer senden"}. +{"Send this invite to your device","Sende diese Einladung auf dein Gerät"}. {"September","September"}. {"Server:","Server:"}. {"Service list retrieval timed out","Zeitüberschreitung bei Abfrage der Serviceliste"}. @@ -442,9 +496,13 @@ {"Shared Roster Groups","Gruppen der gemeinsamen Kontaktliste"}. {"Show Integral Table","Integral-Tabelle anzeigen"}. {"Show Ordinary Table","Gewöhnliche Tabelle anzeigen"}. +{"Show","Anzeigen"}. +{"Showing apps for your current platform only. You may also view all apps.","Wir zeigen dir nur Apps für deine aktuelle Platform an. Du kannst dir gerne auch sämtliche Apps anzeigen lassen."}. {"Shut Down Service","Dienst herunterfahren"}. {"SOCKS5 Bytestreams","SOCKS5-Bytestreams"}. {"Some XMPP clients can store your password in the computer, but you should do this only in your personal computer for safety reasons.","Einige XMPP-Clients speichern Ihr Passwort auf dem Computer. Aus Sicherheitsgründen sollten Sie das nur auf Ihrem persönlichen Computer tun."}. +{"Sorry, it looks like this invite code has expired!","Entschuldigung, es sieht so aus als wäre diese Einladung abgelaufen!"}. +{"Sorry, there was a problem registering your account.","Es trat leider ein Fehler beim Erstellen des Kontos auf."}. {"Sources Specs:","Quellenspezifikationen:"}. {"Specify the access model","Geben Sie das Zugangsmodell an"}. {"Specify the event message type","Geben Sie den Ereignisnachrichtentyp an"}. @@ -452,12 +510,17 @@ {"Stanza id is not valid","Stanza-ID ist ungültig"}. {"Stanza ID","Stanza-ID"}. {"Statically specify a replyto of the node owner(s)","Ein 'replyto' des/der Nodebesitzer(s) statisch angeben"}. +{"Step 1: Download and install {{ app_name }}","Schritt 1: {{ app_name }} herunterladen und installieren"}. +{"Step 1: Install {{ app_name }}","Schritt 1: Installiere {{ app_name }}"}. +{"Step 2: Activate your account","Konto aktivieren"}. +{"Step 2: Connect {{ app_name }} to your new account","Schritt 2: Verbinde {{ app_name }} mit deinem neuen Konto"}. {"Stopped Nodes","Angehaltene Knoten"}. {"Store binary backup:","Speichere binäres Backup:"}. {"Store plain text backup:","Speichere Klartext-Backup:"}. {"Stream management is already enabled","Stream-Verwaltung ist bereits aktiviert"}. {"Stream management is not enabled","Stream-Verwaltung ist nicht aktiviert"}. {"Subject","Betreff"}. +{"Submit","Senden"}. {"Submitted","Gesendet"}. {"Subscriber Address","Abonnenten-Adresse"}. {"Subscribers may publish","Abonnenten dürfen veröffentlichen"}. @@ -516,6 +579,8 @@ {"There was an error changing the password: ","Es trat ein Fehler beim Ändern des Passwortes auf: "}. {"There was an error creating the account: ","Es trat ein Fehler beim Erstellen des Kontos auf: "}. {"There was an error deleting the account: ","Es trat ein Fehler beim Löschen des Kontos auf: "}. +{"This button works only if you have the app installed already!","Dieser Button funktioniert nur, wenn du die App bereits installiert hast!"}. +{"This is an invite from {{ inviter }} to connect and chat on the XMPP network. If you already have an XMPP client installed just press the button below!","Die ist eine Kontakt-Einladung von {{ inviter }} um miteinander über XMPP zu chatten. Solltest du bereits einen XMPP-Client haben, so drücke einfach auf den Button hier unten!"}. {"This is case insensitive: macbeth is the same that MacBeth and Macbeth.","Dies ist schreibungsunabhängig: macbeth ist gleich MacBeth und Macbeth."}. {"This page allows to register an XMPP account in this XMPP server. Your JID (Jabber ID) will be of the form: username@server. Please read carefully the instructions to fill correctly the fields.","Diese Seite erlaubt das Anlegen eines XMPP-Kontos auf diesem XMPP-Server. Ihre JID (Jabber-ID) wird diese Form aufweisen: benutzername@server. Bitte lesen Sie die Anweisungen genau durch, um die Felder korrekt auszufüllen."}. {"This page allows to unregister an XMPP account in this XMPP server.","Diese Seite erlaubt es, ein XMPP-Konto von diesem XMPP-Server zu entfernen."}. @@ -524,21 +589,21 @@ {"Thursday","Donnerstag"}. {"Time delay","Zeitverzögerung"}. {"Timed out waiting for stream resumption","Zeitüberschreitung beim Warten auf Streamfortsetzung"}. -{"To register, visit ~s","Um sich zu registrieren, besuchen Sie ~s"}. {"To ~ts","An ~ts"}. +{"To get started, you need to install an app for your platform:","Um loszulegen musst du eine App für deine Platform installieren:"}. +{"To register, visit ~s","Um sich zu registrieren, besuchen Sie ~s"}. +{"To start chatting, you need to enter your new account credentials into your chosen XMPP software.","Um mit dem Chatten zu beginnen, musst du deine neuen Anmeldedaten in der XMPP Software deiner Wahl eintragen."}. {"Token TTL","Token-TTL"}. +{"Too many (~p) failed authentications from this IP address (~s). The address will be unblocked at ~s UTC","Zu viele (~p) fehlgeschlagene Authentifizierungen von dieser IP-Adresse (~s). Die Adresse wird an ~s UTC entsperrt"}. +{"Too many elements","Zu viele -Elemente"}. +{"Too many elements","Zu viele -Elemente"}. {"Too many active bytestreams","Zu viele aktive Bytestreams"}. {"Too many CAPTCHA requests","Zu viele CAPTCHA-Anforderungen"}. {"Too many child elements","Zu viele 'child'-Elemente"}. -{"Too many elements","Zu viele -Elemente"}. -{"Too many elements","Zu viele -Elemente"}. -{"Too many (~p) failed authentications from this IP address (~s). The address will be unblocked at ~s UTC","Zu viele (~p) fehlgeschlagene Authentifizierungen von dieser IP-Adresse (~s). Die Adresse wird an ~s UTC entsperrt"}. {"Too many receiver fields were specified","Zu viele Empfängerfelder wurden angegeben"}. {"Too many unacked stanzas","Zu viele unbestätigte Stanzas"}. {"Too many users in this conference","Zu viele Benutzer in dieser Konferenz"}. {"Traffic rate limit is exceeded","Datenratenlimit wurde überschritten"}. -{"~ts's MAM Archive","~ts's MAM Archiv"}. -{"~ts's Offline Messages Queue","Offline-Nachrichten-Warteschlange von ~ts"}. {"Tuesday","Dienstag"}. {"Unable to generate a CAPTCHA","Konnte kein CAPTCHA erstellen"}. {"Unable to register route on existing local domain","Konnte Route auf existierender lokaler Domäne nicht registrieren"}. @@ -557,32 +622,34 @@ {"Updating the vCard is not supported by the vCard storage backend","Aktualisierung der vCard wird vom vCard-Speicher-Backend nicht unterstützt"}. {"Upgrade","Upgrade"}. {"URL for Archived Discussion Logs","URL für archivierte Diskussionsprotokolle"}. -{"User already exists","Benutzer existiert bereits"}. +{"Use a QR code scanner on your mobile device to scan the code below:","Benutze einen QR-Code Scanner auf deinem mobilen Endgerät um den Code hier unten zu scannen:"}. {"User (jid)","Benutzer (JID)"}. +{"User ~ts","Benutzer ~ts"}. +{"User already exists","Benutzer existiert bereits"}. {"User JID","Benutzer-JID"}. {"User Management","Benutzerverwaltung"}. {"User removed","Benutzer entfernt"}. {"User session not found","Benutzersitzung nicht gefunden"}. {"User session terminated","Benutzersitzung beendet"}. -{"User ~ts","Benutzer ~ts"}. {"User","Benutzer"}. {"Username:","Benutzername:"}. +{"Username","Benutzername"}. {"Users are not allowed to register accounts so quickly","Benutzer dürfen Konten nicht so schnell registrieren"}. {"Users Last Activity","Letzte Benutzeraktivität"}. {"Users","Benutzer"}. {"Value 'get' of 'type' attribute is not allowed","Wert 'get' des 'type'-Attributs ist nicht erlaubt"}. +{"Value 'set' of 'type' attribute is not allowed","Wert 'set' des 'type'-Attributs ist nicht erlaubt"}. {"Value of '~s' should be boolean","Wert von '~s' sollte boolesch sein"}. {"Value of '~s' should be datetime string","Wert von '~s' sollte DateTime-Zeichenkette sein"}. {"Value of '~s' should be integer","Wert von '~s' sollte eine Ganzzahl sein"}. -{"Value 'set' of 'type' attribute is not allowed","Wert 'set' des 'type'-Attributs ist nicht erlaubt"}. {"vCard User Search","vCard-Benutzer-Suche"}. {"View joined MIX channels","Beitretene MIX-Channel ansehen"}. {"Virtual Hosts","Virtuelle Hosts"}. {"Visitor","Besucher"}. {"Visitors are not allowed to change their nicknames in this room","Besucher dürfen in diesem Raum ihren Spitznamen nicht ändern"}. {"Visitors are not allowed to send messages to all occupants","Besucher dürfen nicht an alle Teilnehmer Nachrichten versenden"}. -{"Voice requests are disabled in this conference","Sprachrecht-Anforderungen sind in diesem Raum deaktiviert"}. {"Voice request","Sprachrecht-Anforderung"}. +{"Voice requests are disabled in this conference","Sprachrecht-Anforderungen sind in diesem Raum deaktiviert"}. {"Web client which allows to join the room anonymously","Web-Client, der es ermöglicht, dem Raum anonym beizutreten"}. {"Wednesday","Mittwoch"}. {"When a new subscription is processed and whenever a subscriber comes online","Sobald ein neues Abonnement verarbeitet wird und wann immer ein Abonnent sich anmeldet"}. @@ -601,6 +668,7 @@ {"Wrong parameters in the web formulary","Falsche Parameter im Webformular"}. {"Wrong xmlns","Falscher xmlns"}. {"XMPP Account Registration","XMPP-Konto-Registrierung"}. +{"XMPP client for Haiku","XMPP-Client für Haiku."}. {"XMPP Domains","XMPP-Domänen"}. {"XMPP Show Value of Away","XMPP-Anzeigewert von Abwesend"}. {"XMPP Show Value of Chat","XMPP-Anzeigewert von Chat"}. @@ -610,16 +678,26 @@ {"You are being removed from the room because of a system shutdown","Sie werden wegen einer Systemabschaltung aus dem Raum entfernt"}. {"You are not allowed to send private messages","Sie dürfen keine privaten Nachrichten senden"}. {"You are not joined to the channel","Sie sind dem Raum nicht beigetreten"}. +{"You can connect to {{ site_name }} using any XMPP-compatible software. If your preferred software is not listed above, you may still register an account manually.","Du kannst dich mit {{ site_name }} über jede XMPP-kompatible Software verbinden. Sollte deine gewünschte Software hier oben nicht aufgeführt sein, so kannst du zumindest einen Account manuell anlegen."}. {"You can later change your password using an XMPP client.","Sie können Ihr Passwort später mit einem XMPP-Client ändern."}. +{"You can now set up {{ app_name }} and connect it to your new account.","Jetzt kannst du {{ app_name }} einrichten und mit deinem neuen Konto verknüpfen."}. +{"You can start chatting right away with {{ app_name }}. Let's get started!","Mittels {{ app_name }} kannst du direkt mit dem Chatten loslegen. Auf geht's!"}. +{"You can transfer this invite to your mobile device by scanning a code with your camera.","Du kannst diese Einladung auf dein Mobilgerät übertragen indem du den Code mit Deiner Kamera einscannst."}. {"You have been banned from this room","Sie wurden aus diesem Raum verbannt"}. +{"You have been invited to chat on {{ site_name }}, part of the XMPP secure and decentralized messaging network.","Du wurdest auf {{ site_name }} zum Chat eingeladen, Teil des sicheren und dezentralen XMPP-Sofortnachrichten-Netzwerkes."}. +{"You have been invited to chat on {{ site_name }}, part of the XMPP secure and decentralized messaging network.","Du wurdest auf {{ site_name }} zum Chat eingeladen. {{ site_name }} ist Teil des sicheren und dezentralen XMPP-Sofortnachrichten-Netzwerkes."}. +{"You have been invited to chat with {{ inviter }} on {{ site_name }}, part of the XMPP secure and decentralized messaging network.","Du wurdest von {{ inviter }} auf {{ site_name }} zum Chat eingeladen. {{ site_name }} ist Teil des sicheren und dezentralen XMPP-Sofortnachrichten-Netzwerkes."}. +{"You have been invited to chat with {{ inviter }} on {{ site_name }}, part of the XMPP secure and decentralized messaging network.","Du wurdest von {{ inviter }} auf {{ site_name }} zum Chat eingeladen. {{ site_name }} ist Teil des sicheren und dezentralen XMPP-Sofortnachrichten-Netzwerkes."}. +{"You have created an account on {{ site_name }}.","Du hast ein Konto auf {{ site_name }} angelegt."}. {"You have joined too many conferences","Sie sind zu vielen Konferenzen beigetreten"}. {"You must fill in field \"Nickname\" in the form","Sie müssen das Feld \"Spitzname\" im Formular ausfüllen"}. {"You need a client that supports x:data and CAPTCHA to register","Sie benötigen einen Client der x:data und CAPTCHA unterstützt, um sich zu registrieren"}. {"You need a client that supports x:data to register the nickname","Sie benötigen einen Client der x:data unterstützt, um Ihren Spitznamen zu registrieren"}. {"You need an x:data capable client to search","Sie benötigen einen Client der x:data unterstützt, um zu suchen"}. +{"You're not allowed to create nodes","Sie dürfen keine Knoten erstellen"}. {"Your active privacy list has denied the routing of this stanza.","Ihre aktive Privacy-Liste hat das Routing dieses Stanzas verweigert."}. {"Your contact offline message queue is full. The message has been discarded.","Die Offline-Nachrichten-Warteschlange Ihres Kontaktes ist voll. Die Nachricht wurde verworfen."}. +{"Your password is stored encrypted on the server and will not be accessible after you close this page. Keep it safe and never share it with anyone.","Dein Passwort wird verschlüsselt auf dem Server gespeichert und wird nicht mehr im Klartext verfügbar sein nachdem du diese Seite geschlossen hast. Verwahre es an einem sicheren Ort und teile es mit niemandem!"}. {"Your subscription request and/or messages to ~s have been blocked. To unblock your subscription request, visit ~s","Ihre Abonnement-Anforderung und/oder Nachrichten an ~s wurden blockiert. Um Ihre Abonnement-Anforderungen freizugeben, besuchen Sie ~s"}. {"Your XMPP account was successfully registered.","Ihr XMPP-Konto wurde erfolgreich registriert."}. {"Your XMPP account was successfully unregistered.","Ihr XMPP-Konto wurde erfolgreich entfernt."}. -{"You're not allowed to create nodes","Sie dürfen keine Knoten erstellen"}. diff --git a/rebar.config b/rebar.config index 9346c78e9e6..c2d09c5e9d0 100644 --- a/rebar.config +++ b/rebar.config @@ -34,6 +34,7 @@ {if_rebar3, {eredis, "~> 1.7.1", {git, "https://github.com/Nordix/eredis/", {tag, "v1.7.1"}}} }}, + {erlydtl, "~> 0.14.0", {git, "https://github.com/erlydtl/erlydtl.git", {tag, "0.15.0"}}}, {if_var_true, sip, {esip, "~> 1.0.59", {git, "https://github.com/processone/esip", {tag, "1.0.59"}}}}, {if_var_true, zlib, diff --git a/src/mod_invites.erl b/src/mod_invites.erl index bcdaf5120da..2234c77e6c9 100644 --- a/src/mod_invites.erl +++ b/src/mod_invites.erl @@ -1,7 +1,7 @@ %%%---------------------------------------------------------------------- %%% File : mod_invites.erl %%% Author : Stefan Strigler -%%% Purpose : Ad-hoc Account Invitation +%%% Purpose : Account and Roster Invitation (aka Great Invitations) %%% Created : Mon Sep 15 2025 by Stefan Strigler %%% %%% @@ -34,12 +34,15 @@ -export([depends/2, mod_doc/0, mod_options/1, mod_opt_type/1, reload/3, start/2, stop/1]). -export([adhoc_commands/4, adhoc_items/4, c2s_unauthenticated_packet/2, cleanup_expired/0, - expire_tokens/2, gen_invite/1, gen_invite/2, list_invites/1, remove_user/2, - s2s_receive_packet/1, sm_receive_packet/1, stream_feature_register/2]). + expire_tokens/2, gen_invite/1, gen_invite/2, get_invite/2, is_reserved/3, + is_token_valid/2, list_invites/1, remove_user/2, roster_add/2, + s2s_receive_packet/1, send_presence/3, set_invitee/3, sm_receive_packet/1, + stream_feature_register/2, token_uri/1, xdata_field/3]). + +-export([process/2]). -ifdef(TEST). --export([create_roster_invite/2, create_account_invite/4, get_invite/2, is_reserved/3, - is_token_valid/3, is_token_valid/2, num_account_invites/2, set_invitee/3]). +-export([create_roster_invite/2, create_account_invite/4, is_token_valid/3, num_account_invites/2]). -endif. -include("logger.hrl"). @@ -65,20 +68,6 @@ -callback remove_user(User :: binary(), Server :: binary()) -> any(). -callback set_invitee(Host :: binary(), Token :: binary(), Invitee :: binary()) -> ok. --define(try_subtag(IQ, SUBTAG, F, Else), - try xmpp:try_subtag(IQ, SUBTAG) of - false -> - Else(); - SubTag -> - F(SubTag) - catch _:{xmpp_codec, Why} -> - Txt = xmpp:io_format_error(Why), - Lang = maps:get(lang, State), - Err = make_stripped_error(IQ, SUBTAG, xmpp:err_bad_request(Txt, Lang)), - {stop, ejabberd_c2s:send(State, Err)} - end). --define(try_subtag(IQ, SUBTAG, F), ?try_subtag(IQ, SUBTAG, F, fun() -> State end)). - %% @format-begin %%-------------------------------------------------------------------- @@ -104,12 +93,23 @@ mod_doc() -> desc => ?T("Same as top-level _`default_db`_ option, but applied to this " "module only.")}}, + {landing_page, + #{value => "none | binary()", + desc => ?T("Web address of service handling invite links. This is either a local address handled by `mod_invites` configured as a handler at `ejabberd_http` or an external service like 'easy-xmpp-invitation'. The address must be given as a template pattern with fields from the `invite` that will then get replaced accordingly. Eg.: 'https://{{ host }}:5281/invites/{{ invite.token }}' or as an external service like 'http://{{ host }}:8080/easy-xmpp-invites/#{{ invite.uri|strip_protocol }}'. This is the landing page that is being communicated when creating an invite using one of the ad-hoc commands.")} + }, {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.")}}, + {site_name, + #{value => ?T("Site Name"), + desc => ?T("A human readable name for your site. E.g. 'My Beautiful Laundrette'")} + }, + {templates_dir, + #{value => ?T("binary()"), + 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 (e.g.: '7 * 86400')")}}], @@ -140,12 +140,17 @@ mod_doc() -> " mod_invites:", " access_create_account: create_account_invite"]}, ?T("Note that the names of the access rules are just examples and " - "you're free to change them.")]}. + "you're free to change them.") + %% TODO add example for invite page + ]}. 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}]. reload(ServerHost, NewOpts, OldOpts) -> @@ -167,10 +172,8 @@ start(Host, Opts) -> {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}, - {hook, - c2s_unauthenticated_packet, - c2s_unauthenticated_packet, - 10}, % note that the sequence is important here + %% note the sequence below is important + {hook, c2s_unauthenticated_packet, c2s_unauthenticated_packet, 10}, {commands, get_commands_spec()}]}. stop(_Host) -> @@ -180,8 +183,14 @@ mod_opt_type(access_create_account) -> econf:acl(); mod_opt_type(db_type) -> econf:db_type(?MODULE); +mod_opt_type(landing_page) -> + econf:either(none, econf:binary("^http[s]?://")); 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(). @@ -217,7 +226,7 @@ get_commands_spec() -> args_desc = ["Hostname to generate 'create account' invite for."], args_example = [<<"example.com">>], result_example = <<"xmpp:example.com?register;preauth=CJAi3TvpzuBJpmuf">>, - result = {invite_uri, string}}, + result = {invite, {tuple, [{invite_uri, string}, {landing_page, string}]}}}, #ejabberd_commands{name = generate_invite_with_username, tags = [accounts], desc = @@ -232,7 +241,7 @@ get_commands_spec() -> args_example = [<<"juliet">>, <<"example.com">>], result_example = <<"xmpp:juliet@example.com?register;preauth=CJAi3TvpzuBJpmuf">>, - result = {invite_uri, string}}, + result = {invite, {tuple, [{invite_uri, string}, {landing_page, string}]}}}, #ejabberd_commands{name = list_invites, tags = [accounts], desc = "List invite tokens", @@ -244,7 +253,17 @@ get_commands_spec() -> %result_example = [{invite_token, invite}], result = {invites, - {list, {invite, {tuple, [{token, string}, {token_uri, string}, {inviter, string}, {invitee, string}, {account_name, string}, {created_at, string}, {expires, string}, {type, atom}, {valid, atom}]}}}} + {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() -> @@ -278,21 +297,23 @@ gen_invite(Username, Host0) -> {error, _Reason} = Error -> Error; Invite -> - token_uri(Invite) + {token_uri(Invite), landing_page(Host, Invite)} end. list_invites(Host) -> Invites = db_call(Host, list_invites, [Host]), Format = fun(#invite_token{token = TO, inviter = {IU, IS}, invitee = IE, created_at = CA, expires = Exp, type = TY, account_name = AN} = Invite) -> {TO, - token_uri(Invite), - jid:encode(jid:make(IU, IS)), - IE, - AN, + is_token_valid(Host, TO), encode_datetime(CA), encode_datetime(Exp), TY, - is_token_valid(Host, TO)} + jid:encode(jid:make(IU, IS)), + IE, + AN, + token_uri(Invite), + landing_page(Host, Invite) + } end, [Format(Invite) || Invite <- Invites]. @@ -355,14 +376,18 @@ adhoc_commands(_Acc, #xdata{type = result, title = trans(Lang, <<"New Invite Token Created">>), fields = - [#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)]}]}, + 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, @@ -392,14 +417,18 @@ adhoc_commands(_Acc, {stop, {error, to_stanza_error(Lang, Reason)}}; _Invite -> ResultFields = - [#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)]}], + 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, @@ -496,95 +525,23 @@ handle_pre_auth_token([El | Els], handle_pre_auth_token(Els, To, From) end. --spec stream_feature_register([xmpp_element()], binary()) -> [xmpp_element()]. +%%-------------------------------------------------------------------- +%%| ibr hooks stream_feature_register(Acc, Host) -> - case mod_invites_opt:access_create_account(Host) of - none -> - Acc; - _ -> - [#feature_register_ibr_token{} | Acc] + case gen_mod:is_loaded(Host, ?MODULE) of + true -> + mod_invites_register:stream_feature_register(Acc, Host); + false -> + Acc end. -c2s_unauthenticated_packet(#{invite := Invite} = State, - #iq{type = get, sub_els = [_]} = IQ) -> - %% User requests registration form after processing token - ?try_subtag(IQ, - #register{}, - fun(Register) -> - #{server := Server} = State, - IQ1 = xmpp:set_els(IQ, [Register]), - User = Invite#invite_token.account_name, - IQ2 = xmpp:set_from_to(IQ1, jid:make(User, Server), jid:make(Server)), - ResIQ = mod_register:process_iq(IQ2), - ResIQ1 = xmpp:set_from_to(ResIQ, jid:make(Server), undefined), - {stop, ejabberd_c2s:send(State, ResIQ1)} - end); -c2s_unauthenticated_packet(#{invite := Invite, server := Server} = State, - #iq{type = set, sub_els = [_]} = IQ) -> - %% Process registration request after processing token - ?try_subtag(IQ, - #register{}, - fun (Register) -> - case check_captcha(mod_register_opt:captcha_protected(Server), Register, IQ) of - {ok, {Username, Password}} -> - Token = Invite#invite_token.token, - #{ip := IP} = State, - {Address, _} = IP, - case try_register(Token, - Username, - Server, - Password, - IQ, - Address) - of - #iq{type = result} = ResIQ -> - set_invitee(Server, - Invite#invite_token.token, - jid:make(Username, Server)), - NewInvite = get_invite(Server, Invite#invite_token.token), - ResState = State#{invite => NewInvite}, - maybe_create_mutual_subscription(NewInvite), - {stop, ejabberd_c2s:send(ResState, ResIQ)}; - #iq{type = error} = ResIQ -> - {stop, ejabberd_c2s:send(State, ResIQ)} - end; - {error, ResIQ} -> - {stop, ejabberd_c2s:send(State, ResIQ)} -end - end); -c2s_unauthenticated_packet(State, #iq{type = set, sub_els = [_]} = IQ) -> - %% Check for preauth token and process it - ?try_subtag(IQ, - #preauth{}, - fun(#preauth{token = Token}) -> - #{server := Server} = State, - IQ1 = xmpp:set_from_to(IQ, jid:make(<<>>), jid:make(Server)), - {ResState, ResIQ} = process_token(State, Token, IQ1), - ResIQ1 = xmpp:set_from_to(ResIQ, jid:make(Server), undefined), - {stop, ejabberd_c2s:send(ResState, ResIQ1)} - end, - fun() -> - ?try_subtag(IQ, - #register{}, - fun (#register{username = User, password = Password}) - when is_binary(User), is_binary(Password) -> - #{server := Server} = State, - case is_reserved(Server, <<>>, User) of - true -> - ResIQ = - make_stripped_error(IQ, - #register{}, - xmpp:err_not_allowed()), - {stop, ejabberd_c2s:send(State, ResIQ)}; - false -> - State - end; - (_) -> - State - end) - end); -c2s_unauthenticated_packet(State, _) -> - State. +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 @@ -715,6 +672,9 @@ token_uri(#invite_token{type = roster_only, Inviter = jid:to_string(jid:make(User, Host)), <<"xmpp:", Inviter/binary, "?roster;preauth=", Token/binary>>. +landing_page(Host, Invite) -> + mod_invites_http:landing_page(Host, Invite). + db_call(Host, Fun, Args) -> Mod = gen_mod:db_mod(Host, ?MODULE), apply(Mod, Fun, Args). @@ -741,6 +701,18 @@ xdata_field(Field, [#xdata_field{var = Field, values = [Result | _]} | _], _Defa 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 -> @@ -787,10 +759,6 @@ reason_to_text(user_exists) -> reason_to_text(num_invites_exceeded) -> "Maximum number of invites reached". -make_stripped_error(IQ, SubTag, Err) -> - xmpp:make_error( - xmpp:remove_subtag(IQ, SubTag), Err). - maybe_gen_sid(<<>>) -> p1_rand:get_alphanum_string(?INVITE_TOKEN_LENGTH_DEFAULT); maybe_gen_sid(SID) -> @@ -808,87 +776,3 @@ send_presence(From, To, Type) -> to = To, type = Type}, ejabberd_router:route(From, To, Presence). - -maybe_create_mutual_subscription(#invite_token{inviter = {User, _Server}, type = Type}) - when User == <<>>; % server token - Type /= account_subscription -> - noop; -maybe_create_mutual_subscription(#invite_token{inviter = {User, Server}, invitee = Invitee}) -> - InviterJID = jid:make(User, Server), - InviteeJID = jid:decode(Invitee), - roster_add(InviterJID, InviteeJID), - roster_add(InviteeJID, InviterJID), - send_presence(InviteeJID, InviterJID, subscribe), - send_presence(InviterJID, InviteeJID, subscribed), - send_presence(InviterJID, InviteeJID, subscribe), - send_presence(InviteeJID, InviterJID, subscribed), - ok. - -process_token(#{server := Host} = State, Token, #iq{lang = Lang} = IQ) -> - ?DEBUG("checking token (~s): ~s", [Host, Token]), - case is_token_valid(Host, Token) of - true -> - Invite = get_invite(Host, Token), - NewState = State#{invite => Invite}, - {NewState, xmpp:make_iq_result(IQ)}; - false -> - Text = ?T("The token provided is either invalid or expired."), - {State, make_stripped_error(IQ, #preauth{}, xmpp:err_item_not_found(Text, Lang))} - end. - -try_register(Token, - User, - Server, - Password, - #iq{lang = Lang} = IQ, - Source) -> - case {jid:nodeprep(User), not is_reserved(Server, Token, User)} of - {error, _} -> - Err = xmpp:err_jid_malformed( - mod_register:format_error(invalid_jid), Lang), - make_stripped_error(IQ, #register{}, Err); - {_, true} -> - case mod_register:try_register(User, Server, Password, Source, ?MODULE, Lang) of - ok -> - xmpp:make_iq_result(IQ); - {error, Error} -> - make_stripped_error(IQ, #register{}, Error) - end - end. - -check_captcha(true, #register{xdata = X}, #iq{lang = Lang} = IQ) -> - XdataC = xmpp_util:set_xdata_field( - #xdata_field{ - var = <<"FORM_TYPE">>, - type = hidden, values = [?NS_CAPTCHA]}, - X), - case ejabberd_captcha:process_reply(XdataC) of - ok -> - case process_xdata_submit(X) of - {ok, _} = Result -> - Result; - _ -> - Txt = ?T("Incorrect data form"), - make_stripped_error(IQ, #register{}, xmpp:err_bad_request(Txt, Lang)) - end; - {error, malformed} -> - Txt = ?T("Incorrect CAPTCHA submit"), - make_stripped_error(IQ, #register{}, xmpp:err_bad_request(Txt, Lang)); - _ -> - ErrText = ?T("The CAPTCHA verification has failed"), - make_stripped_error(IQ, #register{}, xmpp:err_not_allowed(ErrText, Lang)) - end; -check_captcha(false, #register{username = Username, password = Password}, _IQ) - when is_binary(Username), is_binary(Password) -> - {ok, {Username, Password}}; -check_captcha(_IsCaptchaEnabled, _Register, IQ) -> - ResIQ = make_stripped_error(IQ, #register{}, xmpp:err_bad_request()), - {error, ResIQ}. - -process_xdata_submit(X) -> - case {xdata_field(<<"username">>, X, undefined), xdata_field(<<"password">>, X, undefined)} of - {UndefU, UndefP} when UndefU == undefined; UndefP == undefined -> - error; - {Username, Password} -> - {ok, {Username, Password}} - end. diff --git a/src/mod_invites_http.erl b/src/mod_invites_http.erl new file mode 100644 index 00000000000..ca1bf2c6fc7 --- /dev/null +++ b/src/mod_invites_http.erl @@ -0,0 +1,334 @@ +%%%---------------------------------------------------------------------- +%%% File : mod_invites_http.erl +%%% Author : Stefan Strigler +%%% Purpose : Provide web page(s) to sign up using an invite token. +%%% Created : Fri Oct 31 2025 by Stefan Strigler +%%% +%%% +%%% ejabberd, Copyright (C) 2025 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_http). + +-include("logger.hrl"). + +-export([process/2, landing_page/2]). + +-ifdef(TEST). +-export([apps_json/3]). +-endif. + +-include_lib("xmpp/include/xmpp.hrl"). + +-include("ejabberd_http.hrl"). +-include("mod_invites.hrl"). +-include("translate.hrl"). + +-define(HTTP(Code, CT, Text), {Code, [{<<"Content-Type">>, CT}], Text}). +-define(HTTP(Code, Text), ?HTTP(Code, <<"text/plain">>, Text)). +-define(HTTP_OK(Text), ?HTTP(200, <<"text/html">>, Text)). +-define(NOT_FOUND, ?HTTP(404, ?T("NOT FOUND"))). +-define(NOT_FOUND(Text), ?HTTP(404, <<"text/html">>, Text)). +-define(BAD_REQUEST, ?HTTP(400, ?T("BAD REQUEST"))). +-define(BAD_REQUEST(Text), ?HTTP(400, <<"text/html">>, Text)). + +-define(DEFAULT_CONTENT_TYPE, <<"application/octet-stream">>). +-define(CONTENT_TYPES, + [{<<".js">>, <<"application/javascript">>}, + {<<".png">>, <<"image/png">>}, + {<<".svg">>, <<"image/svg+xml">>}]). + +-define(STATIC, <<"static">>). +-define(REGISTRATION, <<"registration">>). +-define(STATIC_CTX, {static, ["/", Base, "/", ?STATIC]}). +-define(SITE_NAME_CTX(Name), {site_name, Name}). + +%% @format-begin + +landing_page(Host, Invite) -> + case mod_invites_opt:landing_page(Host) of + none -> + <<>>; + Tmpl -> + Ctx = [{invite, invite_to_proplist(Invite)}, {host, Host}], + render_url(Tmpl, Ctx) + end. + +-spec process(LocalPath::[binary()], #request{}) -> + {HTTPCode::integer(), [{binary(), binary()}], Page::string()}. +process([?STATIC | StaticFile], #request{host = Host} = Request) -> + ?DEBUG("Static file requested ~p:~n~p", [StaticFile, Request]), + TemplatesDir = mod_invites_opt:templates_dir(Host), + Filename = filename:join([TemplatesDir, "static" | StaticFile]), + case file:read_file(Filename) of + {ok, Content} -> + CT = guess_content_type(Filename), + ?HTTP(200, CT, Content); + {error, _} -> + ?NOT_FOUND + end; +process([Token | _] = LocalPath, #request{host = Host, lang = Lang} = Request) -> + ?DEBUG("Requested:~n~p", [Request]), + case mod_invites:is_token_valid(Host, Token) of + true -> + case mod_invites:get_invite(Host, Token) of + #invite_token{type = 'roster_only'} = Invite -> + process_roster_token(LocalPath, Request, Invite); + Invite -> + process_valid_token(LocalPath, Request, Invite) + end; + false -> + ?NOT_FOUND(render(Host, Lang, <<"invite_invalid.html">>, ctx(Request))) + end; +process([], _Request) -> + ?NOT_FOUND. + +process_valid_token([_Token, AppID, ?REGISTRATION], #request{method = 'POST'} = Request, Invite) -> + process_register_post(Invite, AppID, Request); +process_valid_token([_Token, AppID, ?REGISTRATION], Request, Invite) -> + process_register_form(Invite, AppID, Request); +process_valid_token([_Token, ?REGISTRATION], #request{method = 'POST'} = Request, Invite) -> + process_register_post(Invite, <<>>, Request); +process_valid_token([_Token, ?REGISTRATION], Request, Invite) -> + process_register_form(Invite, <<>>, Request); +process_valid_token([_Token, AppID], #request{host = Host, lang = Lang} = Request, Invite) -> + try app_ctx(Host, AppID, Lang, ctx(Invite, Request)) of + AppCtx -> + render_ok(Host, Lang, <<"client.html">>, AppCtx) + catch + _:not_found -> + ?NOT_FOUND + end; +process_valid_token([_Token], #request{host = Host, lang = Lang} = Request, Invite) -> + Ctx0 = ctx(Invite, Request), + Apps = lists:map(fun(App0) -> + App = app_id(App0), + render_app_urls(App, [{app, App} | Ctx0]) + end, apps_json(Host, Lang, Ctx0)), + Ctx = [{apps, Apps} | Ctx0], + render_ok(Host, Lang, <<"invite.html">>, Ctx); +process_valid_token(_, _, _) -> + ?NOT_FOUND. + +process_register_form(Invite, AppID, #request{host = Host, lang = Lang} = Request) -> + try app_ctx(Host, AppID, Lang, ctx(Invite, Request)) of + AppCtx -> + Body = render_register_form(Request, AppCtx, maybe_add_username(Invite)), + ?HTTP_OK(Body) + catch + _:not_found -> + ?NOT_FOUND + end. + +render_register_form(#request{host = Host, lang = Lang}, Ctx, AdditionalCtx) -> + render(Host, Lang, <<"register.html">>, Ctx ++ AdditionalCtx). + +process_register_post(Invite, AppID, #request{host = Host, q = Q, lang = Lang, ip = {Source, _}} = Request) -> + ?DEBUG("got query: ~p", [Q]), + Username = proplists:get_value(<<"user">>, Q), + Password = proplists:get_value(<<"password">>, Q), + Token = Invite#invite_token.token, + try {app_ctx(Host, AppID, Lang, ctx(Invite, Request)), + ensure_same(Token, proplists:get_value(<<"token">>, Q))} of + {AppCtx, ok} -> + case mod_register:try_register(Username, Host, Password, Source, mod_invites, Lang) of + ok -> + InviteeJid = jid:make(Username, Host), + mod_invites:set_invitee(Host, Token, InviteeJid), + UpdatedInvite = mod_invites:get_invite(Host, Token), + mod_invites_register:maybe_create_mutual_subscription(UpdatedInvite), + Ctx = [{username, Username}, + {password, Password} + | AppCtx], + render_ok(Host, Lang, <<"register_success.html">>, Ctx); + {error, #stanza_error{text = Text, type = Type} = Error} -> + ?DEBUG("registration failed with error: ~p", [Error]), + Msg = xmpp:get_text(Text, xmpp:prep_lang(Lang)), + case Type of + T when T == 'cancel'; T == 'modify' -> + Body = render_register_form(Request, AppCtx, + [{username, Username}, + {message, [{text, Msg}, + {class, <<"alert-warning">>}]}]), + ?BAD_REQUEST(Body); + _ -> + render_bad_request(Host, <<"register_error.html">>, [{message, Msg} | ctx(Request)]) + end + end + catch + _:not_found -> + ?NOT_FOUND; + _:no_match -> + ?BAD_REQUEST + end. + +process_roster_token([_Token], #request{host = Host, lang = Lang} = Request, Invite) -> + Ctx0 = ctx(Invite, Request), + Apps = lists:map( + fun(App = #{<<"download">> := #{<<"buttons">> := [Button | _]}}) -> + ProceedUrl = case render_app_button_url(Button, Ctx0) of + #{magic_link := MagicLink} -> + MagicLink; + #{<<"url">> := Url} -> + Url + end, + App#{proceed_url => ProceedUrl, + select_text => translate:translate(Lang, ?T("Install"))} + end, apps_json(Host, Lang, Ctx0)), + Ctx = [{apps, Apps} | Ctx0], + render_ok(Host, Lang, <<"roster.html">>, Ctx); +process_roster_token(_, _, _) -> + ?NOT_FOUND. + +ensure_same(V, V) -> + ok; +ensure_same(_, _) -> + throw(no_match). + +app_ctx(_Host, <<>>, _Lang, Ctx) -> + Ctx; +app_ctx(Host, AppID, Lang, Ctx) -> + FilteredApps = [App || A <- apps_json(Host, Lang, Ctx), maps:get(<<"id">>, App = app_id(A)) == AppID], + case FilteredApps of + [App] -> + [{app, render_app_button_urls(App, Ctx)} | Ctx]; + [] -> + throw(not_found) + end. + +ctx(#request{host = Host, path = [Base | _]}) -> + SiteName = mod_invites_opt:site_name(Host), + [?STATIC_CTX, ?SITE_NAME_CTX(SiteName)]. + +ctx(Invite, #request{host = Host} = Request) -> + [{invite, invite_to_proplist(Invite)}, + {uri, mod_invites:token_uri(Invite)}, + {domain, Host}, + {token, Invite#invite_token.token}, + {registration_url, <<(Invite#invite_token.token)/binary, "/", ?REGISTRATION/binary>>} + | ctx(Request)]. + +apps_json(Host, Lang, Ctx) -> + AppsBins = render(Host, Lang, <<"apps.json">>, Ctx), + AppsBin = lists:foldr(fun([], B) -> B; (A, B) -> <> end, <<>>, AppsBins), + misc:json_decode(AppsBin). + +app_id(App = #{<<"id">> := _ID}) -> + App; +app_id(App = #{<<"name">> := Name}) -> + App#{<<"id">> => re:replace(Name, "[^a-zA-Z0-9]+", "-", [global, {return, binary}])}. + +invite_to_proplist(I) -> + [{uri, mod_invites:token_uri(I)} + | lists:zip(record_info(fields, invite_token), tl(tuple_to_list(I)))]. + +render_url(Tmpl, Vars) -> + Renderer = tmpl_to_renderer(Tmpl), + {ok, URL} = Renderer:render(Vars), + binary_join(URL, <<>>). + +render_app_urls(App = #{<<"supports_preauth_uri">> := true}, Vars) -> + App#{proceed_url => render_url(<<"{{ invite.token }}/{{ app.id }}">>, Vars)}; +render_app_urls(App, Vars) -> + App#{proceed_url => render_url(<<"{{ invite.token }}/{{ app.id }}/", ?REGISTRATION/binary >>, Vars)}. + +render_app_button_urls(App = #{<<"download">> := #{<<"buttons">> := Buttons}}, Vars) -> + App#{<<"download">> => #{<<"buttons">> => lists:map(fun(Button) -> render_app_button_url(Button, [{button, Button} | Vars]) end, Buttons)}}; +render_app_button_urls(App, _Vars) -> + App. + +render_app_button_url(Button = #{<<"magic_link_format">> := MLF}, Vars) -> + Button#{magic_link => render_url(MLF, Vars)}; +render_app_button_url(Button, _Vars) -> + Button. + +file_to_renderer(Host, Filename) -> + ModName = binary_to_atom(<<"mod_invites_template__", Host/binary, "__", Filename/binary>>), + TemplatesDir = mod_invites_opt:templates_dir(Host), + TemplatePath = binary_to_list(filename:join([TemplatesDir, Filename])), + {ok, _Mod, Warnings} = erlydtl:compile_file(TemplatePath, ModName, + [{out_dir, false}, + return, + {libraries, + [{mod_invites_http_erlylib, mod_invites_http_erlylib}]}, + {default_libraries, [mod_invites_http_erlylib]}]), + ?DEBUG("got warnings: ~p", [Warnings]), + ModName. + +tmpl_to_renderer(Tmpl) -> + ModName = binary_to_atom(<<"mod_invites_template__", Tmpl/binary>>), + case erlang:function_exported(ModName, render, 1) of + true -> + ModName; + false -> + {ok, _Mod} = erlydtl:compile_template(Tmpl, ModName, [{out_dir, false}, + {libraries, + [{mod_invites_http_erlylib, mod_invites_http_erlylib}]}, + {default_libraries, [mod_invites_http_erlylib]}]), + ModName + end. + +render(Host, Lang, File, Ctx) -> + Renderer = file_to_renderer(Host, File), + {ok, Rendered} = + Renderer:render( + Ctx, + [{locale, Lang}, + {translation_fun, + fun(Msg, TFLang) -> + translate:translate(lang(TFLang), list_to_binary(Msg)) + end}]), + Rendered. + +lang(default) -> + <<"en">>; +lang(Lang) -> + Lang. + +render_ok(Host, Lang, File, Ctx) -> + ?HTTP_OK(render(Host, Lang, File, Ctx)). + +render_bad_request(Host, File, Ctx) -> + Renderer = file_to_renderer(Host, File), + {ok, Rendered} = Renderer:render(Ctx), + ?BAD_REQUEST(Rendered). + +-spec guess_content_type(binary()) -> binary(). +guess_content_type(FileName) -> + mod_http_fileserver:content_type(FileName, + ?DEFAULT_CONTENT_TYPE, + ?CONTENT_TYPES). + +maybe_add_username(#invite_token{account_name = <<>>}) -> + []; +maybe_add_username(#invite_token{account_name = AccountName}) -> + [{username, AccountName}]. + +-spec binary_join(binary() | [binary()], binary()) -> binary(). +binary_join(Bin, _Sep) when is_binary(Bin) -> + Bin; +binary_join([], _Sep) -> + <<>>; +binary_join([Part], _Sep) -> + Part; +binary_join(List, Sep) -> + lists:foldr(fun (A, B) -> + if + bit_size(B) > 0 -> <>; + true -> A + end + end, <<>>, List). diff --git a/src/mod_invites_http_erlylib.erl b/src/mod_invites_http_erlylib.erl new file mode 100644 index 00000000000..b62c0b1fbd4 --- /dev/null +++ b/src/mod_invites_http_erlylib.erl @@ -0,0 +1,49 @@ +%%%---------------------------------------------------------------------- +%%% File : mod_invites_http_erlylib.erl +%%% Author : Stefan Strigler +%%% Purpose : Elydtl custom tags and filters +%%% Created : Mon Nov 10 2025 by Stefan Strigler +%%% +%%% +%%% ejabberd, Copyright (C) 2025 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_http_erlylib). + +-behaviour(erlydtl_library). + +-export([version/0, inventory/1]). +-export([jid/1, user/1, strip_protocol/1]). + +-include("logger.hrl"). + +version() -> + 1. + +inventory(tags) -> + []; +inventory(filters) -> + [{jid, jid}, {user, user}, {token_uri, {mod_invites, token_uri}}, {strip_protocol, strip_protocol}]. + +jid({User, Server}) -> + jid:to_string(jid:make(User, Server)). + +strip_protocol(Uri) -> + re:replace(Uri, <<"xmpp:">>, <<>>, [{return, binary}]). + +user({User, _Server}) -> + User. diff --git a/src/mod_invites_mnesia.erl b/src/mod_invites_mnesia.erl index fbebfc46c93..0ba92c82c0b 100644 --- a/src/mod_invites_mnesia.erl +++ b/src/mod_invites_mnesia.erl @@ -77,7 +77,7 @@ init(_Host, _Opts) -> invite_token, [{disc_copies, [node()]}, {attributes, record_info(fields, invite_token)}, - {index, [#invite_token.inviter]}]). + {index, [inviter]}]). is_reserved(_Host, Token, User) -> Ts = erlang:timestamp(), diff --git a/src/mod_invites_opt.erl b/src/mod_invites_opt.erl index fbfd13451aa..81043356632 100644 --- a/src/mod_invites_opt.erl +++ b/src/mod_invites_opt.erl @@ -5,7 +5,10 @@ -export([access_create_account/1]). -export([db_type/1]). +-export([landing_page/1]). -export([max_invites/1]). +-export([site_name/1]). +-export([templates_dir/1]). -export([token_expire_seconds/1]). -spec access_create_account(gen_mod:opts() | global | binary()) -> 'none' | acl:acl(). @@ -20,12 +23,30 @@ db_type(Opts) when is_map(Opts) -> db_type(Host) -> gen_mod:get_module_opt(Host, mod_invites, db_type). +-spec landing_page(gen_mod:opts() | global | binary()) -> 'none' | binary(). +landing_page(Opts) when is_map(Opts) -> + gen_mod:get_opt(landing_page, Opts); +landing_page(Host) -> + gen_mod:get_module_opt(Host, mod_invites, landing_page). + -spec max_invites(gen_mod:opts() | global | binary()) -> 'infinity' | pos_integer(). max_invites(Opts) when is_map(Opts) -> gen_mod:get_opt(max_invites, Opts); max_invites(Host) -> gen_mod:get_module_opt(Host, mod_invites, max_invites). +-spec site_name(gen_mod:opts() | global | binary()) -> binary(). +site_name(Opts) when is_map(Opts) -> + gen_mod:get_opt(site_name, Opts); +site_name(Host) -> + gen_mod:get_module_opt(Host, mod_invites, site_name). + +-spec templates_dir(gen_mod:opts() | global | binary()) -> binary(). +templates_dir(Opts) when is_map(Opts) -> + gen_mod:get_opt(templates_dir, Opts); +templates_dir(Host) -> + gen_mod:get_module_opt(Host, mod_invites, templates_dir). + -spec token_expire_seconds(gen_mod:opts() | global | binary()) -> pos_integer(). token_expire_seconds(Opts) when is_map(Opts) -> gen_mod:get_opt(token_expire_seconds, Opts); diff --git a/src/mod_invites_register.erl b/src/mod_invites_register.erl new file mode 100644 index 00000000000..1f2815261ae --- /dev/null +++ b/src/mod_invites_register.erl @@ -0,0 +1,234 @@ +%%%---------------------------------------------------------------------- +%%% File : mod_invites_register.erl +%%% Author : Stefan Strigler +%%% Purpose : Provide web page(s) to sign up using an invite token. +%%% Created : Fri Oct 31 2025 by Stefan Strigler +%%% +%%% +%%% ejabberd, Copyright (C) 2025 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_register). + +-export([c2s_unauthenticated_packet/2, stream_feature_register/2]). +-export([maybe_create_mutual_subscription/1]). + +-import(mod_invites, [roster_add/2, send_presence/3, xdata_field/3]). +-include("logger.hrl"). + +-include_lib("xmpp/include/xmpp.hrl"). + +-include("ejabberd_commands.hrl"). +-include("mod_invites.hrl"). +-include("translate.hrl"). + +-define(TRY_SUBTAG(IQ, SUBTAG, F, Else), + try xmpp:try_subtag(IQ, SUBTAG) of + false -> + Else(); + SubTag -> + F(SubTag) + catch _:{xmpp_codec, Why} -> + Txt = xmpp:io_format_error(Why), + Lang = maps:get(lang, State), + Err = make_stripped_error(IQ, SUBTAG, xmpp:err_bad_request(Txt, Lang)), + {stop, ejabberd_c2s:send(State, Err)} + end). +-define(TRY_SUBTAG(IQ, SUBTAG, F), ?TRY_SUBTAG(IQ, SUBTAG, F, fun() -> State end)). + +-spec stream_feature_register([xmpp_element()], binary()) -> [xmpp_element()]. +stream_feature_register(Acc, Host) -> + case mod_invites_opt:access_create_account(Host) of + none -> + Acc; + _ -> + [#feature_register_ibr_token{} | Acc] + end. + +c2s_unauthenticated_packet(#{invite := Invite} = State, + #iq{type = get, sub_els = [_]} = IQ) -> + %% User requests registration form after processing token + ?TRY_SUBTAG(IQ, + #register{}, + fun(Register) -> + #{server := Server} = State, + IQ1 = xmpp:set_els(IQ, [Register]), + User = Invite#invite_token.account_name, + IQ2 = xmpp:set_from_to(IQ1, jid:make(User, Server), jid:make(Server)), + ResIQ = mod_register:process_iq(IQ2), + ResIQ1 = xmpp:set_from_to(ResIQ, jid:make(Server), undefined), + {stop, ejabberd_c2s:send(State, ResIQ1)} + end); +c2s_unauthenticated_packet(#{invite := Invite, server := Server} = State, + #iq{type = set, sub_els = [_]} = IQ) -> + %% Process registration request after processing token + ?TRY_SUBTAG(IQ, + #register{}, + fun (Register) -> + case check_captcha(mod_register_opt:captcha_protected(Server), Register, IQ) of + {ok, {Username, Password}} -> + Token = Invite#invite_token.token, + #{ip := IP} = State, + {Address, _} = IP, + case try_register(Token, + Username, + Server, + Password, + IQ, + Address) + of + #iq{type = result} = ResIQ -> + mod_invites:set_invitee(Server, + Invite#invite_token.token, + jid:make(Username, Server)), + NewInvite = mod_invites:get_invite(Server, Invite#invite_token.token), + ResState = State#{invite => NewInvite}, + maybe_create_mutual_subscription(NewInvite), + {stop, ejabberd_c2s:send(ResState, ResIQ)}; + #iq{type = error} = ResIQ -> + {stop, ejabberd_c2s:send(State, ResIQ)} + end; + {error, ResIQ} -> + {stop, ejabberd_c2s:send(State, ResIQ)} +end + end); +c2s_unauthenticated_packet(State, #iq{type = set, sub_els = [_]} = IQ) -> + %% Check for preauth token and process it + ?TRY_SUBTAG(IQ, + #preauth{}, + fun(#preauth{token = Token}) -> + #{server := Server} = State, + IQ1 = xmpp:set_from_to(IQ, jid:make(<<>>), jid:make(Server)), + {ResState, ResIQ} = process_token(State, Token, IQ1), + ResIQ1 = xmpp:set_from_to(ResIQ, jid:make(Server), undefined), + {stop, ejabberd_c2s:send(ResState, ResIQ1)} + end, + fun() -> + ?TRY_SUBTAG(IQ, + #register{}, + fun (#register{username = User, password = Password}) + when is_binary(User), is_binary(Password) -> + #{server := Server} = State, + case mod_invites:is_reserved(Server, <<>>, User) of + true -> + ResIQ = + make_stripped_error(IQ, + #register{}, + xmpp:err_not_allowed()), + {stop, ejabberd_c2s:send(State, ResIQ)}; + false -> + State + end; + (_) -> + State + end) + end); +c2s_unauthenticated_packet(State, _) -> + State. + +make_stripped_error(IQ, SubTag, Err) -> + xmpp:make_error( + xmpp:remove_subtag(IQ, SubTag), Err). + +maybe_create_mutual_subscription(#invite_token{inviter = {User, _Server}, type = Type}) + when User == <<>>; % server token + Type /= account_subscription -> + noop; +maybe_create_mutual_subscription(#invite_token{inviter = {User, Server}, invitee = Invitee}) -> + InviterJID = jid:make(User, Server), + InviteeJID = jid:decode(Invitee), + roster_add(InviterJID, InviteeJID), + roster_add(InviteeJID, InviterJID), + send_presence(InviteeJID, InviterJID, subscribe), + send_presence(InviterJID, InviteeJID, subscribed), + send_presence(InviterJID, InviteeJID, subscribe), + send_presence(InviteeJID, InviterJID, subscribed), + ok. + +process_token(#{server := Host} = State, Token, #iq{lang = Lang} = IQ) -> + ?DEBUG("checking token (~s): ~s", [Host, Token]), + case mod_invites:is_token_valid(Host, Token) of + true -> + case mod_invites:get_invite(Host, Token) of + #invite_token{type = 'roster_only'} -> + Text = ?T("The token provided is either invalid or expired."), + {State, make_stripped_error(IQ, #preauth{}, xmpp:err_item_not_found(Text, Lang))}; + Invite -> + NewState = State#{invite => Invite}, + {NewState, xmpp:make_iq_result(IQ)} + end; + false -> + Text = ?T("The token provided is either invalid or expired."), + {State, make_stripped_error(IQ, #preauth{}, xmpp:err_item_not_found(Text, Lang))} + end. + +try_register(Token, + User, + Server, + Password, + #iq{lang = Lang} = IQ, + Source) -> + case {jid:nodeprep(User), not mod_invites:is_reserved(Server, Token, User)} of + {error, _} -> + Err = xmpp:err_jid_malformed( + mod_register:format_error(invalid_jid), Lang), + make_stripped_error(IQ, #register{}, Err); + {_, true} -> + case mod_register:try_register(User, Server, Password, Source, mod_invites, Lang) of + ok -> + xmpp:make_iq_result(IQ); + {error, Error} -> + make_stripped_error(IQ, #register{}, Error) + end + end. + +check_captcha(true, #register{xdata = X}, #iq{lang = Lang} = IQ) -> + XdataC = xmpp_util:set_xdata_field( + #xdata_field{ + var = <<"FORM_TYPE">>, + type = hidden, values = [?NS_CAPTCHA]}, + X), + case ejabberd_captcha:process_reply(XdataC) of + ok -> + case process_xdata_submit(X) of + {ok, _} = Result -> + Result; + _ -> + Txt = ?T("Incorrect data form"), + make_stripped_error(IQ, #register{}, xmpp:err_bad_request(Txt, Lang)) + end; + {error, malformed} -> + Txt = ?T("Incorrect CAPTCHA submit"), + make_stripped_error(IQ, #register{}, xmpp:err_bad_request(Txt, Lang)); + _ -> + ErrText = ?T("The CAPTCHA verification has failed"), + make_stripped_error(IQ, #register{}, xmpp:err_not_allowed(ErrText, Lang)) + end; +check_captcha(false, #register{username = Username, password = Password}, _IQ) + when is_binary(Username), is_binary(Password) -> + {ok, {Username, Password}}; +check_captcha(_IsCaptchaEnabled, _Register, IQ) -> + ResIQ = make_stripped_error(IQ, #register{}, xmpp:err_bad_request()), + {error, ResIQ}. + +process_xdata_submit(X) -> + case {mod_invites:xdata_field(<<"username">>, X, undefined), mod_invites:xdata_field(<<"password">>, X, undefined)} of + {UndefU, UndefP} when UndefU == undefined; UndefP == undefined -> + error; + {Username, Password} -> + {ok, {Username, Password}} + end. diff --git a/test/ejabberd_SUITE_data/ejabberd.mnesia.yml b/test/ejabberd_SUITE_data/ejabberd.mnesia.yml index c2b9e9ec471..f01934fb14d 100644 --- a/test/ejabberd_SUITE_data/ejabberd.mnesia.yml +++ b/test/ejabberd_SUITE_data/ejabberd.mnesia.yml @@ -19,6 +19,7 @@ define_macro: db_type: internal mod_invites: db_type: internal + landing_page: "http://@HOST@:5280/invites/{{ invite.token }}" # fixme mod_last: db_type: internal mod_muc: diff --git a/test/ejabberd_SUITE_data/ejabberd.yml b/test/ejabberd_SUITE_data/ejabberd.yml index b560df2c94a..71e7f8e6862 100644 --- a/test/ejabberd_SUITE_data/ejabberd.yml +++ b/test/ejabberd_SUITE_data/ejabberd.yml @@ -98,6 +98,7 @@ listen: "/api": mod_http_api "/upload": mod_http_upload "/captcha": ejabberd_captcha + "/invites": mod_invites - port: STUN_PORT module: ejabberd_stun diff --git a/test/invites_tests.erl b/test/invites_tests.erl index 4a553b81c73..a01d31db92b 100644 --- a/test/invites_tests.erl +++ b/test/invites_tests.erl @@ -65,23 +65,24 @@ single_cases() -> single_test(stream_feature), single_test(ibr), single_test(ibr_reserved), - single_test(ibr_subscription)]}. + single_test(ibr_subscription), + single_test(http)]}. %%%=================================================================== gen_invite(Config) -> Server = ?config(server, Config), User = ?config(user, Config), - Res = mod_invites:gen_invite(<<"foo">>, Server), - ?match(<<"xmpp:", _/binary>>, Res), - Token = token_from_uri(Res), + {TokenURI, _LandingPage} = mod_invites:gen_invite(<<"foo">>, Server), + ?match(<<"xmpp:foo@", Server:(size(Server))/binary, "?register;preauth=", _/binary>>, TokenURI), + Token = token_from_uri(TokenURI), #invite_token{inviter = {<<>>, Server}, type = account_only, account_name = <<"foo">>} = mod_invites:get_invite(Server, Token), - Res2 = mod_invites:gen_invite(Server), - ?match(<<"xmpp:", _/binary>>, Res), - Token2 = token_from_uri(Res2), + {TokenURI2, _LP2} = mod_invites:gen_invite(Server), + ?match(<<"xmpp:", _/binary>>, TokenURI2), + Token2 = token_from_uri(TokenURI2), #invite_token{inviter = {<<>>, Server}, type = account_only, account_name = <<>>} = @@ -98,7 +99,7 @@ cleanup_expired(Config) -> Server = ?config(server, Config), create_account_invite(Server, {<<"foo">>, Server}), mod_invites:expire_tokens(<<"foo">>, Server), - Token = token_from_uri(mod_invites:gen_invite(<<"foobar">>, Server)), + Token = token_from_uri(element(1, mod_invites:gen_invite(<<"foobar">>, Server))), ?match(1, mod_invites:cleanup_expired()), ?match(#invite_token{}, mod_invites:get_invite(Server, Token)), ?match(0, mod_invites:cleanup_expired()), @@ -209,8 +210,8 @@ adhoc_command_create_account(Config) -> token_valid(Config) -> Server = ?config(server, Config), User = ?config(user, Config), - Res = mod_invites:gen_invite(<<"foobar">>, Server), - Token = token_from_uri(Res), + {TokenURI, _LandingPage} = mod_invites:gen_invite(<<"foobar">>, Server), + Token = token_from_uri(TokenURI), ?match(true, mod_invites:is_token_valid(Server, Token)), Inviter = {<<"foo">>, Server}, #invite_token{token = AccountToken} = @@ -330,6 +331,10 @@ ibr(Config0) -> ?match(#iq{type = error}, send_pars(Config1, <<"bad_token">>)), + #invite_token{token = RosterToken} = + mod_invites:create_roster_invite(Server, {<<"inviter">>, Server}), + ?match(#iq{type = error}, send_pars(Config1, RosterToken)), + #invite_token{token = Token} = mod_invites:create_account_invite(Server, {<<>>, Server}, AccountName, false), ?match(#iq{type = result}, send_pars(Config1, Token)), @@ -453,6 +458,52 @@ receive_subscription_stanzas(Count, Elements, ServerJID, UserFullJID, NewAccount end, receive_subscription_stanzas(Count - 1, Res, ServerJID, UserFullJID, NewAccountFullJID). +http(Config) -> + Server = ?config(server, Config), + User = ?config(user, Config), + {TokenURI, LandingPage} = mod_invites:gen_invite(Server), + Token = token_from_uri(TokenURI), + {ok, {{_, 200, _}, _Headers, Body}} = httpc:request(LandingPage), + {match, RegistrationURLs} = re:run(Body, <<"href=\"", Token/binary, "([a-zA-Z0-9\/\-]+)\"">>, [global, {capture, [1], binary}]), + Apps = mod_invites_http:apps_json(Server, <<"en">>, []), + ?match(true, length(RegistrationURLs) == length(Apps) + 1), + BaseURL = mod_invites_http:landing_page(Server, mod_invites:get_invite(Server, Token)), + lists:foreach( + fun([URL]) -> + FullURL = <>, + ct:pal("Checking url ~p", [FullURL]), + ?match({ok, {{_, 200, _}, _, _}}, + httpc:request(FullURL) + ) + end, RegistrationURLs), + + {ok, {{_, 404, _}, _, _}} = httpc:request(<>), + {ok, {{_, 404, _}, _, _}} = httpc:request(<>), + {ok, {{_, 404, _}, _, _}} = httpc:request(<>), + + [Last] = hd(lists:reverse(RegistrationURLs)), + RegURL = <>, + {ok, {{_, 400, _}, _, _}} = post(RegURL, <<"badtoken">>, <<"foo">>, <<"bar">>), + {ok, {{_, 400, _}, _, _}} = post(RegURL, Token, User, <<"bar">>), + {ok, {{_, 400, _}, _, _}} = post(RegURL, Token, <<"@invalidUser">>, <<"bar">>), + {ok, {{_, 200, _}, _, _}} = post(RegURL, Token, <<"foo">>, <<"bar">>), + {ok, {{_, 404, _}, _, _}} = post(RegURL, Token, <<"foo">>, <<"bar">>), + {ok, {{_, 404, _}, _, _}} = httpc:request(LandingPage), + lists:foreach( + fun([URL]) -> + FullURL = <>, + ct:pal("Checking url ~p", [FullURL]), + ?match({ok, {{_, 404, _}, _, _}}, + httpc:request(FullURL) + ) + end, RegistrationURLs), + RosterInvite = #invite_token{token = RosterToken} = mod_invites:create_roster_invite(Server, {<<"inviter">>, Server}), + RosterURL = mod_invites_http:landing_page(Server, RosterInvite), + {ok, {{_, 200, _}, _, _}} = httpc:request(RosterURL), + FakeRegURL = <>, + {ok, {{_, 404, _}, _, _}} = post(FakeRegURL, RosterToken, <<"baz">>, <<"bar">>), + ok. + %%%=================================================================== %%% Internal functions %%%=================================================================== @@ -569,3 +620,7 @@ send_pars(Config, Token) -> #iq{type = set, to = ServerJID, sub_els = [#preauth{token = Token}]}). + +post(URL, Token, User, Password) -> + Data = <<"token=", Token/binary, "&user=", User/binary, "&password=", Password/binary>>, + httpc:request(post, {URL, [], "application/x-www-form-urlencoded", Data}, [], []). diff --git a/tools/extract-erlydtl-templates.sh b/tools/extract-erlydtl-templates.sh new file mode 100755 index 00000000000..e348ba14bd3 --- /dev/null +++ b/tools/extract-erlydtl-templates.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env escript +%% -*- erlang -*- +%%! -pz _build/default/lib/erlydtl/ebin + +main([Pattern, OutFile]) -> + Phrases = sources_parser:parse_pattern([Pattern]), + Msgs = lists:foldl( + fun(Phrase, M) -> + [MsgId, File, Line] = sources_parser:phrase_info([msgid, file, line], Phrase), + L = maps:get(MsgId, M, []), + M#{MsgId => [{File, Line} | L]} + end, #{}, Phrases), + {ok, Fd} = file:open(OutFile, [write]), + maps:foreach( + fun(MsgId, Places) -> + lists:foreach( + fun({File, Line}) -> + file:write(Fd, io_lib:format("#: ~s:~p~n", [File, Line])) + end, lists:reverse(Places)), + file:write(Fd, io_lib:format("msgid ~p~nmsgstr \"\"~n~n", [MsgId])) + end, Msgs), + file:close(Fd). diff --git a/tools/prepare-tr.sh b/tools/prepare-tr.sh index 3c5596189a8..ae89e3ca541 100755 --- a/tools/prepare-tr.sh +++ b/tools/prepare-tr.sh @@ -11,6 +11,9 @@ extract_lang_src2pot () { ./tools/extract-tr.sh src $DEPS_DIR/xmpp/src > $PO_DIR/ejabberd.pot + ./tools/extract-erlydtl-templates.sh "priv/mod_invites/*.*" $PO_DIR/templates.pot + msgcat $PO_DIR/ejabberd.pot $PO_DIR/templates.pot > $PO_DIR/temp.pot + mv $PO_DIR/temp.pot $PO_DIR/ejabberd.pot } extract_lang_popot2po () @@ -55,7 +58,7 @@ extract_lang_po2msg () echo "%% https://docs.ejabberd.im/developer/extending-ejabberd/localization/" echo "" } >>$MSGS_PATH - paste $MSGID_PATH $MSGSTR_PATH --delimiter=, | awk '{print "{" $0 "}."}' | sort -g >>$MSGS_PATH + paste -d , $MSGID_PATH $MSGSTR_PATH | awk '{print "{" $0 "}."}' | sort -g >>$MSGS_PATH rm $MS_PATH rm $MSGID_PATH From d930f2d903ba81561c2488884b84ab83383b4dea Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Thu, 13 Nov 2025 13:54:00 +0100 Subject: [PATCH 04/27] allow roster invite tokens to create account only if inviter is allowed to issue create account invites always return create account uri with ";register" suffix --- src/mod_invites.erl | 26 ++++++++++++++++---------- src/mod_invites_http.erl | 5 ++++- src/mod_invites_mnesia.erl | 2 +- src/mod_invites_register.erl | 30 +++++++++++++++++++++--------- src/mod_invites_sql.erl | 12 +++++++++++- test/invites_tests.erl | 34 +++++++++++++++++++++++----------- 6 files changed, 76 insertions(+), 33 deletions(-) diff --git a/src/mod_invites.erl b/src/mod_invites.erl index 2234c77e6c9..d4e330189fc 100644 --- a/src/mod_invites.erl +++ b/src/mod_invites.erl @@ -34,8 +34,8 @@ -export([depends/2, mod_doc/0, mod_options/1, mod_opt_type/1, reload/3, start/2, stop/1]). -export([adhoc_commands/4, adhoc_items/4, c2s_unauthenticated_packet/2, cleanup_expired/0, - expire_tokens/2, gen_invite/1, gen_invite/2, get_invite/2, is_reserved/3, - is_token_valid/2, list_invites/1, remove_user/2, roster_add/2, + create_account_allowed/2, expire_tokens/2, gen_invite/1, gen_invite/2, get_invite/2, + is_reserved/3, is_token_valid/2, list_invites/1, remove_user/2, roster_add/2, s2s_receive_packet/1, send_presence/3, set_invitee/3, sm_receive_packet/1, stream_feature_register/2, token_uri/1, xdata_field/3]). @@ -649,10 +649,12 @@ invite_token(Type, Host, Inviter, AccountName0) -> account_name = AccountName}, mod_invites_opt:token_expire_seconds(Host)). -token_uri(#invite_token{type = account_only, +token_uri(#invite_token{type = Type, token = Token, account_name = AccountName, - inviter = {_User, Host}}) -> + inviter = {_User, Host}}) + when Type =:= account_only; + Type =:= account_subscription -> Invitee = case AccountName of <<>> -> @@ -661,16 +663,20 @@ token_uri(#invite_token{type = account_only, <> end, <<"xmpp:", Invitee/binary, "?register;preauth=", Token/binary>>; -token_uri(#invite_token{type = account_subscription, - token = Token, - inviter = {User, Host}}) -> - Inviter = jid:to_string(jid:make(User, Host)), - <<"xmpp:", Inviter/binary, "?roster;preauth=", Token/binary, ";ibr=y">>; token_uri(#invite_token{type = roster_only, token = Token, inviter = {User, Host}}) -> + IBR = maybe_add_ibr_allowed(User, Host), Inviter = jid:to_string(jid:make(User, Host)), - <<"xmpp:", Inviter/binary, "?roster;preauth=", Token/binary>>. + <<"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). diff --git a/src/mod_invites_http.erl b/src/mod_invites_http.erl index ca1bf2c6fc7..b064f3fcdeb 100644 --- a/src/mod_invites_http.erl +++ b/src/mod_invites_http.erl @@ -83,7 +83,7 @@ process([?STATIC | StaticFile], #request{host = Host} = Request) -> end; process([Token | _] = LocalPath, #request{host = Host, lang = Lang} = Request) -> ?DEBUG("Requested:~n~p", [Request]), - case mod_invites:is_token_valid(Host, Token) of + try mod_invites:is_token_valid(Host, Token) of true -> case mod_invites:get_invite(Host, Token) of #invite_token{type = 'roster_only'} = Invite -> @@ -93,6 +93,9 @@ process([Token | _] = LocalPath, #request{host = Host, lang = Lang} = Request) - end; false -> ?NOT_FOUND(render(Host, Lang, <<"invite_invalid.html">>, ctx(Request))) + catch + _:not_found -> + ?NOT_FOUND end; process([], _Request) -> ?NOT_FOUND. diff --git a/src/mod_invites_mnesia.erl b/src/mod_invites_mnesia.erl index 0ba92c82c0b..8d3721e1d6e 100644 --- a/src/mod_invites_mnesia.erl +++ b/src/mod_invites_mnesia.erl @@ -97,7 +97,7 @@ is_token_valid(Host, Token, Scope) -> [#invite_token{}] -> false; [] -> - false + throw(not_found) end. list_invites(Host) -> diff --git a/src/mod_invites_register.erl b/src/mod_invites_register.erl index 1f2815261ae..d3e90709279 100644 --- a/src/mod_invites_register.erl +++ b/src/mod_invites_register.erl @@ -161,21 +161,33 @@ maybe_create_mutual_subscription(#invite_token{inviter = {User, Server}, invitee process_token(#{server := Host} = State, Token, #iq{lang = Lang} = IQ) -> ?DEBUG("checking token (~s): ~s", [Host, Token]), - case mod_invites:is_token_valid(Host, Token) of + try mod_invites:is_token_valid(Host, Token) of true -> - case mod_invites:get_invite(Host, Token) of - #invite_token{type = 'roster_only'} -> - Text = ?T("The token provided is either invalid or expired."), - {State, make_stripped_error(IQ, #preauth{}, xmpp:err_item_not_found(Text, Lang))}; - Invite -> + Invite = #invite_token{inviter = {User, Host}} = + mod_invites:get_invite(Host, Token), + case create_account_allowed(User, Host) of + ok -> NewState = State#{invite => Invite}, - {NewState, xmpp:make_iq_result(IQ)} + {NewState, xmpp:make_iq_result(IQ)}; + {error, not_allowed} -> + {State, preauth_invalid(IQ, Lang)} end; false -> - Text = ?T("The token provided is either invalid or expired."), - {State, make_stripped_error(IQ, #preauth{}, xmpp:err_item_not_found(Text, Lang))} + {State, preauth_invalid(IQ, Lang)} + catch + _:not_found -> + {State, preauth_invalid(IQ, Lang)} end. +create_account_allowed(<<>>, _Host) -> + ok; +create_account_allowed(User, Host) -> + mod_invites:create_account_allowed(Host, jid:make(User, Host)). + +preauth_invalid(IQ, Lang) -> + Text = ?T("The token provided is either invalid or expired."), + make_stripped_error(IQ, #preauth{}, xmpp:err_item_not_found(Text, Lang)). + try_register(Token, User, Server, diff --git a/src/mod_invites_sql.erl b/src/mod_invites_sql.erl index 67e73f3a910..e26da508ab8 100644 --- a/src/mod_invites_sql.erl +++ b/src/mod_invites_sql.erl @@ -143,7 +143,17 @@ is_token_valid(Host, Token, {User, Host}) -> ejabberd_sql:sql_query(Host, ?SQL("SELECT @(token)s FROM invite_token WHERE %(Host)H AND token = %(Token)s AND " "invitee = '' AND expires > now() AND (%(User)s = '' OR username = %(User)s)")), - Rows /= []. + case Rows /= [] of + true -> + true; + false -> + case get_invite(Host, Token) of + {error, not_found} -> + throw(not_found); + _ -> + false + end + end. list_invites(Host) -> {selected, Rows} = diff --git a/test/invites_tests.erl b/test/invites_tests.erl index a01d31db92b..9cbd19eec07 100644 --- a/test/invites_tests.erl +++ b/test/invites_tests.erl @@ -189,19 +189,21 @@ adhoc_command_create_account(Config) -> re:run(xdata_field(<<"uri">>, ResultXDataFields2), <<"xmpp:foobar@", Server/binary, "\\?register;preauth=(.+)">>)), ResultXDataFields3 = test_create_account(Config, <<>>, <<"1">>), - Inviter = ?config(user, Config), - ?match({match, [Inviter, _]}, + ?match({match, _}, re:run(xdata_field(<<"uri">>, ResultXDataFields3), - <<"xmpp:(.+)", "@", Server/binary, "\\?roster;preauth=([a-zA-Z0-9]+);ibr=y">>, + <<"xmpp:", Server/binary, "\\?register;preauth=([a-zA-Z0-9]+)">>, [{capture, all_but_first, binary}])), - Token = token_from_uri(xdata_field(<<"uri">>, ResultXDataFields3, <<>>)), + Token3 = token_from_uri(xdata_field(<<"uri">>, ResultXDataFields3, <<>>)), #invite_token{account_name = <<>>, type = account_subscription} = - mod_invites:get_invite(Server, Token), + mod_invites:get_invite(Server, Token3), ResultXDataFields4 = test_create_account(Config, <<"foobar">>, <<"1">>), - ?match({match, [Inviter, _]}, + ?match({match, _}, re:run(xdata_field(<<"uri">>, ResultXDataFields4), - <<"xmpp:(.+)", "@", Server/binary, "\\?roster;preauth=([a-zA-Z0-9]+);ibr=y">>, + <<"xmpp:foobar@", Server/binary, "\\?register;preauth=([a-zA-Z0-9]+)">>, [{capture, all_but_first, binary}])), + Token4 = token_from_uri(xdata_field(<<"uri">>, ResultXDataFields4, <<>>)), + #invite_token{account_name = <<"foobar">>, type = account_subscription} = + mod_invites:get_invite(Server, Token4), update_module_opts(Server, mod_invites, OldOpts), User = jid:nodeprep(?config(user, Config)), mod_invites:remove_user(User, Server), @@ -217,7 +219,12 @@ token_valid(Config) -> #invite_token{token = AccountToken} = create_account_invite(Server, Inviter), ?match(true, mod_invites:is_token_valid(Server, AccountToken, Inviter)), - ?match(false, mod_invites:is_token_valid(Server, <<"madeUptoken">>)), + try mod_invites:is_token_valid(Server, <<"madeUptoken">>) of + break -> broken + catch + _:E -> + ?match(not_found, E) + end, ?match(false, mod_invites:is_token_valid(Server, AccountToken, {<<"someoneElse">>, Server})), mod_invites:expire_tokens(<<"foo">>, Server), @@ -386,15 +393,19 @@ ibr_subscription(Config0) -> NewAccount = <<"new_friend">>, NewAccountJID = jid:make(NewAccount, Server), gen_mod:stop_module_keep_config(Server, mod_vcard_xupdate), + OldOpts = gen_mod:get_module_opts(Server, mod_invites), + NewOpts = gen_mod:set_opt(access_create_account, account_invite, OldOpts), + update_module_opts(Server, mod_invites, NewOpts), + self_presence(Config0, available), #invite_token{token = Token} = mod_invites:create_account_invite(Server, {User, Server}, NewAccount, true), Config1 = set_opts([{user, NewAccount}, - {password, <<"mySecret">>}, - {resource, <<"invite_tests">>}, - {receiver, undefined}], Config0), + {password, <<"mySecret">>}, + {resource, <<"invite_tests">>}, + {receiver, undefined}], Config0), Config = connect(Config1), ?match(#iq{type = result}, send_pars(Config, Token)), @@ -429,6 +440,7 @@ ibr_subscription(Config0) -> mod_roster:del_roster(User, Server, jid:tolower(NewAccountJID)), mod_roster:del_roster(NewAccount, Server, jid:tolower(UserJID)), + update_module_opts(Server, mod_invites, OldOpts), disconnect(Config0), disconnect(Config). From 473cfcaf2e72ddfda69288f6b76f1d7d807209d4 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Thu, 13 Nov 2025 19:26:34 +0100 Subject: [PATCH 05/27] remove yaxim, link conversations on fdroid also no remote logos --- priv/mod_invites/apps.json | 33 ++++---------- priv/mod_invites/static/logos/apple_as.svg | 46 ++++++++++++++++++++ priv/mod_invites/static/logos/fdroid.png | Bin 0 -> 30136 bytes priv/mod_invites/static/logos/google_ps.png | Bin 0 -> 4904 bytes src/mod_invites_http.erl | 4 +- 5 files changed, 57 insertions(+), 26 deletions(-) create mode 100644 priv/mod_invites/static/logos/apple_as.svg create mode 100644 priv/mod_invites/static/logos/fdroid.png create mode 100644 priv/mod_invites/static/logos/google_ps.png diff --git a/priv/mod_invites/apps.json b/priv/mod_invites/apps.json index 786faa72dd0..6e0e01a17bd 100644 --- a/priv/mod_invites/apps.json +++ b/priv/mod_invites/apps.json @@ -3,9 +3,14 @@ "download": { "buttons": [ { - "image": "https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png", + "image": "{{ static }}/logos/google_ps.png", "url": "https://play.google.com/store/apps/details?id=eu.siacs.conversations", "magic_link_format": "https://play.google.com/store/apps/details?id=eu.siacs.conversations&referrer={{ uri }}" + }, + { + "image": "{{ static }}/logos/fdroid.png", + "url": "https://f-droid.org/en/packages/eu.siacs.conversations/", + "magic_link_format": "https://f-droid.org/packages/eu.siacs.conversations/" } ] }, @@ -23,7 +28,7 @@ "download": { "buttons": [ { - "image": "https://toolbox.marketingtools.apple.com/api/v2/badges/download-on-the-app-store/black/en-us?releaseDate=1245024000", + "image": "{{ static }}/logos/apple_as.svg", "target": "_blank", "url": "https://apps.apple.com/app/id317711500" } @@ -42,7 +47,7 @@ "download": { "buttons": [ { - "image": "https://toolbox.marketingtools.apple.com/api/v2/badges/download-on-the-app-store/black/en-us?releaseDate=1245024000", + "image": "{{ static }}/logos/apple_as.svg", "target": "_blank", "url": "https://apps.apple.com/app/id1637078500" } @@ -61,27 +66,7 @@ "download": { "buttons": [ { - "image": "https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png", - "url": "https://play.google.com/store/apps/details?id=org.yaxim.androidclient", - "magic_link_format": "https://play.google.com/store/apps/details?id=org.yaxim.androidclient&referrer={{ invite.uri }}" - } - ] - }, - "image": "logos/yaxim.svg", - "link": "https://play.google.com/store/apps/details?id=org.yaxim.androidclient", - "magic_link_format": "https://play.google.com/store/apps/details?id=org.yaxim.androidclient&referrer={{ invite.uri }}", - "name": "yaxim", - "platforms": [ - "Android" - ], - "supports_preauth_uri": true, - "text": "{% trans "A lean Jabber/XMPP client for Android. It aims at usability, low overhead and security, and works on low-end Android devices starting with Android 4.0." %}" - }, - { - "download": { - "buttons": [ - { - "image": "https://toolbox.marketingtools.apple.com/api/v2/badges/download-on-the-app-store/black/en-us?releaseDate=1245024000", + "image": "{{ static }}/logos/apple_as.svg", "target": "_blank", "url": "https://apps.apple.com/us/app/siskin-im/id1153516838" } diff --git a/priv/mod_invites/static/logos/apple_as.svg b/priv/mod_invites/static/logos/apple_as.svg new file mode 100644 index 00000000000..072b425a1ab --- /dev/null +++ b/priv/mod_invites/static/logos/apple_as.svg @@ -0,0 +1,46 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/mod_invites/static/logos/fdroid.png b/priv/mod_invites/static/logos/fdroid.png new file mode 100644 index 0000000000000000000000000000000000000000..4185d66293f2f88adc14641300c46281719d05f8 GIT binary patch literal 30136 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 literal 0 HcmV?d00001 diff --git a/priv/mod_invites/static/logos/google_ps.png b/priv/mod_invites/static/logos/google_ps.png new file mode 100644 index 0000000000000000000000000000000000000000..131f3acaa252a863c3b694d0f522ea750aebd81c GIT binary patch literal 4904 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 literal 0 HcmV?d00001 diff --git a/src/mod_invites_http.erl b/src/mod_invites_http.erl index b064f3fcdeb..2c7f84727c9 100644 --- a/src/mod_invites_http.erl +++ b/src/mod_invites_http.erl @@ -54,7 +54,7 @@ -define(STATIC, <<"static">>). -define(REGISTRATION, <<"registration">>). --define(STATIC_CTX, {static, ["/", Base, "/", ?STATIC]}). +-define(STATIC_CTX, {static, <<"/", Base/binary, "/", ?STATIC/binary>>}). -define(SITE_NAME_CTX(Name), {site_name, Name}). %% @format-begin @@ -227,7 +227,7 @@ ctx(Invite, #request{host = Host} = Request) -> apps_json(Host, Lang, Ctx) -> AppsBins = render(Host, Lang, <<"apps.json">>, Ctx), - AppsBin = lists:foldr(fun([], B) -> B; (A, B) -> <> end, <<>>, AppsBins), + AppsBin = binary_join(AppsBins, <<>>), misc:json_decode(AppsBin). app_id(App = #{<<"id">> := _ID}) -> From a3e496e45d359e331bef3bb4b1158552286a62ee Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Fri, 14 Nov 2025 08:20:43 +0100 Subject: [PATCH 06/27] use custom branch of xmpp --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 94b63cd147d..ef19da58e68 100644 --- a/mix.exs +++ b/mix.exs @@ -119,7 +119,7 @@ defmodule Ejabberd.MixProject do {:p1_utils, "~> 1.0"}, {:pkix, "~> 1.0"}, {:stringprep, ">= 1.0.26"}, - {:xmpp, git: "https://github.com/processone/xmpp", ref: "7285aa7802bfa90bcefafdad3a342fbb93ce7eea", override: true}, + {:xmpp, git: "https://github.com/sstrigler/xmpp", branch: "great_invitations", override: true}, {:yconf, ">= 1.0.22"}] ++ cond_deps() end From d51212ac40417f2a0192ac89d821abbeaa7e56c8 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Fri, 14 Nov 2025 08:30:34 +0100 Subject: [PATCH 07/27] add erlydtl to the mix and update lock files --- mix.exs | 1 + mix.lock | 4 ++-- rebar.lock | 7 +++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/mix.exs b/mix.exs index ef19da58e68..45db2ebb3c0 100644 --- a/mix.exs +++ b/mix.exs @@ -108,6 +108,7 @@ defmodule Ejabberd.MixProject do [{:cache_tab, "~> 1.0"}, {:dialyxir, "~> 1.2", only: [:test], runtime: false}, {:eimp, "~> 1.0"}, + {:erlydtl, "~> 0.14.0"}, {:ex_doc, "~> 0.31", only: [:edoc], runtime: false}, {:fast_tls, "~> 1.1.24"}, {:fast_xml, "~> 1.1.56"}, diff --git a/mix.lock b/mix.lock index 6934ef8fc7b..d1c3dcf3f21 100644 --- a/mix.lock +++ b/mix.lock @@ -7,7 +7,7 @@ "epam": {:hex, :epam, "1.0.14", "aa0b85d27f4ef3a756ae995179df952a0721237e83c6b79d644347b75016681a", [:rebar3], [], "hexpm", "2f3449e72885a72a6c2a843f561add0fc2f70d7a21f61456930a547473d4d989"}, "eredis": {:hex, :eredis, "1.7.1", "39e31aa02adcd651c657f39aafd4d31a9b2f63c6c700dc9cece98d4bc3c897ab", [:mix, :rebar3], [], "hexpm", "7c2b54c566fed55feef3341ca79b0100a6348fd3f162184b7ed5118d258c3cc1"}, "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, - "esip": {:hex, :esip, "1.0.59", "eb202f8c62928193588091dfedbc545fe3274c34ecd209961f86dcb6c9ebce88", [:rebar3], [{:fast_tls, "1.1.25", [hex: :fast_tls, repo: "hexpm", optional: false]}, {:p1_utils, "1.0.28", [hex: :p1_utils, repo: "hexpm", optional: false]}, {:stun, "1.2.21", [hex: :stun, repo: "hexpm", optional: false]}], "hexpm", "0bdf2e3c349dc0b144f173150329e675c6a51ac473d7a0b2e362245faad3fbe6"}, + "erlydtl": {:hex, :erlydtl, "0.14.0", "964b2dc84f8c17acfaa69c59ba129ef26ac45d2ba898c3c6ad9b5bdc8ba13ced", [:rebar3], [], "hexpm", "d80ec044cd8f58809c19d29ac5605be09e955040911b644505e31e9dd814343 "esip": {:hex, :esip, "1.0.59", "eb202f8c62928193588091dfedbc545fe3274c34ecd209961f86dcb6c9ebce88", [:rebar3], [{:fast_tls, "1.1.25", [hex: :fast_tls, repo: "hexpm", optional: false]}, {:p1_utils, "1.0.28", [hex: :p1_utils, repo: "hexpm", optional: false]}, {:stun, "1.2.21", [hex: :stun, repo: "hexpm", optional: false]}], "hexpm", "0bdf2e3c349dc0b144f173150329e675c6a51ac473d7a0b2e362245faad3fbe6"}, "ex_doc": {:hex, :ex_doc, "0.39.1", "e19d356a1ba1e8f8cfc79ce1c3f83884b6abfcb79329d435d4bbb3e97ccc286e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "8abf0ed3e3ca87c0847dfc4168ceab5bedfe881692f1b7c45f4a11b232806865"}, "exsync": {:hex, :exsync, "0.4.1", "0a14fe4bfcb80a509d8a0856be3dd070fffe619b9ba90fec13c58b316c176594", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "cefb22aa805ec97ffc5b75a4e1dc54bcaf781e8b32564bf74abbe5803d1b5178"}, "ezlib": {:hex, :ezlib, "1.0.15", "d74f5df191784744726a5b1ae9062522c606334f11086363385eb3b772d91357", [:rebar3], [{:p1_utils, "1.0.28", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm", "dd14ba6c12521af5cfe6923e73e3d545f4a0897dc66bfab5287fbb7ae3962eab"}, @@ -34,6 +34,6 @@ "stringprep": {:hex, :stringprep, "1.0.33", "22f42866b4f6f3c238ea2b9cb6241791184ddedbab55e94a025511f46325f3ca", [:rebar3], [{:p1_utils, "1.0.28", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm", "96f8b30bc50887f605b33b46bca1d248c19a879319b8c482790e3b4da5da98c0"}, "stun": {:hex, :stun, "1.2.21", "735855314ad22cb7816b88597d2f5ca22e24aa5e4d6010a0ef3affb33ceed6a5", [:rebar3], [{:fast_tls, "1.1.25", [hex: :fast_tls, repo: "hexpm", optional: false]}, {:p1_utils, "1.0.28", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm", "3d7fe8efb9d05b240a6aa9a6bf8b8b7bff2d802895d170443c588987dc1e12d9"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, - "xmpp": {:git, "https://github.com/processone/xmpp", "7285aa7802bfa90bcefafdad3a342fbb93ce7eea", [ref: "7285aa7802bfa90bcefafdad3a342fbb93ce7eea"]}, + "xmpp": {:git, "https://github.com/sstrigler/xmpp", "03bce052c2c1509ded3a4acdf653985d68cfd8ed", [branch: "great_invitations"]}, "yconf": {:hex, :yconf, "1.0.22", "52a435f9b60ab1e13950dfe3f7131ecdd8b3d1ca72c44bf66fc74b4571027124", [:rebar3], [{:fast_yaml, "1.0.39", [hex: :fast_yaml, repo: "hexpm", optional: false]}], "hexpm", "aca83457ceabe70756484b5c87ba7b1955f511d499168687eaeaa7c300e857f1"}, } diff --git a/rebar.lock b/rebar.lock index cd775b05ccb..2e7ff089206 100644 --- a/rebar.lock +++ b/rebar.lock @@ -4,6 +4,7 @@ {<<"eimp">>,{pkg,<<"eimp">>,<<"1.0.26">>},0}, {<<"epam">>,{pkg,<<"epam">>,<<"1.0.14">>},0}, {<<"eredis">>,{pkg,<<"eredis">>,<<"1.7.1">>},0}, + {<<"erlydtl">>,{pkg,<<"erlydtl">>,<<"0.14.0">>},0}, {<<"esip">>,{pkg,<<"esip">>,<<"1.0.59">>},0}, {<<"ezlib">>,{pkg,<<"ezlib">>,<<"1.0.15">>},0}, {<<"fast_tls">>,{pkg,<<"fast_tls">>,<<"1.1.25">>},0}, @@ -25,8 +26,8 @@ {<<"stun">>,{pkg,<<"stun">>,<<"1.2.21">>},0}, {<<"unicode_util_compat">>,{pkg,<<"unicode_util_compat">>,<<"0.7.1">>},1}, {<<"xmpp">>, - {git,"https://github.com/processone/xmpp", - {ref,"7285aa7802bfa90bcefafdad3a342fbb93ce7eea"}}, + {git,"https://github.com/sstrigler/xmpp", + {ref,"03bce052c2c1509ded3a4acdf653985d68cfd8ed"}}, 0}, {<<"yconf">>,{pkg,<<"yconf">>,<<"1.0.22">>},0}]}. [ @@ -36,6 +37,7 @@ {<<"eimp">>, <<"C0B05F32E35629C4D9BCFB832FF879A92B0F92B19844BC7835E0A45635F2899A">>}, {<<"epam">>, <<"AA0B85D27F4EF3A756AE995179DF952A0721237E83C6B79D644347B75016681A">>}, {<<"eredis">>, <<"39E31AA02ADCD651C657F39AAFD4D31A9B2F63C6C700DC9CECE98D4BC3C897AB">>}, + {<<"erlydtl">>, <<"964B2DC84F8C17ACFAA69C59BA129EF26AC45D2BA898C3C6AD9B5BDC8BA13CED">>}, {<<"esip">>, <<"EB202F8C62928193588091DFEDBC545FE3274C34ECD209961F86DCB6C9EBCE88">>}, {<<"ezlib">>, <<"D74F5DF191784744726A5B1AE9062522C606334F11086363385EB3B772D91357">>}, {<<"fast_tls">>, <<"DA8ED6F05A2452121B087158B17234749F36704C1F2B74DC51DB99A1E27ED5E8">>}, @@ -63,6 +65,7 @@ {<<"eimp">>, <<"D96D4E8572B9DFC40F271E47F0CB1D8849373BC98A21223268781765ED52044C">>}, {<<"epam">>, <<"2F3449E72885A72A6C2A843F561ADD0FC2F70D7A21F61456930A547473D4D989">>}, {<<"eredis">>, <<"7C2B54C566FED55FEEF3341CA79B0100A6348FD3F162184B7ED5118D258C3CC1">>}, + {<<"erlydtl">>, <<"D80EC044CD8F58809C19D29AC5605BE09E955040911B644505E31E9DD8143431">>}, {<<"esip">>, <<"0BDF2E3C349DC0B144F173150329E675C6A51AC473D7A0B2E362245FAAD3FBE6">>}, {<<"ezlib">>, <<"DD14BA6C12521AF5CFE6923E73E3D545F4A0897DC66BFAB5287FBB7AE3962EAB">>}, {<<"fast_tls">>, <<"59E183B5740E670E02B8AA6BE673B5E7779E5FE5BFCC679FE2D4993D1949A821">>}, From 2cf2e7ab82c0f0bf384ed7e0833a4d280e7ac339 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Fri, 14 Nov 2025 09:12:36 +0100 Subject: [PATCH 08/27] fix missing context --- test/invites_tests.erl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/invites_tests.erl b/test/invites_tests.erl index 9cbd19eec07..cba897acfd4 100644 --- a/test/invites_tests.erl +++ b/test/invites_tests.erl @@ -477,7 +477,7 @@ http(Config) -> Token = token_from_uri(TokenURI), {ok, {{_, 200, _}, _Headers, Body}} = httpc:request(LandingPage), {match, RegistrationURLs} = re:run(Body, <<"href=\"", Token/binary, "([a-zA-Z0-9\/\-]+)\"">>, [global, {capture, [1], binary}]), - Apps = mod_invites_http:apps_json(Server, <<"en">>, []), + Apps = mod_invites_http:apps_json(Server, <<"en">>, [{static, <<"/static">>}, {uri, <<>>}]), ?match(true, length(RegistrationURLs) == length(Apps) + 1), BaseURL = mod_invites_http:landing_page(Server, mod_invites:get_invite(Server, Token)), lists:foreach( From ab9bbed0f8aa6f310f29e46352e44f30750f5771 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Fri, 14 Nov 2025 09:13:11 +0100 Subject: [PATCH 09/27] fix xref deprecation warnings --- src/mod_invites.erl | 4 ++-- src/mod_invites_http_erlylib.erl | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mod_invites.erl b/src/mod_invites.erl index d4e330189fc..6ada42a8790 100644 --- a/src/mod_invites.erl +++ b/src/mod_invites.erl @@ -667,7 +667,7 @@ token_uri(#invite_token{type = roster_only, token = Token, inviter = {User, Host}}) -> IBR = maybe_add_ibr_allowed(User, Host), - Inviter = jid:to_string(jid:make(User, Host)), + Inviter = jid:encode(jid:make(User, Host)), <<"xmpp:", Inviter/binary, "?roster;preauth=", Token/binary, IBR/binary>>. maybe_add_ibr_allowed(User, Host) -> @@ -781,4 +781,4 @@ send_presence(From, To, Type) -> Presence = #presence{from = From, to = To, type = Type}, - ejabberd_router:route(From, To, Presence). + ejabberd_router:route(Presence). diff --git a/src/mod_invites_http_erlylib.erl b/src/mod_invites_http_erlylib.erl index b62c0b1fbd4..4c5aaa26b51 100644 --- a/src/mod_invites_http_erlylib.erl +++ b/src/mod_invites_http_erlylib.erl @@ -40,7 +40,7 @@ inventory(filters) -> [{jid, jid}, {user, user}, {token_uri, {mod_invites, token_uri}}, {strip_protocol, strip_protocol}]. jid({User, Server}) -> - jid:to_string(jid:make(User, Server)). + jid:encode(jid:make(User, Server)). strip_protocol(Uri) -> re:replace(Uri, <<"xmpp:">>, <<>>, [{return, binary}]). From f5e94e59e757ebe5902a613ae72d0f2e2904206b Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Fri, 14 Nov 2025 10:31:16 +0100 Subject: [PATCH 10/27] set default for expires --- include/mod_invites.hrl | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/include/mod_invites.hrl b/include/mod_invites.hrl index d5ef0d97450..986ddffc96a 100644 --- a/include/mod_invites.hrl +++ b/include/mod_invites.hrl @@ -1,14 +1,15 @@ +-define(INVITE_TOKEN_EXPIRE_SECONDS_DEFAULT, 5*86400). +-define(INVITE_TOKEN_LENGTH_DEFAULT, 24). + +-define(NS_INVITE_INVITE, <<"urn:xmpp:invite#invite">>). +-define(NS_INVITE_CREATE_ACCOUNT, <<"urn:xmpp:invite#create-account">>). + -record(invite_token, {token :: binary(), inviter :: {binary(), binary()}, invitee = <<>> :: binary(), created_at = calendar:now_to_datetime(erlang:timestamp()) :: calendar:datetime(), - expires :: calendar:datetime(), + expires = calendar:gregorian_seconds_to_datetime(calendar:datetime_to_gregorian_seconds(calendar:now_to_datetime(erlang:timestamp())) + + ?INVITE_TOKEN_EXPIRE_SECONDS_DEFAULT) :: calendar:datetime(), type = roster_only :: roster_only | account_only | account_subscription, account_name = <<>> :: binary() }). - --define(INVITE_TOKEN_EXPIRE_SECONDS_DEFAULT, 5*86400). --define(INVITE_TOKEN_LENGTH_DEFAULT, 24). - --define(NS_INVITE_INVITE, <<"urn:xmpp:invite#invite">>). --define(NS_INVITE_CREATE_ACCOUNT, <<"urn:xmpp:invite#create-account">>). From 2462ed29d03045175c6b3e3e97d1962135f27dc3 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Fri, 14 Nov 2025 10:31:28 +0100 Subject: [PATCH 11/27] remove unneeded type conversion --- src/mod_invites_sql.erl | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/mod_invites_sql.erl b/src/mod_invites_sql.erl index e26da508ab8..a015114a6b4 100644 --- a/src/mod_invites_sql.erl +++ b/src/mod_invites_sql.erl @@ -70,19 +70,12 @@ cleanup_expired(Host) -> create_invite(Invite) -> #invite_token{inviter = {User, Host}, token = Token, - account_name = AccountName0, + account_name = AccountName, created_at = CreatedAt0, expires = Expires0, type = Type} = Invite, TypeBin = atom_to_binary(Type), - AccountName = - case AccountName0 of - undefined -> - <<>>; - _ -> - AccountName0 - end, CreatedAt = datetime_to_sql_timestamp(CreatedAt0), Expires = datetime_to_sql_timestamp(Expires0), From d41bd41e0dc830d605e91bfd564850b9dad1ca2f Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Fri, 14 Nov 2025 10:31:47 +0100 Subject: [PATCH 12/27] fix processing xdata fields --- src/mod_invites_register.erl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mod_invites_register.erl b/src/mod_invites_register.erl index d3e90709279..0f8117cb615 100644 --- a/src/mod_invites_register.erl +++ b/src/mod_invites_register.erl @@ -237,8 +237,8 @@ check_captcha(_IsCaptchaEnabled, _Register, IQ) -> ResIQ = make_stripped_error(IQ, #register{}, xmpp:err_bad_request()), {error, ResIQ}. -process_xdata_submit(X) -> - case {mod_invites:xdata_field(<<"username">>, X, undefined), mod_invites:xdata_field(<<"password">>, X, undefined)} of +process_xdata_submit(#xdata{fields = Fields}) -> + case {mod_invites:xdata_field(<<"username">>, Fields, undefined), mod_invites:xdata_field(<<"password">>, Fields, undefined)} of {UndefU, UndefP} when UndefU == undefined; UndefP == undefined -> error; {Username, Password} -> From c1385315fc3738a4329d601d5f2b96798317f3c3 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Fri, 14 Nov 2025 10:31:58 +0100 Subject: [PATCH 13/27] fix bad spec --- src/mod_invites.erl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mod_invites.erl b/src/mod_invites.erl index 6ada42a8790..112177833ef 100644 --- a/src/mod_invites.erl +++ b/src/mod_invites.erl @@ -56,7 +56,7 @@ -type invite_token() :: #invite_token{}. -callback cleanup_expired(Host :: binary()) -> non_neg_integer(). --callback create_invite(Invitee :: binary()) -> invite_token(). +-callback create_invite(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}. From 14083c55f0b779371d6365d2f7d0a857eef05249 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Fri, 14 Nov 2025 10:32:08 +0100 Subject: [PATCH 14/27] fix type for i18n strings --- src/mod_invites.erl | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/mod_invites.erl b/src/mod_invites.erl index 112177833ef..9ad0694405b 100644 --- a/src/mod_invites.erl +++ b/src/mod_invites.erl @@ -339,11 +339,11 @@ adhoc_items(Acc, InviteUser = #disco_item{jid = jid:make(Server), node = ?NS_INVITE_INVITE, - name = translate:translate(Lang, "Invite User")}, + name = translate:translate(Lang, ?T("Invite User"))}, CreateAccount = #disco_item{jid = jid:make(Server), node = ?NS_INVITE_CREATE_ACCOUNT, - name = translate:translate(Lang, "Create Account")}, + name = translate:translate(Lang, ?T("Create Account"))}, MyItems = case create_account_allowed(LServer, From) of ok -> @@ -755,15 +755,15 @@ to_stanza_error(Lang, Reason) -> xmpp:err_bad_request(Text, Lang). reason_to_text(host_unknown) -> - "Host unknown"; + ?T("Host unknown"); reason_to_text(hostname_invalid) -> - "Hostname invalid"; + ?T("Hostname invalid"); reason_to_text(account_name_invalid) -> - "Username invalid"; + ?T("Username invalid"); reason_to_text(user_exists) -> - "User already exists"; + ?T("User already exists"); reason_to_text(num_invites_exceeded) -> - "Maximum number of invites reached". + ?T("Maximum number of invites reached"). maybe_gen_sid(<<>>) -> p1_rand:get_alphanum_string(?INVITE_TOKEN_LENGTH_DEFAULT); From c951aadfe25ba5917638320f8a9b4d1bb677c39f Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Fri, 14 Nov 2025 10:32:23 +0100 Subject: [PATCH 15/27] add a spec --- src/mod_invites.erl | 1 + 1 file changed, 1 insertion(+) diff --git a/src/mod_invites.erl b/src/mod_invites.erl index 9ad0694405b..02a7d64111f 100644 --- a/src/mod_invites.erl +++ b/src/mod_invites.erl @@ -681,6 +681,7 @@ maybe_add_ibr_allowed(User, Host) -> landing_page(Host, Invite) -> mod_invites_http:landing_page(Host, Invite). +-spec db_call(binary(), atom(), list()) -> any(). db_call(Host, Fun, Args) -> Mod = gen_mod:db_mod(Host, ?MODULE), apply(Mod, Fun, Args). From 5da29164dfbec3c2dc439c476657a85a503e978e Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Fri, 14 Nov 2025 11:28:20 +0100 Subject: [PATCH 16/27] use 0.14.0 of erlydtl --- rebar.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rebar.config b/rebar.config index c2d09c5e9d0..2578887e487 100644 --- a/rebar.config +++ b/rebar.config @@ -34,7 +34,7 @@ {if_rebar3, {eredis, "~> 1.7.1", {git, "https://github.com/Nordix/eredis/", {tag, "v1.7.1"}}} }}, - {erlydtl, "~> 0.14.0", {git, "https://github.com/erlydtl/erlydtl.git", {tag, "0.15.0"}}}, + {erlydtl, "~> 0.14.0", {git, "https://github.com/erlydtl/erlydtl.git", {tag, "0.14.0"}}}, {if_var_true, sip, {esip, "~> 1.0.59", {git, "https://github.com/processone/esip", {tag, "1.0.59"}}}}, {if_var_true, zlib, From 58e88f0859c76f9ba0e0eb86998fb9ac8624b8cc Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Fri, 14 Nov 2025 12:11:06 +0100 Subject: [PATCH 17/27] add erlydtl to plt_extra_apps --- rebar.config | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rebar.config b/rebar.config index 2578887e487..839a2c3910d 100644 --- a/rebar.config +++ b/rebar.config @@ -202,7 +202,7 @@ {plt_extra_apps, [asn1, odbc, public_key, stdlib, syntax_tools, idna, jose, - cache_tab, eimp, fast_tls, fast_xml, fast_yaml, + cache_tab, eimp, erlydtl, fast_tls, fast_xml, fast_yaml, mqtree, p1_acme, p1_oauth2, p1_utils, pkix, stringprep, xmpp, yconf, {if_version_below, "27", jiffy}, @@ -216,7 +216,7 @@ {if_var_true, stun, stun}, {if_var_true, sqlite, sqlite3}]}, {plt_extra_apps, % For Erlang/OTP 25 and older - [cache_tab, eimp, fast_tls, fast_xml, fast_yaml, + [cache_tab, eimp, erlydtl, fast_tls, fast_xml, fast_yaml, mqtree, p1_acme, p1_oauth2, p1_utils, pkix, stringprep, xmpp, yconf, {if_var_true, pam, epam}, {if_var_true, redis, eredis}, From 1709688bda8ffd68fc60172d3f05eae45aca78d4 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Fri, 14 Nov 2025 12:16:16 +0100 Subject: [PATCH 18/27] undo adding username to xdata form --- src/mod_register.erl | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/mod_register.erl b/src/mod_register.erl index f083dc4fec2..099356262e8 100644 --- a/src/mod_register.erl +++ b/src/mod_register.erl @@ -32,8 +32,8 @@ -behaviour(gen_mod). -export([start/2, stop/1, reload/3, stream_feature_register/2, - c2s_unauthenticated_packet/2, try_register/4, try_register/5, try_register/6, - process_iq/1, send_registration_notifications/3, + c2s_unauthenticated_packet/2, try_register/4, try_register/5, + try_register/6, process_iq/1, send_registration_notifications/3, mod_opt_type/1, mod_options/1, depends/2, format_error/1, mod_doc/0]). @@ -225,12 +225,10 @@ process_iq(#iq{type = get, from = From, to = To, id = ID, lang = Lang} = IQ, TopInstr = translate:translate( Lang, ?T("You need a client that supports x:data " "and CAPTCHA to register")), - UField = maybe_add_xdata_value( - Username, - #xdata_field{type = 'text-single', - label = translate:translate(Lang, ?T("User")), - var = <<"username">>, - required = true}), + UField = #xdata_field{type = 'text-single', + label = translate:translate(Lang, ?T("User")), + var = <<"username">>, + required = true}, PField = #xdata_field{type = 'text-private', label = translate:translate(Lang, ?T("Password")), var = <<"password">>, @@ -266,11 +264,6 @@ process_iq(#iq{type = get, from = From, to = To, id = ID, lang = Lang} = IQ, registered = IsRegistered}) end. -maybe_add_xdata_value(<<>>, XData) -> - XData; -maybe_add_xdata_value(Value, XData) -> - XData#xdata_field{values = [Value]}. - try_register_or_set_password(User, Server, Password, #iq{from = From, lang = Lang} = IQ, Source, CaptchaSucceed) -> From 73896aeb163c8449cfc98bd34decf61d8c7d3bc3 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Fri, 14 Nov 2025 16:20:42 +0100 Subject: [PATCH 19/27] allow to set landing_page to auto --- src/mod_invites.erl | 16 ++++++++++------ src/mod_invites_http.erl | 17 +++++++++++++++-- test/ejabberd_SUITE_data/ejabberd.mnesia.yml | 2 +- test/ejabberd_SUITE_data/ejabberd.mysql.yml | 1 + 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/mod_invites.erl b/src/mod_invites.erl index 02a7d64111f..06b82583138 100644 --- a/src/mod_invites.erl +++ b/src/mod_invites.erl @@ -94,8 +94,8 @@ mod_doc() -> ?T("Same as top-level _`default_db`_ option, but applied to this " "module only.")}}, {landing_page, - #{value => "none | binary()", - desc => ?T("Web address of service handling invite links. This is either a local address handled by `mod_invites` configured as a handler at `ejabberd_http` or an external service like 'easy-xmpp-invitation'. The address must be given as a template pattern with fields from the `invite` that will then get replaced accordingly. Eg.: 'https://{{ host }}:5281/invites/{{ invite.token }}' or as an external service like 'http://{{ host }}:8080/easy-xmpp-invites/#{{ invite.uri|strip_protocol }}'. This is the landing page that is being communicated when creating an invite using one of the ad-hoc commands.")} + #{value => "none | auto | LandingPageURLTemplate", + desc => ?T("This is the landing page that is being communicated when creating an invite using one of the ad-hoc commands, the web address of service handling invite links. This is either a local address handled by `mod_invites` configured as a handler at `ejabberd_http` or an external service like 'easy-xmpp-invitation'. The address must be given as a template pattern with fields from the `invite` that will then get replaced accordingly. Eg.: 'https://{{ host }}:5281/invites/{{ invite.token }}' or as an external service like 'http://{{ host }}:8080/easy-xmpp-invites/#{{ invite.uri|strip_protocol }}'. For convenience you can choose 'auto' here and the ejabberd_http handler will be used.")} }, {max_invites, #{value => "pos_integer() | infinity", @@ -126,9 +126,12 @@ mod_doc() -> "", "modules:", " mod_invites:", - " access_create_account: register"]}, + " access_create_account: register" + " landing_page: auto" + ]}, {?T("If you want all your users to be able to send 'create account' " - "invites, you would configure your server like this instead:"), + "invites and have a proxy in front of `ejabberd_http` to not expose its port " + "directly, you would configure your server like this instead:"), ["acl:", " local:", " user_regexp: \"\"", @@ -138,7 +141,8 @@ mod_doc() -> "", "modules:", " mod_invites:", - " access_create_account: create_account_invite"]}, + " access_create_account: create_account_invite" + " landing_page: https://yourhost/invites/{{ invite.token }}"]}, ?T("Note that the names of the access rules are just examples and " "you're free to change them.") %% TODO add example for invite page @@ -184,7 +188,7 @@ mod_opt_type(access_create_account) -> mod_opt_type(db_type) -> econf:db_type(?MODULE); mod_opt_type(landing_page) -> - econf:either(none, econf:binary("^http[s]?://")); + econf:either(none, econf:binary()); mod_opt_type(max_invites) -> econf:pos_int(infinity); mod_opt_type(site_name) -> diff --git a/src/mod_invites_http.erl b/src/mod_invites_http.erl index 2c7f84727c9..7e0a4559b72 100644 --- a/src/mod_invites_http.erl +++ b/src/mod_invites_http.erl @@ -63,11 +63,24 @@ landing_page(Host, Invite) -> case mod_invites_opt:landing_page(Host) of none -> <<>>; + <<"auto">> -> + try ejabberd_http:get_auto_url(any, mod_invites) of + AutoURL0 -> + AutoURL = misc:expand_keyword(<<"@HOST@">>, AutoURL0, Host), + render_landing_page_url(<>, Host, Invite) + catch + _:_ -> + ?WARNING_MSG("'auto' URL configured for mod_invites but no request_handler found in your ~s listeners configuration.", [Host]), + <<>> + end; Tmpl -> - Ctx = [{invite, invite_to_proplist(Invite)}, {host, Host}], - render_url(Tmpl, Ctx) + render_landing_page_url(Tmpl, Host, Invite) end. +render_landing_page_url(Tmpl, Host, Invite) -> + Ctx = [{invite, invite_to_proplist(Invite)}, {host, Host}], + render_url(Tmpl, Ctx). + -spec process(LocalPath::[binary()], #request{}) -> {HTTPCode::integer(), [{binary(), binary()}], Page::string()}. process([?STATIC | StaticFile], #request{host = Host} = Request) -> diff --git a/test/ejabberd_SUITE_data/ejabberd.mnesia.yml b/test/ejabberd_SUITE_data/ejabberd.mnesia.yml index f01934fb14d..a7e55c673b5 100644 --- a/test/ejabberd_SUITE_data/ejabberd.mnesia.yml +++ b/test/ejabberd_SUITE_data/ejabberd.mnesia.yml @@ -19,7 +19,7 @@ define_macro: db_type: internal mod_invites: db_type: internal - landing_page: "http://@HOST@:5280/invites/{{ invite.token }}" # fixme + landing_page: auto mod_last: db_type: internal mod_muc: diff --git a/test/ejabberd_SUITE_data/ejabberd.mysql.yml b/test/ejabberd_SUITE_data/ejabberd.mysql.yml index 1cb1de6ef5a..65df93e902b 100644 --- a/test/ejabberd_SUITE_data/ejabberd.mysql.yml +++ b/test/ejabberd_SUITE_data/ejabberd.mysql.yml @@ -18,6 +18,7 @@ define_macro: db_type: sql mod_invites: db_type: sql + landing_page: auto mod_last: db_type: sql mod_muc: From 41312789bb7156d7a2b2cfe1cc86a44ed8c2e38d Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Fri, 21 Nov 2025 12:01:40 +0100 Subject: [PATCH 20/27] fix erlydtl dependency --- mix.exs | 2 +- mix.lock | 3 ++- rebar.config | 7 ++++++- rebar.lock | 5 ++++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/mix.exs b/mix.exs index 45db2ebb3c0..3e9c570206d 100644 --- a/mix.exs +++ b/mix.exs @@ -108,7 +108,7 @@ defmodule Ejabberd.MixProject do [{:cache_tab, "~> 1.0"}, {:dialyxir, "~> 1.2", only: [:test], runtime: false}, {:eimp, "~> 1.0"}, - {:erlydtl, "~> 0.14.0"}, + {:erlydtl, git: "https://github.com/erlydtl/erlydtl", tag: "0.15.0", override: true}, {:ex_doc, "~> 0.31", only: [:edoc], runtime: false}, {:fast_tls, "~> 1.1.24"}, {:fast_xml, "~> 1.1.56"}, diff --git a/mix.lock b/mix.lock index d1c3dcf3f21..3cc208d3cb3 100644 --- a/mix.lock +++ b/mix.lock @@ -7,7 +7,8 @@ "epam": {:hex, :epam, "1.0.14", "aa0b85d27f4ef3a756ae995179df952a0721237e83c6b79d644347b75016681a", [:rebar3], [], "hexpm", "2f3449e72885a72a6c2a843f561add0fc2f70d7a21f61456930a547473d4d989"}, "eredis": {:hex, :eredis, "1.7.1", "39e31aa02adcd651c657f39aafd4d31a9b2f63c6c700dc9cece98d4bc3c897ab", [:mix, :rebar3], [], "hexpm", "7c2b54c566fed55feef3341ca79b0100a6348fd3f162184b7ed5118d258c3cc1"}, "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, - "erlydtl": {:hex, :erlydtl, "0.14.0", "964b2dc84f8c17acfaa69c59ba129ef26ac45d2ba898c3c6ad9b5bdc8ba13ced", [:rebar3], [], "hexpm", "d80ec044cd8f58809c19d29ac5605be09e955040911b644505e31e9dd814343 "esip": {:hex, :esip, "1.0.59", "eb202f8c62928193588091dfedbc545fe3274c34ecd209961f86dcb6c9ebce88", [:rebar3], [{:fast_tls, "1.1.25", [hex: :fast_tls, repo: "hexpm", optional: false]}, {:p1_utils, "1.0.28", [hex: :p1_utils, repo: "hexpm", optional: false]}, {:stun, "1.2.21", [hex: :stun, repo: "hexpm", optional: false]}], "hexpm", "0bdf2e3c349dc0b144f173150329e675c6a51ac473d7a0b2e362245faad3fbe6"}, + "erlydtl": {:git, "https://github.com/erlydtl/erlydtl", "aae414692b6052e96d890e03bbeeeca0f4dc01c2", [tag: "0.15.0"]}, + "esip": {:hex, :esip, "1.0.59", "eb202f8c62928193588091dfedbc545fe3274c34ecd209961f86dcb6c9ebce88", [:rebar3], [{:fast_tls, "1.1.25", [hex: :fast_tls, repo: "hexpm", optional: false]}, {:p1_utils, "1.0.28", [hex: :p1_utils, repo: "hexpm", optional: false]}, {:stun, "1.2.21", [hex: :stun, repo: "hexpm", optional: false]}], "hexpm", "0bdf2e3c349dc0b144f173150329e675c6a51ac473d7a0b2e362245faad3fbe6"}, "ex_doc": {:hex, :ex_doc, "0.39.1", "e19d356a1ba1e8f8cfc79ce1c3f83884b6abfcb79329d435d4bbb3e97ccc286e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "8abf0ed3e3ca87c0847dfc4168ceab5bedfe881692f1b7c45f4a11b232806865"}, "exsync": {:hex, :exsync, "0.4.1", "0a14fe4bfcb80a509d8a0856be3dd070fffe619b9ba90fec13c58b316c176594", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "cefb22aa805ec97ffc5b75a4e1dc54bcaf781e8b32564bf74abbe5803d1b5178"}, "ezlib": {:hex, :ezlib, "1.0.15", "d74f5df191784744726a5b1ae9062522c606334f11086363385eb3b772d91357", [:rebar3], [{:p1_utils, "1.0.28", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm", "dd14ba6c12521af5cfe6923e73e3d545f4a0897dc66bfab5287fbb7ae3962eab"}, diff --git a/rebar.config b/rebar.config index 839a2c3910d..186bedf5f55 100644 --- a/rebar.config +++ b/rebar.config @@ -34,7 +34,12 @@ {if_rebar3, {eredis, "~> 1.7.1", {git, "https://github.com/Nordix/eredis/", {tag, "v1.7.1"}}} }}, - {erlydtl, "~> 0.14.0", {git, "https://github.com/erlydtl/erlydtl.git", {tag, "0.14.0"}}}, + {if_not_rebar3, + {erlydtl, "~> 0.14.0", {git, "https://github.com/sstrigler/erlydtl.git", {tag, "0.14.0-fix.1"}}} + }, + {if_rebar3, + {erlydtl, ".*", {git, "https://github.com/erlydtl/erlydtl.git", {branch, "master"}}} + }, {if_var_true, sip, {esip, "~> 1.0.59", {git, "https://github.com/processone/esip", {tag, "1.0.59"}}}}, {if_var_true, zlib, diff --git a/rebar.lock b/rebar.lock index 2e7ff089206..64102983934 100644 --- a/rebar.lock +++ b/rebar.lock @@ -4,8 +4,11 @@ {<<"eimp">>,{pkg,<<"eimp">>,<<"1.0.26">>},0}, {<<"epam">>,{pkg,<<"epam">>,<<"1.0.14">>},0}, {<<"eredis">>,{pkg,<<"eredis">>,<<"1.7.1">>},0}, - {<<"erlydtl">>,{pkg,<<"erlydtl">>,<<"0.14.0">>},0}, {<<"esip">>,{pkg,<<"esip">>,<<"1.0.59">>},0}, + {<<"erlydtl">>, + {git,"https://github.com/erlydtl/erlydtl.git", + {ref,"aae414692b6052e96d890e03bbeeeca0f4dc01c2"}}, + 0}, {<<"ezlib">>,{pkg,<<"ezlib">>,<<"1.0.15">>},0}, {<<"fast_tls">>,{pkg,<<"fast_tls">>,<<"1.1.25">>},0}, {<<"fast_xml">>,{pkg,<<"fast_xml">>,<<"1.1.57">>},0}, From 29456a6afdcd39d138f955ef7bbe4ad03c0213e7 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Fri, 21 Nov 2025 12:59:49 +0100 Subject: [PATCH 21/27] use p1/xmpp as of 1.11.3 --- mix.exs | 2 +- mix.lock | 2 +- rebar.config | 2 +- rebar.lock | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mix.exs b/mix.exs index 3e9c570206d..658a6cd1fc7 100644 --- a/mix.exs +++ b/mix.exs @@ -120,7 +120,7 @@ defmodule Ejabberd.MixProject do {:p1_utils, "~> 1.0"}, {:pkix, "~> 1.0"}, {:stringprep, ">= 1.0.26"}, - {:xmpp, git: "https://github.com/sstrigler/xmpp", branch: "great_invitations", override: true}, + {:xmpp, git: "https://github.com/processone/xmpp", tag: "1.11.3", override: true}, {:yconf, ">= 1.0.22"}] ++ cond_deps() end diff --git a/mix.lock b/mix.lock index 3cc208d3cb3..8103a5c407b 100644 --- a/mix.lock +++ b/mix.lock @@ -35,6 +35,6 @@ "stringprep": {:hex, :stringprep, "1.0.33", "22f42866b4f6f3c238ea2b9cb6241791184ddedbab55e94a025511f46325f3ca", [:rebar3], [{:p1_utils, "1.0.28", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm", "96f8b30bc50887f605b33b46bca1d248c19a879319b8c482790e3b4da5da98c0"}, "stun": {:hex, :stun, "1.2.21", "735855314ad22cb7816b88597d2f5ca22e24aa5e4d6010a0ef3affb33ceed6a5", [:rebar3], [{:fast_tls, "1.1.25", [hex: :fast_tls, repo: "hexpm", optional: false]}, {:p1_utils, "1.0.28", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm", "3d7fe8efb9d05b240a6aa9a6bf8b8b7bff2d802895d170443c588987dc1e12d9"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, - "xmpp": {:git, "https://github.com/sstrigler/xmpp", "03bce052c2c1509ded3a4acdf653985d68cfd8ed", [branch: "great_invitations"]}, + "xmpp": {:git, "https://github.com/processone/xmpp", "9f323f01122766f806363b0a5e640a17ee1a41be", [tag: "1.11.3"]}, "yconf": {:hex, :yconf, "1.0.22", "52a435f9b60ab1e13950dfe3f7131ecdd8b3d1ca72c44bf66fc74b4571027124", [:rebar3], [{:fast_yaml, "1.0.39", [hex: :fast_yaml, repo: "hexpm", optional: false]}], "hexpm", "aca83457ceabe70756484b5c87ba7b1955f511d499168687eaeaa7c300e857f1"}, } diff --git a/rebar.config b/rebar.config index 186bedf5f55..1d39d2409e7 100644 --- a/rebar.config +++ b/rebar.config @@ -72,7 +72,7 @@ {stringprep, "~> 1.0.33", {git, "https://github.com/processone/stringprep", {tag, "1.0.33"}}}, {if_var_true, stun, {stun, "~> 1.2.21", {git, "https://github.com/processone/stun", {tag, "1.2.21"}}}}, - {xmpp, "~> 1.11.2", {git, "https://github.com/sstrigler/xmpp", {branch, "great_invitations"}}}, + {xmpp, "~> 1.11.3", {git, "https://github.com/processone/xmpp", {tag, "1.11.3"}}}, {yconf, "~> 1.0.22", {git, "https://github.com/processone/yconf", {tag, "1.0.22"}}} ]}. diff --git a/rebar.lock b/rebar.lock index 64102983934..2a14d5529f6 100644 --- a/rebar.lock +++ b/rebar.lock @@ -29,8 +29,8 @@ {<<"stun">>,{pkg,<<"stun">>,<<"1.2.21">>},0}, {<<"unicode_util_compat">>,{pkg,<<"unicode_util_compat">>,<<"0.7.1">>},1}, {<<"xmpp">>, - {git,"https://github.com/sstrigler/xmpp", - {ref,"03bce052c2c1509ded3a4acdf653985d68cfd8ed"}}, + {git,"https://github.com/processone/xmpp", + {ref,"9f323f01122766f806363b0a5e640a17ee1a41be"}}, 0}, {<<"yconf">>,{pkg,<<"yconf">>,<<"1.0.22">>},0}]}. [ From d37a2f5bee5853315b92fa26662d1f54befba7e7 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Fri, 21 Nov 2025 20:48:08 +0100 Subject: [PATCH 22/27] p1/xmpp 1.11.4 --- mix.exs | 2 +- mix.lock | 2 +- rebar.config | 2 +- rebar.lock | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mix.exs b/mix.exs index 658a6cd1fc7..81faed96c77 100644 --- a/mix.exs +++ b/mix.exs @@ -120,7 +120,7 @@ defmodule Ejabberd.MixProject do {:p1_utils, "~> 1.0"}, {:pkix, "~> 1.0"}, {:stringprep, ">= 1.0.26"}, - {:xmpp, git: "https://github.com/processone/xmpp", tag: "1.11.3", override: true}, + {:xmpp, git: "https://github.com/processone/xmpp", tag: "1.11.4", override: true}, {:yconf, ">= 1.0.22"}] ++ cond_deps() end diff --git a/mix.lock b/mix.lock index 8103a5c407b..f316987ee9b 100644 --- a/mix.lock +++ b/mix.lock @@ -35,6 +35,6 @@ "stringprep": {:hex, :stringprep, "1.0.33", "22f42866b4f6f3c238ea2b9cb6241791184ddedbab55e94a025511f46325f3ca", [:rebar3], [{:p1_utils, "1.0.28", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm", "96f8b30bc50887f605b33b46bca1d248c19a879319b8c482790e3b4da5da98c0"}, "stun": {:hex, :stun, "1.2.21", "735855314ad22cb7816b88597d2f5ca22e24aa5e4d6010a0ef3affb33ceed6a5", [:rebar3], [{:fast_tls, "1.1.25", [hex: :fast_tls, repo: "hexpm", optional: false]}, {:p1_utils, "1.0.28", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm", "3d7fe8efb9d05b240a6aa9a6bf8b8b7bff2d802895d170443c588987dc1e12d9"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, - "xmpp": {:git, "https://github.com/processone/xmpp", "9f323f01122766f806363b0a5e640a17ee1a41be", [tag: "1.11.3"]}, + "xmpp": {:git, "https://github.com/processone/xmpp", "f96c9adde9841bdeb184740857bddd60d3f51ab7", [tag: "1.11.4"]}, "yconf": {:hex, :yconf, "1.0.22", "52a435f9b60ab1e13950dfe3f7131ecdd8b3d1ca72c44bf66fc74b4571027124", [:rebar3], [{:fast_yaml, "1.0.39", [hex: :fast_yaml, repo: "hexpm", optional: false]}], "hexpm", "aca83457ceabe70756484b5c87ba7b1955f511d499168687eaeaa7c300e857f1"}, } diff --git a/rebar.config b/rebar.config index 1d39d2409e7..4c764854111 100644 --- a/rebar.config +++ b/rebar.config @@ -72,7 +72,7 @@ {stringprep, "~> 1.0.33", {git, "https://github.com/processone/stringprep", {tag, "1.0.33"}}}, {if_var_true, stun, {stun, "~> 1.2.21", {git, "https://github.com/processone/stun", {tag, "1.2.21"}}}}, - {xmpp, "~> 1.11.3", {git, "https://github.com/processone/xmpp", {tag, "1.11.3"}}}, + {xmpp, "~> 1.11.4", {git, "https://github.com/processone/xmpp", {tag, "1.11.4"}}}, {yconf, "~> 1.0.22", {git, "https://github.com/processone/yconf", {tag, "1.0.22"}}} ]}. diff --git a/rebar.lock b/rebar.lock index 2a14d5529f6..6169d591d52 100644 --- a/rebar.lock +++ b/rebar.lock @@ -30,7 +30,7 @@ {<<"unicode_util_compat">>,{pkg,<<"unicode_util_compat">>,<<"0.7.1">>},1}, {<<"xmpp">>, {git,"https://github.com/processone/xmpp", - {ref,"9f323f01122766f806363b0a5e640a17ee1a41be"}}, + {ref,"f96c9adde9841bdeb184740857bddd60d3f51ab7"}}, 0}, {<<"yconf">>,{pkg,<<"yconf">>,<<"1.0.22">>},0}]}. [ From cb6626edb385cd5a7de4dd84ea01790c53afdc58 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Fri, 28 Nov 2025 17:52:34 +0100 Subject: [PATCH 23/27] fix get_invite, return invitee and don't set account_name to undefined also uppercase all sql for readability --- src/mod_invites_sql.erl | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/src/mod_invites_sql.erl b/src/mod_invites_sql.erl index a015114a6b4..c5a071bd674 100644 --- a/src/mod_invites_sql.erl +++ b/src/mod_invites_sql.erl @@ -64,7 +64,7 @@ sql_schemas() -> cleanup_expired(Host) -> {updated, Count} = - ejabberd_sql:sql_query(Host, "delete from invite_token where expires < now()"), + ejabberd_sql:sql_query(Host, "DELETE FROM invite_token WHERE expires < NOW()"), Count. create_invite(Invite) -> @@ -88,34 +88,27 @@ create_invite(Invite) -> "created_at=%(CreatedAt)s", "expires=%(Expires)s", "account_name=%(AccountName)s"]), - % ejabberd_sql:sql_transaction(Host, Queries) {updated, 1} = ejabberd_sql:sql_query(Host, Query), Invite. expire_tokens(User, Server) -> {updated, Count} = ejabberd_sql:sql_query(Server, - ?SQL("update invite_token set expires = '1970-01-01 00:00:01' where " - "username = %(User)s and %(Server)H and expires > now() and " + ?SQL("UPDATE invite_token SET expires = '1970-01-01 00:00:01' WHERE " + "username = %(User)s AND %(Server)H AND expires > NOW() AND " "type != 'roster_only'")), Count. get_invite(Host, Token) -> case ejabberd_sql:sql_query(Host, - ?SQL("select @(token)s, @(username)s, @(type)s, @(account_name)s, " - "@(expires)s, @(created_at)s from invite_token where token = " - "%(Token)s and %(Host)H")) + ?SQL("SELECT @(token)s, @(username)s, @(invitee)s, @(type)s, @(account_name)s, " + "@(expires)s, @(created_at)s FROM invite_token WHERE token = " + "%(Token)s AND %(Host)H")) of - {selected, [{Token, User, Type, AccountName0, Expires, CreatedAt}]} -> - AccountName = - case AccountName0 of - <<>> -> - undefined; - _ -> - AccountName0 - end, + {selected, [{Token, User, Invitee, Type, AccountName, Expires, CreatedAt}]} -> #invite_token{token = Token, inviter = {User, Host}, + invitee = Invitee, type = binary_to_existing_atom(Type), account_name = AccountName, expires = Expires, @@ -127,7 +120,7 @@ get_invite(Host, Token) -> is_reserved(Host, Token, User) -> {selected, [{Count}]} = ejabberd_sql:sql_query(Host, - ?SQL("SELECT @(COUNT(*))d from invite_token WHERE %(Host)H AND token != %(Token)s AND " + ?SQL("SELECT @(COUNT(*))d FROM invite_token WHERE %(Host)H AND token != %(Token)s AND " "account_name = %(User)s AND invitee = '' AND expires > NOW()")), Count > 0. @@ -135,7 +128,7 @@ is_token_valid(Host, Token, {User, Host}) -> {selected, Rows} = ejabberd_sql:sql_query(Host, ?SQL("SELECT @(token)s FROM invite_token WHERE %(Host)H AND token = %(Token)s AND " - "invitee = '' AND expires > now() AND (%(User)s = '' OR username = %(User)s)")), + "invitee = '' AND expires > NOW() AND (%(User)s = '' OR username = %(User)s)")), case Rows /= [] of true -> true; @@ -173,13 +166,13 @@ list_invites(Host) -> num_account_invites(User, Server) -> {selected, [{Count}]} = ejabberd_sql:sql_query(Server, - ?SQL("select @(count(*))d from invite_token where username=%(User)s " - "and %(Server)H and type != 'roster_only'")), + ?SQL("SELECT @(COUNT(*))d FROM invite_token WHERE username=%(User)s " + "AND %(Server)H AND type != 'roster_only'")), Count. remove_user(User, Server) -> ejabberd_sql:sql_query(Server, - ?SQL("delete from invite_token where username=%(User)s and %(Server)H")). + ?SQL("DELETE FROM invite_token WHERE username=%(User)s AND %(Server)H")). set_invitee(Host, Token, Invitee) -> {updated, 1} = From d3b678fdc93b3997ce1868aab268c08c2916205d Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Tue, 2 Dec 2025 11:53:15 +0100 Subject: [PATCH 24/27] enable invites for all sql create now manually since, set type to char(1) to not waste space --- src/mod_invites_sql.erl | 39 ++++++++++++++------ test/ejabberd_SUITE_data/ejabberd.mssql.yml | 3 ++ test/ejabberd_SUITE_data/ejabberd.pgsql.yml | 3 ++ test/ejabberd_SUITE_data/ejabberd.redis.yml | 3 ++ test/ejabberd_SUITE_data/ejabberd.sqlite.yml | 3 ++ 5 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/mod_invites_sql.erl b/src/mod_invites_sql.erl index c5a071bd674..834c6abf4af 100644 --- a/src/mod_invites_sql.erl +++ b/src/mod_invites_sql.erl @@ -55,7 +55,7 @@ sql_schemas() -> type = timestamp, default = true}, #sql_column{name = <<"expires">>, type = timestamp}, - #sql_column{name = <<"type">>, type = text}, + #sql_column{name = <<"type">>, type = {char, 1}}, #sql_column{name = <<"account_name">>, type = text}], indices = [#sql_index{columns = [<<"token">>], unique = true}, @@ -63,8 +63,9 @@ sql_schemas() -> [<<"username">>, <<"server_host">>]}]}]}]. cleanup_expired(Host) -> + NOW = sql_now(), {updated, Count} = - ejabberd_sql:sql_query(Host, "DELETE FROM invite_token WHERE expires < NOW()"), + ejabberd_sql:sql_query(Host, ?SQL("DELETE FROM invite_token WHERE expires < %(NOW)s")), Count. create_invite(Invite) -> @@ -73,9 +74,9 @@ create_invite(Invite) -> account_name = AccountName, created_at = CreatedAt0, expires = Expires0, - type = Type} = + type = Type0} = Invite, - TypeBin = atom_to_binary(Type), + Type = enc_type(Type0), CreatedAt = datetime_to_sql_timestamp(CreatedAt0), Expires = datetime_to_sql_timestamp(Expires0), @@ -84,7 +85,7 @@ create_invite(Invite) -> ["token=%(Token)s", "username=%(User)s", "server_host=%(Host)s", - "type=%(TypeBin)s", + "type=%(Type)s", "created_at=%(CreatedAt)s", "expires=%(Expires)s", "account_name=%(AccountName)s"]), @@ -92,11 +93,12 @@ create_invite(Invite) -> Invite. expire_tokens(User, Server) -> + NOW = sql_now(), {updated, Count} = ejabberd_sql:sql_query(Server, ?SQL("UPDATE invite_token SET expires = '1970-01-01 00:00:01' WHERE " - "username = %(User)s AND %(Server)H AND expires > NOW() AND " - "type != 'roster_only'")), + "username = %(User)s AND %(Server)H AND expires > %(NOW)s AND " + "type != 'R'")), Count. get_invite(Host, Token) -> @@ -109,7 +111,7 @@ get_invite(Host, Token) -> #invite_token{token = Token, inviter = {User, Host}, invitee = Invitee, - type = binary_to_existing_atom(Type), + type = dec_type(Type), account_name = AccountName, expires = Expires, created_at = CreatedAt}; @@ -118,17 +120,19 @@ get_invite(Host, Token) -> end. is_reserved(Host, Token, User) -> + NOW = sql_now(), {selected, [{Count}]} = ejabberd_sql:sql_query(Host, ?SQL("SELECT @(COUNT(*))d FROM invite_token WHERE %(Host)H AND token != %(Token)s AND " - "account_name = %(User)s AND invitee = '' AND expires > NOW()")), + "account_name = %(User)s AND invitee = '' AND expires > %(NOW)s")), Count > 0. is_token_valid(Host, Token, {User, Host}) -> + NOW = sql_now(), {selected, Rows} = ejabberd_sql:sql_query(Host, ?SQL("SELECT @(token)s FROM invite_token WHERE %(Host)H AND token = %(Token)s AND " - "invitee = '' AND expires > NOW() AND (%(User)s = '' OR username = %(User)s)")), + "invitee = '' AND expires > %(NOW)s AND (%(User)s = '' OR username = %(User)s)")), case Rows /= [] of true -> true; @@ -157,7 +161,7 @@ list_invites(Host) -> end, #invite_token{token = Token, inviter = {User, Host}, - type = binary_to_existing_atom(Type), + type = dec_type(Type), account_name = AccountName, expires = Expires, created_at = CreatedAt} @@ -167,7 +171,7 @@ num_account_invites(User, Server) -> {selected, [{Count}]} = ejabberd_sql:sql_query(Server, ?SQL("SELECT @(COUNT(*))d FROM invite_token WHERE username=%(User)s " - "AND %(Server)H AND type != 'roster_only'")), + "AND %(Server)H AND type != 'R'")), Count. remove_user(User, Server) -> @@ -186,3 +190,14 @@ set_invitee(Host, Token, Invitee) -> datetime_to_sql_timestamp({{Year, Month, Day}, {Hour, Minute, Second}}) -> list_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B ~2..0B:~2..0B:~2..0B", [Year, Month, Day, Hour, Minute, Second])). + +sql_now() -> + datetime_to_sql_timestamp(calendar:local_time()). + +enc_type(roster_only) -> <<"R">>; +enc_type(account_subscription) -> <<"S">>; +enc_type(account_only) -> <<"A">>. + +dec_type(<<"R">>) -> roster_only; +dec_type(<<"S">>) -> account_subscription; +dec_type(<<"A">>) -> account_only. diff --git a/test/ejabberd_SUITE_data/ejabberd.mssql.yml b/test/ejabberd_SUITE_data/ejabberd.mssql.yml index 1458cafa44b..2e4c1942ffc 100644 --- a/test/ejabberd_SUITE_data/ejabberd.mssql.yml +++ b/test/ejabberd_SUITE_data/ejabberd.mssql.yml @@ -16,6 +16,9 @@ define_macro: mod_blocking: [] mod_caps: db_type: sql + mod_invites: + db_type: sql + landing_page: auto mod_last: db_type: sql mod_muc: diff --git a/test/ejabberd_SUITE_data/ejabberd.pgsql.yml b/test/ejabberd_SUITE_data/ejabberd.pgsql.yml index 16d8b1d2747..4da5444015a 100644 --- a/test/ejabberd_SUITE_data/ejabberd.pgsql.yml +++ b/test/ejabberd_SUITE_data/ejabberd.pgsql.yml @@ -16,6 +16,9 @@ define_macro: mod_blocking: [] mod_caps: db_type: sql + mod_invites: + db_type: sql + landing_page: auto mod_last: db_type: sql mod_muc: diff --git a/test/ejabberd_SUITE_data/ejabberd.redis.yml b/test/ejabberd_SUITE_data/ejabberd.redis.yml index fb1ba435fa6..18b22e7bdff 100644 --- a/test/ejabberd_SUITE_data/ejabberd.redis.yml +++ b/test/ejabberd_SUITE_data/ejabberd.redis.yml @@ -18,6 +18,9 @@ define_macro: mod_blocking: [] mod_caps: db_type: internal + mod_invites: + db_type: internal + landing_page: auto mod_last: db_type: internal mod_muc: diff --git a/test/ejabberd_SUITE_data/ejabberd.sqlite.yml b/test/ejabberd_SUITE_data/ejabberd.sqlite.yml index 11420ef6c68..52093f9eed8 100644 --- a/test/ejabberd_SUITE_data/ejabberd.sqlite.yml +++ b/test/ejabberd_SUITE_data/ejabberd.sqlite.yml @@ -14,6 +14,9 @@ define_macro: mod_blocking: [] mod_caps: db_type: sql + mod_invites: + db_type: sql + landing_page: auto mod_last: db_type: sql mod_muc: From 39fc7fcc56d0c0adac67b41fbc3822d09ba0eec2 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Tue, 2 Dec 2025 11:54:00 +0100 Subject: [PATCH 25/27] mysql table schema --- sql/mysql.new.sql | 14 ++++++++++++++ sql/mysql.sql | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/sql/mysql.new.sql b/sql/mysql.new.sql index cf818ad3dd8..f8cb58edf93 100644 --- a/sql/mysql.new.sql +++ b/sql/mysql.new.sql @@ -507,3 +507,17 @@ CREATE TABLE mqtt_pub ( expiry int unsigned NOT NULL, UNIQUE KEY i_mqtt_topic_server (topic(191), server_host) ) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE invite_token ( + token text NOT NULL, + username text NOT NULL, + server_host varchar(191) NOT NULL, + invitee text NOT NULL DEFAULT '', + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires timestamp NOT NULL, + type character(1) NOT NULL, + account_name text NOT NULL, + PRIMARY KEY (token(191)), +) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE INDEX i_invite_token_username USING BTREE ON invite_token(username(191)); diff --git a/sql/mysql.sql b/sql/mysql.sql index 630c4a55786..e27d2086107 100644 --- a/sql/mysql.sql +++ b/sql/mysql.sql @@ -473,3 +473,16 @@ CREATE TABLE mqtt_pub ( expiry int unsigned NOT NULL, UNIQUE KEY i_mqtt_topic (topic(191)) ) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE invite_token ( + token text NOT NULL, + username text NOT NULL, + invitee text NOT NULL DEFAULT (''), + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires timestamp NOT NULL, + type character(1) NOT NULL, + account_name text NOT NULL, + PRIMARY KEY (token(191)) +) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE INDEX i_invite_token_username USING BTREE ON invite_token(username(191)); From 73f85defbbc0ab62171e9d3ddbcbd78cd1469b60 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Tue, 2 Dec 2025 13:30:33 +0100 Subject: [PATCH 26/27] cleanup after myself --- test/ejabberd_SUITE.erl | 13 ++++++++++--- test/invites_tests.erl | 26 +++++++++++++++++--------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/test/ejabberd_SUITE.erl b/test/ejabberd_SUITE.erl index 5f9aff18f03..6ddc9115f85 100644 --- a/test/ejabberd_SUITE.erl +++ b/test/ejabberd_SUITE.erl @@ -363,8 +363,15 @@ init_per_testcase(TestCase, OrigConfig) -> open_session(bind(auth(connect(Config)))) end. -end_per_testcase(_TestCase, _Config) -> - ok. +end_per_testcase(TestCase, Config) -> + case atom_to_list(TestCase) of + "invites_" ++ _ -> + User = ?config(user, Config), + Server = ?config(server, Config), + ejabberd_auth:remove_user(User, Server); + _ -> + ok + end. legacy_auth_tests() -> {legacy_auth, [parallel], @@ -440,7 +447,6 @@ db_tests(DB) when DB == mnesia; DB == redis -> presence_broadcast, last, antispam_tests:single_cases(), - invites_tests:single_cases(), webadmin_tests:single_cases(), roster_tests:single_cases(), private_tests:single_cases(), @@ -449,6 +455,7 @@ db_tests(DB) when DB == mnesia; DB == redis -> pubsub_tests:single_cases(), muc_tests:single_cases(), offline_tests:single_cases(), + invites_tests:single_cases(), mam_tests:single_cases(), csi_tests:single_cases(), push_tests:single_cases(), diff --git a/test/invites_tests.erl b/test/invites_tests.erl index cba897acfd4..968406bbc19 100644 --- a/test/invites_tests.erl +++ b/test/invites_tests.erl @@ -93,7 +93,7 @@ gen_invite(Config) -> ?match({error, host_unknown}, mod_invites:gen_invite(<<"foo">>, <<"non.existant.host">>)), %% TooLongHostname = list_to_binary([$a || _ <- lists:seq(1, 1024)]), %% ?match({error, hostname_invalid}, mod_invites:gen_invite(<<"foo">>, TooLongHostname)), - ok. + disconnect(Config). cleanup_expired(Config) -> Server = ?config(server, Config), @@ -103,7 +103,7 @@ cleanup_expired(Config) -> ?match(1, mod_invites:cleanup_expired()), ?match(#invite_token{}, mod_invites:get_invite(Server, Token)), ?match(0, mod_invites:cleanup_expired()), - ok. + disconnect(Config). adhoc_items(Config) -> Server = ?config(server, Config), @@ -231,7 +231,7 @@ token_valid(Config) -> ?match(false, mod_invites:is_token_valid(Server, AccountToken, Inviter)), mod_invites:cleanup_expired(), mod_invites:remove_user(User, Server), - ok. + disconnect(Config). remove_user(Config) -> Server = ?config(server, Config), @@ -241,7 +241,7 @@ remove_user(Config) -> ?match(1, mod_invites:num_account_invites(User, Server)), mod_invites:remove_user(User, Server), ?match(0, mod_invites:num_account_invites(User, Server)), - ok. + disconnect(Config). expire_tokens(Config) -> Server = ?config(server, Config), @@ -255,7 +255,8 @@ expire_tokens(Config) -> ?match(true, mod_invites:is_token_valid(Server, RosterToken, Inviter)), ?match(false, mod_invites:is_token_valid(Server, AccountToken, Inviter)), ?match(0, mod_invites:expire_tokens(User, Server)), - mod_invites:cleanup_expired(). + mod_invites:cleanup_expired(), + disconnect(Config). max_invites(Config) -> Server = ?config(server, Config), @@ -271,7 +272,7 @@ max_invites(Config) -> create_account_invite(Server, Inviter)), update_module_opts(Server, mod_invites, OldOpts), #invite_token{} = create_account_invite(Server, Inviter), - ok. + disconnect(Config). presence_with_preauth_token(Config) -> Server = ?config(server, Config), @@ -308,7 +309,7 @@ is_reserved(Config) -> mod_invites:set_invitee(Server, Token, jid:make(<<"some_other_username">>, Server)), ?match(false, mod_invites:is_reserved(Server, <<"some_other_token">>, <<"reserved_user">>)), - ok. + disconnect(Config). stream_feature(Config0) -> Server = ?config(server, Config0), @@ -369,6 +370,9 @@ ibr(Config0) -> send_get_iq_register(Config3)), ?match(#iq{type = result}, send_iq_register(Config3, <<"some_self_chosen_name">>)), + ejabberd_auth:remove_user(AccountName, Server), + ejabberd_auth:remove_user(<<"some_self_chosen_name">>, Server), + ejabberd_auth:remove_user(<<"some_much_better_name">>, Server), update_module_opts(Server, mod_register, OldRegisterOpts), disconnect(Config3). @@ -383,6 +387,7 @@ ibr_reserved(Config0) -> Config2 = reconnect(Config1), ?match(#iq{type = error}, send_iq_register(Config2, <<"reserved">>)), ?match(#iq{type = result}, send_pars(Config2, OtherToken)), + ejabberd_auth:remove_user(<<"check_registration_works">>, Server), disconnect(Config2). ibr_subscription(Config0) -> @@ -442,7 +447,9 @@ ibr_subscription(Config0) -> update_module_opts(Server, mod_invites, OldOpts), disconnect(Config0), - disconnect(Config). + disconnect(Config), + ejabberd_auth:remove_user(NewAccount, Server), + ok. receive_subscription_stanzas(ServerJID, UserFullJID, NewAccountFullJID) -> Stanzas = [pres1, pres2, pres3, msg], @@ -514,7 +521,8 @@ http(Config) -> {ok, {{_, 200, _}, _, _}} = httpc:request(RosterURL), FakeRegURL = <>, {ok, {{_, 404, _}, _, _}} = post(FakeRegURL, RosterToken, <<"baz">>, <<"bar">>), - ok. + ejabberd_auth:remove_user(<<"foo">>, Server), + disconnect(Config). %%%=================================================================== %%% Internal functions From 98cbc824fea70cbfa68ba4500586152568816ec5 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Tue, 2 Dec 2025 15:54:18 +0100 Subject: [PATCH 27/27] be less agressive removing data --- test/ejabberd_SUITE.erl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ejabberd_SUITE.erl b/test/ejabberd_SUITE.erl index 6ddc9115f85..d61e37615bd 100644 --- a/test/ejabberd_SUITE.erl +++ b/test/ejabberd_SUITE.erl @@ -368,7 +368,7 @@ end_per_testcase(TestCase, Config) -> "invites_" ++ _ -> User = ?config(user, Config), Server = ?config(server, Config), - ejabberd_auth:remove_user(User, Server); + mod_offline:remove_user(User, Server); _ -> ok end.