comparison sat/plugins/plugin_misc_invitations.py @ 2909:90146552cde5

core (memory), plugin XEP-0329, plugin invitation: minor style improvments
author Goffi <goffi@goffi.org>
date Sun, 14 Apr 2019 08:21:51 +0200
parents 003b8b4b56a7
children
comparison
equal deleted inserted replaced
2908:695fc440c3b8 2909:90146552cde5
15 # GNU Affero General Public License for more details. 15 # GNU Affero General Public License for more details.
16 16
17 # You should have received a copy of the GNU Affero General Public License 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/>. 18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 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
20 from sat.core.i18n import _, D_ 24 from sat.core.i18n import _, D_
21 from sat.core.constants import Const as C 25 from sat.core.constants import Const as C
22 from sat.core import exceptions 26 from sat.core import exceptions
23 from sat.core.log import getLogger 27 from sat.core.log import getLogger
24 log = getLogger(__name__)
25 import shortuuid
26 from sat.tools import utils 28 from sat.tools import utils
27 from sat.tools.common import data_format 29 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 30 from sat.memory import persistent
32 from sat.tools import email as sat_email 31 from sat.tools import email as sat_email
32
33 log = getLogger(__name__)
33 34
34 35
35 PLUGIN_INFO = { 36 PLUGIN_INFO = {
36 C.PI_NAME: "Invitations", 37 C.PI_NAME: "Invitations",
37 C.PI_IMPORT_NAME: "INVITATIONS", 38 C.PI_IMPORT_NAME: "INVITATIONS",
51 KEY_CREATED = u'created' 52 KEY_CREATED = u'created'
52 KEY_LAST_CONNECTION = u'last_connection' 53 KEY_LAST_CONNECTION = u'last_connection'
53 KEY_GUEST_PROFILE = u'guest_profile' 54 KEY_GUEST_PROFILE = u'guest_profile'
54 KEY_PASSWORD = u'password' 55 KEY_PASSWORD = u'password'
55 KEY_EMAILS_EXTRA = u'emails_extra' 56 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 EXTRA_RESERVED = {KEY_ID, KEY_JID, KEY_CREATED, u'jid_', u'jid', KEY_LAST_CONNECTION,
58 KEY_GUEST_PROFILE, KEY_PASSWORD, KEY_EMAILS_EXTRA}
57 DEFAULT_SUBJECT = D_(u"You have been invited by {host_name} to {app_name}") 59 DEFAULT_SUBJECT = D_(u"You have been invited by {host_name} to {app_name}")
58 DEFAULT_BODY = D_(u"""Hello {name}! 60 DEFAULT_BODY = D_(u"""Hello {name}!
59 61
60 You have received an invitation from {host_name} to participate to "{app_name}". 62 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: 63 To join, you just have to click on the following URL:
72 74
73 def __init__(self, host): 75 def __init__(self, host):
74 log.info(_(u"plugin Invitations initialization")) 76 log.info(_(u"plugin Invitations initialization"))
75 self.host = host 77 self.host = host
76 self.invitations = persistent.LazyPersistentBinaryDict(u'invitations') 78 self.invitations = persistent.LazyPersistentBinaryDict(u'invitations')
77 host.bridge.addMethod("invitationCreate", ".plugin", in_sign='sasssssssssa{ss}s', out_sign='a{ss}', 79 host.bridge.addMethod("invitationCreate", ".plugin", in_sign='sasssssssssa{ss}s',
80 out_sign='a{ss}',
78 method=self._create, 81 method=self._create,
79 async=True) 82 async=True)
80 host.bridge.addMethod("invitationGet", ".plugin", in_sign='s', out_sign='a{ss}', 83 host.bridge.addMethod("invitationGet", ".plugin", in_sign='s', out_sign='a{ss}',
81 method=self.get, 84 method=self.get,
82 async=True) 85 async=True)
83 host.bridge.addMethod("invitationModify", ".plugin", in_sign='sa{ss}b', out_sign='', 86 host.bridge.addMethod("invitationModify", ".plugin", in_sign='sa{ss}b',
87 out_sign='',
84 method=self._modify, 88 method=self._modify,
85 async=True) 89 async=True)
86 host.bridge.addMethod("invitationList", ".plugin", in_sign='s', out_sign='a{sa{ss}}', 90 host.bridge.addMethod("invitationList", ".plugin", in_sign='s',
91 out_sign='a{sa{ss}}',
87 method=self._list, 92 method=self._list,
88 async=True) 93 async=True)
89 94
90 def checkExtra(self, extra): 95 def checkExtra(self, extra):
91 if EXTRA_RESERVED.intersection(extra): 96 if EXTRA_RESERVED.intersection(extra):
92 raise ValueError(_(u"You can't use following key(s) in extra, they are reserved: {}").format( 97 raise ValueError(
93 u', '.join(EXTRA_RESERVED.intersection(extra)))) 98 _(u"You can't use following key(s) in extra, they are reserved: {}")
94 99 .format(u', '.join(EXTRA_RESERVED.intersection(extra))))
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''): 100
96 # XXX: we don't use **kwargs here to keep arguments name for introspection with D-Bus bridge 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
97 if emails_extra is None: 106 if emails_extra is None:
98 emails_extra = [] 107 emails_extra = []
99 108
100 if extra is None: 109 if extra is None:
101 extra = {} 110 extra = {}
104 113
105 kwargs = {"extra": extra, 114 kwargs = {"extra": extra,
106 KEY_EMAILS_EXTRA: [unicode(e) for e in emails_extra] 115 KEY_EMAILS_EXTRA: [unicode(e) for e in emails_extra]
107 } 116 }
108 117
109 # we need to be sure that values are unicode, else they won't be pickled correctly with D-Bus 118 # we need to be sure that values are unicode, else they won't be pickled correctly
110 for key in ("jid_", "password", "name", "host_name", "email", "language", "url_template", "message_subject", "message_body", "profile"): 119 # with D-Bus
120 for key in ("jid_", "password", "name", "host_name", "email", "language",
121 "url_template", "message_subject", "message_body", "profile"):
111 value = locals()[key] 122 value = locals()[key]
112 if value: 123 if value:
113 kwargs[key] = unicode(value) 124 kwargs[key] = unicode(value)
114 d = self.create(**kwargs) 125 d = self.create(**kwargs)
115 def serialize(data): 126 def serialize(data):
118 d.addCallback(serialize) 129 d.addCallback(serialize)
119 return d 130 return d
120 131
121 @defer.inlineCallbacks 132 @defer.inlineCallbacks
122 def create(self, **kwargs): 133 def create(self, **kwargs):
123 ur"""create an invitation 134 ur"""Create an invitation
124 135
125 this will create an XMPP account and a profile, and use a UUID to retrieve them. 136 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 137 The profile is automatically generated in the form guest@@[UUID], this way they
127 **kwargs: keywords arguments which can have the following keys, unset values are equivalent to None: 138 can be retrieved easily
128 jid_(jid.JID, None): jid to use for invitation, the jid will be created using XEP-0077 139 **kwargs: keywords arguments which can have the following keys, unset values are
129 if the jid has no user part, an anonymous account will be used (no XMPP account created in this case) 140 equivalent to None:
130 if None, automatically generate an account name (in the form "invitation-[random UUID]@domain.tld") (note that this UUID is not the 141 jid_(jid.JID, None): jid to use for invitation, the jid will be created using
131 same as the invitation one, as jid can be used publicly (leaking the UUID), and invitation UUID give access to account. 142 XEP-0077
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) 143 if the jid has no user part, an anonymous account will be used (no XMPP
133 password(unicode, None): password to use (will be used for XMPP account and profile) 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)
134 None to automatically generate one 153 None to automatically generate one
135 name(unicode, None): name of the invitee 154 name(unicode, None): name of the invitee
136 will be set as profile identity if present 155 will be set as profile identity if present
137 host_name(unicode, None): name of the host 156 host_name(unicode, None): name of the host
138 email(unicode, None): email to send the invitation to 157 email(unicode, None): email to send the invitation to
139 if None, no invitation email is sent, you can still associate email using extra 158 if None, no invitation email is sent, you can still associate email using
159 extra
140 if email is used, extra can't have "email" key 160 if email is used, extra can't have "email" key
141 language(unicode): language of the invitee (used notabily to translate the invitation) 161 language(unicode): language of the invitee (used notabily to translate the
162 invitation)
142 TODO: not used yet 163 TODO: not used yet
143 url_template(unicode, None): template to use to construct the invitation URL 164 url_template(unicode, None): template to use to construct the invitation URL
144 use {uuid} as a placeholder for identifier 165 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) 166 use None if you don't want to include URL (or if it is already specified
167 in custom message)
146 /!\ you must put full URL, don't forget https:// 168 /!\ 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 169 /!\ the URL will give access to the invitee account, you should warn in
148 message_subject(unicode, None): customised message body for the invitation email 170 message to not publish it publicly
171 message_subject(unicode, None): customised message body for the invitation
172 email
149 None to use default subject 173 None to use default subject
150 uses the same substitution as for message_body 174 uses the same substitution as for message_body
151 message_body(unicode, None): customised message body for the invitation email 175 message_body(unicode, None): customised message body for the invitation email
152 None to use default body 176 None to use default body
153 use {name} as a place holder for invitee name 177 use {name} as a place holder for invitee name
167 - filled extra dictionary, as saved in the databae 191 - filled extra dictionary, as saved in the databae
168 """ 192 """
169 ## initial checks 193 ## initial checks
170 extra = kwargs.pop('extra', {}) 194 extra = kwargs.pop('extra', {})
171 if set(kwargs).intersection(extra): 195 if set(kwargs).intersection(extra):
172 raise ValueError(_(u"You can't use following key(s) in both args and extra: {}").format( 196 raise ValueError(
197 _(u"You can't use following key(s) in both args and extra: {}").format(
173 u', '.join(set(kwargs).intersection(extra)))) 198 u', '.join(set(kwargs).intersection(extra))))
174 199
175 self.checkExtra(extra) 200 self.checkExtra(extra)
176 201
177 email = kwargs.pop(u'email', None) 202 email = kwargs.pop(u'email', None)
178 emails_extra = kwargs.pop(u'emails_extra', []) 203 emails_extra = kwargs.pop(u'emails_extra', [])
179 if not email and emails_extra: 204 if not email and emails_extra:
180 raise ValueError(_(u'You need to provide a main email address before using emails_extra')) 205 raise ValueError(
181 206 _(u'You need to provide a main email address before using emails_extra'))
182 if email is not None and not 'url_template' in kwargs and not 'message_body' in kwargs: 207
183 raise ValueError(_(u"You need to provide url_template if you use default message body")) 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"))
184 213
185 ## uuid 214 ## uuid
186 log.info(_(u"creating an invitation")) 215 log.info(_(u"creating an invitation"))
187 id_ = unicode(shortuuid.uuid()) 216 id_ = unicode(shortuuid.uuid())
188 217
193 assert password 222 assert password
194 # XXX: password is here saved in clear in database 223 # XXX: password is here saved in clear in database
195 # it is needed for invitation as the same password is used for profile 224 # 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 225 # 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 226 # 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 227 # when the invitee is connecting (e.g. with URL). This key would not be
199 # and could be used to encrypt profile password. 228 # saved and could be used to encrypt profile password.
200 extra[KEY_PASSWORD] = password 229 extra[KEY_PASSWORD] = password
201 230
202 jid_ = kwargs.pop(u'jid_', None) 231 jid_ = kwargs.pop(u'jid_', None)
203 if not jid_: 232 if not jid_:
204 domain = self.host.memory.getConfig(None, 'xmpp_domain') 233 domain = self.host.memory.getConfig(None, 'xmpp_domain')
205 if not domain: 234 if not domain:
206 # TODO: fallback to profile's domain 235 # TODO: fallback to profile's domain
207 raise ValueError(_(u"You need to specify xmpp_domain in sat.conf")) 236 raise ValueError(_(u"You need to specify xmpp_domain in sat.conf"))
208 jid_ = u"invitation-{uuid}@{domain}".format(uuid=shortuuid.uuid(), domain=domain) 237 jid_ = u"invitation-{uuid}@{domain}".format(uuid=shortuuid.uuid(),
238 domain=domain)
209 jid_ = jid.JID(jid_) 239 jid_ = jid.JID(jid_)
210 if jid_.user: 240 if jid_.user:
211 # we don't register account if there is no user as anonymous login is then used 241 # we don't register account if there is no user as anonymous login is then
242 # used
212 try: 243 try:
213 yield self.host.plugins['XEP-0077'].registerNewAccount(jid_, password) 244 yield self.host.plugins['XEP-0077'].registerNewAccount(jid_, password)
214 except error.StanzaError as e: 245 except error.StanzaError as e:
215 prefix = jid_.user 246 prefix = jid_.user
216 idx = 0 247 idx = 0
217 while e.condition == u'conflict': 248 while e.condition == u'conflict':
218 if idx >= SUFFIX_MAX: 249 if idx >= SUFFIX_MAX:
219 raise exceptions.ConflictError(_(u"Can't create XMPP account")) 250 raise exceptions.ConflictError(_(u"Can't create XMPP account"))
220 jid_.user = prefix + '_' + unicode(idx) 251 jid_.user = prefix + '_' + unicode(idx)
221 log.info(_(u"requested jid already exists, trying with {}".format(jid_.full()))) 252 log.info(_(u"requested jid already exists, trying with {}".format(
253 jid_.full())))
222 try: 254 try:
223 yield self.host.plugins['XEP-0077'].registerNewAccount(jid_, password) 255 yield self.host.plugins['XEP-0077'].registerNewAccount(jid_,
256 password)
224 except error.StanzaError as e: 257 except error.StanzaError as e:
225 idx += 1 258 idx += 1
226 else: 259 else:
227 break 260 break
228 if e.condition != u'conflict': 261 if e.condition != u'conflict':
234 267
235 extra[KEY_GUEST_PROFILE] = guest_profile = INVITEE_PROFILE_TPL.format(uuid=id_) 268 extra[KEY_GUEST_PROFILE] = guest_profile = INVITEE_PROFILE_TPL.format(uuid=id_)
236 # profile creation should not fail as we generate unique name ourselves 269 # profile creation should not fail as we generate unique name ourselves
237 yield self.host.memory.createProfile(guest_profile, password) 270 yield self.host.memory.createProfile(guest_profile, password)
238 yield self.host.memory.startSession(password, guest_profile) 271 yield self.host.memory.startSession(password, guest_profile)
239 yield self.host.memory.setParam("JabberID", jid_.full(), "Connection", profile_key=guest_profile) 272 yield self.host.memory.setParam("JabberID", jid_.full(), "Connection",
240 yield self.host.memory.setParam("Password", password, "Connection", profile_key=guest_profile) 273 profile_key=guest_profile)
274 yield self.host.memory.setParam("Password", password, "Connection",
275 profile_key=guest_profile)
241 name = kwargs.pop(u'name', None) 276 name = kwargs.pop(u'name', None)
242 if name is not None: 277 if name is not None:
243 extra[u'name'] = name 278 extra[u'name'] = name
244 try: 279 try:
245 id_plugin = self.host.plugins[u'IDENTITY'] 280 id_plugin = self.host.plugins[u'IDENTITY']
286 format_args[u'url'] = invite_url 321 format_args[u'url'] = invite_url
287 322
288 yield sat_email.sendEmail( 323 yield sat_email.sendEmail(
289 self.host, 324 self.host,
290 [email] + emails_extra, 325 [email] + emails_extra,
291 (kwargs.pop(u'message_subject', None) or DEFAULT_SUBJECT).format(**format_args), 326 (kwargs.pop(u'message_subject', None) or DEFAULT_SUBJECT).format(
327 **format_args),
292 (kwargs.pop(u'message_body', None) or DEFAULT_BODY).format(**format_args), 328 (kwargs.pop(u'message_body', None) or DEFAULT_BODY).format(**format_args),
293 ) 329 )
294 330
295 ## extra data saving 331 ## extra data saving
296 self.invitations[id_] = extra 332 self.invitations[id_] = extra
310 @raise KeyError: there is not invitation with this id_ 346 @raise KeyError: there is not invitation with this id_
311 """ 347 """
312 return self.invitations[id_] 348 return self.invitations[id_]
313 349
314 def _modify(self, id_, new_extra, replace): 350 def _modify(self, id_, new_extra, replace):
315 return self.modify(id_, {unicode(k): unicode(v) for k,v in new_extra.iteritems()}, replace) 351 return self.modify(id_, {unicode(k): unicode(v) for k,v in new_extra.iteritems()},
352 replace)
316 353
317 def modify(self, id_, new_extra, replace=False): 354 def modify(self, id_, new_extra, replace=False):
318 """Modify invitation data 355 """Modify invitation data
319 356
320 @param id_(unicode): UUID linked to an invitation 357 @param id_(unicode): UUID linked to an invitation
364 C.PROF_KEY_NONE: don't filter invitations 401 C.PROF_KEY_NONE: don't filter invitations
365 @return list(unicode): invitations uids 402 @return list(unicode): invitations uids
366 """ 403 """
367 invitations = yield self.invitations.items() 404 invitations = yield self.invitations.items()
368 if profile != C.PROF_KEY_NONE: 405 if profile != C.PROF_KEY_NONE:
369 invitations = {id_:data for id_, data in invitations.iteritems() if data.get(u'profile') == profile} 406 invitations = {id_:data for id_, data in invitations.iteritems()
407 if data.get(u'profile') == profile}
370 408
371 defer.returnValue(invitations) 409 defer.returnValue(invitations)