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)