comparison 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
comparison
equal deleted inserted replaced
2183:1b42bd8c10fb 2184:e0f91efa404a
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3
4 # SAT plugin for file tansfer
5 # Copyright (C) 2009-2016 Jérôme Poisson (goffi@goffi.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 from sat.core.i18n import _, D_
21 from sat.core.constants import Const as C
22 from sat.core import exceptions
23 from sat.core.log import getLogger
24 log = getLogger(__name__)
25 import shortuuid
26 from sat.tools import utils
27 from twisted.internet import defer
28 from twisted.words.protocols.jabber import jid
29 from twisted.words.protocols.jabber import error
30 from sat.memory import persistent
31 from sat.tools import email as sat_email
32
33
34 PLUGIN_INFO = {
35 C.PI_NAME: "Invitations",
36 C.PI_IMPORT_NAME: "INVITATIONS",
37 C.PI_TYPE: C.PLUG_TYPE_MISC,
38 C.PI_DEPENDENCIES: ['XEP-0077'],
39 C.PI_MAIN: "InvitationsPlugin",
40 C.PI_HANDLER: "no",
41 C.PI_DESCRIPTION: _(u"""invitation of people without XMPP account""")
42 }
43
44
45 SUFFIX_MAX = 5
46 INVITEE_PROFILE_TPL = u"guest@@{uuid}"
47 KEY_CREATED = u'created'
48 KEY_LAST_CONNECTION = u'last_connection'
49 EXTRA_RESERVED = {KEY_CREATED, u'jid_', u'jid', KEY_LAST_CONNECTION}
50 DEFAULT_SUBJECT = D_(u"You have been invited by {host_name} to {app_name}")
51 DEFAULT_BODY = D_(u"""Hello {name}!
52
53 You have received an invitation from {host_name} to participate to "{app_name}".
54 To join, you just have to click on the following URL:
55 {url}
56
57 Please note that this URL should not be shared with anybody!
58 If you want more details on {app_name}, you can check {app_url}.
59
60 Welcome!
61 """)
62
63
64 class InvitationsPlugin(object):
65 # TODO: plugin unload
66
67 def __init__(self, host):
68 log.info(_(u"plugin Invitations initialization"))
69 self.host = host
70 self.invitations = persistent.LazyPersistentBinaryDict(u'invitations')
71 host.bridge.addMethod("invitationCreate", ".plugin", in_sign='sssssssssa{ss}s', out_sign='(sa{ss})',
72 method=self._createInvitation,
73 async=True)
74 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''):
75 # XXX: we don't use **kwargs here to keep arguments name for introspection with D-Bus bridge
76
77 if extra is None:
78 extra = {}
79 else:
80 extra = {unicode(k): unicode(v) for k,v in extra.iteritems()}
81
82 # we need to be sure that values are unicode, else they won't be pickled correctly with D-Bus
83 kwargs = {"extra": extra}
84 for key in ("jid_", "password", "name", "host_name", "email", "language", "url_template", "message_subject", "message_body", "profile"):
85 value = locals()[key]
86 if value:
87 kwargs[key] = unicode(value)
88 return self.createInvitation(**kwargs)
89
90 @defer.inlineCallbacks
91 def createInvitation(self, **kwargs):
92 ur"""create an invitation
93
94 this will create an XMPP account and a profile, and use a UUID to retrieve them.
95 the profile is automatically generated in the form guest@@[UUID], this way they can be retrieved easily
96 **kwargs: keywords arguments which can have the following keys, unset values are equivalent to None:
97 jid_(jid.JID, None): jid to use for invitation, the jid will be created using XEP-0077
98 if the jid has no user part, an anonymous account will be used (no XMPP account created in this case)
99 if None, automatically generate an account name (in the form "invitation-[UUID]@domain.tld")
100 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)
101 password(unicode, None): password to use (will be used for XMPP account and profile)
102 None to automatically generate one
103 name(unicode, None): name of the invitee
104 host_name(unicode, None): name of the host
105 email(unicode, None): email to send the invitation to
106 if None, no invitation email is sent, you can still associate email using extra
107 if email is used, extra can't have "email" key
108 language(unicode): language of the invitee (used notabily to translate the invitation)
109 TODO: not used yet
110 url_template(unicode, None): template to use to construct the invitation URL
111 use {uuid} as a placeholder for identifier
112 use None if you don't want to include URL (or if it is already specified in custom message)
113 /!\ you must put full URL, don't forget https://
114 /!\ the URL will give access to the invitee account, you should warn in message to not publish it publicly
115 message_subject(unicode, None): customised message body for t:o he invitation email
116 None to use default subject
117 uses the same substitution as for message_body
118 message_body(unicode, None): customised message body for the invitation email
119 None to use default body
120 use {name} as a place holder for invitee name
121 use {url} as a placeholder for the invitation url
122 use {uuid} as a placeholder for the identifier
123 use {app_name} as a placeholder for this software name
124 use {app_url} as a placeholder for this software official website
125 use {profile} as a placeholder for host's profile
126 use {host_name} as a placeholder for host's name
127 extra(dict, None): extra data to associate with the invitee
128 some keys are reserved:
129 - created (creation date)
130 if email argument is used, "email" key can't be used
131 profile(unicode, None): profile of the host (person who is inviting)
132 @return (unicode, dict[unicode, unicode]): tuple with:
133 - UUID associated with the invitee
134 - filled extra dictionary, as saved in the databae
135 """
136 ## initial checks
137 extra = kwargs.pop('extra', {})
138 if set(kwargs).intersection(extra):
139 raise exceptions.ValueError(_(u"You can't use following key(s) in both args and extra: {}").format(
140 u', '.join(set(kwargs).intersection(extra))))
141
142 if EXTRA_RESERVED.intersection(extra):
143 raise exceptions.ValueError(_(u"You can't use following key(s) in extra, they are reserved: {}").format(
144 u', '.join(EXTRA_RESERVED.intersection(extra))))
145
146 ## uuid
147 log.info(_(u"creating an invitation"))
148 id_ = unicode(shortuuid.uuid())
149
150 ## XMPP account creation
151 password = kwargs.pop(u'password', None)
152 if password is None:
153 password = utils.generatePassword()
154 assert password
155 # XXX: password is here saved in clear in database
156 # it is needed for invitation as the same password is used for profile
157 # and SàT need to be able to automatically open the profile with the uuid
158 # FIXME: we could add an extra encryption key which would be used with the uuid
159 # when the invitee is connecting (e.g. with URL). This key would not be saved
160 # and could be used to encrypt profile password.
161 extra[u'password'] = password
162
163 jid_ = kwargs.pop(u'jid_', None)
164 if not jid_:
165 domain = self.host.memory.getConfig(None, 'xmpp_domain')
166 if not domain:
167 # TODO: fallback to profile's domain
168 raise ValueError(_(u"You need to specify xmpp_domain in sat.conf"))
169 jid_ = u"invitation-{uuid}@{domain}".format(uuid=id_, domain=domain)
170 jid_ = jid.JID(jid_)
171 if jid_.user:
172 # we don't register account if there is no user as anonymous login is then used
173 try:
174 yield self.host.plugins['XEP-0077'].registerNewAccount(jid_, password)
175 except error.StanzaError as e:
176 prefix = jid_.user
177 idx = 0
178 while e.condition == u'conflict':
179 if idx >= SUFFIX_MAX:
180 raise exceptions.ConflictError(_(u"Can't create XMPP account"))
181 jid_.user = prefix + '_' + unicode(idx)
182 log.info(_(u"requested jid already exists, trying with {}".format(jid_.full())))
183 try:
184 yield self.host.plugins['XEP-0077'].registerNewAccount(jid_, password)
185 except error.StanzaError as e:
186 idx += 1
187 else:
188 break
189 if e.condition != u'conflict':
190 raise e
191
192 log.info(_(u"account {jid_} created").format(jid_=jid_.full()))
193
194 ## profile creation
195 extra['guest_profile'] = guest_profile = INVITEE_PROFILE_TPL.format(uuid=id_)
196 # profile creation should not fail as we generate unique name ourselves
197 yield self.host.memory.createProfile(guest_profile, password)
198 yield self.host.memory.startSession(password, guest_profile)
199 yield self.host.memory.setParam("JabberID", jid_.full(), "Connection", profile_key=guest_profile)
200 yield self.host.memory.setParam("Password", password, "Connection", profile_key=guest_profile)
201
202 ## email
203 language = kwargs.pop(u'language', None)
204 if language is not None:
205 extra[u'language'] = language
206 email = kwargs.pop(u'email', None)
207
208 if email is not None:
209 url_template = kwargs.pop(u'url_template', '')
210 format_args = {
211 u'uuid': id_,
212 u'app_name': C.APP_NAME,
213 u'app_url': C.APP_URL}
214
215 name = kwargs.pop(u'name', None)
216 if name is None:
217 format_args[u'name'] = email
218 else:
219 format_args[u'name'] = extra[u'name'] = name
220
221 profile = kwargs.pop(u'profile', None)
222 if profile is None:
223 format_args[u'profile'] = u''
224 else:
225 format_args[u'profile'] = extra[u'profile'] = profile
226
227 host_name = kwargs.pop(u'host_name', None)
228 if host_name is None:
229 format_args[u'host_name'] = profile or _(u"somebody")
230 else:
231 format_args[u'host_name'] = extra[u'host_name'] = host_name
232
233 invite_url = url_template.format(**format_args)
234 format_args[u'url'] = invite_url
235
236 yield sat_email.sendEmail(
237 self.host,
238 [email],
239 (kwargs.pop(u'message_subject', None) or DEFAULT_SUBJECT).format(**format_args),
240 (kwargs.pop(u'message_body', None) or DEFAULT_BODY).format(**format_args),
241 )
242
243 ## extra data saving
244 self.invitations[id_] = extra
245
246 if kwargs:
247 log.warning(_(u"Not all arguments have been consumed: {}").format(kwargs))
248
249 defer.returnValue((id_, extra))