Mercurial > libervia-backend
comparison libervia/backend/plugins/plugin_misc_email_invitation.py @ 4071:4b842c1fb686
refactoring: renamed `sat` package to `libervia.backend`
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 11:49:51 +0200 |
parents | sat/plugins/plugin_misc_email_invitation.py@524856bd7b19 |
children | 0d7bb4df2343 |
comparison
equal
deleted
inserted
replaced
4070:d10748475025 | 4071:4b842c1fb686 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # SàT plugin for sending invitations by email | |
4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) | |
5 | |
6 # This program is free software: you can redistribute it and/or modify | |
7 # it under the terms of the GNU Affero General Public License as published by | |
8 # the Free Software Foundation, either version 3 of the License, or | |
9 # (at your option) any later version. | |
10 | |
11 # This program is distributed in the hope that it will be useful, | |
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
14 # GNU Affero General Public License for more details. | |
15 | |
16 # You should have received a copy of the GNU Affero General Public License | |
17 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
18 | |
19 import shortuuid | |
20 from typing import Optional | |
21 from twisted.internet import defer | |
22 from twisted.words.protocols.jabber import jid | |
23 from twisted.words.protocols.jabber import error | |
24 from twisted.words.protocols.jabber import sasl | |
25 from libervia.backend.core.i18n import _, D_ | |
26 from libervia.backend.core.constants import Const as C | |
27 from libervia.backend.core import exceptions | |
28 from libervia.backend.core.log import getLogger | |
29 from libervia.backend.tools import utils | |
30 from libervia.backend.tools.common import data_format | |
31 from libervia.backend.memory import persistent | |
32 from libervia.backend.tools.common import email as sat_email | |
33 | |
34 log = getLogger(__name__) | |
35 | |
36 | |
37 PLUGIN_INFO = { | |
38 C.PI_NAME: "Email Invitations", | |
39 C.PI_IMPORT_NAME: "EMAIL_INVITATION", | |
40 C.PI_TYPE: C.PLUG_TYPE_MISC, | |
41 C.PI_DEPENDENCIES: ['XEP-0077'], | |
42 C.PI_RECOMMENDATIONS: ["IDENTITY"], | |
43 C.PI_MAIN: "InvitationsPlugin", | |
44 C.PI_HANDLER: "no", | |
45 C.PI_DESCRIPTION: _("""invitation of people without XMPP account""") | |
46 } | |
47 | |
48 | |
49 SUFFIX_MAX = 5 | |
50 INVITEE_PROFILE_TPL = "guest@@{uuid}" | |
51 KEY_ID = 'id' | |
52 KEY_JID = 'jid' | |
53 KEY_CREATED = 'created' | |
54 KEY_LAST_CONNECTION = 'last_connection' | |
55 KEY_GUEST_PROFILE = 'guest_profile' | |
56 KEY_PASSWORD = 'password' | |
57 KEY_EMAILS_EXTRA = 'emails_extra' | |
58 EXTRA_RESERVED = {KEY_ID, KEY_JID, KEY_CREATED, 'jid_', 'jid', KEY_LAST_CONNECTION, | |
59 KEY_GUEST_PROFILE, KEY_PASSWORD, KEY_EMAILS_EXTRA} | |
60 DEFAULT_SUBJECT = D_("You have been invited by {host_name} to {app_name}") | |
61 DEFAULT_BODY = D_("""Hello {name}! | |
62 | |
63 You have received an invitation from {host_name} to participate to "{app_name}". | |
64 To join, you just have to click on the following URL: | |
65 {url} | |
66 | |
67 Please note that this URL should not be shared with anybody! | |
68 If you want more details on {app_name}, you can check {app_url}. | |
69 | |
70 Welcome! | |
71 """) | |
72 | |
73 | |
74 class InvitationsPlugin(object): | |
75 | |
76 def __init__(self, host): | |
77 log.info(_("plugin Invitations initialization")) | |
78 self.host = host | |
79 self.invitations = persistent.LazyPersistentBinaryDict('invitations') | |
80 host.bridge.add_method("invitation_create", ".plugin", in_sign='sasssssssssa{ss}s', | |
81 out_sign='a{ss}', | |
82 method=self._create, | |
83 async_=True) | |
84 host.bridge.add_method("invitation_get", ".plugin", in_sign='s', out_sign='a{ss}', | |
85 method=self.get, | |
86 async_=True) | |
87 host.bridge.add_method("invitation_delete", ".plugin", in_sign='s', out_sign='', | |
88 method=self._delete, | |
89 async_=True) | |
90 host.bridge.add_method("invitation_modify", ".plugin", in_sign='sa{ss}b', | |
91 out_sign='', | |
92 method=self._modify, | |
93 async_=True) | |
94 host.bridge.add_method("invitation_list", ".plugin", in_sign='s', | |
95 out_sign='a{sa{ss}}', | |
96 method=self._list, | |
97 async_=True) | |
98 host.bridge.add_method("invitation_simple_create", ".plugin", in_sign='sssss', | |
99 out_sign='a{ss}', | |
100 method=self._simple_create, | |
101 async_=True) | |
102 | |
103 def check_extra(self, extra): | |
104 if EXTRA_RESERVED.intersection(extra): | |
105 raise ValueError( | |
106 _("You can't use following key(s) in extra, they are reserved: {}") | |
107 .format(', '.join(EXTRA_RESERVED.intersection(extra)))) | |
108 | |
109 def _create(self, email='', emails_extra=None, jid_='', password='', name='', | |
110 host_name='', language='', url_template='', message_subject='', | |
111 message_body='', extra=None, profile=''): | |
112 # XXX: we don't use **kwargs here to keep arguments name for introspection with | |
113 # D-Bus bridge | |
114 if emails_extra is None: | |
115 emails_extra = [] | |
116 | |
117 if extra is None: | |
118 extra = {} | |
119 else: | |
120 extra = {str(k): str(v) for k,v in extra.items()} | |
121 | |
122 kwargs = {"extra": extra, | |
123 KEY_EMAILS_EXTRA: [str(e) for e in emails_extra] | |
124 } | |
125 | |
126 # we need to be sure that values are unicode, else they won't be pickled correctly | |
127 # with D-Bus | |
128 for key in ("jid_", "password", "name", "host_name", "email", "language", | |
129 "url_template", "message_subject", "message_body", "profile"): | |
130 value = locals()[key] | |
131 if value: | |
132 kwargs[key] = str(value) | |
133 return defer.ensureDeferred(self.create(**kwargs)) | |
134 | |
135 async def get_existing_invitation(self, email: Optional[str]) -> Optional[dict]: | |
136 """Retrieve existing invitation with given email | |
137 | |
138 @param email: check if any invitation exist with this email | |
139 @return: first found invitation, or None if nothing found | |
140 """ | |
141 # FIXME: This method is highly inefficient, it get all invitations and check them | |
142 # one by one, this is just a temporary way to avoid creating creating new accounts | |
143 # for an existing email. A better way will be available with Libervia 0.9. | |
144 # TODO: use a better way to check existing invitations | |
145 | |
146 if email is None: | |
147 return None | |
148 all_invitations = await self.invitations.all() | |
149 for id_, invitation in all_invitations.items(): | |
150 if invitation.get("email") == email: | |
151 invitation[KEY_ID] = id_ | |
152 return invitation | |
153 | |
154 async def _create_account_and_profile( | |
155 self, | |
156 id_: str, | |
157 kwargs: dict, | |
158 extra: dict | |
159 ) -> None: | |
160 """Create XMPP account and Libervia profile for guest""" | |
161 ## XMPP account creation | |
162 password = kwargs.pop('password', None) | |
163 if password is None: | |
164 password = utils.generate_password() | |
165 assert password | |
166 # XXX: password is here saved in clear in database | |
167 # it is needed for invitation as the same password is used for profile | |
168 # and SàT need to be able to automatically open the profile with the uuid | |
169 # FIXME: we could add an extra encryption key which would be used with the | |
170 # uuid when the invitee is connecting (e.g. with URL). This key would | |
171 # not be saved and could be used to encrypt profile password. | |
172 extra[KEY_PASSWORD] = password | |
173 | |
174 jid_ = kwargs.pop('jid_', None) | |
175 if not jid_: | |
176 domain = self.host.memory.config_get(None, 'xmpp_domain') | |
177 if not domain: | |
178 # TODO: fallback to profile's domain | |
179 raise ValueError(_("You need to specify xmpp_domain in sat.conf")) | |
180 jid_ = "invitation-{uuid}@{domain}".format(uuid=shortuuid.uuid(), | |
181 domain=domain) | |
182 jid_ = jid.JID(jid_) | |
183 extra[KEY_JID] = jid_.full() | |
184 | |
185 if jid_.user: | |
186 # we don't register account if there is no user as anonymous login is then | |
187 # used | |
188 try: | |
189 await self.host.plugins['XEP-0077'].register_new_account(jid_, password) | |
190 except error.StanzaError as e: | |
191 prefix = jid_.user | |
192 idx = 0 | |
193 while e.condition == 'conflict': | |
194 if idx >= SUFFIX_MAX: | |
195 raise exceptions.ConflictError(_("Can't create XMPP account")) | |
196 jid_.user = prefix + '_' + str(idx) | |
197 log.info(_("requested jid already exists, trying with {}".format( | |
198 jid_.full()))) | |
199 try: | |
200 await self.host.plugins['XEP-0077'].register_new_account( | |
201 jid_, | |
202 password | |
203 ) | |
204 except error.StanzaError: | |
205 idx += 1 | |
206 else: | |
207 break | |
208 if e.condition != 'conflict': | |
209 raise e | |
210 | |
211 log.info(_("account {jid_} created").format(jid_=jid_.full())) | |
212 | |
213 ## profile creation | |
214 | |
215 extra[KEY_GUEST_PROFILE] = guest_profile = INVITEE_PROFILE_TPL.format( | |
216 uuid=id_ | |
217 ) | |
218 # profile creation should not fail as we generate unique name ourselves | |
219 await self.host.memory.create_profile(guest_profile, password) | |
220 await self.host.memory.start_session(password, guest_profile) | |
221 await self.host.memory.param_set("JabberID", jid_.full(), "Connection", | |
222 profile_key=guest_profile) | |
223 await self.host.memory.param_set("Password", password, "Connection", | |
224 profile_key=guest_profile) | |
225 | |
226 async def create(self, **kwargs): | |
227 r"""Create an invitation | |
228 | |
229 This will create an XMPP account and a profile, and use a UUID to retrieve them. | |
230 The profile is automatically generated in the form guest@@[UUID], this way they | |
231 can be retrieved easily | |
232 **kwargs: keywords arguments which can have the following keys, unset values are | |
233 equivalent to None: | |
234 jid_(jid.JID, None): jid to use for invitation, the jid will be created using | |
235 XEP-0077 | |
236 if the jid has no user part, an anonymous account will be used (no XMPP | |
237 account created in this case) | |
238 if None, automatically generate an account name (in the form | |
239 "invitation-[random UUID]@domain.tld") (note that this UUID is not the | |
240 same as the invitation one, as jid can be used publicly (leaking the | |
241 UUID), and invitation UUID give access to account. | |
242 in case of conflict, a suffix number is added to the account until a free | |
243 one if found (with a failure if SUFFIX_MAX is reached) | |
244 password(unicode, None): password to use (will be used for XMPP account and | |
245 profile) | |
246 None to automatically generate one | |
247 name(unicode, None): name of the invitee | |
248 will be set as profile identity if present | |
249 host_name(unicode, None): name of the host | |
250 email(unicode, None): email to send the invitation to | |
251 if None, no invitation email is sent, you can still associate email using | |
252 extra | |
253 if email is used, extra can't have "email" key | |
254 language(unicode): language of the invitee (used notabily to translate the | |
255 invitation) | |
256 TODO: not used yet | |
257 url_template(unicode, None): template to use to construct the invitation URL | |
258 use {uuid} as a placeholder for identifier | |
259 use None if you don't want to include URL (or if it is already specified | |
260 in custom message) | |
261 /!\ you must put full URL, don't forget https:// | |
262 /!\ the URL will give access to the invitee account, you should warn in | |
263 message to not publish it publicly | |
264 message_subject(unicode, None): customised message body for the invitation | |
265 email | |
266 None to use default subject | |
267 uses the same substitution as for message_body | |
268 message_body(unicode, None): customised message body for the invitation email | |
269 None to use default body | |
270 use {name} as a place holder for invitee name | |
271 use {url} as a placeholder for the invitation url | |
272 use {uuid} as a placeholder for the identifier | |
273 use {app_name} as a placeholder for this software name | |
274 use {app_url} as a placeholder for this software official website | |
275 use {profile} as a placeholder for host's profile | |
276 use {host_name} as a placeholder for host's name | |
277 extra(dict, None): extra data to associate with the invitee | |
278 some keys are reserved: | |
279 - created (creation date) | |
280 if email argument is used, "email" key can't be used | |
281 profile(unicode, None): profile of the host (person who is inviting) | |
282 @return (dict[unicode, unicode]): dictionary with: | |
283 - UUID associated with the invitee (key: id) | |
284 - filled extra dictionary, as saved in the databae | |
285 """ | |
286 ## initial checks | |
287 extra = kwargs.pop('extra', {}) | |
288 if set(kwargs).intersection(extra): | |
289 raise ValueError( | |
290 _("You can't use following key(s) in both args and extra: {}").format( | |
291 ', '.join(set(kwargs).intersection(extra)))) | |
292 | |
293 self.check_extra(extra) | |
294 | |
295 email = kwargs.pop('email', None) | |
296 | |
297 existing = await self.get_existing_invitation(email) | |
298 if existing is not None: | |
299 log.info(f"There is already an invitation for {email!r}") | |
300 extra.update(existing) | |
301 del extra[KEY_ID] | |
302 | |
303 emails_extra = kwargs.pop('emails_extra', []) | |
304 if not email and emails_extra: | |
305 raise ValueError( | |
306 _('You need to provide a main email address before using emails_extra')) | |
307 | |
308 if (email is not None | |
309 and not 'url_template' in kwargs | |
310 and not 'message_body' in kwargs): | |
311 raise ValueError( | |
312 _("You need to provide url_template if you use default message body")) | |
313 | |
314 ## uuid | |
315 log.info(_("creating an invitation")) | |
316 id_ = existing[KEY_ID] if existing else str(shortuuid.uuid()) | |
317 | |
318 if existing is None: | |
319 await self._create_account_and_profile(id_, kwargs, extra) | |
320 | |
321 profile = kwargs.pop('profile', None) | |
322 guest_profile = extra[KEY_GUEST_PROFILE] | |
323 jid_ = jid.JID(extra[KEY_JID]) | |
324 | |
325 ## identity | |
326 name = kwargs.pop('name', None) | |
327 password = extra[KEY_PASSWORD] | |
328 if name is not None: | |
329 extra['name'] = name | |
330 try: | |
331 id_plugin = self.host.plugins['IDENTITY'] | |
332 except KeyError: | |
333 pass | |
334 else: | |
335 await self.host.connect(guest_profile, password) | |
336 guest_client = self.host.get_client(guest_profile) | |
337 await id_plugin.set_identity(guest_client, {'nicknames': [name]}) | |
338 await self.host.disconnect(guest_profile) | |
339 | |
340 ## email | |
341 language = kwargs.pop('language', None) | |
342 if language is not None: | |
343 extra['language'] = language.strip() | |
344 | |
345 if email is not None: | |
346 extra['email'] = email | |
347 data_format.iter2dict(KEY_EMAILS_EXTRA, extra) | |
348 url_template = kwargs.pop('url_template', '') | |
349 format_args = { | |
350 'uuid': id_, | |
351 'app_name': C.APP_NAME, | |
352 'app_url': C.APP_URL} | |
353 | |
354 if name is None: | |
355 format_args['name'] = email | |
356 else: | |
357 format_args['name'] = name | |
358 | |
359 if profile is None: | |
360 format_args['profile'] = '' | |
361 else: | |
362 format_args['profile'] = extra['profile'] = profile | |
363 | |
364 host_name = kwargs.pop('host_name', None) | |
365 if host_name is None: | |
366 format_args['host_name'] = profile or _("somebody") | |
367 else: | |
368 format_args['host_name'] = extra['host_name'] = host_name | |
369 | |
370 invite_url = url_template.format(**format_args) | |
371 format_args['url'] = invite_url | |
372 | |
373 await sat_email.send_email( | |
374 self.host.memory.config, | |
375 [email] + emails_extra, | |
376 (kwargs.pop('message_subject', None) or DEFAULT_SUBJECT).format( | |
377 **format_args), | |
378 (kwargs.pop('message_body', None) or DEFAULT_BODY).format(**format_args), | |
379 ) | |
380 | |
381 ## roster | |
382 | |
383 # we automatically add guest to host roster (if host is specified) | |
384 # FIXME: a parameter to disable auto roster adding would be nice | |
385 if profile is not None: | |
386 try: | |
387 client = self.host.get_client(profile) | |
388 except Exception as e: | |
389 log.error(f"Can't get host profile: {profile}: {e}") | |
390 else: | |
391 await self.host.contact_update(client, jid_, name, ['guests']) | |
392 | |
393 if kwargs: | |
394 log.warning(_("Not all arguments have been consumed: {}").format(kwargs)) | |
395 | |
396 ## extra data saving | |
397 self.invitations[id_] = extra | |
398 | |
399 extra[KEY_ID] = id_ | |
400 | |
401 return extra | |
402 | |
403 def _simple_create(self, invitee_email, invitee_name, url_template, extra_s, profile): | |
404 client = self.host.get_client(profile) | |
405 # FIXME: needed because python-dbus use a specific string class | |
406 invitee_email = str(invitee_email) | |
407 invitee_name = str(invitee_name) | |
408 url_template = str(url_template) | |
409 extra = data_format.deserialise(extra_s) | |
410 d = defer.ensureDeferred( | |
411 self.simple_create(client, invitee_email, invitee_name, url_template, extra) | |
412 ) | |
413 d.addCallback(lambda data: {k: str(v) for k,v in data.items()}) | |
414 return d | |
415 | |
416 async def simple_create( | |
417 self, client, invitee_email, invitee_name, url_template, extra): | |
418 """Simplified method to invite somebody by email""" | |
419 return await self.create( | |
420 name=invitee_name, | |
421 email=invitee_email, | |
422 url_template=url_template, | |
423 profile=client.profile, | |
424 ) | |
425 | |
426 def get(self, id_): | |
427 """Retrieve invitation linked to uuid if it exists | |
428 | |
429 @param id_(unicode): UUID linked to an invitation | |
430 @return (dict[unicode, unicode]): data associated to the invitation | |
431 @raise KeyError: there is not invitation with this id_ | |
432 """ | |
433 return self.invitations[id_] | |
434 | |
435 def _delete(self, id_): | |
436 return defer.ensureDeferred(self.delete(id_)) | |
437 | |
438 async def delete(self, id_): | |
439 """Delete an invitation data and associated XMPP account""" | |
440 log.info(f"deleting invitation {id_}") | |
441 data = await self.get(id_) | |
442 guest_profile = data['guest_profile'] | |
443 password = data['password'] | |
444 try: | |
445 await self.host.connect(guest_profile, password) | |
446 guest_client = self.host.get_client(guest_profile) | |
447 # XXX: be extra careful to use guest_client and not client below, as this will | |
448 # delete the associated XMPP account | |
449 log.debug("deleting XMPP account") | |
450 await self.host.plugins['XEP-0077'].unregister(guest_client, None) | |
451 except (error.StanzaError, sasl.SASLAuthError) as e: | |
452 log.warning( | |
453 f"Can't delete {guest_profile}'s XMPP account, maybe it as already been " | |
454 f"deleted: {e}") | |
455 try: | |
456 await self.host.memory.profile_delete_async(guest_profile, True) | |
457 except Exception as e: | |
458 log.warning(f"Can't delete guest profile {guest_profile}: {e}") | |
459 log.debug("removing guest data") | |
460 await self.invitations.adel(id_) | |
461 log.info(f"{id_} invitation has been deleted") | |
462 | |
463 def _modify(self, id_, new_extra, replace): | |
464 return self.modify(id_, {str(k): str(v) for k,v in new_extra.items()}, | |
465 replace) | |
466 | |
467 def modify(self, id_, new_extra, replace=False): | |
468 """Modify invitation data | |
469 | |
470 @param id_(unicode): UUID linked to an invitation | |
471 @param new_extra(dict[unicode, unicode]): data to update | |
472 empty values will be deleted if replace is True | |
473 @param replace(bool): if True replace the data | |
474 else update them | |
475 @raise KeyError: there is not invitation with this id_ | |
476 """ | |
477 self.check_extra(new_extra) | |
478 def got_current_data(current_data): | |
479 if replace: | |
480 new_data = new_extra | |
481 for k in EXTRA_RESERVED: | |
482 try: | |
483 new_data[k] = current_data[k] | |
484 except KeyError: | |
485 continue | |
486 else: | |
487 new_data = current_data | |
488 for k,v in new_extra.items(): | |
489 if k in EXTRA_RESERVED: | |
490 log.warning(_("Skipping reserved key {key}").format(key=k)) | |
491 continue | |
492 if v: | |
493 new_data[k] = v | |
494 else: | |
495 try: | |
496 del new_data[k] | |
497 except KeyError: | |
498 pass | |
499 | |
500 self.invitations[id_] = new_data | |
501 | |
502 d = self.invitations[id_] | |
503 d.addCallback(got_current_data) | |
504 return d | |
505 | |
506 def _list(self, profile=C.PROF_KEY_NONE): | |
507 return defer.ensureDeferred(self.list(profile)) | |
508 | |
509 async def list(self, profile=C.PROF_KEY_NONE): | |
510 """List invitations | |
511 | |
512 @param profile(unicode): return invitation linked to this profile only | |
513 C.PROF_KEY_NONE: don't filter invitations | |
514 @return list(unicode): invitations uids | |
515 """ | |
516 invitations = await self.invitations.all() | |
517 if profile != C.PROF_KEY_NONE: | |
518 invitations = {id_:data for id_, data in invitations.items() | |
519 if data.get('profile') == profile} | |
520 | |
521 return invitations |