%%%---------------------------------------------------------------------- %%% File : mod_invites_http.erl %%% Author : Stefan Strigler %%% Purpose : Provide web page(s) to sign up using an invite token. %%% Created : Fri Oct 31 2025 by Stefan Strigler %%% %%% %%% ejabberd, Copyright (C) 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_http). -author('stefan@strigler.de'). -include("logger.hrl"). -export([process/2, landing_page/2]). -ifdef(TEST). -export([apps_json/3]). -endif. -include_lib("xmpp/include/xmpp.hrl"). -include("ejabberd_http.hrl"). -include("mod_invites.hrl"). -include("translate.hrl"). -define(HTTP(Code, CT, Text), {Code, [{<<"Content-Type">>, CT}], Text}). -define(HTTP(Code, Text), ?HTTP(Code, <<"text/plain">>, Text)). -define(HTTP_OK(Text), ?HTTP(200, <<"text/html">>, Text)). -define(NOT_FOUND, ?HTTP(404, ?T("NOT FOUND"))). -define(NOT_FOUND(Text), ?HTTP(404, <<"text/html">>, Text)). -define(BAD_REQUEST, ?HTTP(400, ?T("BAD REQUEST"))). -define(BAD_REQUEST(Text), ?HTTP(400, <<"text/html">>, Text)). -define(DEFAULT_CONTENT_TYPE, <<"application/octet-stream">>). -define(CONTENT_TYPES, [{<<".js">>, <<"application/javascript">>}, {<<".png">>, <<"image/png">>}, {<<".svg">>, <<"image/svg+xml">>}]). -define(STATIC, <<"static">>). -define(REGISTRATION, <<"registration">>). -define(STATIC_CTX, {static, <<"/", Base/binary, "/", ?STATIC/binary>>}). -define(SITE_NAME_CTX(Name), {site_name, Name}). %% @format-begin landing_page(Host, Invite) -> case mod_invites_opt:landing_page(Host) of none -> <<>>; <<"auto">> -> try ejabberd_http:get_auto_url(any, mod_invites) of AutoURL0 -> AutoURL = misc:expand_keyword(<<"@HOST@">>, AutoURL0, Host), render_landing_page_url(<>, Host, Invite) catch _:_ -> ?WARNING_MSG("'auto' URL configured for mod_invites but no request_handler found in your ~s listeners configuration.", [Host]), <<>> end; Tmpl -> render_landing_page_url(Tmpl, Host, Invite) end. render_landing_page_url(Tmpl, Host, Invite) -> Ctx = [{invite, invite_to_proplist(Invite)}, {host, Host}], render_url(Tmpl, Ctx). -spec process(LocalPath :: [binary()], #request{}) -> {HTTPCode :: integer(), [{binary(), binary()}], Page :: string()}. process([?STATIC | StaticFile], #request{host = Host} = Request) -> ?DEBUG("Static file requested ~p:~n~p", [StaticFile, Request]), TemplatesDir = mod_invites_opt:templates_dir(Host), Filename = filename:join([TemplatesDir, "static" | StaticFile]), case file:read_file(Filename) of {ok, Content} -> CT = guess_content_type(Filename), ?HTTP(200, CT, Content); {error, _} -> ?NOT_FOUND end; process([Token | _] = LocalPath, #request{host = Host, lang = Lang} = Request) -> ?DEBUG("Requested:~n~p", [Request]), try mod_invites:is_token_valid(Host, Token) of true -> case mod_invites:get_invite(Host, Token) of #invite_token{type = roster_only} = Invite -> process_roster_token(LocalPath, Request, Invite); Invite -> process_valid_token(LocalPath, Request, Invite) end; false -> ?NOT_FOUND(render(Host, Lang, <<"invite_invalid.html">>, ctx(Request, LocalPath))) catch _:not_found -> ?NOT_FOUND end; process([], _Request) -> ?NOT_FOUND. process_valid_token([_Token, AppID, ?REGISTRATION] = LocalPath, #request{method = 'POST'} = Request, Invite) -> process_register_post(Invite, AppID, Request, LocalPath); process_valid_token([_Token, AppID, ?REGISTRATION] = LocalPath, Request, Invite) -> process_register_form(Invite, AppID, Request, LocalPath); process_valid_token([_Token, ?REGISTRATION] = LocalPath, #request{method = 'POST'} = Request, Invite) -> process_register_post(Invite, <<>>, Request, LocalPath); process_valid_token([_Token, ?REGISTRATION] = LocalPath, Request, Invite) -> process_register_form(Invite, <<>>, Request, LocalPath); process_valid_token([_Token, AppID] = LocalPath, #request{host = Host, lang = Lang} = Request, Invite) -> try app_ctx(Host, AppID, Lang, ctx(Invite, Request, LocalPath)) of AppCtx -> render_ok(Host, Lang, <<"client.html">>, AppCtx) catch _:not_found -> ?NOT_FOUND end; process_valid_token([_Token] = LocalPath, #request{host = Host, lang = Lang} = Request, Invite) -> Ctx0 = ctx(Invite, Request, LocalPath), Apps = lists:map(fun(App0) -> App = app_id(App0), render_app_urls(App, [{app, App} | Ctx0]) end, apps_json(Host, Lang, Ctx0)), Ctx = [{apps, Apps} | Ctx0], render_ok(Host, Lang, <<"invite.html">>, Ctx); process_valid_token(_, _, _) -> ?NOT_FOUND. process_register_form(Invite, AppID, #request{host = Host, lang = Lang} = Request, LocalPath) -> try app_ctx(Host, AppID, Lang, ctx(Invite, Request, LocalPath)) of AppCtx -> Body = render_register_form(Request, AppCtx, maybe_add_username(Invite)), ?HTTP_OK(Body) catch _:not_found -> ?NOT_FOUND end. render_register_form(#request{host = Host, lang = Lang}, Ctx, AdditionalCtx) -> render(Host, Lang, <<"register.html">>, Ctx ++ AdditionalCtx). process_register_post(Invite, AppID, #request{host = Host, q = Q, lang = Lang, ip = {Source, _}} = Request, LocalPath) -> ?DEBUG("got query: ~p", [Q]), Username = proplists:get_value(<<"user">>, Q), Password = proplists:get_value(<<"password">>, Q), Token = Invite#invite_token.token, try {app_ctx(Host, AppID, Lang, ctx(Invite, Request, LocalPath)), ensure_same(Token, proplists:get_value(<<"token">>, Q))} of {AppCtx, ok} -> case mod_invites_register:try_register(Invite, Username, Host, Password, Source, Lang) of {ok, _UpdatedInvite} -> Ctx = [{username, Username}, {password, Password} | AppCtx], render_ok(Host, Lang, <<"register_success.html">>, Ctx); {error, #stanza_error{text = Text, type = Type} = Error} -> ?DEBUG("registration failed with error: ~p", [Error]), Msg = xmpp:get_text(Text, xmpp:prep_lang(Lang)), case Type of T when T == cancel; T == modify -> Body = render_register_form(Request, AppCtx, [{username, Username}, {message, [{text, Msg}, {class, <<"alert-warning">>}]}]), ?BAD_REQUEST(Body); _ -> render_bad_request(Host, <<"register_error.html">>, [{message, Msg} | ctx(Request, LocalPath)]) end end catch _:not_found -> ?NOT_FOUND; _:no_match -> ?BAD_REQUEST end. process_roster_token([_Token] = LocalPath, #request{host = Host, lang = Lang} = Request, Invite) -> Ctx0 = ctx(Invite, Request, LocalPath), Apps = lists:map(fun(App = #{<<"download">> := #{<<"buttons">> := [Button | _]}}) -> ProceedUrl = case render_app_button_url(Button, Ctx0) of #{magic_link := MagicLink} -> MagicLink; #{<<"url">> := Url} -> Url end, App#{proceed_url => ProceedUrl, select_text => translate:translate(Lang, ?T("Install"))} end, apps_json(Host, Lang, Ctx0)), Ctx = [{apps, Apps} | Ctx0], render_ok(Host, Lang, <<"roster.html">>, Ctx); process_roster_token(_, _, _) -> ?NOT_FOUND. ensure_same(V, V) -> ok; ensure_same(_, _) -> throw(no_match). app_ctx(_Host, <<>>, _Lang, Ctx) -> Ctx; app_ctx(Host, AppID, Lang, Ctx) -> FilteredApps = [App || A <- apps_json(Host, Lang, Ctx), maps:get(<<"id">>, App = app_id(A)) == AppID], case FilteredApps of [App] -> [{app, render_app_button_urls(App, Ctx)} | Ctx]; [] -> throw(not_found) end. ctx(#request{host = Host, path = Path}, LocalPath) -> Base = iolist_to_binary(uri_string:normalize( lists:join(<<"/">>, Path -- LocalPath))), SiteName = mod_invites_opt:site_name(Host), [?STATIC_CTX, ?SITE_NAME_CTX(SiteName)]. ctx(Invite, #request{host = Host} = Request, LocalPath) -> [{invite, invite_to_proplist(Invite)}, {uri, mod_invites:token_uri(Invite)}, {domain, Host}, {token, Invite#invite_token.token}, {registration_url, <<(Invite#invite_token.token)/binary, "/", ?REGISTRATION/binary>>} | ctx(Request, LocalPath)]. apps_json(Host, Lang, Ctx) -> AppsBins = render(Host, Lang, <<"apps.json">>, Ctx), AppsBin = binary_join(AppsBins, <<>>), misc:json_decode(AppsBin). app_id(App = #{<<"id">> := _ID}) -> App; app_id(App = #{<<"name">> := Name}) -> App#{<<"id">> => re:replace(Name, "[^a-zA-Z0-9]+", "-", [global, {return, binary}])}. invite_to_proplist(I) -> [{uri, mod_invites:token_uri(I)} | lists:zip(record_info(fields, invite_token), tl(tuple_to_list(I)))]. render_url(Tmpl, Vars) -> Renderer = tmpl_to_renderer(Tmpl), {ok, URL} = Renderer:render(Vars), binary_join(URL, <<>>). render_app_urls(App = #{<<"supports_preauth_uri">> := true}, Vars) -> App#{proceed_url => render_url(<<"{{ invite.token }}/{{ app.id }}">>, Vars)}; render_app_urls(App, Vars) -> App#{proceed_url => render_url(<<"{{ invite.token }}/{{ app.id }}/", ?REGISTRATION/binary>>, Vars)}. render_app_button_urls(App = #{<<"download">> := #{<<"buttons">> := Buttons}}, Vars) -> App#{<<"download">> => #{<<"buttons">> => lists:map(fun(Button) -> render_app_button_url(Button, [{button, Button} | Vars]) end, Buttons)}}; render_app_button_urls(App, _Vars) -> App. render_app_button_url(Button = #{<<"magic_link_format">> := MLF}, Vars) -> Button#{magic_link => render_url(MLF, Vars)}; render_app_button_url(Button, _Vars) -> Button. file_to_renderer(Host, Filename) -> ModName = binary_to_atom(<<"mod_invites_template__", Host/binary, "__", Filename/binary>>), TemplatesDir = mod_invites_opt:templates_dir(Host), TemplatePath = binary_to_list(filename:join([TemplatesDir, Filename])), {ok, _Mod, Warnings} = erlydtl:compile_file(TemplatePath, ModName, [{out_dir, false}, return, {libraries, [{mod_invites_http_erlylib, mod_invites_http_erlylib}]}, {default_libraries, [mod_invites_http_erlylib]}]), ?DEBUG("got warnings: ~p", [Warnings]), ModName. tmpl_to_renderer(Tmpl) -> ModName = binary_to_atom(<<"mod_invites_template__", Tmpl/binary>>), case erlang:function_exported(ModName, render, 1) of true -> ModName; false -> {ok, _Mod} = erlydtl:compile_template(Tmpl, ModName, [{out_dir, false}, {libraries, [{mod_invites_http_erlylib, mod_invites_http_erlylib}]}, {default_libraries, [mod_invites_http_erlylib]}]), ModName end. render(Host, Lang, File, Ctx) -> Renderer = file_to_renderer(Host, File), {ok, Rendered} = Renderer:render(Ctx, [{locale, Lang}, {translation_fun, fun(Msg, TFLang) -> translate:translate(lang(TFLang), list_to_binary(Msg)) end}]), Rendered. lang(default) -> <<"en">>; lang(Lang) -> Lang. render_ok(Host, Lang, File, Ctx) -> ?HTTP_OK(render(Host, Lang, File, Ctx)). render_bad_request(Host, File, Ctx) -> Renderer = file_to_renderer(Host, File), {ok, Rendered} = Renderer:render(Ctx), ?BAD_REQUEST(Rendered). -spec guess_content_type(binary()) -> binary(). guess_content_type(FileName) -> mod_http_fileserver:content_type(FileName, ?DEFAULT_CONTENT_TYPE, ?CONTENT_TYPES). maybe_add_username(#invite_token{account_name = <<>>}) -> []; maybe_add_username(#invite_token{account_name = AccountName}) -> [{username, AccountName}]. -spec binary_join(binary() | [binary()], binary()) -> binary(). binary_join(Bin, _Sep) when is_binary(Bin) -> Bin; binary_join([], _Sep) -> <<>>; binary_join([Part], _Sep) -> Part; binary_join(List, Sep) -> lists:foldr(fun(A, B) -> if bit_size(B) > 0 -> <>; true -> A end end, <<>>, List).