Mercurial > libervia-backend
comparison sat/plugins/plugin_misc_email_invitation.py @ 2912:a3faf1c86596
plugin events: refactored invitation and personal lists logic:
- invitation logic has been moved to a new generic "plugin_exp_invitation" plugin
- plugin_misc_invitations has be rename "plugin_exp_email_invitation" to avoid confusion
- personal event list has be refactored to use a new experimental "list of interest", which regroup all interestings items, events or other ones
author | Goffi <goffi@goffi.org> |
---|---|
date | Sun, 14 Apr 2019 08:21:51 +0200 |
parents | sat/plugins/plugin_misc_invitations.py@90146552cde5 |
children | 420897488080 |
comparison
equal
deleted
inserted
replaced
2911:cd391ea847cb | 2912:a3faf1c86596 |
---|---|
1 #!/usr/bin/env python2 | |
2 # -*- coding: utf-8 -*- | |
3 | |
4 # SAT plugin for file tansfer | |
5 # Copyright (C) 2009-2019 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 import shortuuid | |
21 from twisted.internet import defer | |
22 from twisted.words.protocols.jabber import jid | |
23 from twisted.words.protocols.jabber import error | |
24 from sat.core.i18n import _, D_ | |
25 from sat.core.constants import Const as C | |
26 from sat.core import exceptions | |
27 from sat.core.log import getLogger | |
28 from sat.tools import utils | |
29 from sat.tools.common import data_format | |
30 from sat.memory import persistent | |
31 from sat.tools import email as sat_email | |
32 | |
33 log = getLogger(__name__) | |
34 | |
35 | |
36 PLUGIN_INFO = { | |
37 C.PI_NAME: "Invitations", | |
38 C.PI_IMPORT_NAME: "EMAIL_INVITATION", | |
39 C.PI_TYPE: C.PLUG_TYPE_MISC, | |
40 C.PI_DEPENDENCIES: ['XEP-0077'], | |
41 C.PI_RECOMMENDATIONS: ["IDENTITY"], | |
42 C.PI_MAIN: "InvitationsPlugin", | |
43 C.PI_HANDLER: "no", | |
44 C.PI_DESCRIPTION: _(u"""invitation of people without XMPP account""") | |
45 } | |
46 | |
47 | |
48 SUFFIX_MAX = 5 | |
49 INVITEE_PROFILE_TPL = u"guest@@{uuid}" | |
50 KEY_ID = u'id' | |
51 KEY_JID = u'jid' | |
52 KEY_CREATED = u'created' | |
53 KEY_LAST_CONNECTION = u'last_connection' | |
54 KEY_GUEST_PROFILE = u'guest_profile' | |
55 KEY_PASSWORD = u'password' | |
56 KEY_EMAILS_EXTRA = u'emails_extra' | |
57 EXTRA_RESERVED = {KEY_ID, KEY_JID, KEY_CREATED, u'jid_', u'jid', KEY_LAST_CONNECTION, | |
58 KEY_GUEST_PROFILE, KEY_PASSWORD, KEY_EMAILS_EXTRA} | |
59 DEFAULT_SUBJECT = D_(u"You have been invited by {host_name} to {app_name}") | |
60 DEFAULT_BODY = D_(u"""Hello {name}! | |
61 | |
62 You have received an invitation from {host_name} to participate to "{app_name}". | |
63 To join, you just have to click on the following URL: | |
64 {url} | |
65 | |
66 Please note that this URL should not be shared with anybody! | |
67 If you want more details on {app_name}, you can check {app_url}. | |
68 | |
69 Welcome! | |
70 """) | |
71 | |
72 | |
73 class InvitationsPlugin(object): | |
74 | |
75 def __init__(self, host): | |
76 log.info(_(u"plugin Invitations initialization")) | |
77 self.host = host | |
78 self.invitations = persistent.LazyPersistentBinaryDict(u'invitations') | |
79 host.bridge.addMethod("invitationCreate", ".plugin", in_sign='sasssssssssa{ss}s', | |
80 out_sign='a{ss}', | |
81 method=self._create, | |
82 async=True) | |
83 host.bridge.addMethod("invitationGet", ".plugin", in_sign='s', out_sign='a{ss}', | |
84 method=self.get, | |
85 async=True) | |
86 host.bridge.addMethod("invitationModify", ".plugin", in_sign='sa{ss}b', | |
87 out_sign='', | |
88 method=self._modify, | |
89 async=True) | |
90 host.bridge.addMethod("invitationList", ".plugin", in_sign='s', | |
91 out_sign='a{sa{ss}}', | |
92 method=self._list, | |
93 async=True) | |
94 | |
95 def checkExtra(self, extra): | |
96 if EXTRA_RESERVED.intersection(extra): | |
97 raise ValueError( | |
98 _(u"You can't use following key(s) in extra, they are reserved: {}") | |
99 .format(u', '.join(EXTRA_RESERVED.intersection(extra)))) | |
100 | |
101 def _create(self, email=u'', emails_extra=None, jid_=u'', password=u'', name=u'', | |
102 host_name=u'', language=u'', url_template=u'', message_subject=u'', | |
103 message_body=u'', extra=None, profile=u''): | |
104 # XXX: we don't use **kwargs here to keep arguments name for introspection with | |
105 # D-Bus bridge | |
106 if emails_extra is None: | |
107 emails_extra = [] | |
108 | |
109 if extra is None: | |
110 extra = {} | |
111 else: | |
112 extra = {unicode(k): unicode(v) for k,v in extra.iteritems()} | |
113 | |
114 kwargs = {"extra": extra, | |
115 KEY_EMAILS_EXTRA: [unicode(e) for e in emails_extra] | |
116 } | |
117 | |
118 # we need to be sure that values are unicode, else they won't be pickled correctly | |
119 # with D-Bus | |
120 for key in ("jid_", "password", "name", "host_name", "email", "language", | |
121 "url_template", "message_subject", "message_body", "profile"): | |
122 value = locals()[key] | |
123 if value: | |
124 kwargs[key] = unicode(value) | |
125 d = self.create(**kwargs) | |
126 def serialize(data): | |
127 data[KEY_JID] = data[KEY_JID].full() | |
128 return data | |
129 d.addCallback(serialize) | |
130 return d | |
131 | |
132 @defer.inlineCallbacks | |
133 def create(self, **kwargs): | |
134 ur"""Create an invitation | |
135 | |
136 This will create an XMPP account and a profile, and use a UUID to retrieve them. | |
137 The profile is automatically generated in the form guest@@[UUID], this way they | |
138 can be retrieved easily | |
139 **kwargs: keywords arguments which can have the following keys, unset values are | |
140 equivalent to None: | |
141 jid_(jid.JID, None): jid to use for invitation, the jid will be created using | |
142 XEP-0077 | |
143 if the jid has no user part, an anonymous account will be used (no XMPP | |
144 account created in this case) | |
145 if None, automatically generate an account name (in the form | |
146 "invitation-[random UUID]@domain.tld") (note that this UUID is not the | |
147 same as the invitation one, as jid can be used publicly (leaking the | |
148 UUID), and invitation UUID give access to account. | |
149 in case of conflict, a suffix number is added to the account until a free | |
150 one if found (with a failure if SUFFIX_MAX is reached) | |
151 password(unicode, None): password to use (will be used for XMPP account and | |
152 profile) | |
153 None to automatically generate one | |
154 name(unicode, None): name of the invitee | |
155 will be set as profile identity if present | |
156 host_name(unicode, None): name of the host | |
157 email(unicode, None): email to send the invitation to | |
158 if None, no invitation email is sent, you can still associate email using | |
159 extra | |
160 if email is used, extra can't have "email" key | |
161 language(unicode): language of the invitee (used notabily to translate the | |
162 invitation) | |
163 TODO: not used yet | |
164 url_template(unicode, None): template to use to construct the invitation URL | |
165 use {uuid} as a placeholder for identifier | |
166 use None if you don't want to include URL (or if it is already specified | |
167 in custom message) | |
168 /!\ you must put full URL, don't forget https:// | |
169 /!\ the URL will give access to the invitee account, you should warn in | |
170 message to not publish it publicly | |
171 message_subject(unicode, None): customised message body for the invitation | |
172 email | |
173 None to use default subject | |
174 uses the same substitution as for message_body | |
175 message_body(unicode, None): customised message body for the invitation email | |
176 None to use default body | |
177 use {name} as a place holder for invitee name | |
178 use {url} as a placeholder for the invitation url | |
179 use {uuid} as a placeholder for the identifier | |
180 use {app_name} as a placeholder for this software name | |
181 use {app_url} as a placeholder for this software official website | |
182 use {profile} as a placeholder for host's profile | |
183 use {host_name} as a placeholder for host's name | |
184 extra(dict, None): extra data to associate with the invitee | |
185 some keys are reserved: | |
186 - created (creation date) | |
187 if email argument is used, "email" key can't be used | |
188 profile(unicode, None): profile of the host (person who is inviting) | |
189 @return (dict[unicode, unicode]): dictionary with: | |
190 - UUID associated with the invitee (key: id) | |
191 - filled extra dictionary, as saved in the databae | |
192 """ | |
193 ## initial checks | |
194 extra = kwargs.pop('extra', {}) | |
195 if set(kwargs).intersection(extra): | |
196 raise ValueError( | |
197 _(u"You can't use following key(s) in both args and extra: {}").format( | |
198 u', '.join(set(kwargs).intersection(extra)))) | |
199 | |
200 self.checkExtra(extra) | |
201 | |
202 email = kwargs.pop(u'email', None) | |
203 emails_extra = kwargs.pop(u'emails_extra', []) | |
204 if not email and emails_extra: | |
205 raise ValueError( | |
206 _(u'You need to provide a main email address before using emails_extra')) | |
207 | |
208 if (email is not None | |
209 and not 'url_template' in kwargs | |
210 and not 'message_body' in kwargs): | |
211 raise ValueError( | |
212 _(u"You need to provide url_template if you use default message body")) | |
213 | |
214 ## uuid | |
215 log.info(_(u"creating an invitation")) | |
216 id_ = unicode(shortuuid.uuid()) | |
217 | |
218 ## XMPP account creation | |
219 password = kwargs.pop(u'password', None) | |
220 if password is None: | |
221 password = utils.generatePassword() | |
222 assert password | |
223 # XXX: password is here saved in clear in database | |
224 # it is needed for invitation as the same password is used for profile | |
225 # and SàT need to be able to automatically open the profile with the uuid | |
226 # FIXME: we could add an extra encryption key which would be used with the uuid | |
227 # when the invitee is connecting (e.g. with URL). This key would not be | |
228 # saved and could be used to encrypt profile password. | |
229 extra[KEY_PASSWORD] = password | |
230 | |
231 jid_ = kwargs.pop(u'jid_', None) | |
232 if not jid_: | |
233 domain = self.host.memory.getConfig(None, 'xmpp_domain') | |
234 if not domain: | |
235 # TODO: fallback to profile's domain | |
236 raise ValueError(_(u"You need to specify xmpp_domain in sat.conf")) | |
237 jid_ = u"invitation-{uuid}@{domain}".format(uuid=shortuuid.uuid(), | |
238 domain=domain) | |
239 jid_ = jid.JID(jid_) | |
240 if jid_.user: | |
241 # we don't register account if there is no user as anonymous login is then | |
242 # used | |
243 try: | |
244 yield self.host.plugins['XEP-0077'].registerNewAccount(jid_, password) | |
245 except error.StanzaError as e: | |
246 prefix = jid_.user | |
247 idx = 0 | |
248 while e.condition == u'conflict': | |
249 if idx >= SUFFIX_MAX: | |
250 raise exceptions.ConflictError(_(u"Can't create XMPP account")) | |
251 jid_.user = prefix + '_' + unicode(idx) | |
252 log.info(_(u"requested jid already exists, trying with {}".format( | |
253 jid_.full()))) | |
254 try: | |
255 yield self.host.plugins['XEP-0077'].registerNewAccount(jid_, | |
256 password) | |
257 except error.StanzaError as e: | |
258 idx += 1 | |
259 else: | |
260 break | |
261 if e.condition != u'conflict': | |
262 raise e | |
263 | |
264 log.info(_(u"account {jid_} created").format(jid_=jid_.full())) | |
265 | |
266 ## profile creation | |
267 | |
268 extra[KEY_GUEST_PROFILE] = guest_profile = INVITEE_PROFILE_TPL.format(uuid=id_) | |
269 # profile creation should not fail as we generate unique name ourselves | |
270 yield self.host.memory.createProfile(guest_profile, password) | |
271 yield self.host.memory.startSession(password, guest_profile) | |
272 yield self.host.memory.setParam("JabberID", jid_.full(), "Connection", | |
273 profile_key=guest_profile) | |
274 yield self.host.memory.setParam("Password", password, "Connection", | |
275 profile_key=guest_profile) | |
276 name = kwargs.pop(u'name', None) | |
277 if name is not None: | |
278 extra[u'name'] = name | |
279 try: | |
280 id_plugin = self.host.plugins[u'IDENTITY'] | |
281 except KeyError: | |
282 pass | |
283 else: | |
284 yield self.host.connect(guest_profile, password) | |
285 guest_client = self.host.getClient(guest_profile) | |
286 yield id_plugin.setIdentity(guest_client, {u'nick': name}) | |
287 yield self.host.disconnect(guest_profile) | |
288 | |
289 ## email | |
290 language = kwargs.pop(u'language', None) | |
291 if language is not None: | |
292 extra[u'language'] = language.strip() | |
293 | |
294 if email is not None: | |
295 extra[u'email'] = email | |
296 data_format.iter2dict(KEY_EMAILS_EXTRA, extra) | |
297 url_template = kwargs.pop(u'url_template', '') | |
298 format_args = { | |
299 u'uuid': id_, | |
300 u'app_name': C.APP_NAME, | |
301 u'app_url': C.APP_URL} | |
302 | |
303 if name is None: | |
304 format_args[u'name'] = email | |
305 else: | |
306 format_args[u'name'] = name | |
307 | |
308 profile = kwargs.pop(u'profile', None) | |
309 if profile is None: | |
310 format_args[u'profile'] = u'' | |
311 else: | |
312 format_args[u'profile'] = extra[u'profile'] = profile | |
313 | |
314 host_name = kwargs.pop(u'host_name', None) | |
315 if host_name is None: | |
316 format_args[u'host_name'] = profile or _(u"somebody") | |
317 else: | |
318 format_args[u'host_name'] = extra[u'host_name'] = host_name | |
319 | |
320 invite_url = url_template.format(**format_args) | |
321 format_args[u'url'] = invite_url | |
322 | |
323 yield sat_email.sendEmail( | |
324 self.host, | |
325 [email] + emails_extra, | |
326 (kwargs.pop(u'message_subject', None) or DEFAULT_SUBJECT).format( | |
327 **format_args), | |
328 (kwargs.pop(u'message_body', None) or DEFAULT_BODY).format(**format_args), | |
329 ) | |
330 | |
331 ## extra data saving | |
332 self.invitations[id_] = extra | |
333 | |
334 if kwargs: | |
335 log.warning(_(u"Not all arguments have been consumed: {}").format(kwargs)) | |
336 | |
337 extra[KEY_ID] = id_ | |
338 extra[KEY_JID] = jid_ | |
339 defer.returnValue(extra) | |
340 | |
341 def get(self, id_): | |
342 """Retrieve invitation linked to uuid if it exists | |
343 | |
344 @param id_(unicode): UUID linked to an invitation | |
345 @return (dict[unicode, unicode]): data associated to the invitation | |
346 @raise KeyError: there is not invitation with this id_ | |
347 """ | |
348 return self.invitations[id_] | |
349 | |
350 def _modify(self, id_, new_extra, replace): | |
351 return self.modify(id_, {unicode(k): unicode(v) for k,v in new_extra.iteritems()}, | |
352 replace) | |
353 | |
354 def modify(self, id_, new_extra, replace=False): | |
355 """Modify invitation data | |
356 | |
357 @param id_(unicode): UUID linked to an invitation | |
358 @param new_extra(dict[unicode, unicode]): data to update | |
359 empty values will be deleted if replace is True | |
360 @param replace(bool): if True replace the data | |
361 else update them | |
362 @raise KeyError: there is not invitation with this id_ | |
363 """ | |
364 self.checkExtra(new_extra) | |
365 def gotCurrentData(current_data): | |
366 if replace: | |
367 new_data = new_extra | |
368 for k in EXTRA_RESERVED: | |
369 try: | |
370 new_data[k] = current_data[k] | |
371 except KeyError: | |
372 continue | |
373 else: | |
374 new_data = current_data | |
375 for k,v in new_extra.iteritems(): | |
376 if k in EXTRA_RESERVED: | |
377 log.warning(_(u"Skipping reserved key {key}".format(k))) | |
378 continue | |
379 if v: | |
380 new_data[k] = v | |
381 else: | |
382 try: | |
383 del new_data[k] | |
384 except KeyError: | |
385 pass | |
386 | |
387 self.invitations[id_] = new_data | |
388 | |
389 d = self.invitations[id_] | |
390 d.addCallback(gotCurrentData) | |
391 return d | |
392 | |
393 def _list(self, profile=C.PROF_KEY_NONE): | |
394 return self.list(profile) | |
395 | |
396 @defer.inlineCallbacks | |
397 def list(self, profile=C.PROF_KEY_NONE): | |
398 """List invitations | |
399 | |
400 @param profile(unicode): return invitation linked to this profile only | |
401 C.PROF_KEY_NONE: don't filter invitations | |
402 @return list(unicode): invitations uids | |
403 """ | |
404 invitations = yield self.invitations.items() | |
405 if profile != C.PROF_KEY_NONE: | |
406 invitations = {id_:data for id_, data in invitations.iteritems() | |
407 if data.get(u'profile') == profile} | |
408 | |
409 defer.returnValue(invitations) |