• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

processone / ejabberd / 1195

14 Nov 2025 07:30AM UTC coverage: 14.534% (-19.2%) from 33.775%
1195

Pull #4493

github

web-flow
Merge 53c116bc1 into 026bd24a5
Pull Request #4493: Great invitations

0 of 521 new or added lines in 8 files covered. (0.0%)

2195 existing lines in 61 files now uncovered.

6752 of 46457 relevant lines covered (14.53%)

47.62 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/mod_invites_http.erl
1
%%%----------------------------------------------------------------------
2
%%% File    : mod_invites_http.erl
3
%%% Author  : Stefan Strigler <stefan@strigler.de>
4
%%% Purpose : Provide web page(s) to sign up using an invite token.
5
%%% Created : Fri Oct 31 2025 by Stefan Strigler <stefan@strigler.de>
6
%%%
7
%%%
8
%%% ejabberd, Copyright (C) 2025 ProcessOne
9
%%%
10
%%% This program is free software; you can redistribute it and/or
11
%%% modify it under the terms of the GNU General Public License as
12
%%% published by the Free Software Foundation; either version 2 of the
13
%%% License, or (at your option) any later version.
14
%%%
15
%%% This program is distributed in the hope that it will be useful,
16
%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
17
%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
18
%%% General Public License for more details.
19
%%%
20
%%% You should have received a copy of the GNU General Public License along
21
%%% with this program; if not, write to the Free Software Foundation, Inc.,
22
%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
23
%%%
24
%%%----------------------------------------------------------------------
25
-module(mod_invites_http).
26

27
-include("logger.hrl").
28

29
-export([process/2, landing_page/2]).
30

31
-ifdef(TEST).
32
-export([apps_json/3]).
33
-endif.
34

35
-include_lib("xmpp/include/xmpp.hrl").
36

37
-include("ejabberd_http.hrl").
38
-include("mod_invites.hrl").
39
-include("translate.hrl").
40

41
-define(HTTP(Code, CT, Text), {Code, [{<<"Content-Type">>, CT}], Text}).
42
-define(HTTP(Code, Text), ?HTTP(Code, <<"text/plain">>, Text)).
43
-define(HTTP_OK(Text), ?HTTP(200, <<"text/html">>, Text)).
44
-define(NOT_FOUND, ?HTTP(404, ?T("NOT FOUND"))).
45
-define(NOT_FOUND(Text), ?HTTP(404, <<"text/html">>, Text)).
46
-define(BAD_REQUEST, ?HTTP(400, ?T("BAD REQUEST"))).
47
-define(BAD_REQUEST(Text), ?HTTP(400, <<"text/html">>, Text)).
48

49
-define(DEFAULT_CONTENT_TYPE, <<"application/octet-stream">>).
50
-define(CONTENT_TYPES,
51
        [{<<".js">>, <<"application/javascript">>},
52
         {<<".png">>, <<"image/png">>},
53
         {<<".svg">>, <<"image/svg+xml">>}]).
54

55
-define(STATIC, <<"static">>).
56
-define(REGISTRATION, <<"registration">>).
57
-define(STATIC_CTX, {static, <<"/", Base/binary, "/", ?STATIC/binary>>}).
58
-define(SITE_NAME_CTX(Name), {site_name, Name}).
59

60
%% @format-begin
61

62
landing_page(Host, Invite) ->
NEW
63
    case mod_invites_opt:landing_page(Host) of
×
64
        none ->
NEW
65
            <<>>;
×
66
        Tmpl ->
NEW
67
            Ctx = [{invite, invite_to_proplist(Invite)}, {host, Host}],
×
NEW
68
            render_url(Tmpl, Ctx)
×
69
    end.
70

71
-spec process(LocalPath::[binary()], #request{}) ->
72
    {HTTPCode::integer(), [{binary(), binary()}], Page::string()}.
73
process([?STATIC | StaticFile], #request{host = Host} = Request) ->
NEW
74
    ?DEBUG("Static file requested ~p:~n~p", [StaticFile, Request]),
×
NEW
75
    TemplatesDir = mod_invites_opt:templates_dir(Host),
×
NEW
76
    Filename = filename:join([TemplatesDir, "static" | StaticFile]),
×
NEW
77
    case file:read_file(Filename) of
×
78
        {ok, Content} ->
NEW
79
            CT = guess_content_type(Filename),
×
NEW
80
            ?HTTP(200, CT, Content);
×
81
        {error, _} ->
NEW
82
            ?NOT_FOUND
×
83
    end;
84
process([Token | _] = LocalPath, #request{host = Host, lang = Lang} = Request) ->
NEW
85
    ?DEBUG("Requested:~n~p", [Request]),
×
NEW
86
    try mod_invites:is_token_valid(Host, Token) of
×
87
        true ->
NEW
88
            case mod_invites:get_invite(Host, Token) of
×
89
                #invite_token{type = 'roster_only'} = Invite ->
NEW
90
                    process_roster_token(LocalPath, Request, Invite);
×
91
                Invite ->
NEW
92
                    process_valid_token(LocalPath, Request, Invite)
×
93
                end;
94
        false ->
NEW
95
            ?NOT_FOUND(render(Host, Lang, <<"invite_invalid.html">>, ctx(Request)))
×
96
    catch
97
        _:not_found ->
NEW
98
            ?NOT_FOUND
×
99
    end;
100
process([], _Request) ->
NEW
101
    ?NOT_FOUND.
×
102

103
process_valid_token([_Token, AppID, ?REGISTRATION], #request{method = 'POST'} = Request, Invite) ->
NEW
104
    process_register_post(Invite, AppID, Request);
×
105
process_valid_token([_Token, AppID, ?REGISTRATION], Request, Invite) ->
NEW
106
    process_register_form(Invite, AppID, Request);
×
107
process_valid_token([_Token, ?REGISTRATION], #request{method = 'POST'} =  Request, Invite) ->
NEW
108
    process_register_post(Invite, <<>>, Request);
×
109
process_valid_token([_Token, ?REGISTRATION], Request, Invite) ->
NEW
110
    process_register_form(Invite, <<>>, Request);
×
111
process_valid_token([_Token, AppID], #request{host = Host, lang = Lang} = Request, Invite) ->
NEW
112
    try app_ctx(Host, AppID, Lang, ctx(Invite, Request)) of
×
113
        AppCtx ->
NEW
114
            render_ok(Host, Lang, <<"client.html">>, AppCtx)
×
115
    catch
116
        _:not_found ->
NEW
117
            ?NOT_FOUND
×
118
    end;
119
process_valid_token([_Token], #request{host = Host, lang = Lang} = Request, Invite) ->
NEW
120
    Ctx0 = ctx(Invite, Request),
×
NEW
121
    Apps = lists:map(fun(App0) ->
×
NEW
122
                             App = app_id(App0),
×
NEW
123
                             render_app_urls(App, [{app, App} | Ctx0])
×
124
                     end, apps_json(Host, Lang, Ctx0)),
NEW
125
    Ctx = [{apps, Apps} | Ctx0],
×
NEW
126
    render_ok(Host, Lang, <<"invite.html">>, Ctx);
×
127
process_valid_token(_, _, _) ->
NEW
128
    ?NOT_FOUND.
×
129

130
process_register_form(Invite, AppID, #request{host = Host, lang = Lang} = Request) ->
NEW
131
    try app_ctx(Host, AppID, Lang, ctx(Invite, Request)) of
×
132
        AppCtx ->
NEW
133
            Body = render_register_form(Request, AppCtx, maybe_add_username(Invite)),
×
NEW
134
            ?HTTP_OK(Body)
×
135
    catch
136
        _:not_found ->
NEW
137
            ?NOT_FOUND
×
138
    end.
139

140
render_register_form(#request{host = Host, lang = Lang}, Ctx, AdditionalCtx) ->
NEW
141
    render(Host, Lang, <<"register.html">>, Ctx ++ AdditionalCtx).
×
142

143
process_register_post(Invite, AppID, #request{host = Host, q = Q, lang = Lang, ip = {Source, _}} = Request) ->
NEW
144
    ?DEBUG("got query: ~p", [Q]),
×
NEW
145
    Username = proplists:get_value(<<"user">>, Q),
×
NEW
146
    Password = proplists:get_value(<<"password">>, Q),
×
NEW
147
    Token = Invite#invite_token.token,
×
NEW
148
    try {app_ctx(Host, AppID, Lang, ctx(Invite, Request)),
×
149
         ensure_same(Token, proplists:get_value(<<"token">>, Q))} of
150
        {AppCtx, ok} ->
NEW
151
            case mod_register:try_register(Username, Host, Password, Source, mod_invites, Lang) of
×
152
                ok ->
NEW
153
                    InviteeJid = jid:make(Username, Host),
×
NEW
154
                    mod_invites:set_invitee(Host, Token, InviteeJid),
×
NEW
155
                    UpdatedInvite = mod_invites:get_invite(Host, Token),
×
NEW
156
                    mod_invites_register:maybe_create_mutual_subscription(UpdatedInvite),
×
NEW
157
                    Ctx = [{username, Username},
×
158
                           {password, Password}
159
                          | AppCtx],
NEW
160
                    render_ok(Host, Lang, <<"register_success.html">>, Ctx);
×
161
                {error, #stanza_error{text = Text, type = Type} = Error} ->
NEW
162
                    ?DEBUG("registration failed with error: ~p", [Error]),
×
NEW
163
                    Msg = xmpp:get_text(Text, xmpp:prep_lang(Lang)),
×
NEW
164
                    case Type of
×
165
                        T when T == 'cancel'; T == 'modify' ->
NEW
166
                            Body = render_register_form(Request, AppCtx,
×
167
                                                        [{username, Username},
168
                                                         {message, [{text, Msg},
169
                                                                    {class, <<"alert-warning">>}]}]),
NEW
170
                            ?BAD_REQUEST(Body);
×
171
                        _ ->
NEW
172
                            render_bad_request(Host, <<"register_error.html">>, [{message, Msg} | ctx(Request)])
×
173
                    end
174
            end
175
    catch
176
        _:not_found ->
NEW
177
            ?NOT_FOUND;
×
178
        _:no_match ->
NEW
179
            ?BAD_REQUEST
×
180
    end.
181

182
process_roster_token([_Token], #request{host = Host, lang = Lang} = Request, Invite) ->
NEW
183
    Ctx0 = ctx(Invite, Request),
×
NEW
184
    Apps = lists:map(
×
185
             fun(App = #{<<"download">> := #{<<"buttons">> := [Button | _]}}) ->
NEW
186
                     ProceedUrl = case render_app_button_url(Button, Ctx0) of
×
187
                                      #{magic_link := MagicLink} ->
NEW
188
                                          MagicLink;
×
189
                                      #{<<"url">> := Url} ->
NEW
190
                                          Url
×
191
                                  end,
NEW
192
                     App#{proceed_url => ProceedUrl,
×
193
                          select_text => translate:translate(Lang, ?T("Install"))}
194
             end, apps_json(Host, Lang, Ctx0)),
NEW
195
    Ctx = [{apps, Apps} | Ctx0],
×
NEW
196
    render_ok(Host, Lang, <<"roster.html">>, Ctx);
×
197
process_roster_token(_, _, _) ->
NEW
198
    ?NOT_FOUND.
×
199

200
ensure_same(V, V) ->
NEW
201
    ok;
×
202
ensure_same(_, _) ->
NEW
203
    throw(no_match).
×
204

205
app_ctx(_Host, <<>>, _Lang, Ctx) ->
NEW
206
    Ctx;
×
207
app_ctx(Host, AppID, Lang, Ctx) ->
NEW
208
    FilteredApps = [App || A <- apps_json(Host, Lang, Ctx), maps:get(<<"id">>, App = app_id(A)) == AppID],
×
NEW
209
    case FilteredApps of
×
210
        [App] ->
NEW
211
            [{app, render_app_button_urls(App, Ctx)} | Ctx];
×
212
        [] ->
NEW
213
            throw(not_found)
×
214
    end.
215

216
ctx(#request{host = Host, path = [Base | _]}) ->
NEW
217
    SiteName = mod_invites_opt:site_name(Host),
×
NEW
218
    [?STATIC_CTX, ?SITE_NAME_CTX(SiteName)].
×
219

220
ctx(Invite, #request{host = Host} = Request) ->
NEW
221
    [{invite, invite_to_proplist(Invite)},
×
222
     {uri, mod_invites:token_uri(Invite)},
223
     {domain, Host},
224
     {token, Invite#invite_token.token},
225
     {registration_url, <<(Invite#invite_token.token)/binary, "/", ?REGISTRATION/binary>>}
226
    | ctx(Request)].
227

228
apps_json(Host, Lang, Ctx) ->
NEW
229
    AppsBins = render(Host, Lang, <<"apps.json">>, Ctx),
×
NEW
230
    AppsBin = binary_join(AppsBins, <<>>),
×
NEW
231
    misc:json_decode(AppsBin).
×
232

233
app_id(App = #{<<"id">> := _ID}) ->
NEW
234
    App;
×
235
app_id(App = #{<<"name">> := Name}) ->
NEW
236
    App#{<<"id">> => re:replace(Name, "[^a-zA-Z0-9]+", "-", [global, {return, binary}])}.
×
237

238
invite_to_proplist(I) ->
NEW
239
    [{uri, mod_invites:token_uri(I)}
×
240
    | lists:zip(record_info(fields, invite_token), tl(tuple_to_list(I)))].
241

242
render_url(Tmpl, Vars) ->
NEW
243
    Renderer = tmpl_to_renderer(Tmpl),
×
NEW
244
    {ok, URL} = Renderer:render(Vars),
×
NEW
245
    binary_join(URL, <<>>).
×
246

247
render_app_urls(App = #{<<"supports_preauth_uri">> := true}, Vars) ->
NEW
248
    App#{proceed_url => render_url(<<"{{ invite.token }}/{{ app.id }}">>, Vars)};
×
249
render_app_urls(App, Vars) ->
NEW
250
    App#{proceed_url => render_url(<<"{{ invite.token }}/{{ app.id }}/", ?REGISTRATION/binary >>, Vars)}.
×
251

252
render_app_button_urls(App = #{<<"download">> := #{<<"buttons">> := Buttons}}, Vars) ->
NEW
253
    App#{<<"download">> => #{<<"buttons">> => lists:map(fun(Button) -> render_app_button_url(Button, [{button, Button} | Vars]) end, Buttons)}};
×
254
render_app_button_urls(App, _Vars) ->
NEW
255
    App.
×
256

257
render_app_button_url(Button = #{<<"magic_link_format">> := MLF}, Vars) ->
NEW
258
    Button#{magic_link => render_url(MLF, Vars)};
×
259
render_app_button_url(Button, _Vars) ->
NEW
260
    Button.
×
261

262
file_to_renderer(Host, Filename) ->
NEW
263
    ModName = binary_to_atom(<<"mod_invites_template__", Host/binary, "__", Filename/binary>>),
×
NEW
264
    TemplatesDir = mod_invites_opt:templates_dir(Host),
×
NEW
265
    TemplatePath = binary_to_list(filename:join([TemplatesDir, Filename])),
×
NEW
266
    {ok, _Mod, Warnings} = erlydtl:compile_file(TemplatePath, ModName,
×
267
                              [{out_dir, false},
268
                               return,
269
                               {libraries,
270
                                [{mod_invites_http_erlylib, mod_invites_http_erlylib}]},
271
                               {default_libraries, [mod_invites_http_erlylib]}]),
NEW
272
    ?DEBUG("got warnings: ~p", [Warnings]),
×
NEW
273
    ModName.
×
274

275
tmpl_to_renderer(Tmpl) ->
NEW
276
    ModName = binary_to_atom(<<"mod_invites_template__", Tmpl/binary>>),
×
NEW
277
    case erlang:function_exported(ModName, render, 1) of
×
278
        true ->
NEW
279
            ModName;
×
280
        false ->
NEW
281
            {ok, _Mod} = erlydtl:compile_template(Tmpl, ModName, [{out_dir, false},
×
282
                                                                  {libraries,
283
                                                                   [{mod_invites_http_erlylib, mod_invites_http_erlylib}]},
284
                                                                  {default_libraries, [mod_invites_http_erlylib]}]),
NEW
285
            ModName
×
286
    end.
287

288
render(Host, Lang, File, Ctx) ->
NEW
289
    Renderer = file_to_renderer(Host, File),
×
NEW
290
    {ok, Rendered} =
×
291
        Renderer:render(
292
          Ctx,
293
          [{locale, Lang},
294
           {translation_fun,
295
            fun(Msg, TFLang) ->
NEW
296
                    translate:translate(lang(TFLang), list_to_binary(Msg))
×
297
            end}]),
NEW
298
    Rendered.
×
299

300
lang(default) ->
NEW
301
    <<"en">>;
×
302
lang(Lang) ->
NEW
303
    Lang.
×
304

305
render_ok(Host, Lang, File, Ctx) ->
NEW
306
    ?HTTP_OK(render(Host, Lang, File, Ctx)).
×
307

308
render_bad_request(Host, File, Ctx) ->
NEW
309
    Renderer = file_to_renderer(Host, File),
×
NEW
310
    {ok, Rendered} = Renderer:render(Ctx),
×
NEW
311
    ?BAD_REQUEST(Rendered).
×
312

313
-spec guess_content_type(binary()) -> binary().
314
guess_content_type(FileName) ->
NEW
315
    mod_http_fileserver:content_type(FileName,
×
316
                                     ?DEFAULT_CONTENT_TYPE,
317
                                     ?CONTENT_TYPES).
318

319
maybe_add_username(#invite_token{account_name = <<>>}) ->
NEW
320
    [];
×
321
maybe_add_username(#invite_token{account_name = AccountName}) ->
NEW
322
    [{username, AccountName}].
×
323

324
-spec binary_join(binary() | [binary()], binary()) -> binary().
325
binary_join(Bin, _Sep) when is_binary(Bin) ->
NEW
326
    Bin;
×
327
binary_join([], _Sep) ->
NEW
328
    <<>>;
×
329
binary_join([Part], _Sep) ->
NEW
330
    Part;
×
331
binary_join(List, Sep) ->
NEW
332
    lists:foldr(fun (A, B) ->
×
NEW
333
                        if
×
NEW
334
                            bit_size(B) > 0 -> <<A/binary, Sep/binary, B/binary>>;
×
NEW
335
                            true -> A
×
336
                        end
337
                end, <<>>, List).
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc