From 0341027aa0044b0e26ecb14e4c76d30686f6ef98 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Tue, 14 Jul 2026 17:05:00 +0200 Subject: [PATCH 01/11] first stab --- include/mod_invites.hrl | 24 + priv/graphql/schemas/admin/admin_schema.gql | 4 + priv/graphql/schemas/admin/invites.gql | 16 + priv/graphql/schemas/global/invites.gql | 12 + src/config/mongoose_config_spec.erl | 1 + .../admin/mongoose_graphql_admin_mutation.erl | 2 + .../admin/mongoose_graphql_admin_query.erl | 4 +- ...ongoose_graphql_invites_admin_mutation.erl | 23 + .../mongoose_graphql_invites_admin_query.erl | 25 + src/graphql/mongoose_graphql.erl | 2 + src/invites/d.erl | 6 + src/invites/mod_invites.erl | 984 ++++++++++++++++++ src/invites/mod_invites_db_backend.erl | 30 + src/invites/mod_invites_db_mnesia.erl | 199 ++++ src/mongoose_disco.erl | 2 +- 15 files changed, 1332 insertions(+), 2 deletions(-) create mode 100644 include/mod_invites.hrl create mode 100644 priv/graphql/schemas/admin/invites.gql create mode 100644 priv/graphql/schemas/global/invites.gql create mode 100644 src/graphql/admin/mongoose_graphql_invites_admin_mutation.erl create mode 100644 src/graphql/admin/mongoose_graphql_invites_admin_query.erl create mode 100644 src/invites/d.erl create mode 100644 src/invites/mod_invites.erl create mode 100644 src/invites/mod_invites_db_backend.erl create mode 100644 src/invites/mod_invites_db_mnesia.erl diff --git a/include/mod_invites.hrl b/include/mod_invites.hrl new file mode 100644 index 00000000000..568d2880722 --- /dev/null +++ b/include/mod_invites.hrl @@ -0,0 +1,24 @@ +-define(DEFAULT_MAX_INVITES, infinity). +-define(DEFAULT_TOKEN_EXPIRE_SECONDS, 5*86400). +-define(DEFAULT_TOKEN_LENGTH, 24). + +-define(NS_INVITE_INVITE, <<"urn:xmpp:invite#invite">>). +-define(NS_INVITE_CREATE_ACCOUNT, <<"urn:xmpp:invite#create-account">>). + +-define(OVERUSE_LIMIT, 1000). + +-define(SPEEDY_GOAT_LEVELS, 2). +-define(SPEEDY_GOAT_SECONDS, 300). + +-record(invite_token, {token :: binary(), + inviter :: {binary(), binary()}, + %% A non-empty value if `invitee` indicates the invite has been used. + invitee = <<>> :: binary(), + created_at = calendar:now_to_datetime(erlang:timestamp()) :: calendar:datetime(), + expires = calendar:gregorian_seconds_to_datetime(calendar:datetime_to_gregorian_seconds(calendar:now_to_datetime(erlang:timestamp())) + ?DEFAULT_TOKEN_EXPIRE_SECONDS) :: calendar:datetime(), + type = roster_only :: roster_only | account_only | account_subscription | reset_token, + %% If type is 'roster_only' then we indicate a token has been used to create + %% an account (if allowed) by setting `account_name` to the name of the user + %% (which should match `invitee`). + account_name = <<>> :: binary() + }). diff --git a/priv/graphql/schemas/admin/admin_schema.gql b/priv/graphql/schemas/admin/admin_schema.gql index e79cc85b9cb..3a02544c073 100644 --- a/priv/graphql/schemas/admin/admin_schema.gql +++ b/priv/graphql/schemas/admin/admin_schema.gql @@ -47,6 +47,8 @@ type AdminQuery{ broadcast: BroadcastAdminQuery "Blocklist management" blocklist: BlocklistAdminQuery + "Invites management" + invites: InvitesAdminQuery } """ @@ -92,6 +94,8 @@ type AdminMutation @protected{ broadcast: BroadcastAdminMutation "Blocklist management" blocklist: BlocklistAdminMutation + "Invites management" + invites: InvitesAdminMutation } type AdminSubscription { diff --git a/priv/graphql/schemas/admin/invites.gql b/priv/graphql/schemas/admin/invites.gql new file mode 100644 index 00000000000..5b9af24f896 --- /dev/null +++ b/priv/graphql/schemas/admin/invites.gql @@ -0,0 +1,16 @@ +""" +Allow admin to manage invites. +""" +type InvitesAdminMutation @use(modules: ["mod_invites"]) @protected{ + "Generate Account Creation Invite for given XMPP hostname" + generateInvite(host: DomainName!): Invite + @protected(type: DOMAIN, args: ["host"]) @use(args: ["host"]) +} + +""" +Allow admin to retrieve information about invites. +""" +type InvitesAdminQuery @protected @use(modules: ["mod_invites"]){ + listInvites(host: DomainName!): [Invite!] + @protected(type: DOMAIN, args: ["host"]) @use(args: ["host"]) +} diff --git a/priv/graphql/schemas/global/invites.gql b/priv/graphql/schemas/global/invites.gql new file mode 100644 index 00000000000..28e0f06511e --- /dev/null +++ b/priv/graphql/schemas/global/invites.gql @@ -0,0 +1,12 @@ +type Invite{ + account_name: String + created_at: String + expires: String + invitee: JID + inviter: JID! + landing_page: String + token: String! + token_uri: String + type: String + valid: Boolean +} diff --git a/src/config/mongoose_config_spec.erl b/src/config/mongoose_config_spec.erl index e2b3c6fa7c6..aa03a23a7e9 100644 --- a/src/config/mongoose_config_spec.erl +++ b/src/config/mongoose_config_spec.erl @@ -743,6 +743,7 @@ configurable_modules() -> mod_global_distrib, mod_http_upload, mod_inbox, + mod_invites, mod_keystore, mod_last, mod_mam, diff --git a/src/graphql/admin/mongoose_graphql_admin_mutation.erl b/src/graphql/admin/mongoose_graphql_admin_mutation.erl index 43e3c2da5e4..cf0c0d3fa36 100644 --- a/src/graphql/admin/mongoose_graphql_admin_mutation.erl +++ b/src/graphql/admin/mongoose_graphql_admin_mutation.erl @@ -17,6 +17,8 @@ execute(_Ctx, _Obj, <<"externalServices">>, _Args) -> {ok, externalServices}; execute(_Ctx, _Obj, <<"inbox">>, _Args) -> {ok, inbox}; +execute(_Ctx, _Obj, <<"invites">>, _Args) -> + {ok, invites}; execute(_Ctx, _Obj, <<"last">>, _Args) -> {ok, last}; execute(_Ctx, _Obj, <<"muc">>, _Args) -> diff --git a/src/graphql/admin/mongoose_graphql_admin_query.erl b/src/graphql/admin/mongoose_graphql_admin_query.erl index 994a4600ba4..f3b9e3edfda 100644 --- a/src/graphql/admin/mongoose_graphql_admin_query.erl +++ b/src/graphql/admin/mongoose_graphql_admin_query.erl @@ -44,4 +44,6 @@ execute(_Ctx, _Obj, <<"stanza">>, _Args) -> execute(_Ctx, _Obj, <<"stat">>, _Args) -> {ok, stats}; execute(_Ctx, _Obj, <<"vcard">>, _Args) -> - {ok, vcard}. + {ok, vcard}; +execute(_Ctx, _Obj, <<"invites">>, _Args) -> + {ok, invites}. diff --git a/src/graphql/admin/mongoose_graphql_invites_admin_mutation.erl b/src/graphql/admin/mongoose_graphql_invites_admin_mutation.erl new file mode 100644 index 00000000000..73edbf7b4a6 --- /dev/null +++ b/src/graphql/admin/mongoose_graphql_invites_admin_mutation.erl @@ -0,0 +1,23 @@ +-module(mongoose_graphql_invites_admin_mutation). + +-behaviour(mongoose_graphql). + +-export([execute/4]). + +-ignore_xref([execute/4]). + +-include("../mongoose_graphql_types.hrl"). + +-import(mongoose_graphql_helper, [make_error/2, format_result/2]). + +execute(_Ctx, _Obj, <<"generateInvite">>, Args) -> + generate_invite(Args). + +-spec generate_invite(map()) -> {ok, map()} | {error, resolver_error()}. +generate_invite(#{<<"host">> := Host}) -> + case mod_invites:generate_invite(Host) of + {ok, Invite} -> + {ok, mod_invites:format_invite(Host, Invite)}; + Err -> + make_error(Err, #{host => Host}) + end. diff --git a/src/graphql/admin/mongoose_graphql_invites_admin_query.erl b/src/graphql/admin/mongoose_graphql_invites_admin_query.erl new file mode 100644 index 00000000000..35904d4c532 --- /dev/null +++ b/src/graphql/admin/mongoose_graphql_invites_admin_query.erl @@ -0,0 +1,25 @@ +-module(mongoose_graphql_invites_admin_query). + +-export([execute/4]). + +-ignore_xref([execute/4]). + +-include("../mongoose_graphql_types.hrl"). +-include("mod_invites.hrl"). + +-import(mongoose_graphql_helper, [make_error/2, format_result/2]). + +execute(_Ctx, _Obj, <<"listInvites">>, Args) -> + list_invites(Args). + +-spec list_invites(map()) -> {ok, [map()]} | {error, resolver_error()}. +list_invites(#{<<"host">> := Host}) -> + case mod_invites:list_invites(Host) of + {ok, Invites} -> + {ok, [{ok, mod_invites:format_invite(Host, Invite)} || Invite <- sort(Invites)]}; + Err -> + make_error(Err, #{host => Host}) + end. + +sort(Invites) -> + lists:sort(fun(#invite_token{created_at = A}, #invite_token{created_at = B}) -> A < B end, Invites). diff --git a/src/graphql/mongoose_graphql.erl b/src/graphql/mongoose_graphql.erl index d09209541b2..8eb0be1d9ac 100644 --- a/src/graphql/mongoose_graphql.erl +++ b/src/graphql/mongoose_graphql.erl @@ -211,6 +211,8 @@ admin_mapping_rules() -> 'Domain' => mongoose_graphql_domain, 'DomainWithType' => mongoose_graphql_domain, 'MetricAdminQuery' => mongoose_graphql_metric_admin_query, + 'InvitesAdminMutation' => mongoose_graphql_invites_admin_mutation, + 'InvitesAdminQuery' => mongoose_graphql_invites_admin_query, default => mongoose_graphql_default}, interfaces => #{default => mongoose_graphql_default}, scalars => #{default => mongoose_graphql_scalar}, diff --git a/src/invites/d.erl b/src/invites/d.erl new file mode 100644 index 00000000000..fd3399ec278 --- /dev/null +++ b/src/invites/d.erl @@ -0,0 +1,6 @@ +-module(d). + +-compile(export_all). + +trace(Mod) -> + recon_trace:calls({Mod, '_', '_'}, 1000, [{scope, local}]). diff --git a/src/invites/mod_invites.erl b/src/invites/mod_invites.erl new file mode 100644 index 00000000000..97bef8e7e8f --- /dev/null +++ b/src/invites/mod_invites.erl @@ -0,0 +1,984 @@ +%%%---------------------------------------------------------------------- +%%% File : mod_invites.erl +%%% Author : Stefan Strigler +%%% Purpose : Account and Roster Invitation (aka Great Invitations) +%%% Created : Fr Jul 12 2026 by Stefan Strigler +%%% +%%% This is a backport of ejabberd's mod_invite. +%%% +%%% 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'). + +-behaviour(gen_mod). + +%% gen_mod callbacks +-export([start/2, stop/1, hooks/1, config_spec/0, supported_features/0, deps/2]). + +%% hooks and callbacks +-export([adhoc_commands/3, remove_user/3]). +%% -export([adhoc_commands/4, c2s_unauthenticated_packet/2, remove_user/3, +%% s2s_receive_packet/1, sm_receive_packet/1, stream_feature_register/2]). + +%% Service Discovery +-export([disco_local_identity/3, disco_local_features/3, disco_local_items/3]). + +%% commands +-export([cleanup_expired/0, delete_invite_by_token/2, expire_invites/2, expire_invite_by_token/2, generate_invite/1, + generate_invite/2, generate_reset_token/2, list_invites/1]). + +%% helpers +-export([create_account_allowed/2, create_account_invite/4, format_invite/2, + get_invite/2, get_invites_tree_t/2, + get_max_invites/2, is_create_allowed/2, is_expired/1, is_reserved/3, is_token_valid/2, + %roster_add/2, + %send_presence/3, + set_invitee/3, set_invitee/5, token_uri/1, transaction/2, + xdata_field/3]). + +-ifdef(TEST). +-export([create_roster_invite/2, create_reset_token/2, find_invites_tree_root_t/4, gen_invite/1, + gen_invite/2, get_invites/2, get_invites_tree_as_root_t/2, is_token_valid/3]). +-endif. + +-include("mongoose.hrl"). +-include("mongoose_config_spec.hrl"). +-include("jlib.hrl"). +-include("adhoc.hrl"). +-include("mod_invites.hrl"). + +-type invite_token() :: #invite_token{}. +-export_type([invite_token/0]). + +-callback cleanup_expired(Host :: binary()) -> non_neg_integer(). +-callback create_invite_t(Host :: binary(), Invite :: invite_token()) -> invite_token(). +-callback delete_invite_by_token(Server :: binary(), Token :: binary()) -> ok | {error, not_found}. +-callback expire_invite_by_token(Server :: binary(), Token :: binary()) -> ok | {error, not_found}. +-callback expire_tokens(User :: binary(), Server :: binary()) -> non_neg_integer(). +-callback get_invite(Host :: binary(), Token :: binary()) -> + invite_token() | {error, not_found}. +-callback get_invite_by_invitee_t(Host :: binary(), Invitee :: {User :: binary(), Host :: binary()}) -> + invite_token() | {error, not_found}. +-callback get_invites_t(Host :: binary(), Inviter :: {User :: binary(), Host :: binary()}) -> + [invite_token()]. +-callback is_reserved(Host :: binary(), Token :: binary(), User :: binary()) -> boolean(). +-callback is_token_valid(Host :: binary(), binary(), {binary(), binary()}) -> boolean(). +-callback list_invites(Host :: binary()) -> [tuple()]. +-callback remove_user(User :: binary(), Server :: binary()) -> any(). +-callback set_invitee(Fun :: fun(() -> OkOrError), + Host :: binary(), + Token :: binary(), + Invitee :: binary(), + AccountName :: binary()) -> OkOrError | {error, conflict} + when OkOrError :: ok | {error, term()}. +-callback transaction(Host:: binary(), fun(() -> T)) -> {atomic, T} | {aborted, any()}. + +%%% FIXME +-define(BIN(S), <>). + +%%-------------------------------------------------------------------- +%%| gen_mod callbacks + +-spec config_spec() -> mongoose_config_spec:config_section(). +config_spec() -> + #section{ + items = #{<<"access_create_account">> => #option{type = atom, + validate = access_rule}, + <<"backend">> => #option{type = atom, + validate = {module, mod_invites_db}}, + <<"max_invites">> => #option{type = int_or_infinity, + validate = positive}, + <<"token_expire_seconds">> => #option{type = int_or_infinity, + validate = positive} + }, + defaults = #{<<"access_create_account">> => none, + <<"backend">> => mnesia, + <<"max_invites">> => ?DEFAULT_MAX_INVITES, + <<"token_expire_seconds">> => ?DEFAULT_TOKEN_EXPIRE_SECONDS + } + }. + +deps(_Host, _Opts) -> + %% TODO + % [{mod_adhoc, #{}, soft}, {mod_register, #{}, soft}, {mod_roster, #{}, soft}]. + []. + +-spec supported_features() -> [atom()]. +supported_features() -> + []. + +-spec hooks(mongooseim:host_type()) -> gen_hook:hook_list(). +hooks(HostType) -> + [{remove_user, HostType, fun ?MODULE:remove_user/3, #{}, 50}, + {adhoc_local_commands, HostType, fun ?MODULE:adhoc_commands/3, #{}, 50}, + {disco_local_items, HostType, fun ?MODULE:disco_local_items/3, #{}, 50}, + {disco_local_features, HostType, fun ?MODULE:disco_local_features/3, #{}, 50}, + {disco_local_identity, HostType, fun ?MODULE:disco_local_identity/3, #{}, 50}%, +% {s2s_receive_packet, HostType, fun ?MODULE:s2s_receive_packet/3, #{}, 50}, +% {sm_receive_packet, HostType, fun ?MODULE:sm_receive_packet/3, #{}, 50}, +% {c2s_pre_auth_features, HostType, fun ?MODULE:stream_feature_register/3, #{}, 50}, + %% note the sequence below is important +% {c2s_unauthenticated_packet, HostType, fun ?MODULE:c2s_unauthenticated_packet/3, #{}, 10} + ]. + +start(HostType, Opts) -> + mod_invites_db_backend:start(HostType, Opts), + ok. + +stop(HostType) -> + mod_invites_db_backend:stop(HostType), + ok. + +%%-------------------------------------------------------------------- +%%| ejabberd command callbacks + +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, + ?MYHOSTS). + +-spec delete_invite_by_token(binary(), binary()) -> ok | {error, not_found}. +delete_invite_by_token(Host, Token) -> + pretty_format_command_result(try_db_call(Host, delete_invite_by_token, [Host, Token])). + +-spec expire_invites(binary(), binary()) -> non_neg_integer(). +expire_invites(User0, Server0) -> + User = jid:nodeprep(User0), + Server = jid:nameprep(Server0), + pretty_format_command_result(try_db_call(Server, expire_tokens, [User, Server])). + +-spec expire_invite_by_token(binary(), binary()) -> ok | {error, not_found}. +expire_invite_by_token(Host, Token) -> + pretty_format_command_result(try_db_call(Host, expire_invite_by_token, [Host, Token])). + +-spec generate_invite(binary()) -> {binary(), binary()} | {error, any()}. +generate_invite(Host) -> + generate_invite(<<>>, Host). + +-spec generate_invite(binary(), binary()) -> {binary(), binary()} | {error, any()}. +generate_invite(AccountName, Host0) -> + Host = jid:nameprep(Host0), + lift(create_account_invite(Host, {<<>>, Host}, AccountName, false)). + +-ifdef(TEST). + +-spec gen_invite(binary()) -> binary() | {error, any()}. +gen_invite(Host) -> + gen_invite(<<>>, Host). + +-endif. + +-spec gen_invite(binary(), binary()) -> {binary(), binary()} | {error, any()}. +gen_invite(AccountName, Host0) -> + Host = jid:nameprep(Host0), + case create_account_invite(Host, {<<>>, Host}, AccountName, false) of + {error, _Reason} = Error -> + Error; + Invite -> + {token_uri(Invite), landing_page(Host, Invite)} + end. + +-spec generate_reset_token(binary(), binary()) -> {binary(), binary()} | {error, any()}. +generate_reset_token(User, Host) -> + Res = case create_reset_token(User, Host) of + {error, _Reason} = Error -> + Error; + Invite -> + {token_uri(Invite), landing_page(Host, Invite)} + end, + pretty_format_command_result(Res). + +list_invites(Host) -> + try_db_call(Host, list_invites, [Host]). + +format_invite(Host, + #invite_token{token = TO, + inviter = {IU, IS}, + invitee = IE, + created_at = CA, + expires = Exp, + type = TY, + account_name = AN} = + Invite) -> + #{<<"token">> => TO, + <<"valid">> => is_token_valid(Host, TO), + <<"created_at">> => encode_datetime(CA), + <<"expires">> => encode_datetime(Exp), + <<"type">> => TY, + <<"inviter">> => jid:to_binary(jid:make_bare(IU, IS)), + <<"invitee">> => IE, + <<"account_name">> => AN, + <<"token_uri">> => token_uri(Invite), + <<"landing_page">> => landing_page(Host, Invite) + }. + +%%-------------------------------------------------------------------- +%%| hooks and callbacks + +remove_user(Acc, #{jid := #jid{luser = LUser, lserver = LServer}}, #{host_type := HostType}) -> + case try_db_call(HostType, remove_user, [LUser, LServer]) of + {error, Reason} -> + ?LOG_ERROR(#{what => muc_remove_user_failed, + reason => Reason, acc => Acc}), + {ok, Acc}; + _ -> + {ok, Acc} + end. + +%% --- + +-spec adhoc_commands(Acc, Params, Extra) -> {ok, Acc} when + Acc :: mod_adhoc:command_hook_acc(), + Params :: #{adhoc_request := adhoc:request()}, + Extra :: gen_hook:extra(). +adhoc_commands(empty, + #{adhoc_request := #adhoc_request{node = ?NS_INVITE_INVITE = Node, + action = <<"execute">>, + session_id = SID, + lang = Lang}, + from := #jid{luser = LUser, lserver = LServer}}, + _) -> + Invite = create_roster_invite(LServer, {LUser, LServer}), + Form = mongoose_data_forms:form( + #{type => <<"result">>, + title => trans(Lang, <<"New Invite Token Created">>), + fields => + maybe_add_landing_url(LServer, + Invite, + Lang, + [#{var => <<"uri">>, + label => trans(Lang, <<"Invite URI">>), + type => <<"text-single">>, + values => [token_uri(Invite)]}, + #{var => <<"expire">>, + label => + trans(Lang, + <<"Invite token valid until">>), + type => <<"text-single">>, + values => + [encode_datetime(Invite#invite_token.expires)]} + ])}), + Response = adhoc:produce_response( + #adhoc_response{status = completed, + node = Node, + elements = [Form], + lang = Lang, + session_id = SID}), + {ok, Response}; +adhoc_commands(empty, + #{adhoc_request := #adhoc_request{node = ?NS_INVITE_CREATE_ACCOUNT = Node, + action = <<"execute">>, + session_id = SID, + xdata = false, + lang = Lang}, + from := From, + to := #jid{lserver = LServer}}, + _) -> + check(fun create_account_allowed/2, + [LServer, From], + fun() -> + Form = + mongoose_data_forms:form( + #{type => <<"form">>, + title => trans(Lang, <<"Account Creation Invite">>), + fields => + [#{var => <<"username">>, + label => trans(Lang, <<"Username">>), + type => <<"text-single">>}, + #{var => <<"roster-subscription">>, + label => trans(Lang, <<"Roster Subscription">>), + type => <<"boolean">>} + ]}), + Response = adhoc:produce_response( + #adhoc_response{status = executing, + node = Node, + default_action = <<"complete">>, + actions = [<<"complete">>], + elements = [Form], + lang = Lang, + session_id = maybe_gen_sid(SID)}), + {ok, Response} + end, + fun(Reason) -> {error, to_stanza_error(Lang, Reason)} end); +adhoc_commands(empty, + #{adhoc_request := #adhoc_request{node = ?NS_INVITE_CREATE_ACCOUNT = Node, + session_id = SID, + xdata = XData, + lang = Lang}, + from := #jid{luser = LUser, lserver = LServer} = From, + to := #jid{lserver = LServer}}, + _) when XData /= false -> + case mongoose_data_forms:parse_form(XData) of + #{type := <<"submit">>, kvs := KVs} -> + check(fun create_account_allowed/2, + [LServer, From], + fun() -> + AccountName = hd(maps:get(<<"username">>, KVs, [<<>>])), + Invite = + create_account_invite(LServer, + {LUser, LServer}, + AccountName, + to_boolean(hd(maps:get(<<"roster-subscription">>, KVs, false)))), + case Invite of + {error, Reason} -> + {ok, {error, to_stanza_error(Lang, Reason)}}; + _Invite -> + ResultFields = + maybe_add_landing_url(LServer, + Invite, + Lang, + [#{var => <<"uri">>, + label => trans(Lang, <<"Invite URI">>), + type => <<"text-single">>, + values => [token_uri(Invite)]}, + #{var => <<"expire">>, + label => trans(Lang, <<"Invite token valid until">>), + type => <<"text-single">>, + values => + [encode_datetime(Invite#invite_token.expires)]}]), + ResultXData = mongoose_data_forms:form(#{type => <<"result">>, + fields => ResultFields}), + Response = adhoc:produce_response( + #adhoc_response{status = completed, + node = Node, + lang = Lang, + session_id = SID, + elements = [ResultXData]}), + {ok, Response} + end + end, + fun(Reason) -> {ok, {error, to_stanza_error(Lang, Reason)}} end); + _ -> + {ok, {error, mongoose_xmpp_errors:bad_request()}} + end; +adhoc_commands(Acc, _, _) -> + {ok, Acc}. + +%% -spec s2s_receive_packet({stanza() | drop, State}) -> +%% {stanza() | drop, State} | {stop, {drop, State}} +%% when State :: ejabberd_s2s_in:state(). +%% s2s_receive_packet({Stanza, State}) -> +%% case sm_receive_packet(Stanza) of +%% {stop, drop} -> +%% {stop, {drop, State}}; +%% Res -> +%% {Res, State} +%% end. + +%% -spec sm_receive_packet(stanza() | drop) -> stanza() | drop | {stop, drop}. +%% sm_receive_packet(#presence{from = From, +%% to = To, +%% type = subscribe, +%% sub_els = Els} = +%% Presence) -> +%% case handle_pre_auth_token(Els, To, From) of +%% true -> +%% {stop, drop}; +%% false -> +%% Presence +%% end; +%% sm_receive_packet(Other) -> +%% Other. + +%% handle_pre_auth_token([], _To, _From) -> +%% false; +%% handle_pre_auth_token([El | Els], +%% #jid{luser = LUser, lserver = LServer} = To, +%% FromFullJid) -> +%% From = jid:remove_resource(FromFullJid), +%% try xmpp:decode(El) of +%% #preauth{token = Token} = PreAuth -> +%% ?DEBUG("got preauth token: ~p", [PreAuth]), +%% case is_token_valid(LServer, Token, {LUser, LServer}) of +%% true -> +%% roster_add(To, From), +%% send_presence(To, From, subscribed), +%% send_presence(To, From, subscribe), +%% set_invitee(LServer, Token, From), +%% true; +%% false -> +%% ?INFO_MSG("Got invalid preauth token from ~s: ~p", [jid:encode(From), PreAuth]), +%% false +%% end; +%% _Other -> +%% handle_pre_auth_token(Els, To, From) +%% catch +%% _:{xmpp_codec, _} -> +%% handle_pre_auth_token(Els, To, From) +%% end. + +%%-------------------------------------------------------------------- +%%| Service Disco + +-define(INFO_IDENTITY(Category, Type, Name), + #{category => Category, + type => Type, + name => Name}). +-define(INFO_COMMAND(Name), + ?INFO_IDENTITY(<<"automation">>, <<"command-node">>, Name)). + +%-spec get_local_identity([identity()], jid(), jid(), binary(), binary()) -> [identity()]. +-spec disco_local_identity(Acc, Params, Extra) -> {ok, Acc} when + Acc :: mongoose_disco:identity_acc(), + Params :: map(), + Extra :: gen_hook:extra(). +disco_local_identity(Acc = #{node := ?NS_INVITE_CREATE_ACCOUNT}, _, _) -> + {ok, mongoose_disco:add_identities([?INFO_COMMAND("Create Account")], Acc)}; +disco_local_identity(Acc = #{node := ?NS_INVITE_INVITE}, _, _) -> + {ok, mongoose_disco:add_identities([?INFO_COMMAND("Invite User")], Acc)}; +disco_local_identity(Acc, _Params, _Extra) -> + {ok, Acc}. + +-spec disco_local_features(Acc, Params, Extra) -> {ok, Acc} when + Acc :: mongoose_disco:feature_acc(), + Params :: map(), + Extra :: gen_hook:extra(). +disco_local_features(Acc = #{node := Ns, from_jid := From, to_jid := #jid{lserver = LServer}}, _, _) -> + maybe + allow ?= + case Ns of + ?NS_INVITE_CREATE_ACCOUNT -> + Access = gen_mod:get_module_opt(LServer, ?MODULE, access_create_account), + acl:match_rule(LServer, Access, From); + ?NS_INVITE_INVITE -> + allow; + _ -> + false + end, + {ok, mongoose_disco:add_features([?NS_COMMANDS], Acc)} + else + false -> + {ok, Acc}; + deny -> + %% FIXME + {error, "Access denied by service policy"} + end; +disco_local_features(Acc, _, _) -> + {ok, Acc}. + +-spec disco_local_items(Acc, Params, Extra) -> {ok, Acc} when + Acc :: mongoose_disco:item_acc(), + Params :: map(), + Extra :: #{host_type := mongooseim:host_type()}. +disco_local_items(Acc = #{from_jid := From, to_jid := #jid{lserver = LServer}, node := ?NS_COMMANDS}, _, _) -> + InviteUser = + #{jid => LServer, + node => ?NS_INVITE_INVITE, + name => <<"Invite User">>}, + CreateAccount = + #{jid => LServer, + node => ?NS_INVITE_CREATE_ACCOUNT, + name => <<"Create Account">>}, + Items = + case create_account_allowed(LServer, From) of + ok -> + [InviteUser, CreateAccount]; + {error, not_allowed} -> + [InviteUser] + end, + ResAcc = mongoose_disco:add_items(Items, Acc), + {ok, ResAcc}; +disco_local_items(Acc, _Params, _Extra) -> + {ok, Acc}. + +%% --- + +%%-------------------------------------------------------------------- +%%| ibr hooks +stream_feature_register(Acc, Host) -> + case gen_mod:is_loaded(Host, ?MODULE) of + true -> + mod_invites_register:stream_feature_register(Acc, Host); + false -> + Acc + end. + +c2s_unauthenticated_packet(State, IQ) -> + mod_invites_register:c2s_unauthenticated_packet(State, IQ). + + +%%-------------------------------------------------------------------- +%%| helpers +get_invite(Host, Token) -> + db_call(Host, get_invite, [Host, Token]). + +-ifdef(TEST). + +get_invites(Host, Inviter) -> + transaction(Host, fun() -> get_invites_t(Host, Inviter) end). + +-endif. + +get_invites_t(Host, Inviter) -> + db_call(Host, get_invites_t, [Host, Inviter]). + +is_expired(#invite_token{expires = Expires}) -> + Now = erlang:timestamp(), + calendar:datetime_to_gregorian_seconds(Expires) + < calendar:datetime_to_gregorian_seconds( + calendar:now_to_universal_time(Now)). + +is_reserved(Host, Token, User) -> + db_call(Host, is_reserved, [Host, Token, User]). + +-spec is_token_valid(binary(), binary()) -> boolean(). +is_token_valid(Host, Token) -> + is_token_valid(Host, Token, {<<>>, Host}). + +-spec is_token_valid(binary(), binary(), {binary(), binary()}) -> boolean(). +is_token_valid(Host, Token, Inviter) -> + db_call(Host, is_token_valid, [Host, Token, Inviter]). + +%-spec set_invitee(binary(), binary(), jid() | binary()) -> ok. +set_invitee(Host, Token, #jid{} = InviteeJid) -> + set_invitee(Host, + Token, + jid:encode( + jid:remove_resource(InviteeJid)), + <<>>); +set_invitee(Host, Token, Invitee) -> + set_invitee(Host, Token, Invitee, <<>>). + +set_invitee(Host, Token, Invitee, AccountName) -> + set_invitee(fun() -> ok end, Host, Token, Invitee, AccountName). + +-spec set_invitee(binary(), binary(), binary(), binary()) -> ok. +set_invitee(F, Host, Token, Invitee, AccountName) -> + %% This invalidates the invite token if Invitee isn't empty + db_call(Host, set_invitee, [F, Host, Token, Invitee, AccountName]). + +create_roster_invite(Host, Inviter) -> + create_invite(roster_only, Host, Inviter, <<>>). + +create_account_invite(Host, Inviter, AccountName, _Subscribe = true) -> + create_invite(account_subscription, Host, Inviter, AccountName); +create_account_invite(Host, Inviter, AccountName, _Subcribe = false) -> + create_invite(account_only, Host, Inviter, AccountName). + +create_invite(Type, Host, Inviter, AccountName) -> + F = fun() -> create_invite_t(Type, Host, Inviter, AccountName) end, + transaction(Host, F). + +create_invite_t(Type, Host, Inviter, AccountName) -> + try invite_token_t(Type, Host, Inviter, AccountName) of + Invite -> + db_call(Host, create_invite_t, [Host, Invite]) + catch + _:({error, _Reason} = Error) -> + Error; + _:Error -> + {error, Error} + end. + +check_account_name(<<>>, _) -> + <<>>; +check_account_name(error, _) -> + {error, account_name_invalid}; +check_account_name(_, error) -> + {error, hostname_invalid}; +check_account_name(AccountName, Host) -> + case lists:member(Host, ?MYHOSTS) of + false -> + {error, host_unknown}; + true -> + case ejabberd_auth:does_user_exist(jid:make_bare(AccountName, Host)) of + true -> + {error, user_exists}; + false -> + case is_reserved(Host, <<>>, AccountName) of + true -> + {error, reserved}; + false -> + AccountName + end + end + end. + +check_max_invites_t(roster_only, _) -> + ok; +check_max_invites_t(_Type, {User, Host}) -> + case is_create_allowed_t(User, Host) of + true -> + ok; + false -> + {error, num_invites_exceeded} + end. + +is_create_allowed(User, Host) -> + transaction(Host, fun() -> is_create_allowed_t(User, Host) end). + +is_create_allowed_t(User, Host) -> + case get_max_invites(User, Host) of + infinity -> + true; + MaxInvites -> + Invites = get_invites_t(Host, {User, Host}), + NumCreated = + lists:foldl(fun (#invite_token{type = roster_only, account_name = <<>>}, Num) -> + Num; + (#invite_token{type = roster_only}, Num) -> + %% We make sure to set account_name to the registered name when + %% creating the account. This field is not used in roster_only + %% scenario otherwise. + Num + 1; + (#invite_token{invitee = <<>>} = Invite, Num) -> + %% account create tokens count unless they haven't been used and + %% are expired + case is_expired(Invite) of + true -> + Num; + false -> + Num + 1 + end; + (_, Num) -> + %% account create token where invitee is not empty + Num + 1 + end, + 0, + Invites), + NumCreated < MaxInvites + end. + +get_max_invites(<<>>, _Server) -> + infinity; +get_max_invites(User, Server) -> + case {gen_mod:get_module_opt(Server, ?MODULE, max_invites), + acl:match_rule(Server, admin, jid:make_bare(User, Server))} + of + {infinity, _} -> + infinity; + {_, allow} -> + infinity; + {MaxInvites, deny} -> + MaxInvites + end. + +check_overuse_t(roster_only, {User, Host}) -> + NumInvites = length(get_invites_t(Host, {User, Host})), + case NumInvites >= ?OVERUSE_LIMIT of + true -> + {error, num_invites_exceeded}; + false -> + ok + end; +check_overuse_t(_Type, {User, Host}) -> + NumInvites = length(get_invites_tree_t(Host, {User, Host})), + case NumInvites >= ?OVERUSE_LIMIT of + true -> + {error, num_invites_exceeded}; + false -> + ok + end. + +get_invites_tree_t(Host, Inviter) -> + Now = calendar:datetime_to_gregorian_seconds( + calendar:now_to_datetime( + erlang:timestamp())), + Root = find_invites_tree_root_t(Now, Host, Inviter, 0), + get_invites_tree_as_root_t(Host, Root). + +find_invites_tree_root_t(Now, Host, Invitee, Lvl) -> + case get_invite_by_invitee_t(Host, Invitee) of + #invite_token{inviter = Inviter, created_at = CreatedAt} -> + maybe_block_speedy_goat(Now, CreatedAt, Lvl), + find_invites_tree_root_t(Now, Host, Inviter, Lvl + 1); + {error, not_found} -> + Invitee + end. + +-spec get_invite_by_invitee_t(binary(), {binary(), binary()}) -> + invite_token() | {error, not_found}. +get_invite_by_invitee_t(_Host, {<<>>, _Server}) -> + {error, not_found}; +get_invite_by_invitee_t(Host, {User, Server}) -> + db_call(Host, get_invite_by_invitee_t, [Host, {User, Server}]). + +maybe_block_speedy_goat(Now, CreatedAt, Lvl) when Lvl == ?SPEEDY_GOAT_LEVELS -> + Then = calendar:datetime_to_gregorian_seconds(CreatedAt), + if Now - Then < ?SPEEDY_GOAT_SECONDS -> + throw(speedy_goat); + true -> + ok + end; +maybe_block_speedy_goat(_, _, _) -> + ok. + +-spec get_invites_tree_as_root_t(binary(), {binary(), binary()}) -> [invite_token()]. +get_invites_tree_as_root_t(Host, Inviter) -> + Invites = get_invites_t(Host, Inviter), + get_invites_tree_as_root_t(Host, Inviter, Invites, []). + +get_invites_tree_as_root_t(_Host, _Inviter, [], Acc) -> + Acc; +get_invites_tree_as_root_t(Host, + Inviter, + [#invite_token{type = roster_only, account_name = <<>>} | Invites], + Acc) -> + get_invites_tree_as_root_t(Host, Inviter, Invites, Acc); +get_invites_tree_as_root_t(Host, + Inviter, + [#invite_token{invitee = <<>>} = Invite | Invites], + Acc) -> + get_invites_tree_as_root_t(Host, Inviter, Invites, [Invite | Acc]); +get_invites_tree_as_root_t(Host, + Inviter, + [#invite_token{invitee = InviteeJID} = Invite | Invites], + Acc) -> + case jid:decode(InviteeJID) of + #jid{luser = Invitee, lserver = Host} -> + get_invites_tree_as_root_t(Host, + Inviter, + Invites, + [Invite | Acc] + ++ get_invites_tree_as_root_t(Host, {Invitee, Host})); + _Nomatch -> + get_invites_tree_as_root_t(Host, Inviter, Invites, [Invite | Acc]) + end. + +maybe_throw({error, _} = Error) -> + throw(Error); +maybe_throw(Good) -> + Good. + +invite_token_t(Type, Host, Inviter, AccountName0) -> + maybe_throw(check_max_invites_t(Type, Inviter)), + maybe_throw(check_overuse_t(Type, Inviter)), + Token = p1_rand:get_alphanum_string(?DEFAULT_TOKEN_LENGTH), + AccountName = maybe_throw(check_account_name(jid:nodeprep(AccountName0), Host)), + ExpireSeconds = gen_mod:get_module_opt(Host, ?MODULE, token_expire_seconds), + set_token_expires(#invite_token{token = Token, + inviter = Inviter, + type = Type, + account_name = AccountName}, + ExpireSeconds). + +-spec create_reset_token(binary(), binary()) -> invite_token() | {error, any()}. +create_reset_token(User, Host) -> + maybe + (#invite_token{} = ResetToken) ?= reset_token(User, Host), + F = fun() -> db_call(Host, create_invite_t, [ResetToken]) end, + transaction(Host, F) + end. + +reset_token(User, Host) -> + maybe + true ?= lists:member(Host, ?MYHOSTS) orelse {error, host_unknown}, + true ?= ejabberd_auth:user_exists(User, Host) orelse {error, user_not_exists}, + set_token_expires(#invite_token{token = + p1_rand:get_alphanum_string(?DEFAULT_TOKEN_LENGTH), + inviter = {<<>>, Host}, + type = reset_token, + account_name = User}, + gen_mod:get_module_opt(Host, ?MODULE, token_expire_seconds)) + end. + +token_uri(#invite_token{type = roster_only, + token = Token, + inviter = {User, Host}}) -> + IBR = maybe_add_ibr_allowed(User, Host), + Inviter = + jid:to_binary( + jid:make_bare(User, Host)), + <<"xmpp:", Inviter/binary, "?roster;preauth=", Token/binary, IBR/binary>>; +token_uri(#invite_token{token = Token, + account_name = AccountName, + inviter = {_User, Host}}) -> + Invitee = + case AccountName of + <<>> -> + Host; + _ -> + <> + end, + <<"xmpp:", Invitee/binary, "?register;preauth=", Token/binary>>. + +maybe_add_ibr_allowed(User, Host) -> + case create_account_allowed(Host, jid:make_bare(User, Host)) of + ok -> + <<";ibr=y">>; + {error, not_allowed} -> + <<>> + end. + +landing_page(_Host, _Invite) -> + %%mod_invites_http:landing_page(Host, Invite). + <<"TBD">>. + +-spec db_call(binary(), atom(), [any()]) -> any(). +db_call(Host, Fun, Args) -> + mongoose_backend:call(Host, mod_invites_db, Fun, Args). + +%% father forgive me +lift({error, _R} = E) -> + E; +lift({ok, _V} = R) -> + R; +lift(Res) -> + {ok, Res}. + +-spec try_db_call(Host :: binary(), Fun :: atom(), Args :: [any()]) -> + {ok, any()} | {error, any()}. +try_db_call(Host, Fun, Args) -> + try + lift(db_call(Host, Fun, Args)) + catch + _:({error, _Reason} = Error) -> + Error; + error:Error -> + {error, Error} + end. + +transaction(Host, F) -> + try db_call(Host, transaction, [Host, F]) of + {atomic, Result} -> + Result; + {aborted, Reason} -> + {error, Reason} + catch + _:Error -> + Error + end. + +-spec trans(binary(), binary()) -> binary(). +trans(_Lang, Msg) -> + %translate:translate(Lang, Msg). + 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, [El | Fields], Default) -> + case exml_query:paths(El, [{element_with_attr, <<"var">>, Field}, {element, <<"value">>}, cdata]) of + [<<>> | _] -> Default; + [Value | _] -> + Value; + [] -> + xdata_field(Field, Fields, Default) + end. + +maybe_add_landing_url(Host, Invite, Lang, Fields) -> + case landing_page(Host, Invite) of + <<>> -> + Fields; + LandingPage -> + [#{var => <<"landing-url">>, + values => [LandingPage], + label => trans(Lang, <<"Invite Landing Page URL">>), + type => <<"text-single">>} + | Fields] + end. + +check(Check, Args, Fun, Else) -> + case erlang:apply(Check, Args) of + ok -> + Fun(); + {error, Reason} -> + Else(Reason) + end. + +create_account_allowed(Host, User) -> + case gen_mod:get_module_opt(Host, ?MODULE, access_create_account) of + none -> + {error, not_allowed}; + Access -> + case acl:match_rule(Host, Access, User) of + deny -> + {error, not_allowed}; + allow -> + ok + end + end. + +to_boolean(<<>>) -> + false; +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">>), + mongoose_xmpp_errors:forbidden(Text); +to_stanza_error(Lang, Reason) -> + Text = trans(Lang, reason_to_text(Reason)), + mongoose_xmpp_errors:bad_request(Text). + +reason_to_text(account_name_invalid) -> + ?BIN("Username invalid"); +reason_to_text(host_unknown) -> + ?BIN("Host unknown"); +reason_to_text(hostname_invalid) -> + ?BIN("Hostname invalid"); +reason_to_text(num_invites_exceeded) -> + ?BIN("Maximum number of invites reached"); +reason_to_text(reserved) -> + ?BIN("Username is reserved"); +reason_to_text(user_exists) -> + ?BIN("User already exists"). + +maybe_gen_sid(<<>>) -> + p1_rand:get_alphanum_string(?DEFAULT_TOKEN_LENGTH); +maybe_gen_sid(SID) -> + SID. + +%% roster_add(UserJID, RosterItemJID) -> +%% RosterItem = +%% #roster_item{jid = RosterItemJID, +%% subscription = from, +%% ask = subscribe}, +%% mod_roster:set_item_and_notify_clients(UserJID, RosterItem, true). + +%% send_presence(From, To, Type) -> +%% Presence = +%% #presence{from = From, +%% to = To, +%% type = Type}, +%% ejabberd_router:route(Presence). + +pretty_format_command_result({error, {module_not_loaded, ?MODULE, Host}}) -> + {error, + lists:flatten( + io_lib:format("Virtual host not known: ~s", [binary_to_list(Host)]))}; +pretty_format_command_result({error, host_unknown}) -> + {error, "Virtual host not known"}; +pretty_format_command_result({error, user_exists}) -> + {error, "Username already taken"}; +pretty_format_command_result({error, user_not_exists}) -> + {error, "User does not exist"}; +pretty_format_command_result({ok, Result}) -> + Result; +pretty_format_command_result(Result) -> + Result. diff --git a/src/invites/mod_invites_db_backend.erl b/src/invites/mod_invites_db_backend.erl new file mode 100644 index 00000000000..80e0a96a41e --- /dev/null +++ b/src/invites/mod_invites_db_backend.erl @@ -0,0 +1,30 @@ +%%%---------------------------------------------------------------------- +%%% File : mod_invites_db_backend.erl +%%% Author : Stefan Strigler +%%% Purpose : Invites DB behaviour +%%% Created : 13 July 2016 by Stefan Strigler +%%%---------------------------------------------------------------------- + +-module(mod_invites_db_backend). + +-author('stefan@strigler.de'). + +-define(MAIN_MODULE, mod_invites_db). + +-export([start/2, stop/1]). + +-callback start(mongooseim:host_type(), gen_mod:module_opts()) -> ok. + +-callback stop(mongooseim:host_type()) -> ok. + +-spec start(mongooseim:host_type(), gen_mod:module_opts()) -> ok. +start(HostType, Opts) -> + TrackedFuns = [], + mongoose_backend:init(HostType, ?MAIN_MODULE, TrackedFuns, Opts), + Args = [HostType, Opts], + mongoose_backend:call(HostType, ?MAIN_MODULE, ?FUNCTION_NAME, Args). + +-spec stop(mongooseim:host_type()) -> ok. +stop(HostType) -> + Args = [HostType], + mongoose_backend:call(HostType, ?MAIN_MODULE, ?FUNCTION_NAME, Args). diff --git a/src/invites/mod_invites_db_mnesia.erl b/src/invites/mod_invites_db_mnesia.erl new file mode 100644 index 00000000000..487a54c6337 --- /dev/null +++ b/src/invites/mod_invites_db_mnesia.erl @@ -0,0 +1,199 @@ +%%%---------------------------------------------------------------------- +%%% File : mod_invites_db_mnesia.erl +%%% Author : Stefan Strigler +%%% Created : Mon Sep 15 2025 by Stefan Strigler +%%% +%%% +%%% 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_db_mnesia). + +-author('stefan@strigler.de'). + +-behaviour(mod_invites). +-behaviour(mod_invites_db_backend). + +-export([start/2, stop/1]). + +-export([cleanup_expired/1, create_invite_t/2, delete_invite_by_token/2, expire_invite_by_token/2, + expire_tokens/2, get_invite/2, get_invites_t/2, get_invite_by_invitee_t/2, + is_reserved/3, is_token_valid/3, list_invites/1, remove_user/2, set_invitee/5, transaction/2]). + +-include("mod_invites.hrl"). + +%%==================================================================== +%% API +%%==================================================================== + +%% ------------------------ Backend start/stop ------------------------ + +-spec start(Host :: jid:server(), any()) -> ok. +start(_Host, _) -> + mongoose_mnesia:create_table(invite_token, + [{disc_copies, [node()]}, + {attributes, record_info(fields, invite_token)}, + {index, [inviter, invitee, account_name]}]). + + +-spec stop(Host :: jid:server()) -> ok. +stop(_Host) -> + ok. + +%% ------------------------ general invites mgmt ------------------------ + +cleanup_expired(_Host) -> + lists:foldl(fun(Token, Count) -> + [Invite] = mnesia:dirty_read(invite_token, Token), + case mod_invites:is_expired(Invite) of + true -> + ok = mnesia:dirty_delete(invite_token, Token), + Count + 1; + false -> + Count + end + end, + 0, + mnesia:dirty_all_keys(invite_token)). + +create_invite_t(_Host, Invite) -> + ok = mnesia:write(Invite), + Invite. + +delete_invite_by_token(_Host, Token) -> + case mnesia:dirty_read(invite_token, Token) of + [_Invite] -> + mnesia:dirty_delete(invite_token, Token); + [] -> + {error, not_found} + end. + +expire_invite_by_token(_Host, Token) -> + case mnesia:dirty_read(invite_token, Token) of + [Invite] -> + mnesia:dirty_write(Invite#invite_token{expires = {{1970, 1, 1}, {0, 0, 1}}}); + [] -> + {error, not_found} + end. + +expire_tokens(User, Server) -> + 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 mod_invites:is_expired(I), + I#invite_token.type /= roster_only]). + +get_invite(_Host, Token) -> + case mnesia:dirty_read(invite_token, Token) of + [Invite] -> + Invite; + [] -> + {error, not_found} + end. + +get_invite_by_invitee_t(_Host, {User, Host}) -> + Invitee = jid:to_bare_binary({User, Host}), + Invites = mnesia:index_read(invite_token, Invitee, #invite_token.invitee), + case [I + || I = #invite_token{type = Type, account_name = AccountName} <- Invites, + Type =/= roster_only orelse AccountName == User] + of + [Invite] -> + Invite; + [] -> + %% It might be a roster_only invite was used to create account but invitee has not been + %% set + case mnesia:index_read(invite_token, User, #invite_token.account_name) of + [#invite_token{type = Type} = Invite] when Type == roster_only -> + Invite; + _ -> + {error, not_found} + end + end. + +get_invites_t(_Host, Inviter) -> + mnesia:index_read(invite_token, Inviter, #invite_token.inviter). + +is_reserved(_Host, Token, User) -> + lists:filter(fun(T) -> + I = hd(mnesia:dirty_read(invite_token, T)), + not mod_invites:is_expired(I) + and (I#invite_token.token /= Token) + and (I#invite_token.invitee == <<>>) + and (I#invite_token.account_name == User) + end, + mnesia:dirty_all_keys(invite_token)) + =/= []. + +is_token_valid(Host, Token, Scope) -> + case mnesia:dirty_read(invite_token, Token) of + [Invite = #invite_token{invitee = <<>>, inviter = {_, Host} = Inviter}] + when Scope == Inviter; Scope == {<<>>, Host} -> + not mod_invites:is_expired(Invite); + [#invite_token{}] -> + false; + [] -> + throw(not_found) + end. + +list_invites(Host) -> + lists:filtermap(fun(Token) -> + Invite = hd(mnesia:dirty_read(invite_token, Token)), + case element(2, Invite#invite_token.inviter) of + Host -> + {true, Invite}; + _ -> + false + end + end, + mnesia:dirty_all_keys(invite_token)). + +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. + +-spec set_invitee(fun(() -> OkOrError), binary(), binary(), binary(), binary()) -> + OkOrError | {error, conflict} + when OkOrError :: ok | {error, term()}. +set_invitee(F, _Host, Token, Invitee, AccountName) -> + Transaction = + fun() -> + case hd(mnesia:read(invite_token, Token)) of + #invite_token{type = Type, + invitee = OInvitee, + account_name = OAccountName} + when OInvitee =/= <<>> + orelse Type == roster_only + andalso OAccountName =/= <<>> + andalso AccountName =/= <<>> -> + {error, conflict}; + Invite -> + case F() of + ok -> + ok = + mnesia:write(Invite#invite_token{invitee = Invitee, + account_name = AccountName}); + {error, _Res} = Error -> + Error + end + end + end, + {atomic, Res} = mnesia:transaction(Transaction), + Res. + +transaction(_Host, Fun) -> + mnesia:transaction(Fun). diff --git a/src/mongoose_disco.erl b/src/mongoose_disco.erl index c38a4148bd7..a2314bd1179 100644 --- a/src/mongoose_disco.erl +++ b/src/mongoose_disco.erl @@ -158,7 +158,7 @@ items_to_xml(Items) -> %% For each JID, leave only the rightmost item with that JID (the one which was added first). %% This is needed as extension modules might add more detailed information about an item %% than the default which is obtained from the registered routes and contains only the JID. - maps:values(maps:from_list([{JID, item_to_xml(Item)} || #{jid := JID} = Item <- Items])). + maps:values(maps:from_list([{{JID, Node}, item_to_xml(Item)} || #{jid := JID, node := Node} = Item <- Items])). -spec features_to_xml([feature()]) -> [exml:element()]. features_to_xml(Features) -> From f24bab5c91480ae84a42bad346ab9b59a5853d62 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Sat, 18 Jul 2026 10:08:38 +0200 Subject: [PATCH 02/11] send stream feature --- include/mod_invites.hrl | 6 + src/invites/mod_invites.erl | 18 +- src/invites/mod_invites_register.erl | 335 +++++++++++++++++++++++++++ 3 files changed, 346 insertions(+), 13 deletions(-) create mode 100644 src/invites/mod_invites_register.erl diff --git a/include/mod_invites.hrl b/include/mod_invites.hrl index 568d2880722..e69dfdb48f5 100644 --- a/include/mod_invites.hrl +++ b/include/mod_invites.hrl @@ -5,6 +5,9 @@ -define(NS_INVITE_INVITE, <<"urn:xmpp:invite#invite">>). -define(NS_INVITE_CREATE_ACCOUNT, <<"urn:xmpp:invite#create-account">>). +-define(NS_FEATURE_IBR_TOKEN, <<"urn:xmpp:ibr-token:0">>). +-define(NS_FEATURE_SUB_PRE_APPROVAL, <<"urn:xmpp:features:pre-approval">>). + -define(OVERUSE_LIMIT, 1000). -define(SPEEDY_GOAT_LEVELS, 2). @@ -22,3 +25,6 @@ %% (which should match `invitee`). account_name = <<>> :: binary() }). + +%%% FIXME - just being lazy here, replacing ejabberd's lazy T with BIN +-define(BIN(S), <>). diff --git a/src/invites/mod_invites.erl b/src/invites/mod_invites.erl index 97bef8e7e8f..c431108827a 100644 --- a/src/invites/mod_invites.erl +++ b/src/invites/mod_invites.erl @@ -31,7 +31,7 @@ -export([start/2, stop/1, hooks/1, config_spec/0, supported_features/0, deps/2]). %% hooks and callbacks --export([adhoc_commands/3, remove_user/3]). +-export([adhoc_commands/3, remove_user/3, stream_feature_register/3]). %% -export([adhoc_commands/4, c2s_unauthenticated_packet/2, remove_user/3, %% s2s_receive_packet/1, sm_receive_packet/1, stream_feature_register/2]). @@ -88,9 +88,6 @@ when OkOrError :: ok | {error, term()}. -callback transaction(Host:: binary(), fun(() -> T)) -> {atomic, T} | {aborted, any()}. -%%% FIXME --define(BIN(S), <>). - %%-------------------------------------------------------------------- %%| gen_mod callbacks @@ -128,10 +125,10 @@ hooks(HostType) -> {adhoc_local_commands, HostType, fun ?MODULE:adhoc_commands/3, #{}, 50}, {disco_local_items, HostType, fun ?MODULE:disco_local_items/3, #{}, 50}, {disco_local_features, HostType, fun ?MODULE:disco_local_features/3, #{}, 50}, - {disco_local_identity, HostType, fun ?MODULE:disco_local_identity/3, #{}, 50}%, + {disco_local_identity, HostType, fun ?MODULE:disco_local_identity/3, #{}, 50}, % {s2s_receive_packet, HostType, fun ?MODULE:s2s_receive_packet/3, #{}, 50}, % {sm_receive_packet, HostType, fun ?MODULE:sm_receive_packet/3, #{}, 50}, -% {c2s_pre_auth_features, HostType, fun ?MODULE:stream_feature_register/3, #{}, 50}, + {c2s_stream_features, HostType, fun ?MODULE:stream_feature_register/3, #{}, 50}%, %% note the sequence below is important % {c2s_unauthenticated_packet, HostType, fun ?MODULE:c2s_unauthenticated_packet/3, #{}, 10} ]. @@ -507,13 +504,8 @@ disco_local_items(Acc, _Params, _Extra) -> %%-------------------------------------------------------------------- %%| ibr hooks -stream_feature_register(Acc, Host) -> - case gen_mod:is_loaded(Host, ?MODULE) of - true -> - mod_invites_register:stream_feature_register(Acc, Host); - false -> - Acc - end. +stream_feature_register(Acc, #{lserver := Host}, _) -> + {ok, mod_invites_register:stream_feature_register(Acc, Host)}. c2s_unauthenticated_packet(State, IQ) -> mod_invites_register:c2s_unauthenticated_packet(State, IQ). diff --git a/src/invites/mod_invites_register.erl b/src/invites/mod_invites_register.erl new file mode 100644 index 00000000000..8eb2b405cf7 --- /dev/null +++ b/src/invites/mod_invites_register.erl @@ -0,0 +1,335 @@ +%%%---------------------------------------------------------------------- +%%% 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) 2026 ProcessOne +%%% +%%% This program is free software; you can redistribute it and/or +%%% modify it under the terms of the GNU General Public License as +%%% published by the Free Software Foundation; either version 2 of the +%%% License, or (at your option) any later version. +%%% +%%% This program is distributed in the hope that it will be useful, +%%% but WITHOUT ANY WARRANTY; without even the implied warranty of +%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +%%% General Public License for more details. +%%% +%%% You should have received a copy of the GNU General Public License along +%%% with this program; if not, write to the Free Software Foundation, Inc., +%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +%%% +%%%---------------------------------------------------------------------- +-module(mod_invites_register). + +-author('stefan@strigler.de'). + +%% -export([c2s_unauthenticated_packet/2, stream_feature_register/2]). +%% -export([try_register/6]). + +-export([stream_feature_register/2]). + +-import(mod_invites, [roster_add/2, send_presence/3, xdata_field/3]). + +-include("mongoose.hrl"). +-include("mod_invites.hrl"). +-include("jlib.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([#xmlel{}], binary()) -> [#xmlel{}]. +stream_feature_register(Acc, Host) -> + case gen_mod:get_module_opt(Host, mod_invites, access_create_account) of + none -> + Acc; + _ -> + [#xmlel{name = <<"register">>, attrs = #{<<"xmlns">> => ?NS_FEATURE_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)), +%% Meta = xmpp:get_meta(IQ2), +%% ResIQ = +%% mod_register:process_iq( +%% xmpp:set_meta(IQ2, Meta#{pre_auth => true})), +%% 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 = [_], +%% lang = Lang} = +%% 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}} -> +%% #{ip := IP} = State, +%% {Address, _} = IP, +%% case try_register(Invite, Username, Server, Password, Address, Lang) of +%% {ok, UpdatedInvite} -> +%% ResState = State#{invite => UpdatedInvite}, +%% {stop, ejabberd_c2s:send(ResState, xmpp:make_iq_result(IQ))}; +%% {error, #stanza_error{} = Err} -> +%% ResIQ = make_stripped_error(IQ, #register{}, Err), +%% {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("processing token (~s): ~s", [Host, Token]), +%% case can_create_account_or_change_pw(Host, Token) of +%% {true, Invite} -> +%% NewState = State#{invite => Invite}, +%% {NewState, xmpp:make_iq_result(IQ)}; +%% false -> +%% {State, preauth_invalid(IQ, Lang)} +%% end. + +can_create_account_or_change_pw(Host, Token) -> + try mod_invites:is_token_valid(Host, Token) of + true -> + case mod_invites:get_invite(Host, Token) of + #invite_token{type = reset_token} = Invite -> + {true, Invite}; + #invite_token{type = roster_only, account_name = AccountName} + when AccountName /= <<>> -> + false; + Invite -> + maybe + true ?= create_account_allowed(Invite), + {true, Invite} + end + end; + false -> + false + catch + _:not_found -> + false + end. + +create_account_allowed(#invite_token{type = roster_only} = Invite) -> + #invite_token{inviter = {User, Host}} = Invite, + case mod_invites:is_create_allowed(User, Host) of + true -> + NumInvites = + length(mod_invites:transaction(Host, + fun() -> + mod_invites:get_invites_tree_t(Host, {User, Host}) + end)), + NumInvites < ?OVERUSE_LIMIT; + false -> + false + end; +create_account_allowed(#invite_token{inviter = {<<>>, _Host}}) -> + true; +create_account_allowed(#invite_token{inviter = {User, Host}}) -> + mod_invites:create_account_allowed(Host, jid:make(User, Host)) == ok. + +%% preauth_invalid(IQ, Lang) -> +%% Text = ?BIN("The token provided is either invalid or expired."), +%% make_stripped_error(IQ, #preauth{}, xmpp:err_item_not_found(Text, Lang)). + +%% -spec try_register(mod_invites:invite_token(), +%% binary(), +%% binary(), +%% binary(), +%% tuple(), +%% binary()) -> +%% {ok, mod_invites:invite_token()} | {error, stanza_error()}. +%% try_register(#invite_token{type = reset_token} = Invite, +%% User, +%% Server, +%% Password, +%% _Source, +%% Lang) -> +%% case Invite#invite_token.account_name == User of +%% true -> +%% ChPwF = fun() -> mod_register:try_set_password(User, Server, Password) end, +%% NewInvite = +%% #invite_token{invitee = Invitee} = +%% maybe_set_invitee(Invite, jid:make(User, Server)), +%% case mod_invites:set_invitee(ChPwF, Server, Invite#invite_token.token, Invitee, User) of +%% ok -> +%% {ok, NewInvite}; +%% {error, Why} -> +%% {error, to_xmpp_error(Why, Lang)} +%% end; +%% false -> +%% {error, to_xmpp_error(not_allowed, Lang)} +%% end; +%% try_register(Invite, User, Server, Password, Source, Lang) -> +%% #invite_token{token = Token} = Invite, +%% case {jid:nodeprep(User), not mod_invites:is_reserved(Server, Token, User)} of +%% {error, _} -> +%% {error, to_xmpp_error(invalid_jid, Lang)}; +%% {_, false} -> +%% {error, to_xmpp_error(not_allowed, Lang)}; +%% {_, true} -> +%% RegF = +%% fun() -> +%% mod_register:try_register(User, Server, Password, Source, mod_invites, Lang) +%% end, +%% NewInvite = +%% #invite_token{invitee = Invitee, account_name = AccountName} = +%% maybe_set_account_name(maybe_set_invitee(Invite, jid:make(User, Server)), User), +%% case mod_invites:set_invitee(RegF, Server, Token, Invitee, AccountName) of +%% ok -> +%% maybe_create_mutual_subscription(NewInvite), +%% {ok, NewInvite}; +%% {error, conflict} -> +%% ?LOG_WARNING("Conflict when redeeming invite token: ~p", [NewInvite]), +%% {error, to_xmpp_error(conflict, Lang)}; +%% {error, Why} -> +%% {error, to_xmpp_error(Why, Lang)} +%% end +%% end. + +to_xmpp_error(Why, Lang) when Why == not_allowed; Why == invalid_password -> + xmpp:err_not_allowed( + mod_register:format_error(Why), Lang); +to_xmpp_error(weak_password = Why, Lang) -> + xmpp:err_not_acceptable( + mod_register:format_error(Why), Lang); +to_xmpp_error(invalid_jid = Why, Lang) -> + xmpp:err_jid_malformed( + mod_register:format_error(Why), Lang); +to_xmpp_error(db_failure = Why, Lang) -> + xmpp:err_internal_server_error( + mod_register:format_error(Why), Lang); +to_xmpp_error(conflict, Lang) -> + xmpp:err_conflict( + mod_register:format_error(not_allowed), Lang); +to_xmpp_error(Unexpected, Lang) -> + xmpp:err_internal_server_error( + mod_register:format_error(Unexpected), Lang). + +%% 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(#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} -> +%% {ok, {Username, Password}} +%% end. + +maybe_set_invitee(#invite_token{type = roster_only} = Invite, _Invitee) -> + Invite; +maybe_set_invitee(Invite, Invitee) -> + Invite#invite_token{invitee = jid:encode(Invitee)}. + +maybe_set_account_name(#invite_token{type = roster_only} = Invite, AccountName) -> + Invite#invite_token{account_name = AccountName}; +maybe_set_account_name(Invite, _AccountName) -> + Invite. From 86d02fd1e12521b6ca747d9b301ca150eead6598 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Sat, 18 Jul 2026 16:27:33 +0200 Subject: [PATCH 03/11] handle incoming roster preauth --- include/mod_invites.hrl | 6 +- src/invites/mod_invites.erl | 138 +++++++++++++++++------------------- src/roster/mod_roster.erl | 25 +++++++ 3 files changed, 94 insertions(+), 75 deletions(-) diff --git a/include/mod_invites.hrl b/include/mod_invites.hrl index e69dfdb48f5..70d17f46b6e 100644 --- a/include/mod_invites.hrl +++ b/include/mod_invites.hrl @@ -2,11 +2,11 @@ -define(DEFAULT_TOKEN_EXPIRE_SECONDS, 5*86400). -define(DEFAULT_TOKEN_LENGTH, 24). --define(NS_INVITE_INVITE, <<"urn:xmpp:invite#invite">>). --define(NS_INVITE_CREATE_ACCOUNT, <<"urn:xmpp:invite#create-account">>). - -define(NS_FEATURE_IBR_TOKEN, <<"urn:xmpp:ibr-token:0">>). -define(NS_FEATURE_SUB_PRE_APPROVAL, <<"urn:xmpp:features:pre-approval">>). +-define(NS_INVITE_CREATE_ACCOUNT, <<"urn:xmpp:invite#create-account">>). +-define(NS_INVITE_INVITE, <<"urn:xmpp:invite#invite">>). +-define(NS_PARS, <<"urn:xmpp:pars:0">>). -define(OVERUSE_LIMIT, 1000). diff --git a/src/invites/mod_invites.erl b/src/invites/mod_invites.erl index c431108827a..6b2614a289b 100644 --- a/src/invites/mod_invites.erl +++ b/src/invites/mod_invites.erl @@ -31,9 +31,10 @@ -export([start/2, stop/1, hooks/1, config_spec/0, supported_features/0, deps/2]). %% hooks and callbacks --export([adhoc_commands/3, remove_user/3, stream_feature_register/3]). -%% -export([adhoc_commands/4, c2s_unauthenticated_packet/2, remove_user/3, -%% s2s_receive_packet/1, sm_receive_packet/1, stream_feature_register/2]). +-export([adhoc_commands/3, + %c2s_unauthenticated_packet/2, + remove_user/3, + s2s_receive_packet/3, user_receive_packet/3, stream_feature_register/3]). %% Service Discovery -export([disco_local_identity/3, disco_local_features/3, disco_local_items/3]). @@ -126,8 +127,8 @@ hooks(HostType) -> {disco_local_items, HostType, fun ?MODULE:disco_local_items/3, #{}, 50}, {disco_local_features, HostType, fun ?MODULE:disco_local_features/3, #{}, 50}, {disco_local_identity, HostType, fun ?MODULE:disco_local_identity/3, #{}, 50}, -% {s2s_receive_packet, HostType, fun ?MODULE:s2s_receive_packet/3, #{}, 50}, -% {sm_receive_packet, HostType, fun ?MODULE:sm_receive_packet/3, #{}, 50}, + {s2s_receive_packet, HostType, fun ?MODULE:s2s_receive_packet/3, #{}, 50}, + {user_receive_packet, HostType, fun ?MODULE:user_receive_packet/3, #{}, 50}, {c2s_stream_features, HostType, fun ?MODULE:stream_feature_register/3, #{}, 50}%, %% note the sequence below is important % {c2s_unauthenticated_packet, HostType, fun ?MODULE:c2s_unauthenticated_packet/3, #{}, 10} @@ -373,58 +374,52 @@ adhoc_commands(empty, adhoc_commands(Acc, _, _) -> {ok, Acc}. -%% -spec s2s_receive_packet({stanza() | drop, State}) -> -%% {stanza() | drop, State} | {stop, {drop, State}} -%% when State :: ejabberd_s2s_in:state(). -%% s2s_receive_packet({Stanza, State}) -> -%% case sm_receive_packet(Stanza) of -%% {stop, drop} -> -%% {stop, {drop, State}}; -%% Res -> -%% {Res, State} -%% end. - -%% -spec sm_receive_packet(stanza() | drop) -> stanza() | drop | {stop, drop}. -%% sm_receive_packet(#presence{from = From, -%% to = To, -%% type = subscribe, -%% sub_els = Els} = -%% Presence) -> -%% case handle_pre_auth_token(Els, To, From) of -%% true -> -%% {stop, drop}; -%% false -> -%% Presence -%% end; -%% sm_receive_packet(Other) -> -%% Other. - -%% handle_pre_auth_token([], _To, _From) -> -%% false; -%% handle_pre_auth_token([El | Els], -%% #jid{luser = LUser, lserver = LServer} = To, -%% FromFullJid) -> -%% From = jid:remove_resource(FromFullJid), -%% try xmpp:decode(El) of -%% #preauth{token = Token} = PreAuth -> -%% ?DEBUG("got preauth token: ~p", [PreAuth]), -%% case is_token_valid(LServer, Token, {LUser, LServer}) of -%% true -> -%% roster_add(To, From), -%% send_presence(To, From, subscribed), -%% send_presence(To, From, subscribe), -%% set_invitee(LServer, Token, From), -%% true; -%% false -> -%% ?INFO_MSG("Got invalid preauth token from ~s: ~p", [jid:encode(From), PreAuth]), -%% false -%% end; -%% _Other -> -%% handle_pre_auth_token(Els, To, From) -%% catch -%% _:{xmpp_codec, _} -> -%% handle_pre_auth_token(Els, To, From) -%% end. +-spec s2s_receive_packet(Acc, map(), any()) -> {ok|stop, Acc} when Acc :: mongoose_acc:t(). +s2s_receive_packet(Acc, Params, Extras) -> + user_receive_packet(Acc, Params, Extras). + +-spec user_receive_packet(Acc, map(), any()) -> {ok|stop, Acc} when Acc :: mongoose_acc:t(). +user_receive_packet(Acc, _Params, _Extras) -> + case maybe_handle_pre_auth_token(Acc) of + true -> + {stop, Acc}; + false -> + {ok, Acc} + end. + +maybe_handle_pre_auth_token(Acc) -> + case get_preauth_token(Acc) of + undefined -> + ?DEBUG("no preauth token", []), + false; + Token -> + ?DEBUG("got preauth token: ~p", [Token]), + #jid{luser = LUser, lserver = LServer} = To = jid:to_bare(mongoose_acc:to_jid(Acc)), + case is_token_valid(LServer, Token, {LUser, LServer}) of + true -> + ?DEBUG("got valid token! ~p", [Token]), + From = jid:to_bare(mongoose_acc:from_jid(Acc)), + ok = roster_add(LServer, To, From), + _Acc1 = send_presence(LServer, To, From, <<"subscribed">>), + _Acc2 = send_presence(LServer, To, From, <<"subscribe">>), + set_invitee(LServer, Token, From), + true; + false -> + ?INFO_MSG("Got invalid preauth token from ~s: ~p", + [jid:to_binary(mongoose_acc:from_jid(Acc)), Token]), + false + end + end. + +get_preauth_token(Acc) -> + case {mongoose_acc:stanza_name(Acc), mongoose_acc:stanza_type(Acc)} of + {<<"presence">>, <<"subscribe">>} -> + Presence = mongoose_acc:element(Acc), + ?DEBUG("got presence: ~p", [Presence]), + exml_query:path(Presence, [{element_with_ns, <<"preauth">>, ?NS_PARS}, {attr, <<"token">>}]); + _ -> + undefined + end. %%-------------------------------------------------------------------- %%| Service Disco @@ -547,8 +542,7 @@ is_token_valid(Host, Token, Inviter) -> set_invitee(Host, Token, #jid{} = InviteeJid) -> set_invitee(Host, Token, - jid:encode( - jid:remove_resource(InviteeJid)), + jid:to_bare_binary(InviteeJid), <<>>); set_invitee(Host, Token, Invitee) -> set_invitee(Host, Token, Invitee, <<>>). @@ -946,19 +940,19 @@ maybe_gen_sid(<<>>) -> maybe_gen_sid(SID) -> SID. -%% roster_add(UserJID, RosterItemJID) -> -%% RosterItem = -%% #roster_item{jid = RosterItemJID, -%% subscription = from, -%% ask = subscribe}, -%% mod_roster:set_item_and_notify_clients(UserJID, RosterItem, true). - -%% send_presence(From, To, Type) -> -%% Presence = -%% #presence{from = From, -%% to = To, -%% type = Type}, -%% ejabberd_router:route(Presence). +roster_add(Host, UserJID, RosterItemJID) -> + mod_roster:set_roster_entry(Host, UserJID, RosterItemJID, #{subscription => from, ask => subscribe}). + +send_presence(HostType, FromJid, ToJid, Type) -> + #jid{lserver =FromS} = FromJid, + Presence = #xmlel{name = <<"presence">>, + attrs = #{<<"from">> => jid:to_binary(FromJid), + <<"to">> => jid:to_binary(ToJid), + <<"type">> => Type}}, + AccParams = #{host_type => HostType, lserver => FromS, location => ?LOCATION, + element => Presence, from_jid => FromJid, to_jid => ToJid}, + Acc = mongoose_acc:new(AccParams), + mongoose_router:route(Acc). pretty_format_command_result({error, {module_not_loaded, ?MODULE, Host}}) -> {error, diff --git a/src/roster/mod_roster.erl b/src/roster/mod_roster.erl index 5324ac5d446..883ed9872f4 100644 --- a/src/roster/mod_roster.erl +++ b/src/roster/mod_roster.erl @@ -48,6 +48,7 @@ instrumentation/1, process_iq/5, get_roster_entry/4, + set_roster_entry/4, set_roster_entry/5, remove_from_roster/3, item_to_xml/1 @@ -893,6 +894,30 @@ set_items_t(HostType, JID, #xmlel{children = Els}) -> process_item_set_t(HostType, JID, El) end, Els). + +-spec set_roster_entry(mongooseim:host_type(), jid:jid(), jid:jid(), map()) -> ok | {error, any()}. +set_roster_entry(HostType, UserJid, ContactJid, Params) -> + UpdateF = update_item_from_params_f(Params), + set_roster_item(HostType, ContactJid, UserJid, UserJid, UpdateF). + +update_item_from_params_f(Params) -> + fun(Item) -> + maps:fold(fun(K, V, I) -> + maybe_update(K, V, I) + end, Item, Params) + end. + +maybe_update(name, Name, I) -> + I#roster{name = Name}; +maybe_update(group, Groups, I) -> + I#roster{groups = Groups}; +maybe_update(subscription, Subscription, I) -> + I#roster{subscription = Subscription}; +maybe_update(ask, Ask, I) -> + I#roster{ask = Ask}; +maybe_update(_, _, I) -> + I. + %% @doc add a contact to roster, or update -spec set_roster_entry(mongooseim:host_type(), jid:jid(), jid:jid(), binary(), [binary()]) -> ok | {error, any()}. From 339ebf8c8f8a0d5f8649678854ae9154194564a1 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Sat, 18 Jul 2026 16:37:34 +0200 Subject: [PATCH 04/11] this and that --- src/invites/mod_invites.erl | 168 +++++++++++++++++------------------- 1 file changed, 80 insertions(+), 88 deletions(-) diff --git a/src/invites/mod_invites.erl b/src/invites/mod_invites.erl index 6b2614a289b..4fefde4ea65 100644 --- a/src/invites/mod_invites.erl +++ b/src/invites/mod_invites.erl @@ -40,8 +40,15 @@ -export([disco_local_identity/3, disco_local_features/3, disco_local_items/3]). %% commands --export([cleanup_expired/0, delete_invite_by_token/2, expire_invites/2, expire_invite_by_token/2, generate_invite/1, - generate_invite/2, generate_reset_token/2, list_invites/1]). +-export([ +%% cleanup_expired/0, +%% delete_invite_by_token/2, +%% expire_invites/2, +%% expire_invite_by_token/2, + generate_invite/1, + generate_invite/2, +%% generate_reset_token/2, + list_invites/1]). %% helpers -export([create_account_allowed/2, create_account_invite/4, format_invite/2, @@ -145,31 +152,31 @@ stop(HostType) -> %%-------------------------------------------------------------------- %%| ejabberd command callbacks -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, - ?MYHOSTS). - --spec delete_invite_by_token(binary(), binary()) -> ok | {error, not_found}. -delete_invite_by_token(Host, Token) -> - pretty_format_command_result(try_db_call(Host, delete_invite_by_token, [Host, Token])). - --spec expire_invites(binary(), binary()) -> non_neg_integer(). -expire_invites(User0, Server0) -> - User = jid:nodeprep(User0), - Server = jid:nameprep(Server0), - pretty_format_command_result(try_db_call(Server, expire_tokens, [User, Server])). - --spec expire_invite_by_token(binary(), binary()) -> ok | {error, not_found}. -expire_invite_by_token(Host, Token) -> - pretty_format_command_result(try_db_call(Host, expire_invite_by_token, [Host, Token])). +%% 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, +%% ?MYHOSTS). + +%% -spec delete_invite_by_token(binary(), binary()) -> ok | {error, not_found}. +%% delete_invite_by_token(Host, Token) -> +%% pretty_format_command_result(try_db_call(Host, delete_invite_by_token, [Host, Token])). + +%% -spec expire_invites(binary(), binary()) -> non_neg_integer(). +%% expire_invites(User0, Server0) -> +%% User = jid:nodeprep(User0), +%% Server = jid:nameprep(Server0), +%% pretty_format_command_result(try_db_call(Server, expire_tokens, [User, Server])). + +%% -spec expire_invite_by_token(binary(), binary()) -> ok | {error, not_found}. +%% expire_invite_by_token(Host, Token) -> +%% pretty_format_command_result(try_db_call(Host, expire_invite_by_token, [Host, Token])). -spec generate_invite(binary()) -> {binary(), binary()} | {error, any()}. generate_invite(Host) -> @@ -180,33 +187,33 @@ generate_invite(AccountName, Host0) -> Host = jid:nameprep(Host0), lift(create_account_invite(Host, {<<>>, Host}, AccountName, false)). --ifdef(TEST). - --spec gen_invite(binary()) -> binary() | {error, any()}. -gen_invite(Host) -> - gen_invite(<<>>, Host). - --endif. - --spec gen_invite(binary(), binary()) -> {binary(), binary()} | {error, any()}. -gen_invite(AccountName, Host0) -> - Host = jid:nameprep(Host0), - case create_account_invite(Host, {<<>>, Host}, AccountName, false) of - {error, _Reason} = Error -> - Error; - Invite -> - {token_uri(Invite), landing_page(Host, Invite)} - end. - --spec generate_reset_token(binary(), binary()) -> {binary(), binary()} | {error, any()}. -generate_reset_token(User, Host) -> - Res = case create_reset_token(User, Host) of - {error, _Reason} = Error -> - Error; - Invite -> - {token_uri(Invite), landing_page(Host, Invite)} - end, - pretty_format_command_result(Res). +%% -ifdef(TEST). + +%% -spec gen_invite(binary()) -> binary() | {error, any()}. +%% gen_invite(Host) -> +%% gen_invite(<<>>, Host). + +%% -endif. + +%% -spec gen_invite(binary(), binary()) -> {binary(), binary()} | {error, any()}. +%% gen_invite(AccountName, Host0) -> +%% Host = jid:nameprep(Host0), +%% case create_account_invite(Host, {<<>>, Host}, AccountName, false) of +%% {error, _Reason} = Error -> +%% Error; +%% Invite -> +%% {token_uri(Invite), landing_page(Host, Invite)} +%% end. + +%% -spec generate_reset_token(binary(), binary()) -> {binary(), binary()} | {error, any()}. +%% generate_reset_token(User, Host) -> +%% Res = case create_reset_token(User, Host) of +%% {error, _Reason} = Error -> +%% Error; +%% Invite -> +%% {token_uri(Invite), landing_page(Host, Invite)} +%% end, +%% pretty_format_command_result(Res). list_invites(Host) -> try_db_call(Host, list_invites, [Host]). @@ -760,25 +767,25 @@ invite_token_t(Type, Host, Inviter, AccountName0) -> account_name = AccountName}, ExpireSeconds). --spec create_reset_token(binary(), binary()) -> invite_token() | {error, any()}. -create_reset_token(User, Host) -> - maybe - (#invite_token{} = ResetToken) ?= reset_token(User, Host), - F = fun() -> db_call(Host, create_invite_t, [ResetToken]) end, - transaction(Host, F) - end. - -reset_token(User, Host) -> - maybe - true ?= lists:member(Host, ?MYHOSTS) orelse {error, host_unknown}, - true ?= ejabberd_auth:user_exists(User, Host) orelse {error, user_not_exists}, - set_token_expires(#invite_token{token = - p1_rand:get_alphanum_string(?DEFAULT_TOKEN_LENGTH), - inviter = {<<>>, Host}, - type = reset_token, - account_name = User}, - gen_mod:get_module_opt(Host, ?MODULE, token_expire_seconds)) - end. +%% -spec create_reset_token(binary(), binary()) -> invite_token() | {error, any()}. +%% create_reset_token(User, Host) -> +%% maybe +%% (#invite_token{} = ResetToken) ?= reset_token(User, Host), +%% F = fun() -> db_call(Host, create_invite_t, [ResetToken]) end, +%% transaction(Host, F) +%% end. + +%% reset_token(User, Host) -> +%% maybe +%% true ?= lists:member(Host, ?MYHOSTS) orelse {error, host_unknown}, +%% true ?= ejabberd_auth:user_exists(User, Host) orelse {error, user_not_exists}, +%% set_token_expires(#invite_token{token = +%% p1_rand:get_alphanum_string(?DEFAULT_TOKEN_LENGTH), +%% inviter = {<<>>, Host}, +%% type = reset_token, +%% account_name = User}, +%% gen_mod:get_module_opt(Host, ?MODULE, token_expire_seconds)) +%% end. token_uri(#invite_token{type = roster_only, token = Token, @@ -953,18 +960,3 @@ send_presence(HostType, FromJid, ToJid, Type) -> element => Presence, from_jid => FromJid, to_jid => ToJid}, Acc = mongoose_acc:new(AccParams), mongoose_router:route(Acc). - -pretty_format_command_result({error, {module_not_loaded, ?MODULE, Host}}) -> - {error, - lists:flatten( - io_lib:format("Virtual host not known: ~s", [binary_to_list(Host)]))}; -pretty_format_command_result({error, host_unknown}) -> - {error, "Virtual host not known"}; -pretty_format_command_result({error, user_exists}) -> - {error, "Username already taken"}; -pretty_format_command_result({error, user_not_exists}) -> - {error, "User does not exist"}; -pretty_format_command_result({ok, Result}) -> - Result; -pretty_format_command_result(Result) -> - Result. From 895823a3345af672bbc97e32c9352fa52d3ff006 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Sun, 19 Jul 2026 13:33:38 +0200 Subject: [PATCH 05/11] handle register iq on unauthenticated stream --- src/invites/mod_invites.erl | 24 +- src/invites/mod_invites_register.erl | 397 ++++++++++++--------------- src/mod_register.erl | 32 ++- 3 files changed, 208 insertions(+), 245 deletions(-) diff --git a/src/invites/mod_invites.erl b/src/invites/mod_invites.erl index 4fefde4ea65..82dd59dbcfc 100644 --- a/src/invites/mod_invites.erl +++ b/src/invites/mod_invites.erl @@ -31,9 +31,7 @@ -export([start/2, stop/1, hooks/1, config_spec/0, supported_features/0, deps/2]). %% hooks and callbacks --export([adhoc_commands/3, - %c2s_unauthenticated_packet/2, - remove_user/3, +-export([adhoc_commands/3, user_send_xmlel/3, remove_user/3, s2s_receive_packet/3, user_receive_packet/3, stream_feature_register/3]). %% Service Discovery @@ -54,8 +52,8 @@ -export([create_account_allowed/2, create_account_invite/4, format_invite/2, get_invite/2, get_invites_tree_t/2, get_max_invites/2, is_create_allowed/2, is_expired/1, is_reserved/3, is_token_valid/2, - %roster_add/2, - %send_presence/3, + %roster_add/3, + %send_presence/4, set_invitee/3, set_invitee/5, token_uri/1, transaction/2, xdata_field/3]). @@ -136,9 +134,9 @@ hooks(HostType) -> {disco_local_identity, HostType, fun ?MODULE:disco_local_identity/3, #{}, 50}, {s2s_receive_packet, HostType, fun ?MODULE:s2s_receive_packet/3, #{}, 50}, {user_receive_packet, HostType, fun ?MODULE:user_receive_packet/3, #{}, 50}, - {c2s_stream_features, HostType, fun ?MODULE:stream_feature_register/3, #{}, 50}%, + {c2s_stream_features, HostType, fun ?MODULE:stream_feature_register/3, #{}, 50}, %% note the sequence below is important -% {c2s_unauthenticated_packet, HostType, fun ?MODULE:c2s_unauthenticated_packet/3, #{}, 10} + {user_send_xmlel, HostType, fun ?MODULE:user_send_xmlel/3, #{}, 10} ]. start(HostType, Opts) -> @@ -423,7 +421,7 @@ get_preauth_token(Acc) -> {<<"presence">>, <<"subscribe">>} -> Presence = mongoose_acc:element(Acc), ?DEBUG("got presence: ~p", [Presence]), - exml_query:path(Presence, [{element_with_ns, <<"preauth">>, ?NS_PARS}, {attr, <<"token">>}]); + get_pars_token(Presence); _ -> undefined end. @@ -509,8 +507,8 @@ disco_local_items(Acc, _Params, _Extra) -> stream_feature_register(Acc, #{lserver := Host}, _) -> {ok, mod_invites_register:stream_feature_register(Acc, Host)}. -c2s_unauthenticated_packet(State, IQ) -> - mod_invites_register:c2s_unauthenticated_packet(State, IQ). +user_send_xmlel(Acc, Params, Extras) -> + mod_invites_register:user_send_xmlel(Acc, Params, Extras). %%-------------------------------------------------------------------- @@ -554,10 +552,11 @@ set_invitee(Host, Token, #jid{} = InviteeJid) -> set_invitee(Host, Token, Invitee) -> set_invitee(Host, Token, Invitee, <<>>). +-spec set_invitee(binary(), binary(), binary(), binary()) -> ok. set_invitee(Host, Token, Invitee, AccountName) -> set_invitee(fun() -> ok end, Host, Token, Invitee, AccountName). --spec set_invitee(binary(), binary(), binary(), binary()) -> ok. +-spec set_invitee(fun(() -> ok | {error, any()}), binary(), binary(), binary(), binary()) -> ok. set_invitee(F, Host, Token, Invitee, AccountName) -> %% This invalidates the invite token if Invitee isn't empty db_call(Host, set_invitee, [F, Host, Token, Invitee, AccountName]). @@ -960,3 +959,6 @@ send_presence(HostType, FromJid, ToJid, Type) -> element => Presence, from_jid => FromJid, to_jid => ToJid}, Acc = mongoose_acc:new(AccParams), mongoose_router:route(Acc). + +get_pars_token(Xmlel) -> + exml_query:path(Xmlel, [{element_with_ns, <<"preauth">>, ?NS_PARS}, {attr, <<"token">>}]). diff --git a/src/invites/mod_invites_register.erl b/src/invites/mod_invites_register.erl index 8eb2b405cf7..b854dd18b6c 100644 --- a/src/invites/mod_invites_register.erl +++ b/src/invites/mod_invites_register.erl @@ -26,32 +26,15 @@ -author('stefan@strigler.de'). -%% -export([c2s_unauthenticated_packet/2, stream_feature_register/2]). +-export([user_send_xmlel/3, stream_feature_register/2]). %% -export([try_register/6]). --export([stream_feature_register/2]). - -import(mod_invites, [roster_add/2, send_presence/3, xdata_field/3]). -include("mongoose.hrl"). -include("mod_invites.hrl"). -include("jlib.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([#xmlel{}], binary()) -> [#xmlel{}]. stream_feature_register(Acc, Host) -> case gen_mod:get_module_opt(Host, mod_invites, access_create_account) of @@ -61,85 +44,80 @@ stream_feature_register(Acc, Host) -> [#xmlel{name = <<"register">>, attrs = #{<<"xmlns">> => ?NS_FEATURE_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)), -%% Meta = xmpp:get_meta(IQ2), -%% ResIQ = -%% mod_register:process_iq( -%% xmpp:set_meta(IQ2, Meta#{pre_auth => true})), -%% 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 = [_], -%% lang = Lang} = -%% 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}} -> -%% #{ip := IP} = State, -%% {Address, _} = IP, -%% case try_register(Invite, Username, Server, Password, Address, Lang) of -%% {ok, UpdatedInvite} -> -%% ResState = State#{invite => UpdatedInvite}, -%% {stop, ejabberd_c2s:send(ResState, xmpp:make_iq_result(IQ))}; -%% {error, #stanza_error{} = Err} -> -%% ResIQ = make_stripped_error(IQ, #register{}, Err), -%% {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. +-spec user_send_xmlel(mongoose_acc:t(), mongoose_c2s_hooks:params(), gen_hook:extra()) -> + mongoose_c2s_hooks:result(). +user_send_xmlel(Acc, Params, Extra) -> + case mongoose_acc:stanza_name(Acc) of + <<"iq">> -> + {Iq, Acc1} = mongoose_iq:info(Acc), + handle_unauthenticated_iq(Acc1, Params, Extra, Iq); + _ -> {ok, Acc} + end. + +handle_unauthenticated_iq(Acc, + #{c2s_data := StateData}, + #{host_type := _HostType}, + #iq{type = set, xmlns=?NS_PARS} = IQ) -> + Token = exml_query:path(mongoose_iq:iq_to_sub_el(IQ), [{attr, <<"token">>}], <<>>), + LServer = mongoose_c2s:get_lserver(StateData), + %% invite is stored in state (ResAcc) so we have access at next step + {ResAcc, ResIQ} = process_token(Acc, LServer, Token, IQ), + Res = make_iq_response_acc(ResIQ, ResAcc, jid:make_noprep(<<>>, LServer, <<>>)), + {stop, Res}; +handle_unauthenticated_iq(Acc, + #{c2s_data := StateData}, + #{host_type := HostType}, + #iq{type = set, xmlns=?NS_REGISTER, lang = Lang} = IQ) -> + LServer = mongoose_c2s:get_lserver(StateData), + FromServer = jid:make_noprep(<<>>, LServer, <<>>), + case mongoose_c2s:get_mod_state(StateData, mod_invites) of + {ok, Invite} -> + case check_form(mongoose_iq:iq_to_sub_el(IQ)) of + {ok, {Username, Password}} -> + {Address, _} = mongoose_c2s:get_ip(StateData), + case try_register_or_reset(Invite, Username, LServer, Password, Address, Lang) of + {ok, UpdatedInvite} -> + NewAcc = mongoose_c2s_acc:to_acc(Acc, state_mod, {mod_invites, UpdatedInvite}), + {stop, make_iq_response_acc(IQ, NewAcc, FromServer)}; + {error, Err} -> + ResIQ = error_response(IQ, Err), + {stop, make_iq_response_acc(ResIQ, Acc, FromServer)} + end; + {error, BadRes} -> + ?LOG_INFO(#{what => invites_iq_set_register_check_form, host => HostType, value => BadRes}), + ResIQ = error_response(IQ, mongoose_xmpp_errors:bad_request()), + {stop, make_iq_response_acc(ResIQ, Acc, FromServer)} + end; + _ -> + %% This is to protect regular IBR (w/0 token, if enabled) from taking a reserved name + case check_form(mongoose_iq:iq_to_sub_el(IQ)) of + {ok, {Username, _Password}} -> + case mod_invites:is_reserved(LServer, <<>>, Username) of + true -> + ResIQ = error_response(IQ, mongoose_xmpp_errors:not_allowed()), + {stop, make_iq_response_acc(ResIQ, Acc, FromServer)}; + false -> + {ok, Acc} + end; + _ -> + {ok, Acc} + end + end; +handle_unauthenticated_iq(Acc, _Params, _Extra, _IQ) -> + {ok, Acc}. + +make_iq_response_acc(IQ, Acc, From) -> + make_iq_response_acc(IQ, Acc, From, #jid{}). + +make_iq_response_acc(IQ, Acc, From, To) -> + Response = set_sender(jlib:iq_to_xml(IQ), From), + AccParams = #{from_jid => From, to_jid => To, element => Response}, + ResponseAcc = mongoose_acc:update_stanza(AccParams, Acc), + mongoose_c2s_acc:to_acc(Acc, route, ResponseAcc). + -make_stripped_error(IQ, SubTag, Err) -> - xmpp:make_error( - xmpp:remove_subtag(IQ, SubTag), Err). +set_sender(#xmlel{attrs = A} = Stanza, #jid{} = From) -> + Stanza#xmlel{attrs = A#{<<"from">> => jid:to_binary(From)}}. maybe_create_mutual_subscription(#invite_token{inviter = {User, _Server}, type = Type}) when User == <<>>; % server token @@ -147,8 +125,8 @@ maybe_create_mutual_subscription(#invite_token{inviter = {User, _Server}, type = noop; maybe_create_mutual_subscription(#invite_token{inviter = {User, Server}, invitee = Invitee}) -> - InviterJID = jid:make(User, Server), - InviteeJID = jid:decode(Invitee), + InviterJID = jid:make_bare(User, Server), + InviteeJID = jid:to_binary(Invitee), roster_add(InviterJID, InviteeJID), roster_add(InviteeJID, InviterJID), send_presence(InviteeJID, InviterJID, subscribe), @@ -157,15 +135,14 @@ maybe_create_mutual_subscription(#invite_token{inviter = {User, Server}, send_presence(InviteeJID, InviterJID, subscribed), ok. -%% process_token(#{server := Host} = State, Token, #iq{lang = Lang} = IQ) -> -%% ?DEBUG("processing token (~s): ~s", [Host, Token]), -%% case can_create_account_or_change_pw(Host, Token) of -%% {true, Invite} -> -%% NewState = State#{invite => Invite}, -%% {NewState, xmpp:make_iq_result(IQ)}; -%% false -> -%% {State, preauth_invalid(IQ, Lang)} -%% end. +process_token(Acc, Host, Token, #iq{lang = Lang} = IQ) -> + case can_create_account_or_change_pw(Host, Token) of + {true, Invite} -> + NewAcc = mongoose_c2s_acc:to_acc(Acc, state_mod, {mod_invites, Invite}), + {NewAcc, mongoose_iq:empty_result_iq(IQ)}; + false -> + {Acc, preauth_invalid(IQ, Lang)} + end. can_create_account_or_change_pw(Host, Token) -> try mod_invites:is_token_valid(Host, Token) of @@ -207,129 +184,107 @@ create_account_allowed(#invite_token{inviter = {<<>>, _Host}}) -> create_account_allowed(#invite_token{inviter = {User, Host}}) -> mod_invites:create_account_allowed(Host, jid:make(User, Host)) == ok. -%% preauth_invalid(IQ, Lang) -> -%% Text = ?BIN("The token provided is either invalid or expired."), -%% make_stripped_error(IQ, #preauth{}, xmpp:err_item_not_found(Text, Lang)). - -%% -spec try_register(mod_invites:invite_token(), -%% binary(), -%% binary(), -%% binary(), -%% tuple(), -%% binary()) -> -%% {ok, mod_invites:invite_token()} | {error, stanza_error()}. -%% try_register(#invite_token{type = reset_token} = Invite, -%% User, -%% Server, -%% Password, -%% _Source, -%% Lang) -> -%% case Invite#invite_token.account_name == User of -%% true -> -%% ChPwF = fun() -> mod_register:try_set_password(User, Server, Password) end, -%% NewInvite = -%% #invite_token{invitee = Invitee} = -%% maybe_set_invitee(Invite, jid:make(User, Server)), -%% case mod_invites:set_invitee(ChPwF, Server, Invite#invite_token.token, Invitee, User) of -%% ok -> -%% {ok, NewInvite}; -%% {error, Why} -> -%% {error, to_xmpp_error(Why, Lang)} -%% end; -%% false -> -%% {error, to_xmpp_error(not_allowed, Lang)} -%% end; -%% try_register(Invite, User, Server, Password, Source, Lang) -> -%% #invite_token{token = Token} = Invite, -%% case {jid:nodeprep(User), not mod_invites:is_reserved(Server, Token, User)} of -%% {error, _} -> -%% {error, to_xmpp_error(invalid_jid, Lang)}; -%% {_, false} -> -%% {error, to_xmpp_error(not_allowed, Lang)}; -%% {_, true} -> -%% RegF = -%% fun() -> -%% mod_register:try_register(User, Server, Password, Source, mod_invites, Lang) -%% end, -%% NewInvite = -%% #invite_token{invitee = Invitee, account_name = AccountName} = -%% maybe_set_account_name(maybe_set_invitee(Invite, jid:make(User, Server)), User), -%% case mod_invites:set_invitee(RegF, Server, Token, Invitee, AccountName) of -%% ok -> -%% maybe_create_mutual_subscription(NewInvite), -%% {ok, NewInvite}; -%% {error, conflict} -> -%% ?LOG_WARNING("Conflict when redeeming invite token: ~p", [NewInvite]), -%% {error, to_xmpp_error(conflict, Lang)}; -%% {error, Why} -> -%% {error, to_xmpp_error(Why, Lang)} -%% end -%% end. +preauth_invalid(IQ, _Lang) -> + Text = ?BIN("The token provided is either invalid or expired."), + error_response(IQ, mongoose_xmpp_errors:item_not_found(Text)). -to_xmpp_error(Why, Lang) when Why == not_allowed; Why == invalid_password -> - xmpp:err_not_allowed( - mod_register:format_error(Why), Lang); -to_xmpp_error(weak_password = Why, Lang) -> - xmpp:err_not_acceptable( - mod_register:format_error(Why), Lang); -to_xmpp_error(invalid_jid = Why, Lang) -> - xmpp:err_jid_malformed( - mod_register:format_error(Why), Lang); -to_xmpp_error(db_failure = Why, Lang) -> - xmpp:err_internal_server_error( - mod_register:format_error(Why), Lang); -to_xmpp_error(conflict, Lang) -> - xmpp:err_conflict( - mod_register:format_error(not_allowed), Lang); -to_xmpp_error(Unexpected, Lang) -> - xmpp:err_internal_server_error( - mod_register:format_error(Unexpected), Lang). +-spec try_register_or_reset(mod_invites:invite_token(), + binary(), + binary(), + binary(), + tuple(), + binary()) -> + {ok, mod_invites:invite_token()} | {error, exml:element()}. +try_register_or_reset(#invite_token{type = reset_token} = Invite, + User, + Server, + Password, + _Source, + Lang) -> + case Invite#invite_token.account_name == User of + true -> + ChPwF = fun() -> mod_register:try_set_password(User, Server, Password) end, + NewInvite = + #invite_token{invitee = Invitee} = + maybe_set_invitee(Invite, jid:make(User, Server)), + case mod_invites:set_invitee(ChPwF, Server, Invite#invite_token.token, Invitee, User) of + ok -> + {ok, NewInvite}; + {error, #xmlel{} = XmlEl} -> + {error, XmlEl} + end; + false -> + {error, to_xmpp_error(not_allowed, Lang)} + end; +try_register_or_reset(Invite, User, Server, Password, Source, Lang) -> + #invite_token{token = Token} = Invite, + case {jid:nodeprep(User), not mod_invites:is_reserved(Server, Token, User)} of + {error, _} -> + {error, to_xmpp_error(invalid_jid, Lang)}; + {_, false} -> + {error, to_xmpp_error(not_allowed, Lang)}; + {_, true} -> + UserJid = jid:make_bare(User, Server), + RegF = + fun() -> + mod_register:verify_password_and_register( + Server, UserJid, Password, Source) + end, + NewInvite = + #invite_token{invitee = Invitee, account_name = AccountName} = + maybe_set_account_name( + maybe_set_invitee(Invite, UserJid), + User), + case mod_invites:set_invitee(RegF, Server, Token, Invitee, AccountName) of + ok -> + maybe_create_mutual_subscription(NewInvite), + {ok, NewInvite}; + {error, conflict} -> + ?LOG_WARNING("Conflict when redeeming invite token: ~p", [NewInvite]), + {error, to_xmpp_error(conflict, Lang)}; + {error, #xmlel{} = XmlEl} -> + {error, XmlEl} + 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}. +to_xmpp_error(Why, _Lang) when Why == not_allowed; Why == invalid_password -> + mongoose_xmpp_errors:not_allowed(); +to_xmpp_error(weak_password = _Why, _Lang) -> + mongoose_xmpp_errors:not_acceptable(); +to_xmpp_error(invalid_jid = _Why, _Lang) -> + mongoose_xmpp_errors:jid_malformed(); +to_xmpp_error(db_failure = _Why, _Lang) -> + mongoose_xmpp_errors:internal_server_error(); +to_xmpp_error(conflict, _Lang) -> + mongoose_xmpp_errors:conflict(); +to_xmpp_error(_Unexpected, _Lang) -> + mongoose_xmpp_errors:internal_server_error(). -%% 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} -> -%% {ok, {Username, Password}} -%% end. +check_form(XmlEl) -> + case + { + exml_query:path(XmlEl, [{element, <<"username">>}, cdata]), + exml_query:path(XmlEl, [{element, <<"password">>}, cdata]) + } + of + {Username, Password} when is_binary(Username), + is_binary(Password) -> + {ok, {Username, Password}}; + BadRes -> + {error, {bad_form, BadRes}} + end. maybe_set_invitee(#invite_token{type = roster_only} = Invite, _Invitee) -> Invite; maybe_set_invitee(Invite, Invitee) -> - Invite#invite_token{invitee = jid:encode(Invitee)}. + Invite#invite_token{invitee = jid:to_binary(Invitee)}. maybe_set_account_name(#invite_token{type = roster_only} = Invite, AccountName) -> Invite#invite_token{account_name = AccountName}; maybe_set_account_name(Invite, _AccountName) -> Invite. + +error_response(Request, Reasons) when is_list(Reasons) -> + Request#iq{type = error, sub_el = Reasons}; +error_response(Request, Reason) -> + Request#iq{type = error, sub_el = Reason}. diff --git a/src/mod_register.erl b/src/mod_register.erl index 3c887ba201a..402ab33ccc7 100644 --- a/src/mod_register.erl +++ b/src/mod_register.erl @@ -38,6 +38,8 @@ %% API -export([try_register/5, + try_set_password/3, + verify_password_and_register/4, process_ip_access/1, process_welcome_message/1]). @@ -312,38 +314,37 @@ process_iq_get(_HostType, From, _To, #iq{sub_el = Child} = IQ, _Source) -> try_register_or_set_password(HostType, LUser, Server, Password, #jid{luser = LUser, lserver = Server} = UserJID, IQ, SubEl, _Source) -> - try_set_password(HostType, UserJID, Password, IQ, SubEl); + handle_register_response( + try_set_password(HostType, UserJID, Password), + IQ, SubEl); try_register_or_set_password(HostType, LUser, Server, Password, _From, IQ, SubEl, Source) -> case check_timeout(Source) of true -> - case try_register(HostType, LUser, Server, Password, Source) of - ok -> - IQ#iq{type = result, sub_el = [SubEl]}; - {error, Error} -> - error_response(IQ, [SubEl, Error]) - end; + handle_register_response( + try_register(HostType, LUser, Server, Password, Source), + IQ, SubEl); false -> ErrText = <<"Users are not allowed to register accounts so quickly">>, error_response(IQ, mongoose_xmpp_errors:resource_constraint(ErrText)) end. %% @doc Try to change password and return IQ response -try_set_password(HostType, #jid{} = UserJID, Password, IQ, SubEl) -> +try_set_password(HostType, #jid{} = UserJID, Password) -> case is_strong_password(HostType, Password) of true -> case ejabberd_auth:set_password(UserJID, Password) of ok -> - IQ#iq{type = result, sub_el = [SubEl]}; + ok; {error, empty_password} -> - error_response(IQ, [SubEl, mongoose_xmpp_errors:bad_request()]); + {error, mongoose_xmpp_errors:bad_request()}; {error, not_allowed} -> - error_response(IQ, [SubEl, mongoose_xmpp_errors:not_allowed()]); + {error, mongoose_xmpp_errors:not_allowed()}; {error, invalid_jid} -> - error_response(IQ, [SubEl, mongoose_xmpp_errors:item_not_found()]) + {error, mongoose_xmpp_errors:item_not_found()} end; false -> ErrText = <<"The password is too weak">>, - error_response(IQ, [SubEl, mongoose_xmpp_errors:not_acceptable(ErrText)]) + {error, mongoose_xmpp_errors:not_acceptable(ErrText)} end. try_register(HostType, User, Server, Password, SourceRaw) -> @@ -477,6 +478,11 @@ is_strong_password(HostType, Password) -> true end. +handle_register_response(ok, IQ, SubEl) -> + IQ#iq{type = result, sub_el = [SubEl]}; +handle_register_response({error, Error}, IQ, SubEl) -> + error_response(IQ, [SubEl, Error]). + %%% %%% ip_access management %%% From 2a8923e2809081e46f8979ff1d0603d86ab89af0 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Sun, 19 Jul 2026 13:43:14 +0200 Subject: [PATCH 06/11] some cleanup --- src/invites/d.erl | 6 ------ src/invites/mod_invites.erl | 23 +++-------------------- src/invites/mod_invites_register.erl | 2 +- 3 files changed, 4 insertions(+), 27 deletions(-) delete mode 100644 src/invites/d.erl diff --git a/src/invites/d.erl b/src/invites/d.erl deleted file mode 100644 index fd3399ec278..00000000000 --- a/src/invites/d.erl +++ /dev/null @@ -1,6 +0,0 @@ --module(d). - --compile(export_all). - -trace(Mod) -> - recon_trace:calls({Mod, '_', '_'}, 1000, [{scope, local}]). diff --git a/src/invites/mod_invites.erl b/src/invites/mod_invites.erl index 82dd59dbcfc..67d6755b729 100644 --- a/src/invites/mod_invites.erl +++ b/src/invites/mod_invites.erl @@ -49,13 +49,9 @@ list_invites/1]). %% helpers --export([create_account_allowed/2, create_account_invite/4, format_invite/2, - get_invite/2, get_invites_tree_t/2, +-export([create_account_allowed/2, create_account_invite/4, format_invite/2, get_invite/2, get_invites_tree_t/2, get_max_invites/2, is_create_allowed/2, is_expired/1, is_reserved/3, is_token_valid/2, - %roster_add/3, - %send_presence/4, - set_invitee/3, set_invitee/5, token_uri/1, transaction/2, - xdata_field/3]). + roster_add/3, send_presence/4, set_invitee/3, set_invitee/5, token_uri/1, transaction/2]). -ifdef(TEST). -export([create_roster_invite/2, create_reset_token/2, find_invites_tree_root_t/4, gen_invite/1, @@ -117,9 +113,7 @@ config_spec() -> }. deps(_Host, _Opts) -> - %% TODO - % [{mod_adhoc, #{}, soft}, {mod_register, #{}, soft}, {mod_roster, #{}, soft}]. - []. + [{mod_adhoc, #{}, soft}, {mod_register, #{}, soft}, {mod_roster, #{}, soft}]. -spec supported_features() -> [atom()]. supported_features() -> @@ -868,17 +862,6 @@ set_token_expires(#invite_token{created_at = CreatedAt} = Invite, ExpireSecs) -> calendar:gregorian_seconds_to_datetime(calendar:datetime_to_gregorian_seconds(CreatedAt) + ExpireSecs)}. -xdata_field(_Field, [], Default) -> - Default; -xdata_field(Field, [El | Fields], Default) -> - case exml_query:paths(El, [{element_with_attr, <<"var">>, Field}, {element, <<"value">>}, cdata]) of - [<<>> | _] -> Default; - [Value | _] -> - Value; - [] -> - xdata_field(Field, Fields, Default) - end. - maybe_add_landing_url(Host, Invite, Lang, Fields) -> case landing_page(Host, Invite) of <<>> -> diff --git a/src/invites/mod_invites_register.erl b/src/invites/mod_invites_register.erl index b854dd18b6c..e0c52bcbc10 100644 --- a/src/invites/mod_invites_register.erl +++ b/src/invites/mod_invites_register.erl @@ -29,7 +29,7 @@ -export([user_send_xmlel/3, stream_feature_register/2]). %% -export([try_register/6]). --import(mod_invites, [roster_add/2, send_presence/3, xdata_field/3]). +-import(mod_invites, [roster_add/2, send_presence/3]). -include("mongoose.hrl"). -include("mod_invites.hrl"). From 9ea429f2d046e3c5d7f0fbe37abcc6beb56d36cf Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Sun, 19 Jul 2026 15:25:09 +0200 Subject: [PATCH 07/11] add commands --- priv/graphql/schemas/admin/invites.gql | 22 ++- ...ongoose_graphql_invites_admin_mutation.erl | 48 ++++- .../mongoose_graphql_invites_admin_query.erl | 10 +- src/invites/mod_invites.erl | 181 +++++++++--------- 4 files changed, 153 insertions(+), 108 deletions(-) diff --git a/priv/graphql/schemas/admin/invites.gql b/priv/graphql/schemas/admin/invites.gql index 5b9af24f896..5fb57bc2dae 100644 --- a/priv/graphql/schemas/admin/invites.gql +++ b/priv/graphql/schemas/admin/invites.gql @@ -3,8 +3,28 @@ Allow admin to manage invites. """ type InvitesAdminMutation @use(modules: ["mod_invites"]) @protected{ "Generate Account Creation Invite for given XMPP hostname" - generateInvite(host: DomainName!): Invite + generateInvite(host: DomainName!, username: String): Invite @protected(type: DOMAIN, args: ["host"]) @use(args: ["host"]) + + "Cleanup all expired tokens accross all hosts. Be careful since this will affect traceability and reset all acccounts max invites settings" + cleanupExpired: Int + @protected(type: DOMAIN, args: []) @use(args: []) + + "Delete an invite using the associated token." + deleteInviteByToken(host: DomainName!, token: String!): String + @protected(type: DOMAIN, args: ["host", "token"]) @use(args: ["host", "token"]) + + "Expire all tokens for a given user, this way they can't be used anymore." + expireInvites(host: DomainName!, username: String!): Int + @protected(type: DOMAIN, args: ["host", "username"]) @use(args: ["host", "username"]) + + "Expire a specific invite by associated token." + expireInviteByToken(host: DomainName!, token: String!): String + @protected(type: DOMAIN, args: ["host", "token"]) @use(args: ["host", "token"]) + + "Generate a reset token for a useraccount, it can be used to reset a lost password." + generateResetToken(host: DomainName!, username: String!): Invite + @protected(type: Domain, args: ["host", "username"]) @use(args: ["host", "username"]) } """ diff --git a/src/graphql/admin/mongoose_graphql_invites_admin_mutation.erl b/src/graphql/admin/mongoose_graphql_invites_admin_mutation.erl index 73edbf7b4a6..b7f258d458d 100644 --- a/src/graphql/admin/mongoose_graphql_invites_admin_mutation.erl +++ b/src/graphql/admin/mongoose_graphql_invites_admin_mutation.erl @@ -11,13 +11,47 @@ -import(mongoose_graphql_helper, [make_error/2, format_result/2]). execute(_Ctx, _Obj, <<"generateInvite">>, Args) -> - generate_invite(Args). + generate_invite(Args); +execute(_Ctx, _Obj, <<"cleanupExpired">>, Args) -> + cleanup_expired(Args); +execute(_Ctx, _Obj, <<"deleteInviteByToken">>, Args) -> + delete_invite_by_token(Args); +execute(_Ctx, _Obj, <<"expireInvites">>, Args) -> + expire_invites(Args); +execute(_Ctx, _Obj, <<"expireInviteByToken">>, Args) -> + expire_invite_by_token(Args); +execute(_Ctx, _Obj, <<"generateResetToken">>, Args) -> + generate_reset_token(Args). + +cleanup_expired(_) -> + {ok, mod_invites:cleanup_expired()}. + +delete_invite_by_token(#{<<"host">> := Host, <<"token">> := Token}) -> + handle_cmd_result(mod_invites:delete_invite_by_token(Host, Token), Host). + +expire_invites(#{<<"host">> := Host, <<"username">> := Username}) -> + handle_cmd_result(mod_invites:expire_invites(Host, Username), Host). + +expire_invite_by_token(#{<<"host">> := Host, <<"token">> := Token}) -> + handle_cmd_result(mod_invites:expire_invite_by_token(Host, Token), Host). -spec generate_invite(map()) -> {ok, map()} | {error, resolver_error()}. -generate_invite(#{<<"host">> := Host}) -> - case mod_invites:generate_invite(Host) of - {ok, Invite} -> - {ok, mod_invites:format_invite(Host, Invite)}; - Err -> - make_error(Err, #{host => Host}) +generate_invite(#{<<"host">> := Host, <<"username">> := Username0}) -> + Username = null_to_bin(Username0), + case mod_invites:generate_invite(Host, Username) of + {error, _} = Error -> + make_error(Error, #{host => Host}); + Invite -> + {ok, mod_invites:format_invite(Host, Invite)} end. + +generate_reset_token(#{<<"host">> := Host, <<"username">> := Username}) -> + handle_cmd_result(mod_invites:generate_reset_token(Host, Username), Host). + +null_to_bin(null) -> <<>>; +null_to_bin(Bin) when is_binary(Bin) -> Bin. + +handle_cmd_result({error, _} = Error, Host) -> + make_error(Error, #{host => Host}); +handle_cmd_result(Result, _) -> + {ok, Result}. diff --git a/src/graphql/admin/mongoose_graphql_invites_admin_query.erl b/src/graphql/admin/mongoose_graphql_invites_admin_query.erl index 35904d4c532..5d49b55d386 100644 --- a/src/graphql/admin/mongoose_graphql_invites_admin_query.erl +++ b/src/graphql/admin/mongoose_graphql_invites_admin_query.erl @@ -14,11 +14,11 @@ execute(_Ctx, _Obj, <<"listInvites">>, Args) -> -spec list_invites(map()) -> {ok, [map()]} | {error, resolver_error()}. list_invites(#{<<"host">> := Host}) -> - case mod_invites:list_invites(Host) of - {ok, Invites} -> - {ok, [{ok, mod_invites:format_invite(Host, Invite)} || Invite <- sort(Invites)]}; - Err -> - make_error(Err, #{host => Host}) + case mod_invites:pretty_format_command_result(mod_invites:list_invites(Host)) of + {error, _} = Error -> + make_error(Error, #{host => Host}); + Invites -> + {ok, [{ok, mod_invites:format_invite(Host, Invite)} || Invite <- sort(Invites)]} end. sort(Invites) -> diff --git a/src/invites/mod_invites.erl b/src/invites/mod_invites.erl index 67d6755b729..023e39bcbed 100644 --- a/src/invites/mod_invites.erl +++ b/src/invites/mod_invites.erl @@ -39,24 +39,19 @@ %% commands -export([ -%% cleanup_expired/0, -%% delete_invite_by_token/2, -%% expire_invites/2, -%% expire_invite_by_token/2, - generate_invite/1, - generate_invite/2, -%% generate_reset_token/2, - list_invites/1]). + cleanup_expired/0, + delete_invite_by_token/2, + expire_invite_by_token/2, + expire_invites/2, + generate_invite/2, + generate_reset_token/2, + list_invites/1]). %% helpers -export([create_account_allowed/2, create_account_invite/4, format_invite/2, get_invite/2, get_invites_tree_t/2, get_max_invites/2, is_create_allowed/2, is_expired/1, is_reserved/3, is_token_valid/2, - roster_add/3, send_presence/4, set_invitee/3, set_invitee/5, token_uri/1, transaction/2]). - --ifdef(TEST). --export([create_roster_invite/2, create_reset_token/2, find_invites_tree_root_t/4, gen_invite/1, - gen_invite/2, get_invites/2, get_invites_tree_as_root_t/2, is_token_valid/3]). --endif. + pretty_format_command_result/1, roster_add/3, send_presence/4, set_invitee/3, set_invitee/5, token_uri/1, + transaction/2]). -include("mongoose.hrl"). -include("mongoose_config_spec.hrl"). @@ -144,68 +139,37 @@ stop(HostType) -> %%-------------------------------------------------------------------- %%| ejabberd command callbacks -%% 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, -%% ?MYHOSTS). - -%% -spec delete_invite_by_token(binary(), binary()) -> ok | {error, not_found}. -%% delete_invite_by_token(Host, Token) -> -%% pretty_format_command_result(try_db_call(Host, delete_invite_by_token, [Host, Token])). - -%% -spec expire_invites(binary(), binary()) -> non_neg_integer(). -%% expire_invites(User0, Server0) -> -%% User = jid:nodeprep(User0), -%% Server = jid:nameprep(Server0), -%% pretty_format_command_result(try_db_call(Server, expire_tokens, [User, Server])). - -%% -spec expire_invite_by_token(binary(), binary()) -> ok | {error, not_found}. -%% expire_invite_by_token(Host, Token) -> -%% pretty_format_command_result(try_db_call(Host, expire_invite_by_token, [Host, Token])). - --spec generate_invite(binary()) -> {binary(), binary()} | {error, any()}. -generate_invite(Host) -> - generate_invite(<<>>, Host). +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, + ?MYHOSTS). + +-spec delete_invite_by_token(binary(), binary()) -> ok | {error, not_found}. +delete_invite_by_token(Host, Token) -> + pretty_format_command_result(try_db_call(Host, delete_invite_by_token, [Host, Token])). + +-spec expire_invites(binary(), binary()) -> non_neg_integer(). +expire_invites(Host, User) -> + pretty_format_command_result(try_db_call(Host, expire_tokens, [User, Host])). + +-spec expire_invite_by_token(binary(), binary()) -> ok | {error, not_found}. +expire_invite_by_token(Host, Token) -> + pretty_format_command_result(try_db_call(Host, expire_invite_by_token, [Host, Token])). -spec generate_invite(binary(), binary()) -> {binary(), binary()} | {error, any()}. -generate_invite(AccountName, Host0) -> - Host = jid:nameprep(Host0), - lift(create_account_invite(Host, {<<>>, Host}, AccountName, false)). - -%% -ifdef(TEST). - -%% -spec gen_invite(binary()) -> binary() | {error, any()}. -%% gen_invite(Host) -> -%% gen_invite(<<>>, Host). - -%% -endif. - -%% -spec gen_invite(binary(), binary()) -> {binary(), binary()} | {error, any()}. -%% gen_invite(AccountName, Host0) -> -%% Host = jid:nameprep(Host0), -%% case create_account_invite(Host, {<<>>, Host}, AccountName, false) of -%% {error, _Reason} = Error -> -%% Error; -%% Invite -> -%% {token_uri(Invite), landing_page(Host, Invite)} -%% end. - -%% -spec generate_reset_token(binary(), binary()) -> {binary(), binary()} | {error, any()}. -%% generate_reset_token(User, Host) -> -%% Res = case create_reset_token(User, Host) of -%% {error, _Reason} = Error -> -%% Error; -%% Invite -> -%% {token_uri(Invite), landing_page(Host, Invite)} -%% end, -%% pretty_format_command_result(Res). +generate_invite(Host, User) -> + pretty_format_command_result(create_account_invite(Host, {<<>>, Host}, User, false)). + +-spec generate_reset_token(binary(), binary()) -> {binary(), binary()} | {error, any()}. +generate_reset_token(Host, User) -> + pretty_format_command_result(create_reset_token(User, Host)). list_invites(Host) -> try_db_call(Host, list_invites, [Host]). @@ -732,7 +696,7 @@ get_invites_tree_as_root_t(Host, Inviter, [#invite_token{invitee = InviteeJID} = Invite | Invites], Acc) -> - case jid:decode(InviteeJID) of + case jid:to_binary(InviteeJID) of #jid{luser = Invitee, lserver = Host} -> get_invites_tree_as_root_t(Host, Inviter, @@ -760,25 +724,25 @@ invite_token_t(Type, Host, Inviter, AccountName0) -> account_name = AccountName}, ExpireSeconds). -%% -spec create_reset_token(binary(), binary()) -> invite_token() | {error, any()}. -%% create_reset_token(User, Host) -> -%% maybe -%% (#invite_token{} = ResetToken) ?= reset_token(User, Host), -%% F = fun() -> db_call(Host, create_invite_t, [ResetToken]) end, -%% transaction(Host, F) -%% end. - -%% reset_token(User, Host) -> -%% maybe -%% true ?= lists:member(Host, ?MYHOSTS) orelse {error, host_unknown}, -%% true ?= ejabberd_auth:user_exists(User, Host) orelse {error, user_not_exists}, -%% set_token_expires(#invite_token{token = -%% p1_rand:get_alphanum_string(?DEFAULT_TOKEN_LENGTH), -%% inviter = {<<>>, Host}, -%% type = reset_token, -%% account_name = User}, -%% gen_mod:get_module_opt(Host, ?MODULE, token_expire_seconds)) -%% end. +-spec create_reset_token(binary(), binary()) -> invite_token() | {error, any()}. +create_reset_token(User, Host) -> + maybe + (#invite_token{} = ResetToken) ?= reset_token(User, Host), + F = fun() -> db_call(Host, create_invite_t, [ResetToken]) end, + transaction(Host, F) + end. + +reset_token(User, Host) -> + maybe + true ?= lists:member(Host, ?MYHOSTS) orelse {error, host_unknown}, + true ?= ejabberd_auth:user_exists(User, Host) orelse {error, user_not_exists}, + set_token_expires(#invite_token{token = + p1_rand:get_alphanum_string(?DEFAULT_TOKEN_LENGTH), + inviter = {<<>>, Host}, + type = reset_token, + account_name = User}, + gen_mod:get_module_opt(Host, ?MODULE, token_expire_seconds)) + end. token_uri(#invite_token{type = roster_only, token = Token, @@ -809,12 +773,18 @@ maybe_add_ibr_allowed(User, Host) -> end. landing_page(_Host, _Invite) -> + %% TODO %%mod_invites_http:landing_page(Host, Invite). <<"TBD">>. -spec db_call(binary(), atom(), [any()]) -> any(). db_call(Host, Fun, Args) -> - mongoose_backend:call(Host, mod_invites_db, Fun, Args). + try + mongoose_backend:call(Host, mod_invites_db, Fun, Args) + catch + _:badarg -> + throw({error, host_unknown}) + end. %% father forgive me lift({error, _R} = E) -> @@ -945,3 +915,24 @@ send_presence(HostType, FromJid, ToJid, Type) -> get_pars_token(Xmlel) -> exml_query:path(Xmlel, [{element_with_ns, <<"preauth">>, ?NS_PARS}, {attr, <<"token">>}]). + +pretty_format_command_result({error, Error}) -> + {error, pretty_format_command_error(Error)}; +pretty_format_command_result({ok, Result}) -> + Result; +pretty_format_command_result(Result) -> + Result. + +pretty_format_command_error({module_not_loaded, ?MODULE, Host}) -> + lists:flatten( + io_lib:format("Virtual host not known: ~s", [binary_to_list(Host)])); +pretty_format_command_error(host_unknown) -> + "Virtual host not known"; +pretty_format_command_error(user_exists) -> + "Username already taken"; +pretty_format_command_error(user_not_exists) -> + "User does not exist"; +pretty_format_command_error(reserved) -> + "Username is reserved"; +pretty_format_command_error(account_name_invalid) -> + "Username is invalid". From aa4f7dcf1da98c2a188a2646ccceb5b72bdb5f5b Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Sun, 19 Jul 2026 19:22:05 +0200 Subject: [PATCH 08/11] remove unused define --- include/mod_invites.hrl | 1 - 1 file changed, 1 deletion(-) diff --git a/include/mod_invites.hrl b/include/mod_invites.hrl index 70d17f46b6e..77ca2b98e71 100644 --- a/include/mod_invites.hrl +++ b/include/mod_invites.hrl @@ -3,7 +3,6 @@ -define(DEFAULT_TOKEN_LENGTH, 24). -define(NS_FEATURE_IBR_TOKEN, <<"urn:xmpp:ibr-token:0">>). --define(NS_FEATURE_SUB_PRE_APPROVAL, <<"urn:xmpp:features:pre-approval">>). -define(NS_INVITE_CREATE_ACCOUNT, <<"urn:xmpp:invite#create-account">>). -define(NS_INVITE_INVITE, <<"urn:xmpp:invite#invite">>). -define(NS_PARS, <<"urn:xmpp:pars:0">>). From d5655fa0427ca741ada1998d6b2614c03db5689e Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Mon, 20 Jul 2026 17:43:25 +0200 Subject: [PATCH 09/11] return not allowed if no access --- src/invites/mod_invites.erl | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/invites/mod_invites.erl b/src/invites/mod_invites.erl index 023e39bcbed..8ee72561d3c 100644 --- a/src/invites/mod_invites.erl +++ b/src/invites/mod_invites.erl @@ -38,8 +38,7 @@ -export([disco_local_identity/3, disco_local_features/3, disco_local_items/3]). %% commands --export([ - cleanup_expired/0, +-export([cleanup_expired/0, delete_invite_by_token/2, expire_invite_by_token/2, expire_invites/2, @@ -394,7 +393,6 @@ get_preauth_token(Acc) -> -define(INFO_COMMAND(Name), ?INFO_IDENTITY(<<"automation">>, <<"command-node">>, Name)). -%-spec get_local_identity([identity()], jid(), jid(), binary(), binary()) -> [identity()]. -spec disco_local_identity(Acc, Params, Extra) -> {ok, Acc} when Acc :: mongoose_disco:identity_acc(), Params :: map(), @@ -406,7 +404,7 @@ disco_local_identity(Acc = #{node := ?NS_INVITE_INVITE}, _, _) -> disco_local_identity(Acc, _Params, _Extra) -> {ok, Acc}. --spec disco_local_features(Acc, Params, Extra) -> {ok, Acc} when +-spec disco_local_features(Acc, Params, Extra) -> {ok, Acc} | {stop, exml:element()} when Acc :: mongoose_disco:feature_acc(), Params :: map(), Extra :: gen_hook:extra(). @@ -427,8 +425,7 @@ disco_local_features(Acc = #{node := Ns, from_jid := From, to_jid := #jid{lserve false -> {ok, Acc}; deny -> - %% FIXME - {error, "Access denied by service policy"} + {stop, mongoose_xmpp_errors:not_allowed("Access denied by service policy")} end; disco_local_features(Acc, _, _) -> {ok, Acc}. From b799657e1c392e59e3537bf31011316ca668a9a2 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Mon, 20 Jul 2026 17:43:47 +0200 Subject: [PATCH 10/11] sql adapter --- src/invites/mod_invites_db_rdbms.erl | 211 +++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 src/invites/mod_invites_db_rdbms.erl diff --git a/src/invites/mod_invites_db_rdbms.erl b/src/invites/mod_invites_db_rdbms.erl new file mode 100644 index 00000000000..45cacc8bb0c --- /dev/null +++ b/src/invites/mod_invites_db_rdbms.erl @@ -0,0 +1,211 @@ +%%%---------------------------------------------------------------------- +%%% File : mod_invites_db_rdbms.erl +%%% Author : Stefan Strigler +%%% Created : Mon Jul 20 2026 by Stefan Strigler +%%% +%%% 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_db_rdbms). + +-behaviour(mod_invites). + +-include("mod_invites.hrl"). + +-export([init/2]). + +-export([ cleanup_expired/1 + , create_invite_t/2 + , delete_invite_by_token/2 + , expire_invite_by_token/2 + , expire_tokens/2 + , get_invite/2 + , get_invite_by_invitee_t/2 + , get_invites_t/2 + , is_reserved/3 + , is_token_valid/3 + , list_invites/1 + , remove_user/2 + , set_invitee/5 + , transaction/2 + ]). + +-import(mongoose_rdbms, [prepare/4, execute_successfully/3, sql_transaction/2]). + +-define(SELECT_INVITE(Where), + <<"SELECT host, token, username, invitee, type, account_name, expires, created_at FROM invites WHERE "Where>>). + +-spec init(mongooseim:host_type(), ModuleOpts :: list()) -> ok. +init(HostType, _Opts) -> + prepare_queries(HostType), + ok. + +prepare_queries(_HostType) -> + + prepare(invites_cleanup_expired, invites, + [host], + <<"DELETE FROM invites WHERE host = ? AND expires < NOW()">>), + prepare(invites_create_invite, invites, [token, username, host, type, created_at, expores, account_name], + <<"INSERT INTO invites SET (token, username, host, type, created_at, expires, account_name)" + " VALUES (?, ?, ?, ?, ?, ?, ?)">>), + prepare(invites_delete_invite_by_token, invites, [host, token], + <<"DELETE FORM invites WHERE host = ? AND token = ?">>), + prepare(invites_expire_invite_by_token, invites, [host, token], + <<"UPDATE invites SET expires = '1970-01-01 00:00:01' WHERE host = ? AND token = ? AND type != 'R'">>), + prepare(invites_expire_tokens, invites, [host, user], + <<"UPDATE invites SET expires = '1970-01-01 00:00:01' WHERE host = ? AND username = ?" + " AND expires > NOW() AND type != 'R'">>), + prepare(invites_get_invite, invites, [host, token], + ?SELECT_INVITE("host = ? AND token = ?")), + prepare(invites_get_invite_by_invitee, invites, [host, invitee, account_name], + ?SELECT_INVITE("host = ? AND (type != 'R' AND invitee = ?) OR (type = 'R' AND account_name = ?)")), + prepare(invites_get_invites, invites, [host, user], + ?SELECT_INVITE("host = ? AND username = ?")), + prepare(invites_is_reserved, invites, [host, token, account_name], + <<"SELECT COUNT(*) FROM invites WHERE host = ? AND token = ? AND account_name = ?" + " AND invitee = '' AND expires > NOW()">>), + prepare(invites_is_token_valid, invites, [host, token, user, user], + <<"SELECT token FROM invites WHERE host = ? AND token = ? AND invitee = '' and expires > NOW()" + " AND (? = '' OR username = ?)">>), + prepare(invites_list_invites, invites, [host], + ?SELECT_INVITE("host = ?")), + prepare(invites_remove_user, invites, [host, user], + <<"DELETE FROM invites WHERE host = ? AND username = ?">>), + prepare(invites_set_invitee, invites, [host, token, account_name, invitee, account_name], + <<"UPDATE invites SET (invitee, account_name) WHERE host = ? AND token = ? AND invitee = ''" + " AND (type != 'R' OR account_name = '' OR ? = '') VALUES (?, ?)">>), + ok. + +cleanup_expired(Host) -> + exec(Host, invites_cleanup_expired, [Host]). + +create_invite_t(Host, Invite) -> + #invite_token{inviter = {User, Host}, + token = Token, + account_name = AccountName, + created_at = CreatedAt, + expires = Expires, + type = Type0} = + Invite, + Type = enc_type(Type0), + + 1 = execute_successfully(Host, invites_create_invite, [Token, User, Host, Type, CreatedAt, Expires, AccountName]), + Invite. + +delete_invite_by_token(Host, Token) -> + ensure_exists(exec(Host, invites_delete_invite_by_token, [Host, Token])). + +expire_invite_by_token(Host, Token) -> + ensure_exists(exec(Host, invites_expire_invite_by_token, [Host, Token])). + +expire_tokens(User, Host) -> + exec(Host, invites_expire_tokens, [Host, User]). + +get_invite(Host, Token) -> + row_to_invite(exec(Host, invites_get_invite, [Host, Token])). + +get_invite_by_invitee_t(Host, {User, Server}) -> + Invitee = jid:to_bare_binary(jid:make_bare(User, Server)), + row_to_invite(execute_successfully(Host, invites_get_invite_by_invitee, [Host, Invitee, User])). + +get_invites_t(Host, {User, _Server}) -> + rows_to_invites(execute_successfully(Host, invites_get_invites, [Host, User])). + +is_reserved(Host, Token, User) -> + count(exec(Host, invites_is_reserved, [Host, Token, User])) > 0. + +is_token_valid(Host, Token, {User, _Server}) -> + case exec(Host, invites_is_token_valid, [Host, Token, User, User]) of + [] -> + case get_invite(Host, Token) of + {error, not_found} -> + throw(not_found); + _ -> + false + end; + _ -> + true + end. + +list_invites(Host) -> + rows_to_invites(exec(Host, invites_list_invites, [Host])). + +remove_user(User, Server) -> + exec(Server, invites_remove_user, [User, Server]). + +set_invitee(Fun, Host, Token, Invitee, AccountName) -> + F = fun() -> + 1 = execute_successfully(Host, invites_set_invitee, [Host, Token, AccountName, Invitee, AccountName]), + ok = Fun() + end, + case sql_transaction(Host, F) of + {atomic, ok} -> ok; + {aborted, {badmatch, {updated, 0}}} -> {error, conflict}; + {aborted, {badmatch, {error, _R} = Error }} -> Error + end. + +transaction(Host, Fun) -> + sql_transaction(Host, Fun). + +%%-------------------------------------------------------------------- +%%| helpers + +exec(Host, Fun, Args) -> + trans(Host, fun() -> execute_successfully(Host, Fun, Args) end). + +trans(Host, F) -> + {atomic, Res} = sql_transaction(Host, F), + Res. + +ensure_exists(1) -> ok; +ensure_exists(0) -> {error, not_found}. + +count([{Count}]) -> Count. + +enc_type(roster_only) -> + <<"R">>; +enc_type(account_subscription) -> + <<"S">>; +enc_type(account_only) -> + <<"A">>; +enc_type(reset_token) -> + <<"T">>. + +dec_type(<<"R">>) -> + roster_only; +dec_type(<<"S">>) -> + account_subscription; +dec_type(<<"A">>) -> + account_only; +dec_type(<<"T">>) -> + reset_token. + +row_to_invite([]) -> + {error, not_found}; +row_to_invite([Row]) -> + row_to_invite(Row); +row_to_invite({Host, Token, User, Invitee, Type, AccountName, Expires, CreatedAt}) -> + #invite_token{ + token = Token, + inviter = {User, Host}, + invitee = Invitee, + type = dec_type(Type), + account_name = AccountName, + expires = Expires, + created_at = CreatedAt + }. + +rows_to_invites(Rows) -> + lists:map(fun row_to_invite/1, Rows). From 67c5724db580e3572f88f11aec8fa3663826bc50 Mon Sep 17 00:00:00 2001 From: Stefan Strigler Date: Mon, 20 Jul 2026 17:49:45 +0200 Subject: [PATCH 11/11] try with a db schema --- priv/migrations/mysql_6.6.0_6.7.0.sql | 14 ++++++++++++++ priv/migrations/pgsql_6.6.0_6.7.0.sql | 13 +++++++++++++ priv/mysql.sql | 15 +++++++++++++++ priv/pg.sql | 14 ++++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 priv/migrations/mysql_6.6.0_6.7.0.sql create mode 100644 priv/migrations/pgsql_6.6.0_6.7.0.sql diff --git a/priv/migrations/mysql_6.6.0_6.7.0.sql b/priv/migrations/mysql_6.6.0_6.7.0.sql new file mode 100644 index 00000000000..8e130182040 --- /dev/null +++ b/priv/migrations/mysql_6.6.0_6.7.0.sql @@ -0,0 +1,14 @@ +CREATE TABLE invites ( + token text NOT NULL, + username text NOT NULL, + host varchar(250) NOT NULL, + invitee varchar(191) NOT NULL DEFAULT '', + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + 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 invites(username(191), server_host(191)); +CREATE INDEX i_invite_token_invitee USING BTREE ON invites(invitee(191)); diff --git a/priv/migrations/pgsql_6.6.0_6.7.0.sql b/priv/migrations/pgsql_6.6.0_6.7.0.sql new file mode 100644 index 00000000000..0ea0304af8e --- /dev/null +++ b/priv/migrations/pgsql_6.6.0_6.7.0.sql @@ -0,0 +1,13 @@ +CREATE TABLE invites ( + token text NOT NULL, + username text NOT NULL, + host text NOT NULL, + invitee text NOT NULL DEFAULT '', + created_at timestamp NOT NULL DEFAULT now(), + expires timestamp NOT NULL DEFAULT now(), + "type" character(1) NOT NULL, + account_name text NOT NULL, + PRIMARY KEY (token) +); +CREATE INDEX i_invite_token_username_server_host ON invites USING btree (username, server_host); +CREATE INDEX i_invite_token_invitee ON invites USING btree (invitee); diff --git a/priv/mysql.sql b/priv/mysql.sql index d87eb7b57f2..2c8e43812b0 100644 --- a/priv/mysql.sql +++ b/priv/mysql.sql @@ -673,3 +673,18 @@ CREATE TABLE blocklist ( reason TEXT, PRIMARY KEY (luser, lserver) ); + +CREATE TABLE invites ( + token text NOT NULL, + username text NOT NULL, + host varchar(191) NOT NULL, + invitee varchar(191) NOT NULL DEFAULT '', + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + 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 invites(username(191), server_host(191)); +CREATE INDEX i_invite_token_invitee USING BTREE ON invites(invitee(191)); diff --git a/priv/pg.sql b/priv/pg.sql index 88bfd0b95f5..2634814c84a 100644 --- a/priv/pg.sql +++ b/priv/pg.sql @@ -618,3 +618,17 @@ CREATE TABLE blocklist ( reason TEXT, PRIMARY KEY (luser, lserver) ); + +CREATE TABLE invites ( + token text NOT NULL, + username text NOT NULL, + host text NOT NULL, + invitee text NOT NULL DEFAULT '', + created_at timestamp NOT NULL DEFAULT now(), + expires timestamp NOT NULL DEFAULT now(), + "type" character(1) NOT NULL, + account_name text NOT NULL, + PRIMARY KEY (token) +); +CREATE INDEX i_invite_token_username_server_host ON invites USING btree (username, server_host); +CREATE INDEX i_invite_token_invitee ON invites USING btree (invitee);