Mercurial > libervia-backend
diff libervia/backend/plugins/plugin_misc_email_invitation.py @ 4071:4b842c1fb686
refactoring: renamed `sat` package to `libervia.backend`
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 11:49:51 +0200 |
parents | sat/plugins/plugin_misc_email_invitation.py@524856bd7b19 |
children | 0d7bb4df2343 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/backend/plugins/plugin_misc_email_invitation.py Fri Jun 02 11:49:51 2023 +0200 @@ -0,0 +1,521 @@ +#!/usr/bin/env python3 + +# SàT plugin for sending invitations by email +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import shortuuid +from typing import Optional +from twisted.internet import defer +from twisted.words.protocols.jabber import jid +from twisted.words.protocols.jabber import error +from twisted.words.protocols.jabber import sasl +from libervia.backend.core.i18n import _, D_ +from libervia.backend.core.constants import Const as C +from libervia.backend.core import exceptions +from libervia.backend.core.log import getLogger +from libervia.backend.tools import utils +from libervia.backend.tools.common import data_format +from libervia.backend.memory import persistent +from libervia.backend.tools.common import email as sat_email + +log = getLogger(__name__) + + +PLUGIN_INFO = { + C.PI_NAME: "Email Invitations", + C.PI_IMPORT_NAME: "EMAIL_INVITATION", + C.PI_TYPE: C.PLUG_TYPE_MISC, + C.PI_DEPENDENCIES: ['XEP-0077'], + C.PI_RECOMMENDATIONS: ["IDENTITY"], + C.PI_MAIN: "InvitationsPlugin", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""invitation of people without XMPP account""") +} + + +SUFFIX_MAX = 5 +INVITEE_PROFILE_TPL = "guest@@{uuid}" +KEY_ID = 'id' +KEY_JID = 'jid' +KEY_CREATED = 'created' +KEY_LAST_CONNECTION = 'last_connection' +KEY_GUEST_PROFILE = 'guest_profile' +KEY_PASSWORD = 'password' +KEY_EMAILS_EXTRA = 'emails_extra' +EXTRA_RESERVED = {KEY_ID, KEY_JID, KEY_CREATED, 'jid_', 'jid', KEY_LAST_CONNECTION, + KEY_GUEST_PROFILE, KEY_PASSWORD, KEY_EMAILS_EXTRA} +DEFAULT_SUBJECT = D_("You have been invited by {host_name} to {app_name}") +DEFAULT_BODY = D_("""Hello {name}! + +You have received an invitation from {host_name} to participate to "{app_name}". +To join, you just have to click on the following URL: +{url} + +Please note that this URL should not be shared with anybody! +If you want more details on {app_name}, you can check {app_url}. + +Welcome! +""") + + +class InvitationsPlugin(object): + + def __init__(self, host): + log.info(_("plugin Invitations initialization")) + self.host = host + self.invitations = persistent.LazyPersistentBinaryDict('invitations') + host.bridge.add_method("invitation_create", ".plugin", in_sign='sasssssssssa{ss}s', + out_sign='a{ss}', + method=self._create, + async_=True) + host.bridge.add_method("invitation_get", ".plugin", in_sign='s', out_sign='a{ss}', + method=self.get, + async_=True) + host.bridge.add_method("invitation_delete", ".plugin", in_sign='s', out_sign='', + method=self._delete, + async_=True) + host.bridge.add_method("invitation_modify", ".plugin", in_sign='sa{ss}b', + out_sign='', + method=self._modify, + async_=True) + host.bridge.add_method("invitation_list", ".plugin", in_sign='s', + out_sign='a{sa{ss}}', + method=self._list, + async_=True) + host.bridge.add_method("invitation_simple_create", ".plugin", in_sign='sssss', + out_sign='a{ss}', + method=self._simple_create, + async_=True) + + def check_extra(self, extra): + if EXTRA_RESERVED.intersection(extra): + raise ValueError( + _("You can't use following key(s) in extra, they are reserved: {}") + .format(', '.join(EXTRA_RESERVED.intersection(extra)))) + + def _create(self, email='', emails_extra=None, jid_='', password='', name='', + host_name='', language='', url_template='', message_subject='', + message_body='', extra=None, profile=''): + # XXX: we don't use **kwargs here to keep arguments name for introspection with + # D-Bus bridge + if emails_extra is None: + emails_extra = [] + + if extra is None: + extra = {} + else: + extra = {str(k): str(v) for k,v in extra.items()} + + kwargs = {"extra": extra, + KEY_EMAILS_EXTRA: [str(e) for e in emails_extra] + } + + # we need to be sure that values are unicode, else they won't be pickled correctly + # with D-Bus + for key in ("jid_", "password", "name", "host_name", "email", "language", + "url_template", "message_subject", "message_body", "profile"): + value = locals()[key] + if value: + kwargs[key] = str(value) + return defer.ensureDeferred(self.create(**kwargs)) + + async def get_existing_invitation(self, email: Optional[str]) -> Optional[dict]: + """Retrieve existing invitation with given email + + @param email: check if any invitation exist with this email + @return: first found invitation, or None if nothing found + """ + # FIXME: This method is highly inefficient, it get all invitations and check them + # one by one, this is just a temporary way to avoid creating creating new accounts + # for an existing email. A better way will be available with Libervia 0.9. + # TODO: use a better way to check existing invitations + + if email is None: + return None + all_invitations = await self.invitations.all() + for id_, invitation in all_invitations.items(): + if invitation.get("email") == email: + invitation[KEY_ID] = id_ + return invitation + + async def _create_account_and_profile( + self, + id_: str, + kwargs: dict, + extra: dict + ) -> None: + """Create XMPP account and Libervia profile for guest""" + ## XMPP account creation + password = kwargs.pop('password', None) + if password is None: + password = utils.generate_password() + assert password + # XXX: password is here saved in clear in database + # it is needed for invitation as the same password is used for profile + # and SàT need to be able to automatically open the profile with the uuid + # FIXME: we could add an extra encryption key which would be used with the + # uuid when the invitee is connecting (e.g. with URL). This key would + # not be saved and could be used to encrypt profile password. + extra[KEY_PASSWORD] = password + + jid_ = kwargs.pop('jid_', None) + if not jid_: + domain = self.host.memory.config_get(None, 'xmpp_domain') + if not domain: + # TODO: fallback to profile's domain + raise ValueError(_("You need to specify xmpp_domain in sat.conf")) + jid_ = "invitation-{uuid}@{domain}".format(uuid=shortuuid.uuid(), + domain=domain) + jid_ = jid.JID(jid_) + extra[KEY_JID] = jid_.full() + + if jid_.user: + # we don't register account if there is no user as anonymous login is then + # used + try: + await self.host.plugins['XEP-0077'].register_new_account(jid_, password) + except error.StanzaError as e: + prefix = jid_.user + idx = 0 + while e.condition == 'conflict': + if idx >= SUFFIX_MAX: + raise exceptions.ConflictError(_("Can't create XMPP account")) + jid_.user = prefix + '_' + str(idx) + log.info(_("requested jid already exists, trying with {}".format( + jid_.full()))) + try: + await self.host.plugins['XEP-0077'].register_new_account( + jid_, + password + ) + except error.StanzaError: + idx += 1 + else: + break + if e.condition != 'conflict': + raise e + + log.info(_("account {jid_} created").format(jid_=jid_.full())) + + ## profile creation + + extra[KEY_GUEST_PROFILE] = guest_profile = INVITEE_PROFILE_TPL.format( + uuid=id_ + ) + # profile creation should not fail as we generate unique name ourselves + await self.host.memory.create_profile(guest_profile, password) + await self.host.memory.start_session(password, guest_profile) + await self.host.memory.param_set("JabberID", jid_.full(), "Connection", + profile_key=guest_profile) + await self.host.memory.param_set("Password", password, "Connection", + profile_key=guest_profile) + + async def create(self, **kwargs): + r"""Create an invitation + + This will create an XMPP account and a profile, and use a UUID to retrieve them. + The profile is automatically generated in the form guest@@[UUID], this way they + can be retrieved easily + **kwargs: keywords arguments which can have the following keys, unset values are + equivalent to None: + jid_(jid.JID, None): jid to use for invitation, the jid will be created using + XEP-0077 + if the jid has no user part, an anonymous account will be used (no XMPP + account created in this case) + if None, automatically generate an account name (in the form + "invitation-[random UUID]@domain.tld") (note that this UUID is not the + same as the invitation one, as jid can be used publicly (leaking the + UUID), and invitation UUID give access to account. + in case of conflict, a suffix number is added to the account until a free + one if found (with a failure if SUFFIX_MAX is reached) + password(unicode, None): password to use (will be used for XMPP account and + profile) + None to automatically generate one + name(unicode, None): name of the invitee + will be set as profile identity if present + host_name(unicode, None): name of the host + email(unicode, None): email to send the invitation to + if None, no invitation email is sent, you can still associate email using + extra + if email is used, extra can't have "email" key + language(unicode): language of the invitee (used notabily to translate the + invitation) + TODO: not used yet + url_template(unicode, None): template to use to construct the invitation URL + use {uuid} as a placeholder for identifier + use None if you don't want to include URL (or if it is already specified + in custom message) + /!\ you must put full URL, don't forget https:// + /!\ the URL will give access to the invitee account, you should warn in + message to not publish it publicly + message_subject(unicode, None): customised message body for the invitation + email + None to use default subject + uses the same substitution as for message_body + message_body(unicode, None): customised message body for the invitation email + None to use default body + use {name} as a place holder for invitee name + use {url} as a placeholder for the invitation url + use {uuid} as a placeholder for the identifier + use {app_name} as a placeholder for this software name + use {app_url} as a placeholder for this software official website + use {profile} as a placeholder for host's profile + use {host_name} as a placeholder for host's name + extra(dict, None): extra data to associate with the invitee + some keys are reserved: + - created (creation date) + if email argument is used, "email" key can't be used + profile(unicode, None): profile of the host (person who is inviting) + @return (dict[unicode, unicode]): dictionary with: + - UUID associated with the invitee (key: id) + - filled extra dictionary, as saved in the databae + """ + ## initial checks + extra = kwargs.pop('extra', {}) + if set(kwargs).intersection(extra): + raise ValueError( + _("You can't use following key(s) in both args and extra: {}").format( + ', '.join(set(kwargs).intersection(extra)))) + + self.check_extra(extra) + + email = kwargs.pop('email', None) + + existing = await self.get_existing_invitation(email) + if existing is not None: + log.info(f"There is already an invitation for {email!r}") + extra.update(existing) + del extra[KEY_ID] + + emails_extra = kwargs.pop('emails_extra', []) + if not email and emails_extra: + raise ValueError( + _('You need to provide a main email address before using emails_extra')) + + if (email is not None + and not 'url_template' in kwargs + and not 'message_body' in kwargs): + raise ValueError( + _("You need to provide url_template if you use default message body")) + + ## uuid + log.info(_("creating an invitation")) + id_ = existing[KEY_ID] if existing else str(shortuuid.uuid()) + + if existing is None: + await self._create_account_and_profile(id_, kwargs, extra) + + profile = kwargs.pop('profile', None) + guest_profile = extra[KEY_GUEST_PROFILE] + jid_ = jid.JID(extra[KEY_JID]) + + ## identity + name = kwargs.pop('name', None) + password = extra[KEY_PASSWORD] + if name is not None: + extra['name'] = name + try: + id_plugin = self.host.plugins['IDENTITY'] + except KeyError: + pass + else: + await self.host.connect(guest_profile, password) + guest_client = self.host.get_client(guest_profile) + await id_plugin.set_identity(guest_client, {'nicknames': [name]}) + await self.host.disconnect(guest_profile) + + ## email + language = kwargs.pop('language', None) + if language is not None: + extra['language'] = language.strip() + + if email is not None: + extra['email'] = email + data_format.iter2dict(KEY_EMAILS_EXTRA, extra) + url_template = kwargs.pop('url_template', '') + format_args = { + 'uuid': id_, + 'app_name': C.APP_NAME, + 'app_url': C.APP_URL} + + if name is None: + format_args['name'] = email + else: + format_args['name'] = name + + if profile is None: + format_args['profile'] = '' + else: + format_args['profile'] = extra['profile'] = profile + + host_name = kwargs.pop('host_name', None) + if host_name is None: + format_args['host_name'] = profile or _("somebody") + else: + format_args['host_name'] = extra['host_name'] = host_name + + invite_url = url_template.format(**format_args) + format_args['url'] = invite_url + + await sat_email.send_email( + self.host.memory.config, + [email] + emails_extra, + (kwargs.pop('message_subject', None) or DEFAULT_SUBJECT).format( + **format_args), + (kwargs.pop('message_body', None) or DEFAULT_BODY).format(**format_args), + ) + + ## roster + + # we automatically add guest to host roster (if host is specified) + # FIXME: a parameter to disable auto roster adding would be nice + if profile is not None: + try: + client = self.host.get_client(profile) + except Exception as e: + log.error(f"Can't get host profile: {profile}: {e}") + else: + await self.host.contact_update(client, jid_, name, ['guests']) + + if kwargs: + log.warning(_("Not all arguments have been consumed: {}").format(kwargs)) + + ## extra data saving + self.invitations[id_] = extra + + extra[KEY_ID] = id_ + + return extra + + def _simple_create(self, invitee_email, invitee_name, url_template, extra_s, profile): + client = self.host.get_client(profile) + # FIXME: needed because python-dbus use a specific string class + invitee_email = str(invitee_email) + invitee_name = str(invitee_name) + url_template = str(url_template) + extra = data_format.deserialise(extra_s) + d = defer.ensureDeferred( + self.simple_create(client, invitee_email, invitee_name, url_template, extra) + ) + d.addCallback(lambda data: {k: str(v) for k,v in data.items()}) + return d + + async def simple_create( + self, client, invitee_email, invitee_name, url_template, extra): + """Simplified method to invite somebody by email""" + return await self.create( + name=invitee_name, + email=invitee_email, + url_template=url_template, + profile=client.profile, + ) + + def get(self, id_): + """Retrieve invitation linked to uuid if it exists + + @param id_(unicode): UUID linked to an invitation + @return (dict[unicode, unicode]): data associated to the invitation + @raise KeyError: there is not invitation with this id_ + """ + return self.invitations[id_] + + def _delete(self, id_): + return defer.ensureDeferred(self.delete(id_)) + + async def delete(self, id_): + """Delete an invitation data and associated XMPP account""" + log.info(f"deleting invitation {id_}") + data = await self.get(id_) + guest_profile = data['guest_profile'] + password = data['password'] + try: + await self.host.connect(guest_profile, password) + guest_client = self.host.get_client(guest_profile) + # XXX: be extra careful to use guest_client and not client below, as this will + # delete the associated XMPP account + log.debug("deleting XMPP account") + await self.host.plugins['XEP-0077'].unregister(guest_client, None) + except (error.StanzaError, sasl.SASLAuthError) as e: + log.warning( + f"Can't delete {guest_profile}'s XMPP account, maybe it as already been " + f"deleted: {e}") + try: + await self.host.memory.profile_delete_async(guest_profile, True) + except Exception as e: + log.warning(f"Can't delete guest profile {guest_profile}: {e}") + log.debug("removing guest data") + await self.invitations.adel(id_) + log.info(f"{id_} invitation has been deleted") + + def _modify(self, id_, new_extra, replace): + return self.modify(id_, {str(k): str(v) for k,v in new_extra.items()}, + replace) + + def modify(self, id_, new_extra, replace=False): + """Modify invitation data + + @param id_(unicode): UUID linked to an invitation + @param new_extra(dict[unicode, unicode]): data to update + empty values will be deleted if replace is True + @param replace(bool): if True replace the data + else update them + @raise KeyError: there is not invitation with this id_ + """ + self.check_extra(new_extra) + def got_current_data(current_data): + if replace: + new_data = new_extra + for k in EXTRA_RESERVED: + try: + new_data[k] = current_data[k] + except KeyError: + continue + else: + new_data = current_data + for k,v in new_extra.items(): + if k in EXTRA_RESERVED: + log.warning(_("Skipping reserved key {key}").format(key=k)) + continue + if v: + new_data[k] = v + else: + try: + del new_data[k] + except KeyError: + pass + + self.invitations[id_] = new_data + + d = self.invitations[id_] + d.addCallback(got_current_data) + return d + + def _list(self, profile=C.PROF_KEY_NONE): + return defer.ensureDeferred(self.list(profile)) + + async def list(self, profile=C.PROF_KEY_NONE): + """List invitations + + @param profile(unicode): return invitation linked to this profile only + C.PROF_KEY_NONE: don't filter invitations + @return list(unicode): invitations uids + """ + invitations = await self.invitations.all() + if profile != C.PROF_KEY_NONE: + invitations = {id_:data for id_, data in invitations.items() + if data.get('profile') == profile} + + return invitations