comparison src/plugins/plugin_xep_0329.py @ 2503:c0bec8bac2b5

XEP-0329: implementation of File Information Sharing: the plugin do an implementation for client and component. For client, any directory from local file can be shared. For component file are stored using new file handling mechanism from SàT. Permission is handled, using "public" and "whitelist" for now.
author Goffi <goffi@goffi.org>
date Wed, 28 Feb 2018 18:28:39 +0100
parents
children 025afb04c10b
comparison
equal deleted inserted replaced
2502:7ad5f2c4e34a 2503:c0bec8bac2b5
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.persistent = False
79 self.parent = None
80 if parent is not None:
81 assert name
82 parent.addChild(self)
83 else:
84 assert name is None
85 if path is not None:
86 if type_ != TYPE_PATH:
87 raise exceptions.InternalError(_(u"path can only be set on path nodes"))
88 self._path = path
89
90 @property
91 def path(self):
92 return self._path
93
94 def __getitem__(self, key):
95 return self.children[key]
96
97 def __contains__(self, item):
98 return self.children.__contains__(item)
99
100 def __iter__(self):
101 return self.children.__iter__
102
103 def iteritems(self):
104 return self.children.iteritems()
105
106 def getOrCreate(self, name, type_=TYPE_VIRTUAL, access=None):
107 """get a node or create a virtual one and return it"""
108 if access is None:
109 access = {C.ACCESS_PERM_READ: {KEY_TYPE: C.ACCESS_TYPE_PUBLIC}}
110 try:
111 return self.children[name]
112 except KeyError:
113 node = ShareNode(name, self, type_=type_, access=access)
114 return node
115
116 def addChild(self, node):
117 if node.parent is not None:
118 raise exceptions.ConflictError(_(u"a node can't have several parents"))
119 node.parent = self
120 self.children[node.name] = node
121
122 def _checkNodePermission(self, client, node, perms, peer_jid):
123 """Check access to this node for peer_jid
124
125 @param node(SharedNode): node to check access
126 @param perms(unicode): permissions to check, iterable of C.ACCESS_PERM_*
127 @param peer_jid(jid.JID): entity which try to access the node
128 @return (bool): True if entity can access
129 """
130 file_data = {u'access':self.access, u'owner': client.jid.userhostJID()}
131 try:
132 self.host.memory.checkFilePermission(file_data, peer_jid, perms)
133 except exceptions.PermissionError:
134 return False
135 else:
136 return True
137
138 def checkPermissions(self, client, peer_jid, perms=(C.ACCESS_PERM_READ,), check_parents=True):
139 """check that peer_jid can access this node and all its parents
140
141 @param peer_jid(jid.JID): entrity trying to access the node
142 @param perms(unicode): permissions to check, iterable of C.ACCESS_PERM_*
143 @param check_parents(bool): if True, access of all parents of this node will be checked too
144 @return (bool): True if entity can access this node
145 """
146 peer_jid = peer_jid.userhostJID()
147 if peer_jid == client.jid.userhostJID():
148 return True
149
150 parent = self
151 while parent != None:
152 if not self._checkNodePermission(client, parent, perms, peer_jid):
153 return False
154 parent = parent.parent
155
156 return True
157
158 @staticmethod
159 def find(client, path, peer_jid, perms=(C.ACCESS_PERM_READ,)):
160 """find node corresponding to a path
161
162 @param path(unicode): path to the requested file or directory
163 @param peer_jid(jid.JID): entity trying to find the node
164 used to check permission
165 @return (dict, unicode): shared data, remaining path
166 @raise exceptions.PermissionError: user can't access this file
167 @raise exceptions.DataError: path is invalid
168 @raise NotFound: path lead to a non existing file/directory
169 """
170 path_elts = filter(None, path.split(u'/'))
171
172 if u'..' in path_elts:
173 log.warning(_(u'parent dir ("..") found in path, hack attempt? path is {path} [{profile}]').format(
174 path=path, profile=client.profile))
175 raise exceptions.PermissionError(u"illegal path elements")
176
177 if not path_elts:
178 raise exceptions.DataError(_(u'path is invalid: {path}').format(path=path))
179
180 node = client._XEP_0329_root_node
181
182 while path_elts:
183 if node.type == TYPE_VIRTUAL:
184 try:
185 node = node[path_elts.pop(0)]
186 except KeyError:
187 raise exceptions.NotFound
188 elif node.type == TYPE_PATH:
189 break
190
191 if not node.checkPermissions(client, peer_jid, perms = perms):
192 raise exceptions.PermissionError(u"permission denied")
193
194 return node, u'/'.join(path_elts)
195
196
197 class XEP_0329(object):
198
199 def __init__(self, host):
200 log.info(_("File Information Sharing initialization"))
201 self.host = host
202 ShareNode.host = host
203 self._h = host.plugins['XEP-0300']
204 self._jf = host.plugins['XEP-0234']
205 host.bridge.addMethod("FISList", ".plugin", in_sign='ssa{ss}s', out_sign='aa{ss}', method=self._listFiles, async=True)
206 host.bridge.addMethod("FISSharePath", ".plugin", in_sign='ssss', out_sign='s', method=self._sharePath)
207 host.trigger.add("XEP-0234_fileSendingRequest", self._fileSendingRequestTrigger)
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 media_type = mimetypes.guess_type(path, strict=False)[0]
315 file_elt = self._jf.buildFileElement(name = name,
316 size = size,
317 media_type = media_type,
318 date = 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 'media_type': media_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 peer_jid = jid.JID(iq_elt['from'])
428 try:
429 files_data = yield self.host.memory.getFiles(client, peer_jid=peer_jid, path=node_path, owner=owner)
430 except exceptions.NotFound:
431 self._iqError(client, iq_elt)
432 return
433 iq_result_elt = xmlstream.toResponse(iq_elt, 'result')
434 query_elt = iq_result_elt.addElement((NS_FIS, 'query'))
435 query_elt[u'node'] = node_path
436 for file_data in files_data:
437 file_elt = self._jf.buildFileElementFromDict(file_data,
438 date=file_data.get(u'modified', file_data[u'created']))
439 query_elt.addChild(file_elt)
440 client.send(iq_result_elt)
441
442 def onComponentRequest(self, iq_elt, client):
443 return self._requestHandler(client, iq_elt, self._compGetRootNodesCb, self._compGetFilesFromNodeCb)
444
445 def _parseResult(self, iq_elt):
446 query_elt = next(iq_elt.elements(NS_FIS, 'query'))
447 files = []
448
449 for elt in query_elt.elements():
450 if elt.name == 'file':
451 # we have a file
452 try:
453 file_data = self._jf.parseFileElement(elt)
454 except exceptions.DataError:
455 continue
456 file_data[u'type'] = C.FILE_TYPE_FILE
457 elif elt.name == 'directory' and elt.uri == NS_FIS:
458 # we have a directory
459
460 file_data = {'name': elt['name'], 'type': C.FILE_TYPE_DIRECTORY}
461 else:
462 log.warning(_(u"unexpected element, ignoring: {elt}").format(elt=elt.toXml()))
463 continue
464 files.append(file_data)
465 return files
466
467 # file methods #
468
469 def _serializeData(self, files_data):
470 for file_data in files_data:
471 for key, value in file_data.iteritems():
472 file_data[key] = unicode(value)
473 return files_data
474
475 def _listFiles(self, target_jid, path, extra, profile):
476 client = self.host.getClient(profile)
477 target_jid = client.jid.userhostJID() if not target_jid else jid.JID(target_jid)
478 d = self.listFiles(client, target_jid, path or None)
479 d.addCallback(self._serializeData)
480 return d
481
482 def listFiles(self, client, target_jid, path=None, extra=None):
483 """List file shared by an entity
484
485 @param target_jid(jid.JID): jid of the sharing entity
486 @param path(unicode, None): path to the directory containing shared files
487 None to get root directories
488 @param extra(dict, None): extra data
489 @return list(dict): shared files
490 """
491 iq_elt = client.IQ('get')
492 iq_elt['to'] = target_jid.full()
493 query_elt = iq_elt.addElement((NS_FIS, 'query'))
494 if path:
495 query_elt['node'] = path
496 d = iq_elt.send()
497 d.addCallback(self._parseResult)
498 return d
499
500 def _sharePath(self, name, path, access, profile):
501 client = self.host.getClient(profile)
502 access= json.loads(access)
503 return self.sharePath(client, name or None, path, access)
504
505 def sharePath(self, client, name, path, access):
506 if client.is_component:
507 raise exceptions.ClientTypeError
508 if not os.path.exists(path):
509 raise ValueError(_(u"This path doesn't exist!"))
510 if not path or not path.strip(u' /'):
511 raise ValueError(_(u"A path need to be specified"))
512
513 node = client._XEP_0329_root_node
514 node_type = TYPE_PATH
515 if os.path.isfile(path):
516 # we have a single file, the workflow is diferrent as we store all single files in the same dir
517 node = node.getOrCreate(SINGLE_FILES_DIR)
518
519 if not name:
520 name = os.path.basename(path.rstrip(u' /'))
521 if not name:
522 raise exceptions.InternalError(_(u"Can't find a proper name"))
523
524 if not isinstance(access, dict):
525 raise ValueError(_(u'access must be a dict'))
526
527 if name in node or name == SINGLE_FILES_DIR:
528 idx = 1
529 new_name = name + '_' + unicode(idx)
530 while new_name in node:
531 idx += 1
532 new_name = name + '_' + unicode(idx)
533 name = new_name
534 log.info(_(u"A directory with this name is already shared, renamed to {new_name} [{profile}]".format(
535 new_name=new_name, profile=client.profile)))
536
537 ShareNode(name=name, parent=node, type_=node_type, access=access, path=path)
538 return name
539
540
541 class XEP_0329_handler(xmlstream.XMPPHandler):
542 implements(iwokkel.IDisco)
543
544 def __init__(self, plugin_parent):
545 self.plugin_parent = plugin_parent
546 self.host = plugin_parent.host
547
548 def connectionInitialized(self):
549 if self.parent.is_component:
550 self.xmlstream.addObserver(IQ_FIS_REQUEST, self.plugin_parent.onComponentRequest, client=self.parent)
551 else:
552 self.xmlstream.addObserver(IQ_FIS_REQUEST, self.plugin_parent.onRequest, client=self.parent)
553
554 def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
555 return [disco.DiscoFeature(NS_FIS)]
556
557 def getDiscoItems(self, requestor, target, nodeIdentifier=''):
558 return []