diff --git a/include/mod_invites.hrl b/include/mod_invites.hrl
new file mode 100644
index 00000000000..986ddffc96a
--- /dev/null
+++ b/include/mod_invites.hrl
@@ -0,0 +1,15 @@
+-define(INVITE_TOKEN_EXPIRE_SECONDS_DEFAULT, 5*86400).
+-define(INVITE_TOKEN_LENGTH_DEFAULT, 24).
+
+-define(NS_INVITE_INVITE, <<"urn:xmpp:invite#invite">>).
+-define(NS_INVITE_CREATE_ACCOUNT, <<"urn:xmpp:invite#create-account">>).
+
+-record(invite_token, {token :: binary(),
+ inviter :: {binary(), binary()},
+ invitee = <<>> :: binary(),
+ created_at = calendar:now_to_datetime(erlang:timestamp()) :: calendar:datetime(),
+ expires = calendar:gregorian_seconds_to_datetime(calendar:datetime_to_gregorian_seconds(calendar:now_to_datetime(erlang:timestamp()))
+ + ?INVITE_TOKEN_EXPIRE_SECONDS_DEFAULT) :: calendar:datetime(),
+ type = roster_only :: roster_only | account_only | account_subscription,
+ account_name = <<>> :: binary()
+ }).
diff --git a/mix.exs b/mix.exs
index 64031baa4a1..521808b7404 100644
--- a/mix.exs
+++ b/mix.exs
@@ -100,6 +100,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"},
@@ -112,7 +113,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 060ce0ab09e..b9c0e2fdd7b 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.1", "e19d356a1ba1e8f8cfc79ce1c3f83884b6abfcb79329d435d4bbb3e97ccc286e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "8abf0ed3e3ca87c0847dfc4168ceab5bedfe881692f1b7c45f4a11b232806865"},
"exsync": {:hex, :exsync, "0.4.1", "0a14fe4bfcb80a509d8a0856be3dd070fffe619b9ba90fec13c58b316c176594", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "cefb22aa805ec97ffc5b75a4e1dc54bcaf781e8b32564bf74abbe5803d1b5178"},
@@ -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..9d61a16853c
--- /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..8c5d03d592c
--- /dev/null
+++ b/priv/mod_invites/static/invite.js
@@ -0,0 +1,87 @@
+(function () {
+ // If QR lib loaded ok, show QR button on desktop devices
+ if(window.QRCode) {
+ new QRCode(document.getElementById("qr-invite-page"), document.location.href);
+ document.getElementById('qr-button-container').classList.add("d-md-block");
+ }
+
+ // Detect current platform and show/hide appropriate clients
+ if(window.platform) {
+ let platform_friendly = null;
+ let platform_classname = null;
+ switch(platform.os.family) {
+ case "Ubuntu":
+ case "Linux":
+ case "Fedora":
+ case "Red Hat":
+ case "SuSE":
+ platform_friendly = platform.os.family + " (Linux)";
+ platform_classname = "linux";
+ break;
+ case "Linux aarch64":
+ platform_friendly = "Linux mobile";
+ platform_classname = "linux";
+ break;
+ case "Haiku R1":
+ platform_friendly = "Haiku";
+ platform_classname = "haiku";
+ break;
+ case "Windows Phone":
+ platform_friendly = "Windows Phone";
+ platform_classname = "windows-phone";
+ break;
+ case "OS X":
+ if (navigator.maxTouchPoints > 1) {
+ // looks like iPad to me!
+ platform_friendly = "iPadOS";
+ platform_classname = "ipados";
+ } else {
+ platform_friendly = "macOS";
+ platform_classname = "macos";
+ }
+ break;
+ default:
+ if(platform.os.family.startsWith("Windows")) {
+ platform_friendly = "Windows";
+ platform_classname = "windows";
+ } else {
+ platform_friendly = platform.os.family;
+ platform_classname = platform_friendly.toLowerCase();
+ }
+ }
+
+ if(platform_friendly && platform_classname) {
+ if(document.querySelectorAll('.client-card .client-platform-badge-'+platform_classname).length == 0) {
+ // No clients recognised for this platform, do nothing
+ return;
+ }
+ // Hide clients not for this platform
+ const client_cards = document.getElementsByClassName('client-card');
+ for (let card of client_cards) {
+ if (card.classList.contains('app-platform-'+platform_classname))
+ card.classList.add('supported-platform');
+ else if (!card.classList.contains('app-platform-web'))
+ card.hidden = true;
+ const badges = card.querySelectorAll('.client-platform-badge');
+ for (let badge of badges) {
+ if (badge.classList.contains('client-platform-badge-'+platform_classname)) {
+ badge.classList.add("badge-success");
+ badge.classList.remove("badge-info");
+ } else {
+ badge.classList.add("badge-secondary");
+ badge.classList.remove("badge-info");
+ }
+ }
+ }
+ const show_all_clients_button_container = document.getElementById('show-all-clients-button-container');
+ show_all_clients_button_container.querySelector('.platform-name').innerHTML = platform_friendly;
+ show_all_clients_button_container.classList.remove("d-none");
+ document.getElementById('show-all-clients-button').addEventListener('click', function (e) {
+ for (let card of client_cards)
+ card.hidden = false;
+ show_all_clients_button_container.hidden = true;
+ e.preventDefault();
+ });
+ }
+ }
+})();
diff --git a/priv/mod_invites/static/logos/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..2ec2f64d3b2
--- /dev/null
+++ b/priv/mod_invites/static/qrcode.min.js
@@ -0,0 +1,17 @@
+/*
+The MIT License (MIT)
+---------------------
+Copyright (c) 2012 davidshimjs
+
+Permission is hereby granted, free of charge,
+to any person obtaining a copy of this software and associated documentation files (the "Software"),
+to deal in the Software without restriction,
+including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+*/
+var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push(' ');g.push(" ")}g.push("
"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}();
diff --git a/priv/msgs/de.msg b/priv/msgs/de.msg
index 7247d5f55b0..f0fdff22278 100644
--- a/priv/msgs/de.msg
+++ b/priv/msgs/de.msg
@@ -5,21 +5,39 @@
{" (Add * to the end of field to match substring)"," (Fügen Sie * am Ende des Feldes hinzu um nach Teilzeichenketten zu suchen)"}.
{" has set the subject to: "," hat das Thema geändert auf: "}.
+{"{{ app_name }} already installed?","{{ app_name }} bereits installiert?"}.
+{"{{ inviter }} has invited you to connect!","{{ inviter }} will sich mit dir verbinden!"}.
{"# participants","# Teilnehmer"}.
+{"{{ site_name }} is part of XMPP, a secure and decentralized messaging network. To begin chatting using {{ app_name }} you need to first register an account.","{{ site_name }} ist Teil von XMPP, ein sicheres und dezentrales Sofortnachrichten-Netzwerk. Um mittels {{ app_name }} chatten zu können musst du zunächst ein Konto anlegen."}.
+{"{{ site_name }} is part of XMPP, a secure and decentralized messaging network. To begin chatting you need to first register an account.","{{ site_name }} is Teil von XMPP, ein sicheres und dezentrales Sofortnachrichten-Netzwerk. Um chatten zu können musst du zunächst ein Konto anlegen."}.
+{"No suitable software installed right now? You can also log in to your account through our online web chat!","Du hast keine passende Software zur Hand im Moment? Du kannst dich auch mit unserem online Webchat anmelden!"}.
+{"Tip: You can open this invite on your mobile device by scanning a barcode with your camera.","Tipp: du kannst diese Einladung auf deinem mobilen Endgerät öffnen indem du einen Barcode mit deiner Kamera scannst."}.
+{"~s invites you to the room ~s","~s lädt Sie in den Raum ~s ein"}.
+{"~ts's MAM Archive","~ts's MAM Archiv"}.
+{"~ts's Offline Messages Queue","Offline-Nachrichten-Warteschlange von ~ts"}.
{"A description of the node","Eine Beschreibung des Knotens"}.
{"A friendly name for the node","Ein benutzerfreundlicher Name für den Knoten"}.
+{"A fully-featured desktop chat client for Windows and Linux.","Ein funktionsreicher Desktop-Client für Windows und Linux."}.
+{"A lean Jabber/XMPP client for Android. It aims at usability, low overhead and security, and works on low-end Android devices starting with Android 4.0.","Ein schlanker Jabber/XMPP-Client für Android mit Fokus Benutzerfreundlichkeit und Sicherheit. Er funktioniert auch mit langsameren Android-Geräten mit Android 4.0 oder neuer."}.
+{"A lightweight and powerful XMPP client for iPhone and iPad. It provides an easy way to talk and share moments with your friends.","Ein schlanker aber funktionsreicher XMPP-Client für iPhone und iPad. Ein einfacher Weg um mit Deinen Freunden zu kommunizieren und Erinnerungen zu teilen."}.
+{"A modern open-source chat client for iPhone and iPad. It is easy to use and has a clean user interface.","Ein moderner open-source Chatclient für iPhone und iPad mit einfacher und übersichtlicher Benutzeroberfläche."}.
+{"A modern open-source chat client for Mac. It is easy to use and has a clean user interface.","Ein moderner open-source Chatclient für Mac mit einfacher und übersichtlicher Benutzeroberfläche."}.
+{"A modern open-source chat client for the desktop. It focuses on providing a clean and reliable Jabber/XMPP experience while having your privacy in mind.","Ein moderner open-source Jabber/XMPP Chatclient für den Desktop mit Fokus auf Einfachheit und Zuverlässigkeit, aber auch Schutz deiner Privatsphäre."}.
{"A password is required to enter this room","Ein Passwort ist erforderlich um diesen Raum zu betreten"}.
{"A Web Page","Eine Webseite"}.
+{"Accept invite using {{ app_name }}","Einladung mittels {{ app_name }} annehmen!"}.
{"Accept","Akzeptieren"}.
{"Access denied by service policy","Zugriff aufgrund der Dienstrichtlinien verweigert"}.
{"Access model","Zugriffsmodell"}.
{"Account doesn't exist","Konto existiert nicht"}.
{"Action on user","Aktion auf Benutzer"}.
-{"Add a hat to a user","Funktion zu einem Benutzer hinzufügen"}.
+{"Add {{ inviter }} to your contact list","Füge {{ inviter }} zu deiner Kontaktliste hinzu"}.
{"Add User","Benutzer hinzufügen"}.
{"Administration of ","Administration von "}.
{"Administration","Verwaltung"}.
{"Administrator privileges required","Administratorrechte erforderlich"}.
+{"After clicking the button you will be taken to {{ app_name }} to finish setting up your new {{ site_name }} account.","Nach dem Drücken des Buttons wirst du nach {{ app_name }} umgeleitet um dort die Einrichtung deines neuen Kontos auf {{ site_name }} fertigzustellen."}.
+{"After successfully installing {{ app_name }}, come back to this page and continue with Step 2 .","Nachdem du {{ app_name }} erfolgreich installiert hast, komme zu dieser Seite zurück und fahre mit Schritt 2 fort !"}.
{"All activity","Alle Aktivitäten"}.
{"All Users","Alle Benutzer"}.
{"Allow subscription","Abonnement erlauben"}.
@@ -49,6 +67,7 @@
{"API Commands","API Befehle"}.
{"April","April"}.
{"Arguments","Argumente"}.
+{"As a final reminder, your account details are shown below:","Zur Erinnerung hier nochmal deine Kontodetails:"}.
{"Attribute 'channel' is required for this request","Attribut 'channel' ist für diese Anforderung erforderlich"}.
{"Attribute 'id' is mandatory for MIX messages","Attribut 'id' ist verpflichtend für MIX-Nachrichten"}.
{"Attribute 'jid' is not allowed here","Attribut 'jid' ist hier nicht erlaubt"}.
@@ -61,6 +80,7 @@
{"Backup to File at ","Backup in Datei bei "}.
{"Backup","Backup"}.
{"Bad format","Ungültiges Format"}.
+{"Beagle IM by Tigase, Inc. is a lightweight and powerful XMPP client for macOS.","Beagle IM von Tigase Inc. ist eine schlanker aber funktionsreicher XMPP-Client für macOS."}.
{"Birthday","Geburtsdatum"}.
{"Both the username and the resource are required","Sowohl der Benutzername als auch die Ressource sind erforderlich"}.
{"Bytestream already activated","Bytestream bereits aktiviert"}.
@@ -77,6 +97,7 @@
{"Channel JID","Kanal-JID"}.
{"Channels","Kanäle"}.
{"Characters not allowed:","Nicht erlaubte Zeichen:"}.
+{"Chat address (JID)","Chat-Adresse (JID)"}.
{"Chatroom configuration modified","Chatraum-Konfiguration geändert"}.
{"Chatroom is created","Chatraum ist erstellt"}.
{"Chatroom is destroyed","Chatraum ist entfernt"}.
@@ -84,16 +105,24 @@
{"Chatroom is stopped","Chatraum ist beendet"}.
{"Chatrooms","Chaträume"}.
{"Choose a username and password to register with this server","Wählen Sie zum Registrieren auf diesem Server einen Benutzernamen und ein Passwort"}.
+{"Choose a username, this will become the first part of your new chat address.","Wähle eine Benutzernamen! Dies wird zum ersten Teil deiner neuen Chat-Adresse."}.
{"Choose storage type of tables","Wähle Speichertyp der Tabellen"}.
{"Choose whether to approve this entity's subscription.","Wählen Sie, ob das Abonnement dieser Entität genehmigt werden soll."}.
{"City","Stadt"}.
+{"Click the button to open the Dino website where you can download and install it on your PC.","Klicke auf den Button um Dino's Webseite zu öffnen, wo du ihn für deinen PC herunterladen und installieren kannst."}.
{"Client acknowledged more stanzas than sent by server","Client bestätigte mehr Stanzas als vom Server gesendet"}.
+{"Close","Schließen"}.
{"Commands","Befehle"}.
{"Conference room does not exist","Konferenzraum existiert nicht"}.
{"Configuration of room ~s","Konfiguration des Raumes ~s"}.
{"Configuration","Konfiguration"}.
+{"Congratulations!","Gratuliere!"}.
{"Contact Addresses (normally, room owner or owners)","Kontaktadresse (normalerweise Raumbesitzer)"}.
+{"Conversations is a Jabber/XMPP client for Android 6.0+ smartphones that has been optimized to provide a unique mobile experience.","Conversations ist ein Jabber/XMPP-Client für Android 6.0+, der für mobile Endgeräte optimiert wurde."}.
{"Country","Land"}.
+{"Create an account","Konto anlegen"}.
+{"Creating an account will allow to communicate with {{ inviter }} and other people on {{ site_name }} and other services on the XMPP network.","Ein Konto erlaubt es dir mit {{ inviter }} und anderen Leuten auf {{ site_name }} und anderen Diensten des XMPP-Netwerkes zu kommunizieren."}.
+{"Creating an account will allow to communicate with other people on {{ site_name }} and other services on the XMPP network.","Ein Konto erlaubt es dir mit anderen Leuten auf {{ site_name }} und anderen Diensten des XMPP-Netwerkes zu kommunizieren."}.
{"Current Discussion Topic","Aktuelles Diskussionsthema"}.
{"Database failure","Datenbankfehler"}.
{"Database Tables Configuration at ","Datenbanktabellen-Konfiguration bei "}.
@@ -107,6 +136,11 @@
{"Deliver payloads with event notifications","Nutzdaten mit Ereignisbenachrichtigungen zustellen"}.
{"Disc only copy","Nur auf Festplatte"}.
{"Don't tell your password to anybody, not even the administrators of the XMPP server.","Geben Sie niemandem Ihr Passwort, auch nicht den Administratoren des XMPP-Servers."}.
+{"Download and install {{ app_name }} below:","Herunterladen und Installieren von {{ app_name }}:"}.
+{"Download Dino for Linux","Dino für Linux herunterladen"}.
+{"Download from Mac App Store","Vom Mac App Store herunterladen"}.
+{"Download Gajim","Gajim herunterladen"}.
+{"Download Renga for Haiku","Renga für Haiku herunterladen"}.
{"Dump Backup to Text File at ","Gib Backup in Textdatei aus bei "}.
{"Dump to Text File","Ausgabe in Textdatei"}.
{"Duplicated groups are not allowed by RFC6121","Doppelte Gruppen sind laut RFC6121 nicht erlaubt"}.
@@ -128,6 +162,7 @@
{"Enable message archiving","Nachrichtenarchivierung aktivieren"}.
{"Enabling push without 'node' attribute is not supported","push ohne 'node'-Attribut zu aktivieren wird nicht unterstützt"}.
{"End User Session","Benutzersitzung beenden"}.
+{"Enter a secure password that you do not use anywhere else.","Gib bitte ein sicheres Passwort ein, das du nirgends sonst verwendest!"}.
{"Enter nickname you want to register","Geben Sie den Spitznamen ein den Sie registrieren wollen"}.
{"Enter path to backup file","Geben Sie den Pfad zur Backupdatei ein"}.
{"Enter path to jabberd14 spool dir","Geben Sie den Pfad zum jabberd14-Spoolverzeichnis ein"}.
@@ -161,6 +196,7 @@
{"Get Number of Online Users","Anzahl der angemeldeten Benutzer abrufen"}.
{"Get Number of Registered Users","Anzahl der registrierten Benutzer abrufen"}.
{"Get Pending","Ausstehende abrufen"}.
+{"Get started","Leg los!"}.
{"Get User Last Login Time","letzte Anmeldezeit des Benutzers abrufen"}.
{"Get User Statistics","Benutzerstatistiken abrufen"}.
{"Given Name","Vorname"}.
@@ -174,9 +210,12 @@
{"Hat title","Funktionstitel"}.
{"Hat URI","Funktions-URI"}.
{"Hats limit exceeded","Funktionslimit wurde überschritten"}.
+{"Hide","Verbergen"}.
{"Host unknown","Host unbekannt"}.
{"HTTP File Upload","HTTP-Dateiupload"}.
{"Idle connection","Inaktive Verbindung"}.
+{"If you already have {{ app_name }} installed, we recommend that you continue the account creation process using the app by clicking on the button below:","Solltest du {{ app_name }} bereits installiert haben, empfehlen wir dir die Einrichtung des Kontos mittels dieser App durchzuführen indem du auf den Button unten klickst:"}.
+{"If you don't have an XMPP client installed yet, here's a list of suitable clients for your platform.","Solltest du noch keinen XMPP-Client installiert haben, haben wir hier eine Liste geegineter Clients für deine Platform."}.
{"If you don't see the CAPTCHA image here, visit the web page.","Wenn Sie das CAPTCHA-Bild nicht sehen, besuchen Sie die Webseite."}.
{"Import Directory","Verzeichnis importieren"}.
{"Import File","Datei importieren"}.
@@ -194,14 +233,17 @@
{"Incorrect value of 'action' attribute","Falscher Wert des 'action'-Attributs"}.
{"Incorrect value of 'action' in data form","Falscher Wert von 'action' in Datenformular"}.
{"Incorrect value of 'path' in data form","Falscher Wert von 'path' in Datenformular"}.
-{"Installed Modules:","Installierte Module:"}.
{"Install","Installieren"}.
+{"Installed Modules:","Installierte Module:"}.
+{"Installed ok? Great! Click or tap the button below to accept your invite and continue with your account setup:","Fertig mit der Installation? Prima! Drück auf den Button unten um deine Einaldung anzunehmen und mit der Einrichtung deines Kontos fortzufahren:"}.
{"Insufficient privilege","Unzureichende Privilegien"}.
{"Internal server error","Interner Serverfehler"}.
{"Invalid 'from' attribute in forwarded message","Ungültiges 'from'-Attribut in weitergeleiteter Nachricht"}.
-{"Invalid node name","Ungültiger Knotenname"}.
{"Invalid 'previd' value","Ungültiger 'previd'-Wert"}.
+{"Invalid node name","Ungültiger Knotenname"}.
{"Invitations are not allowed in this conference","Einladungen sind in dieser Konferenz nicht erlaubt"}.
+{"Invite expired","Die Einladung ist abgelaufen"}.
+{"Invite to {{ site_name }}","Einladung für {{ site_name }}"}.
{"IP addresses","IP-Adressen"}.
{"is now known as","ist nun bekannt als"}.
{"It is not allowed to send error messages to the room. The participant (~s) has sent an error message (~s) and got kicked from the room","Es ist nicht erlaubt Fehlermeldungen an den Raum zu senden. Der Teilnehmer (~s) hat eine Fehlermeldung (~s) gesendet und wurde aus dem Raum geworfen"}.
@@ -211,6 +253,7 @@
{"January","Januar"}.
{"JID normalization denied by service policy","JID-Normalisierung aufgrund der Dienstrichtlinien verweigert"}.
{"JID normalization failed","JID-Normalisierung fehlgeschlagen"}.
+{"Join {{ site_name }} with {{ app_name }}","Konto auf {{ site_name }} mittels {{ app_name }} anlegen"}.
{"Joined MIX channels of ~ts","Beigetretene MIX-Channels von ~ts"}.
{"Joined MIX channels:","Beigetretene MIX-Channels:"}.
{"joins the room","betritt den Raum"}.
@@ -222,10 +265,12 @@
{"Last message","Letzte Nachricht"}.
{"Last month","Letzter Monat"}.
{"Last year","Letztes Jahr"}.
+{"Launch {{ app_name }} and sign in using your account credentials.","Starte {{ app_name }} und logge dich deinen Anmeldedaten ein."}.
+{"Launch Beagle IM, and select 'Yes' to add a new account. Click the '+' button under the empty account list and then enter your credentials.","Starte Beagle IM und wähle 'Yes' um ein neues Konto hinzuzufügen. Drücke auf das '+' unter der leeren Account-Liste und gib dann deine Anmeldedaten ein!"}.
{"Least significant bits of SHA-256 hash of text should equal hexadecimal label","Niederwertigstes Bit des SHA-256-Hashes des Textes sollte hexadezimalem Label gleichen"}.
{"leaves the room","verlässt den Raum"}.
-{"List of users with hats","Liste der Benutzer mit Funktionen"}.
{"List users with hats","Benutzer mit Funktionen auflisten"}.
+{"Log in via web","Via Web anmelden"}.
{"Logged Out","Abgemeldet"}.
{"Logging","Protokollierung"}.
{"Make participants list public","Teilnehmerliste öffentlich machen"}.
@@ -260,9 +305,9 @@
{"Moderators Only","nur Moderatoren"}.
{"Module failed to handle the query","Modul konnte die Anfrage nicht verarbeiten"}.
{"Monday","Montag"}.
+{"Multi-User Chat","Mehrbenutzer-Chat (MUC)"}.
{"Multicast","Multicast"}.
{"Multiple elements are not allowed by RFC6121","Mehrere -Elemente sind laut RFC6121 nicht erlaubt"}.
-{"Multi-User Chat","Mehrbenutzer-Chat (MUC)"}.
{"Name","Vorname"}.
{"Natural Language for Room Discussions","Natürliche Sprache für Raumdiskussionen"}.
{"Natural-Language Room Name","Raumname in natürlicher Sprache"}.
@@ -270,43 +315,43 @@
{"Neither 'role' nor 'affiliation' attribute found","Weder 'role'- noch 'affiliation'-Attribut gefunden"}.
{"Never","Nie"}.
{"New Password:","Neues Passwort:"}.
+{"Nickname ~s does not exist in the room","Der Spitzname ~s existiert nicht im Raum"}.
{"Nickname can't be empty","Spitzname darf nicht leer sein"}.
{"Nickname Registration at ","Registrieren des Spitznamens auf "}.
-{"Nickname ~s does not exist in the room","Der Spitzname ~s existiert nicht im Raum"}.
{"Nickname","Spitzname"}.
+{"No 'affiliation' attribute found","Kein 'affiliation'-Attribut gefunden"}.
+{"No 'item' element found","Kein 'item'-Element gefunden"}.
+{"No 'password' found in data form","Kein 'password' im Datenformular gefunden"}.
+{"No 'password' found in this query","Kein 'password' in dieser Anfrage gefunden"}.
+{"No 'path' found in data form","Kein 'path' im Datenformular gefunden"}.
+{"No 'to' attribute found in the invitation","Kein 'to'-Attribut in der Einladung gefunden"}.
+{"No element found","Kein -Element gefunden"}.
{"No address elements found","Keine 'address'-Elemente gefunden"}.
{"No addresses element found","Kein 'addresses'-Element gefunden"}.
-{"No 'affiliation' attribute found","Kein 'affiliation'-Attribut gefunden"}.
{"No available resource found","Keine verfügbare Ressource gefunden"}.
{"No body provided for announce message","Kein Text für die Ankündigungsnachricht angegeben"}.
{"No child elements found","Keine 'child'-Elemente gefunden"}.
{"No data form found","Kein Datenformular gefunden"}.
{"No Data","Keine Daten"}.
{"No features available","Keine Eigenschaften verfügbar"}.
-{"No element found","Kein -Element gefunden"}.
{"No hook has processed this command","Kein Hook hat diesen Befehl verarbeitet"}.
{"No info about last activity found","Keine Informationen über letzte Aktivität gefunden"}.
-{"No 'item' element found","Kein 'item'-Element gefunden"}.
{"No items found in this query","Keine Items in dieser Anfrage gefunden"}.
{"No limit","Keine Begrenzung"}.
{"No module is handling this query","Kein Modul verarbeitet diese Anfrage"}.
{"No node specified","Kein Knoten angegeben"}.
-{"No 'password' found in data form","Kein 'password' im Datenformular gefunden"}.
-{"No 'password' found in this query","Kein 'password' in dieser Anfrage gefunden"}.
-{"No 'path' found in data form","Kein 'path' im Datenformular gefunden"}.
{"No pending subscriptions found","Keine ausstehenden Abonnements gefunden"}.
{"No privacy list with this name found","Keine Privacy-Liste mit diesem Namen gefunden"}.
{"No private data found in this query","Keine privaten Daten in dieser Anfrage gefunden"}.
{"No running node found","Kein laufender Knoten gefunden"}.
{"No services available","Keine Dienste verfügbar"}.
{"No statistics found for this item","Keine Statistiken für dieses Item gefunden"}.
-{"No 'to' attribute found in the invitation","Kein 'to'-Attribut in der Einladung gefunden"}.
{"Nobody","Niemand"}.
+{"Node ~p","Knoten ~p"}.
{"Node already exists","Knoten existiert bereits"}.
{"Node ID","Knoten-ID"}.
{"Node index not found","Knotenindex nicht gefunden"}.
{"Node not found","Knoten nicht gefunden"}.
-{"Node ~p","Knoten ~p"}.
{"Node","Knoten"}.
{"Nodeprep has failed","Nodeprep fehlgeschlagen"}.
{"Nodes","Knoten"}.
@@ -332,10 +377,10 @@
{"Old Password:","Altes Passwort:"}.
{"Online Users","Angemeldete Benutzer"}.
{"Online","Angemeldet"}.
-{"Only collection node owners may associate leaf nodes with the collection","Nur Sammlungsknoten-Besitzer dürfen Blattknoten mit der Sammlung verknüpfen"}.
-{"Only deliver notifications to available users","Benachrichtigungen nur an verfügbare Benutzer schicken"}.
{"Only or tags are allowed","Nur - oder -Tags sind erlaubt"}.
{"Only
element is allowed in this query","Nur
-Elemente sind in dieser Anfrage erlaubt"}.
+{"Only collection node owners may associate leaf nodes with the collection","Nur Sammlungsknoten-Besitzer dürfen Blattknoten mit der Sammlung verknüpfen"}.
+{"Only deliver notifications to available users","Benachrichtigungen nur an verfügbare Benutzer schicken"}.
{"Only members may query archives of this room","Nur Mitglieder dürfen den Verlauf dieses Raumes abrufen"}.
{"Only moderators and participants are allowed to change the subject in this room","Nur Moderatoren und Teilnehmer dürfen das Thema in diesem Raum ändern"}.
{"Only moderators are allowed to change the subject in this room","Nur Moderatoren dürfen das Thema in diesem Raum ändern"}.
@@ -347,18 +392,20 @@
{"Only service administrators are allowed to send service messages","Nur Service-Administratoren dürfen Servicenachrichten senden"}.
{"Only those on a whitelist may associate leaf nodes with the collection","Nur jemand auf einer Whitelist darf Blattknoten mit der Sammlung verknüpfen"}.
{"Only those on a whitelist may subscribe and retrieve items","Nur jemand auf einer Whitelist darf Items abonnieren und abrufen"}.
+{"Open the app","App öffnen"}.
{"Organization Name","Name der Organisation"}.
{"Organization Unit","Abteilung"}.
{"Other Modules Available:","Andere Module verfügbar:"}.
+{"Other software","Andere Software"}.
{"Outgoing s2s Connections","Ausgehende s2s-Verbindungen"}.
{"Owner privileges required","Besitzerrechte erforderlich"}.
{"Packet relay is denied by service policy","Paket-Relay aufgrund der Dienstrichtlinien verweigert"}.
{"Participant ID","Teilnehmer-ID"}.
{"Participant","Teilnehmer"}.
-{"Password Verification","Passwort bestätigen"}.
{"Password Verification:","Passwort bestätigen:"}.
-{"Password","Passwort"}.
+{"Password Verification","Passwort bestätigen"}.
{"Password:","Passwort:"}.
+{"Password","Passwort"}.
{"Path to Dir","Pfad zum Verzeichnis"}.
{"Path to File","Pfad zur Datei"}.
{"Period: ","Zeitraum: "}.
@@ -383,6 +430,7 @@
{"PubSub subscriber request","PubSub-Abonnenten-Anforderung"}.
{"Purge all items when the relevant publisher goes offline","Alle Items löschen, wenn der relevante Veröffentlicher offline geht"}.
{"Push record not found","Push-Eintrag nicht gefunden"}.
+{"QR code icon","QR-Code Icon"}.
{"Queries to the conference members are not allowed in this room","Anfragen an die Konferenzteilnehmer sind in diesem Raum nicht erlaubt"}.
{"Query to another users is forbidden","Anfrage an andere Benutzer ist verboten"}.
{"RAM and disc copy","RAM und Festplatte"}.
@@ -394,7 +442,9 @@
{"Receive notification of new nodes only","Benachrichtigung nur von neuen Knoten erhalten"}.
{"Recipient is not in the conference room","Empfänger ist nicht im Konferenzraum"}.
{"Register an XMPP account","Ein XMPP-Konto registrieren"}.
+{"Register on {{ site_name }}","Registriere dich auf {{ site_name }}"}.
{"Register","Anmelden"}.
+{"Registration error","Fehler beim Registrieren"}.
{"Remote copy","Fernkopie"}.
{"Remove a hat from a user","Eine Funktion bei einem Benutzer entfernen"}.
{"Remove User","Benutzer löschen"}.
@@ -422,17 +472,21 @@
{"Roster groups allowed to subscribe","Kontaktlistengruppen die abonnieren dürfen"}.
{"Roster size","Kontaktlistengröße"}.
{"Running Nodes","Laufende Knoten"}.
-{"~s invites you to the room ~s","~s lädt Sie in den Raum ~s ein"}.
+{"Sad person holding empty box","Eine traurige Person mit einer leeren Schachtel"}.
{"Saturday","Samstag"}.
+{"Scan invite code","Einladungscode einscannen"}.
+{"Scan with mobile device","Mit Mobilgerät einscannen"}.
{"Search from the date","Suche ab Datum"}.
{"Search Results for ","Suchergebnisse für "}.
{"Search the text","Text durchsuchen"}.
{"Search until the date","Suche bis Datum"}.
{"Search users in ","Suche Benutzer in "}.
+{"Select","Auswählen"}.
{"Send announcement to all online users on all hosts","Ankündigung an alle angemeldeten Benutzer auf allen Hosts senden"}.
{"Send announcement to all online users","Ankündigung an alle angemeldeten Benutzer senden"}.
{"Send announcement to all users on all hosts","Ankündigung an alle Benutzer auf allen Hosts senden"}.
{"Send announcement to all users","Ankündigung an alle Benutzer senden"}.
+{"Send this invite to your device","Sende diese Einladung auf dein Gerät"}.
{"September","September"}.
{"Server:","Server:"}.
{"Service list retrieval timed out","Zeitüberschreitung bei Abfrage der Serviceliste"}.
@@ -442,9 +496,13 @@
{"Shared Roster Groups","Gruppen der gemeinsamen Kontaktliste"}.
{"Show Integral Table","Integral-Tabelle anzeigen"}.
{"Show Ordinary Table","Gewöhnliche Tabelle anzeigen"}.
+{"Show","Anzeigen"}.
+{"Showing apps for your current platform only. You may also view all apps. ","Wir zeigen dir nur Apps für deine aktuelle Platform an. Du kannst dir gerne auch sämtliche Apps anzeigen lassen ."}.
{"Shut Down Service","Dienst herunterfahren"}.
{"SOCKS5 Bytestreams","SOCKS5-Bytestreams"}.
{"Some XMPP clients can store your password in the computer, but you should do this only in your personal computer for safety reasons.","Einige XMPP-Clients speichern Ihr Passwort auf dem Computer. Aus Sicherheitsgründen sollten Sie das nur auf Ihrem persönlichen Computer tun."}.
+{"Sorry, it looks like this invite code has expired!","Entschuldigung, es sieht so aus als wäre diese Einladung abgelaufen!"}.
+{"Sorry, there was a problem registering your account.","Es trat leider ein Fehler beim Erstellen des Kontos auf."}.
{"Sources Specs:","Quellenspezifikationen:"}.
{"Specify the access model","Geben Sie das Zugangsmodell an"}.
{"Specify the event message type","Geben Sie den Ereignisnachrichtentyp an"}.
@@ -452,12 +510,17 @@
{"Stanza id is not valid","Stanza-ID ist ungültig"}.
{"Stanza ID","Stanza-ID"}.
{"Statically specify a replyto of the node owner(s)","Ein 'replyto' des/der Nodebesitzer(s) statisch angeben"}.
+{"Step 1: Download and install {{ app_name }}","Schritt 1: {{ app_name }} herunterladen und installieren"}.
+{"Step 1: Install {{ app_name }}","Schritt 1: Installiere {{ app_name }}"}.
+{"Step 2: Activate your account","Konto aktivieren"}.
+{"Step 2: Connect {{ app_name }} to your new account","Schritt 2: Verbinde {{ app_name }} mit deinem neuen Konto"}.
{"Stopped Nodes","Angehaltene Knoten"}.
{"Store binary backup:","Speichere binäres Backup:"}.
{"Store plain text backup:","Speichere Klartext-Backup:"}.
{"Stream management is already enabled","Stream-Verwaltung ist bereits aktiviert"}.
{"Stream management is not enabled","Stream-Verwaltung ist nicht aktiviert"}.
{"Subject","Betreff"}.
+{"Submit","Senden"}.
{"Submitted","Gesendet"}.
{"Subscriber Address","Abonnenten-Adresse"}.
{"Subscribers may publish","Abonnenten dürfen veröffentlichen"}.
@@ -516,6 +579,8 @@
{"There was an error changing the password: ","Es trat ein Fehler beim Ändern des Passwortes auf: "}.
{"There was an error creating the account: ","Es trat ein Fehler beim Erstellen des Kontos auf: "}.
{"There was an error deleting the account: ","Es trat ein Fehler beim Löschen des Kontos auf: "}.
+{"This button works only if you have the app installed already!","Dieser Button funktioniert nur, wenn du die App bereits installiert hast!"}.
+{"This is an invite from {{ inviter }} to connect and chat on the XMPP network. If you already have an XMPP client installed just press the button below!","Die ist eine Kontakt-Einladung von {{ inviter }} um miteinander über XMPP zu chatten. Solltest du bereits einen XMPP-Client haben, so drücke einfach auf den Button hier unten!"}.
{"This is case insensitive: macbeth is the same that MacBeth and Macbeth.","Dies ist schreibungsunabhängig: macbeth ist gleich MacBeth und Macbeth."}.
{"This page allows to register an XMPP account in this XMPP server. Your JID (Jabber ID) will be of the form: username@server. Please read carefully the instructions to fill correctly the fields.","Diese Seite erlaubt das Anlegen eines XMPP-Kontos auf diesem XMPP-Server. Ihre JID (Jabber-ID) wird diese Form aufweisen: benutzername@server. Bitte lesen Sie die Anweisungen genau durch, um die Felder korrekt auszufüllen."}.
{"This page allows to unregister an XMPP account in this XMPP server.","Diese Seite erlaubt es, ein XMPP-Konto von diesem XMPP-Server zu entfernen."}.
@@ -524,21 +589,21 @@
{"Thursday","Donnerstag"}.
{"Time delay","Zeitverzögerung"}.
{"Timed out waiting for stream resumption","Zeitüberschreitung beim Warten auf Streamfortsetzung"}.
-{"To register, visit ~s","Um sich zu registrieren, besuchen Sie ~s"}.
{"To ~ts","An ~ts"}.
+{"To get started, you need to install an app for your platform:","Um loszulegen musst du eine App für deine Platform installieren:"}.
+{"To register, visit ~s","Um sich zu registrieren, besuchen Sie ~s"}.
+{"To start chatting, you need to enter your new account credentials into your chosen XMPP software.","Um mit dem Chatten zu beginnen, musst du deine neuen Anmeldedaten in der XMPP Software deiner Wahl eintragen."}.
{"Token TTL","Token-TTL"}.
+{"Too many (~p) failed authentications from this IP address (~s). The address will be unblocked at ~s UTC","Zu viele (~p) fehlgeschlagene Authentifizierungen von dieser IP-Adresse (~s). Die Adresse wird an ~s UTC entsperrt"}.
+{"Too many elements","Zu viele -Elemente"}.
+{"Too many
elements","Zu viele
-Elemente"}.
{"Too many active bytestreams","Zu viele aktive Bytestreams"}.
{"Too many CAPTCHA requests","Zu viele CAPTCHA-Anforderungen"}.
{"Too many child elements","Zu viele 'child'-Elemente"}.
-{"Too many elements","Zu viele -Elemente"}.
-{"Too many
elements","Zu viele
-Elemente"}.
-{"Too many (~p) failed authentications from this IP address (~s). The address will be unblocked at ~s UTC","Zu viele (~p) fehlgeschlagene Authentifizierungen von dieser IP-Adresse (~s). Die Adresse wird an ~s UTC entsperrt"}.
{"Too many receiver fields were specified","Zu viele Empfängerfelder wurden angegeben"}.
{"Too many unacked stanzas","Zu viele unbestätigte Stanzas"}.
{"Too many users in this conference","Zu viele Benutzer in dieser Konferenz"}.
{"Traffic rate limit is exceeded","Datenratenlimit wurde überschritten"}.
-{"~ts's MAM Archive","~ts's MAM Archiv"}.
-{"~ts's Offline Messages Queue","Offline-Nachrichten-Warteschlange von ~ts"}.
{"Tuesday","Dienstag"}.
{"Unable to generate a CAPTCHA","Konnte kein CAPTCHA erstellen"}.
{"Unable to register route on existing local domain","Konnte Route auf existierender lokaler Domäne nicht registrieren"}.
@@ -557,32 +622,34 @@
{"Updating the vCard is not supported by the vCard storage backend","Aktualisierung der vCard wird vom vCard-Speicher-Backend nicht unterstützt"}.
{"Upgrade","Upgrade"}.
{"URL for Archived Discussion Logs","URL für archivierte Diskussionsprotokolle"}.
-{"User already exists","Benutzer existiert bereits"}.
+{"Use a QR code scanner on your mobile device to scan the code below:","Benutze einen QR-Code Scanner auf deinem mobilen Endgerät um den Code hier unten zu scannen:"}.
{"User (jid)","Benutzer (JID)"}.
+{"User ~ts","Benutzer ~ts"}.
+{"User already exists","Benutzer existiert bereits"}.
{"User JID","Benutzer-JID"}.
{"User Management","Benutzerverwaltung"}.
{"User removed","Benutzer entfernt"}.
{"User session not found","Benutzersitzung nicht gefunden"}.
{"User session terminated","Benutzersitzung beendet"}.
-{"User ~ts","Benutzer ~ts"}.
{"User","Benutzer"}.
{"Username:","Benutzername:"}.
+{"Username","Benutzername"}.
{"Users are not allowed to register accounts so quickly","Benutzer dürfen Konten nicht so schnell registrieren"}.
{"Users Last Activity","Letzte Benutzeraktivität"}.
{"Users","Benutzer"}.
{"Value 'get' of 'type' attribute is not allowed","Wert 'get' des 'type'-Attributs ist nicht erlaubt"}.
+{"Value 'set' of 'type' attribute is not allowed","Wert 'set' des 'type'-Attributs ist nicht erlaubt"}.
{"Value of '~s' should be boolean","Wert von '~s' sollte boolesch sein"}.
{"Value of '~s' should be datetime string","Wert von '~s' sollte DateTime-Zeichenkette sein"}.
{"Value of '~s' should be integer","Wert von '~s' sollte eine Ganzzahl sein"}.
-{"Value 'set' of 'type' attribute is not allowed","Wert 'set' des 'type'-Attributs ist nicht erlaubt"}.
{"vCard User Search","vCard-Benutzer-Suche"}.
{"View joined MIX channels","Beitretene MIX-Channel ansehen"}.
{"Virtual Hosts","Virtuelle Hosts"}.
{"Visitor","Besucher"}.
{"Visitors are not allowed to change their nicknames in this room","Besucher dürfen in diesem Raum ihren Spitznamen nicht ändern"}.
{"Visitors are not allowed to send messages to all occupants","Besucher dürfen nicht an alle Teilnehmer Nachrichten versenden"}.
-{"Voice requests are disabled in this conference","Sprachrecht-Anforderungen sind in diesem Raum deaktiviert"}.
{"Voice request","Sprachrecht-Anforderung"}.
+{"Voice requests are disabled in this conference","Sprachrecht-Anforderungen sind in diesem Raum deaktiviert"}.
{"Web client which allows to join the room anonymously","Web-Client, der es ermöglicht, dem Raum anonym beizutreten"}.
{"Wednesday","Mittwoch"}.
{"When a new subscription is processed and whenever a subscriber comes online","Sobald ein neues Abonnement verarbeitet wird und wann immer ein Abonnent sich anmeldet"}.
@@ -601,6 +668,7 @@
{"Wrong parameters in the web formulary","Falsche Parameter im Webformular"}.
{"Wrong xmlns","Falscher xmlns"}.
{"XMPP Account Registration","XMPP-Konto-Registrierung"}.
+{"XMPP client for Haiku","XMPP-Client für Haiku."}.
{"XMPP Domains","XMPP-Domänen"}.
{"XMPP Show Value of Away","XMPP-Anzeigewert von Abwesend"}.
{"XMPP Show Value of Chat","XMPP-Anzeigewert von Chat"}.
@@ -610,16 +678,26 @@
{"You are being removed from the room because of a system shutdown","Sie werden wegen einer Systemabschaltung aus dem Raum entfernt"}.
{"You are not allowed to send private messages","Sie dürfen keine privaten Nachrichten senden"}.
{"You are not joined to the channel","Sie sind dem Raum nicht beigetreten"}.
+{"You can connect to {{ site_name }} using any XMPP-compatible software. If your preferred software is not listed above, you may still register an account manually .","Du kannst dich mit {{ site_name }} über jede XMPP-kompatible Software verbinden. Sollte deine gewünschte Software hier oben nicht aufgeführt sein, so kannst du zumindest einen Account manuell anlegen ."}.
{"You can later change your password using an XMPP client.","Sie können Ihr Passwort später mit einem XMPP-Client ändern."}.
+{"You can now set up {{ app_name }} and connect it to your new account.","Jetzt kannst du {{ app_name }} einrichten und mit deinem neuen Konto verknüpfen."}.
+{"You can start chatting right away with {{ app_name }}. Let's get started!","Mittels {{ app_name }} kannst du direkt mit dem Chatten loslegen. Auf geht's!"}.
+{"You can transfer this invite to your mobile device by scanning a code with your camera.","Du kannst diese Einladung auf dein Mobilgerät übertragen indem du den Code mit Deiner Kamera einscannst."}.
{"You have been banned from this room","Sie wurden aus diesem Raum verbannt"}.
+{"You have been invited to chat on {{ site_name }}, part of the XMPP secure and decentralized messaging network.","Du wurdest auf {{ site_name }} zum Chat eingeladen, Teil des sicheren und dezentralen XMPP-Sofortnachrichten-Netzwerkes."}.
+{"You have been invited to chat on {{ site_name }} , part of the XMPP secure and decentralized messaging network.","Du wurdest auf {{ site_name }} zum Chat eingeladen. {{ site_name }} ist Teil des sicheren und dezentralen XMPP-Sofortnachrichten-Netzwerkes."}.
+{"You have been invited to chat with {{ inviter }} on {{ site_name }}, part of the XMPP secure and decentralized messaging network.","Du wurdest von {{ inviter }} auf {{ site_name }} zum Chat eingeladen. {{ site_name }} ist Teil des sicheren und dezentralen XMPP-Sofortnachrichten-Netzwerkes."}.
+{"You have been invited to chat with {{ inviter }} on {{ site_name }} , part of the XMPP secure and decentralized messaging network.","Du wurdest von {{ inviter }} auf {{ site_name }} zum Chat eingeladen. {{ site_name }} ist Teil des sicheren und dezentralen XMPP-Sofortnachrichten-Netzwerkes."}.
+{"You have created an account on {{ site_name }} .","Du hast ein Konto auf {{ site_name }} angelegt."}.
{"You have joined too many conferences","Sie sind zu vielen Konferenzen beigetreten"}.
{"You must fill in field \"Nickname\" in the form","Sie müssen das Feld \"Spitzname\" im Formular ausfüllen"}.
{"You need a client that supports x:data and CAPTCHA to register","Sie benötigen einen Client der x:data und CAPTCHA unterstützt, um sich zu registrieren"}.
{"You need a client that supports x:data to register the nickname","Sie benötigen einen Client der x:data unterstützt, um Ihren Spitznamen zu registrieren"}.
{"You need an x:data capable client to search","Sie benötigen einen Client der x:data unterstützt, um zu suchen"}.
+{"You're not allowed to create nodes","Sie dürfen keine Knoten erstellen"}.
{"Your active privacy list has denied the routing of this stanza.","Ihre aktive Privacy-Liste hat das Routing dieses Stanzas verweigert."}.
{"Your contact offline message queue is full. The message has been discarded.","Die Offline-Nachrichten-Warteschlange Ihres Kontaktes ist voll. Die Nachricht wurde verworfen."}.
+{"Your password is stored encrypted on the server and will not be accessible after you close this page. Keep it safe and never share it with anyone.","Dein Passwort wird verschlüsselt auf dem Server gespeichert und wird nicht mehr im Klartext verfügbar sein nachdem du diese Seite geschlossen hast. Verwahre es an einem sicheren Ort und teile es mit niemandem!"}.
{"Your subscription request and/or messages to ~s have been blocked. To unblock your subscription request, visit ~s","Ihre Abonnement-Anforderung und/oder Nachrichten an ~s wurden blockiert. Um Ihre Abonnement-Anforderungen freizugeben, besuchen Sie ~s"}.
{"Your XMPP account was successfully registered.","Ihr XMPP-Konto wurde erfolgreich registriert."}.
{"Your XMPP account was successfully unregistered.","Ihr XMPP-Konto wurde erfolgreich entfernt."}.
-{"You're not allowed to create nodes","Sie dürfen keine Knoten erstellen"}.
diff --git a/rebar.config b/rebar.config
index 78831cb161e..e26cfd8e47a 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,
@@ -63,7 +69,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"}}}
]}.
@@ -198,7 +204,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},
@@ -212,7 +218,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 f1c7b09ecf8..0d2111ca030 100644
--- a/rebar.lock
+++ b/rebar.lock
@@ -4,7 +4,12 @@
{<<"eimp">>,{pkg,<<"eimp">>,<<"1.0.26">>},0},
{<<"epam">>,{pkg,<<"epam">>,<<"1.0.14">>},0},
{<<"eredis">>,{pkg,<<"eredis">>,<<"1.7.1">>},0},
+ {<<"erlydtl">>,{pkg,<<"erlydtl">>,<<"0.14.0">>},0},
{<<"esip">>,{pkg,<<"esip">>,<<"1.0.59">>},0},
+ {<<"erlydtl">>,
+ {git,"https://github.com/erlydtl/erlydtl.git",
+ {ref,"aae414692b6052e96d890e03bbeeeca0f4dc01c2"}},
+ 0},
{<<"ezlib">>,{pkg,<<"ezlib">>,<<"1.0.15">>},0},
{<<"fast_tls">>,{pkg,<<"fast_tls">>,<<"1.1.25">>},0},
{<<"fast_xml">>,{pkg,<<"fast_xml">>,<<"1.1.57">>},0},
@@ -26,7 +31,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}]}.
[
@@ -36,6 +41,7 @@
{<<"eimp">>, <<"C0B05F32E35629C4D9BCFB832FF879A92B0F92B19844BC7835E0A45635F2899A">>},
{<<"epam">>, <<"AA0B85D27F4EF3A756AE995179DF952A0721237E83C6B79D644347B75016681A">>},
{<<"eredis">>, <<"39E31AA02ADCD651C657F39AAFD4D31A9B2F63C6C700DC9CECE98D4BC3C897AB">>},
+ {<<"erlydtl">>, <<"964B2DC84F8C17ACFAA69C59BA129EF26AC45D2BA898C3C6AD9B5BDC8BA13CED">>},
{<<"esip">>, <<"EB202F8C62928193588091DFEDBC545FE3274C34ECD209961F86DCB6C9EBCE88">>},
{<<"ezlib">>, <<"D74F5DF191784744726A5B1AE9062522C606334F11086363385EB3B772D91357">>},
{<<"fast_tls">>, <<"DA8ED6F05A2452121B087158B17234749F36704C1F2B74DC51DB99A1E27ED5E8">>},
@@ -63,6 +69,7 @@
{<<"eimp">>, <<"D96D4E8572B9DFC40F271E47F0CB1D8849373BC98A21223268781765ED52044C">>},
{<<"epam">>, <<"2F3449E72885A72A6C2A843F561ADD0FC2F70D7A21F61456930A547473D4D989">>},
{<<"eredis">>, <<"7C2B54C566FED55FEEF3341CA79B0100A6348FD3F162184B7ED5118D258C3CC1">>},
+ {<<"erlydtl">>, <<"D80EC044CD8F58809C19D29AC5605BE09E955040911B644505E31E9DD8143431">>},
{<<"esip">>, <<"0BDF2E3C349DC0B144F173150329E675C6A51AC473D7A0B2E362245FAAD3FBE6">>},
{<<"ezlib">>, <<"DD14BA6C12521AF5CFE6923E73E3D545F4A0897DC66BFAB5287FBB7AE3962EAB">>},
{<<"fast_tls">>, <<"59E183B5740E670E02B8AA6BE673B5E7779E5FE5BFCC679FE2D4993D1949A821">>},
diff --git a/src/mod_invites.erl b/src/mod_invites.erl
new file mode 100644
index 00000000000..06b82583138
--- /dev/null
+++ b/src/mod_invites.erl
@@ -0,0 +1,789 @@
+%%%----------------------------------------------------------------------
+%%% 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, ''}).
+-xep({xep, 401, '0.5.0'}). % [TODO]
+-xep({xep, 445, ''}).
+
+-behaviour(gen_mod).
+
+-export([depends/2, mod_doc/0, mod_options/1, mod_opt_type/1, reload/3, start/2, stop/1]).
+-export([adhoc_commands/4, adhoc_items/4, c2s_unauthenticated_packet/2, cleanup_expired/0,
+ create_account_allowed/2, expire_tokens/2, gen_invite/1, gen_invite/2, get_invite/2,
+ is_reserved/3, is_token_valid/2, list_invites/1, remove_user/2, roster_add/2,
+ s2s_receive_packet/1, send_presence/3, set_invitee/3, sm_receive_packet/1,
+ stream_feature_register/2, token_uri/1, xdata_field/3]).
+
+-export([process/2]).
+
+-ifdef(TEST).
+-export([create_roster_invite/2, create_account_invite/4, is_token_valid/3, num_account_invites/2]).
+-endif.
+
+-include("logger.hrl").
+
+-include_lib("xmpp/include/xmpp.hrl").
+
+-include("ejabberd_commands.hrl").
+-include("mod_invites.hrl").
+-include("translate.hrl").
+
+-type invite_token() :: #invite_token{}.
+
+-callback cleanup_expired(Host :: binary()) -> non_neg_integer().
+-callback create_invite(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 init(Host :: binary(), gen_mod:opts()) -> any().
+-callback is_reserved(Host :: binary(), Token :: binary(), User :: binary()) -> boolean().
+-callback is_token_valid(Host :: binary(), binary(), {binary(), binary()}) -> boolean().
+-callback list_invites(Host :: binary()) -> [tuple()].
+-callback num_account_invites(User :: binary(), Server :: binary()) -> non_neg_integer().
+-callback remove_user(User :: binary(), Server :: binary()) -> any().
+-callback set_invitee(Host :: binary(), Token :: binary(), Invitee :: binary()) -> ok.
+
+%% @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("AccessRuleName"),
+ desc =>
+ ?T("This option specifies who is allowed to send 'create account' "
+ "invites. The default value is 'none', i.e. nobody is able to "
+ "create such invites.")}},
+ {db_type,
+ #{value => "mnesia | sql",
+ desc =>
+ ?T("Same as top-level _`default_db`_ option, but applied to this "
+ "module only.")}},
+ {landing_page,
+ #{value => "none | auto | LandingPageURLTemplate",
+ desc => ?T("This is the landing page that is being communicated when creating an invite using one of the ad-hoc commands, the web address of service handling invite links. This is either a local address handled by `mod_invites` configured as a handler at `ejabberd_http` or an external service like 'easy-xmpp-invitation'. The address must be given as a template pattern with fields from the `invite` that will then get replaced accordingly. Eg.: 'https://{{ host }}:5281/invites/{{ invite.token }}' or as an external service like 'http://{{ host }}:8080/easy-xmpp-invites/#{{ invite.uri|strip_protocol }}'. For convenience you can choose 'auto' here and the ejabberd_http handler will be used.")}
+ },
+ {max_invites,
+ #{value => "pos_integer() | infinity",
+ desc =>
+ ?T("Maximum number of 'create account' invites that can be created "
+ "by an individual user. Users that match the 'admin' acl are "
+ "exempt from this limitation.")}},
+ {site_name,
+ #{value => ?T("Site Name"),
+ desc => ?T("A human readable name for your site. E.g. 'My Beautiful Laundrette'")}
+ },
+ {templates_dir,
+ #{value => ?T("binary()"),
+ desc => ?T("The directory containing templates and static files used for landing page and web registration form. Only needs to be set if you want to ship your own set of templates or list of recommended apps.")}},
+ {token_expire_seconds,
+ #{value => "pos_integer()",
+ desc => ?T("Number of seconds until token expires (e.g.: '7 * 86400')")}}],
+ example =>
+ [{?T("To allow only admin users to send such invites, you would have "
+ "a config like this:"),
+ ["acl:",
+ " admin:",
+ " - user: \"my_admin_user@example.com\"",
+ "",
+ "access_rules:",
+ " register:",
+ " allow: admin",
+ "",
+ "modules:",
+ " mod_invites:",
+ " access_create_account: register"
+ " landing_page: auto"
+ ]},
+ {?T("If you want all your users to be able to send 'create account' "
+ "invites and have a proxy in front of `ejabberd_http` to not expose its port "
+ "directly, you would configure your server like this instead:"),
+ ["acl:",
+ " local:",
+ " user_regexp: \"\"",
+ "access_rules:",
+ " create_account_invite:",
+ " allow: local",
+ "",
+ "modules:",
+ " mod_invites:",
+ " access_create_account: create_account_invite"
+ " landing_page: https://yourhost/invites/{{ invite.token }}"]},
+ ?T("Note that the names of the access rules are just examples and "
+ "you're free to change them.")
+ %% TODO add example for invite page
+ ]}.
+
+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(Username, Host0) ->
+ Host = jid:nameprep(Host0),
+ case create_account_invite(Host, {<<>>, Host}, Username, false) of
+ {error, {module_not_loaded, ?MODULE, Host}} ->
+ {error, host_unknown};
+ {error, _Reason} = Error ->
+ Error;
+ Invite ->
+ {token_uri(Invite), 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]).
+
+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]).
+
+set_invitee(Host, Token, InviteeJid) ->
+ %% This invalidates the invite token
+ db_call(Host,
+ set_invitee,
+ [Host,
+ Token,
+ jid:encode(
+ jid:remove_resource(InviteeJid))]).
+
+create_roster_invite(Host, Inviter) ->
+ create_roster_invite(Host, Inviter, <<>>).
+
+create_roster_invite(Host, Inviter, AccountName) ->
+ create_invite(roster_only, Host, Inviter, AccountName).
+
+create_account_invite(Host, Inviter, AccountName, _Subscribe = true) ->
+ create_invite(account_subscription, Host, Inviter, AccountName);
+create_account_invite(Host, Inviter, AccountName, _Subcribe = false) ->
+ create_invite(account_only, Host, Inviter, AccountName).
+
+create_invite(Type, Host, Inviter, AccountName) ->
+ try invite_token(Type, Host, Inviter, AccountName) of
+ Invite ->
+ ?DEBUG("Created invite: ~p", [Invite]),
+ db_call(Host, create_invite, [Invite])
+ catch
+ _:({error, _Reason} = Error) ->
+ Error;
+ _:Error ->
+ {error, Error}
+ end.
+
+check_account_name(<<>>, _) ->
+ <<>>;
+check_account_name(error, _) ->
+ {error, account_name_invalid};
+check_account_name(_, error) ->
+ {error, hostname_invalid};
+check_account_name(AccountName, Host) ->
+ MyHosts = ejabberd_option:hosts(),
+ case lists:member(Host, MyHosts) of
+ false ->
+ {error, host_unknown};
+ true ->
+ case ejabberd_auth:user_exists(AccountName, Host) of
+ true ->
+ {error, user_exists};
+ false ->
+ AccountName
+ end
+ end.
+
+num_account_invites(User, Server) ->
+ db_call(Server, num_account_invites, [User, Server]).
+
+check_max_invites(roster_only, _, _) ->
+ ok;
+check_max_invites(_Type, Host, {User, Server}) ->
+ case {mod_invites_opt:max_invites(Host),
+ acl:match_acl(Host, {acl, admin}, #{usr => {User, Server, <<>>}})}
+ of
+ {infinity, _} ->
+ ok;
+ {_, true} ->
+ ok;
+ {MaxInvites, false} ->
+ case num_account_invites(User, Server) of
+ NumInvites when MaxInvites =< NumInvites ->
+ {error, num_invites_exceeded};
+ _AllGood ->
+ ok
+ end
+ end.
+
+maybe_throw({error, _} = Error) ->
+ throw(Error);
+maybe_throw(Good) ->
+ Good.
+
+invite_token(Type, Host, Inviter, AccountName0) ->
+ maybe_throw(check_max_invites(Type, Host, Inviter)),
+ Token = p1_rand:get_alphanum_string(?INVITE_TOKEN_LENGTH_DEFAULT),
+ AccountName =
+ 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(host_unknown) ->
+ ?T("Host unknown");
+reason_to_text(hostname_invalid) ->
+ ?T("Hostname invalid");
+reason_to_text(account_name_invalid) ->
+ ?T("Username invalid");
+reason_to_text(user_exists) ->
+ ?T("User already exists");
+reason_to_text(num_invites_exceeded) ->
+ ?T("Maximum number of invites reached").
+
+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..eacf3705064
--- /dev/null
+++ b/src/mod_invites_http.erl
@@ -0,0 +1,349 @@
+%%%----------------------------------------------------------------------
+%%% File : mod_invites_http.erl
+%%% Author : Stefan Strigler
+%%% Purpose : Provide web page(s) to sign up using an invite token.
+%%% Created : Fri Oct 31 2025 by Stefan Strigler
+%%%
+%%%
+%%% ejabberd, Copyright (C) 2025 ProcessOne
+%%%
+%%% This program is free software; you can redistribute it and/or
+%%% modify it under the terms of the GNU General Public License as
+%%% published by the Free Software Foundation; either version 2 of the
+%%% License, or (at your option) any later version.
+%%%
+%%% This program is distributed in the hope that it will be useful,
+%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
+%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+%%% General Public License for more details.
+%%%
+%%% You should have received a copy of the GNU General Public License along
+%%% with this program; if not, write to the Free Software Foundation, Inc.,
+%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+%%%
+%%%----------------------------------------------------------------------
+-module(mod_invites_http).
+
+-include("logger.hrl").
+
+-export([process/2, landing_page/2]).
+
+-ifdef(TEST).
+-export([apps_json/3]).
+-endif.
+
+-include_lib("xmpp/include/xmpp.hrl").
+
+-include("ejabberd_http.hrl").
+-include("mod_invites.hrl").
+-include("translate.hrl").
+
+-define(HTTP(Code, CT, Text), {Code, [{<<"Content-Type">>, CT}], Text}).
+-define(HTTP(Code, Text), ?HTTP(Code, <<"text/plain">>, Text)).
+-define(HTTP_OK(Text), ?HTTP(200, <<"text/html">>, Text)).
+-define(NOT_FOUND, ?HTTP(404, ?T("NOT FOUND"))).
+-define(NOT_FOUND(Text), ?HTTP(404, <<"text/html">>, Text)).
+-define(BAD_REQUEST, ?HTTP(400, ?T("BAD REQUEST"))).
+-define(BAD_REQUEST(Text), ?HTTP(400, <<"text/html">>, Text)).
+
+-define(DEFAULT_CONTENT_TYPE, <<"application/octet-stream">>).
+-define(CONTENT_TYPES,
+ [{<<".js">>, <<"application/javascript">>},
+ {<<".png">>, <<"image/png">>},
+ {<<".svg">>, <<"image/svg+xml">>}]).
+
+-define(STATIC, <<"static">>).
+-define(REGISTRATION, <<"registration">>).
+-define(STATIC_CTX, {static, <<"/", Base/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">> ->
+ case ejabberd_http:get_auto_url(any, mod_invites) of
+ undefined ->
+ ?WARNING_MSG("'auto' URL configured for mod_invites but no request_handler found in your ~s listeners configuration.", [Host]),
+ <<>>;
+ AutoURL0 ->
+ AutoURL = misc:expand_keyword(<<"@HOST@">>, AutoURL0, Host),
+ render_landing_page_url(<>, Host, Invite)
+ 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_register:try_register(Username, Host, Password, Source, mod_invites, Lang) of
+ ok ->
+ InviteeJid = jid:make(Username, Host),
+ mod_invites:set_invitee(Host, Token, InviteeJid),
+ UpdatedInvite = mod_invites:get_invite(Host, Token),
+ mod_invites_register:maybe_create_mutual_subscription(UpdatedInvite),
+ Ctx = [{username, Username},
+ {password, Password}
+ | AppCtx],
+ render_ok(Host, Lang, <<"register_success.html">>, Ctx);
+ {error, #stanza_error{text = Text, type = Type} = Error} ->
+ ?DEBUG("registration failed with error: ~p", [Error]),
+ Msg = xmpp:get_text(Text, xmpp:prep_lang(Lang)),
+ case Type of
+ T when T == 'cancel'; T == 'modify' ->
+ Body = render_register_form(Request, AppCtx,
+ [{username, Username},
+ {message, [{text, Msg},
+ {class, <<"alert-warning">>}]}]),
+ ?BAD_REQUEST(Body);
+ _ ->
+ render_bad_request(Host, <<"register_error.html">>, [{message, Msg} | ctx(Request)])
+ end
+ end
+ catch
+ _:not_found ->
+ ?NOT_FOUND;
+ _:no_match ->
+ ?BAD_REQUEST
+ end.
+
+process_roster_token([_Token], #request{host = Host, lang = Lang} = Request, Invite) ->
+ Ctx0 = ctx(Invite, Request),
+ Apps = lists:map(
+ fun(App = #{<<"download">> := #{<<"buttons">> := [Button | _]}}) ->
+ ProceedUrl = case render_app_button_url(Button, Ctx0) of
+ #{magic_link := MagicLink} ->
+ MagicLink;
+ #{<<"url">> := Url} ->
+ Url
+ end,
+ App#{proceed_url => ProceedUrl,
+ select_text => translate:translate(Lang, ?T("Install"))}
+ end, apps_json(Host, Lang, Ctx0)),
+ Ctx = [{apps, Apps} | Ctx0],
+ render_ok(Host, Lang, <<"roster.html">>, Ctx);
+process_roster_token(_, _, _) ->
+ ?NOT_FOUND.
+
+ensure_same(V, V) ->
+ ok;
+ensure_same(_, _) ->
+ throw(no_match).
+
+app_ctx(_Host, <<>>, _Lang, Ctx) ->
+ Ctx;
+app_ctx(Host, AppID, Lang, Ctx) ->
+ FilteredApps = [App || A <- apps_json(Host, Lang, Ctx), maps:get(<<"id">>, App = app_id(A)) == AppID],
+ case FilteredApps of
+ [App] ->
+ [{app, render_app_button_urls(App, Ctx)} | Ctx];
+ [] ->
+ throw(not_found)
+ end.
+
+ctx(#request{host = Host, path = [Base | _]}) ->
+ SiteName = mod_invites_opt:site_name(Host),
+ [?STATIC_CTX, ?SITE_NAME_CTX(SiteName)].
+
+ctx(Invite, #request{host = Host} = Request) ->
+ [{invite, invite_to_proplist(Invite)},
+ {uri, mod_invites:token_uri(Invite)},
+ {domain, Host},
+ {token, Invite#invite_token.token},
+ {registration_url, <<(Invite#invite_token.token)/binary, "/", ?REGISTRATION/binary>>}
+ | ctx(Request)].
+
+apps_json(Host, Lang, Ctx) ->
+ AppsBins = render(Host, Lang, <<"apps.json">>, Ctx),
+ AppsBin = 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..8d3721e1d6e
--- /dev/null
+++ b/src/mod_invites_mnesia.erl
@@ -0,0 +1,135 @@
+%%%----------------------------------------------------------------------
+%%% File : mod_invites_mnesia.erl
+%%% Author : Stefan Strigler
+%%% Created : Mon Sep 15 2025 by Stefan Strigler
+%%%
+%%%
+%%% ejabberd, Copyright (C) 2025 ProcessOne
+%%%
+%%% This program is free software; you can redistribute it and/or
+%%% modify it under the terms of the GNU General Public License as
+%%% published by the Free Software Foundation; either version 2 of the
+%%% License, or (at your option) any later version.
+%%%
+%%% This program is distributed in the hope that it will be useful,
+%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
+%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+%%% General Public License for more details.
+%%%
+%%% You should have received a copy of the GNU General Public License along
+%%% with this program; if not, write to the Free Software Foundation, Inc.,
+%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+%%%
+%%%----------------------------------------------------------------------
+-module(mod_invites_mnesia).
+
+-author('stefan@strigler.de').
+
+-behaviour(mod_invites).
+
+-export([cleanup_expired/1, create_invite/1, expire_tokens/2, get_invite/2, init/2,
+ is_reserved/3, is_token_valid/3, list_invites/1, num_account_invites/2,
+ remove_user/2, set_invitee/3]).
+
+-include("mod_invites.hrl").
+
+%% @format-begin
+
+%%--------------------------------------------------------------------
+%%| mod_invite callbacks
+
+cleanup_expired(_Host) ->
+ Ts = erlang:timestamp(),
+ lists:foldl(fun(Token, Count) ->
+ [Invite] = mnesia:dirty_read(invite_token, Token),
+ case is_expired(Invite, Ts) of
+ true ->
+ ok = mnesia:dirty_delete(invite_token, Token),
+ Count + 1;
+ false ->
+ Count
+ end
+ end,
+ 0,
+ mnesia:dirty_all_keys(invite_token)).
+
+create_invite(Invite) ->
+ ok = mnesia:dirty_write(Invite),
+ Invite.
+
+expire_tokens(User, Server) ->
+ Ts = erlang:timestamp(),
+ length([mnesia:dirty_write(I#invite_token{expires = {{1970, 1, 1}, {0, 0, 1}}})
+ || I <- mnesia:dirty_index_read(invite_token, {User, Server}, #invite_token.inviter),
+ not is_expired(I, Ts),
+ I#invite_token.type /= roster_only]).
+
+get_invite(_Host, Token) ->
+ case mnesia:dirty_read(invite_token, Token) of
+ [Invite] ->
+ Invite;
+ [] ->
+ {error, not_found}
+ end.
+
+init(_Host, _Opts) ->
+ ejabberd_mnesia:create(?MODULE,
+ invite_token,
+ [{disc_copies, [node()]},
+ {attributes, record_info(fields, invite_token)},
+ {index, [inviter]}]).
+
+is_reserved(_Host, Token, User) ->
+ Ts = erlang:timestamp(),
+ [T
+ || T <- mnesia:dirty_all_keys(invite_token),
+ not is_expired(I = hd(mnesia:dirty_read(invite_token, T)), Ts),
+ I#invite_token.token /= Token,
+ I#invite_token.invitee == <<>>,
+ I#invite_token.account_name == User]
+ =/= [].
+
+is_token_valid(Host, Token, Scope) ->
+ case mnesia:dirty_read(invite_token, Token) of
+ [Invite = #invite_token{invitee = <<>>, inviter = {_, Host} = Inviter}]
+ when Scope == Inviter; Scope == {<<>>, Host} ->
+ not is_expired(Invite, erlang:timestamp());
+ [#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].
+
+num_account_invites(User, Server) ->
+ length([I
+ || I = #invite_token{type = Type}
+ <- mnesia:dirty_index_read(invite_token, {User, Server}, #invite_token.inviter),
+ Type =/= roster_only]).
+
+remove_user(User, Server) ->
+ Inviter = {User, Server},
+ [ok = mnesia:dirty_delete(invite_token, Token)
+ || #invite_token{token = Token}
+ <- mnesia:dirty_index_read(invite_token, Inviter, #invite_token.inviter)],
+ ok.
+
+set_invitee(_Host, Token, Invitee) ->
+ Transaction =
+ fun() ->
+ [Invite] = mnesia:read(invite_token, Token),
+ mnesia:write(Invite#invite_token{invitee = Invitee})
+ end,
+ {atomic, ok} = mnesia:transaction(Transaction),
+ ok.
+
+%%--------------------------------------------------------------------
+%%| helpers
+
+is_expired(#invite_token{expires = Expires}, Now) ->
+ calendar:datetime_to_gregorian_seconds(Expires)
+ < calendar:datetime_to_gregorian_seconds(
+ calendar:now_to_universal_time(Now)).
diff --git a/src/mod_invites_opt.erl b/src/mod_invites_opt.erl
new file mode 100644
index 00000000000..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..0f8117cb615
--- /dev/null
+++ b/src/mod_invites_register.erl
@@ -0,0 +1,246 @@
+%%%----------------------------------------------------------------------
+%%% File : mod_invites_register.erl
+%%% Author : Stefan Strigler
+%%% Purpose : Provide web page(s) to sign up using an invite token.
+%%% Created : Fri Oct 31 2025 by Stefan Strigler
+%%%
+%%%
+%%% ejabberd, Copyright (C) 2025 ProcessOne
+%%%
+%%% This program is free software; you can redistribute it and/or
+%%% modify it under the terms of the GNU General Public License as
+%%% published by the Free Software Foundation; either version 2 of the
+%%% License, or (at your option) any later version.
+%%%
+%%% This program is distributed in the hope that it will be useful,
+%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
+%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+%%% General Public License for more details.
+%%%
+%%% You should have received a copy of the GNU General Public License along
+%%% with this program; if not, write to the Free Software Foundation, Inc.,
+%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+%%%
+%%%----------------------------------------------------------------------
+-module(mod_invites_register).
+
+-export([c2s_unauthenticated_packet/2, stream_feature_register/2]).
+-export([maybe_create_mutual_subscription/1]).
+
+-import(mod_invites, [roster_add/2, send_presence/3, xdata_field/3]).
+-include("logger.hrl").
+
+-include_lib("xmpp/include/xmpp.hrl").
+
+-include("ejabberd_commands.hrl").
+-include("mod_invites.hrl").
+-include("translate.hrl").
+
+-define(TRY_SUBTAG(IQ, SUBTAG, F, Else),
+ try xmpp:try_subtag(IQ, SUBTAG) of
+ false ->
+ Else();
+ SubTag ->
+ F(SubTag)
+ catch _:{xmpp_codec, Why} ->
+ Txt = xmpp:io_format_error(Why),
+ Lang = maps:get(lang, State),
+ Err = make_stripped_error(IQ, SUBTAG, xmpp:err_bad_request(Txt, Lang)),
+ {stop, ejabberd_c2s:send(State, Err)}
+ end).
+-define(TRY_SUBTAG(IQ, SUBTAG, F), ?TRY_SUBTAG(IQ, SUBTAG, F, fun() -> State end)).
+
+-spec stream_feature_register([xmpp_element()], binary()) -> [xmpp_element()].
+stream_feature_register(Acc, Host) ->
+ case mod_invites_opt:access_create_account(Host) of
+ none ->
+ Acc;
+ _ ->
+ [#feature_register_ibr_token{} | Acc]
+ end.
+
+c2s_unauthenticated_packet(#{invite := Invite} = State,
+ #iq{type = get, sub_els = [_]} = IQ) ->
+ %% User requests registration form after processing token
+ ?TRY_SUBTAG(IQ,
+ #register{},
+ fun(Register) ->
+ #{server := Server} = State,
+ IQ1 = xmpp:set_els(IQ, [Register]),
+ User = Invite#invite_token.account_name,
+ IQ2 = xmpp:set_from_to(IQ1, jid:make(User, Server), jid:make(Server)),
+ ResIQ = mod_register:process_iq(IQ2),
+ ResIQ1 = xmpp:set_from_to(ResIQ, jid:make(Server), undefined),
+ {stop, ejabberd_c2s:send(State, ResIQ1)}
+ end);
+c2s_unauthenticated_packet(#{invite := Invite, server := Server} = State,
+ #iq{type = set, sub_els = [_]} = IQ) ->
+ %% Process registration request after processing token
+ ?TRY_SUBTAG(IQ,
+ #register{},
+ fun (Register) ->
+ case check_captcha(mod_register_opt:captcha_protected(Server), Register, IQ) of
+ {ok, {Username, Password}} ->
+ Token = Invite#invite_token.token,
+ #{ip := IP} = State,
+ {Address, _} = IP,
+ case try_register(Token,
+ Username,
+ Server,
+ Password,
+ IQ,
+ Address)
+ of
+ #iq{type = result} = ResIQ ->
+ mod_invites:set_invitee(Server,
+ Invite#invite_token.token,
+ jid:make(Username, Server)),
+ NewInvite = mod_invites:get_invite(Server, Invite#invite_token.token),
+ ResState = State#{invite => NewInvite},
+ maybe_create_mutual_subscription(NewInvite),
+ {stop, ejabberd_c2s:send(ResState, ResIQ)};
+ #iq{type = error} = ResIQ ->
+ {stop, ejabberd_c2s:send(State, ResIQ)}
+ end;
+ {error, ResIQ} ->
+ {stop, ejabberd_c2s:send(State, ResIQ)}
+end
+ end);
+c2s_unauthenticated_packet(State, #iq{type = set, sub_els = [_]} = IQ) ->
+ %% Check for preauth token and process it
+ ?TRY_SUBTAG(IQ,
+ #preauth{},
+ fun(#preauth{token = Token}) ->
+ #{server := Server} = State,
+ IQ1 = xmpp:set_from_to(IQ, jid:make(<<>>), jid:make(Server)),
+ {ResState, ResIQ} = process_token(State, Token, IQ1),
+ ResIQ1 = xmpp:set_from_to(ResIQ, jid:make(Server), undefined),
+ {stop, ejabberd_c2s:send(ResState, ResIQ1)}
+ end,
+ fun() ->
+ ?TRY_SUBTAG(IQ,
+ #register{},
+ fun (#register{username = User, password = Password})
+ when is_binary(User), is_binary(Password) ->
+ #{server := Server} = State,
+ case mod_invites:is_reserved(Server, <<>>, User) of
+ true ->
+ ResIQ =
+ make_stripped_error(IQ,
+ #register{},
+ xmpp:err_not_allowed()),
+ {stop, ejabberd_c2s:send(State, ResIQ)};
+ false ->
+ State
+ end;
+ (_) ->
+ State
+ end)
+ end);
+c2s_unauthenticated_packet(State, _) ->
+ State.
+
+make_stripped_error(IQ, SubTag, Err) ->
+ xmpp:make_error(
+ xmpp:remove_subtag(IQ, SubTag), Err).
+
+maybe_create_mutual_subscription(#invite_token{inviter = {User, _Server}, type = Type})
+ when User == <<>>; % server token
+ Type /= account_subscription ->
+ noop;
+maybe_create_mutual_subscription(#invite_token{inviter = {User, Server}, invitee = Invitee}) ->
+ InviterJID = jid:make(User, Server),
+ InviteeJID = jid:decode(Invitee),
+ roster_add(InviterJID, InviteeJID),
+ roster_add(InviteeJID, InviterJID),
+ send_presence(InviteeJID, InviterJID, subscribe),
+ send_presence(InviterJID, InviteeJID, subscribed),
+ send_presence(InviterJID, InviteeJID, subscribe),
+ send_presence(InviteeJID, InviterJID, subscribed),
+ ok.
+
+process_token(#{server := Host} = State, Token, #iq{lang = Lang} = IQ) ->
+ ?DEBUG("checking token (~s): ~s", [Host, Token]),
+ try mod_invites:is_token_valid(Host, Token) of
+ true ->
+ Invite = #invite_token{inviter = {User, Host}} =
+ mod_invites:get_invite(Host, Token),
+ case create_account_allowed(User, Host) of
+ ok ->
+ NewState = State#{invite => Invite},
+ {NewState, xmpp:make_iq_result(IQ)};
+ {error, not_allowed} ->
+ {State, preauth_invalid(IQ, Lang)}
+ end;
+ false ->
+ {State, preauth_invalid(IQ, Lang)}
+ catch
+ _:not_found ->
+ {State, preauth_invalid(IQ, Lang)}
+ end.
+
+create_account_allowed(<<>>, _Host) ->
+ ok;
+create_account_allowed(User, Host) ->
+ mod_invites:create_account_allowed(Host, jid:make(User, Host)).
+
+preauth_invalid(IQ, Lang) ->
+ Text = ?T("The token provided is either invalid or expired."),
+ make_stripped_error(IQ, #preauth{}, xmpp:err_item_not_found(Text, Lang)).
+
+try_register(Token,
+ User,
+ Server,
+ Password,
+ #iq{lang = Lang} = IQ,
+ Source) ->
+ case {jid:nodeprep(User), not mod_invites:is_reserved(Server, Token, User)} of
+ {error, _} ->
+ Err = xmpp:err_jid_malformed(
+ mod_register:format_error(invalid_jid), Lang),
+ make_stripped_error(IQ, #register{}, Err);
+ {_, true} ->
+ case mod_register:try_register(User, Server, Password, Source, mod_invites, Lang) of
+ ok ->
+ xmpp:make_iq_result(IQ);
+ {error, Error} ->
+ make_stripped_error(IQ, #register{}, Error)
+ end
+ end.
+
+check_captcha(true, #register{xdata = X}, #iq{lang = Lang} = IQ) ->
+ XdataC = xmpp_util:set_xdata_field(
+ #xdata_field{
+ var = <<"FORM_TYPE">>,
+ type = hidden, values = [?NS_CAPTCHA]},
+ X),
+ case ejabberd_captcha:process_reply(XdataC) of
+ ok ->
+ case process_xdata_submit(X) of
+ {ok, _} = Result ->
+ Result;
+ _ ->
+ Txt = ?T("Incorrect data form"),
+ make_stripped_error(IQ, #register{}, xmpp:err_bad_request(Txt, Lang))
+ end;
+ {error, malformed} ->
+ Txt = ?T("Incorrect CAPTCHA submit"),
+ make_stripped_error(IQ, #register{}, xmpp:err_bad_request(Txt, Lang));
+ _ ->
+ ErrText = ?T("The CAPTCHA verification has failed"),
+ make_stripped_error(IQ, #register{}, xmpp:err_not_allowed(ErrText, Lang))
+ end;
+check_captcha(false, #register{username = Username, password = Password}, _IQ)
+ when is_binary(Username), is_binary(Password) ->
+ {ok, {Username, Password}};
+check_captcha(_IsCaptchaEnabled, _Register, IQ) ->
+ ResIQ = make_stripped_error(IQ, #register{}, xmpp:err_bad_request()),
+ {error, ResIQ}.
+
+process_xdata_submit(#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.
diff --git a/src/mod_invites_sql.erl b/src/mod_invites_sql.erl
new file mode 100644
index 00000000000..c5a071bd674
--- /dev/null
+++ b/src/mod_invites_sql.erl
@@ -0,0 +1,188 @@
+%%%----------------------------------------------------------------------
+%%% File : mod_invites_sql.erl
+%%% Author : Stefan Strigler
+%%% Created : Mon Sep 15 2025 by Stefan Strigler
+%%%
+%%%
+%%% ejabberd, Copyright (C) 2025 ProcessOne
+%%%
+%%% This program is free software; you can redistribute it and/or
+%%% modify it under the terms of the GNU General Public License as
+%%% published by the Free Software Foundation; either version 2 of the
+%%% License, or (at your option) any later version.
+%%%
+%%% This program is distributed in the hope that it will be useful,
+%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
+%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+%%% General Public License for more details.
+%%%
+%%% You should have received a copy of the GNU General Public License along
+%%% with this program; if not, write to the Free Software Foundation, Inc.,
+%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+%%%
+%%%----------------------------------------------------------------------
+-module(mod_invites_sql).
+
+-author('stefan@strigler.de').
+
+-behaviour(mod_invites).
+
+-export([cleanup_expired/1, create_invite/1, expire_tokens/2, get_invite/2, init/2, is_reserved/3,
+ is_token_valid/3, list_invites/1, num_account_invites/2, remove_user/2, set_invitee/3]).
+
+-include("mod_invites.hrl").
+-include("ejabberd_sql_pt.hrl").
+
+%% @format-begin
+
+%%--------------------------------------------------------------------
+%%| mod_invite callbacks
+init(Host, _Opts) ->
+ ejabberd_sql_schema:update_schema(Host, ?MODULE, sql_schemas()).
+
+sql_schemas() ->
+ [#sql_schema{version = 1,
+ tables =
+ [#sql_table{name = <<"invite_token">>,
+ columns =
+ [#sql_column{name = <<"token">>, type = text},
+ #sql_column{name = <<"username">>, type = text},
+ #sql_column{name = <<"server_host">>, type = text},
+ #sql_column{name = <<"invitee">>,
+ type = text,
+ default = true},
+ #sql_column{name = <<"created_at">>,
+ type = timestamp,
+ default = true},
+ #sql_column{name = <<"expires">>, type = timestamp},
+ #sql_column{name = <<"type">>, type = text},
+ #sql_column{name = <<"account_name">>, type = text}],
+ indices =
+ [#sql_index{columns = [<<"token">>], unique = true},
+ #sql_index{columns =
+ [<<"username">>, <<"server_host">>]}]}]}].
+
+cleanup_expired(Host) ->
+ {updated, Count} =
+ ejabberd_sql:sql_query(Host, "DELETE FROM invite_token WHERE expires < NOW()"),
+ Count.
+
+create_invite(Invite) ->
+ #invite_token{inviter = {User, Host},
+ token = Token,
+ account_name = AccountName,
+ created_at = CreatedAt0,
+ expires = Expires0,
+ type = Type} =
+ Invite,
+ TypeBin = atom_to_binary(Type),
+ CreatedAt = datetime_to_sql_timestamp(CreatedAt0),
+ Expires = datetime_to_sql_timestamp(Expires0),
+
+ Query =
+ ?SQL_INSERT("invite_token",
+ ["token=%(Token)s",
+ "username=%(User)s",
+ "server_host=%(Host)s",
+ "type=%(TypeBin)s",
+ "created_at=%(CreatedAt)s",
+ "expires=%(Expires)s",
+ "account_name=%(AccountName)s"]),
+ {updated, 1} = ejabberd_sql:sql_query(Host, Query),
+ Invite.
+
+expire_tokens(User, Server) ->
+ {updated, Count} =
+ ejabberd_sql:sql_query(Server,
+ ?SQL("UPDATE invite_token SET expires = '1970-01-01 00:00:01' WHERE "
+ "username = %(User)s AND %(Server)H AND expires > NOW() AND "
+ "type != 'roster_only'")),
+ Count.
+
+get_invite(Host, Token) ->
+ case ejabberd_sql:sql_query(Host,
+ ?SQL("SELECT @(token)s, @(username)s, @(invitee)s, @(type)s, @(account_name)s, "
+ "@(expires)s, @(created_at)s FROM invite_token WHERE token = "
+ "%(Token)s AND %(Host)H"))
+ of
+ {selected, [{Token, User, Invitee, Type, AccountName, Expires, CreatedAt}]} ->
+ #invite_token{token = Token,
+ inviter = {User, Host},
+ invitee = Invitee,
+ type = binary_to_existing_atom(Type),
+ account_name = AccountName,
+ expires = Expires,
+ created_at = CreatedAt};
+ {selected, []} ->
+ {error, not_found}
+ end.
+
+is_reserved(Host, Token, User) ->
+ {selected, [{Count}]} =
+ ejabberd_sql:sql_query(Host,
+ ?SQL("SELECT @(COUNT(*))d FROM invite_token WHERE %(Host)H AND token != %(Token)s AND "
+ "account_name = %(User)s AND invitee = '' AND expires > NOW()")),
+ Count > 0.
+
+is_token_valid(Host, Token, {User, Host}) ->
+ {selected, Rows} =
+ ejabberd_sql:sql_query(Host,
+ ?SQL("SELECT @(token)s FROM invite_token WHERE %(Host)H AND token = %(Token)s AND "
+ "invitee = '' AND expires > NOW() 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)s, @(created_at)s FROM invite_token WHERE %(Host)H")),
+ lists:map(
+ fun({Token, User, Type, AccountName0, Expires, CreatedAt}) ->
+ AccountName =
+ case AccountName0 of
+ <<>> ->
+ undefined;
+ _ ->
+ AccountName0
+ end,
+ #invite_token{token = Token,
+ inviter = {User, Host},
+ type = binary_to_existing_atom(Type),
+ account_name = AccountName,
+ expires = Expires,
+ created_at = CreatedAt}
+ end, Rows).
+
+num_account_invites(User, Server) ->
+ {selected, [{Count}]} =
+ ejabberd_sql:sql_query(Server,
+ ?SQL("SELECT @(COUNT(*))d FROM invite_token WHERE username=%(User)s "
+ "AND %(Server)H AND type != 'roster_only'")),
+ Count.
+
+remove_user(User, Server) ->
+ ejabberd_sql:sql_query(Server,
+ ?SQL("DELETE FROM invite_token WHERE username=%(User)s AND %(Server)H")).
+
+set_invitee(Host, Token, Invitee) ->
+ {updated, 1} =
+ ejabberd_sql:sql_query(Host,
+ ?SQL("UPDATE invite_token SET invitee=%(Invitee)s WHERE token=%(Token)s")),
+ ok.
+
+%%--------------------------------------------------------------------
+%%| helpers
+
+datetime_to_sql_timestamp({{Year, Month, Day}, {Hour, Minute, Second}}) ->
+ list_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B ~2..0B:~2..0B:~2..0B",
+ [Year, Month, Day, Hour, Minute, Second])).
diff --git a/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..5f9aff18f03 100644
--- a/test/ejabberd_SUITE.erl
+++ b/test/ejabberd_SUITE.erl
@@ -349,6 +349,10 @@ init_per_testcase(TestCase, OrigConfig) ->
Password = ?config(password, Config),
ejabberd_auth:try_register(User, Server, Password),
open_session(bind(auth(connect(Config))));
+ "invites_" ++ _ ->
+ Password = ?config(password, Config),
+ ejabberd_auth:try_register(User, Server, Password),
+ open_session(bind(auth(connect(Config))));
_ when IsMaster or IsSlave ->
Password = ?config(password, Config),
ejabberd_auth:try_register(User, Server, Password),
@@ -436,6 +440,7 @@ db_tests(DB) when DB == mnesia; DB == redis ->
presence_broadcast,
last,
antispam_tests:single_cases(),
+ invites_tests:single_cases(),
webadmin_tests:single_cases(),
roster_tests:single_cases(),
private_tests:single_cases(),
@@ -477,6 +482,7 @@ db_tests(DB) ->
offline_tests:single_cases(),
mam_tests:single_cases(),
push_tests:single_cases(),
+ invites_tests:single_cases(),
test_pass_change,
test_unregister]},
muc_tests:master_slave_cases(),
diff --git a/test/ejabberd_SUITE_data/ejabberd.mnesia.yml b/test/ejabberd_SUITE_data/ejabberd.mnesia.yml
index 56fdf5e6e4d..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.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.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..cba897acfd4
--- /dev/null
+++ b/test/invites_tests.erl
@@ -0,0 +1,638 @@
+%%%-------------------------------------------------------------------
+%%% 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(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(<<"foo">>, <<"non.existant.host">>)),
+ %% TooLongHostname = list_to_binary([$a || _ <- lists:seq(1, 1024)]),
+ %% ?match({error, hostname_invalid}, mod_invites:gen_invite(<<"foo">>, TooLongHostname)),
+ ok.
+
+cleanup_expired(Config) ->
+ Server = ?config(server, Config),
+ 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()),
+ ok.
+
+adhoc_items(Config) ->
+ Server = ?config(server, Config),
+ ServerJID = jid:from_string(Server),
+ User = ?config(user, Config),
+ UserJID = jid:from_string(User),
+ Disco = #disco_items{node = ?NS_COMMANDS},
+ #iq{type = result, sub_els = [#disco_items{node = ?NS_COMMANDS, items = Items}]} =
+ send_recv(Config,
+ #iq{type = get,
+ to = ServerJID,
+ sub_els = [Disco]}),
+ ?match(true, [I || I = #disco_item{node = ?NS_INVITE_INVITE} <- Items] /= []),
+ ?match(deny,
+ acl:match_rule(Server,
+ gen_mod:get_module_opt(Server, mod_invites, access_create_account),
+ UserJID)),
+ ?match(false, [I || I = #disco_item{node = ?NS_INVITE_CREATE_ACCOUNT} <- Items] /= []),
+ OldOpts = gen_mod:get_module_opts(Server, mod_invites),
+ NewOpts = gen_mod:set_opt(access_create_account, account_invite, OldOpts),
+ update_module_opts(Server, mod_invites, NewOpts),
+ ?match(allow,
+ acl:match_rule(Server,
+ gen_mod:get_module_opt(Server, mod_invites, access_create_account),
+ UserJID)),
+ #iq{type = result, sub_els = [#disco_items{node = ?NS_COMMANDS, items = NewItems}]} =
+ send_recv(Config,
+ #iq{type = get,
+ to = ServerJID,
+ sub_els = [Disco]}),
+ ?match(true, [I || I = #disco_item{node = ?NS_INVITE_INVITE} <- NewItems] /= []),
+ ?match(true, [I || I = #disco_item{node = ?NS_INVITE_CREATE_ACCOUNT} <- NewItems] /= []),
+ update_module_opts(Server, mod_invites, OldOpts),
+ ?match(deny,
+ acl:match_rule(Server,
+ gen_mod:get_module_opt(Server, mod_invites, access_create_account),
+ UserJID)),
+ disconnect(Config).
+
+adhoc_command_invite(Config) ->
+ Server = ?config(server, Config),
+ ServerJID = jid:from_string(Server),
+ Command = #adhoc_command{node = ?NS_INVITE_INVITE},
+ #iq{type = result,
+ sub_els =
+ [#adhoc_command{status = completed,
+ xdata = #xdata{type = result, fields = XdataFields}}]} =
+ send_recv(Config,
+ #iq{type = set,
+ to = ServerJID,
+ sub_els = [Command]}),
+ [Uri] = [V || #xdata_field{var = <<"uri">>, values = [V]} <- XdataFields],
+ ?match(<<"xmpp:", _/binary>>, Uri),
+ ?match(true, [V || #xdata_field{var = <<"expire">>, values = [V]} <- XdataFields] /= []),
+ Token = token_from_uri(Uri),
+ User = jid:nodeprep(?config(user, Config)),
+ ?match(true, mod_invites:is_token_valid(Server, Token, {User, Server})),
+ mod_invites:remove_user(User, Server),
+ 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">>, <<"1">>),
+ ?match({match, _},
+ re:run(xdata_field(<<"uri">>, ResultXDataFields4),
+ <<"xmpp:foobar@", Server/binary, "\\?register;preauth=([a-zA-Z0-9]+)">>,
+ [{capture, all_but_first, binary}])),
+ Token4 = token_from_uri(xdata_field(<<"uri">>, ResultXDataFields4, <<>>)),
+ #invite_token{account_name = <<"foobar">>, type = account_subscription} =
+ mod_invites:get_invite(Server, Token4),
+ update_module_opts(Server, mod_invites, OldOpts),
+ User = jid:nodeprep(?config(user, Config)),
+ mod_invites:remove_user(User, Server),
+ 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),
+ ok.
+
+remove_user(Config) ->
+ Server = ?config(server, Config),
+ User = ?config(user, Config),
+ Inviter = {User, Server},
+ #invite_token{} = create_account_invite(Server, Inviter),
+ ?match(1, mod_invites:num_account_invites(User, Server)),
+ mod_invites:remove_user(User, Server),
+ ?match(0, mod_invites:num_account_invites(User, Server)),
+ ok.
+
+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().
+
+max_invites(Config) ->
+ Server = ?config(server, Config),
+ User = ?config(user, Config),
+ Inviter = {User, Server},
+ OldOpts = gen_mod:get_module_opts(Server, mod_invites),
+ NewOpts = gen_mod:set_opt(max_invites, 3, OldOpts),
+ update_module_opts(Server, mod_invites, NewOpts),
+ #invite_token{} = 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)),
+ update_module_opts(Server, mod_invites, OldOpts),
+ #invite_token{} = create_account_invite(Server, Inviter),
+ ok.
+
+presence_with_preauth_token(Config) ->
+ Server = ?config(server, Config),
+ User = ?config(user, Config),
+ Inviter = {<<"inviter">>, Server},
+ InviterJID = jid:make(<<"inviter">>, Server),
+ #invite_token{token = RosterToken} = mod_invites:create_roster_invite(Server, Inviter),
+ send(Config,
+ #presence{type = subscribe,
+ to = 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),
+ disconnect(Config).
+
+is_reserved(Config) ->
+ Server = ?config(server, Config),
+ Inviter = {<<"inviter">>, Server},
+ mod_invites:expire_tokens(<<"inviter">>, Server),
+ mod_invites:cleanup_expired(),
+ #invite_token{token = Token} =
+ mod_invites:create_account_invite(Server, Inviter, <<"reserved_user">>, false),
+ ?match(false, mod_invites:is_reserved(Server, Token, <<"some_other_username">>)),
+ ?match(false, mod_invites:is_reserved(Server, Token, <<"reserved_user">>)),
+ ?match(true,
+ mod_invites:is_reserved(Server, <<"some_other_token">>, <<"reserved_user">>)),
+ %% "use" token to create account under different name, then it should not be reserved anymore
+ mod_invites:set_invitee(Server, Token, jid:make(<<"some_other_username">>, Server)),
+ ?match(false,
+ mod_invites:is_reserved(Server, <<"some_other_token">>, <<"reserved_user">>)),
+ ok.
+
+stream_feature(Config0) ->
+ Server = ?config(server, Config0),
+ OldOpts = gen_mod:get_module_opts(Server, mod_invites),
+ Config1 = reconnect(Config0),
+ ?match(true, ?config(register, Config1)),
+ ?match(false, ?config(register_ibr_token, Config1)),
+ NewOpts = gen_mod:set_opt(access_create_account, account_invite, OldOpts),
+ update_module_opts(Server, mod_invites, NewOpts),
+ Config2 = reconnect(Config1),
+ ?match(true, ?config(register, Config2)),
+ ?match(true, ?config(register_ibr_token, Config2)),
+ update_module_opts(Server, mod_invites, OldOpts),
+ disconnect(Config2).
+
+ibr(Config0) ->
+ Server = ?config(server, Config0),
+ AccountName = <<"new_user">>,
+
+ OldRegisterOpts = gen_mod:get_module_opts(Server, mod_register),
+ NewRegisterOpts = gen_mod:set_opt(allow_modules, [mod_invites], OldRegisterOpts),
+ update_module_opts(Server, mod_register, NewRegisterOpts),
+
+ Config1 = reconnect(Config0),
+
+ ?match(#iq{type = error}, send_iq_register(Config1, AccountName)),
+
+ ?match(#iq{type = error}, send_pars(Config1, <<"bad_token">>)),
+
+ #invite_token{token = RosterToken} =
+ mod_invites:create_roster_invite(Server, {<<"inviter">>, Server}),
+ ?match(#iq{type = error}, send_pars(Config1, RosterToken)),
+
+ #invite_token{token = Token} =
+ mod_invites:create_account_invite(Server, {<<>>, Server}, AccountName, false),
+ ?match(#iq{type = result}, send_pars(Config1, Token)),
+ ?match(#iq{type = result, sub_els = [#register{username = AccountName}]},
+ send_get_iq_register(Config1)),
+ ?match(#iq{type = result}, send_iq_register(Config1, AccountName)),
+
+ Config2 = reconnect(Config1),
+ ?match(#iq{type = error}, send_pars(Config2, Token)),
+
+ #invite_token{token = Token2} =
+ mod_invites:create_account_invite(Server,
+ {<<>>, Server},
+ <<"some_unfavorable_name">>,
+ false),
+ ?match(#iq{type = result}, send_pars(Config2, Token2)),
+ ?match(#iq{type = result, sub_els = [#register{username = <<"some_unfavorable_name">>}]},
+ send_get_iq_register(Config2)),
+ ?match(#iq{type = result}, send_iq_register(Config2, <<"some_much_better_name">>)),
+
+ Config3 = reconnect(Config2),
+ #invite_token{token = Token3} = create_account_invite(Server, {<<>>, Server}),
+ ?match(#iq{type = result}, send_pars(Config3, Token3)),
+ ?match(#iq{type = result, sub_els = [#register{username = <<>>}]},
+ send_get_iq_register(Config3)),
+ ?match(#iq{type = result}, send_iq_register(Config3, <<"some_self_chosen_name">>)),
+
+ update_module_opts(Server, mod_register, OldRegisterOpts),
+ disconnect(Config3).
+
+ibr_reserved(Config0) ->
+ Server = ?config(server, Config0),
+ Config1 = reconnect(Config0),
+ #invite_token{token = _ReservedToken} =
+ mod_invites:create_account_invite(Server, {<<>>, Server}, <<"reserved">>, false),
+ #invite_token{token = OtherToken} =
+ mod_invites:create_account_invite(Server, {<<>>, Server}, <<"some_other">>, false),
+ ?match(#iq{type = result}, send_iq_register(Config1, <<"check_registration_works">>)),
+ Config2 = reconnect(Config1),
+ ?match(#iq{type = error}, send_iq_register(Config2, <<"reserved">>)),
+ ?match(#iq{type = result}, send_pars(Config2, OtherToken)),
+ disconnect(Config2).
+
+ibr_subscription(Config0) ->
+ Server = ?config(server, Config0),
+ ServerJID = jid:from_string(Server),
+ User = ?config(user, Config0),
+ UserJID = jid:make(User, Server),
+ NewAccount = <<"new_friend">>,
+ NewAccountJID = jid:make(NewAccount, Server),
+ gen_mod:stop_module_keep_config(Server, mod_vcard_xupdate),
+ 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).
+
+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).
+
+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">>),
+ ok.
+
+%%%===================================================================
+%%% 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}, [], []).
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/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