comparison sat/plugins/plugin_misc_invitations.py @ 2562:26edcf3a30eb

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