changeset 2184:e0f91efa404a

plugin invitations: first draft: /!\ new dependency: shortuuid Plugin invitations allows to invite somebody without XMPP account, using an unique identifier. The various arguments are explained in the docstring.
author Goffi <goffi@goffi.org>
date Sun, 12 Mar 2017 19:35:36 +0100
parents 1b42bd8c10fb
children dd53d7a3219a
files setup.py src/plugins/plugin_misc_invitations.py
diffstat 2 files changed, 250 insertions(+), 1 deletions(-) [+]
line wrap: on
line diff
--- a/setup.py	Sun Mar 12 19:33:25 2017 +0100
+++ b/setup.py	Sun Mar 12 19:35:36 2017 +0100
@@ -304,6 +304,6 @@
                   ],
       scripts=['frontends/src/jp/jp', 'frontends/src/primitivus/primitivus', ],
       zip_safe=False,
-      install_requires=['twisted >= 15.2.0', 'wokkel >= 0.7.1', 'progressbar', 'urwid >= 1.2.0', 'urwid-satext >= 0.6.1', 'mutagen', 'pillow', 'lxml >= 3.1.0', 'pyxdg', 'markdown', 'html2text', 'pycrypto >= 2.6.1', 'python-potr', 'PyOpenSSL', 'service_identity'],
+      install_requires=['twisted >= 15.2.0', 'wokkel >= 0.7.1', 'progressbar', 'urwid >= 1.2.0', 'urwid-satext >= 0.6.1', 'mutagen', 'pillow', 'lxml >= 3.1.0', 'pyxdg', 'markdown', 'html2text', 'pycrypto >= 2.6.1', 'python-potr', 'PyOpenSSL', 'service_identity', 'shortuuid'],
       cmdclass={'install': CustomInstall},
       )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/plugins/plugin_misc_invitations.py	Sun Mar 12 19:35:36 2017 +0100
@@ -0,0 +1,249 @@
+#!/usr/bin/env python2
+# -*- coding: utf-8 -*-
+
+# SAT plugin for file tansfer
+# Copyright (C) 2009-2016 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/>.
+
+from sat.core.i18n import _, D_
+from sat.core.constants import Const as C
+from sat.core import exceptions
+from sat.core.log import getLogger
+log = getLogger(__name__)
+import shortuuid
+from sat.tools import utils
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber import error
+from sat.memory import persistent
+from sat.tools import email as sat_email
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Invitations",
+    C.PI_IMPORT_NAME: "INVITATIONS",
+    C.PI_TYPE: C.PLUG_TYPE_MISC,
+    C.PI_DEPENDENCIES: ['XEP-0077'],
+    C.PI_MAIN: "InvitationsPlugin",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _(u"""invitation of people without XMPP account""")
+}
+
+
+SUFFIX_MAX = 5
+INVITEE_PROFILE_TPL = u"guest@@{uuid}"
+KEY_CREATED = u'created'
+KEY_LAST_CONNECTION = u'last_connection'
+EXTRA_RESERVED = {KEY_CREATED, u'jid_', u'jid', KEY_LAST_CONNECTION}
+DEFAULT_SUBJECT = D_(u"You have been invited by {host_name} to {app_name}")
+DEFAULT_BODY = D_(u"""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):
+    # TODO: plugin unload
+
+    def __init__(self, host):
+        log.info(_(u"plugin Invitations initialization"))
+        self.host = host
+        self.invitations = persistent.LazyPersistentBinaryDict(u'invitations')
+        host.bridge.addMethod("invitationCreate", ".plugin", in_sign='sssssssssa{ss}s', out_sign='(sa{ss})',
+                              method=self._createInvitation,
+                              async=True)
+    def _createInvitation(self, jid_=u'', password=u'', name=u'', host_name=u'', email=u'', language=u'', url_template=u'', message_subject=u'', message_body=u'', extra=None, profile=u''):
+        # XXX: we don't use **kwargs here to keep arguments name for introspection with D-Bus bridge
+
+        if extra is None:
+            extra = {}
+        else:
+            extra = {unicode(k): unicode(v) for k,v in extra.iteritems()}
+
+        # we need to be sure that values are unicode, else they won't be pickled correctly with D-Bus
+        kwargs = {"extra": extra}
+        for key in ("jid_", "password", "name", "host_name", "email", "language", "url_template", "message_subject", "message_body", "profile"):
+            value = locals()[key]
+            if value:
+                kwargs[key] = unicode(value)
+        return self.createInvitation(**kwargs)
+
+    @defer.inlineCallbacks
+    def createInvitation(self, **kwargs):
+        ur"""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-[UUID]@domain.tld")
+                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
+            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 t:o he 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 (unicode, dict[unicode, unicode]): tuple with:
+            - UUID associated with the invitee
+            - filled extra dictionary, as saved in the databae
+        """
+        ## initial checks
+        extra = kwargs.pop('extra', {})
+        if set(kwargs).intersection(extra):
+            raise exceptions.ValueError(_(u"You can't use following key(s) in both args and extra: {}").format(
+                u', '.join(set(kwargs).intersection(extra))))
+
+        if EXTRA_RESERVED.intersection(extra):
+            raise exceptions.ValueError(_(u"You can't use following key(s) in extra, they are reserved: {}").format(
+                u', '.join(EXTRA_RESERVED.intersection(extra))))
+
+        ## uuid
+        log.info(_(u"creating an invitation"))
+        id_ = unicode(shortuuid.uuid())
+
+        ## XMPP account creation
+        password = kwargs.pop(u'password', None)
+        if password is None:
+           password = utils.generatePassword()
+        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[u'password'] = password
+
+        jid_ = kwargs.pop(u'jid_', None)
+        if not jid_:
+            domain = self.host.memory.getConfig(None, 'xmpp_domain')
+            if not domain:
+                # TODO: fallback to profile's domain
+                raise ValueError(_(u"You need to specify xmpp_domain in sat.conf"))
+            jid_ = u"invitation-{uuid}@{domain}".format(uuid=id_, domain=domain)
+        jid_ = jid.JID(jid_)
+        if jid_.user:
+            # we don't register account if there is no user as anonymous login is then used
+            try:
+                yield self.host.plugins['XEP-0077'].registerNewAccount(jid_, password)
+            except error.StanzaError as e:
+                prefix = jid_.user
+                idx = 0
+                while e.condition == u'conflict':
+                    if idx >= SUFFIX_MAX:
+                        raise exceptions.ConflictError(_(u"Can't create XMPP account"))
+                    jid_.user = prefix + '_' + unicode(idx)
+                    log.info(_(u"requested jid already exists, trying with {}".format(jid_.full())))
+                    try:
+                        yield self.host.plugins['XEP-0077'].registerNewAccount(jid_, password)
+                    except error.StanzaError as e:
+                        idx += 1
+                    else:
+                        break
+                if e.condition != u'conflict':
+                    raise e
+
+            log.info(_(u"account {jid_} created").format(jid_=jid_.full()))
+
+        ## profile creation
+        extra['guest_profile'] = guest_profile = INVITEE_PROFILE_TPL.format(uuid=id_)
+        # profile creation should not fail as we generate unique name ourselves
+        yield self.host.memory.createProfile(guest_profile, password)
+        yield self.host.memory.startSession(password, guest_profile)
+        yield self.host.memory.setParam("JabberID", jid_.full(), "Connection", profile_key=guest_profile)
+        yield self.host.memory.setParam("Password", password, "Connection", profile_key=guest_profile)
+
+        ## email
+        language = kwargs.pop(u'language', None)
+        if language is not None:
+            extra[u'language'] = language
+        email = kwargs.pop(u'email', None)
+
+        if email is not None:
+            url_template = kwargs.pop(u'url_template', '')
+            format_args = {
+                u'uuid': id_,
+                u'app_name': C.APP_NAME,
+                u'app_url': C.APP_URL}
+
+            name = kwargs.pop(u'name', None)
+            if name is None:
+                format_args[u'name'] = email
+            else:
+                format_args[u'name'] = extra[u'name'] = name
+
+            profile = kwargs.pop(u'profile', None)
+            if profile is None:
+                format_args[u'profile'] = u''
+            else:
+                format_args[u'profile'] = extra[u'profile'] = profile
+
+            host_name = kwargs.pop(u'host_name', None)
+            if host_name is None:
+                format_args[u'host_name'] = profile or _(u"somebody")
+            else:
+                format_args[u'host_name'] = extra[u'host_name'] = host_name
+
+            invite_url = url_template.format(**format_args)
+            format_args[u'url'] = invite_url
+
+            yield sat_email.sendEmail(
+                self.host,
+                [email],
+                (kwargs.pop(u'message_subject', None) or DEFAULT_SUBJECT).format(**format_args),
+                (kwargs.pop(u'message_body', None) or DEFAULT_BODY).format(**format_args),
+            )
+
+        ## extra data saving
+        self.invitations[id_] = extra
+
+        if kwargs:
+            log.warning(_(u"Not all arguments have been consumed: {}").format(kwargs))
+
+        defer.returnValue((id_, extra))