diff --git a/include/mod_invites.hrl b/include/mod_invites.hrl
new file mode 100644
index 00000000000..8555808e9b6
--- /dev/null
+++ b/include/mod_invites.hrl
@@ -0,0 +1,18 @@
+-define(INVITE_TOKEN_EXPIRE_SECONDS_DEFAULT, 5*86400).
+-define(INVITE_TOKEN_LENGTH_DEFAULT, 24).
+
+-define(NS_INVITE_INVITE, <<"urn:xmpp:invite#invite">>).
+-define(NS_INVITE_CREATE_ACCOUNT, <<"urn:xmpp:invite#create-account">>).
+
+-record(invite_token, {token :: binary(),
+ inviter :: {binary(), binary()},
+ %% 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())) + ?INVITE_TOKEN_EXPIRE_SECONDS_DEFAULT) :: calendar:datetime(),
+ type = roster_only :: roster_only | account_only | account_subscription,
+ %% 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/mix.exs b/mix.exs
index 31fb65aaa1b..551d8d2453e 100644
--- a/mix.exs
+++ b/mix.exs
@@ -108,6 +108,7 @@ defmodule Ejabberd.MixProject do
[{:cache_tab, "~> 1.0"},
{:dialyxir, "~> 1.2", only: [:test], runtime: false},
{:eimp, "~> 1.0"},
+ {:erlydtl, git: "https://github.com/erlydtl/erlydtl", tag: "0.15.0", override: true},
{:ex_doc, "~> 0.31", only: [:edoc], runtime: false},
{:fast_tls, "~> 1.1.24"},
{:fast_xml, "~> 1.1.56"},
@@ -119,7 +120,7 @@ defmodule Ejabberd.MixProject do
{:p1_utils, "~> 1.0"},
{:pkix, "~> 1.0"},
{:stringprep, ">= 1.0.26"},
- {:xmpp, git: "https://github.com/processone/xmpp", ref: "7285aa7802bfa90bcefafdad3a342fbb93ce7eea", override: true},
+ {:xmpp, git: "https://github.com/processone/xmpp", tag: "1.11.4", override: true},
{:yconf, ">= 1.0.22"}]
++ cond_deps()
end
diff --git a/mix.lock b/mix.lock
index 1d12a9ff93d..308741ec44d 100644
--- a/mix.lock
+++ b/mix.lock
@@ -7,6 +7,7 @@
"epam": {:hex, :epam, "1.0.14", "aa0b85d27f4ef3a756ae995179df952a0721237e83c6b79d644347b75016681a", [:rebar3], [], "hexpm", "2f3449e72885a72a6c2a843f561add0fc2f70d7a21f61456930a547473d4d989"},
"eredis": {:hex, :eredis, "1.7.1", "39e31aa02adcd651c657f39aafd4d31a9b2f63c6c700dc9cece98d4bc3c897ab", [:mix, :rebar3], [], "hexpm", "7c2b54c566fed55feef3341ca79b0100a6348fd3f162184b7ed5118d258c3cc1"},
"erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
+ "erlydtl": {:git, "https://github.com/erlydtl/erlydtl", "aae414692b6052e96d890e03bbeeeca0f4dc01c2", [tag: "0.15.0"]},
"esip": {:hex, :esip, "1.0.59", "eb202f8c62928193588091dfedbc545fe3274c34ecd209961f86dcb6c9ebce88", [:rebar3], [{:fast_tls, "1.1.25", [hex: :fast_tls, repo: "hexpm", optional: false]}, {:p1_utils, "1.0.28", [hex: :p1_utils, repo: "hexpm", optional: false]}, {:stun, "1.2.21", [hex: :stun, repo: "hexpm", optional: false]}], "hexpm", "0bdf2e3c349dc0b144f173150329e675c6a51ac473d7a0b2e362245faad3fbe6"},
"ex_doc": {:hex, :ex_doc, "0.39.2", "da5549bbce34c5fb0811f829f9f6b7a13d5607b222631d9e989447096f295c57", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "62665526a88c207653dbcee2aac66c2c229d7c18a70ca4ffc7f74f9e01324daa"},
"exsync": {:hex, :exsync, "0.4.1", "0a14fe4bfcb80a509d8a0856be3dd070fffe619b9ba90fec13c58b316c176594", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "cefb22aa805ec97ffc5b75a4e1dc54bcaf781e8b32564bf74abbe5803d1b5178"},
@@ -34,6 +35,6 @@
"stringprep": {:hex, :stringprep, "1.0.33", "22f42866b4f6f3c238ea2b9cb6241791184ddedbab55e94a025511f46325f3ca", [:rebar3], [{:p1_utils, "1.0.28", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm", "96f8b30bc50887f605b33b46bca1d248c19a879319b8c482790e3b4da5da98c0"},
"stun": {:hex, :stun, "1.2.21", "735855314ad22cb7816b88597d2f5ca22e24aa5e4d6010a0ef3affb33ceed6a5", [:rebar3], [{:fast_tls, "1.1.25", [hex: :fast_tls, repo: "hexpm", optional: false]}, {:p1_utils, "1.0.28", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm", "3d7fe8efb9d05b240a6aa9a6bf8b8b7bff2d802895d170443c588987dc1e12d9"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
- "xmpp": {:git, "https://github.com/processone/xmpp", "7285aa7802bfa90bcefafdad3a342fbb93ce7eea", [ref: "7285aa7802bfa90bcefafdad3a342fbb93ce7eea"]},
+ "xmpp": {:git, "https://github.com/processone/xmpp", "f96c9adde9841bdeb184740857bddd60d3f51ab7", [tag: "1.11.4"]},
"yconf": {:hex, :yconf, "1.0.22", "52a435f9b60ab1e13950dfe3f7131ecdd8b3d1ca72c44bf66fc74b4571027124", [:rebar3], [{:fast_yaml, "1.0.39", [hex: :fast_yaml, repo: "hexpm", optional: false]}], "hexpm", "aca83457ceabe70756484b5c87ba7b1955f511d499168687eaeaa7c300e857f1"},
}
diff --git a/priv/mod_invites/apps.html b/priv/mod_invites/apps.html
new file mode 100644
index 00000000000..d0678966459
--- /dev/null
+++ b/priv/mod_invites/apps.html
@@ -0,0 +1,26 @@
+
+
+ {% for item in apps %}
+
+ {% endfor %}
+
+
+
diff --git a/priv/mod_invites/apps.json b/priv/mod_invites/apps.json
new file mode 100644
index 00000000000..6e0e01a17bd
--- /dev/null
+++ b/priv/mod_invites/apps.json
@@ -0,0 +1,161 @@
+[
+ {
+ "download": {
+ "buttons": [
+ {
+ "image": "{{ static }}/logos/google_ps.png",
+ "url": "https://play.google.com/store/apps/details?id=eu.siacs.conversations",
+ "magic_link_format": "https://play.google.com/store/apps/details?id=eu.siacs.conversations&referrer={{ uri }}"
+ },
+ {
+ "image": "{{ static }}/logos/fdroid.png",
+ "url": "https://f-droid.org/en/packages/eu.siacs.conversations/",
+ "magic_link_format": "https://f-droid.org/packages/eu.siacs.conversations/"
+ }
+ ]
+ },
+ "image": "logos/conversations.svg",
+ "link": "https://play.google.com/store/apps/details?id=eu.siacs.conversations",
+ "magic_link_format": "https://play.google.com/store/apps/details?id=eu.siacs.conversations&referrer={{ uri }}",
+ "name": "Conversations",
+ "platforms": [
+ "Android"
+ ],
+ "supports_preauth_uri": true,
+ "text": "{% trans "Conversations is a Jabber/XMPP client for Android 6.0+ smartphones that has been optimized to provide a unique mobile experience." %}"
+ },
+ {
+ "download": {
+ "buttons": [
+ {
+ "image": "{{ static }}/logos/apple_as.svg",
+ "target": "_blank",
+ "url": "https://apps.apple.com/app/id317711500"
+ }
+ ]
+ },
+ "image": "logos/monal-tmp.svg",
+ "link": "https://monal-im.org/",
+ "name": "Monal",
+ "platforms": [
+ "iOS", "iPadOS"
+ ],
+ "supports_preauth_uri": true,
+ "text": "{% trans "A modern open-source chat client for iPhone and iPad. It is easy to use and has a clean user interface." %}"
+ },
+ {
+ "download": {
+ "buttons": [
+ {
+ "image": "{{ static }}/logos/apple_as.svg",
+ "target": "_blank",
+ "url": "https://apps.apple.com/app/id1637078500"
+ }
+ ]
+ },
+ "image": "logos/monal-tmp.svg",
+ "link": "https://monal-im.org/",
+ "name": "Monal (macOS)",
+ "platforms": [
+ "macOS"
+ ],
+ "supports_preauth_uri": true,
+ "text": "{% trans "A modern open-source chat client for Mac. It is easy to use and has a clean user interface." %}"
+ },
+ {
+ "download": {
+ "buttons": [
+ {
+ "image": "{{ static }}/logos/apple_as.svg",
+ "target": "_blank",
+ "url": "https://apps.apple.com/us/app/siskin-im/id1153516838"
+ }
+ ]
+ },
+ "image": "logos/siskin-im.svg",
+ "link": "https://apps.apple.com/us/app/siskin-im/id1153516838",
+ "name": "Siskin IM",
+ "platforms": [
+ "iOS", "iPadOS"
+ ],
+ "supports_preauth_uri": true,
+ "text": "{% trans "A lightweight and powerful XMPP client for iPhone and iPad. It provides an easy way to talk and share moments with your friends." %}"
+ },
+ {
+ "download": {
+ "buttons": [
+ {
+ "target": "_blank",
+ "text": "{% trans "Download from Mac App Store" %}",
+ "url": "https://apps.apple.com/us/app/beagle-im/id1445349494"
+ }
+ ]
+ },
+ "image": "logos/beagle-im.svg",
+ "link": "https://apps.apple.com/us/app/beagle-im/id1445349494",
+ "name": "Beagle IM",
+ "platforms": [
+ "macOS"
+ ],
+ "setup": {
+ "text": "{% trans "Launch Beagle IM, and select 'Yes' to add a new account. Click the '+' button under the empty account list and then enter your credentials." %}"
+ },
+ "text": "{% trans "Beagle IM by Tigase, Inc. is a lightweight and powerful XMPP client for macOS." %}"
+ },
+ {
+ "download": {
+ "buttons": [
+ {
+ "target": "_blank",
+ "text": "{% trans "Download Dino for Linux" %}",
+ "url": "https://dino.im/#download"
+ }
+ ],
+ "text": "{% trans "Click the button to open the Dino website where you can download and install it on your PC." %}"
+ },
+ "image": "logos/dino.svg",
+ "link": "https://dino.im/",
+ "name": "Dino",
+ "platforms": [
+ "Linux"
+ ],
+ "text": "{% trans "A modern open-source chat client for the desktop. It focuses on providing a clean and reliable Jabber/XMPP experience while having your privacy in mind." %}"
+ },
+ {
+ "download": {
+ "buttons": [
+ {
+ "target": "_blank",
+ "text": "{% trans "Download Gajim" %}",
+ "url": "https://gajim.org/download/"
+ }
+ ]
+ },
+ "image": "logos/gajim.svg",
+ "link": "https://gajim.org/",
+ "name": "Gajim",
+ "platforms": [
+ "Windows",
+ "Linux"
+ ],
+ "text": "{% trans "A fully-featured desktop chat client for Windows and Linux." %}"
+ },
+ {
+ "download": {
+ "buttons": [
+ {
+ "target": "_blank",
+ "text": "{% trans "Download Renga for Haiku" %}",
+ "url": "https://depot.haiku-os.org/#!/pkg/renga?bcguid=bc233-PQIA"
+ }
+ ]
+ },
+ "image": "logos/renga.svg",
+ "link": "https://pulkomandy.tk/projects/renga",
+ "name": "Renga",
+ "platforms": [
+ "Haiku"
+ ],
+ "text": "{% trans "XMPP client for Haiku" %}"
+ }
+]
diff --git a/priv/mod_invites/base.html b/priv/mod_invites/base.html
new file mode 100644
index 00000000000..593dffaa2b8
--- /dev/null
+++ b/priv/mod_invites/base.html
@@ -0,0 +1,47 @@
+{% extends "base_min.html" %}
+
+{% block rel_alternate %}
+
+{% endblock %}
+
+{% block qr_button %}
+
+{% endblock %}
+
+{% block qr_code %}
+
+
+
+
+
+
{% trans "You can transfer this invite to your mobile device by scanning a code with your camera." %}
+
+
{% trans "Use a QR code scanner on your mobile device to scan the code below:" %}
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block extra_scripts %}
+
+
+
+{% endblock %}
diff --git a/priv/mod_invites/base_min.html b/priv/mod_invites/base_min.html
new file mode 100644
index 00000000000..7cda0451bde
--- /dev/null
+++ b/priv/mod_invites/base_min.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+ {% block title %}{% blocktrans %}Invite to {{ site_name }}{% endblocktrans %}{% endblock %}
+ {% block rel_alternate %}{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+
+ {% block qr_code %}{% endblock %}
+ {% block extra_scripts %}{% endblock %}
+
+
+
+
diff --git a/priv/mod_invites/client.html b/priv/mod_invites/client.html
new file mode 100644
index 00000000000..45a3dbf4a66
--- /dev/null
+++ b/priv/mod_invites/client.html
@@ -0,0 +1,70 @@
+{% extends "base.html" %}
+
+{% block h1 %}
+ {% blocktrans with app_name=app.name %}Join {{ site_name }} with {{ app_name }}{% endblocktrans %}
+{% endblock %}
+
+{% block content %}
+ {% if invite.inviter|user %}
+ {% blocktrans with inviter=invite.inviter|user %}You have been invited to chat with {{ inviter }} on {{ site_name }}, part of the XMPP secure and decentralized messaging network.{% endblocktrans %}
+ {% else %}
+ {% blocktrans %}You have been invited to chat on {{ site_name }}, part of the XMPP secure and decentralized messaging network.{% endblocktrans %}
+ {% endif %}
+
+
+ {% blocktrans with app_name=app.name %}You can start chatting right away with {{ app_name }}. Let's get started!{% endblocktrans %}
+
+
+
+ {% blocktrans with app_name=app.name %}Step 1: Install {{ app_name }}{% endblocktrans %}
+
+ {% if app.download.text %}{{ app.download.text }}{% else %}{% blocktrans with app_name=app.name %}Download and install {{ app_name }} below:{% endblocktrans %}{% endif %}
+
+
+ {% for button in app.download.buttons %}
+ {% if button.image %}
+
+
+
+ {% endif %}
+ {% if button.text %}
+
+ {{ button.text }}
+
+ {% endif %}
+ {% endfor %}
+
+
+ {% blocktrans with app_name=app.name %}After successfully installing {{ app_name }}, come back to this page and continue with Step 2 .{% endblocktrans %}
+
+ {% trans "Step 2: Activate your account" %}
+
+ {% trans "Installed ok? Great! Click or tap the button below to accept your invite and continue with your account setup:" %}
+
+
+
+ {% blocktrans with app_name=app.name %}After clicking the button you will be taken to {{ app_name }} to finish setting up your new {{ site_name }} account.{% endblocktrans %}
+{% endblock %}
+
+{% block extra_scripts %}
+
+
+
+{% endblock %}
diff --git a/priv/mod_invites/invite.html b/priv/mod_invites/invite.html
new file mode 100644
index 00000000000..d59211bf248
--- /dev/null
+++ b/priv/mod_invites/invite.html
@@ -0,0 +1,18 @@
+{% extends "base.html" %}
+
+{% block content %}
+ {% if invite.inviter|user %}
+ {% blocktrans with inviter=invite.inviter|user %}You have been invited to chat with {{ inviter }} on {{ site_name }} , part of the XMPP secure and decentralized messaging network.{% endblocktrans %}
+ {% else %}
+ {% blocktrans %}You have been invited to chat on {{ site_name }} , part of the XMPP secure and decentralized messaging network.{% endblocktrans %}
+ {% endif %}
+
+ {% trans "Get started" %}
+
+ {% trans "To get started, you need to install an app for your platform:" %}
+
+ {% include "apps.html" %}
+
+ {% trans "Other software" %}
+ {% blocktrans %}You can connect to {{ site_name }} using any XMPP-compatible software. If your preferred software is not listed above, you may still register an account manually .{% endblocktrans %}
+{% endblock %}
diff --git a/priv/mod_invites/invite_invalid.html b/priv/mod_invites/invite_invalid.html
new file mode 100644
index 00000000000..ad40d8ba039
--- /dev/null
+++ b/priv/mod_invites/invite_invalid.html
@@ -0,0 +1,9 @@
+{% extends "base_min.html" %}
+{% block form_class %}container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 mt-2 mt-md-5{% endblock %}
+{% block content %}
+ {% trans "Invite expired" %}
+
+ {% trans "Sorry, it looks like this invite code has expired!" %}
+
+
+{% endblock %}
diff --git a/priv/mod_invites/register.html b/priv/mod_invites/register.html
new file mode 100644
index 00000000000..ed55781668d
--- /dev/null
+++ b/priv/mod_invites/register.html
@@ -0,0 +1,64 @@
+{% extends "base_min.html" %}
+
+{% block title %}{% blocktrans %}Register on {{ site_name }}{% endblocktrans %}{% endblock %}
+{% block h1 %}{% blocktrans %}Register on {{ site_name }}{% endblocktrans %}{% endblock %}
+
+{% block form_class %}container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 mt-2 mt-md-5{% endblock %}
+
+{% block content %}
+ {% if app %}{% blocktrans with app_name=app.name %}{{ site_name }} is part of XMPP, a secure and decentralized messaging network. To begin chatting using {{ app_name }} you need to first register an account.{% endblocktrans %}{% else %}{% blocktrans %}{{ site_name }} is part of XMPP, a secure and decentralized messaging network. To begin chatting you need to first register an account.{% endblocktrans %}{% endif %}
+
+ {%if invite.inviter %}{% blocktrans with inviter=invite.inviter|user %}Creating an account will allow to communicate with {{ inviter }} and other people on {{ site_name }} and other services on the XMPP network.{% endblocktrans %}{% else %}{% blocktrans %}Creating an account will allow to communicate with other people on {{ site_name }} and other services on the XMPP network.{% endblocktrans %}{% endif %}
+
+ {% if app %}{% if app.supports_preauth_uri %}
+
+
{% blocktrans with app_name=app.name %}If you already have {{ app_name }} installed, we recommend that you continue the account creation process using the app by clicking on the button below:{% endblocktrans %}
+
+
{% blocktrans with app_name=app.name %}{{ app_name }} already installed?{% endblocktrans %}
+
+
+
{% trans "Open the app" %}
+
{% trans "This button works only if you have the app installed already!" %}
+
+
+
+ {% endif %}{% endif %}
+
+ {% trans "Create an account" %}
+
+ {%if message %}
+ {{ message.text }}
+
{% endif %}
+
+
+{% endblock %}
diff --git a/priv/mod_invites/register_error.html b/priv/mod_invites/register_error.html
new file mode 100644
index 00000000000..804f89a837c
--- /dev/null
+++ b/priv/mod_invites/register_error.html
@@ -0,0 +1,7 @@
+{% extends "base_min.html" %}
+{% block form_class %}container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 mt-2 mt-md-5{% endblock %}
+{% block content %}
+ {% trans "Registration error" %}
+
+ {% if message %}{{ message }}{% else %}{% trans "Sorry, there was a problem registering your account." %}{% endif %}
+{% endblock%}
diff --git a/priv/mod_invites/register_success.html b/priv/mod_invites/register_success.html
new file mode 100644
index 00000000000..cc87593714a
--- /dev/null
+++ b/priv/mod_invites/register_success.html
@@ -0,0 +1,101 @@
+{% extends "base_min.html" %}
+{% block form_class %}container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 mt-2 mt-md-5{% endblock %}
+{% block title %}{{site_name}}{% endblock %}
+{% block h1 %}{{site_name}}{% endblock %}
+{% block extra_scripts %}
+
+{% endblock %}
+{% block content %}
+ {% trans "Congratulations!" %}
+
+ {% blocktrans %}You have created an account on {{ site_name }} .{% endblocktrans %}
+
+ {% trans "To start chatting, you need to enter your new account credentials into your chosen XMPP software." %}
+
+ {% if webchat_url %}
+
+
+
+
+
{% trans "No suitable software installed right now? You can also log in to your account through our online web chat!" %}
+
+
+
+
+
+ {% endif %}
+
+ {% if app %}
+ {% blocktrans with app_name=app.name %}You can now set up {{ app_name }} and connect it to your new account.{% endblocktrans %}
+
+ {% blocktrans with app_name=app.name %}Step 1: Download and install {{ app_name }}{% endblocktrans %}
+
+ {% if app.download.text %}{{ app.download.text }}{% else %}{% blocktrans with app_name=app.name %}Download and install {{ app_name }} below:{% endblocktrans %}{% endif %}
+
+
+ {% for item in app.download.buttons %}
+ {% if item.image %}
+
+
+
+ {% endif %}
+ {%if item.text %}
+
+
+ {{ item.text }}
+
+
+ {% endif %}
+ {% endfor %}
+
+
+ {% blocktrans with app_name=app.name %}Step 2: Connect {{ app_name }} to your new account{% endblocktrans %}
+
+ {% if app.setup.text %}{{ app.setup.text }}{% else %}{% blocktrans with app_name=app.name %}Launch {{ app_name }} and sign in using your account credentials.{% endblocktrans %}{% endif %}
+ {% endif %}
+
+ {% trans "As a final reminder, your account details are shown below:" %}
+
+
+
+ {% if password %}
+ {% trans "Your password is stored encrypted on the server and will not be accessible after you close this page. Keep it safe and never share it with anyone." %}
+ {% endif %}
+{% endblock %}
diff --git a/priv/mod_invites/roster.html b/priv/mod_invites/roster.html
new file mode 100644
index 00000000000..9a00d4045bb
--- /dev/null
+++ b/priv/mod_invites/roster.html
@@ -0,0 +1,18 @@
+{% extends "base.html" %}
+
+{% block title %}{% blocktrans with inviter=invite.inviter|jid %}{{ inviter }} has invited you to connect!{% endblocktrans %}{% endblock %}
+{% block h1 %}{% blocktrans with inviter=invite.inviter|user %}{{ inviter }} has invited you to connect!{% endblocktrans %}{% endblock %}
+
+{% block content %}
+
+ {% blocktrans with inviter=invite.inviter|jid %}This is an invite from
{{ inviter }} to connect and chat on the XMPP network. If you already have an XMPP client installed just press the button below!{% endblocktrans %}
+
+
+{% trans "If you don't have an XMPP client installed yet, here's a list of suitable clients for your platform." %}
+
+{% include "apps.html" %}
+
+{% endblock %}
diff --git a/priv/mod_invites/static/illus-empty.svg b/priv/mod_invites/static/illus-empty.svg
new file mode 100644
index 00000000000..7a963020962
--- /dev/null
+++ b/priv/mod_invites/static/illus-empty.svg
@@ -0,0 +1 @@
+empty
\ No newline at end of file
diff --git a/priv/mod_invites/static/invite.js b/priv/mod_invites/static/invite.js
new file mode 100644
index 00000000000..aff07cd2c81
--- /dev/null
+++ b/priv/mod_invites/static/invite.js
@@ -0,0 +1,91 @@
+(function () {
+ // If QR lib loaded ok, show QR button on desktop devices
+ if(window.QRCode) {
+ const qrcode_opts = {
+ text : document.location.href,
+ addQuietZone: true
+ };
+ new QRCode(document.getElementById("qr-invite-page"), qrcode_opts);
+ document.getElementById('qr-button-container').classList.add("d-md-block");
+ }
+
+ // Detect current platform and show/hide appropriate clients
+ if(window.platform) {
+ let platform_friendly = null;
+ let platform_classname = null;
+ switch(platform.os.family) {
+ case "Ubuntu":
+ case "Linux":
+ case "Fedora":
+ case "Red Hat":
+ case "SuSE":
+ platform_friendly = platform.os.family + " (Linux)";
+ platform_classname = "linux";
+ break;
+ case "Linux aarch64":
+ platform_friendly = "Linux mobile";
+ platform_classname = "linux";
+ break;
+ case "Haiku R1":
+ platform_friendly = "Haiku";
+ platform_classname = "haiku";
+ break;
+ case "Windows Phone":
+ platform_friendly = "Windows Phone";
+ platform_classname = "windows-phone";
+ break;
+ case "OS X":
+ if (navigator.maxTouchPoints > 1) {
+ // looks like iPad to me!
+ platform_friendly = "iPadOS";
+ platform_classname = "ipados";
+ } else {
+ platform_friendly = "macOS";
+ platform_classname = "macos";
+ }
+ break;
+ default:
+ if(platform.os.family.startsWith("Windows")) {
+ platform_friendly = "Windows";
+ platform_classname = "windows";
+ } else {
+ platform_friendly = platform.os.family;
+ platform_classname = platform_friendly.toLowerCase();
+ }
+ }
+
+ if(platform_friendly && platform_classname) {
+ if(document.querySelectorAll('.client-card .client-platform-badge-'+platform_classname).length == 0) {
+ // No clients recognised for this platform, do nothing
+ return;
+ }
+ // Hide clients not for this platform
+ const client_cards = document.getElementsByClassName('client-card');
+ for (let card of client_cards) {
+ if (card.classList.contains('app-platform-'+platform_classname))
+ card.classList.add('supported-platform');
+ else if (!card.classList.contains('app-platform-web'))
+ card.hidden = true;
+ const badges = card.querySelectorAll('.client-platform-badge');
+ for (let badge of badges) {
+ if (badge.classList.contains('client-platform-badge-'+platform_classname)) {
+ badge.classList.add("badge-success");
+ badge.classList.remove("badge-info");
+ } else {
+ badge.classList.add("badge-secondary");
+ badge.classList.remove("badge-info");
+ }
+ }
+ }
+ const show_all_clients_button_container = document.getElementById('show-all-clients-button-container');
+ show_all_clients_button_container.querySelector('.platform-name').innerHTML = platform_friendly;
+ show_all_clients_button_container.classList.remove("d-none");
+ document.getElementById('show-all-clients-button').addEventListener('click', function (e) {
+ for (let card of client_cards)
+ card.hidden = false;
+ show_all_clients_button_container.hidden = true;
+ e.preventDefault();
+ });
+ }
+ }
+})();
diff --git a/priv/mod_invites/static/logos/apple_as.svg b/priv/mod_invites/static/logos/apple_as.svg
new file mode 100644
index 00000000000..072b425a1ab
--- /dev/null
+++ b/priv/mod_invites/static/logos/apple_as.svg
@@ -0,0 +1,46 @@
+
+ Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/priv/mod_invites/static/logos/beagle-im.svg b/priv/mod_invites/static/logos/beagle-im.svg
new file mode 100644
index 00000000000..068df5ceffd
--- /dev/null
+++ b/priv/mod_invites/static/logos/beagle-im.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/priv/mod_invites/static/logos/conversations.svg b/priv/mod_invites/static/logos/conversations.svg
new file mode 100644
index 00000000000..47b5ec79cb9
--- /dev/null
+++ b/priv/mod_invites/static/logos/conversations.svg
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/priv/mod_invites/static/logos/converse-js.svg b/priv/mod_invites/static/logos/converse-js.svg
new file mode 100644
index 00000000000..e286482c073
--- /dev/null
+++ b/priv/mod_invites/static/logos/converse-js.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/priv/mod_invites/static/logos/dino.svg b/priv/mod_invites/static/logos/dino.svg
new file mode 100644
index 00000000000..a893b5b2471
--- /dev/null
+++ b/priv/mod_invites/static/logos/dino.svg
@@ -0,0 +1 @@
+
diff --git a/priv/mod_invites/static/logos/fdroid.png b/priv/mod_invites/static/logos/fdroid.png
new file mode 100644
index 00000000000..4185d66293f
Binary files /dev/null and b/priv/mod_invites/static/logos/fdroid.png differ
diff --git a/priv/mod_invites/static/logos/gajim.svg b/priv/mod_invites/static/logos/gajim.svg
new file mode 100644
index 00000000000..15a88fc7b69
--- /dev/null
+++ b/priv/mod_invites/static/logos/gajim.svg
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/priv/mod_invites/static/logos/generic.svg b/priv/mod_invites/static/logos/generic.svg
new file mode 100644
index 00000000000..7fc38d5c4f3
--- /dev/null
+++ b/priv/mod_invites/static/logos/generic.svg
@@ -0,0 +1,267 @@
+
+
+
+image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/priv/mod_invites/static/logos/google_ps.png b/priv/mod_invites/static/logos/google_ps.png
new file mode 100644
index 00000000000..131f3acaa25
Binary files /dev/null and b/priv/mod_invites/static/logos/google_ps.png differ
diff --git a/priv/mod_invites/static/logos/monal-tmp.svg b/priv/mod_invites/static/logos/monal-tmp.svg
new file mode 100644
index 00000000000..3cfaf1fc7c6
--- /dev/null
+++ b/priv/mod_invites/static/logos/monal-tmp.svg
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/priv/mod_invites/static/logos/monal.png b/priv/mod_invites/static/logos/monal.png
new file mode 100644
index 00000000000..936f5442df9
Binary files /dev/null and b/priv/mod_invites/static/logos/monal.png differ
diff --git a/priv/mod_invites/static/logos/renga.svg b/priv/mod_invites/static/logos/renga.svg
new file mode 100644
index 00000000000..a51d9563a6d
--- /dev/null
+++ b/priv/mod_invites/static/logos/renga.svg
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/priv/mod_invites/static/logos/siskin-im.svg b/priv/mod_invites/static/logos/siskin-im.svg
new file mode 100644
index 00000000000..50fd76736b9
--- /dev/null
+++ b/priv/mod_invites/static/logos/siskin-im.svg
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/priv/mod_invites/static/logos/yaxim.svg b/priv/mod_invites/static/logos/yaxim.svg
new file mode 100644
index 00000000000..4c8c487d3e4
--- /dev/null
+++ b/priv/mod_invites/static/logos/yaxim.svg
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/priv/mod_invites/static/platform.min.js b/priv/mod_invites/static/platform.min.js
new file mode 100644
index 00000000000..2dcd14c3f31
--- /dev/null
+++ b/priv/mod_invites/static/platform.min.js
@@ -0,0 +1,100 @@
+/*!* Platform.js
+* Copyright 2014-2018 Benjamin Tan
+* Copyright 2011-2013 John-David Dalton
+* Available under MIT license */;(function(){'use strict';var objectTypes={'function':true,'object':true};var root=(objectTypes[typeof window]&&window)||this;var oldRoot=root;var freeExports=objectTypes[typeof exports]&&exports;var freeModule=objectTypes[typeof module]&&module&&!module.nodeType&&module;var freeGlobal=freeExports&&freeModule&&typeof global=='object'&&global;if(freeGlobal&&(freeGlobal.global===freeGlobal||freeGlobal.window===freeGlobal||freeGlobal.self===freeGlobal)){root=freeGlobal;}
+var maxSafeInteger=Math.pow(2,53)-1;var reOpera=/\bOpera/;var thisBinding=this;var objectProto=Object.prototype;var hasOwnProperty=objectProto.hasOwnProperty;var toString=objectProto.toString;function capitalize(string){string=String(string);return string.charAt(0).toUpperCase()+string.slice(1);}
+function cleanupOS(os,pattern,label){var data={'10.0':'10','6.4':'10 Technical Preview','6.3':'8.1','6.2':'8','6.1':'Server 2008 R2 / 7','6.0':'Server 2008 / Vista','5.2':'Server 2003 / XP 64-bit','5.1':'XP','5.01':'2000 SP1','5.0':'2000','4.0':'NT','4.90':'ME'};if(pattern&&label&&/^Win/i.test(os)&&!/^Windows Phone /i.test(os)&&(data=data[/[\d.]+$/.exec(os)])){os='Windows '+data;}
+os=String(os);if(pattern&&label){os=os.replace(RegExp(pattern,'i'),label);}
+os=format(os.replace(/ ce$/i,' CE').replace(/\bhpw/i,'web').replace(/\bMacintosh\b/,'Mac OS').replace(/_PowerPC\b/i,' OS').replace(/\b(OS X) [^ \d]+/i,'$1').replace(/\bMac (OS X)\b/,'$1').replace(/\/(\d)/,' $1').replace(/_/g,'.').replace(/(?: BePC|[ .]*fc[ \d.]+)$/i,'').replace(/\bx86\.64\b/gi,'x86_64').replace(/\b(Windows Phone) OS\b/,'$1').replace(/\b(Chrome OS \w+) [\d.]+\b/,'$1').split(' on ')[0]);return os;}
+function each(object,callback){var index=-1,length=object?object.length:0;if(typeof length=='number'&&length>-1&&length<=maxSafeInteger){while(++index3&&'WebKit'||/\bOpera\b/.test(name)&&(/\bOPR\b/.test(ua)?'Blink':'Presto')||/\b(?:Midori|Nook|Safari)\b/i.test(ua)&&!/^(?:Trident|EdgeHTML)$/.test(layout)&&'WebKit'||!layout&&/\bMSIE\b/i.test(ua)&&(os=='Mac OS'?'Tasman':'Trident')||layout=='WebKit'&&/\bPlayStation\b(?! Vita\b)/i.test(name)&&'NetFront')){layout=[data];}
+if(name=='IE'&&(data=(/; *(?:XBLWP|ZuneWP)(\d+)/i.exec(ua)||0)[1])){name+=' Mobile';os='Windows Phone '+(/\+$/.test(data)?data:data+'.x');description.unshift('desktop mode');}
+else if(/\bWPDesktop\b/i.test(ua)){name='IE Mobile';os='Windows Phone 8.x';description.unshift('desktop mode');version||(version=(/\brv:([\d.]+)/.exec(ua)||0)[1]);}
+else if(name!='IE'&&layout=='Trident'&&(data=/\brv:([\d.]+)/.exec(ua))){if(name){description.push('identifying as '+name+(version?' '+version:''));}
+name='IE';version=data[1];}
+if(useFeatures){if(isHostType(context,'global')){if(java){data=java.lang.System;arch=data.getProperty('os.arch');os=os||data.getProperty('os.name')+' '+data.getProperty('os.version');}
+if(rhino){try{version=context.require('ringo/engine').version.join('.');name='RingoJS';}catch(e){if((data=context.system)&&data.global.system==context.system){name='Narwhal';os||(os=data[0].os||null);}}
+if(!name){name='Rhino';}}
+else if(typeof context.process=='object'&&!context.process.browser&&(data=context.process)){if(typeof data.versions=='object'){if(typeof data.versions.electron=='string'){description.push('Node '+data.versions.node);name='Electron';version=data.versions.electron;}else if(typeof data.versions.nw=='string'){description.push('Chromium '+version,'Node '+data.versions.node);name='NW.js';version=data.versions.nw;}}
+if(!name){name='Node.js';arch=data.arch;os=data.platform;version=/[\d.]+/.exec(data.version);version=version?version[0]:null;}}}
+else if(getClassOf((data=context.runtime))==airRuntimeClass){name='Adobe AIR';os=data.flash.system.Capabilities.os;}
+else if(getClassOf((data=context.phantom))==phantomClass){name='PhantomJS';version=(data=data.version||null)&&(data.major+'.'+data.minor+'.'+data.patch);}
+else if(typeof doc.documentMode=='number'&&(data=/\bTrident\/(\d+)/i.exec(ua))){version=[version,doc.documentMode];if((data=+data[1]+4)!=version[1]){description.push('IE '+version[1]+' mode');layout&&(layout[1]='');version[1]=data;}
+version=name=='IE'?String(version[1].toFixed(1)):version[0];}
+else if(typeof doc.documentMode=='number'&&/^(?:Chrome|Firefox)\b/.test(name)){description.push('masking as '+name+' '+version);name='IE';version='11.0';layout=['Trident'];os='Windows';}
+os=os&&format(os);}
+if(version&&(data=/(?:[ab]|dp|pre|[ab]\d+pre)(?:\d+\+?)?$/i.exec(version)||/(?:alpha|beta)(?: ?\d)?/i.exec(ua+';'+(useFeatures&&nav.appMinorVersion))||/\bMinefield\b/i.test(ua)&&'a')){prerelease=/b/i.test(data)?'beta':'alpha';version=version.replace(RegExp(data+'\\+?$'),'')+
+(prerelease=='beta'?beta:alpha)+(/\d+\+?/.exec(data)||'');}
+if(name=='Fennec'||name=='Firefox'&&/\b(?:Android|Firefox OS)\b/.test(os)){name='Firefox Mobile';}
+else if(name=='Maxthon'&&version){version=version.replace(/\.[\d.]+/,'.x');}
+else if(/\bXbox\b/i.test(product)){if(product=='Xbox 360'){os=null;}
+if(product=='Xbox 360'&&/\bIEMobile\b/.test(ua)){description.unshift('mobile mode');}}
+else if((/^(?:Chrome|IE|Opera)$/.test(name)||name&&!product&&!/Browser|Mobi/.test(name))&&(os=='Windows CE'||/Mobi/i.test(ua))){name+=' Mobile';}
+else if(name=='IE'&&useFeatures){try{if(context.external===null){description.unshift('platform preview');}}catch(e){description.unshift('embedded');}}
+else if((/\bBlackBerry\b/.test(product)||/\bBB10\b/.test(ua))&&(data=(RegExp(product.replace(/ +/g,' *')+'/([.\\d]+)','i').exec(ua)||0)[1]||version)){data=[data,/BB10/.test(ua)];os=(data[1]?(product=null,manufacturer='BlackBerry'):'Device Software')+' '+data[0];version=null;}
+else if(this!=forOwn&&product!='Wii'&&((useFeatures&&opera)||(/Opera/.test(name)&&/\b(?:MSIE|Firefox)\b/i.test(ua))||(name=='Firefox'&&/\bOS X (?:\d+\.){2,}/.test(os))||(name=='IE'&&((os&&!/^Win/.test(os)&&version>5.5)||/\bWindows XP\b/.test(os)&&version>8||version==8&&!/\bTrident\b/.test(ua))))&&!reOpera.test((data=parse.call(forOwn,ua.replace(reOpera,'')+';')))&&data.name){data='ing as '+data.name+((data=data.version)?' '+data:'');if(reOpera.test(name)){if(/\bIE\b/.test(data)&&os=='Mac OS'){os=null;}
+data='identify'+data;}
+else{data='mask'+data;if(operaClass){name=format(operaClass.replace(/([a-z])([A-Z])/g,'$1 $2'));}else{name='Opera';}
+if(/\bIE\b/.test(data)){os=null;}
+if(!useFeatures){version=null;}}
+layout=['Presto'];description.push(data);}
+if((data=(/\bAppleWebKit\/([\d.]+\+?)/i.exec(ua)||0)[1])){data=[parseFloat(data.replace(/\.(\d)$/,'.0$1')),data];if(name=='Safari'&&data[1].slice(-1)=='+'){name='WebKit Nightly';prerelease='alpha';version=data[1].slice(0,-1);}
+else if(version==data[1]||version==(data[2]=(/\bSafari\/([\d.]+\+?)/i.exec(ua)||0)[1])){version=null;}
+data[1]=(/\bChrome\/([\d.]+)/i.exec(ua)||0)[1];if(data[0]==537.36&&data[2]==537.36&&parseFloat(data[1])>=28&&layout=='WebKit'){layout=['Blink'];}
+if(!useFeatures||(!likeChrome&&!data[1])){layout&&(layout[1]='like Safari');data=(data=data[0],data<400?1:data<500?2:data<526?3:data<533?4:data<534?'4+':data<535?5:data<537?6:data<538?7:data<601?8:'8');}else{layout&&(layout[1]='like Chrome');data=data[1]||(data=data[0],data<530?1:data<532?2:data<532.05?3:data<533?4:data<534.03?5:data<534.07?6:data<534.10?7:data<534.13?8:data<534.16?9:data<534.24?10:data<534.30?11:data<535.01?12:data<535.02?'13+':data<535.07?15:data<535.11?16:data<535.19?17:data<536.05?18:data<536.10?19:data<537.01?20:data<537.11?'21+':data<537.13?23:data<537.18?24:data<537.24?25:data<537.36?26:layout!='Blink'?'27':'28');}
+layout&&(layout[1]+=' '+(data+=typeof data=='number'?'.x':/[.+]/.test(data)?'':'+'));if(name=='Safari'&&(!version||parseInt(version)>45)){version=data;}}
+if(name=='Opera'&&(data=/\bzbov|zvav$/.exec(os))){name+=' ';description.unshift('desktop mode');if(data=='zvav'){name+='Mini';version=null;}else{name+='Mobile';}
+os=os.replace(RegExp(' *'+data+'$'),'');}
+else if(name=='Safari'&&/\bChrome\b/.exec(layout&&layout[1])){description.unshift('desktop mode');name='Chrome Mobile';version=null;if(/\bOS X\b/.test(os)){manufacturer='Apple';os='iOS 4.3+';}else{os=null;}}
+if(version&&version.indexOf((data=/[\d.]+$/.exec(os)))==0&&ua.indexOf('/'+data+'-')>-1){os=trim(os.replace(data,''));}
+if(layout&&!/\b(?:Avant|Nook)\b/.test(name)&&(/Browser|Lunascape|Maxthon/.test(name)||name!='Safari'&&/^iOS/.test(os)&&/\bSafari\b/.test(layout[1])||/^(?:Adobe|Arora|Breach|Midori|Opera|Phantom|Rekonq|Rock|Samsung Internet|Sleipnir|Web)/.test(name)&&layout[1])){(data=layout[layout.length-1])&&description.push(data);}
+if(description.length){description=['('+description.join('; ')+')'];}
+if(manufacturer&&product&&product.indexOf(manufacturer)<0){description.push('on '+manufacturer);}
+if(product){description.push((/^on /.test(description[description.length-1])?'':'on ')+product);}
+if(os){data=/ ([\d.+]+)$/.exec(os);isSpecialCasedOS=data&&os.charAt(os.length-data[0].length-1)=='/';os={'architecture':32,'family':(data&&!isSpecialCasedOS)?os.replace(data[0],''):os,'version':data?data[1]:null,'toString':function(){var version=this.version;return this.family+((version&&!isSpecialCasedOS)?' '+version:'')+(this.architecture==64?' 64-bit':'');}};}
+if((data=/\b(?:AMD|IA|Win|WOW|x86_|x)64\b/i.exec(arch))&&!/\bi686\b/i.test(arch)){if(os){os.architecture=64;os.family=os.family.replace(RegExp(' *'+data),'');}
+if(name&&(/\bWOW64\b/i.test(ua)||(useFeatures&&/\w(?:86|32)$/.test(nav.cpuClass||nav.platform)&&!/\bWin64; x64\b/i.test(ua)))){description.unshift('32-bit');}}
+else if(os&&/^OS X/.test(os.family)&&name=='Chrome'&&parseFloat(version)>=39){os.architecture=64;}
+ua||(ua=null);var platform={};platform.description=ua;platform.layout=layout&&layout[0];platform.manufacturer=manufacturer;platform.name=name;platform.prerelease=prerelease;platform.product=product;platform.ua=ua;platform.version=name&&version;platform.os=os||{'architecture':null,'family':null,'version':null,'toString':function(){return 'null';}};platform.parse=parse;platform.toString=toStringPlatform;if(platform.version){description.unshift(version);}
+if(platform.name){description.unshift(name);}
+if(os&&name&&!(os==String(os).split(' ')[0]&&(os==name.split(' ')[0]||product))){description.push(product?'('+os+')':'on '+os);}
+if(description.length){platform.description=description.join(' ');}
+return platform;}
+var platform=parse();if(typeof define=='function'&&typeof define.amd=='object'&&define.amd){root.platform=platform;define(function(){return platform;});}
+else if(freeExports&&freeModule){forOwn(platform,function(value,key){freeExports[key]=value;});}
+else{root.platform=platform;}}.call(this));
\ No newline at end of file
diff --git a/priv/mod_invites/static/qr-logo.png b/priv/mod_invites/static/qr-logo.png
new file mode 100644
index 00000000000..668e8980a8b
Binary files /dev/null and b/priv/mod_invites/static/qr-logo.png differ
diff --git a/priv/mod_invites/static/qrcode.min.js b/priv/mod_invites/static/qrcode.min.js
new file mode 100644
index 00000000000..f35950061c5
--- /dev/null
+++ b/priv/mod_invites/static/qrcode.min.js
@@ -0,0 +1,17 @@
+/*
+The MIT License (MIT)
+---------------------
+Copyright (c) 2012 davidshimjs
+
+Permission is hereby granted, free of charge,
+to any person obtaining a copy of this software and associated documentation files (the "Software"),
+to deal in the Software without restriction,
+including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+*/
+var QRCode;(function(){function QR8bitByte(data){this.mode=QRMode.MODE_8BIT_BYTE;this.data=data;this.parsedData=[];for(var i=0,l=this.data.length;i65536){byteArray[0]=240|(code&1835008)>>>18;byteArray[1]=128|(code&258048)>>>12;byteArray[2]=128|(code&4032)>>>6;byteArray[3]=128|code&63}else if(code>2048){byteArray[0]=224|(code&61440)>>>12;byteArray[1]=128|(code&4032)>>>6;byteArray[2]=128|code&63}else if(code>128){byteArray[0]=192|(code&1984)>>>6;byteArray[1]=128|code&63}else{byteArray[0]=code}this.parsedData.push(byteArray)}this.parsedData=Array.prototype.concat.apply([],this.parsedData);if(this.parsedData.length!=this.data.length){this.parsedData.unshift(191);this.parsedData.unshift(187);this.parsedData.unshift(239)}}QR8bitByte.prototype={getLength:function(buffer){return this.parsedData.length},write:function(buffer){for(var i=0,l=this.parsedData.length;i=7){this.setupTypeNumber(test)}if(this.dataCache==null){this.dataCache=QRCodeModel.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)}this.mapData(this.dataCache,maskPattern)},setupPositionProbePattern:function(row,col){for(var r=-1;r<=7;r++){if(row+r<=-1||this.moduleCount<=row+r)continue;for(var c=-1;c<=7;c++){if(col+c<=-1||this.moduleCount<=col+c)continue;if(0<=r&&r<=6&&(c==0||c==6)||0<=c&&c<=6&&(r==0||r==6)||2<=r&&r<=4&&2<=c&&c<=4){this.modules[row+r][col+c]=true}else{this.modules[row+r][col+c]=false}}}},getBestMaskPattern:function(){var minLostPoint=0;var pattern=0;for(var i=0;i<8;i++){this.makeImpl(true,i);var lostPoint=QRUtil.getLostPoint(this);if(i==0||minLostPoint>lostPoint){minLostPoint=lostPoint;pattern=i}}return pattern},createMovieClip:function(target_mc,instance_name,depth){var qr_mc=target_mc.createEmptyMovieClip(instance_name,depth);var cs=1;this.make();for(var row=0;row>i&1)==1;this.modules[Math.floor(i/3)][i%3+this.moduleCount-8-3]=mod}for(var i=0;i<18;i++){var mod=!test&&(bits>>i&1)==1;this.modules[i%3+this.moduleCount-8-3][Math.floor(i/3)]=mod}},setupTypeInfo:function(test,maskPattern){var data=this.errorCorrectLevel<<3|maskPattern;var bits=QRUtil.getBCHTypeInfo(data);for(var i=0;i<15;i++){var mod=!test&&(bits>>i&1)==1;if(i<6){this.modules[i][8]=mod}else if(i<8){this.modules[i+1][8]=mod}else{this.modules[this.moduleCount-15+i][8]=mod}}for(var i=0;i<15;i++){var mod=!test&&(bits>>i&1)==1;if(i<8){this.modules[8][this.moduleCount-i-1]=mod}else if(i<9){this.modules[8][15-i-1+1]=mod}else{this.modules[8][15-i-1]=mod}}this.modules[this.moduleCount-8][8]=!test},mapData:function(data,maskPattern){var inc=-1;var row=this.moduleCount-1;var bitIndex=7;var byteIndex=0;for(var col=this.moduleCount-1;col>0;col-=2){if(col==6)col--;while(true){for(var c=0;c<2;c++){if(this.modules[row][col-c]==null){var dark=false;if(byteIndex>>bitIndex&1)==1}var mask=QRUtil.getMask(maskPattern,row,col-c);if(mask){dark=!dark}this.modules[row][col-c]=dark;bitIndex--;if(bitIndex==-1){byteIndex++;bitIndex=7}}}row+=inc;if(row<0||this.moduleCount<=row){row-=inc;inc=-inc;break}}}}};QRCodeModel.PAD0=236;QRCodeModel.PAD1=17;QRCodeModel.createData=function(typeNumber,errorCorrectLevel,dataList){var rsBlocks=QRRSBlock.getRSBlocks(typeNumber,errorCorrectLevel);var buffer=new QRBitBuffer;for(var i=0;itotalDataCount*8){throw new Error("code length overflow. ("+buffer.getLengthInBits()+">"+totalDataCount*8+")")}if(buffer.getLengthInBits()+4<=totalDataCount*8){buffer.put(0,4)}while(buffer.getLengthInBits()%8!=0){buffer.putBit(false)}while(true){if(buffer.getLengthInBits()>=totalDataCount*8){break}buffer.put(QRCodeModel.PAD0,8);if(buffer.getLengthInBits()>=totalDataCount*8){break}buffer.put(QRCodeModel.PAD1,8)}return QRCodeModel.createBytes(buffer,rsBlocks)};QRCodeModel.createBytes=function(buffer,rsBlocks){var offset=0;var maxDcCount=0;var maxEcCount=0;var dcdata=new Array(rsBlocks.length);var ecdata=new Array(rsBlocks.length);for(var r=0;r=0?modPoly.get(modIndex):0}}var totalCodeCount=0;for(var i=0;i=0){d^=QRUtil.G15<=0){d^=QRUtil.G18<>>=1}return digit},getPatternPosition:function(typeNumber){return QRUtil.PATTERN_POSITION_TABLE[typeNumber-1]},getMask:function(maskPattern,i,j){switch(maskPattern){case QRMaskPattern.PATTERN000:return(i+j)%2==0;case QRMaskPattern.PATTERN001:return i%2==0;case QRMaskPattern.PATTERN010:return j%3==0;case QRMaskPattern.PATTERN011:return(i+j)%3==0;case QRMaskPattern.PATTERN100:return(Math.floor(i/2)+Math.floor(j/3))%2==0;case QRMaskPattern.PATTERN101:return i*j%2+i*j%3==0;case QRMaskPattern.PATTERN110:return(i*j%2+i*j%3)%2==0;case QRMaskPattern.PATTERN111:return(i*j%3+(i+j)%2)%2==0;default:throw new Error("bad maskPattern:"+maskPattern)}},getErrorCorrectPolynomial:function(errorCorrectLength){var a=new QRPolynomial([1],0);for(var i=0;i5){lostPoint+=3+sameCount-5}}}for(var row=0;row=256){n-=255}return QRMath.EXP_TABLE[n]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)};for(var i=0;i<8;i++){QRMath.EXP_TABLE[i]=1<>>7-index%8&1)==1},put:function(num,length){for(var i=0;i>>length-i-1&1)==1)}},getLengthInBits:function(){return this.length},putBit:function(bit){var bufIndex=Math.floor(this.length/8);if(this.buffer.length<=bufIndex){this.buffer.push(0)}if(bit){this.buffer[bufIndex]|=128>>>this.length%8}this.length++}};var QRCodeLimitLength=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]];function _isSupportCanvas(){return typeof CanvasRenderingContext2D!="undefined"}function _getAndroid(){var android=false;var sAgent=navigator.userAgent;if(/android/i.test(sAgent)){android=true;var aMat=sAgent.toString().match(/android ([0-9]\.[0-9])/i);if(aMat&&aMat[1]){android=parseFloat(aMat[1])}}return android}var svgDrawer=function(){var Drawing=function(el,htOption){this._el=el;this._htOption=htOption};Drawing.prototype.draw=function(oQRCode){var _htOption=this._htOption;var _el=this._el;var nCount=oQRCode.getModuleCount();var nWidth=Math.floor(_htOption.width/nCount);var nHeight=Math.floor(_htOption.height/nCount);this.clear();function makeSVG(tag,attrs){var el=document.createElementNS("http://www.w3.org/2000/svg",tag);for(var k in attrs)if(attrs.hasOwnProperty(k))el.setAttribute(k,attrs[k]);return el}var svg=makeSVG("svg",{viewBox:"0 0 "+String(nCount)+" "+String(nCount),width:"100%",height:"100%",fill:_htOption.colorLight});svg.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink");_el.appendChild(svg);svg.appendChild(makeSVG("rect",{fill:_htOption.colorLight,width:"100%",height:"100%"}));svg.appendChild(makeSVG("rect",{fill:_htOption.colorDark,width:"1",height:"1",id:"template"}));for(var row=0;row'];for(var row=0;row");for(var col=0;col')}aHTML.push("")}aHTML.push("");_el.innerHTML=aHTML.join("");var elTable=_el.childNodes[0];var nLeftMarginTable=(_htOption.width-elTable.offsetWidth)/2;var nTopMarginTable=(_htOption.height-elTable.offsetHeight)/2;if(nLeftMarginTable>0&&nTopMarginTable>0){elTable.style.margin=nTopMarginTable+"px "+nLeftMarginTable+"px"}};Drawing.prototype.clear=function(){this._el.innerHTML=""};return Drawing}():function(){function _onMakeImage(){this._elImage.src=this._elCanvas.toDataURL("image/png");this._elImage.style.display="block";this._elCanvas.style.display="none"}if(this._android&&this._android<=2.1){var factor=1/window.devicePixelRatio;var drawImage=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(image,sx,sy,sw,sh,dx,dy,dw,dh){if("nodeName"in image&&/img/i.test(image.nodeName)){for(var i=arguments.length-1;i>=1;i--){arguments[i]=arguments[i]*factor}}else if(typeof dw=="undefined"){arguments[1]*=factor;arguments[2]*=factor;arguments[3]*=factor;arguments[4]*=factor}drawImage.apply(this,arguments)}}function _safeSetDataURI(fSuccess,fFail){var self=this;self._fFail=fFail;self._fSuccess=fSuccess;if(self._bSupportDataURI===null){var el=document.createElement("img");var fOnError=function(){self._bSupportDataURI=false;if(self._fFail){self._fFail.call(self)}};var fOnSuccess=function(){self._bSupportDataURI=true;if(self._fSuccess){self._fSuccess.call(self)}};el.onabort=fOnError;el.onerror=fOnError;el.onload=fOnSuccess;el.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";return}else if(self._bSupportDataURI===true&&self._fSuccess){self._fSuccess.call(self)}else if(self._bSupportDataURI===false&&self._fFail){self._fFail.call(self)}}var Drawing=function(el,htOption){this._bIsPainted=false;this._android=_getAndroid();this._htOption=htOption;this._elCanvas=document.createElement("canvas");this._elCanvas.width=htOption.width;this._elCanvas.height=htOption.height;el.appendChild(this._elCanvas);this._el=el;this._oContext=this._elCanvas.getContext("2d");this._bIsPainted=false;this._elImage=document.createElement("img");this._elImage.alt="Scan me!";this._elImage.style.display="none";this._el.appendChild(this._elImage);this._bSupportDataURI=null};Drawing.prototype.draw=function(oQRCode){var _elImage=this._elImage;var _oContext=this._oContext;var _htOption=this._htOption;var nCount=oQRCode.getModuleCount();var nCountWithQuietZone=nCount;if(_htOption.addQuietZone){nCountWithQuietZone+=8}var nWidth=_htOption.width/nCountWithQuietZone;var nHeight=_htOption.height/nCountWithQuietZone;var nRoundedWidth=Math.round(nWidth);var nRoundedHeight=Math.round(nHeight);_elImage.style.display="none";this.clear();var drawBitSquare=function(row,col,bIsDark){var nLeft=col*nWidth;var nTop=row*nHeight;_oContext.strokeStyle=bIsDark?_htOption.colorDark:_htOption.colorLight;_oContext.lineWidth=1;_oContext.fillStyle=bIsDark?_htOption.colorDark:_htOption.colorLight;_oContext.fillRect(nLeft,nTop,nWidth,nHeight);_oContext.strokeRect(Math.floor(nLeft)+.5,Math.floor(nTop)+.5,nRoundedWidth,nRoundedHeight);_oContext.strokeRect(Math.ceil(nLeft)-.5,Math.ceil(nTop)-.5,nRoundedWidth,nRoundedHeight)};if(_htOption.addQuietZone){var last=nCountWithQuietZone-1;for(var i=0;iQRCodeLimitLength.length){throw new Error("Too long data")}return nType}function _getUTF8Length(sText){var replacedText=encodeURI(sText).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return replacedText.length+(replacedText.length!=sText?3:0)}QRCode=function(el,vOption){this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:QRErrorCorrectLevel.H,addQuietZone:false};if(typeof vOption==="string"){vOption={text:vOption}}if(vOption){for(var i in vOption){this._htOption[i]=vOption[i]}}if(typeof el=="string"){el=document.getElementById(el)}if(this._htOption.useSVG){Drawing=svgDrawer}this._android=_getAndroid();this._el=el;this._oQRCode=null;this._oDrawing=new Drawing(this._el,this._htOption);if(this._htOption.text){this.makeCode(this._htOption.text)}};QRCode.prototype.makeCode=function(sText){this._oQRCode=new QRCodeModel(_getTypeNumber(sText,this._htOption.correctLevel),this._htOption.correctLevel);this._oQRCode.addData(sText);this._oQRCode.make();this._el.title=sText;this._oDrawing.draw(this._oQRCode);this.makeImage()};QRCode.prototype.makeImage=function(){if(typeof this._oDrawing.makeImage=="function"&&(!this._android||this._android>=3)){this._oDrawing.makeImage()}};QRCode.prototype.clear=function(){this._oDrawing.clear()};QRCode.CorrectLevel=QRErrorCorrectLevel})();
diff --git a/priv/msgs/de.msg b/priv/msgs/de.msg
index 7247d5f55b0..cdf1e601111 100644
--- a/priv/msgs/de.msg
+++ b/priv/msgs/de.msg
@@ -5,21 +5,38 @@
{" (Add * to the end of field to match substring)"," (Fügen Sie * am Ende des Feldes hinzu um nach Teilzeichenketten zu suchen)"}.
{" has set the subject to: "," hat das Thema geändert auf: "}.
+{"{{ app_name }} already installed?","{{ app_name }} bereits installiert?"}.
+{"{{ inviter }} has invited you to connect!","{{ inviter }} will sich mit dir verbinden!"}.
{"# participants","# Teilnehmer"}.
+{"{{ site_name }} is part of XMPP, a secure and decentralized messaging network. To begin chatting using {{ app_name }} you need to first register an account.","{{ site_name }} ist Teil von XMPP, ein sicheres und dezentrales Sofortnachrichten-Netzwerk. Um mittels {{ app_name }} chatten zu können musst du zunächst ein Konto anlegen."}.
+{"{{ site_name }} is part of XMPP, a secure and decentralized messaging network. To begin chatting you need to first register an account.","{{ site_name }} is Teil von XMPP, ein sicheres und dezentrales Sofortnachrichten-Netzwerk. Um chatten zu können musst du zunächst ein Konto anlegen."}.
+{"No suitable software installed right now? You can also log in to your account through our online web chat!","Du hast keine passende Software zur Hand im Moment? Du kannst dich auch mit unserem online Webchat anmelden!"}.
+{"Tip: You can open this invite on your mobile device by scanning a barcode with your camera.","Tipp: du kannst diese Einladung auf deinem mobilen Endgerät öffnen indem du einen Barcode mit deiner Kamera scannst."}.
+{"~s invites you to the room ~s","~s lädt Sie in den Raum ~s ein"}.
+{"~ts's MAM Archive","~ts's MAM Archiv"}.
+{"~ts's Offline Messages Queue","Offline-Nachrichten-Warteschlange von ~ts"}.
{"A description of the node","Eine Beschreibung des Knotens"}.
{"A friendly name for the node","Ein benutzerfreundlicher Name für den Knoten"}.
+{"A fully-featured desktop chat client for Windows and Linux.","Ein funktionsreicher Desktop-Client für Windows und Linux."}.
+{"A lightweight and powerful XMPP client for iPhone and iPad. It provides an easy way to talk and share moments with your friends.","Ein schlanker aber funktionsreicher XMPP-Client für iPhone und iPad. Ein einfacher Weg um mit Deinen Freunden zu kommunizieren und Erinnerungen zu teilen."}.
+{"A modern open-source chat client for iPhone and iPad. It is easy to use and has a clean user interface.","Ein moderner open-source Chatclient für iPhone und iPad mit einfacher und übersichtlicher Benutzeroberfläche."}.
+{"A modern open-source chat client for Mac. It is easy to use and has a clean user interface.","Ein moderner open-source Chatclient für Mac mit einfacher und übersichtlicher Benutzeroberfläche."}.
+{"A modern open-source chat client for the desktop. It focuses on providing a clean and reliable Jabber/XMPP experience while having your privacy in mind.","Ein moderner open-source Jabber/XMPP Chatclient für den Desktop mit Fokus auf Einfachheit und Zuverlässigkeit, aber auch Schutz deiner Privatsphäre."}.
{"A password is required to enter this room","Ein Passwort ist erforderlich um diesen Raum zu betreten"}.
{"A Web Page","Eine Webseite"}.
+{"Accept invite using {{ app_name }}","Einladung mittels {{ app_name }} annehmen!"}.
{"Accept","Akzeptieren"}.
{"Access denied by service policy","Zugriff aufgrund der Dienstrichtlinien verweigert"}.
{"Access model","Zugriffsmodell"}.
{"Account doesn't exist","Konto existiert nicht"}.
{"Action on user","Aktion auf Benutzer"}.
-{"Add a hat to a user","Funktion zu einem Benutzer hinzufügen"}.
+{"Add {{ inviter }} to your contact list","Füge {{ inviter }} zu deiner Kontaktliste hinzu"}.
{"Add User","Benutzer hinzufügen"}.
{"Administration of ","Administration von "}.
{"Administration","Verwaltung"}.
{"Administrator privileges required","Administratorrechte erforderlich"}.
+{"After clicking the button you will be taken to {{ app_name }} to finish setting up your new {{ site_name }} account.","Nach dem Drücken des Buttons wirst du nach {{ app_name }} umgeleitet um dort die Einrichtung deines neuen Kontos auf {{ site_name }} fertigzustellen."}.
+{"After successfully installing {{ app_name }}, come back to this page and continue with Step 2 .","Nachdem du {{ app_name }} erfolgreich installiert hast, komme zu dieser Seite zurück und fahre mit Schritt 2 fort !"}.
{"All activity","Alle Aktivitäten"}.
{"All Users","Alle Benutzer"}.
{"Allow subscription","Abonnement erlauben"}.
@@ -49,6 +66,7 @@
{"API Commands","API Befehle"}.
{"April","April"}.
{"Arguments","Argumente"}.
+{"As a final reminder, your account details are shown below:","Zur Erinnerung hier nochmal deine Kontodetails:"}.
{"Attribute 'channel' is required for this request","Attribut 'channel' ist für diese Anforderung erforderlich"}.
{"Attribute 'id' is mandatory for MIX messages","Attribut 'id' ist verpflichtend für MIX-Nachrichten"}.
{"Attribute 'jid' is not allowed here","Attribut 'jid' ist hier nicht erlaubt"}.
@@ -61,6 +79,7 @@
{"Backup to File at ","Backup in Datei bei "}.
{"Backup","Backup"}.
{"Bad format","Ungültiges Format"}.
+{"Beagle IM by Tigase, Inc. is a lightweight and powerful XMPP client for macOS.","Beagle IM von Tigase Inc. ist eine schlanker aber funktionsreicher XMPP-Client für macOS."}.
{"Birthday","Geburtsdatum"}.
{"Both the username and the resource are required","Sowohl der Benutzername als auch die Ressource sind erforderlich"}.
{"Bytestream already activated","Bytestream bereits aktiviert"}.
@@ -77,6 +96,7 @@
{"Channel JID","Kanal-JID"}.
{"Channels","Kanäle"}.
{"Characters not allowed:","Nicht erlaubte Zeichen:"}.
+{"Chat address (JID)","Chat-Adresse (JID)"}.
{"Chatroom configuration modified","Chatraum-Konfiguration geändert"}.
{"Chatroom is created","Chatraum ist erstellt"}.
{"Chatroom is destroyed","Chatraum ist entfernt"}.
@@ -84,16 +104,25 @@
{"Chatroom is stopped","Chatraum ist beendet"}.
{"Chatrooms","Chaträume"}.
{"Choose a username and password to register with this server","Wählen Sie zum Registrieren auf diesem Server einen Benutzernamen und ein Passwort"}.
+{"Choose a username, this will become the first part of your new chat address.","Wähle eine Benutzernamen! Dies wird zum ersten Teil deiner neuen Chat-Adresse."}.
{"Choose storage type of tables","Wähle Speichertyp der Tabellen"}.
{"Choose whether to approve this entity's subscription.","Wählen Sie, ob das Abonnement dieser Entität genehmigt werden soll."}.
{"City","Stadt"}.
+{"Click the button to open the Dino website where you can download and install it on your PC.","Klicke auf den Button um Dino's Webseite zu öffnen, wo du ihn für deinen PC herunterladen und installieren kannst."}.
{"Client acknowledged more stanzas than sent by server","Client bestätigte mehr Stanzas als vom Server gesendet"}.
+{"Close","Schließen"}.
{"Commands","Befehle"}.
{"Conference room does not exist","Konferenzraum existiert nicht"}.
{"Configuration of room ~s","Konfiguration des Raumes ~s"}.
{"Configuration","Konfiguration"}.
+{"Congratulations!","Gratuliere!"}.
{"Contact Addresses (normally, room owner or owners)","Kontaktadresse (normalerweise Raumbesitzer)"}.
+{"Conversations is a Jabber/XMPP client for Android 6.0+ smartphones that has been optimized to provide a unique mobile experience.","Conversations ist ein Jabber/XMPP-Client für Android 6.0+, der für mobile Endgeräte optimiert wurde."}.
{"Country","Land"}.
+{"Create Account","Konto anlegen"}.
+{"Create an account","Konto anlegen"}.
+{"Creating an account will allow to communicate with {{ inviter }} and other people on {{ site_name }} and other services on the XMPP network.","Ein Konto erlaubt es dir mit {{ inviter }} und anderen Leuten auf {{ site_name }} und anderen Diensten des XMPP-Netwerkes zu kommunizieren."}.
+{"Creating an account will allow to communicate with other people on {{ site_name }} and other services on the XMPP network.","Ein Konto erlaubt es dir mit anderen Leuten auf {{ site_name }} und anderen Diensten des XMPP-Netwerkes zu kommunizieren."}.
{"Current Discussion Topic","Aktuelles Diskussionsthema"}.
{"Database failure","Datenbankfehler"}.
{"Database Tables Configuration at ","Datenbanktabellen-Konfiguration bei "}.
@@ -107,6 +136,11 @@
{"Deliver payloads with event notifications","Nutzdaten mit Ereignisbenachrichtigungen zustellen"}.
{"Disc only copy","Nur auf Festplatte"}.
{"Don't tell your password to anybody, not even the administrators of the XMPP server.","Geben Sie niemandem Ihr Passwort, auch nicht den Administratoren des XMPP-Servers."}.
+{"Download and install {{ app_name }} below:","Herunterladen und Installieren von {{ app_name }}:"}.
+{"Download Dino for Linux","Dino für Linux herunterladen"}.
+{"Download from Mac App Store","Vom Mac App Store herunterladen"}.
+{"Download Gajim","Gajim herunterladen"}.
+{"Download Renga for Haiku","Renga für Haiku herunterladen"}.
{"Dump Backup to Text File at ","Gib Backup in Textdatei aus bei "}.
{"Dump to Text File","Ausgabe in Textdatei"}.
{"Duplicated groups are not allowed by RFC6121","Doppelte Gruppen sind laut RFC6121 nicht erlaubt"}.
@@ -128,6 +162,7 @@
{"Enable message archiving","Nachrichtenarchivierung aktivieren"}.
{"Enabling push without 'node' attribute is not supported","push ohne 'node'-Attribut zu aktivieren wird nicht unterstützt"}.
{"End User Session","Benutzersitzung beenden"}.
+{"Enter a secure password that you do not use anywhere else.","Gib bitte ein sicheres Passwort ein, das du nirgends sonst verwendest!"}.
{"Enter nickname you want to register","Geben Sie den Spitznamen ein den Sie registrieren wollen"}.
{"Enter path to backup file","Geben Sie den Pfad zur Backupdatei ein"}.
{"Enter path to jabberd14 spool dir","Geben Sie den Pfad zum jabberd14-Spoolverzeichnis ein"}.
@@ -161,6 +196,7 @@
{"Get Number of Online Users","Anzahl der angemeldeten Benutzer abrufen"}.
{"Get Number of Registered Users","Anzahl der registrierten Benutzer abrufen"}.
{"Get Pending","Ausstehende abrufen"}.
+{"Get started","Leg los!"}.
{"Get User Last Login Time","letzte Anmeldezeit des Benutzers abrufen"}.
{"Get User Statistics","Benutzerstatistiken abrufen"}.
{"Given Name","Vorname"}.
@@ -174,9 +210,13 @@
{"Hat title","Funktionstitel"}.
{"Hat URI","Funktions-URI"}.
{"Hats limit exceeded","Funktionslimit wurde überschritten"}.
+{"Hide","Verbergen"}.
{"Host unknown","Host unbekannt"}.
+{"Hostname invalid","Hostname unbekannt"}.
{"HTTP File Upload","HTTP-Dateiupload"}.
{"Idle connection","Inaktive Verbindung"}.
+{"If you already have {{ app_name }} installed, we recommend that you continue the account creation process using the app by clicking on the button below:","Solltest du {{ app_name }} bereits installiert haben, empfehlen wir dir die Einrichtung des Kontos mittels dieser App durchzuführen indem du auf den Button unten klickst:"}.
+{"If you don't have an XMPP client installed yet, here's a list of suitable clients for your platform.","Solltest du noch keinen XMPP-Client installiert haben, haben wir hier eine Liste geegineter Clients für deine Platform."}.
{"If you don't see the CAPTCHA image here, visit the web page.","Wenn Sie das CAPTCHA-Bild nicht sehen, besuchen Sie die Webseite."}.
{"Import Directory","Verzeichnis importieren"}.
{"Import File","Datei importieren"}.
@@ -194,14 +234,18 @@
{"Incorrect value of 'action' attribute","Falscher Wert des 'action'-Attributs"}.
{"Incorrect value of 'action' in data form","Falscher Wert von 'action' in Datenformular"}.
{"Incorrect value of 'path' in data form","Falscher Wert von 'path' in Datenformular"}.
-{"Installed Modules:","Installierte Module:"}.
{"Install","Installieren"}.
+{"Installed Modules:","Installierte Module:"}.
+{"Installed ok? Great! Click or tap the button below to accept your invite and continue with your account setup:","Fertig mit der Installation? Prima! Drück auf den Button unten um deine Einaldung anzunehmen und mit der Einrichtung deines Kontos fortzufahren:"}.
{"Insufficient privilege","Unzureichende Privilegien"}.
{"Internal server error","Interner Serverfehler"}.
{"Invalid 'from' attribute in forwarded message","Ungültiges 'from'-Attribut in weitergeleiteter Nachricht"}.
-{"Invalid node name","Ungültiger Knotenname"}.
{"Invalid 'previd' value","Ungültiger 'previd'-Wert"}.
+{"Invalid node name","Ungültiger Knotenname"}.
{"Invitations are not allowed in this conference","Einladungen sind in dieser Konferenz nicht erlaubt"}.
+{"Invite expired","Die Einladung ist abgelaufen"}.
+{"Invite to {{ site_name }}","Einladung für {{ site_name }}"}.
+{"Invite User","Person einladen"}.
{"IP addresses","IP-Adressen"}.
{"is now known as","ist nun bekannt als"}.
{"It is not allowed to send error messages to the room. The participant (~s) has sent an error message (~s) and got kicked from the room","Es ist nicht erlaubt Fehlermeldungen an den Raum zu senden. Der Teilnehmer (~s) hat eine Fehlermeldung (~s) gesendet und wurde aus dem Raum geworfen"}.
@@ -211,6 +255,7 @@
{"January","Januar"}.
{"JID normalization denied by service policy","JID-Normalisierung aufgrund der Dienstrichtlinien verweigert"}.
{"JID normalization failed","JID-Normalisierung fehlgeschlagen"}.
+{"Join {{ site_name }} with {{ app_name }}","Konto auf {{ site_name }} mittels {{ app_name }} anlegen"}.
{"Joined MIX channels of ~ts","Beigetretene MIX-Channels von ~ts"}.
{"Joined MIX channels:","Beigetretene MIX-Channels:"}.
{"joins the room","betritt den Raum"}.
@@ -222,10 +267,12 @@
{"Last message","Letzte Nachricht"}.
{"Last month","Letzter Monat"}.
{"Last year","Letztes Jahr"}.
+{"Launch {{ app_name }} and sign in using your account credentials.","Starte {{ app_name }} und logge dich mit deinen Anmeldedaten ein."}.
+{"Launch Beagle IM, and select 'Yes' to add a new account. Click the '+' button under the empty account list and then enter your credentials.","Starte Beagle IM und wähle 'Yes' um ein neues Konto hinzuzufügen. Drücke auf das '+' unter der leeren Account-Liste und gib dann deine Anmeldedaten ein!"}.
{"Least significant bits of SHA-256 hash of text should equal hexadecimal label","Niederwertigstes Bit des SHA-256-Hashes des Textes sollte hexadezimalem Label gleichen"}.
{"leaves the room","verlässt den Raum"}.
-{"List of users with hats","Liste der Benutzer mit Funktionen"}.
{"List users with hats","Benutzer mit Funktionen auflisten"}.
+{"Log in via web","Via Web anmelden"}.
{"Logged Out","Abgemeldet"}.
{"Logging","Protokollierung"}.
{"Make participants list public","Teilnehmerliste öffentlich machen"}.
@@ -242,6 +289,7 @@
{"Max payload size in bytes","Maximale Nutzdatengröße in Bytes"}.
{"Maximum file size","Maximale Dateigröße"}.
{"Maximum Number of History Messages Returned by Room","Maximale Anzahl der vom Raum zurückgegebenen History-Nachrichten"}.
+{"Maximum number of invites reached","Maximale Anzahl all Einladungen erreicht"}.
{"Maximum number of items to persist","Maximale Anzahl persistenter Items"}.
{"Maximum Number of Occupants","Maximale Anzahl der Teilnehmer"}.
{"May","Mai"}.
@@ -260,9 +308,9 @@
{"Moderators Only","nur Moderatoren"}.
{"Module failed to handle the query","Modul konnte die Anfrage nicht verarbeiten"}.
{"Monday","Montag"}.
+{"Multi-User Chat","Mehrbenutzer-Chat (MUC)"}.
{"Multicast","Multicast"}.
{"Multiple elements are not allowed by RFC6121","Mehrere -Elemente sind laut RFC6121 nicht erlaubt"}.
-{"Multi-User Chat","Mehrbenutzer-Chat (MUC)"}.
{"Name","Vorname"}.
{"Natural Language for Room Discussions","Natürliche Sprache für Raumdiskussionen"}.
{"Natural-Language Room Name","Raumname in natürlicher Sprache"}.
@@ -270,43 +318,43 @@
{"Neither 'role' nor 'affiliation' attribute found","Weder 'role'- noch 'affiliation'-Attribut gefunden"}.
{"Never","Nie"}.
{"New Password:","Neues Passwort:"}.
+{"Nickname ~s does not exist in the room","Der Spitzname ~s existiert nicht im Raum"}.
{"Nickname can't be empty","Spitzname darf nicht leer sein"}.
{"Nickname Registration at ","Registrieren des Spitznamens auf "}.
-{"Nickname ~s does not exist in the room","Der Spitzname ~s existiert nicht im Raum"}.
{"Nickname","Spitzname"}.
+{"No 'affiliation' attribute found","Kein 'affiliation'-Attribut gefunden"}.
+{"No 'item' element found","Kein 'item'-Element gefunden"}.
+{"No 'password' found in data form","Kein 'password' im Datenformular gefunden"}.
+{"No 'password' found in this query","Kein 'password' in dieser Anfrage gefunden"}.
+{"No 'path' found in data form","Kein 'path' im Datenformular gefunden"}.
+{"No 'to' attribute found in the invitation","Kein 'to'-Attribut in der Einladung gefunden"}.
+{"No element found","Kein -Element gefunden"}.
{"No address elements found","Keine 'address'-Elemente gefunden"}.
{"No addresses element found","Kein 'addresses'-Element gefunden"}.
-{"No 'affiliation' attribute found","Kein 'affiliation'-Attribut gefunden"}.
{"No available resource found","Keine verfügbare Ressource gefunden"}.
{"No body provided for announce message","Kein Text für die Ankündigungsnachricht angegeben"}.
{"No child elements found","Keine 'child'-Elemente gefunden"}.
{"No data form found","Kein Datenformular gefunden"}.
{"No Data","Keine Daten"}.
{"No features available","Keine Eigenschaften verfügbar"}.
-{"No element found","Kein -Element gefunden"}.
{"No hook has processed this command","Kein Hook hat diesen Befehl verarbeitet"}.
{"No info about last activity found","Keine Informationen über letzte Aktivität gefunden"}.
-{"No 'item' element found","Kein 'item'-Element gefunden"}.
{"No items found in this query","Keine Items in dieser Anfrage gefunden"}.
{"No limit","Keine Begrenzung"}.
{"No module is handling this query","Kein Modul verarbeitet diese Anfrage"}.
{"No node specified","Kein Knoten angegeben"}.
-{"No 'password' found in data form","Kein 'password' im Datenformular gefunden"}.
-{"No 'password' found in this query","Kein 'password' in dieser Anfrage gefunden"}.
-{"No 'path' found in data form","Kein 'path' im Datenformular gefunden"}.
{"No pending subscriptions found","Keine ausstehenden Abonnements gefunden"}.
{"No privacy list with this name found","Keine Privacy-Liste mit diesem Namen gefunden"}.
{"No private data found in this query","Keine privaten Daten in dieser Anfrage gefunden"}.
{"No running node found","Kein laufender Knoten gefunden"}.
{"No services available","Keine Dienste verfügbar"}.
{"No statistics found for this item","Keine Statistiken für dieses Item gefunden"}.
-{"No 'to' attribute found in the invitation","Kein 'to'-Attribut in der Einladung gefunden"}.
{"Nobody","Niemand"}.
+{"Node ~p","Knoten ~p"}.
{"Node already exists","Knoten existiert bereits"}.
{"Node ID","Knoten-ID"}.
{"Node index not found","Knotenindex nicht gefunden"}.
{"Node not found","Knoten nicht gefunden"}.
-{"Node ~p","Knoten ~p"}.
{"Node","Knoten"}.
{"Nodeprep has failed","Nodeprep fehlgeschlagen"}.
{"Nodes","Knoten"}.
@@ -332,10 +380,10 @@
{"Old Password:","Altes Passwort:"}.
{"Online Users","Angemeldete Benutzer"}.
{"Online","Angemeldet"}.
-{"Only collection node owners may associate leaf nodes with the collection","Nur Sammlungsknoten-Besitzer dürfen Blattknoten mit der Sammlung verknüpfen"}.
-{"Only deliver notifications to available users","Benachrichtigungen nur an verfügbare Benutzer schicken"}.
{"Only or tags are allowed","Nur - oder -Tags sind erlaubt"}.
{"Only
element is allowed in this query","Nur
-Elemente sind in dieser Anfrage erlaubt"}.
+{"Only collection node owners may associate leaf nodes with the collection","Nur Sammlungsknoten-Besitzer dürfen Blattknoten mit der Sammlung verknüpfen"}.
+{"Only deliver notifications to available users","Benachrichtigungen nur an verfügbare Benutzer schicken"}.
{"Only members may query archives of this room","Nur Mitglieder dürfen den Verlauf dieses Raumes abrufen"}.
{"Only moderators and participants are allowed to change the subject in this room","Nur Moderatoren und Teilnehmer dürfen das Thema in diesem Raum ändern"}.
{"Only moderators are allowed to change the subject in this room","Nur Moderatoren dürfen das Thema in diesem Raum ändern"}.
@@ -347,18 +395,20 @@
{"Only service administrators are allowed to send service messages","Nur Service-Administratoren dürfen Servicenachrichten senden"}.
{"Only those on a whitelist may associate leaf nodes with the collection","Nur jemand auf einer Whitelist darf Blattknoten mit der Sammlung verknüpfen"}.
{"Only those on a whitelist may subscribe and retrieve items","Nur jemand auf einer Whitelist darf Items abonnieren und abrufen"}.
+{"Open the app","App öffnen"}.
{"Organization Name","Name der Organisation"}.
{"Organization Unit","Abteilung"}.
{"Other Modules Available:","Andere Module verfügbar:"}.
+{"Other software","Andere Software"}.
{"Outgoing s2s Connections","Ausgehende s2s-Verbindungen"}.
{"Owner privileges required","Besitzerrechte erforderlich"}.
{"Packet relay is denied by service policy","Paket-Relay aufgrund der Dienstrichtlinien verweigert"}.
{"Participant ID","Teilnehmer-ID"}.
{"Participant","Teilnehmer"}.
-{"Password Verification","Passwort bestätigen"}.
{"Password Verification:","Passwort bestätigen:"}.
-{"Password","Passwort"}.
+{"Password Verification","Passwort bestätigen"}.
{"Password:","Passwort:"}.
+{"Password","Passwort"}.
{"Path to Dir","Pfad zum Verzeichnis"}.
{"Path to File","Pfad zur Datei"}.
{"Period: ","Zeitraum: "}.
@@ -383,6 +433,7 @@
{"PubSub subscriber request","PubSub-Abonnenten-Anforderung"}.
{"Purge all items when the relevant publisher goes offline","Alle Items löschen, wenn der relevante Veröffentlicher offline geht"}.
{"Push record not found","Push-Eintrag nicht gefunden"}.
+{"QR code icon","QR-Code Icon"}.
{"Queries to the conference members are not allowed in this room","Anfragen an die Konferenzteilnehmer sind in diesem Raum nicht erlaubt"}.
{"Query to another users is forbidden","Anfrage an andere Benutzer ist verboten"}.
{"RAM and disc copy","RAM und Festplatte"}.
@@ -394,7 +445,9 @@
{"Receive notification of new nodes only","Benachrichtigung nur von neuen Knoten erhalten"}.
{"Recipient is not in the conference room","Empfänger ist nicht im Konferenzraum"}.
{"Register an XMPP account","Ein XMPP-Konto registrieren"}.
+{"Register on {{ site_name }}","Registriere dich auf {{ site_name }}"}.
{"Register","Anmelden"}.
+{"Registration error","Fehler beim Registrieren"}.
{"Remote copy","Fernkopie"}.
{"Remove a hat from a user","Eine Funktion bei einem Benutzer entfernen"}.
{"Remove User","Benutzer löschen"}.
@@ -422,17 +475,21 @@
{"Roster groups allowed to subscribe","Kontaktlistengruppen die abonnieren dürfen"}.
{"Roster size","Kontaktlistengröße"}.
{"Running Nodes","Laufende Knoten"}.
-{"~s invites you to the room ~s","~s lädt Sie in den Raum ~s ein"}.
+{"Sad person holding empty box","Eine traurige Person mit einer leeren Schachtel"}.
{"Saturday","Samstag"}.
+{"Scan invite code","Einladungscode einscannen"}.
+{"Scan with mobile device","Mit Mobilgerät einscannen"}.
{"Search from the date","Suche ab Datum"}.
{"Search Results for ","Suchergebnisse für "}.
{"Search the text","Text durchsuchen"}.
{"Search until the date","Suche bis Datum"}.
{"Search users in ","Suche Benutzer in "}.
+{"Select","Auswählen"}.
{"Send announcement to all online users on all hosts","Ankündigung an alle angemeldeten Benutzer auf allen Hosts senden"}.
{"Send announcement to all online users","Ankündigung an alle angemeldeten Benutzer senden"}.
{"Send announcement to all users on all hosts","Ankündigung an alle Benutzer auf allen Hosts senden"}.
{"Send announcement to all users","Ankündigung an alle Benutzer senden"}.
+{"Send this invite to your device","Sende diese Einladung auf dein Gerät"}.
{"September","September"}.
{"Server:","Server:"}.
{"Service list retrieval timed out","Zeitüberschreitung bei Abfrage der Serviceliste"}.
@@ -442,9 +499,13 @@
{"Shared Roster Groups","Gruppen der gemeinsamen Kontaktliste"}.
{"Show Integral Table","Integral-Tabelle anzeigen"}.
{"Show Ordinary Table","Gewöhnliche Tabelle anzeigen"}.
+{"Show","Anzeigen"}.
+{"Showing apps for your current platform only. You may also view all apps. ","Wir zeigen dir nur Apps für deine aktuelle Platform an. Du kannst dir gerne auch sämtliche Apps anzeigen lassen ."}.
{"Shut Down Service","Dienst herunterfahren"}.
{"SOCKS5 Bytestreams","SOCKS5-Bytestreams"}.
{"Some XMPP clients can store your password in the computer, but you should do this only in your personal computer for safety reasons.","Einige XMPP-Clients speichern Ihr Passwort auf dem Computer. Aus Sicherheitsgründen sollten Sie das nur auf Ihrem persönlichen Computer tun."}.
+{"Sorry, it looks like this invite code has expired!","Entschuldigung, es sieht so aus als wäre diese Einladung abgelaufen!"}.
+{"Sorry, there was a problem registering your account.","Es trat leider ein Fehler beim Erstellen des Kontos auf."}.
{"Sources Specs:","Quellenspezifikationen:"}.
{"Specify the access model","Geben Sie das Zugangsmodell an"}.
{"Specify the event message type","Geben Sie den Ereignisnachrichtentyp an"}.
@@ -452,12 +513,17 @@
{"Stanza id is not valid","Stanza-ID ist ungültig"}.
{"Stanza ID","Stanza-ID"}.
{"Statically specify a replyto of the node owner(s)","Ein 'replyto' des/der Nodebesitzer(s) statisch angeben"}.
+{"Step 1: Download and install {{ app_name }}","Schritt 1: {{ app_name }} herunterladen und installieren"}.
+{"Step 1: Install {{ app_name }}","Schritt 1: Installiere {{ app_name }}"}.
+{"Step 2: Activate your account","Konto aktivieren"}.
+{"Step 2: Connect {{ app_name }} to your new account","Schritt 2: Verbinde {{ app_name }} mit deinem neuen Konto"}.
{"Stopped Nodes","Angehaltene Knoten"}.
{"Store binary backup:","Speichere binäres Backup:"}.
{"Store plain text backup:","Speichere Klartext-Backup:"}.
{"Stream management is already enabled","Stream-Verwaltung ist bereits aktiviert"}.
{"Stream management is not enabled","Stream-Verwaltung ist nicht aktiviert"}.
{"Subject","Betreff"}.
+{"Submit","Senden"}.
{"Submitted","Gesendet"}.
{"Subscriber Address","Abonnenten-Adresse"}.
{"Subscribers may publish","Abonnenten dürfen veröffentlichen"}.
@@ -511,11 +577,14 @@
{"The sender of the last received message","Der Absender der letzten erhaltenen Nachricht"}.
{"The stanza MUST contain only one element, one element, or one
element","Das Stanza darf nur ein -Element, ein -Element oder ein
-Element enthalten"}.
{"The subscription identifier associated with the subscription request","Die mit der Abonnement-Anforderung verknüpfte Abonnement-Bezeichnung"}.
+{"The token provided is either invalid or expired.","Der Einladungscode ist entweder ungültig oder abgelaufen"}.
{"The URL of an XSL transformation which can be applied to payloads in order to generate an appropriate message body element.","Die URL einer XSL-Transformation welche auf Nutzdaten angewendet werden kann, um ein geeignetes Nachrichtenkörper-Element zu generieren."}.
{"The URL of an XSL transformation which can be applied to the payload format in order to generate a valid Data Forms result that the client could display using a generic Data Forms rendering engine","Die URL einer XSL-Transformation welche auf das Nutzdaten-Format angewendet werden kann, um ein gültiges Data Forms-Ergebnis zu generieren das der Client mit Hilfe einer generischen Data Forms-Rendering-Engine anzeigen könnte"}.
{"There was an error changing the password: ","Es trat ein Fehler beim Ändern des Passwortes auf: "}.
{"There was an error creating the account: ","Es trat ein Fehler beim Erstellen des Kontos auf: "}.
{"There was an error deleting the account: ","Es trat ein Fehler beim Löschen des Kontos auf: "}.
+{"This button works only if you have the app installed already!","Dieser Button funktioniert nur, wenn du die App bereits installiert hast!"}.
+{"This is an invite from {{ inviter }} to connect and chat on the XMPP network. If you already have an XMPP client installed just press the button below!","Die ist eine Kontakt-Einladung von {{ inviter }} um miteinander über XMPP zu chatten. Solltest du bereits einen XMPP-Client haben, so drücke einfach auf den Button hier unten!"}.
{"This is case insensitive: macbeth is the same that MacBeth and Macbeth.","Dies ist schreibungsunabhängig: macbeth ist gleich MacBeth und Macbeth."}.
{"This page allows to register an XMPP account in this XMPP server. Your JID (Jabber ID) will be of the form: username@server. Please read carefully the instructions to fill correctly the fields.","Diese Seite erlaubt das Anlegen eines XMPP-Kontos auf diesem XMPP-Server. Ihre JID (Jabber-ID) wird diese Form aufweisen: benutzername@server. Bitte lesen Sie die Anweisungen genau durch, um die Felder korrekt auszufüllen."}.
{"This page allows to unregister an XMPP account in this XMPP server.","Diese Seite erlaubt es, ein XMPP-Konto von diesem XMPP-Server zu entfernen."}.
@@ -524,21 +593,21 @@
{"Thursday","Donnerstag"}.
{"Time delay","Zeitverzögerung"}.
{"Timed out waiting for stream resumption","Zeitüberschreitung beim Warten auf Streamfortsetzung"}.
-{"To register, visit ~s","Um sich zu registrieren, besuchen Sie ~s"}.
{"To ~ts","An ~ts"}.
+{"To get started, you need to install an app for your platform:","Um loszulegen musst du eine App für deine Platform installieren:"}.
+{"To register, visit ~s","Um sich zu registrieren, besuchen Sie ~s"}.
+{"To start chatting, you need to enter your new account credentials into your chosen XMPP software.","Um mit dem Chatten zu beginnen, musst du deine neuen Anmeldedaten in der XMPP Software deiner Wahl eintragen."}.
{"Token TTL","Token-TTL"}.
+{"Too many (~p) failed authentications from this IP address (~s). The address will be unblocked at ~s UTC","Zu viele (~p) fehlgeschlagene Authentifizierungen von dieser IP-Adresse (~s). Die Adresse wird an ~s UTC entsperrt"}.
+{"Too many elements","Zu viele -Elemente"}.
+{"Too many
elements","Zu viele
-Elemente"}.
{"Too many active bytestreams","Zu viele aktive Bytestreams"}.
{"Too many CAPTCHA requests","Zu viele CAPTCHA-Anforderungen"}.
{"Too many child elements","Zu viele 'child'-Elemente"}.
-{"Too many elements","Zu viele -Elemente"}.
-{"Too many
elements","Zu viele
-Elemente"}.
-{"Too many (~p) failed authentications from this IP address (~s). The address will be unblocked at ~s UTC","Zu viele (~p) fehlgeschlagene Authentifizierungen von dieser IP-Adresse (~s). Die Adresse wird an ~s UTC entsperrt"}.
{"Too many receiver fields were specified","Zu viele Empfängerfelder wurden angegeben"}.
{"Too many unacked stanzas","Zu viele unbestätigte Stanzas"}.
{"Too many users in this conference","Zu viele Benutzer in dieser Konferenz"}.
{"Traffic rate limit is exceeded","Datenratenlimit wurde überschritten"}.
-{"~ts's MAM Archive","~ts's MAM Archiv"}.
-{"~ts's Offline Messages Queue","Offline-Nachrichten-Warteschlange von ~ts"}.
{"Tuesday","Dienstag"}.
{"Unable to generate a CAPTCHA","Konnte kein CAPTCHA erstellen"}.
{"Unable to register route on existing local domain","Konnte Route auf existierender lokaler Domäne nicht registrieren"}.
@@ -557,32 +626,36 @@
{"Updating the vCard is not supported by the vCard storage backend","Aktualisierung der vCard wird vom vCard-Speicher-Backend nicht unterstützt"}.
{"Upgrade","Upgrade"}.
{"URL for Archived Discussion Logs","URL für archivierte Diskussionsprotokolle"}.
-{"User already exists","Benutzer existiert bereits"}.
+{"Use a QR code scanner on your mobile device to scan the code below:","Benutze einen QR-Code Scanner auf deinem mobilen Endgerät um den Code hier unten zu scannen:"}.
{"User (jid)","Benutzer (JID)"}.
+{"User ~ts","Benutzer ~ts"}.
+{"User already exists","Benutzer existiert bereits"}.
{"User JID","Benutzer-JID"}.
{"User Management","Benutzerverwaltung"}.
{"User removed","Benutzer entfernt"}.
{"User session not found","Benutzersitzung nicht gefunden"}.
{"User session terminated","Benutzersitzung beendet"}.
-{"User ~ts","Benutzer ~ts"}.
{"User","Benutzer"}.
+{"Username invalid","Benutzername ungültig"}.
+{"Username is reserved","Benutzername ist reserviert"}.
{"Username:","Benutzername:"}.
+{"Username","Benutzername"}.
{"Users are not allowed to register accounts so quickly","Benutzer dürfen Konten nicht so schnell registrieren"}.
{"Users Last Activity","Letzte Benutzeraktivität"}.
{"Users","Benutzer"}.
{"Value 'get' of 'type' attribute is not allowed","Wert 'get' des 'type'-Attributs ist nicht erlaubt"}.
+{"Value 'set' of 'type' attribute is not allowed","Wert 'set' des 'type'-Attributs ist nicht erlaubt"}.
{"Value of '~s' should be boolean","Wert von '~s' sollte boolesch sein"}.
{"Value of '~s' should be datetime string","Wert von '~s' sollte DateTime-Zeichenkette sein"}.
{"Value of '~s' should be integer","Wert von '~s' sollte eine Ganzzahl sein"}.
-{"Value 'set' of 'type' attribute is not allowed","Wert 'set' des 'type'-Attributs ist nicht erlaubt"}.
{"vCard User Search","vCard-Benutzer-Suche"}.
{"View joined MIX channels","Beitretene MIX-Channel ansehen"}.
{"Virtual Hosts","Virtuelle Hosts"}.
{"Visitor","Besucher"}.
{"Visitors are not allowed to change their nicknames in this room","Besucher dürfen in diesem Raum ihren Spitznamen nicht ändern"}.
{"Visitors are not allowed to send messages to all occupants","Besucher dürfen nicht an alle Teilnehmer Nachrichten versenden"}.
-{"Voice requests are disabled in this conference","Sprachrecht-Anforderungen sind in diesem Raum deaktiviert"}.
{"Voice request","Sprachrecht-Anforderung"}.
+{"Voice requests are disabled in this conference","Sprachrecht-Anforderungen sind in diesem Raum deaktiviert"}.
{"Web client which allows to join the room anonymously","Web-Client, der es ermöglicht, dem Raum anonym beizutreten"}.
{"Wednesday","Mittwoch"}.
{"When a new subscription is processed and whenever a subscriber comes online","Sobald ein neues Abonnement verarbeitet wird und wann immer ein Abonnent sich anmeldet"}.
@@ -601,6 +674,7 @@
{"Wrong parameters in the web formulary","Falsche Parameter im Webformular"}.
{"Wrong xmlns","Falscher xmlns"}.
{"XMPP Account Registration","XMPP-Konto-Registrierung"}.
+{"XMPP client for Haiku","XMPP-Client für Haiku."}.
{"XMPP Domains","XMPP-Domänen"}.
{"XMPP Show Value of Away","XMPP-Anzeigewert von Abwesend"}.
{"XMPP Show Value of Chat","XMPP-Anzeigewert von Chat"}.
@@ -610,16 +684,26 @@
{"You are being removed from the room because of a system shutdown","Sie werden wegen einer Systemabschaltung aus dem Raum entfernt"}.
{"You are not allowed to send private messages","Sie dürfen keine privaten Nachrichten senden"}.
{"You are not joined to the channel","Sie sind dem Raum nicht beigetreten"}.
+{"You can connect to {{ site_name }} using any XMPP-compatible software. If your preferred software is not listed above, you may still register an account manually .","Du kannst dich mit {{ site_name }} über jede XMPP-kompatible Software verbinden. Sollte deine gewünschte Software hier oben nicht aufgeführt sein, so kannst du zumindest einen Account manuell anlegen ."}.
{"You can later change your password using an XMPP client.","Sie können Ihr Passwort später mit einem XMPP-Client ändern."}.
+{"You can now set up {{ app_name }} and connect it to your new account.","Jetzt kannst du {{ app_name }} einrichten und mit deinem neuen Konto verknüpfen."}.
+{"You can start chatting right away with {{ app_name }}. Let's get started!","Mittels {{ app_name }} kannst du direkt mit dem Chatten loslegen. Auf geht's!"}.
+{"You can transfer this invite to your mobile device by scanning a code with your camera.","Du kannst diese Einladung auf dein Mobilgerät übertragen indem du den Code mit Deiner Kamera einscannst."}.
{"You have been banned from this room","Sie wurden aus diesem Raum verbannt"}.
+{"You have been invited to chat on {{ site_name }}, part of the XMPP secure and decentralized messaging network.","Du wurdest auf {{ site_name }} zum Chat eingeladen, Teil des sicheren und dezentralen XMPP-Sofortnachrichten-Netzwerkes."}.
+{"You have been invited to chat on {{ site_name }} , part of the XMPP secure and decentralized messaging network.","Du wurdest auf {{ site_name }} zum Chat eingeladen. {{ site_name }} ist Teil des sicheren und dezentralen XMPP-Sofortnachrichten-Netzwerkes."}.
+{"You have been invited to chat with {{ inviter }} on {{ site_name }}, part of the XMPP secure and decentralized messaging network.","Du wurdest von {{ inviter }} auf {{ site_name }} zum Chat eingeladen. {{ site_name }} ist Teil des sicheren und dezentralen XMPP-Sofortnachrichten-Netzwerkes."}.
+{"You have been invited to chat with {{ inviter }} on {{ site_name }} , part of the XMPP secure and decentralized messaging network.","Du wurdest von {{ inviter }} auf {{ site_name }} zum Chat eingeladen. {{ site_name }} ist Teil des sicheren und dezentralen XMPP-Sofortnachrichten-Netzwerkes."}.
+{"You have created an account on {{ site_name }} .","Du hast ein Konto auf {{ site_name }} angelegt."}.
{"You have joined too many conferences","Sie sind zu vielen Konferenzen beigetreten"}.
{"You must fill in field \"Nickname\" in the form","Sie müssen das Feld \"Spitzname\" im Formular ausfüllen"}.
{"You need a client that supports x:data and CAPTCHA to register","Sie benötigen einen Client der x:data und CAPTCHA unterstützt, um sich zu registrieren"}.
{"You need a client that supports x:data to register the nickname","Sie benötigen einen Client der x:data unterstützt, um Ihren Spitznamen zu registrieren"}.
{"You need an x:data capable client to search","Sie benötigen einen Client der x:data unterstützt, um zu suchen"}.
+{"You're not allowed to create nodes","Sie dürfen keine Knoten erstellen"}.
{"Your active privacy list has denied the routing of this stanza.","Ihre aktive Privacy-Liste hat das Routing dieses Stanzas verweigert."}.
{"Your contact offline message queue is full. The message has been discarded.","Die Offline-Nachrichten-Warteschlange Ihres Kontaktes ist voll. Die Nachricht wurde verworfen."}.
+{"Your password is stored encrypted on the server and will not be accessible after you close this page. Keep it safe and never share it with anyone.","Dein Passwort wird verschlüsselt auf dem Server gespeichert und wird nicht mehr im Klartext verfügbar sein nachdem du diese Seite geschlossen hast. Verwahre es an einem sicheren Ort und teile es mit niemandem!"}.
{"Your subscription request and/or messages to ~s have been blocked. To unblock your subscription request, visit ~s","Ihre Abonnement-Anforderung und/oder Nachrichten an ~s wurden blockiert. Um Ihre Abonnement-Anforderungen freizugeben, besuchen Sie ~s"}.
{"Your XMPP account was successfully registered.","Ihr XMPP-Konto wurde erfolgreich registriert."}.
{"Your XMPP account was successfully unregistered.","Ihr XMPP-Konto wurde erfolgreich entfernt."}.
-{"You're not allowed to create nodes","Sie dürfen keine Knoten erstellen"}.
diff --git a/rebar.config b/rebar.config
index 56f1477a709..f4551316a44 100644
--- a/rebar.config
+++ b/rebar.config
@@ -34,6 +34,12 @@
{if_rebar3,
{eredis, "~> 1.7.1", {git, "https://github.com/Nordix/eredis/", {tag, "v1.7.1"}}}
}},
+ {if_not_rebar3,
+ {erlydtl, "~> 0.14.0", {git, "https://github.com/sstrigler/erlydtl.git", {tag, "0.14.0-fix.1"}}}
+ },
+ {if_rebar3,
+ {erlydtl, ".*", {git, "https://github.com/erlydtl/erlydtl.git", {branch, "master"}}}
+ },
{if_var_true, sip,
{esip, "~> 1.0.59", {git, "https://github.com/processone/esip", {tag, "1.0.59"}}}},
{if_var_true, zlib,
@@ -66,7 +72,7 @@
{stringprep, "~> 1.0.33", {git, "https://github.com/processone/stringprep", {tag, "1.0.33"}}},
{if_var_true, stun,
{stun, "~> 1.2.21", {git, "https://github.com/processone/stun", {tag, "1.2.21"}}}},
- {xmpp, ".*", {git, "https://github.com/processone/xmpp", "7285aa7802bfa90bcefafdad3a342fbb93ce7eea"}},
+ {xmpp, "~> 1.11.4", {git, "https://github.com/processone/xmpp", {tag, "1.11.4"}}},
{yconf, "~> 1.0.22", {git, "https://github.com/processone/yconf", {tag, "1.0.22"}}}
]}.
@@ -201,7 +207,7 @@
{plt_extra_apps,
[asn1, odbc, public_key, stdlib, syntax_tools,
idna, jose,
- cache_tab, eimp, fast_tls, fast_xml, fast_yaml,
+ cache_tab, eimp, erlydtl, fast_tls, fast_xml, fast_yaml,
mqtree, p1_acme, p1_oauth2, p1_utils, pkix,
stringprep, xmpp, yconf,
{if_version_below, "27", jiffy},
@@ -215,7 +221,7 @@
{if_var_true, stun, stun},
{if_var_true, sqlite, sqlite3}]},
{plt_extra_apps, % For Erlang/OTP 25 and older
- [cache_tab, eimp, fast_tls, fast_xml, fast_yaml,
+ [cache_tab, eimp, erlydtl, fast_tls, fast_xml, fast_yaml,
mqtree, p1_acme, p1_oauth2, p1_utils, pkix, stringprep, xmpp, yconf,
{if_var_true, pam, epam},
{if_var_true, redis, eredis},
diff --git a/rebar.lock b/rebar.lock
index 871ca6681b7..059f0bc3863 100644
--- a/rebar.lock
+++ b/rebar.lock
@@ -5,6 +5,10 @@
{<<"epam">>,{pkg,<<"epam">>,<<"1.0.14">>},0},
{<<"eredis">>,{pkg,<<"eredis">>,<<"1.7.1">>},0},
{<<"esip">>,{pkg,<<"esip">>,<<"1.0.59">>},0},
+ {<<"erlydtl">>,
+ {git,"https://github.com/erlydtl/erlydtl.git",
+ {ref,"aae414692b6052e96d890e03bbeeeca0f4dc01c2"}},
+ 0},
{<<"ezlib">>,{pkg,<<"ezlib">>,<<"1.0.15">>},0},
{<<"fast_tls">>,{pkg,<<"fast_tls">>,<<"1.1.25">>},0},
{<<"fast_xml">>,{pkg,<<"fast_xml">>,<<"1.1.57">>},0},
@@ -29,7 +33,7 @@
{<<"unicode_util_compat">>,{pkg,<<"unicode_util_compat">>,<<"0.7.1">>},1},
{<<"xmpp">>,
{git,"https://github.com/processone/xmpp",
- {ref,"7285aa7802bfa90bcefafdad3a342fbb93ce7eea"}},
+ {ref,"f96c9adde9841bdeb184740857bddd60d3f51ab7"}},
0},
{<<"yconf">>,{pkg,<<"yconf">>,<<"1.0.22">>},0}]}.
[
@@ -39,6 +43,7 @@
{<<"eimp">>, <<"C0B05F32E35629C4D9BCFB832FF879A92B0F92B19844BC7835E0A45635F2899A">>},
{<<"epam">>, <<"AA0B85D27F4EF3A756AE995179DF952A0721237E83C6B79D644347B75016681A">>},
{<<"eredis">>, <<"39E31AA02ADCD651C657F39AAFD4D31A9B2F63C6C700DC9CECE98D4BC3C897AB">>},
+ {<<"erlydtl">>, <<"964B2DC84F8C17ACFAA69C59BA129EF26AC45D2BA898C3C6AD9B5BDC8BA13CED">>},
{<<"esip">>, <<"EB202F8C62928193588091DFEDBC545FE3274C34ECD209961F86DCB6C9EBCE88">>},
{<<"ezlib">>, <<"D74F5DF191784744726A5B1AE9062522C606334F11086363385EB3B772D91357">>},
{<<"fast_tls">>, <<"DA8ED6F05A2452121B087158B17234749F36704C1F2B74DC51DB99A1E27ED5E8">>},
@@ -65,6 +70,7 @@
{<<"eimp">>, <<"D96D4E8572B9DFC40F271E47F0CB1D8849373BC98A21223268781765ED52044C">>},
{<<"epam">>, <<"2F3449E72885A72A6C2A843F561ADD0FC2F70D7A21F61456930A547473D4D989">>},
{<<"eredis">>, <<"7C2B54C566FED55FEEF3341CA79B0100A6348FD3F162184B7ED5118D258C3CC1">>},
+ {<<"erlydtl">>, <<"D80EC044CD8F58809C19D29AC5605BE09E955040911B644505E31E9DD8143431">>},
{<<"esip">>, <<"0BDF2E3C349DC0B144F173150329E675C6A51AC473D7A0B2E362245FAAD3FBE6">>},
{<<"ezlib">>, <<"DD14BA6C12521AF5CFE6923E73E3D545F4A0897DC66BFAB5287FBB7AE3962EAB">>},
{<<"fast_tls">>, <<"59E183B5740E670E02B8AA6BE673B5E7779E5FE5BFCC679FE2D4993D1949A821">>},
diff --git a/sql/lite.new.sql b/sql/lite.new.sql
index 42f289fb38a..55a9b58c0ea 100644
--- a/sql/lite.new.sql
+++ b/sql/lite.new.sql
@@ -489,3 +489,16 @@ CREATE TABLE mqtt_pub (
);
CREATE UNIQUE INDEX i_mqtt_topic_server ON mqtt_pub (topic, server_host);
+
+CREATE TABLE invite_token (
+ token text NOT NULL,
+ username text NOT NULL,
+ server_host text NOT NULL,
+ invitee text NOT NULL DEFAULT '',
+ created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ expires timestamp NOT NULL,
+ type character(1) NOT NULL,
+ account_name text NOT NULL,
+ PRIMARY KEY (token)
+);
+CREATE INDEX i_invite_token_username_server_host ON invite_token(username, server_host);
diff --git a/sql/lite.sql b/sql/lite.sql
index b31e02b793d..6b8efff8cd1 100644
--- a/sql/lite.sql
+++ b/sql/lite.sql
@@ -457,3 +457,15 @@ CREATE TABLE mqtt_pub (
);
CREATE UNIQUE INDEX i_mqtt_topic ON mqtt_pub (topic);
+
+CREATE TABLE invite_token (
+ token text NOT NULL,
+ username text NOT NULL,
+ invitee text NOT NULL DEFAULT '',
+ created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ expires timestamp NOT NULL,
+ type character(1) NOT NULL,
+ account_name text NOT NULL,
+ PRIMARY KEY (token)
+);
+CREATE INDEX i_invite_token_username ON invite_token(username);
diff --git a/sql/mysql.new.sql b/sql/mysql.new.sql
index cf818ad3dd8..f8cb58edf93 100644
--- a/sql/mysql.new.sql
+++ b/sql/mysql.new.sql
@@ -507,3 +507,17 @@ CREATE TABLE mqtt_pub (
expiry int unsigned NOT NULL,
UNIQUE KEY i_mqtt_topic_server (topic(191), server_host)
) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE invite_token (
+ token text NOT NULL,
+ username text NOT NULL,
+ server_host varchar(191) NOT NULL,
+ invitee text NOT NULL DEFAULT '',
+ created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ expires timestamp NOT NULL,
+ type character(1) NOT NULL,
+ account_name text NOT NULL,
+ PRIMARY KEY (token(191)),
+) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE INDEX i_invite_token_username USING BTREE ON invite_token(username(191));
diff --git a/sql/mysql.sql b/sql/mysql.sql
index 630c4a55786..e27d2086107 100644
--- a/sql/mysql.sql
+++ b/sql/mysql.sql
@@ -473,3 +473,16 @@ CREATE TABLE mqtt_pub (
expiry int unsigned NOT NULL,
UNIQUE KEY i_mqtt_topic (topic(191))
) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE invite_token (
+ token text NOT NULL,
+ username text NOT NULL,
+ invitee text NOT NULL DEFAULT (''),
+ created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ expires timestamp NOT NULL,
+ type character(1) NOT NULL,
+ account_name text NOT NULL,
+ PRIMARY KEY (token(191))
+) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE INDEX i_invite_token_username USING BTREE ON invite_token(username(191));
diff --git a/sql/pg.new.sql b/sql/pg.new.sql
index 1e59ec571eb..aa3fc0cc429 100644
--- a/sql/pg.new.sql
+++ b/sql/pg.new.sql
@@ -662,3 +662,16 @@ CREATE TABLE mqtt_pub (
);
CREATE UNIQUE INDEX i_mqtt_topic_server ON mqtt_pub (topic, server_host);
+
+CREATE TABLE invite_token (
+ token text NOT NULL,
+ username text NOT NULL,
+ server_host text NOT NULL,
+ invitee text NOT NULL DEFAULT '',
+ created_at timestamp NOT NULL DEFAULT now(),
+ expires timestamp NOT NULL,
+ "type" character(1) NOT NULL,
+ account_name text NOT NULL,
+ PRIMARY KEY (token)
+);
+CREATE INDEX i_invite_token_username_server_host ON invite_token USING btree (username, server_host);
diff --git a/sql/pg.sql b/sql/pg.sql
index dd83e087ea6..bc912b017f2 100644
--- a/sql/pg.sql
+++ b/sql/pg.sql
@@ -478,3 +478,15 @@ CREATE TABLE mqtt_pub (
);
CREATE UNIQUE INDEX i_mqtt_topic ON mqtt_pub (topic);
+
+CREATE TABLE invite_token (
+ token text NOT NULL,
+ username text NOT NULL,
+ invitee text NOT NULL DEFAULT '',
+ created_at timestamp NOT NULL DEFAULT now(),
+ expires timestamp NOT NULL,
+ "type" character(1) NOT NULL,
+ account_name text NOT NULL,
+ PRIMARY KEY (token)
+);
+CREATE INDEX i_invite_token_username ON invite_token USING btree (username);
diff --git a/src/mod_invites.erl b/src/mod_invites.erl
new file mode 100644
index 00000000000..5dda7f2ab3b
--- /dev/null
+++ b/src/mod_invites.erl
@@ -0,0 +1,899 @@
+%%%----------------------------------------------------------------------
+%%% File : mod_invites.erl
+%%% Author : Stefan Strigler
+%%% Purpose : Account and Roster Invitation (aka Great Invitations)
+%%% Created : Mon Sep 15 2025 by Stefan Strigler
+%%%
+%%%
+%%% ejabberd, Copyright (C) 2025 ProcessOne
+%%%
+%%% This program is free software; you can redistribute it and/or
+%%% modify it under the terms of the GNU General Public License as
+%%% published by the Free Software Foundation; either version 2 of the
+%%% License, or (at your option) any later version.
+%%%
+%%% This program is distributed in the hope that it will be useful,
+%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
+%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+%%% General Public License for more details.
+%%%
+%%% You should have received a copy of the GNU General Public License along
+%%% with this program; if not, write to the Free Software Foundation, Inc.,
+%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+%%%
+%%%----------------------------------------------------------------------
+-module(mod_invites).
+
+-author('stefan@strigler.de').
+
+-xep({xep, 379, '0.3.3'}).
+-xep({xep, 401, '0.5.0'}).
+-xep({xep, 445, '0.2.0'}).
+
+-behaviour(gen_mod).
+
+%% gen_mod
+-export([depends/2, mod_doc/0, mod_options/1, mod_opt_type/1, reload/3, start/2, stop/1]).
+
+%% hooks and callbacks
+-export([adhoc_commands/4, adhoc_items/4, c2s_unauthenticated_packet/2, remove_user/2,
+ s2s_receive_packet/1, sm_receive_packet/1, stream_feature_register/2]).
+
+%% commands
+-export([cleanup_expired/0, expire_tokens/2, gen_invite/1, gen_invite/2, list_invites/1]).
+
+%% helpers
+-export([create_account_allowed/2, get_invite/2, get_invites/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, xdata_field/3]).
+
+%% ejabberd_http
+-export([process/2]).
+
+-ifdef(TEST).
+-export([create_roster_invite/2, create_account_invite/4, is_token_valid/3]).
+-endif.
+
+-include("logger.hrl").
+
+-include_lib("xmpp/include/xmpp.hrl").
+
+-include("ejabberd_commands.hrl").
+-include("mod_invites.hrl").
+-include("translate.hrl").
+
+-type invite_token() :: #invite_token{}.
+
+-callback cleanup_expired(Host :: binary()) -> non_neg_integer().
+-callback create_invite(Invite :: invite_token()) -> invite_token().
+-callback expire_tokens(User :: binary(), Server :: binary()) -> non_neg_integer().
+-callback get_invite(Host :: binary(), Token :: binary()) ->
+ invite_token() | {error, not_found}.
+-callback get_invites(Host :: binary(), Inviter :: {User :: binary(), Host :: binary()}) ->
+ [invite_token()].
+-callback init(Host :: binary(), gen_mod:opts()) -> any().
+-callback is_reserved(Host :: binary(), Token :: binary(), User :: binary()) -> boolean().
+-callback is_token_valid(Host :: binary(), binary(), {binary(), binary()}) -> boolean().
+-callback list_invites(Host :: binary()) -> [tuple()].
+-callback remove_user(User :: binary(), Server :: binary()) -> any().
+-callback set_invitee(Fun :: fun(() -> OkOrError),
+ Host :: binary(),
+ Token :: binary(),
+ Invitee :: binary(),
+ AccountName :: binary()) -> OkOrError | {error, conflict}
+ when OkOrError :: ok | {error, term()}.
+
+%% @format-begin
+
+%%--------------------------------------------------------------------
+%%| gen_mod callbacks
+
+depends(_Host, _Opts) ->
+ [{mod_adhoc, soft}, {mod_register, soft}, {mod_roster, soft}].
+
+mod_doc() ->
+ #{desc =>
+ ?T("Allow User Invitation and Account Creation to create out-of-band "
+ "links to onboard others onto the XMPP network and establish "
+ "a mutual subscription."),
+ opts =>
+ [{access_create_account,
+ #{value => ?T("Access Rule Name"),
+ desc =>
+ ?T("This is the name of an access rule that specifies who is allowed to create "
+ "'create account' invites. The default value is 'none', i.e. nobody is able to"
+ " create such invites. Furthermore it applies to 'roster invites' and allows "
+ "to do in-band registration (ibr) if the sending user is allowed by this rule. "
+ "Users from the 'admin' ACL are always allowed to create account invites."),
+ example => ["mod_invites:", " access_create_account: local"]}},
+ {db_type,
+ #{value => "mnesia | sql",
+ desc =>
+ ?T("Same as top-level _`default_db`_ option, but applied to this "
+ "module only.")}},
+ {landing_page,
+ #{value => "none | auto | LandingPageURLTemplate",
+ desc =>
+ ?T("This is URI template for the landing page that is being communicated when creating an invite using one of the ad-hoc commands. In other words, the web address of the service handling invite links. This is either a local address handled by 'mod_invites' configured as a handler for 'ejabberd_http' or an external service like 'easy-xmpp-invitation'. The address must be given as a template pattern with fields from the 'invite' that will then get replaced accordingly. Eg.: 'https://{{ host }}:5281/invites/{{invite.token }}' or as an external service like 'http://{{ host }}:8080/easy-xmpp-invites/#{{ invite.uri|strip_protocol }}'. For convenience you can choose 'auto' here and the ejabberd_http handler will be used. Default is 'none'."),
+ example =>
+ [{?T("This will let 'mod_invites' handle the landing page. For this to work you need to expose port 5281 to the public. Furthermore, for the included landing page templates to work correctly, you have to make two javascript libraries (jquery and bootstrap4) available under '/share'. If you're on debian you can just install the required libraries as packages like 'apt install libjs-jquery libjs-bootstrap4'. Either way they need to be found at '/usr/share/javascript/jquery/jquery.min.js' and '/usr/share/javascript/bootstrap4/boostrap.min.js' if you stick to the path as configured like this:"),
+ ["listen: ",
+ " -",
+ " port: 5281",
+ " module: ejabberd_http",
+ " request_handlers:",
+ " /invites: mod_invites",
+ " /share: mod_http_fileserver",
+ "# [...]",
+ "modules:",
+ " mod_http_fileserver:",
+ " docroot: /usr/share/javascript",
+ " mod_invites:",
+ " landing_page: auto"]},
+ {?T("If you'd rather not expose that port and use a reverse proxy (like nginx), you need to configure the landing page parameter manually:"),
+ ["modules:",
+ " mod_invites:",
+ " landing_page: https://{{ host }}/invites/{{ invite.token }}"]},
+ {?T("To use an external service, you set landing_page accordingly:"),
+ ["modules:",
+ " mod_invites:",
+ " landing_page: https://invites.example.com/easy-xmpp-invites/{{ invite.uri|strip_protocol }}"]}]}},
+ {max_invites,
+ #{value => "pos_integer() | infinity",
+ desc =>
+ ?T("Maximum number of 'create account' invites that can be created "
+ "by an individual user. Users that match the 'admin' acl are "
+ "exempt from this limitation. Furthermore it restricts the use of "
+ "'roster invites' for account creation. Default is 'infinity'")}},
+ {site_name,
+ #{value => ?T("Site Name"),
+ desc =>
+ ?T("A human readable name for your site. E.g. 'My Beautiful Laundrette'. Used in landing page templates.")}},
+ {templates_dir,
+ #{value => ?T("binary()"),
+ desc =>
+ ?T("The directory containing templates and static files used for landing page and web registration form. Only needs to be set if you want to ship your own set of templates or list of recommended apps.")}},
+ {token_expire_seconds,
+ #{value => "pos_integer()",
+ desc => ?T("Number of seconds until token expires (e.g.: '5 * 86400' [default])")}}],
+ example =>
+ [{?T("To allow only admin users to create 'create account' invites and disable regular in-band registration, you would have a config like this:"),
+ ["acl:",
+ " admin:",
+ " - user: \"my_admin_user@example.com\"",
+ "",
+ "modules:",
+ " mod_invites:",
+ " landing_page: auto",
+ " mod_register:",
+ " allow_modules:",
+ " - mod_invites"]},
+ {?T("If you want all your users to be able to send 'create account' "
+ "invites, you would configure your server like this instead:"),
+ ["acl:",
+ " local:",
+ " user_regexp: \"\"",
+ "access_rules:",
+ " create_account_invite:",
+ " allow: local",
+ "",
+ "modules:",
+ " mod_invites:",
+ " access_create_account: create_account_invite",
+ " landing_page: auto",
+ " mod_register:",
+ " allow_modules:",
+ " - mod_invites"]},
+ {?T("Note that the names of the access rules are just examples and "
+ "you're free to change them."),
+ []}]}.
+
+mod_options(Host) ->
+ [{access_create_account, none},
+ {db_type, ejabberd_config:default_db(Host, ?MODULE)},
+ {landing_page, none},
+ {max_invites, infinity},
+ {site_name, Host},
+ {templates_dir, filename:join([code:priv_dir(ejabberd), ?MODULE, <<>>])},
+ {token_expire_seconds, ?INVITE_TOKEN_EXPIRE_SECONDS_DEFAULT}].
+
+reload(ServerHost, NewOpts, OldOpts) ->
+ NewMod = gen_mod:db_mod(NewOpts, ?MODULE),
+ OldMod = gen_mod:db_mod(OldOpts, ?MODULE),
+ if NewMod /= OldMod ->
+ NewMod:init(ServerHost, NewOpts);
+ true ->
+ ok
+ end.
+
+start(Host, Opts) ->
+ Mod = gen_mod:db_mod(Opts, ?MODULE),
+ Mod:init(Host, Opts),
+ {ok,
+ [{hook, remove_user, remove_user, 50},
+ {hook, adhoc_local_items, adhoc_items, 50},
+ {hook, adhoc_local_commands, adhoc_commands, 50},
+ {hook, s2s_receive_packet, s2s_receive_packet, 50},
+ {hook, sm_receive_packet, sm_receive_packet, 50},
+ {hook, c2s_pre_auth_features, stream_feature_register, 50},
+ %% note the sequence below is important
+ {hook, c2s_unauthenticated_packet, c2s_unauthenticated_packet, 10},
+ {commands, get_commands_spec()}]}.
+
+stop(_Host) ->
+ ok.
+
+mod_opt_type(access_create_account) ->
+ econf:acl();
+mod_opt_type(db_type) ->
+ econf:db_type(?MODULE);
+mod_opt_type(landing_page) ->
+ econf:either(none, econf:binary());
+mod_opt_type(max_invites) ->
+ econf:pos_int(infinity);
+mod_opt_type(site_name) ->
+ econf:binary();
+mod_opt_type(templates_dir) ->
+ econf:directory();
+mod_opt_type(token_expire_seconds) ->
+ econf:pos_int().
+
+%%--------------------------------------------------------------------
+%%| ejabberd command callbacks
+
+-spec get_commands_spec() -> [ejabberd_commands()].
+get_commands_spec() ->
+ [#ejabberd_commands{name = cleanup_expired_invite_tokens,
+ tags = [purge],
+ desc = "Delete invite tokens that have expired",
+ module = ?MODULE,
+ function = cleanup_expired,
+ args = [],
+ result_example = 42,
+ result = {num_deleted, integer}},
+ #ejabberd_commands{name = expire_invite_tokens,
+ tags = [purge],
+ desc =
+ "Sets expiration to a date in the past for all tokens belonging "
+ "to user",
+ module = ?MODULE,
+ function = expire_tokens,
+ args = [{username, binary}, {host, binary}],
+ result_example = 42,
+ result = {num_deleted, integer}},
+ #ejabberd_commands{name = generate_invite,
+ tags = [accounts],
+ desc = "Create a new 'create account' invite",
+ module = ?MODULE,
+ function = gen_invite,
+ args = [{host, binary}],
+ args_desc = ["Hostname to generate 'create account' invite for."],
+ args_example = [<<"example.com">>],
+ result_example = <<"xmpp:example.com?register;preauth=CJAi3TvpzuBJpmuf">>,
+ result = {invite, {tuple, [{invite_uri, string}, {landing_page, string}]}}},
+ #ejabberd_commands{name = generate_invite_with_username,
+ tags = [accounts],
+ desc =
+ "Create a new 'create account' invite token with a preselected "
+ "username",
+ module = ?MODULE,
+ function = gen_invite,
+ args = [{username, binary}, {host, binary}],
+ args_desc =
+ ["Preselected Username",
+ "hostname to generate 'create account' invite for."],
+ args_example = [<<"juliet">>, <<"example.com">>],
+ result_example =
+ <<"xmpp:juliet@example.com?register;preauth=CJAi3TvpzuBJpmuf">>,
+ result = {invite, {tuple, [{invite_uri, string}, {landing_page, string}]}}},
+ #ejabberd_commands{name = list_invites,
+ tags = [accounts],
+ desc = "List invite tokens",
+ module = ?MODULE,
+ function = list_invites,
+ args = [{host, binary}],
+ args_desc = ["Hostname tokens are valid for"],
+ args_example = [<<"example.com">>],
+ %result_example = [{invite_token, invite}],
+ result =
+ {invites,
+ {list,
+ {invite,
+ {tuple,
+ [{token, string},
+ {valid, atom},
+ {created_at, string},
+ {expires, string},
+ {type, atom},
+ {inviter, string},
+ {invitee, string},
+ {account_name, string},
+ {token_uri, string},
+ {landing_page, string}]}}}}}].
+
+cleanup_expired() ->
+ lists:foldl(fun(Host, Count) ->
+ case gen_mod:is_loaded(Host, ?MODULE) of
+ true ->
+ Count + db_call(Host, cleanup_expired, [Host]);
+ false ->
+ Count
+ end
+ end,
+ 0,
+ ejabberd_option:hosts()).
+
+-spec expire_tokens(binary(), binary()) -> non_neg_integer().
+expire_tokens(User0, Server0) ->
+ User = jid:nodeprep(User0),
+ Server = jid:nameprep(Server0),
+ db_call(Server, expire_tokens, [User, Server]).
+
+-spec gen_invite(binary()) -> binary() | {error, any()}.
+gen_invite(Host) ->
+ gen_invite(<<>>, Host).
+
+-spec gen_invite(binary(), binary()) -> binary() | {error, any()}.
+gen_invite(AccountName, Host0) ->
+ Host = jid:nameprep(Host0),
+ case create_account_invite(Host, {<<>>, Host}, AccountName, false) of
+ {error, {module_not_loaded, ?MODULE, Host}} ->
+ {error, host_unknown};
+ {error, _Reason} = Error ->
+ Error;
+ Invite ->
+ {token_uri(Invite), landing_page(Host, Invite)}
+ end.
+
+list_invites(Host) ->
+ Invites = db_call(Host, list_invites, [Host]),
+ Format =
+ fun(#invite_token{token = TO,
+ inviter = {IU, IS},
+ invitee = IE,
+ created_at = CA,
+ expires = Exp,
+ type = TY,
+ account_name = AN} =
+ Invite) ->
+ {TO,
+ is_token_valid(Host, TO),
+ encode_datetime(CA),
+ encode_datetime(Exp),
+ TY,
+ jid:encode(
+ jid:make(IU, IS)),
+ IE,
+ AN,
+ token_uri(Invite),
+ landing_page(Host, Invite)}
+ end,
+ [Format(Invite) || Invite <- Invites].
+
+%%--------------------------------------------------------------------
+%%| hooks and callbacks
+
+remove_user(User, Server) ->
+ LUser = jid:nodeprep(User),
+ LServer = jid:nameprep(Server),
+ db_call(Server, remove_user, [LUser, LServer]).
+
+%% ---
+
+-spec adhoc_items(empty | {error, stanza_error()} | {result, [disco_item()]},
+ jid(),
+ jid(),
+ binary()) ->
+ {error, stanza_error()} | {result, [disco_item()]} | empty.
+adhoc_items(Acc,
+ #jid{lserver = LServer} = From,
+ #jid{lserver = LServer, server = Server} = _To,
+ Lang) ->
+ InviteUser =
+ #disco_item{jid = jid:make(Server),
+ node = ?NS_INVITE_INVITE,
+ name = translate:translate(Lang, ?T("Invite User"))},
+ CreateAccount =
+ #disco_item{jid = jid:make(Server),
+ node = ?NS_INVITE_CREATE_ACCOUNT,
+ name = translate:translate(Lang, ?T("Create Account"))},
+ MyItems =
+ case create_account_allowed(LServer, From) of
+ ok ->
+ [InviteUser, CreateAccount];
+ {error, not_allowed} ->
+ [InviteUser]
+ end,
+ case Acc of
+ {result, AccItems} ->
+ {result, AccItems ++ MyItems};
+ _ ->
+ {result, MyItems}
+ end;
+adhoc_items(Acc, _From, _To, _Lang) ->
+ Acc.
+
+%% ---
+
+-spec adhoc_commands(empty | adhoc_command(), jid(), jid(), adhoc_command()) ->
+ adhoc_command() | {error, stanza_error()}.
+adhoc_commands(_Acc,
+ #jid{luser = LUser, lserver = LServer},
+ #jid{lserver = LServer},
+ #adhoc_command{node = ?NS_INVITE_INVITE = Node,
+ action = execute,
+ sid = SID,
+ lang = Lang}) ->
+ Invite = create_roster_invite(LServer, {LUser, LServer}),
+ XData =
+ #xdata{type = result,
+ title = trans(Lang, <<"New Invite Token Created">>),
+ fields =
+ maybe_add_landing_url(LServer,
+ Invite,
+ Lang,
+ [#xdata_field{var = <<"uri">>,
+ label = trans(Lang, <<"Invite URI">>),
+ type = 'text-single',
+ values = [token_uri(Invite)]},
+ #xdata_field{var = <<"expire">>,
+ label =
+ trans(Lang,
+ <<"Invite token valid until">>),
+ type = 'text-single',
+ values =
+ [encode_datetime(Invite#invite_token.expires)]}])},
+ Result =
+ #adhoc_command{status = completed,
+ node = Node,
+ xdata = XData,
+ sid = SID},
+ {stop, Result};
+adhoc_commands(_Acc,
+ #jid{luser = LUser, lserver = LServer} = From,
+ #jid{lserver = LServer},
+ #adhoc_command{node = ?NS_INVITE_CREATE_ACCOUNT = Node,
+ sid = SID,
+ lang = Lang,
+ xdata = #xdata{type = submit, fields = Fields}}) ->
+ check(fun create_account_allowed/2,
+ [LServer, From],
+ fun() ->
+ AccountName = xdata_field(<<"username">>, Fields, <<>>),
+ Invite =
+ create_account_invite(LServer,
+ {LUser, LServer},
+ AccountName,
+ to_boolean(xdata_field(<<"roster-subscription">>,
+ Fields,
+ false))),
+ case Invite of
+ {error, Reason} ->
+ {stop, {error, to_stanza_error(Lang, Reason)}};
+ _Invite ->
+ ResultFields =
+ maybe_add_landing_url(LServer,
+ Invite,
+ Lang,
+ [#xdata_field{var = <<"uri">>,
+ label = trans(Lang, <<"Invite URI">>),
+ type = 'text-single',
+ values = [token_uri(Invite)]},
+ #xdata_field{var = <<"expire">>,
+ label =
+ trans(Lang,
+ <<"Invite token valid until">>),
+ type = 'text-single',
+ values =
+ [encode_datetime(Invite#invite_token.expires)]}]),
+ ResultXData = #xdata{type = result, fields = ResultFields},
+ Result =
+ #adhoc_command{status = completed,
+ sid = SID,
+ node = Node,
+ xdata = ResultXData},
+ {stop, Result}
+ end
+ end,
+ fun(Reason) -> {stop, {error, to_stanza_error(Lang, Reason)}} end);
+adhoc_commands(_Acc,
+ #jid{lserver = LServer} = From,
+ #jid{lserver = LServer},
+ #adhoc_command{node = ?NS_INVITE_CREATE_ACCOUNT = Node,
+ action = execute,
+ sid = SID,
+ lang = Lang}) ->
+ check(fun create_account_allowed/2,
+ [LServer, From],
+ fun() ->
+ XData =
+ #xdata{type = form,
+ title = trans(Lang, <<"Account Creation Invite">>),
+ fields =
+ [#xdata_field{var = <<"username">>,
+ label = trans(Lang, <<"Username">>),
+ type = 'text-single'},
+ #xdata_field{var = <<"roster-subscription">>,
+ label = trans(Lang, <<"Roster Subscription">>),
+ type = boolean}]},
+ Actions = #adhoc_actions{execute = complete, complete = true},
+ Result =
+ #adhoc_command{status = executing,
+ node = Node,
+ sid = maybe_gen_sid(SID),
+ actions = Actions,
+ xdata = XData},
+ {stop, Result}
+ end,
+ fun(Reason) -> {stop, {error, to_stanza_error(Lang, Reason)}} end);
+adhoc_commands(Acc, _From, _To, _Command) ->
+ Acc.
+
+-spec s2s_receive_packet({stanza() | drop, State}) ->
+ {stanza() | drop, State} | {stop, {drop, State}}
+ when State :: ejabberd_s2s_in:state().
+s2s_receive_packet({Stanza, State}) ->
+ case sm_receive_packet(Stanza) of
+ {stop, drop} ->
+ {stop, {drop, State}};
+ Res ->
+ {Res, State}
+ end.
+
+-spec sm_receive_packet(stanza() | drop) -> stanza() | drop | {stop, drop}.
+sm_receive_packet(#presence{from = From,
+ to = To,
+ type = subscribe,
+ sub_els = Els} =
+ Presence) ->
+ case handle_pre_auth_token(Els, To, From) of
+ true ->
+ {stop, drop};
+ false ->
+ Presence
+ end;
+sm_receive_packet(Other) ->
+ Other.
+
+handle_pre_auth_token([], _To, _From) ->
+ false;
+handle_pre_auth_token([El | Els],
+ #jid{luser = LUser, lserver = LServer} = To,
+ FromFullJid) ->
+ From = jid:remove_resource(FromFullJid),
+ try xmpp:decode(El) of
+ #preauth{token = Token} = PreAuth ->
+ ?DEBUG("got preauth token: ~p", [PreAuth]),
+ case is_token_valid(LServer, Token, {LUser, LServer}) of
+ true ->
+ roster_add(To, From),
+ send_presence(To, From, subscribed),
+ send_presence(To, From, subscribe),
+ set_invitee(LServer, Token, From),
+ true;
+ false ->
+ ?INFO_MSG("Got invalid preauth token from ~s: ~p", [jid:encode(From), PreAuth]),
+ false
+ end;
+ _Other ->
+ handle_pre_auth_token(Els, To, From)
+ catch
+ _:{xmpp_codec, _} ->
+ handle_pre_auth_token(Els, To, From)
+ end.
+
+%%--------------------------------------------------------------------
+%%| ibr hooks
+stream_feature_register(Acc, Host) ->
+ case gen_mod:is_loaded(Host, ?MODULE) of
+ true ->
+ mod_invites_register:stream_feature_register(Acc, Host);
+ false ->
+ Acc
+ end.
+
+c2s_unauthenticated_packet(State, IQ) ->
+ mod_invites_register:c2s_unauthenticated_packet(State, IQ).
+
+%%--------------------------------------------------------------------
+%%| ejabberd_http
+process(LocalPath, Request) ->
+ mod_invites_http:process(LocalPath, Request).
+
+%%--------------------------------------------------------------------
+%%| helpers
+get_invite(Host, Token) ->
+ db_call(Host, get_invite, [Host, Token]).
+
+get_invites(Host, Inviter) ->
+ db_call(Host, get_invites, [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) ->
+ try invite_token(Type, Host, Inviter, AccountName) of
+ Invite ->
+ ?DEBUG("Created invite: ~p", [Invite]),
+ db_call(Host, create_invite, [Invite])
+ catch
+ _:({error, _Reason} = Error) ->
+ Error;
+ _:Error ->
+ {error, Error}
+ end.
+
+check_account_name(<<>>, _) ->
+ <<>>;
+check_account_name(error, _) ->
+ {error, account_name_invalid};
+check_account_name(_, error) ->
+ {error, hostname_invalid};
+check_account_name(AccountName, Host) ->
+ MyHosts = ejabberd_option:hosts(),
+ case lists:member(Host, MyHosts) of
+ false ->
+ {error, host_unknown};
+ true ->
+ case ejabberd_auth:user_exists(AccountName, Host) of
+ true ->
+ {error, user_exists};
+ false ->
+ case is_reserved(Host, <<>>, AccountName) of
+ true ->
+ {error, reserved};
+ false ->
+ AccountName
+ end
+ end
+ end.
+
+check_max_invites(roster_only, _) ->
+ ok;
+check_max_invites(_Type, {User, Host}) ->
+ case is_create_allowed(User, Host) of
+ true ->
+ ok;
+ false ->
+ {error, num_invites_exceeded}
+ end.
+
+is_create_allowed(User, Host) ->
+ case get_max_invites(User, Host) of
+ infinity ->
+ true;
+ MaxInvites ->
+ Invites = get_invites(Host, {User, Host}),
+ NumCreated =
+ lists:foldl(fun (#invite_token{type = roster_only, account_name = <<>>}, Num) ->
+ Num;
+ (#invite_token{type = roster_only}, Num) ->
+ %% We make sure to set account_name to the registered name when
+ %% creating the account. This field is not used in roster_only
+ %% scenario otherwise.
+ Num + 1;
+ (#invite_token{invitee = <<>>} = Invite, Num) ->
+ %% account create tokens count unless they haven't been used and
+ %% are expired
+ case mod_invites:is_expired(Invite) of
+ true ->
+ Num;
+ false ->
+ Num + 1
+ end;
+ (_, Num) ->
+ %% account create token where invitee is not empty
+ Num + 1
+ end,
+ 0,
+ Invites),
+ NumCreated < MaxInvites
+ end.
+
+get_max_invites(<<>>, _Server) ->
+ infinity;
+get_max_invites(User, Server) ->
+ case {mod_invites_opt:max_invites(Server),
+ acl:match_acl(Server, {acl, admin}, #{usr => {User, Server, <<>>}})}
+ of
+ {infinity, _} ->
+ infinity;
+ {_, true} ->
+ infinity;
+ {MaxInvites, false} ->
+ MaxInvites
+ end.
+
+maybe_throw({error, _} = Error) ->
+ throw(Error);
+maybe_throw(Good) ->
+ Good.
+
+invite_token(Type, Host, Inviter, AccountName0) ->
+ maybe_throw(check_max_invites(Type, Inviter)),
+ Token = p1_rand:get_alphanum_string(?INVITE_TOKEN_LENGTH_DEFAULT),
+ AccountName = maybe_throw(check_account_name(jid:nodeprep(AccountName0), Host)),
+ set_token_expires(#invite_token{token = Token,
+ inviter = Inviter,
+ type = Type,
+ account_name = AccountName},
+ mod_invites_opt:token_expire_seconds(Host)).
+
+token_uri(#invite_token{type = Type,
+ token = Token,
+ account_name = AccountName,
+ inviter = {_User, Host}})
+ when Type =:= account_only; Type =:= account_subscription ->
+ Invitee =
+ case AccountName of
+ <<>> ->
+ Host;
+ _ ->
+ <>
+ end,
+ <<"xmpp:", Invitee/binary, "?register;preauth=", Token/binary>>;
+token_uri(#invite_token{type = roster_only,
+ token = Token,
+ inviter = {User, Host}}) ->
+ IBR = maybe_add_ibr_allowed(User, Host),
+ Inviter =
+ jid:encode(
+ jid:make(User, Host)),
+ <<"xmpp:", Inviter/binary, "?roster;preauth=", Token/binary, IBR/binary>>.
+
+maybe_add_ibr_allowed(User, Host) ->
+ case create_account_allowed(Host, jid:make(User, Host)) of
+ ok ->
+ <<";ibr=y">>;
+ {error, not_allowed} ->
+ <<>>
+ end.
+
+landing_page(Host, Invite) ->
+ mod_invites_http:landing_page(Host, Invite).
+
+-spec db_call(binary(), atom(), list()) -> any().
+db_call(Host, Fun, Args) ->
+ Mod = gen_mod:db_mod(Host, ?MODULE),
+ apply(Mod, Fun, Args).
+
+trans(Lang, Msg) ->
+ translate:translate(Lang, Msg).
+
+-spec encode_datetime(calendar:datetime()) -> binary().
+encode_datetime({{Year, Month, Day}, {Hour, Minute, Second}}) ->
+ list_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
+ [Year, Month, Day, Hour, Minute, Second])).
+
+set_token_expires(#invite_token{created_at = CreatedAt} = Invite, ExpireSecs) ->
+ Invite#invite_token{expires =
+ calendar:gregorian_seconds_to_datetime(calendar:datetime_to_gregorian_seconds(CreatedAt)
+ + ExpireSecs)}.
+
+xdata_field(_Field, [], Default) ->
+ Default;
+xdata_field(Field, [#xdata_field{var = Field, values = [<<>> | _]} | _], Default) ->
+ Default;
+xdata_field(Field, [#xdata_field{var = Field, values = [Result | _]} | _], _Default) ->
+ Result;
+xdata_field(Field, [_NoMatch | Fields], Default) ->
+ xdata_field(Field, Fields, Default).
+
+maybe_add_landing_url(Host, Invite, Lang, XData) ->
+ case landing_page(Host, Invite) of
+ <<>> ->
+ XData;
+ LandingPage ->
+ [#xdata_field{var = <<"landing-url">>,
+ values = [LandingPage],
+ label = trans(Lang, <<"Invite Landing Page URL">>),
+ type = 'text-single'}
+ | XData]
+ end.
+
+check(Check, Args, Fun, Else) ->
+ case erlang:apply(Check, Args) of
+ ok ->
+ Fun();
+ {error, Reason} ->
+ Else(Reason)
+ end.
+
+create_account_allowed(Host, User) ->
+ case mod_invites_opt:access_create_account(Host) of
+ none ->
+ {error, not_allowed};
+ Access ->
+ case acl:match_rule(Host, Access, User) of
+ deny ->
+ {error, not_allowed};
+ allow ->
+ ok
+ end
+ end.
+
+to_boolean(Boolean) when is_boolean(Boolean) ->
+ Boolean;
+to_boolean(True) when True == <<"1">>; True == <<"true">> ->
+ true;
+to_boolean(False) when False == <<"0">>; False == <<"false">> ->
+ false.
+
+to_stanza_error(Lang, not_allowed) ->
+ Text = trans(Lang, <<"Access forbidden">>),
+ xmpp:err_forbidden(Text, Lang);
+to_stanza_error(Lang, Reason) ->
+ Text = trans(Lang, reason_to_text(Reason)),
+ xmpp:err_bad_request(Text, Lang).
+
+reason_to_text(account_name_invalid) ->
+ ?T("Username invalid");
+reason_to_text(host_unknown) ->
+ ?T("Host unknown");
+reason_to_text(hostname_invalid) ->
+ ?T("Hostname invalid");
+reason_to_text(num_invites_exceeded) ->
+ ?T("Maximum number of invites reached");
+reason_to_text(reserved) ->
+ ?T("Username is reserved");
+reason_to_text(user_exists) ->
+ ?T("User already exists").
+
+maybe_gen_sid(<<>>) ->
+ p1_rand:get_alphanum_string(?INVITE_TOKEN_LENGTH_DEFAULT);
+maybe_gen_sid(SID) ->
+ SID.
+
+roster_add(UserJID, RosterItemJID) ->
+ RosterItem =
+ #roster_item{jid = RosterItemJID,
+ subscription = from,
+ ask = subscribe},
+ mod_roster:set_item_and_notify_clients(UserJID, RosterItem, true).
+
+send_presence(From, To, Type) ->
+ Presence =
+ #presence{from = From,
+ to = To,
+ type = Type},
+ ejabberd_router:route(Presence).
diff --git a/src/mod_invites_http.erl b/src/mod_invites_http.erl
new file mode 100644
index 00000000000..8118ca9dec4
--- /dev/null
+++ b/src/mod_invites_http.erl
@@ -0,0 +1,380 @@
+%%%----------------------------------------------------------------------
+%%% File : mod_invites_http.erl
+%%% Author : Stefan Strigler
+%%% Purpose : Provide web page(s) to sign up using an invite token.
+%%% Created : Fri Oct 31 2025 by Stefan Strigler
+%%%
+%%%
+%%% ejabberd, Copyright (C) 2025 ProcessOne
+%%%
+%%% This program is free software; you can redistribute it and/or
+%%% modify it under the terms of the GNU General Public License as
+%%% published by the Free Software Foundation; either version 2 of the
+%%% License, or (at your option) any later version.
+%%%
+%%% This program is distributed in the hope that it will be useful,
+%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
+%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+%%% General Public License for more details.
+%%%
+%%% You should have received a copy of the GNU General Public License along
+%%% with this program; if not, write to the Free Software Foundation, Inc.,
+%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+%%%
+%%%----------------------------------------------------------------------
+-module(mod_invites_http).
+
+-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)))
+ catch
+ _:not_found ->
+ ?NOT_FOUND
+ end;
+process([], _Request) ->
+ ?NOT_FOUND.
+
+process_valid_token([_Token, AppID, ?REGISTRATION],
+ #request{method = 'POST'} = Request,
+ Invite) ->
+ process_register_post(Invite, AppID, Request);
+process_valid_token([_Token, AppID, ?REGISTRATION], Request, Invite) ->
+ process_register_form(Invite, AppID, Request);
+process_valid_token([_Token, ?REGISTRATION],
+ #request{method = 'POST'} = Request,
+ Invite) ->
+ process_register_post(Invite, <<>>, Request);
+process_valid_token([_Token, ?REGISTRATION], Request, Invite) ->
+ process_register_form(Invite, <<>>, Request);
+process_valid_token([_Token, AppID],
+ #request{host = Host, lang = Lang} = Request,
+ Invite) ->
+ try app_ctx(Host, AppID, Lang, ctx(Invite, Request)) of
+ AppCtx ->
+ render_ok(Host, Lang, <<"client.html">>, AppCtx)
+ catch
+ _:not_found ->
+ ?NOT_FOUND
+ end;
+process_valid_token([_Token], #request{host = Host, lang = Lang} = Request, Invite) ->
+ Ctx0 = ctx(Invite, Request),
+ Apps =
+ lists:map(fun(App0) ->
+ App = app_id(App0),
+ render_app_urls(App, [{app, App} | Ctx0])
+ end,
+ apps_json(Host, Lang, Ctx0)),
+ Ctx = [{apps, Apps} | Ctx0],
+ render_ok(Host, Lang, <<"invite.html">>, Ctx);
+process_valid_token(_, _, _) ->
+ ?NOT_FOUND.
+
+process_register_form(Invite, AppID, #request{host = Host, lang = Lang} = Request) ->
+ try app_ctx(Host, AppID, Lang, ctx(Invite, Request)) of
+ AppCtx ->
+ Body = render_register_form(Request, AppCtx, maybe_add_username(Invite)),
+ ?HTTP_OK(Body)
+ catch
+ _:not_found ->
+ ?NOT_FOUND
+ end.
+
+render_register_form(#request{host = Host, lang = Lang}, Ctx, AdditionalCtx) ->
+ render(Host, Lang, <<"register.html">>, Ctx ++ AdditionalCtx).
+
+process_register_post(Invite,
+ AppID,
+ #request{host = Host,
+ q = Q,
+ lang = Lang,
+ ip = {Source, _}} =
+ Request) ->
+ ?DEBUG("got query: ~p", [Q]),
+ Username = proplists:get_value(<<"user">>, Q),
+ Password = proplists:get_value(<<"password">>, Q),
+ Token = Invite#invite_token.token,
+ try {app_ctx(Host, AppID, Lang, ctx(Invite, Request)),
+ ensure_same(Token, proplists:get_value(<<"token">>, Q))}
+ of
+ {AppCtx, ok} ->
+ case mod_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)])
+ end
+ end
+ catch
+ _:not_found ->
+ ?NOT_FOUND;
+ _:no_match ->
+ ?BAD_REQUEST
+ end.
+
+process_roster_token([_Token], #request{host = Host, lang = Lang} = Request, Invite) ->
+ Ctx0 = ctx(Invite, Request),
+ Apps =
+ lists:map(fun(App = #{<<"download">> := #{<<"buttons">> := [Button | _]}}) ->
+ ProceedUrl =
+ case render_app_button_url(Button, Ctx0) of
+ #{magic_link := MagicLink} ->
+ MagicLink;
+ #{<<"url">> := Url} ->
+ Url
+ end,
+ App#{proceed_url => ProceedUrl,
+ select_text => translate:translate(Lang, ?T("Install"))}
+ end,
+ apps_json(Host, Lang, Ctx0)),
+ Ctx = [{apps, Apps} | Ctx0],
+ render_ok(Host, Lang, <<"roster.html">>, Ctx);
+process_roster_token(_, _, _) ->
+ ?NOT_FOUND.
+
+ensure_same(V, V) ->
+ ok;
+ensure_same(_, _) ->
+ throw(no_match).
+
+app_ctx(_Host, <<>>, _Lang, Ctx) ->
+ Ctx;
+app_ctx(Host, AppID, Lang, Ctx) ->
+ FilteredApps =
+ [App || A <- apps_json(Host, Lang, Ctx), maps:get(<<"id">>, App = app_id(A)) == AppID],
+ case FilteredApps of
+ [App] ->
+ [{app, render_app_button_urls(App, Ctx)} | Ctx];
+ [] ->
+ throw(not_found)
+ end.
+
+ctx(#request{host = Host, path = [Base | _]}) ->
+ SiteName = mod_invites_opt:site_name(Host),
+ [?STATIC_CTX, ?SITE_NAME_CTX(SiteName)].
+
+ctx(Invite, #request{host = Host} = Request) ->
+ [{invite, invite_to_proplist(Invite)},
+ {uri, mod_invites:token_uri(Invite)},
+ {domain, Host},
+ {token, Invite#invite_token.token},
+ {registration_url, <<(Invite#invite_token.token)/binary, "/", ?REGISTRATION/binary>>}
+ | ctx(Request)].
+
+apps_json(Host, Lang, Ctx) ->
+ AppsBins = render(Host, Lang, <<"apps.json">>, Ctx),
+ AppsBin = 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).
diff --git a/src/mod_invites_http_erlylib.erl b/src/mod_invites_http_erlylib.erl
new file mode 100644
index 00000000000..4c5aaa26b51
--- /dev/null
+++ b/src/mod_invites_http_erlylib.erl
@@ -0,0 +1,49 @@
+%%%----------------------------------------------------------------------
+%%% File : mod_invites_http_erlylib.erl
+%%% Author : Stefan Strigler
+%%% Purpose : Elydtl custom tags and filters
+%%% Created : Mon Nov 10 2025 by Stefan Strigler
+%%%
+%%%
+%%% ejabberd, Copyright (C) 2025 ProcessOne
+%%%
+%%% This program is free software; you can redistribute it and/or
+%%% modify it under the terms of the GNU General Public License as
+%%% published by the Free Software Foundation; either version 2 of the
+%%% License, or (at your option) any later version.
+%%%
+%%% This program is distributed in the hope that it will be useful,
+%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
+%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+%%% General Public License for more details.
+%%%
+%%% You should have received a copy of the GNU General Public License along
+%%% with this program; if not, write to the Free Software Foundation, Inc.,
+%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+%%%
+%%%----------------------------------------------------------------------
+-module(mod_invites_http_erlylib).
+
+-behaviour(erlydtl_library).
+
+-export([version/0, inventory/1]).
+-export([jid/1, user/1, strip_protocol/1]).
+
+-include("logger.hrl").
+
+version() ->
+ 1.
+
+inventory(tags) ->
+ [];
+inventory(filters) ->
+ [{jid, jid}, {user, user}, {token_uri, {mod_invites, token_uri}}, {strip_protocol, strip_protocol}].
+
+jid({User, Server}) ->
+ jid:encode(jid:make(User, Server)).
+
+strip_protocol(Uri) ->
+ re:replace(Uri, <<"xmpp:">>, <<>>, [{return, binary}]).
+
+user({User, _Server}) ->
+ User.
diff --git a/src/mod_invites_mnesia.erl b/src/mod_invites_mnesia.erl
new file mode 100644
index 00000000000..08c0467dbfd
--- /dev/null
+++ b/src/mod_invites_mnesia.erl
@@ -0,0 +1,143 @@
+%%%----------------------------------------------------------------------
+%%% File : mod_invites_mnesia.erl
+%%% Author : Stefan Strigler
+%%% Created : Mon Sep 15 2025 by Stefan Strigler
+%%%
+%%%
+%%% ejabberd, Copyright (C) 2025 ProcessOne
+%%%
+%%% This program is free software; you can redistribute it and/or
+%%% modify it under the terms of the GNU General Public License as
+%%% published by the Free Software Foundation; either version 2 of the
+%%% License, or (at your option) any later version.
+%%%
+%%% This program is distributed in the hope that it will be useful,
+%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
+%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+%%% General Public License for more details.
+%%%
+%%% You should have received a copy of the GNU General Public License along
+%%% with this program; if not, write to the Free Software Foundation, Inc.,
+%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+%%%
+%%%----------------------------------------------------------------------
+-module(mod_invites_mnesia).
+
+-author('stefan@strigler.de').
+
+-behaviour(mod_invites).
+
+-export([cleanup_expired/1, create_invite/1, expire_tokens/2, get_invite/2, get_invites/2, init/2,
+ is_reserved/3, is_token_valid/3, list_invites/1, remove_user/2,
+ set_invitee/5]).
+
+-include("mod_invites.hrl").
+
+%% @format-begin
+
+%%--------------------------------------------------------------------
+%%| mod_invite callbacks
+
+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(Invite) ->
+ ok = mnesia:dirty_write(Invite),
+ Invite.
+
+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_invites(_Host, Inviter) ->
+ mnesia:dirty_index_read(invite_token, Inviter, #invite_token.inviter).
+
+init(_Host, _Opts) ->
+ ejabberd_mnesia:create(?MODULE,
+ invite_token,
+ [{disc_copies, [node()]},
+ {attributes, record_info(fields, invite_token)},
+ {index, [inviter]}]).
+
+is_reserved(_Host, Token, User) ->
+ [T
+ || T <- mnesia:dirty_all_keys(invite_token),
+ not mod_invites:is_expired(I = hd(mnesia:dirty_read(invite_token, T))),
+ I#invite_token.token /= Token,
+ I#invite_token.invitee == <<>>,
+ I#invite_token.account_name == User]
+ =/= [].
+
+is_token_valid(Host, Token, Scope) ->
+ case mnesia:dirty_read(invite_token, Token) of
+ [Invite = #invite_token{invitee = <<>>, inviter = {_, Host} = Inviter}]
+ when Scope == Inviter; Scope == {<<>>, Host} ->
+ not mod_invites:is_expired(Invite);
+ [#invite_token{}] ->
+ false;
+ [] ->
+ throw(not_found)
+ end.
+
+list_invites(Host) ->
+ [Invite
+ || Token <- mnesia:dirty_all_keys(invite_token),
+ element(2, (Invite = hd(mnesia:dirty_read(invite_token, Token)))#invite_token.inviter)
+ == Host].
+
+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.
diff --git a/src/mod_invites_opt.erl b/src/mod_invites_opt.erl
new file mode 100644
index 00000000000..81043356632
--- /dev/null
+++ b/src/mod_invites_opt.erl
@@ -0,0 +1,55 @@
+%% Generated automatically
+%% DO NOT EDIT: run `make options` instead
+
+-module(mod_invites_opt).
+
+-export([access_create_account/1]).
+-export([db_type/1]).
+-export([landing_page/1]).
+-export([max_invites/1]).
+-export([site_name/1]).
+-export([templates_dir/1]).
+-export([token_expire_seconds/1]).
+
+-spec access_create_account(gen_mod:opts() | global | binary()) -> 'none' | acl:acl().
+access_create_account(Opts) when is_map(Opts) ->
+ gen_mod:get_opt(access_create_account, Opts);
+access_create_account(Host) ->
+ gen_mod:get_module_opt(Host, mod_invites, access_create_account).
+
+-spec db_type(gen_mod:opts() | global | binary()) -> atom().
+db_type(Opts) when is_map(Opts) ->
+ gen_mod:get_opt(db_type, Opts);
+db_type(Host) ->
+ gen_mod:get_module_opt(Host, mod_invites, db_type).
+
+-spec landing_page(gen_mod:opts() | global | binary()) -> 'none' | binary().
+landing_page(Opts) when is_map(Opts) ->
+ gen_mod:get_opt(landing_page, Opts);
+landing_page(Host) ->
+ gen_mod:get_module_opt(Host, mod_invites, landing_page).
+
+-spec max_invites(gen_mod:opts() | global | binary()) -> 'infinity' | pos_integer().
+max_invites(Opts) when is_map(Opts) ->
+ gen_mod:get_opt(max_invites, Opts);
+max_invites(Host) ->
+ gen_mod:get_module_opt(Host, mod_invites, max_invites).
+
+-spec site_name(gen_mod:opts() | global | binary()) -> binary().
+site_name(Opts) when is_map(Opts) ->
+ gen_mod:get_opt(site_name, Opts);
+site_name(Host) ->
+ gen_mod:get_module_opt(Host, mod_invites, site_name).
+
+-spec templates_dir(gen_mod:opts() | global | binary()) -> binary().
+templates_dir(Opts) when is_map(Opts) ->
+ gen_mod:get_opt(templates_dir, Opts);
+templates_dir(Host) ->
+ gen_mod:get_module_opt(Host, mod_invites, templates_dir).
+
+-spec token_expire_seconds(gen_mod:opts() | global | binary()) -> pos_integer().
+token_expire_seconds(Opts) when is_map(Opts) ->
+ gen_mod:get_opt(token_expire_seconds, Opts);
+token_expire_seconds(Host) ->
+ gen_mod:get_module_opt(Host, mod_invites, token_expire_seconds).
+
diff --git a/src/mod_invites_register.erl b/src/mod_invites_register.erl
new file mode 100644
index 00000000000..b3ed6c4f9f3
--- /dev/null
+++ b/src/mod_invites_register.erl
@@ -0,0 +1,285 @@
+%%%----------------------------------------------------------------------
+%%% File : mod_invites_register.erl
+%%% Author : Stefan Strigler
+%%% Purpose : Provide web page(s) to sign up using an invite token.
+%%% Created : Fri Oct 31 2025 by Stefan Strigler
+%%%
+%%%
+%%% ejabberd, Copyright (C) 2025 ProcessOne
+%%%
+%%% This program is free software; you can redistribute it and/or
+%%% modify it under the terms of the GNU General Public License as
+%%% published by the Free Software Foundation; either version 2 of the
+%%% License, or (at your option) any later version.
+%%%
+%%% This program is distributed in the hope that it will be useful,
+%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
+%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+%%% General Public License for more details.
+%%%
+%%% You should have received a copy of the GNU General Public License along
+%%% with this program; if not, write to the Free Software Foundation, Inc.,
+%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+%%%
+%%%----------------------------------------------------------------------
+-module(mod_invites_register).
+
+-author('stefan@strigler.de').
+
+-export([c2s_unauthenticated_packet/2, stream_feature_register/2]).
+-export([try_register/6]).
+
+-import(mod_invites, [roster_add/2, send_presence/3, xdata_field/3]).
+-include("logger.hrl").
+
+-include_lib("xmpp/include/xmpp.hrl").
+
+-include("ejabberd_commands.hrl").
+-include("mod_invites.hrl").
+-include("translate.hrl").
+
+%% @format-begin
+
+-define(TRY_SUBTAG(IQ, SUBTAG, F, Else),
+ try xmpp:try_subtag(IQ, SUBTAG) of
+ false ->
+ Else();
+ SubTag ->
+ F(SubTag)
+ catch
+ _:{xmpp_codec, Why} ->
+ Txt = xmpp:io_format_error(Why),
+ Lang = maps:get(lang, State),
+ Err = make_stripped_error(IQ, SUBTAG, xmpp:err_bad_request(Txt, Lang)),
+ {stop, ejabberd_c2s:send(State, Err)}
+ end).
+-define(TRY_SUBTAG(IQ, SUBTAG, F), ?TRY_SUBTAG(IQ, SUBTAG, F, fun() -> State end)).
+
+-spec stream_feature_register([xmpp_element()], binary()) -> [xmpp_element()].
+stream_feature_register(Acc, Host) ->
+ case mod_invites_opt:access_create_account(Host) of
+ none ->
+ Acc;
+ _ ->
+ [#feature_register_ibr_token{} | Acc]
+ end.
+
+c2s_unauthenticated_packet(#{invite := Invite} = State,
+ #iq{type = get, sub_els = [_]} = IQ) ->
+ %% User requests registration form after processing token
+ ?TRY_SUBTAG(IQ,
+ #register{},
+ fun(Register) ->
+ #{server := Server} = State,
+ IQ1 = xmpp:set_els(IQ, [Register]),
+ User = Invite#invite_token.account_name,
+ IQ2 = xmpp:set_from_to(IQ1, jid:make(User, Server), jid:make(Server)),
+ ResIQ = mod_register:process_iq(IQ2),
+ ResIQ1 = xmpp:set_from_to(ResIQ, jid:make(Server), undefined),
+ {stop, ejabberd_c2s:send(State, ResIQ1)}
+ end);
+c2s_unauthenticated_packet(#{invite := Invite, server := Server} = State,
+ #iq{type = set,
+ sub_els = [_],
+ 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(Host, Token) of
+ {true, Invite} ->
+ NewState = State#{invite => Invite},
+ {NewState, xmpp:make_iq_result(IQ)};
+ false ->
+ {State, preauth_invalid(IQ, Lang)}
+ end.
+
+can_create(Host, Token) ->
+ try mod_invites:is_token_valid(Host, Token) of
+ true ->
+ case mod_invites:get_invite(Host, Token) of
+ #invite_token{type = roster_only, account_name = AccountName}
+ when AccountName /= <<>> ->
+ false;
+ Invite ->
+ case create_account_allowed(Invite) of
+ ok ->
+ {true, Invite};
+ {error, not_allowed} ->
+ false
+ 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 ->
+ ok;
+ false ->
+ {error, not_allowed}
+ end;
+create_account_allowed(#invite_token{inviter = {<<>>, _Host}}) ->
+ ok;
+create_account_allowed(#invite_token{inviter = {User, Host}}) ->
+ mod_invites:create_account_allowed(Host, jid:make(User, Host)).
+
+preauth_invalid(IQ, Lang) ->
+ Text = ?T("The token provided is either invalid or expired."),
+ make_stripped_error(IQ, #preauth{}, xmpp:err_item_not_found(Text, Lang)).
+
+try_register(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,
+ xmpp:err_jid_malformed(
+ mod_register:format_error(invalid_jid), 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,
+ xmpp:err_conflict(
+ mod_register:format_error(not_allowed), Lang)};
+ {error, _Reason} = Error ->
+ Error
+ end
+ end.
+
+check_captcha(true, #register{xdata = X}, #iq{lang = Lang} = IQ) ->
+ XdataC =
+ xmpp_util:set_xdata_field(#xdata_field{var = <<"FORM_TYPE">>,
+ type = hidden,
+ values = [?NS_CAPTCHA]},
+ X),
+ case ejabberd_captcha:process_reply(XdataC) of
+ ok ->
+ case process_xdata_submit(X) of
+ {ok, _} = Result ->
+ Result;
+ _ ->
+ Txt = ?T("Incorrect data form"),
+ make_stripped_error(IQ, #register{}, xmpp:err_bad_request(Txt, Lang))
+ end;
+ {error, malformed} ->
+ Txt = ?T("Incorrect CAPTCHA submit"),
+ make_stripped_error(IQ, #register{}, xmpp:err_bad_request(Txt, Lang));
+ _ ->
+ ErrText = ?T("The CAPTCHA verification has failed"),
+ make_stripped_error(IQ, #register{}, xmpp:err_not_allowed(ErrText, Lang))
+ end;
+check_captcha(false, #register{username = Username, password = Password}, _IQ)
+ when is_binary(Username), is_binary(Password) ->
+ {ok, {Username, Password}};
+check_captcha(_IsCaptchaEnabled, _Register, IQ) ->
+ ResIQ = make_stripped_error(IQ, #register{}, xmpp:err_bad_request()),
+ {error, ResIQ}.
+
+process_xdata_submit(#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.
diff --git a/src/mod_invites_sql.erl b/src/mod_invites_sql.erl
new file mode 100644
index 00000000000..17d908cebcc
--- /dev/null
+++ b/src/mod_invites_sql.erl
@@ -0,0 +1,223 @@
+%%%----------------------------------------------------------------------
+%%% File : mod_invites_sql.erl
+%%% Author : Stefan Strigler
+%%% Created : Mon Sep 15 2025 by Stefan Strigler
+%%%
+%%%
+%%% ejabberd, Copyright (C) 2025 ProcessOne
+%%%
+%%% This program is free software; you can redistribute it and/or
+%%% modify it under the terms of the GNU General Public License as
+%%% published by the Free Software Foundation; either version 2 of the
+%%% License, or (at your option) any later version.
+%%%
+%%% This program is distributed in the hope that it will be useful,
+%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
+%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+%%% General Public License for more details.
+%%%
+%%% You should have received a copy of the GNU General Public License along
+%%% with this program; if not, write to the Free Software Foundation, Inc.,
+%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+%%%
+%%%----------------------------------------------------------------------
+-module(mod_invites_sql).
+
+-author('stefan@strigler.de').
+
+-behaviour(mod_invites).
+
+-export([cleanup_expired/1, create_invite/1, expire_tokens/2, get_invite/2, get_invites/2, init/2,
+ is_reserved/3, is_token_valid/3, list_invites/1, remove_user/2,
+ set_invitee/5]).
+
+-export([sql_schemas/0]).
+
+-include("mod_invites.hrl").
+-include("ejabberd_sql_pt.hrl").
+
+%-define(I(B), binary_to_integer(B)).
+
+%% @format-begin
+
+%%--------------------------------------------------------------------
+%%| mod_invite callbacks
+init(Host, _Opts) ->
+ ejabberd_sql_schema:update_schema(Host, ?MODULE, sql_schemas()).
+
+sql_schemas() ->
+ [#sql_schema{version = 1,
+ tables =
+ [#sql_table{name = <<"invite_token">>,
+ columns =
+ [#sql_column{name = <<"token">>, type = text},
+ #sql_column{name = <<"username">>, type = text},
+ #sql_column{name = <<"server_host">>, type = text},
+ #sql_column{name = <<"invitee">>,
+ type = text,
+ default = true},
+ #sql_column{name = <<"created_at">>,
+ type = timestamp,
+ default = true},
+ #sql_column{name = <<"expires">>, type = timestamp},
+ #sql_column{name = <<"type">>, type = {char, 1}},
+ #sql_column{name = <<"account_name">>, type = text}],
+ indices =
+ [#sql_index{columns = [<<"token">>], unique = true},
+ #sql_index{columns =
+ [<<"username">>, <<"server_host">>]}]}]}].
+
+cleanup_expired(Host) ->
+ NOW = sql_now(),
+ {updated, Count} =
+ ejabberd_sql:sql_query(Host, ?SQL("DELETE FROM invite_token WHERE expires < %(NOW)t")),
+ Count.
+
+create_invite(Invite) ->
+ #invite_token{inviter = {User, Host},
+ token = Token,
+ account_name = AccountName,
+ created_at = CreatedAt0,
+ expires = Expires0,
+ type = Type0} =
+ Invite,
+ Type = enc_type(Type0),
+ CreatedAt = CreatedAt0,
+ Expires = Expires0,
+
+ Query =
+ ?SQL_INSERT("invite_token",
+ ["token=%(Token)s",
+ "username=%(User)s",
+ "server_host=%(Host)s",
+ "type=%(Type)s",
+ "created_at=%(CreatedAt)t",
+ "expires=%(Expires)t",
+ "account_name=%(AccountName)s"]),
+ {updated, 1} = ejabberd_sql:sql_query(Host, Query),
+ Invite.
+
+expire_tokens(User, Server) ->
+ NOW = sql_now(),
+ {updated, Count} =
+ ejabberd_sql:sql_query(Server,
+ ?SQL("UPDATE invite_token SET expires = '1970-01-01 00:00:01' WHERE "
+ "username = %(User)s AND %(Server)H AND expires > %(NOW)t AND "
+ "type != 'R'")),
+ Count.
+
+get_invite(Host, Token) ->
+ case ejabberd_sql:sql_query(Host,
+ ?SQL("SELECT @(username)s, @(invitee)s, @(type)s, @(account_name)s, "
+ "@(expires)t, @(created_at)t FROM invite_token WHERE token = "
+ "%(Token)s AND %(Host)H"))
+ of
+ {selected, [{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};
+ {selected, []} ->
+ {error, not_found}
+ end.
+
+get_invites(Host, {User, _Host}) ->
+ {selected, Invites} =
+ ejabberd_sql:sql_query(Host,
+ ?SQL("SELECT @(token)s, @(invitee)s, @(type)s, @(account_name)s, "
+ "@(expires)t, @(created_at)t FROM invite_token WHERE %(Host)H "
+ "AND username = %(User)s")),
+ lists:map(fun({Token, 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}
+ end,
+ Invites).
+
+is_reserved(Host, Token, User) ->
+ NOW = sql_now(),
+ {selected, [{Count}]} =
+ ejabberd_sql:sql_query(Host,
+ ?SQL("SELECT @(COUNT(*))d FROM invite_token WHERE %(Host)H AND token != %(Token)s AND "
+ "account_name = %(User)s AND invitee = '' AND expires > %(NOW)t")),
+ Count > 0.
+
+is_token_valid(Host, Token, {User, Host}) ->
+ NOW = sql_now(),
+ {selected, Rows} =
+ ejabberd_sql:sql_query(Host,
+ ?SQL("SELECT @(token)s FROM invite_token WHERE %(Host)H AND token = %(Token)s AND "
+ "invitee = '' AND expires > %(NOW)t AND (%(User)s = '' OR username = %(User)s)")),
+ case Rows /= [] of
+ true ->
+ true;
+ false ->
+ case get_invite(Host, Token) of
+ {error, not_found} ->
+ throw(not_found);
+ _ ->
+ false
+ end
+ end.
+
+list_invites(Host) ->
+ {selected, Rows} =
+ ejabberd_sql:sql_query(Host,
+ ?SQL("SELECT @(token)s, @(username)s, @(type)s, @(account_name)s, "
+ "@(expires)t, @(created_at)t FROM invite_token WHERE %(Host)H")),
+ lists:map(fun({Token, User, Type, AccountName, Expires, CreatedAt}) ->
+ #invite_token{token = Token,
+ inviter = {User, Host},
+ type = dec_type(Type),
+ account_name = AccountName,
+ expires = Expires,
+ created_at = CreatedAt}
+ end,
+ Rows).
+
+remove_user(User, Server) ->
+ ejabberd_sql:sql_query(Server,
+ ?SQL("DELETE FROM invite_token WHERE username=%(User)s AND %(Server)H")).
+
+set_invitee(Fun, Host, Token, Invitee, AccountName) ->
+ Trans =
+ fun() ->
+ {updated, 1} =
+ ejabberd_sql:sql_query_t(?SQL("UPDATE invite_token SET invitee = %(Invitee)s, account_name = %(AccountName)s WHERE "
+ "%(Host)H AND token = %(Token)s AND invitee = '' AND (type != 'R' OR account_name = '' OR %(AccountName)s = '')")),
+ ok = Fun()
+ end,
+ case ejabberd_sql:sql_transaction(Host, Trans) of
+ {atomic, Res} ->
+ Res;
+ {aborted, {badmatch, {updated, 0}}} ->
+ {error, conflict};
+ {aborted, {badmatch, {error, _Res} = Error}} ->
+ Error
+ end.
+
+%%--------------------------------------------------------------------
+%%| helpers
+sql_now() ->
+ calendar:local_time().
+
+enc_type(roster_only) ->
+ <<"R">>;
+enc_type(account_subscription) ->
+ <<"S">>;
+enc_type(account_only) ->
+ <<"A">>.
+
+dec_type(<<"R">>) ->
+ roster_only;
+dec_type(<<"S">>) ->
+ account_subscription;
+dec_type(<<"A">>) ->
+ account_only.
diff --git a/src/mod_register.erl b/src/mod_register.erl
index 793c3c54dee..099356262e8 100644
--- a/src/mod_register.erl
+++ b/src/mod_register.erl
@@ -33,7 +33,7 @@
-export([start/2, stop/1, reload/3, stream_feature_register/2,
c2s_unauthenticated_packet/2, try_register/4, try_register/5,
- process_iq/1, send_registration_notifications/3,
+ try_register/6, process_iq/1, send_registration_notifications/3,
mod_opt_type/1, mod_options/1, depends/2,
format_error/1, mod_doc/0]).
diff --git a/test/ejabberd_SUITE.erl b/test/ejabberd_SUITE.erl
index f40c08f666c..d61e37615bd 100644
--- a/test/ejabberd_SUITE.erl
+++ b/test/ejabberd_SUITE.erl
@@ -349,6 +349,10 @@ init_per_testcase(TestCase, OrigConfig) ->
Password = ?config(password, Config),
ejabberd_auth:try_register(User, Server, Password),
open_session(bind(auth(connect(Config))));
+ "invites_" ++ _ ->
+ Password = ?config(password, Config),
+ ejabberd_auth:try_register(User, Server, Password),
+ open_session(bind(auth(connect(Config))));
_ when IsMaster or IsSlave ->
Password = ?config(password, Config),
ejabberd_auth:try_register(User, Server, Password),
@@ -359,8 +363,15 @@ init_per_testcase(TestCase, OrigConfig) ->
open_session(bind(auth(connect(Config))))
end.
-end_per_testcase(_TestCase, _Config) ->
- ok.
+end_per_testcase(TestCase, Config) ->
+ case atom_to_list(TestCase) of
+ "invites_" ++ _ ->
+ User = ?config(user, Config),
+ Server = ?config(server, Config),
+ mod_offline:remove_user(User, Server);
+ _ ->
+ ok
+ end.
legacy_auth_tests() ->
{legacy_auth, [parallel],
@@ -444,6 +455,7 @@ db_tests(DB) when DB == mnesia; DB == redis ->
pubsub_tests:single_cases(),
muc_tests:single_cases(),
offline_tests:single_cases(),
+ invites_tests:single_cases(),
mam_tests:single_cases(),
csi_tests:single_cases(),
push_tests:single_cases(),
@@ -477,6 +489,7 @@ db_tests(DB) ->
offline_tests:single_cases(),
mam_tests:single_cases(),
push_tests:single_cases(),
+ invites_tests:single_cases(),
test_pass_change,
test_unregister]},
muc_tests:master_slave_cases(),
diff --git a/test/ejabberd_SUITE_data/ejabberd.mnesia.yml b/test/ejabberd_SUITE_data/ejabberd.mnesia.yml
index 56fdf5e6e4d..a7e55c673b5 100644
--- a/test/ejabberd_SUITE_data/ejabberd.mnesia.yml
+++ b/test/ejabberd_SUITE_data/ejabberd.mnesia.yml
@@ -17,6 +17,9 @@ define_macro:
mod_blocking: []
mod_caps:
db_type: internal
+ mod_invites:
+ db_type: internal
+ landing_page: auto
mod_last:
db_type: internal
mod_muc:
diff --git a/test/ejabberd_SUITE_data/ejabberd.mssql.yml b/test/ejabberd_SUITE_data/ejabberd.mssql.yml
index 1458cafa44b..2e4c1942ffc 100644
--- a/test/ejabberd_SUITE_data/ejabberd.mssql.yml
+++ b/test/ejabberd_SUITE_data/ejabberd.mssql.yml
@@ -16,6 +16,9 @@ define_macro:
mod_blocking: []
mod_caps:
db_type: sql
+ mod_invites:
+ db_type: sql
+ landing_page: auto
mod_last:
db_type: sql
mod_muc:
diff --git a/test/ejabberd_SUITE_data/ejabberd.mysql.yml b/test/ejabberd_SUITE_data/ejabberd.mysql.yml
index 91705ee681d..65df93e902b 100644
--- a/test/ejabberd_SUITE_data/ejabberd.mysql.yml
+++ b/test/ejabberd_SUITE_data/ejabberd.mysql.yml
@@ -16,6 +16,9 @@ define_macro:
mod_blocking: []
mod_caps:
db_type: sql
+ mod_invites:
+ db_type: sql
+ landing_page: auto
mod_last:
db_type: sql
mod_muc:
diff --git a/test/ejabberd_SUITE_data/ejabberd.pgsql.yml b/test/ejabberd_SUITE_data/ejabberd.pgsql.yml
index 16d8b1d2747..4da5444015a 100644
--- a/test/ejabberd_SUITE_data/ejabberd.pgsql.yml
+++ b/test/ejabberd_SUITE_data/ejabberd.pgsql.yml
@@ -16,6 +16,9 @@ define_macro:
mod_blocking: []
mod_caps:
db_type: sql
+ mod_invites:
+ db_type: sql
+ landing_page: auto
mod_last:
db_type: sql
mod_muc:
diff --git a/test/ejabberd_SUITE_data/ejabberd.redis.yml b/test/ejabberd_SUITE_data/ejabberd.redis.yml
index fb1ba435fa6..18b22e7bdff 100644
--- a/test/ejabberd_SUITE_data/ejabberd.redis.yml
+++ b/test/ejabberd_SUITE_data/ejabberd.redis.yml
@@ -18,6 +18,9 @@ define_macro:
mod_blocking: []
mod_caps:
db_type: internal
+ mod_invites:
+ db_type: internal
+ landing_page: auto
mod_last:
db_type: internal
mod_muc:
diff --git a/test/ejabberd_SUITE_data/ejabberd.sqlite.yml b/test/ejabberd_SUITE_data/ejabberd.sqlite.yml
index 11420ef6c68..52093f9eed8 100644
--- a/test/ejabberd_SUITE_data/ejabberd.sqlite.yml
+++ b/test/ejabberd_SUITE_data/ejabberd.sqlite.yml
@@ -14,6 +14,9 @@ define_macro:
mod_blocking: []
mod_caps:
db_type: sql
+ mod_invites:
+ db_type: sql
+ landing_page: auto
mod_last:
db_type: sql
mod_muc:
diff --git a/test/ejabberd_SUITE_data/ejabberd.yml b/test/ejabberd_SUITE_data/ejabberd.yml
index b323ecfecc2..71e7f8e6862 100644
--- a/test/ejabberd_SUITE_data/ejabberd.yml
+++ b/test/ejabberd_SUITE_data/ejabberd.yml
@@ -68,6 +68,8 @@ access_rules:
allow: local
register:
allow: all
+ account_invite:
+ allow: all
acl:
local:
@@ -96,6 +98,7 @@ listen:
"/api": mod_http_api
"/upload": mod_http_upload
"/captcha": ejabberd_captcha
+ "/invites": mod_invites
-
port: STUN_PORT
module: ejabberd_stun
diff --git a/test/invites_tests.erl b/test/invites_tests.erl
new file mode 100644
index 00000000000..8baeef6c19b
--- /dev/null
+++ b/test/invites_tests.erl
@@ -0,0 +1,846 @@
+%%%-------------------------------------------------------------------
+%%% Author : Stefan Strigler
+%%% Created : 16 September 2025 by Stefan Strigler
+%%%
+%%%
+%%% ejabberd, Copyright (C) 2025 ProcessOne
+%%%
+%%% This program is free software; you can redistribute it and/or
+%%% modify it under the terms of the GNU General Public License as
+%%% published by the Free Software Foundation; either version 2 of the
+%%% License, or (at your option) any later version.
+%%%
+%%% This program is distributed in the hope that it will be useful,
+%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
+%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+%%% General Public License for more details.
+%%%
+%%% You should have received a copy of the GNU General Public License along
+%%% with this program; if not, write to the Free Software Foundation, Inc.,
+%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+%%%
+%%%----------------------------------------------------------------------
+-module(invites_tests).
+
+-compile(export_all).
+
+-import(suite, [auth/1, bind/1, disconnect/1, get_features/2, init_stream/1, open_session/1,
+ recv_presence/1, recv_message/1, recv_iq/1, self_presence/2, send_recv/2, send/2,
+ set_opt/3, set_opts/2]).
+
+-include("suite.hrl").
+-include("mod_invites.hrl").
+-include("mod_roster.hrl").
+
+%% killme
+-record(ejabberd_module,
+ {module_host = {undefined, <<"">>} :: {atom(), binary()},
+ opts = [] :: any(),
+ registrations = [] :: [any()],
+ order = 0 :: integer()}).
+
+%% @format-begin
+
+%%%===================================================================
+%%% API
+%%%===================================================================
+%%%===================================================================
+%%% Single tests
+%%%===================================================================
+
+single_cases() ->
+ {invites_single,
+ [sequence],
+ [single_test(gen_invite),
+ single_test(cleanup_expired),
+ single_test(adhoc_items),
+ single_test(adhoc_command_invite),
+ single_test(adhoc_command_create_account),
+ single_test(token_valid),
+ single_test(remove_user),
+ single_test(expire_tokens),
+ single_test(max_invites),
+ single_test(presence_with_preauth_token),
+ single_test(is_reserved),
+ single_test(stream_feature),
+ single_test(ibr),
+ single_test(ibr_reserved),
+ single_test(ibr_subscription),
+ single_test(ibr_conflict),
+ single_test(http)]}.
+
+%%%===================================================================
+
+gen_invite(Config) ->
+ Server = ?config(server, Config),
+ User = ?config(user, Config),
+ {TokenURI, _LandingPage} = mod_invites:gen_invite(<<"foo">>, Server),
+ ?match(<<"xmpp:foo@", Server:(size(Server))/binary, "?register;preauth=", _/binary>>,
+ TokenURI),
+ Token = token_from_uri(TokenURI),
+ #invite_token{inviter = {<<>>, Server},
+ type = account_only,
+ account_name = <<"foo">>} =
+ mod_invites:get_invite(Server, Token),
+ {TokenURI2, _LP2} = mod_invites:gen_invite(Server),
+ ?match(<<"xmpp:", _/binary>>, TokenURI2),
+ Token2 = token_from_uri(TokenURI2),
+ #invite_token{inviter = {<<>>, Server},
+ type = account_only,
+ account_name = <<>>} =
+ mod_invites:get_invite(Server, Token2),
+ ?match({error, user_exists}, mod_invites:gen_invite(User, Server)),
+ ?match({error, account_name_invalid},
+ mod_invites:gen_invite(<<"@bad_acccount_name">>, Server)),
+ ?match({error, host_unknown}, mod_invites:gen_invite(<<"bar">>, <<"non.existant.host">>)),
+
+ ?match(2, length(mod_invites:list_invites(Server))),
+ %% TooLongHostname = list_to_binary([$a || _ <- lists:seq(1, 1024)]),
+ %% ?match({error, hostname_invalid}, mod_invites:gen_invite(<<"foo">>, TooLongHostname)),
+ mod_invites:expire_tokens(<<>>, Server),
+ ?match(2, mod_invites:cleanup_expired()),
+ disconnect(Config).
+
+cleanup_expired(Config) ->
+ Server = ?config(server, Config),
+ create_account_invite(Server, {<<"foo">>, Server}),
+ mod_invites:expire_tokens(<<"foo">>, Server),
+ Token = token_from_uri(element(1, mod_invites:gen_invite(<<"foobar">>, Server))),
+ ?match(1, mod_invites:cleanup_expired()),
+ ?match(#invite_token{}, mod_invites:get_invite(Server, Token)),
+ ?match(0, mod_invites:cleanup_expired()),
+ mod_invites:expire_tokens(<<>>, Server),
+ ?match(1, mod_invites:cleanup_expired()),
+ disconnect(Config).
+
+adhoc_items(Config) ->
+ Server = ?config(server, Config),
+ ServerJID = jid:from_string(Server),
+ User = ?config(user, Config),
+ UserJID = jid:from_string(User),
+ Disco = #disco_items{node = ?NS_COMMANDS},
+ #iq{type = result, sub_els = [#disco_items{node = ?NS_COMMANDS, items = Items}]} =
+ send_recv(Config,
+ #iq{type = get,
+ to = ServerJID,
+ sub_els = [Disco]}),
+ ?match(true, [I || I = #disco_item{node = ?NS_INVITE_INVITE} <- Items] /= []),
+ ?match(deny,
+ acl:match_rule(Server,
+ gen_mod:get_module_opt(Server, mod_invites, access_create_account),
+ UserJID)),
+ ?match(false, [I || I = #disco_item{node = ?NS_INVITE_CREATE_ACCOUNT} <- Items] /= []),
+ OldOpts = gen_mod:get_module_opts(Server, mod_invites),
+ NewOpts = gen_mod:set_opt(access_create_account, account_invite, OldOpts),
+ update_module_opts(Server, mod_invites, NewOpts),
+ ?match(allow,
+ acl:match_rule(Server,
+ gen_mod:get_module_opt(Server, mod_invites, access_create_account),
+ UserJID)),
+ #iq{type = result, sub_els = [#disco_items{node = ?NS_COMMANDS, items = NewItems}]} =
+ send_recv(Config,
+ #iq{type = get,
+ to = ServerJID,
+ sub_els = [Disco]}),
+ ?match(true, [I || I = #disco_item{node = ?NS_INVITE_INVITE} <- NewItems] /= []),
+ ?match(true, [I || I = #disco_item{node = ?NS_INVITE_CREATE_ACCOUNT} <- NewItems] /= []),
+ update_module_opts(Server, mod_invites, OldOpts),
+ ?match(deny,
+ acl:match_rule(Server,
+ gen_mod:get_module_opt(Server, mod_invites, access_create_account),
+ UserJID)),
+ disconnect(Config).
+
+adhoc_command_invite(Config) ->
+ Server = ?config(server, Config),
+ ServerJID = jid:from_string(Server),
+ User = ?config(user, Config),
+ Command = #adhoc_command{node = ?NS_INVITE_INVITE},
+ #iq{type = result,
+ sub_els =
+ [#adhoc_command{status = completed,
+ xdata = #xdata{type = result, fields = XdataFields}}]} =
+ send_recv(Config,
+ #iq{type = set,
+ to = ServerJID,
+ sub_els = [Command]}),
+ Uri = xdata_field(<<"uri">>, XdataFields),
+ ?match({match, [_, _]},
+ re:run(Uri,
+ <<"xmpp:",
+ (re_escape(User))/binary,
+ "@",
+ Server/binary,
+ "\\?roster;preauth=(.+)">>)),
+ ?match(true, xdata_field(<<"expire">>, XdataFields) /= undefined),
+ Token = token_from_uri(Uri),
+ ?match(true, mod_invites:is_token_valid(Server, Token, {User, Server})),
+ mod_invites:remove_user(User, Server),
+ disconnect(Config).
+
+adhoc_command_create_account(Config) ->
+ Server = ?config(server, Config),
+ ServerJID = jid:from_string(Server),
+ Command = #adhoc_command{node = ?NS_INVITE_CREATE_ACCOUNT},
+ ResForbidden =
+ send_recv(Config,
+ #iq{type = set,
+ to = ServerJID,
+ sub_els = [Command]}),
+ ?match(#iq{type = error}, ResForbidden),
+ #iq{sub_els = ForbiddenSubEls} = ResForbidden,
+ ?match(true,
+ [ok || #stanza_error{type = auth, reason = forbidden} <- ForbiddenSubEls] /= []),
+ OldOpts = gen_mod:get_module_opts(Server, mod_invites),
+ NewOpts = gen_mod:set_opt(access_create_account, account_invite, OldOpts),
+ update_module_opts(Server, mod_invites, NewOpts),
+ ResultXDataFields1 = test_create_account(Config, <<>>, <<"0">>),
+ ?match({match, [_, _]},
+ re:run(xdata_field(<<"uri">>, ResultXDataFields1),
+ <<"xmpp:", Server/binary, "\\?register;preauth=(.+)">>)),
+ ResultXDataFields2 = test_create_account(Config, <<"foobar">>, <<"0">>),
+ ?match({match, [_, _]},
+ re:run(xdata_field(<<"uri">>, ResultXDataFields2),
+ <<"xmpp:foobar@", Server/binary, "\\?register;preauth=(.+)">>)),
+ ResultXDataFields3 = test_create_account(Config, <<>>, <<"1">>),
+ ?match({match, _},
+ re:run(xdata_field(<<"uri">>, ResultXDataFields3),
+ <<"xmpp:", Server/binary, "\\?register;preauth=([a-zA-Z0-9]+)">>,
+ [{capture, all_but_first, binary}])),
+ Token3 = token_from_uri(xdata_field(<<"uri">>, ResultXDataFields3, <<>>)),
+ #invite_token{account_name = <<>>, type = account_subscription} =
+ mod_invites:get_invite(Server, Token3),
+ ResultXDataFields4 = test_create_account(Config, <<"foobar_with_sub">>, <<"1">>),
+ ?match({match, _},
+ re:run(xdata_field(<<"uri">>, ResultXDataFields4),
+ <<"xmpp:foobar_with_sub@", Server/binary, "\\?register;preauth=([a-zA-Z0-9]+)">>,
+ [{capture, all_but_first, binary}])),
+ Token4 = token_from_uri(xdata_field(<<"uri">>, ResultXDataFields4, <<>>)),
+ #invite_token{account_name = <<"foobar_with_sub">>, type = account_subscription} =
+ mod_invites:get_invite(Server, Token4),
+ update_module_opts(Server, mod_invites, OldOpts),
+ User = jid:nodeprep(?config(user, Config)),
+ mod_invites:remove_user(User, Server),
+ disconnect(Config).
+
+token_valid(Config) ->
+ Server = ?config(server, Config),
+ User = ?config(user, Config),
+ {TokenURI, _LandingPage} = mod_invites:gen_invite(<<"foobar">>, Server),
+ Token = token_from_uri(TokenURI),
+ ?match(true, mod_invites:is_token_valid(Server, Token)),
+ Inviter = {<<"foo">>, Server},
+ #invite_token{token = AccountToken} = create_account_invite(Server, Inviter),
+ ?match(true, mod_invites:is_token_valid(Server, AccountToken, Inviter)),
+ try mod_invites:is_token_valid(Server, <<"madeUptoken">>) of
+ break ->
+ broken
+ catch
+ _:E ->
+ ?match(not_found, E)
+ end,
+ ?match(false,
+ mod_invites:is_token_valid(Server, AccountToken, {<<"someoneElse">>, Server})),
+ mod_invites:expire_tokens(<<"foo">>, Server),
+ ?match(false, mod_invites:is_token_valid(Server, AccountToken, Inviter)),
+ mod_invites:cleanup_expired(),
+ mod_invites:remove_user(User, Server),
+ mod_invites:expire_tokens(<<>>, Server),
+ ?match(1, mod_invites:cleanup_expired()),
+ disconnect(Config).
+
+remove_user(Config) ->
+ Server = ?config(server, Config),
+ User = ?config(user, Config),
+ Inviter = {User, Server},
+ #invite_token{} = create_account_invite(Server, Inviter),
+ ?match(1, length(mod_invites:get_invites(Server, Inviter))),
+ mod_invites:remove_user(User, Server),
+ ?match(0, length(mod_invites:get_invites(Server, Inviter))),
+ disconnect(Config).
+
+expire_tokens(Config) ->
+ Server = ?config(server, Config),
+ User = ?config(user, Config),
+ Inviter = {User, Server},
+ #invite_token{token = RosterToken} = mod_invites:create_roster_invite(Server, Inviter),
+ #invite_token{token = AccountToken} = create_account_invite(Server, Inviter),
+ ?match(true, mod_invites:is_token_valid(Server, RosterToken, Inviter)),
+ ?match(1, mod_invites:expire_tokens(User, Server)),
+ ?match(true, mod_invites:is_token_valid(Server, RosterToken, Inviter)),
+ ?match(false, mod_invites:is_token_valid(Server, AccountToken, Inviter)),
+ ?match(0, mod_invites:expire_tokens(User, Server)),
+ mod_invites:cleanup_expired(),
+ mod_invites:remove_user(User, Server),
+ disconnect(Config).
+
+max_invites(Config0) ->
+ Server = ?config(server, Config0),
+ User = ?config(user, Config0),
+ Inviter = {User, Server},
+ OldOpts = gen_mod:get_module_opts(Server, mod_invites),
+ NewOpts =
+ gen_mod_set_opts(OldOpts,
+ [{max_invites, 3},
+ {access_create_account, account_invite},
+ {allow_modules, [mod_invites]}]),
+ update_module_opts(Server, mod_invites, NewOpts),
+
+ Config = reconnect(Config0),
+
+ #invite_token{} = create_account_invite(Server, Inviter),
+ #invite_token{} = create_account_invite(Server, Inviter),
+ #invite_token{} = create_account_invite(Server, Inviter),
+ ?match({error, num_invites_exceeded}, create_account_invite(Server, Inviter)),
+ #invite_token{token = RosterInviteToken} =
+ mod_invites:create_roster_invite(Server, Inviter),
+ ?match(#iq{type = error}, send_pars(Config, RosterInviteToken)),
+
+ %% we can create more than 3 as an "admin" user
+ ?match(0,
+ length([error
+ || _ <- lists:seq(1, 4), element(1, mod_invites:gen_invite(Server)) == error])),
+
+ update_module_opts(Server, mod_invites, OldOpts),
+ #invite_token{} = create_account_invite(Server, Inviter),
+ mod_invites:remove_user(User, Server),
+ mod_invites:expire_tokens(<<>>, Server),
+ ?match(4, mod_invites:cleanup_expired()),
+ disconnect(Config).
+
+presence_with_preauth_token(Config) ->
+ Server = ?config(server, Config),
+ User = ?config(user, Config),
+ Inviter = {<<"inviter">>, Server},
+ InviterJID = jid:make(<<"inviter">>, Server),
+ #invite_token{token = RosterToken} = mod_invites:create_roster_invite(Server, Inviter),
+ send(Config,
+ #presence{type = subscribe,
+ to = InviterJID,
+ sub_els = [#preauth{token = RosterToken}]}),
+ _ = ?recv2(#iq{type = set,
+ sub_els = [#roster_query{items = [#roster_item{ask = subscribe}]}]},
+ #iq{type = set,
+ sub_els = [#roster_query{items = [#roster_item{subscription = to}]}]}),
+ ?match(false, mod_invites:is_token_valid(Server, RosterToken, Inviter)),
+ %% cleanup the mess
+ mod_roster:del_roster(User, Server, jid:tolower(InviterJID)),
+ #iq{type = set} = suite:recv_iq(Config),
+ mod_invites:remove_user(<<"inviter">>, Server),
+ disconnect(Config).
+
+is_reserved(Config) ->
+ Server = ?config(server, Config),
+ Inviter = {<<"inviter">>, Server},
+ mod_invites:expire_tokens(<<"inviter">>, Server),
+ mod_invites:cleanup_expired(),
+ #invite_token{token = Token} =
+ mod_invites:create_account_invite(Server, Inviter, <<"reserved_user">>, false),
+ ?match({error, reserved},
+ mod_invites:create_account_invite(Server, Inviter, <<"reserved_user">>, false)),
+ ?match(false, mod_invites:is_reserved(Server, Token, <<"some_other_username">>)),
+ ?match(false, mod_invites:is_reserved(Server, Token, <<"reserved_user">>)),
+ ?match(true,
+ mod_invites:is_reserved(Server, <<"some_other_token">>, <<"reserved_user">>)),
+ %% "use" token to create account under different name, then it should not be reserved anymore
+ mod_invites:set_invitee(Server, Token, jid:make(<<"some_other_username">>, Server)),
+ ?match(false,
+ mod_invites:is_reserved(Server, <<"some_other_token">>, <<"reserved_user">>)),
+ #invite_token{token = OtherToken} =
+ mod_invites:create_account_invite(Server, Inviter, <<"reserved_user">>, false),
+ ?match(true, OtherToken /= Token),
+ mod_invites:remove_user(<<"inviter">>, Server),
+ disconnect(Config).
+
+stream_feature(Config0) ->
+ Server = ?config(server, Config0),
+ OldOpts = gen_mod:get_module_opts(Server, mod_invites),
+ Config1 = reconnect(Config0),
+ ?match(true, ?config(register, Config1)),
+ ?match(false, ?config(register_ibr_token, Config1)),
+ NewOpts = gen_mod:set_opt(access_create_account, account_invite, OldOpts),
+ update_module_opts(Server, mod_invites, NewOpts),
+ Config2 = reconnect(Config1),
+ ?match(true, ?config(register, Config2)),
+ ?match(true, ?config(register_ibr_token, Config2)),
+ update_module_opts(Server, mod_invites, OldOpts),
+ disconnect(Config2).
+
+ibr(Config0) ->
+ Server = ?config(server, Config0),
+ User = ?config(user, Config0),
+ AccountName = <<"new_user">>,
+
+ OldRegisterOpts = gen_mod:get_module_opts(Server, mod_register),
+ NewRegisterOpts = gen_mod:set_opt(allow_modules, [mod_invites], OldRegisterOpts),
+ update_module_opts(Server, mod_register, NewRegisterOpts),
+
+ Config1 = reconnect(Config0),
+
+ ?match(#iq{type = error}, send_iq_register(Config1, AccountName)),
+
+ ?match(#iq{type = error}, send_pars(Config1, <<"bad_token">>)),
+
+ #invite_token{token = RosterToken} =
+ mod_invites:create_roster_invite(Server, {<<"inviter">>, Server}),
+ ?match(#iq{type = result}, send_pars(Config1, RosterToken)),
+ ?match(#iq{type = result}, send_iq_register(Config1, <<"roster_invite_user">>)),
+ %% roster tokens should still be valid because they will be used for roster pars later by the
+ %% client
+ ?match(true, mod_invites:is_token_valid(Server, RosterToken)),
+
+ Config =
+ open_session(bind(auth(set_opt(password,
+ <<"mySecret">>,
+ set_opt(user, <<"roster_invite_user">>, Config1))))),
+
+ send(Config,
+ #presence{type = subscribe,
+ to = jid:make(<<"inviter">>, Server),
+ sub_els = [#preauth{token = RosterToken}]}),
+ _ = ?recv2(#iq{type = set,
+ sub_els = [#roster_query{items = [#roster_item{ask = subscribe}]}]},
+ #iq{type = set,
+ sub_els = [#roster_query{items = [#roster_item{subscription = to}]}]}),
+ ?match(false, mod_invites:is_token_valid(Server, RosterToken)),
+
+ #iq{type = set} = suite:recv_iq(Config),
+ Config11 = reconnect(Config),
+
+ #invite_token{token = Token} =
+ mod_invites:create_account_invite(Server, {<<>>, Server}, AccountName, false),
+ ?match(#iq{type = result}, send_pars(Config11, Token)),
+ ?match(#iq{type = result, sub_els = [#register{username = AccountName}]},
+ send_get_iq_register(Config11)),
+ ?match(#iq{type = result}, send_iq_register(Config11, AccountName)),
+
+ Config2 = reconnect(Config11),
+ ?match(#iq{type = error}, send_pars(Config2, Token)),
+
+ #invite_token{token = Token2} =
+ mod_invites:create_account_invite(Server,
+ {<<>>, Server},
+ <<"some_unfavorable_name">>,
+ false),
+ ?match(#iq{type = result}, send_pars(Config2, Token2)),
+ ?match(#iq{type = result, sub_els = [#register{username = <<"some_unfavorable_name">>}]},
+ send_get_iq_register(Config2)),
+ ?match(#iq{type = error}, send_iq_register(Config2, User)),
+ ?match(#iq{type = error}, send_iq_register(Config2, <<"@invalid_user">>)),
+ ?match(#iq{type = result}, send_iq_register(Config2, <<"some_much_better_name">>)),
+ ?match(#iq{type = error}, send_iq_register(Config2, <<"one_more_try">>)),
+
+ Config3 = reconnect(Config2),
+ #invite_token{token = Token3} = create_account_invite(Server, {<<>>, Server}),
+ ?match(#iq{type = result}, send_pars(Config3, Token3)),
+ ?match(#iq{type = result, sub_els = [#register{username = <<>>}]},
+ send_get_iq_register(Config3)),
+ ?match(#iq{type = result}, send_iq_register(Config3, <<"some_self_chosen_name">>)),
+
+ ejabberd_auth:remove_user(AccountName, Server),
+ ejabberd_auth:remove_user(<<"some_self_chosen_name">>, Server),
+ ejabberd_auth:remove_user(<<"some_much_better_name">>, Server),
+ update_module_opts(Server, mod_register, OldRegisterOpts),
+ mod_invites:remove_user(<<"inviter">>, Server),
+ mod_invites:expire_tokens(<<>>, Server),
+ ?match(3, mod_invites:cleanup_expired()),
+ disconnect(Config3).
+
+ibr_reserved(Config0) ->
+ Server = ?config(server, Config0),
+ Config1 = reconnect(Config0),
+ #invite_token{token = _ReservedToken} =
+ mod_invites:create_account_invite(Server, {<<>>, Server}, <<"reserved">>, false),
+ #invite_token{token = OtherToken} =
+ mod_invites:create_account_invite(Server, {<<>>, Server}, <<"some_other">>, false),
+ ?match(#iq{type = result}, send_iq_register(Config1, <<"check_registration_works">>)),
+ Config2 = reconnect(Config1),
+ ?match(#iq{type = error}, send_iq_register(Config2, <<"reserved">>)),
+ ?match(#iq{type = result}, send_pars(Config2, OtherToken)),
+ ejabberd_auth:remove_user(<<"check_registration_works">>, Server),
+ mod_invites:expire_tokens(<<>>, Server),
+ ?match(2, mod_invites:cleanup_expired()),
+ disconnect(Config2).
+
+ibr_subscription(Config0) ->
+ Server = ?config(server, Config0),
+ ServerJID = jid:from_string(Server),
+ User = ?config(user, Config0),
+ UserJID = jid:make(User, Server),
+ NewAccount = <<"new_friend">>,
+ NewAccountJID = jid:make(NewAccount, Server),
+ gen_mod:stop_module_keep_config(Server, mod_vcard_xupdate),
+ OldOpts = gen_mod:get_module_opts(Server, mod_invites),
+ NewOpts = gen_mod:set_opt(access_create_account, account_invite, OldOpts),
+ update_module_opts(Server, mod_invites, NewOpts),
+
+ self_presence(Config0, available),
+
+ #invite_token{token = Token} =
+ mod_invites:create_account_invite(Server, {User, Server}, NewAccount, true),
+
+ Config1 =
+ set_opts([{user, NewAccount},
+ {password, <<"mySecret">>},
+ {resource, <<"invite_tests">>},
+ {receiver, undefined}],
+ Config0),
+ Config = connect(Config1),
+
+ ?match(#iq{type = result}, send_pars(Config, Token)),
+ ?match(#iq{type = result}, send_iq_register(Config, NewAccount)),
+
+ open_session(bind(auth(Config))),
+
+ _ = ?recv3(#iq{type = set,
+ sub_els =
+ [#roster_query{items =
+ [#roster_item{jid = NewAccountJID,
+ subscription = from}]}]},
+ #iq{type = set,
+ sub_els =
+ [#roster_query{items =
+ [#roster_item{jid = NewAccountJID,
+ subscription = both}]}]},
+ #presence{from = NewAccountJID, type = subscribed}),
+
+ ?match(true,
+ [Friend
+ || Friend = #roster{jid = {RUser, RServer, <<>>}, subscription = both}
+ <- mod_roster:get_roster(User, Server),
+ {RUser, RServer} == {NewAccount, Server}]
+ /= []),
+ ?match(true,
+ [Friend
+ || Friend = #roster{jid = {RUser, RServer, <<>>}, subscription = both}
+ <- mod_roster:get_roster(NewAccount, Server),
+ {RUser, RServer} == {User, Server}]
+ /= []),
+ UserFullJID = jid:make(User, Server, ?config(resource, Config0)),
+ NewAccountFullJID = jid:make(NewAccount, Server, ?config(resource, Config)),
+ send(Config, #presence{}),
+
+ receive_subscription_stanzas(ServerJID, UserFullJID, NewAccountFullJID),
+
+ mod_roster:del_roster(User, Server, jid:tolower(NewAccountJID)),
+ mod_roster:del_roster(NewAccount, Server, jid:tolower(UserJID)),
+
+ update_module_opts(Server, mod_invites, OldOpts),
+ disconnect(Config0),
+ disconnect(Config),
+ ejabberd_auth:remove_user(NewAccount, Server),
+ mod_invites:remove_user(User, Server),
+ ok.
+
+receive_subscription_stanzas(ServerJID, UserFullJID, NewAccountFullJID) ->
+ Stanzas = [pres1, pres2, pres3, msg],
+ receive_subscription_stanzas(length(Stanzas),
+ Stanzas,
+ ServerJID,
+ UserFullJID,
+ NewAccountFullJID).
+
+receive_subscription_stanzas(_, {timeout, ElementsLeft}, _, _, _) ->
+ {error, {timeout, ElementsLeft}};
+receive_subscription_stanzas(0, [], _, _, _) ->
+ done;
+receive_subscription_stanzas(0, NotEmpty, _, _, _) ->
+ {error, {elements_left, NotEmpty}};
+receive_subscription_stanzas(Count,
+ Elements,
+ ServerJID,
+ UserFullJID,
+ NewAccountFullJID) ->
+ Res = receive
+ #presence{from = UserFullJID, to = NewAccountFullJID} ->
+ lists:delete(pres1, Elements);
+ #presence{from = NewAccountFullJID, to = UserFullJID} ->
+ lists:delete(pres2, Elements);
+ #presence{from = NewAccountFullJID, to = NewAccountFullJID} ->
+ lists:delete(pres3, Elements);
+ #message{from = ServerJID} ->
+ lists:delete(msg, Elements)
+ after 100 ->
+ {timeout, Elements}
+ end,
+ receive_subscription_stanzas(Count - 1, Res, ServerJID, UserFullJID, NewAccountFullJID).
+
+ibr_conflict(Config0) ->
+ Server = ?config(server, Config0),
+
+ OldRegisterOpts = gen_mod:get_module_opts(Server, mod_register),
+ NewRegisterOpts = gen_mod:set_opt(allow_modules, [mod_invites], OldRegisterOpts),
+ update_module_opts(Server, mod_register, NewRegisterOpts),
+
+ Config1 = reconnect(Config0),
+
+ #invite_token{token = Token} =
+ mod_invites:create_account_invite(Server, {<<>>, Server}, <<>>, false),
+ ?match(#iq{type = result}, send_pars(Config1, Token)),
+ Parent = self(),
+ Pid = spawn(fun() ->
+ Config11 = connect(lists:keydelete(receiver, 1, Config1)),
+ #iq{type = result} = send_pars(Config11, Token),
+ #iq{type = Type} = send_iq_register(Config11, <<"ibr_conflict_1">>),
+ Parent ! {self(), Type},
+ disconnect(Config11)
+ end),
+ receive
+ {Pid, Result} ->
+ ?match(result, Result)
+ after 1000 ->
+ ct:fail(timeout)
+ end,
+
+ ?match(#iq{type = result}, send_get_iq_register(Config1)),
+ ?match(#iq{type = error}, send_iq_register(Config1, <<"ibr_conflict_2">>)),
+ ?match(false, ejabberd_auth:user_exists(<<"ibr_conflict_2">>, Server)),
+ ?match(false, mod_invites:is_token_valid(Server, Token)),
+
+ Config2 = reconnect(Config1),
+
+ #invite_token{token = RosterToken} =
+ mod_invites:create_roster_invite(Server, {<<"inviter">>, Server}),
+ ?match(#iq{type = result}, send_pars(Config2, RosterToken)),
+
+ Pid2 =
+ spawn(fun() ->
+ Config21 = connect(lists:keydelete(receiver, 1, Config2)),
+ #iq{type = result} = send_pars(Config21, RosterToken),
+ #iq{type = Type} = send_iq_register(Config21, <<"ibr_conflict_3">>),
+ Parent ! {self(), Type},
+ disconnect(Config21)
+ end),
+ receive
+ {Pid2, Result2} ->
+ ?match(result, Result2)
+ after 1000 ->
+ ct:fail(timeout)
+ end,
+
+ ?match(#iq{type = result}, send_get_iq_register(Config2)),
+ ?match(#iq{type = error}, send_iq_register(Config2, <<"ibr_conflict_4">>)),
+ ?match(false, ejabberd_auth:user_exists(<<"ibr_conflict_4">>, Server)),
+ ?match(true, mod_invites:is_token_valid(Server, RosterToken)),
+
+ mod_invites:remove_user(<<"inviter">>, Server),
+ mod_invites:expire_tokens(<<>>, Server),
+ ?match(1, mod_invites:cleanup_expired()),
+ disconnect(Config2),
+ ok.
+
+http(Config) ->
+ Server = ?config(server, Config),
+ User = ?config(user, Config),
+ {TokenURI, LandingPage} = mod_invites:gen_invite(Server),
+ Token = token_from_uri(TokenURI),
+ {ok, {{_, 200, _}, _Headers, Body}} = httpc:request(LandingPage),
+ {match, RegistrationURLs} =
+ re:run(Body,
+ <<"href=\"", Token/binary, "([a-zA-Z0-9\/\-]+)\"">>,
+ [global, {capture, [1], binary}]),
+ Apps =
+ mod_invites_http:apps_json(Server, <<"en">>, [{static, <<"/static">>}, {uri, <<>>}]),
+ ?match(true, length(RegistrationURLs) == length(Apps) + 1),
+ BaseURL = mod_invites_http:landing_page(Server, mod_invites:get_invite(Server, Token)),
+ lists:foreach(fun([URL]) ->
+ FullURL = <>,
+ ct:pal("Checking url ~p", [FullURL]),
+ ?match({ok, {{_, 200, _}, _, _}}, httpc:request(FullURL))
+ end,
+ RegistrationURLs),
+
+ {ok, {{_, 404, _}, _, _}} = httpc:request(<>),
+ {ok, {{_, 404, _}, _, _}} = httpc:request(<>),
+ {ok, {{_, 404, _}, _, _}} = httpc:request(<>),
+
+ [Last] = hd(lists:reverse(RegistrationURLs)),
+ RegURL = <>,
+ {ok, {{_, 400, _}, _, _}} = post(RegURL, <<"badtoken">>, <<"foo">>, <<"bar">>),
+ {ok, {{_, 400, _}, _, _}} = post(RegURL, Token, User, <<"bar">>),
+ {ok, {{_, 400, _}, _, _}} = post(RegURL, Token, <<"@invalidUser">>, <<"bar">>),
+ {ok, {{_, 200, _}, _, _}} = post(RegURL, Token, <<"foo">>, <<"bar">>),
+ {ok, {{_, 404, _}, _, _}} = post(RegURL, Token, <<"foo">>, <<"bar">>),
+ {ok, {{_, 404, _}, _, _}} = httpc:request(LandingPage),
+ lists:foreach(fun([URL]) ->
+ FullURL = <>,
+ ct:pal("Checking url ~p", [FullURL]),
+ ?match({ok, {{_, 404, _}, _, _}}, httpc:request(FullURL))
+ end,
+ RegistrationURLs),
+ RosterInvite =
+ #invite_token{token = RosterToken} =
+ mod_invites:create_roster_invite(Server, {<<"inviter">>, Server}),
+ RosterURL = mod_invites_http:landing_page(Server, RosterInvite),
+ {ok, {{_, 200, _}, _, _}} = httpc:request(RosterURL),
+ FakeRegURL = <>,
+ {ok, {{_, 404, _}, _, _}} = post(FakeRegURL, RosterToken, <<"baz">>, <<"bar">>),
+ ejabberd_auth:remove_user(<<"foo">>, Server),
+ mod_invites:remove_user(<<"inviter">>, Server),
+ mod_invites:expire_tokens(<<>>, Server),
+ ?match(1, mod_invites:cleanup_expired()),
+ disconnect(Config).
+
+%%%===================================================================
+%%% Internal functions
+%%%===================================================================
+single_test(T) ->
+ list_to_atom("invites_" ++ atom_to_list(T)).
+
+token_from_uri(Uri) ->
+ {match, [Token]} =
+ re:run(Uri, ".+preauth=([a-zA-z0-9]+)", [{capture, all_but_first, binary}]),
+ Token.
+
+create_account_invite(Server, Inviter) ->
+ mod_invites:create_account_invite(Server, Inviter, <<>>, false).
+
+update_module_opts(Host, Module, Opts) ->
+ [EjabMod] = ets:lookup(ejabberd_modules, {Module, Host}),
+ ets:insert(ejabberd_modules, EjabMod#ejabberd_module{opts = Opts}).
+
+xdata_field(Var, Fields) ->
+ xdata_field(Var, Fields, undefined).
+
+xdata_field(_Var, [], Default) ->
+ Default;
+xdata_field(Var, [#xdata_field{var = Var, values = [<<>> | _]} | _], Default) ->
+ Default;
+xdata_field(Var, [#xdata_field{var = Var, values = [Result | _]} | _], _Default) ->
+ Result;
+xdata_field(Var, [_NoMatch | Fields], Default) ->
+ xdata_field(Var, Fields, Default).
+
+xdata_field_set(Var, Val, Fields) ->
+ xdata_field_set(Var, Val, Fields, []).
+
+xdata_field_set(Var, _Val, [], _Result) ->
+ throw({error, {not_found, Var}});
+xdata_field_set(Var, Val, [#xdata_field{var = Var} = Field | Fields], Result) ->
+ Result ++ [Field#xdata_field{values = [Val]} | Fields];
+xdata_field_set(Var, Val, [Field | Tail], Result) ->
+ xdata_field_set(Var, Val, Tail, Result ++ [Field]).
+
+test_create_account(Config, Username, Subscription) ->
+ Server = ?config(server, Config),
+ ServerJID = jid:from_string(Server),
+ Command = #adhoc_command{node = ?NS_INVITE_CREATE_ACCOUNT},
+ #iq{type = result,
+ sub_els =
+ [#adhoc_command{status = executing,
+ sid = SID,
+ node = ?NS_INVITE_CREATE_ACCOUNT,
+ actions = #adhoc_actions{execute = complete, complete = true},
+ xdata = #xdata{type = form, fields = XdataFields0}}]} =
+ send_recv(Config,
+ #iq{type = set,
+ to = ServerJID,
+ sub_els = [Command]}),
+ XdataFields =
+ xdata_field_set(<<"username">>,
+ Username,
+ xdata_field_set(<<"roster-subscription">>, Subscription, XdataFields0)),
+ #iq{type = result,
+ sub_els =
+ [#adhoc_command{status = completed,
+ sid = SID,
+ node = ?NS_INVITE_CREATE_ACCOUNT,
+ xdata = #xdata{type = result, fields = ResultXDataFields}}]} =
+ send_recv(Config,
+ #iq{type = set,
+ to = ServerJID,
+ sub_els =
+ [Command#adhoc_command{sid = SID,
+ xdata =
+ #xdata{type = submit,
+ fields = XdataFields}}]}),
+ ResultXDataFields.
+
+connect(Config) ->
+ process_stream_features(init_stream(Config)).
+
+reconnect(Config) ->
+ connect(disconnect(Config)).
+
+process_stream_features(Config) ->
+ receive
+ #stream_features{sub_els = Fs} ->
+ ct:pal("stream features: ~p", [Fs]),
+ lists:foldl(fun (#feature_register{}, Acc) ->
+ set_opt(register, true, Acc);
+ (#feature_register_ibr_token{}, Acc) ->
+ set_opt(register_ibr_token, true, Acc);
+ (_, Acc) ->
+ Acc
+ end,
+ set_opt(register, false, set_opt(register_ibr_token, false, Config)),
+ Fs)
+ end.
+
+send_get_iq_register(Config) ->
+ ServerJID = jid:from_string(?config(server, Config)),
+ send_recv(Config,
+ #iq{type = get,
+ to = ServerJID,
+ sub_els = [#register{}]}).
+
+send_iq_register(Config, AccountName) ->
+ ServerJID = jid:from_string(?config(server, Config)),
+ send_recv(Config,
+ #iq{type = set,
+ to = ServerJID,
+ sub_els = [#register{username = AccountName, password = <<"mySecret">>}]}).
+
+send_pars(Config, Token) ->
+ ServerJID = jid:from_string(?config(server, Config)),
+ send_recv(Config,
+ #iq{type = set,
+ to = ServerJID,
+ sub_els = [#preauth{token = Token}]}).
+
+post(URL, Token, User, Password) ->
+ Data = <<"token=", Token/binary, "&user=", User/binary, "&password=", Password/binary>>,
+ httpc:request(post, {URL, [], "application/x-www-form-urlencoded", Data}, [], []).
+
+gen_mod_set_opts(OldOpts, NewOpts) ->
+ lists:foldl(fun({Opt, Val}, Opts) -> gen_mod:set_opt(Opt, Val, Opts) end,
+ OldOpts,
+ NewOpts).
+
+re_escape(Str) ->
+ re_escape(Str, <<>>).
+
+re_escape(<<>>, Escaped) ->
+ ct:pal("escaped: ~p", [Escaped]),
+ Escaped;
+re_escape(<>, Acc) ->
+ case lists:member(C,
+ [<<".">>,
+ <<"*">>,
+ <<"+">>,
+ <<"?">>,
+ <<"^">>,
+ <<"$">>,
+ <<"(">>,
+ <<")">>,
+ <<"[">>,
+ <<"]">>,
+ <<"{">>,
+ <<"}">>,
+ <<"|">>,
+ <<";">>,
+ <<"!">>,
+ <<"`">>,
+ <<"#">>,
+ <<"~">>,
+ <<"!">>,
+ <<"_">>,
+ <<"-">>,
+ <<"=">>,
+ <<"\\">>])
+ of
+ true ->
+ re_escape(Tail, <>/binary>>);
+ false ->
+ re_escape(Tail, <>)
+ end.
diff --git a/test/offline_tests.erl b/test/offline_tests.erl
index d859da622fc..6a66b281abe 100644
--- a/test/offline_tests.erl
+++ b/test/offline_tests.erl
@@ -402,6 +402,7 @@ get_nodes(Config) ->
node = ?NS_FLEX_OFFLINE}]}),
ct:comment("Checking if headers are correct"),
lists:sort(
+ fun(A, B) -> binary_to_integer(A) =< binary_to_integer(B) end,
lists:map(
fun(#disco_item{jid = J, name = P, node = N})
when (J == MyBareJID) and (P == Peer_s) ->
diff --git a/test/suite.erl b/test/suite.erl
index 6b61296624d..706a38cec9f 100644
--- a/test/suite.erl
+++ b/test/suite.erl
@@ -791,6 +791,10 @@ is_feature_advertised(Config, Feature, To) ->
set_opt(Opt, Val, Config) ->
[{Opt, Val}|lists:keydelete(Opt, 1, Config)].
+set_opts([], Config) -> Config;
+set_opts([{Opt, Val} | Opts], Config) ->
+ set_opts(Opts, set_opt(Opt, Val, Config)).
+
wait_for_master(Config) ->
put_event(Config, peer_ready),
case get_event(Config) of
diff --git a/tools/extract-erlydtl-templates.sh b/tools/extract-erlydtl-templates.sh
new file mode 100755
index 00000000000..e348ba14bd3
--- /dev/null
+++ b/tools/extract-erlydtl-templates.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env escript
+%% -*- erlang -*-
+%%! -pz _build/default/lib/erlydtl/ebin
+
+main([Pattern, OutFile]) ->
+ Phrases = sources_parser:parse_pattern([Pattern]),
+ Msgs = lists:foldl(
+ fun(Phrase, M) ->
+ [MsgId, File, Line] = sources_parser:phrase_info([msgid, file, line], Phrase),
+ L = maps:get(MsgId, M, []),
+ M#{MsgId => [{File, Line} | L]}
+ end, #{}, Phrases),
+ {ok, Fd} = file:open(OutFile, [write]),
+ maps:foreach(
+ fun(MsgId, Places) ->
+ lists:foreach(
+ fun({File, Line}) ->
+ file:write(Fd, io_lib:format("#: ~s:~p~n", [File, Line]))
+ end, lists:reverse(Places)),
+ file:write(Fd, io_lib:format("msgid ~p~nmsgstr \"\"~n~n", [MsgId]))
+ end, Msgs),
+ file:close(Fd).
diff --git a/tools/make-packages b/tools/make-packages
index d69ce793b64..a3d566da331 100755
--- a/tools/make-packages
+++ b/tools/make-packages
@@ -193,6 +193,8 @@ make_package()
--provides 'xmpp-server' \
--no-depends \
--no-auto-depends \
+ --deb-recommends 'libjs-jquery' \
+ --deb-recommends 'libjs-bootstrap4' \
--deb-maintainerscripts-force-errorchecks \
--deb-systemd-enable \
--deb-systemd-auto-start \
diff --git a/tools/prepare-tr.sh b/tools/prepare-tr.sh
index 3c5596189a8..ae89e3ca541 100755
--- a/tools/prepare-tr.sh
+++ b/tools/prepare-tr.sh
@@ -11,6 +11,9 @@
extract_lang_src2pot ()
{
./tools/extract-tr.sh src $DEPS_DIR/xmpp/src > $PO_DIR/ejabberd.pot
+ ./tools/extract-erlydtl-templates.sh "priv/mod_invites/*.*" $PO_DIR/templates.pot
+ msgcat $PO_DIR/ejabberd.pot $PO_DIR/templates.pot > $PO_DIR/temp.pot
+ mv $PO_DIR/temp.pot $PO_DIR/ejabberd.pot
}
extract_lang_popot2po ()
@@ -55,7 +58,7 @@ extract_lang_po2msg ()
echo "%% https://docs.ejabberd.im/developer/extending-ejabberd/localization/"
echo ""
} >>$MSGS_PATH
- paste $MSGID_PATH $MSGSTR_PATH --delimiter=, | awk '{print "{" $0 "}."}' | sort -g >>$MSGS_PATH
+ paste -d , $MSGID_PATH $MSGSTR_PATH | awk '{print "{" $0 "}."}' | sort -g >>$MSGS_PATH
rm $MS_PATH
rm $MSGID_PATH