view src/plugins/plugin_misc_invitations.py @ 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
children dd53d7a3219a
line wrap: on
line source

#!/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))