# HG changeset patch # User Goffi # Date 1489343736 -3600 # Node ID e0f91efa404a7c85cc6330f612f5d005cd675613 # Parent 1b42bd8c10fb1fea052315347fb033b782b812eb 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. diff -r 1b42bd8c10fb -r e0f91efa404a setup.py --- 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}, ) diff -r 1b42bd8c10fb -r e0f91efa404a src/plugins/plugin_misc_invitations.py --- /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 . + +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))