comparison sat/plugins/plugin_xep_0329.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_xep_0329.py@8d82a62fa098
children 282d1314d574
comparison
equal deleted inserted replaced
2561:bd30dc3ffe5a 2562:26edcf3a30eb
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3
4 # SAT plugin for File Information Sharing (XEP-0329)
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 _
21 from sat.core import exceptions
22 from sat.core.constants import Const as C
23 from sat.core.log import getLogger
24 log = getLogger(__name__)
25 from sat.tools import stream
26 from sat.tools.common import regex
27 from wokkel import disco, iwokkel
28 from zope.interface import implements
29 from twisted.words.protocols.jabber import xmlstream
30 from twisted.words.protocols.jabber import jid
31 from twisted.words.protocols.jabber import error as jabber_error
32 from twisted.internet import defer
33 import mimetypes
34 import json
35 import os
36
37
38 PLUGIN_INFO = {
39 C.PI_NAME: "File Information Sharing",
40 C.PI_IMPORT_NAME: "XEP-0329",
41 C.PI_TYPE: "XEP",
42 C.PI_MODES: C.PLUG_MODE_BOTH,
43 C.PI_PROTOCOLS: ["XEP-0329"],
44 C.PI_DEPENDENCIES: ["XEP-0234", "XEP-0300"],
45 C.PI_MAIN: "XEP_0329",
46 C.PI_HANDLER: "yes",
47 C.PI_DESCRIPTION: _(u"""Implementation of File Information Sharing""")
48 }
49
50 NS_FIS = 'urn:xmpp:fis:0'
51
52 IQ_FIS_REQUEST = C.IQ_GET + '/query[@xmlns="' + NS_FIS + '"]'
53 SINGLE_FILES_DIR = u"files"
54 TYPE_VIRTUAL= u'virtual'
55 TYPE_PATH = u'path'
56 SHARE_TYPES = (TYPE_PATH, TYPE_VIRTUAL)
57 KEY_TYPE = u'type'
58
59
60 class ShareNode(object):
61 """node containing directory or files to share, virtual or real"""
62 host = None
63
64 def __init__(self, name, parent, type_, access, path=None):
65 assert type_ in SHARE_TYPES
66 if name is not None:
67 if name == u'..' or u'/' in name or u'\\' in name:
68 log.warning(_(u'path change chars found in name [{name}], hack attempt?').format(name=name))
69 if name == u'..':
70 name = u'--'
71 else:
72 name = regex.pathEscape(name)
73 self.name = name
74 self.children = {}
75 self.type = type_
76 self.access = {} if access is None else access
77 assert isinstance(self.access, dict)
78 self.parent = None
79 if parent is not None:
80 assert name
81 parent.addChild(self)
82 else:
83 assert name is None
84 if path is not None:
85 if type_ != TYPE_PATH:
86 raise exceptions.InternalError(_(u"path can only be set on path nodes"))
87 self._path = path
88
89 @property
90 def path(self):
91 return self._path
92
93 def __getitem__(self, key):
94 return self.children[key]
95
96 def __contains__(self, item):
97 return self.children.__contains__(item)
98
99 def __iter__(self):
100 return self.children.__iter__
101
102 def iteritems(self):
103 return self.children.iteritems()
104
105 def getOrCreate(self, name, type_=TYPE_VIRTUAL, access=None):
106 """get a node or create a virtual one and return it"""
107 if access is None:
108 access = {C.ACCESS_PERM_READ: {KEY_TYPE: C.ACCESS_TYPE_PUBLIC}}
109 try:
110 return self.children[name]
111 except KeyError:
112 node = ShareNode(name, self, type_=type_, access=access)
113 return node
114
115 def addChild(self, node):
116 if node.parent is not None:
117 raise exceptions.ConflictError(_(u"a node can't have several parents"))
118 node.parent = self
119 self.children[node.name] = node
120
121 def _checkNodePermission(self, client, node, perms, peer_jid):
122 """Check access to this node for peer_jid
123
124 @param node(SharedNode): node to check access
125 @param perms(unicode): permissions to check, iterable of C.ACCESS_PERM_*
126 @param peer_jid(jid.JID): entity which try to access the node
127 @return (bool): True if entity can access
128 """
129 file_data = {u'access':self.access, u'owner': client.jid.userhostJID()}
130 try:
131 self.host.memory.checkFilePermission(file_data, peer_jid, perms)
132 except exceptions.PermissionError:
133 return False
134 else:
135 return True
136
137 def checkPermissions(self, client, peer_jid, perms=(C.ACCESS_PERM_READ,), check_parents=True):
138 """check that peer_jid can access this node and all its parents
139
140 @param peer_jid(jid.JID): entrity trying to access the node
141 @param perms(unicode): permissions to check, iterable of C.ACCESS_PERM_*
142 @param check_parents(bool): if True, access of all parents of this node will be checked too
143 @return (bool): True if entity can access this node
144 """
145 peer_jid = peer_jid.userhostJID()
146 if peer_jid == client.jid.userhostJID():
147 return True
148
149 parent = self
150 while parent != None:
151 if not self._checkNodePermission(client, parent, perms, peer_jid):
152 return False
153 parent = parent.parent
154
155 return True
156
157 @staticmethod
158 def find(client, path, peer_jid, perms=(C.ACCESS_PERM_READ,)):
159 """find node corresponding to a path
160
161 @param path(unicode): path to the requested file or directory
162 @param peer_jid(jid.JID): entity trying to find the node
163 used to check permission
164 @return (dict, unicode): shared data, remaining path
165 @raise exceptions.PermissionError: user can't access this file
166 @raise exceptions.DataError: path is invalid
167 @raise NotFound: path lead to a non existing file/directory
168 """
169 path_elts = filter(None, path.split(u'/'))
170
171 if u'..' in path_elts:
172 log.warning(_(u'parent dir ("..") found in path, hack attempt? path is {path} [{profile}]').format(
173 path=path, profile=client.profile))
174 raise exceptions.PermissionError(u"illegal path elements")
175
176 if not path_elts:
177 raise exceptions.DataError(_(u'path is invalid: {path}').format(path=path))
178
179 node = client._XEP_0329_root_node
180
181 while path_elts:
182 if node.type == TYPE_VIRTUAL:
183 try:
184 node = node[path_elts.pop(0)]
185 except KeyError:
186 raise exceptions.NotFound
187 elif node.type == TYPE_PATH:
188 break
189
190 if not node.checkPermissions(client, peer_jid, perms = perms):
191 raise exceptions.PermissionError(u"permission denied")
192
193 return node, u'/'.join(path_elts)
194
195
196 class XEP_0329(object):
197
198 def __init__(self, host):
199 log.info(_("File Information Sharing initialization"))
200 self.host = host
201 ShareNode.host = host
202 self._h = host.plugins['XEP-0300']
203 self._jf = host.plugins['XEP-0234']
204 host.bridge.addMethod("FISList", ".plugin", in_sign='ssa{ss}s', out_sign='aa{ss}', method=self._listFiles, async=True)
205 host.bridge.addMethod("FISSharePath", ".plugin", in_sign='ssss', out_sign='s', method=self._sharePath)
206 host.trigger.add("XEP-0234_fileSendingRequest", self._fileSendingRequestTrigger)
207 host.registerNamespace('fis', NS_FIS)
208
209 def getHandler(self, client):
210 return XEP_0329_handler(self)
211
212 def profileConnected(self, client):
213 if not client.is_component:
214 client._XEP_0329_root_node = ShareNode(None, None, TYPE_VIRTUAL, {C.ACCESS_PERM_READ: {KEY_TYPE: C.ACCESS_TYPE_PUBLIC}})
215 client._XEP_0329_names_data = {} # name to share map
216
217 def _fileSendingRequestTrigger(self, client, session, content_data, content_name, file_data, file_elt):
218 """this trigger check that a requested file is available, and fill suitable data if so
219
220 path and name are used to retrieve the file. If path is missing, we try our luck with known names
221 """
222 if client.is_component:
223 return True, None
224
225 try:
226 name = file_data[u'name']
227 except KeyError:
228 return True, None
229 assert u'/' not in name
230
231 path = file_data.get(u'path')
232 if path is not None:
233 # we have a path, we can follow it to find node
234 try:
235 node, rem_path = ShareNode.find(client, path, session[u'peer_jid'])
236 except (exceptions.PermissionError, exceptions.NotFound):
237 # no file, or file not allowed, we continue normal workflow
238 return True, None
239 except exceptions.DataError:
240 log.warning(_(u'invalid path: {path}').format(path=path))
241 return True, None
242
243 if node.type == TYPE_VIRTUAL:
244 # we have a virtual node, so name must link to a path node
245 try:
246 path = node[name].path
247 except KeyError:
248 return True, None
249 elif node.type == TYPE_PATH:
250 # we have a path node, so we can retrieve the full path now
251 path = os.path.join(node.path, rem_path, name)
252 else:
253 raise exceptions.InternalError(u'unknown type: {type}'.format(type=node.type))
254 if not os.path.exists(path):
255 return True, None
256 size = os.path.getsize(path)
257 else:
258 # we don't have the path, we try to find the file by its name
259 try:
260 name_data = client._XEP_0329_names_data[name]
261 except KeyError:
262 return True, None
263
264 for path, shared_file in name_data.iteritems():
265 if True: # FIXME: filters are here
266 break
267 else:
268 return True, None
269 parent_node = shared_file[u'parent']
270 if not parent_node.checkPermissions(client, session[u'peer_jid']):
271 log.warning(_(u"{peer_jid} requested a file (s)he can't access [{profile}]").format(
272 peer_jid = session[u'peer_jid'], profile = client.profile))
273 return True, None
274 size = shared_file[u'size']
275
276 file_data[u'size'] = size
277 file_elt.addElement(u'size', content=unicode(size))
278 hash_algo = file_data[u'hash_algo'] = self._h.getDefaultAlgo()
279 hasher = file_data[u'hash_hasher'] = self._h.getHasher(hash_algo)
280 file_elt.addChild(self._h.buildHashUsedElt(hash_algo))
281 content_data['stream_object'] = stream.FileStreamObject(
282 self.host,
283 client,
284 path,
285 uid=self._jf.getProgressId(session, content_name),
286 size=size,
287 data_cb=lambda data: hasher.update(data),
288 )
289 return False, True
290
291 # common methods
292
293 def _requestHandler(self, client, iq_elt, root_nodes_cb, files_from_node_cb):
294 iq_elt.handled = True
295 owner = jid.JID(iq_elt['from']).userhostJID()
296 node = iq_elt.query.getAttribute('node')
297 if not node:
298 d = defer.maybeDeferred(root_nodes_cb, client, iq_elt, owner)
299 else:
300 d = defer.maybeDeferred(files_from_node_cb, client, iq_elt, owner, node)
301 d.addErrback(lambda failure_: log.error(_(u"error while retrieving files: {msg}").format(msg=failure_)))
302
303 def _iqError(self, client, iq_elt, condition="item-not-found"):
304 error_elt = jabber_error.StanzaError(condition).toResponse(iq_elt)
305 client.send(error_elt)
306
307 # client
308
309 def _addPathData(self, client, query_elt, path, parent_node):
310 """Fill query_elt with files/directories found in path"""
311 name = os.path.basename(path)
312 if os.path.isfile(path):
313 size = os.path.getsize(path)
314 mime_type = mimetypes.guess_type(path, strict=False)[0]
315 file_elt = self._jf.buildFileElement(name = name,
316 size = size,
317 mime_type = mime_type,
318 modified = os.path.getmtime(path))
319
320 query_elt.addChild(file_elt)
321 # we don't specify hash as it would be too resource intensive to calculate it for all files
322 # we add file to name_data, so users can request it later
323 name_data = client._XEP_0329_names_data.setdefault(name, {})
324 if path not in name_data:
325 name_data[path] = {'size': size,
326 'mime_type': mime_type,
327 'parent': parent_node}
328 else:
329 # we have a directory
330 directory_elt = query_elt.addElement('directory')
331 directory_elt['name'] = name
332
333 def _pathNodeHandler(self, client, iq_elt, query_elt, node, path):
334 """Fill query_elt for path nodes, i.e. physical directories"""
335 path = os.path.join(node.path, path)
336
337 if not os.path.exists(path):
338 # path may have been moved since it has been shared
339 return self._iqError(client, iq_elt)
340 elif os.path.isfile(path):
341 self._addPathData(client, query_elt, path, node)
342 else:
343 for name in sorted(os.listdir(path.encode('utf-8')), key=lambda n: n.lower()):
344 try:
345 name = name.decode('utf-8', 'strict')
346 except UnicodeDecodeError as e:
347 log.warning(_(u"ignoring invalid unicode name ({name}): {msg}").format(
348 name = name.decode('utf-8', 'replace'),
349 msg = e))
350 continue
351 full_path = os.path.join(path, name)
352 self._addPathData(client, query_elt, full_path, node)
353
354 def _virtualNodeHandler(self, client, peer_jid, iq_elt, query_elt, node):
355 """Fill query_elt for virtual nodes"""
356 for name, child_node in node.iteritems():
357 if not child_node.checkPermissions(client, peer_jid, check_parents=False):
358 continue
359 node_type = child_node.type
360 if node_type == TYPE_VIRTUAL:
361 directory_elt = query_elt.addElement('directory')
362 directory_elt['name'] = name
363 elif node_type == TYPE_PATH:
364 self._addPathData(client, query_elt, child_node.path, child_node)
365 else:
366 raise exceptions.InternalError(_(u'unexpected type: {type}').format(type=node_type))
367
368 def _getRootNodesCb(self, client, iq_elt, owner):
369 peer_jid = jid.JID(iq_elt['from'])
370 iq_result_elt = xmlstream.toResponse(iq_elt, 'result')
371 query_elt = iq_result_elt.addElement((NS_FIS, 'query'))
372 for name, node in client._XEP_0329_root_node.iteritems():
373 if not node.checkPermissions(client, peer_jid, check_parents=False):
374 continue
375 directory_elt = query_elt.addElement('directory')
376 directory_elt['name'] = name
377 client.send(iq_result_elt)
378
379 def _getFilesFromNodeCb(self, client, iq_elt, owner, node_path):
380 """Main method to retrieve files/directories from a node_path"""
381 peer_jid = jid.JID(iq_elt[u'from'])
382 try:
383 node, path = ShareNode.find(client, node_path, peer_jid)
384 except (exceptions.PermissionError, exceptions.NotFound):
385 return self._iqError(client, iq_elt)
386 except exceptions.DataError:
387 return self._iqError(client, iq_elt, condition='not-acceptable')
388
389 node_type = node.type
390 peer_jid = jid.JID(iq_elt['from'])
391 iq_result_elt = xmlstream.toResponse(iq_elt, 'result')
392 query_elt = iq_result_elt.addElement((NS_FIS, 'query'))
393 query_elt[u'node'] = node_path
394
395 # we now fill query_elt according to node_type
396 if node_type == TYPE_PATH:
397 # it's a physical path
398 self._pathNodeHandler(client, iq_elt, query_elt, node, path)
399 elif node_type == TYPE_VIRTUAL:
400 assert not path
401 self._virtualNodeHandler(client, peer_jid, iq_elt, query_elt, node)
402 else:
403 raise exceptions.InternalError(_(u'unknown node type: {type}').format(type=node_type))
404
405 client.send(iq_result_elt)
406
407 def onRequest(self, iq_elt, client):
408 return self._requestHandler(client, iq_elt, self._getRootNodesCb, self._getFilesFromNodeCb)
409
410 # Component
411
412 @defer.inlineCallbacks
413 def _compGetRootNodesCb(self, client, iq_elt, owner):
414 peer_jid = jid.JID(iq_elt['from'])
415 files_data = yield self.host.memory.getFiles(client, peer_jid=peer_jid, parent=u'',
416 type_=C.FILE_TYPE_DIRECTORY, owner=owner)
417 iq_result_elt = xmlstream.toResponse(iq_elt, 'result')
418 query_elt = iq_result_elt.addElement((NS_FIS, 'query'))
419 for file_data in files_data:
420 name = file_data[u'name']
421 directory_elt = query_elt.addElement(u'directory')
422 directory_elt[u'name'] = name
423 client.send(iq_result_elt)
424
425 @defer.inlineCallbacks
426 def _compGetFilesFromNodeCb(self, client, iq_elt, owner, node_path):
427 """retrieve files from local files repository according to permissions
428
429 result stanza is then built and sent to requestor
430 @trigger XEP-0329_compGetFilesFromNode(client, iq_elt, owner, node_path, files_data): can be used to add data/elements
431 """
432 peer_jid = jid.JID(iq_elt['from'])
433 try:
434 files_data = yield self.host.memory.getFiles(client, peer_jid=peer_jid, path=node_path, owner=owner)
435 except exceptions.NotFound:
436 self._iqError(client, iq_elt)
437 return
438 iq_result_elt = xmlstream.toResponse(iq_elt, 'result')
439 query_elt = iq_result_elt.addElement((NS_FIS, 'query'))
440 query_elt[u'node'] = node_path
441 if not self.host.trigger.point(u'XEP-0329_compGetFilesFromNode', client, iq_elt, owner, node_path, files_data):
442 return
443 for file_data in files_data:
444 file_elt = self._jf.buildFileElementFromDict(file_data,
445 modified=file_data.get(u'modified', file_data[u'created']))
446 query_elt.addChild(file_elt)
447 client.send(iq_result_elt)
448
449 def onComponentRequest(self, iq_elt, client):
450 return self._requestHandler(client, iq_elt, self._compGetRootNodesCb, self._compGetFilesFromNodeCb)
451
452 def _parseResult(self, iq_elt):
453 query_elt = next(iq_elt.elements(NS_FIS, 'query'))
454 files = []
455
456 for elt in query_elt.elements():
457 if elt.name == 'file':
458 # we have a file
459 try:
460 file_data = self._jf.parseFileElement(elt)
461 except exceptions.DataError:
462 continue
463 file_data[u'type'] = C.FILE_TYPE_FILE
464 elif elt.name == 'directory' and elt.uri == NS_FIS:
465 # we have a directory
466
467 file_data = {'name': elt['name'], 'type': C.FILE_TYPE_DIRECTORY}
468 else:
469 log.warning(_(u"unexpected element, ignoring: {elt}").format(elt=elt.toXml()))
470 continue
471 files.append(file_data)
472 return files
473
474 # file methods #
475
476 def _serializeData(self, files_data):
477 for file_data in files_data:
478 for key, value in file_data.iteritems():
479 file_data[key] = json.dumps(value) if key in ('extra',) else unicode(value)
480 return files_data
481
482 def _listFiles(self, target_jid, path, extra, profile):
483 client = self.host.getClient(profile)
484 target_jid = client.jid.userhostJID() if not target_jid else jid.JID(target_jid)
485 d = self.listFiles(client, target_jid, path or None)
486 d.addCallback(self._serializeData)
487 return d
488
489 def listFiles(self, client, target_jid, path=None, extra=None):
490 """List file shared by an entity
491
492 @param target_jid(jid.JID): jid of the sharing entity
493 @param path(unicode, None): path to the directory containing shared files
494 None to get root directories
495 @param extra(dict, None): extra data
496 @return list(dict): shared files
497 """
498 iq_elt = client.IQ('get')
499 iq_elt['to'] = target_jid.full()
500 query_elt = iq_elt.addElement((NS_FIS, 'query'))
501 if path:
502 query_elt['node'] = path
503 d = iq_elt.send()
504 d.addCallback(self._parseResult)
505 return d
506
507 def _sharePath(self, name, path, access, profile):
508 client = self.host.getClient(profile)
509 access= json.loads(access)
510 return self.sharePath(client, name or None, path, access)
511
512 def sharePath(self, client, name, path, access):
513 if client.is_component:
514 raise exceptions.ClientTypeError
515 if not os.path.exists(path):
516 raise ValueError(_(u"This path doesn't exist!"))
517 if not path or not path.strip(u' /'):
518 raise ValueError(_(u"A path need to be specified"))
519
520 node = client._XEP_0329_root_node
521 node_type = TYPE_PATH
522 if os.path.isfile(path):
523 # we have a single file, the workflow is diferrent as we store all single files in the same dir
524 node = node.getOrCreate(SINGLE_FILES_DIR)
525
526 if not name:
527 name = os.path.basename(path.rstrip(u' /'))
528 if not name:
529 raise exceptions.InternalError(_(u"Can't find a proper name"))
530
531 if not isinstance(access, dict):
532 raise ValueError(_(u'access must be a dict'))
533
534 if name in node or name == SINGLE_FILES_DIR:
535 idx = 1
536 new_name = name + '_' + unicode(idx)
537 while new_name in node:
538 idx += 1
539 new_name = name + '_' + unicode(idx)
540 name = new_name
541 log.info(_(u"A directory with this name is already shared, renamed to {new_name} [{profile}]".format(
542 new_name=new_name, profile=client.profile)))
543
544 ShareNode(name=name, parent=node, type_=node_type, access=access, path=path)
545 return name
546
547
548 class XEP_0329_handler(xmlstream.XMPPHandler):
549 implements(iwokkel.IDisco)
550
551 def __init__(self, plugin_parent):
552 self.plugin_parent = plugin_parent
553 self.host = plugin_parent.host
554
555 def connectionInitialized(self):
556 if self.parent.is_component:
557 self.xmlstream.addObserver(IQ_FIS_REQUEST, self.plugin_parent.onComponentRequest, client=self.parent)
558 else:
559 self.xmlstream.addObserver(IQ_FIS_REQUEST, self.plugin_parent.onRequest, client=self.parent)
560
561 def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
562 return [disco.DiscoFeature(NS_FIS)]
563
564 def getDiscoItems(self, requestor, target, nodeIdentifier=''):
565 return []