comparison libervia/backend/plugins/plugin_xep_0329.py @ 4071:4b842c1fb686

refactoring: renamed `sat` package to `libervia.backend`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 11:49:51 +0200
parents sat/plugins/plugin_xep_0329.py@524856bd7b19
children 0d7bb4df2343
comparison
equal deleted inserted replaced
4070:d10748475025 4071:4b842c1fb686
1 #!/usr/bin/env python3
2
3 # SAT plugin for File Information Sharing (XEP-0329)
4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 import mimetypes
20 import json
21 import os
22 import traceback
23 from pathlib import Path
24 from typing import Optional, Dict
25 from zope.interface import implementer
26 from twisted.words.protocols.jabber import xmlstream
27 from twisted.words.protocols.jabber import jid
28 from twisted.words.protocols.jabber import error as jabber_error
29 from twisted.internet import defer
30 from wokkel import disco, iwokkel, data_form
31 from libervia.backend.core.i18n import _
32 from libervia.backend.core.xmpp import SatXMPPEntity
33 from libervia.backend.core import exceptions
34 from libervia.backend.core.constants import Const as C
35 from libervia.backend.core.log import getLogger
36 from libervia.backend.tools import stream
37 from libervia.backend.tools import utils
38 from libervia.backend.tools.common import regex
39
40
41 log = getLogger(__name__)
42
43 PLUGIN_INFO = {
44 C.PI_NAME: "File Information Sharing",
45 C.PI_IMPORT_NAME: "XEP-0329",
46 C.PI_TYPE: "XEP",
47 C.PI_MODES: C.PLUG_MODE_BOTH,
48 C.PI_PROTOCOLS: ["XEP-0329"],
49 C.PI_DEPENDENCIES: ["XEP-0231", "XEP-0234", "XEP-0300", "XEP-0106"],
50 C.PI_MAIN: "XEP_0329",
51 C.PI_HANDLER: "yes",
52 C.PI_DESCRIPTION: _("""Implementation of File Information Sharing"""),
53 }
54
55 NS_FIS = "urn:xmpp:fis:0"
56 NS_FIS_AFFILIATION = "org.salut-a-toi.fis-affiliation"
57 NS_FIS_CONFIGURATION = "org.salut-a-toi.fis-configuration"
58 NS_FIS_CREATE = "org.salut-a-toi.fis-create"
59
60 IQ_FIS_REQUEST = f'{C.IQ_GET}/query[@xmlns="{NS_FIS}"]'
61 # not in the standard, but needed, and it's handy to keep it here
62 IQ_FIS_AFFILIATION_GET = f'{C.IQ_GET}/affiliations[@xmlns="{NS_FIS_AFFILIATION}"]'
63 IQ_FIS_AFFILIATION_SET = f'{C.IQ_SET}/affiliations[@xmlns="{NS_FIS_AFFILIATION}"]'
64 IQ_FIS_CONFIGURATION_GET = f'{C.IQ_GET}/configuration[@xmlns="{NS_FIS_CONFIGURATION}"]'
65 IQ_FIS_CONFIGURATION_SET = f'{C.IQ_SET}/configuration[@xmlns="{NS_FIS_CONFIGURATION}"]'
66 IQ_FIS_CREATE_DIR = f'{C.IQ_SET}/dir[@xmlns="{NS_FIS_CREATE}"]'
67 SINGLE_FILES_DIR = "files"
68 TYPE_VIRTUAL = "virtual"
69 TYPE_PATH = "path"
70 SHARE_TYPES = (TYPE_PATH, TYPE_VIRTUAL)
71 KEY_TYPE = "type"
72
73
74 class RootPathException(Exception):
75 """Root path is requested"""
76
77
78 class ShareNode(object):
79 """Node containing directory or files to share, virtual or real"""
80
81 host = None
82
83 def __init__(self, name, parent, type_, access, path=None):
84 assert type_ in SHARE_TYPES
85 if name is not None:
86 if name == ".." or "/" in name or "\\" in name:
87 log.warning(
88 _("path change chars found in name [{name}], hack attempt?").format(
89 name=name
90 )
91 )
92 if name == "..":
93 name = "--"
94 else:
95 name = regex.path_escape(name)
96 self.name = name
97 self.children = {}
98 self.type = type_
99 self.access = {} if access is None else access
100 assert isinstance(self.access, dict)
101 self.parent = None
102 if parent is not None:
103 assert name
104 parent.addChild(self)
105 else:
106 assert name is None
107 if path is not None:
108 if type_ != TYPE_PATH:
109 raise exceptions.InternalError(_("path can only be set on path nodes"))
110 self._path = path
111
112 @property
113 def path(self):
114 return self._path
115
116 def __getitem__(self, key):
117 return self.children[key]
118
119 def __contains__(self, item):
120 return self.children.__contains__(item)
121
122 def __iter__(self):
123 return self.children.__iter__()
124
125 def items(self):
126 return self.children.items()
127
128 def values(self):
129 return self.children.values()
130
131 def get_or_create(self, name, type_=TYPE_VIRTUAL, access=None):
132 """Get a node or create a virtual node and return it"""
133 if access is None:
134 access = {C.ACCESS_PERM_READ: {KEY_TYPE: C.ACCESS_TYPE_PUBLIC}}
135 try:
136 return self.children[name]
137 except KeyError:
138 node = ShareNode(name, self, type_=type_, access=access)
139 return node
140
141 def addChild(self, node):
142 if node.parent is not None:
143 raise exceptions.ConflictError(_("a node can't have several parents"))
144 node.parent = self
145 self.children[node.name] = node
146
147 def remove_from_parent(self):
148 try:
149 del self.parent.children[self.name]
150 except TypeError:
151 raise exceptions.InternalError(
152 "trying to remove a node from inexisting parent"
153 )
154 except KeyError:
155 raise exceptions.InternalError("node not found in parent's children")
156 self.parent = None
157
158 def _check_node_permission(self, client, node, perms, peer_jid):
159 """Check access to this node for peer_jid
160
161 @param node(SharedNode): node to check access
162 @param perms(unicode): permissions to check, iterable of C.ACCESS_PERM_*
163 @param peer_jid(jid.JID): entity which try to access the node
164 @return (bool): True if entity can access
165 """
166 file_data = {"access": self.access, "owner": client.jid.userhostJID()}
167 try:
168 self.host.memory.check_file_permission(file_data, peer_jid, perms)
169 except exceptions.PermissionError:
170 return False
171 else:
172 return True
173
174 def check_permissions(
175 self, client, peer_jid, perms=(C.ACCESS_PERM_READ,), check_parents=True
176 ):
177 """Check that peer_jid can access this node and all its parents
178
179 @param peer_jid(jid.JID): entrity trying to access the node
180 @param perms(unicode): permissions to check, iterable of C.ACCESS_PERM_*
181 @param check_parents(bool): if True, access of all parents of this node will be
182 checked too
183 @return (bool): True if entity can access this node
184 """
185 peer_jid = peer_jid.userhostJID()
186 if peer_jid == client.jid.userhostJID():
187 return True
188
189 parent = self
190 while parent != None:
191 if not self._check_node_permission(client, parent, perms, peer_jid):
192 return False
193 parent = parent.parent
194
195 return True
196
197 @staticmethod
198 def find(client, path, peer_jid, perms=(C.ACCESS_PERM_READ,)):
199 """find node corresponding to a path
200
201 @param path(unicode): path to the requested file or directory
202 @param peer_jid(jid.JID): entity trying to find the node
203 used to check permission
204 @return (dict, unicode): shared data, remaining path
205 @raise exceptions.PermissionError: user can't access this file
206 @raise exceptions.DataError: path is invalid
207 @raise NotFound: path lead to a non existing file/directory
208 """
209 path_elts = [_f for _f in path.split("/") if _f]
210
211 if ".." in path_elts:
212 log.warning(_(
213 'parent dir ("..") found in path, hack attempt? path is {path} '
214 '[{profile}]').format(path=path, profile=client.profile))
215 raise exceptions.PermissionError("illegal path elements")
216
217 node = client._XEP_0329_root_node
218
219 while path_elts:
220 if node.type == TYPE_VIRTUAL:
221 try:
222 node = node[path_elts.pop(0)]
223 except KeyError:
224 raise exceptions.NotFound
225 elif node.type == TYPE_PATH:
226 break
227
228 if not node.check_permissions(client, peer_jid, perms=perms):
229 raise exceptions.PermissionError("permission denied")
230
231 return node, "/".join(path_elts)
232
233 def find_by_local_path(self, path):
234 """retrieve nodes linking to local path
235
236 @return (list[ShareNode]): found nodes associated to path
237 @raise exceptions.NotFound: no node has been found with this path
238 """
239 shared_paths = self.get_shared_paths()
240 try:
241 return shared_paths[path]
242 except KeyError:
243 raise exceptions.NotFound
244
245 def _get_shared_paths(self, node, paths):
246 if node.type == TYPE_VIRTUAL:
247 for node in node.values():
248 self._get_shared_paths(node, paths)
249 elif node.type == TYPE_PATH:
250 paths.setdefault(node.path, []).append(node)
251 else:
252 raise exceptions.InternalError(
253 "unknown node type: {type}".format(type=node.type)
254 )
255
256 def get_shared_paths(self):
257 """retrieve nodes by shared path
258
259 this method will retrieve recursively shared path in children of this node
260 @return (dict): map from shared path to list of nodes
261 """
262 if self.type == TYPE_PATH:
263 raise exceptions.InternalError(
264 "get_shared_paths must be used on a virtual node"
265 )
266 paths = {}
267 self._get_shared_paths(self, paths)
268 return paths
269
270
271 class XEP_0329(object):
272 def __init__(self, host):
273 log.info(_("File Information Sharing initialization"))
274 self.host = host
275 ShareNode.host = host
276 self._b = host.plugins["XEP-0231"]
277 self._h = host.plugins["XEP-0300"]
278 self._jf = host.plugins["XEP-0234"]
279 host.bridge.add_method(
280 "fis_list",
281 ".plugin",
282 in_sign="ssa{ss}s",
283 out_sign="aa{ss}",
284 method=self._list_files,
285 async_=True,
286 )
287 host.bridge.add_method(
288 "fis_local_shares_get",
289 ".plugin",
290 in_sign="s",
291 out_sign="as",
292 method=self._local_shares_get,
293 )
294 host.bridge.add_method(
295 "fis_share_path",
296 ".plugin",
297 in_sign="ssss",
298 out_sign="s",
299 method=self._share_path,
300 )
301 host.bridge.add_method(
302 "fis_unshare_path",
303 ".plugin",
304 in_sign="ss",
305 out_sign="",
306 method=self._unshare_path,
307 )
308 host.bridge.add_method(
309 "fis_affiliations_get",
310 ".plugin",
311 in_sign="ssss",
312 out_sign="a{ss}",
313 method=self._affiliations_get,
314 async_=True,
315 )
316 host.bridge.add_method(
317 "fis_affiliations_set",
318 ".plugin",
319 in_sign="sssa{ss}s",
320 out_sign="",
321 method=self._affiliations_set,
322 async_=True,
323 )
324 host.bridge.add_method(
325 "fis_configuration_get",
326 ".plugin",
327 in_sign="ssss",
328 out_sign="a{ss}",
329 method=self._configuration_get,
330 async_=True,
331 )
332 host.bridge.add_method(
333 "fis_configuration_set",
334 ".plugin",
335 in_sign="sssa{ss}s",
336 out_sign="",
337 method=self._configuration_set,
338 async_=True,
339 )
340 host.bridge.add_method(
341 "fis_create_dir",
342 ".plugin",
343 in_sign="sssa{ss}s",
344 out_sign="",
345 method=self._create_dir,
346 async_=True,
347 )
348 host.bridge.add_signal("fis_shared_path_new", ".plugin", signature="sss")
349 host.bridge.add_signal("fis_shared_path_removed", ".plugin", signature="ss")
350 host.trigger.add("XEP-0234_fileSendingRequest", self._file_sending_request_trigger)
351 host.register_namespace("fis", NS_FIS)
352
353 def get_handler(self, client):
354 return XEP_0329_handler(self)
355
356 def profile_connected(self, client):
357 if client.is_component:
358 client._file_sharing_allowed_hosts = self.host.memory.config_get(
359 'component file_sharing',
360 'http_upload_allowed_hosts_list') or [client.host]
361 else:
362 client._XEP_0329_root_node = ShareNode(
363 None,
364 None,
365 TYPE_VIRTUAL,
366 {C.ACCESS_PERM_READ: {KEY_TYPE: C.ACCESS_TYPE_PUBLIC}},
367 )
368 client._XEP_0329_names_data = {} #  name to share map
369
370 def _file_sending_request_trigger(
371 self, client, session, content_data, content_name, file_data, file_elt
372 ):
373 """This trigger check that a requested file is available, and fill suitable data
374
375 Path and name are used to retrieve the file. If path is missing, we try our luck
376 with known names
377 """
378 if client.is_component:
379 return True, None
380
381 try:
382 name = file_data["name"]
383 except KeyError:
384 return True, None
385 assert "/" not in name
386
387 path = file_data.get("path")
388 if path is not None:
389 # we have a path, we can follow it to find node
390 try:
391 node, rem_path = ShareNode.find(client, path, session["peer_jid"])
392 except (exceptions.PermissionError, exceptions.NotFound):
393 #  no file, or file not allowed, we continue normal workflow
394 return True, None
395 except exceptions.DataError:
396 log.warning(_("invalid path: {path}").format(path=path))
397 return True, None
398
399 if node.type == TYPE_VIRTUAL:
400 # we have a virtual node, so name must link to a path node
401 try:
402 path = node[name].path
403 except KeyError:
404 return True, None
405 elif node.type == TYPE_PATH:
406 # we have a path node, so we can retrieve the full path now
407 path = os.path.join(node.path, rem_path, name)
408 else:
409 raise exceptions.InternalError(
410 "unknown type: {type}".format(type=node.type)
411 )
412 if not os.path.exists(path):
413 return True, None
414 size = os.path.getsize(path)
415 else:
416 # we don't have the path, we try to find the file by its name
417 try:
418 name_data = client._XEP_0329_names_data[name]
419 except KeyError:
420 return True, None
421
422 for path, shared_file in name_data.items():
423 if True: #  FIXME: filters are here
424 break
425 else:
426 return True, None
427 parent_node = shared_file["parent"]
428 if not parent_node.check_permissions(client, session["peer_jid"]):
429 log.warning(
430 _(
431 "{peer_jid} requested a file (s)he can't access [{profile}]"
432 ).format(peer_jid=session["peer_jid"], profile=client.profile)
433 )
434 return True, None
435 size = shared_file["size"]
436
437 file_data["size"] = size
438 file_elt.addElement("size", content=str(size))
439 hash_algo = file_data["hash_algo"] = self._h.get_default_algo()
440 hasher = file_data["hash_hasher"] = self._h.get_hasher(hash_algo)
441 file_elt.addChild(self._h.build_hash_used_elt(hash_algo))
442 content_data["stream_object"] = stream.FileStreamObject(
443 self.host,
444 client,
445 path,
446 uid=self._jf.get_progress_id(session, content_name),
447 size=size,
448 data_cb=lambda data: hasher.update(data),
449 )
450 return False, defer.succeed(True)
451
452 # common methods
453
454 def _request_handler(self, client, iq_elt, root_nodes_cb, files_from_node_cb):
455 iq_elt.handled = True
456 node = iq_elt.query.getAttribute("node")
457 if not node:
458 d = utils.as_deferred(root_nodes_cb, client, iq_elt)
459 else:
460 d = utils.as_deferred(files_from_node_cb, client, iq_elt, node)
461 d.addErrback(
462 lambda failure_: log.error(
463 _("error while retrieving files: {msg}").format(msg=failure_)
464 )
465 )
466
467 def _iq_error(self, client, iq_elt, condition="item-not-found"):
468 error_elt = jabber_error.StanzaError(condition).toResponse(iq_elt)
469 client.send(error_elt)
470
471 #  client
472
473 def _add_path_data(self, client, query_elt, path, parent_node):
474 """Fill query_elt with files/directories found in path"""
475 name = os.path.basename(path)
476 if os.path.isfile(path):
477 size = os.path.getsize(path)
478 mime_type = mimetypes.guess_type(path, strict=False)[0]
479 file_elt = self._jf.build_file_element(
480 client=client, name=name, size=size, mime_type=mime_type,
481 modified=os.path.getmtime(path)
482 )
483
484 query_elt.addChild(file_elt)
485 # we don't specify hash as it would be too resource intensive to calculate
486 # it for all files.
487 # we add file to name_data, so users can request it later
488 name_data = client._XEP_0329_names_data.setdefault(name, {})
489 if path not in name_data:
490 name_data[path] = {
491 "size": size,
492 "mime_type": mime_type,
493 "parent": parent_node,
494 }
495 else:
496 # we have a directory
497 directory_elt = query_elt.addElement("directory")
498 directory_elt["name"] = name
499
500 def _path_node_handler(self, client, iq_elt, query_elt, node, path):
501 """Fill query_elt for path nodes, i.e. physical directories"""
502 path = os.path.join(node.path, path)
503
504 if not os.path.exists(path):
505 # path may have been moved since it has been shared
506 return self._iq_error(client, iq_elt)
507 elif os.path.isfile(path):
508 self._add_path_data(client, query_elt, path, node)
509 else:
510 for name in sorted(os.listdir(path.encode("utf-8")), key=lambda n: n.lower()):
511 try:
512 name = name.decode("utf-8", "strict")
513 except UnicodeDecodeError as e:
514 log.warning(
515 _("ignoring invalid unicode name ({name}): {msg}").format(
516 name=name.decode("utf-8", "replace"), msg=e
517 )
518 )
519 continue
520 full_path = os.path.join(path, name)
521 self._add_path_data(client, query_elt, full_path, node)
522
523 def _virtual_node_handler(self, client, peer_jid, iq_elt, query_elt, node):
524 """Fill query_elt for virtual nodes"""
525 for name, child_node in node.items():
526 if not child_node.check_permissions(client, peer_jid, check_parents=False):
527 continue
528 node_type = child_node.type
529 if node_type == TYPE_VIRTUAL:
530 directory_elt = query_elt.addElement("directory")
531 directory_elt["name"] = name
532 elif node_type == TYPE_PATH:
533 self._add_path_data(client, query_elt, child_node.path, child_node)
534 else:
535 raise exceptions.InternalError(
536 _("unexpected type: {type}").format(type=node_type)
537 )
538
539 def _get_root_nodes_cb(self, client, iq_elt):
540 peer_jid = jid.JID(iq_elt["from"])
541 iq_result_elt = xmlstream.toResponse(iq_elt, "result")
542 query_elt = iq_result_elt.addElement((NS_FIS, "query"))
543 for name, node in client._XEP_0329_root_node.items():
544 if not node.check_permissions(client, peer_jid, check_parents=False):
545 continue
546 directory_elt = query_elt.addElement("directory")
547 directory_elt["name"] = name
548 client.send(iq_result_elt)
549
550 def _get_files_from_node_cb(self, client, iq_elt, node_path):
551 """Main method to retrieve files/directories from a node_path"""
552 peer_jid = jid.JID(iq_elt["from"])
553 try:
554 node, path = ShareNode.find(client, node_path, peer_jid)
555 except (exceptions.PermissionError, exceptions.NotFound):
556 return self._iq_error(client, iq_elt)
557 except exceptions.DataError:
558 return self._iq_error(client, iq_elt, condition="not-acceptable")
559
560 node_type = node.type
561 peer_jid = jid.JID(iq_elt["from"])
562 iq_result_elt = xmlstream.toResponse(iq_elt, "result")
563 query_elt = iq_result_elt.addElement((NS_FIS, "query"))
564 query_elt["node"] = node_path
565
566 # we now fill query_elt according to node_type
567 if node_type == TYPE_PATH:
568 #  it's a physical path
569 self._path_node_handler(client, iq_elt, query_elt, node, path)
570 elif node_type == TYPE_VIRTUAL:
571 assert not path
572 self._virtual_node_handler(client, peer_jid, iq_elt, query_elt, node)
573 else:
574 raise exceptions.InternalError(
575 _("unknown node type: {type}").format(type=node_type)
576 )
577
578 client.send(iq_result_elt)
579
580 def on_request(self, iq_elt, client):
581 return self._request_handler(
582 client, iq_elt, self._get_root_nodes_cb, self._get_files_from_node_cb
583 )
584
585 # Component
586
587 def _comp_parse_jids(self, client, iq_elt):
588 """Retrieve peer_jid and owner to use from IQ stanza
589
590 @param iq_elt(domish.Element): IQ stanza of the FIS request
591 @return (tuple[jid.JID, jid.JID]): peer_jid and owner
592 """
593
594 async def _comp_get_root_nodes_cb(self, client, iq_elt):
595 peer_jid, owner = client.get_owner_and_peer(iq_elt)
596 files_data = await self.host.memory.get_files(
597 client,
598 peer_jid=peer_jid,
599 parent="",
600 type_=C.FILE_TYPE_DIRECTORY,
601 owner=owner,
602 )
603 iq_result_elt = xmlstream.toResponse(iq_elt, "result")
604 query_elt = iq_result_elt.addElement((NS_FIS, "query"))
605 for file_data in files_data:
606 name = file_data["name"]
607 directory_elt = query_elt.addElement("directory")
608 directory_elt["name"] = name
609 client.send(iq_result_elt)
610
611 async def _comp_get_files_from_node_cb(self, client, iq_elt, node_path):
612 """Retrieve files from local files repository according to permissions
613
614 result stanza is then built and sent to requestor
615 @trigger XEP-0329_compGetFilesFromNode(client, iq_elt, owner, node_path,
616 files_data):
617 can be used to add data/elements
618 """
619 peer_jid, owner = client.get_owner_and_peer(iq_elt)
620 try:
621 files_data = await self.host.memory.get_files(
622 client, peer_jid=peer_jid, path=node_path, owner=owner
623 )
624 except exceptions.NotFound:
625 self._iq_error(client, iq_elt)
626 return
627 except exceptions.PermissionError:
628 self._iq_error(client, iq_elt, condition='not-allowed')
629 return
630 except Exception as e:
631 tb = traceback.format_tb(e.__traceback__)
632 log.error(f"internal server error: {e}\n{''.join(tb)}")
633 self._iq_error(client, iq_elt, condition='internal-server-error')
634 return
635 iq_result_elt = xmlstream.toResponse(iq_elt, "result")
636 query_elt = iq_result_elt.addElement((NS_FIS, "query"))
637 query_elt["node"] = node_path
638 if not self.host.trigger.point(
639 "XEP-0329_compGetFilesFromNode",
640 client,
641 iq_elt,
642 iq_result_elt,
643 owner,
644 node_path,
645 files_data
646 ):
647 return
648 for file_data in files_data:
649 if file_data['type'] == C.FILE_TYPE_DIRECTORY:
650 directory_elt = query_elt.addElement("directory")
651 directory_elt['name'] = file_data['name']
652 self.host.trigger.point(
653 "XEP-0329_compGetFilesFromNode_build_directory",
654 client,
655 file_data,
656 directory_elt,
657 owner,
658 node_path,
659 )
660 else:
661 file_elt = self._jf.build_file_element_from_dict(
662 client,
663 file_data,
664 modified=file_data.get("modified", file_data["created"])
665 )
666 query_elt.addChild(file_elt)
667 client.send(iq_result_elt)
668
669 def on_component_request(self, iq_elt, client):
670 return self._request_handler(
671 client, iq_elt, self._comp_get_root_nodes_cb, self._comp_get_files_from_node_cb
672 )
673
674 async def _parse_result(self, client, peer_jid, iq_elt):
675 query_elt = next(iq_elt.elements(NS_FIS, "query"))
676 files = []
677
678 for elt in query_elt.elements():
679 if elt.name == "file":
680 # we have a file
681 try:
682 file_data = await self._jf.parse_file_element(client, elt)
683 except exceptions.DataError:
684 continue
685 file_data["type"] = C.FILE_TYPE_FILE
686 try:
687 thumbs = file_data['extra'][C.KEY_THUMBNAILS]
688 except KeyError:
689 log.debug(f"No thumbnail found for {file_data}")
690 else:
691 for thumb in thumbs:
692 if 'url' not in thumb and "id" in thumb:
693 try:
694 file_path = await self._b.get_file(client, peer_jid, thumb['id'])
695 except Exception as e:
696 log.warning(f"Can't get thumbnail {thumb['id']!r} for {file_data}: {e}")
697 else:
698 thumb['filename'] = file_path.name
699
700 elif elt.name == "directory" and elt.uri == NS_FIS:
701 # we have a directory
702
703 file_data = {"name": elt["name"], "type": C.FILE_TYPE_DIRECTORY}
704 self.host.trigger.point(
705 "XEP-0329_parseResult_directory",
706 client,
707 elt,
708 file_data,
709 )
710 else:
711 log.warning(
712 _("unexpected element, ignoring: {elt}")
713 .format(elt=elt.toXml())
714 )
715 continue
716 files.append(file_data)
717 return files
718
719 # affiliations #
720
721 async def _parse_element(self, client, iq_elt, element, namespace):
722 peer_jid, owner = client.get_owner_and_peer(iq_elt)
723 elt = next(iq_elt.elements(namespace, element))
724 path = Path("/", elt['path'])
725 if len(path.parts) < 2:
726 raise RootPathException
727 namespace = elt.getAttribute('namespace')
728 files_data = await self.host.memory.get_files(
729 client,
730 peer_jid=peer_jid,
731 path=str(path.parent),
732 name=path.name,
733 namespace=namespace,
734 owner=owner,
735 )
736 if len(files_data) != 1:
737 client.sendError(iq_elt, 'item-not-found')
738 raise exceptions.CancelError
739 file_data = files_data[0]
740 return peer_jid, elt, path, namespace, file_data
741
742 def _affiliations_get(self, service_jid_s, namespace, path, profile):
743 client = self.host.get_client(profile)
744 service = jid.JID(service_jid_s)
745 d = defer.ensureDeferred(self.affiliationsGet(
746 client, service, namespace or None, path))
747 d.addCallback(
748 lambda affiliations: {
749 str(entity): affiliation for entity, affiliation in affiliations.items()
750 }
751 )
752 return d
753
754 async def affiliationsGet(
755 self,
756 client: SatXMPPEntity,
757 service: jid.JID,
758 namespace: Optional[str],
759 path: str
760 ) -> Dict[jid.JID, str]:
761 if not path:
762 raise ValueError(f"invalid path: {path!r}")
763 iq_elt = client.IQ("get")
764 iq_elt['to'] = service.full()
765 affiliations_elt = iq_elt.addElement((NS_FIS_AFFILIATION, "affiliations"))
766 if namespace:
767 affiliations_elt["namespace"] = namespace
768 affiliations_elt["path"] = path
769 iq_result_elt = await iq_elt.send()
770 try:
771 affiliations_elt = next(iq_result_elt.elements(NS_FIS_AFFILIATION, "affiliations"))
772 except StopIteration:
773 raise exceptions.DataError(f"Invalid result to affiliations request: {iq_result_elt.toXml()}")
774
775 affiliations = {}
776 for affiliation_elt in affiliations_elt.elements(NS_FIS_AFFILIATION, 'affiliation'):
777 try:
778 affiliations[jid.JID(affiliation_elt['jid'])] = affiliation_elt['affiliation']
779 except (KeyError, RuntimeError):
780 raise exceptions.DataError(
781 f"invalid affiliation element: {affiliation_elt.toXml()}")
782
783 return affiliations
784
785 def _affiliations_set(self, service_jid_s, namespace, path, affiliations, profile):
786 client = self.host.get_client(profile)
787 service = jid.JID(service_jid_s)
788 affiliations = {jid.JID(e): a for e, a in affiliations.items()}
789 return defer.ensureDeferred(self.affiliationsSet(
790 client, service, namespace or None, path, affiliations))
791
792 async def affiliationsSet(
793 self,
794 client: SatXMPPEntity,
795 service: jid.JID,
796 namespace: Optional[str],
797 path: str,
798 affiliations: Dict[jid.JID, str],
799 ):
800 if not path:
801 raise ValueError(f"invalid path: {path!r}")
802 iq_elt = client.IQ("set")
803 iq_elt['to'] = service.full()
804 affiliations_elt = iq_elt.addElement((NS_FIS_AFFILIATION, "affiliations"))
805 if namespace:
806 affiliations_elt["namespace"] = namespace
807 affiliations_elt["path"] = path
808 for entity_jid, affiliation in affiliations.items():
809 affiliation_elt = affiliations_elt.addElement('affiliation')
810 affiliation_elt['jid'] = entity_jid.full()
811 affiliation_elt['affiliation'] = affiliation
812 await iq_elt.send()
813
814 def _on_component_affiliations_get(self, iq_elt, client):
815 iq_elt.handled = True
816 defer.ensureDeferred(self.on_component_affiliations_get(client, iq_elt))
817
818 async def on_component_affiliations_get(self, client, iq_elt):
819 try:
820 (
821 from_jid, affiliations_elt, path, namespace, file_data
822 ) = await self._parse_element(client, iq_elt, "affiliations", NS_FIS_AFFILIATION)
823 except exceptions.CancelError:
824 return
825 except RootPathException:
826 # if root path is requested, we only get owner affiliation
827 peer_jid, owner = client.get_owner_and_peer(iq_elt)
828 is_owner = peer_jid.userhostJID() == owner
829 affiliations = {owner: 'owner'}
830 except exceptions.NotFound:
831 client.sendError(iq_elt, "item-not-found")
832 return
833 except Exception as e:
834 client.sendError(iq_elt, "internal-server-error", str(e))
835 return
836 else:
837 from_jid_bare = from_jid.userhostJID()
838 is_owner = from_jid_bare == file_data.get('owner')
839 affiliations = self.host.memory.get_file_affiliations(file_data)
840 iq_result_elt = xmlstream.toResponse(iq_elt, "result")
841 affiliations_elt = iq_result_elt.addElement((NS_FIS_AFFILIATION, 'affiliations'))
842 for entity_jid, affiliation in affiliations.items():
843 if not is_owner and entity_jid.userhostJID() != from_jid_bare:
844 # only onwer can get all affiliations
845 continue
846 affiliation_elt = affiliations_elt.addElement('affiliation')
847 affiliation_elt['jid'] = entity_jid.userhost()
848 affiliation_elt['affiliation'] = affiliation
849 client.send(iq_result_elt)
850
851 def _on_component_affiliations_set(self, iq_elt, client):
852 iq_elt.handled = True
853 defer.ensureDeferred(self.on_component_affiliations_set(client, iq_elt))
854
855 async def on_component_affiliations_set(self, client, iq_elt):
856 try:
857 (
858 from_jid, affiliations_elt, path, namespace, file_data
859 ) = await self._parse_element(client, iq_elt, "affiliations", NS_FIS_AFFILIATION)
860 except exceptions.CancelError:
861 return
862 except RootPathException:
863 client.sendError(iq_elt, 'bad-request', "Root path can't be used")
864 return
865
866 if from_jid.userhostJID() != file_data['owner']:
867 log.warning(
868 f"{from_jid} tried to modify {path} affiliations while the owner is "
869 f"{file_data['owner']}"
870 )
871 client.sendError(iq_elt, 'forbidden')
872 return
873
874 try:
875 affiliations = {
876 jid.JID(e['jid']): e['affiliation']
877 for e in affiliations_elt.elements(NS_FIS_AFFILIATION, 'affiliation')
878 }
879 except (KeyError, RuntimeError):
880 log.warning(
881 f"invalid affiliation element: {affiliations_elt.toXml()}"
882 )
883 client.sendError(iq_elt, 'bad-request', "invalid affiliation element")
884 return
885 except Exception as e:
886 log.error(
887 f"unexepected exception while setting affiliation element: {e}\n"
888 f"{affiliations_elt.toXml()}"
889 )
890 client.sendError(iq_elt, 'internal-server-error', f"{e}")
891 return
892
893 await self.host.memory.set_file_affiliations(client, file_data, affiliations)
894
895 iq_result_elt = xmlstream.toResponse(iq_elt, "result")
896 client.send(iq_result_elt)
897
898 # configuration
899
900 def _configuration_get(self, service_jid_s, namespace, path, profile):
901 client = self.host.get_client(profile)
902 service = jid.JID(service_jid_s)
903 d = defer.ensureDeferred(self.configuration_get(
904 client, service, namespace or None, path))
905 d.addCallback(
906 lambda configuration: {
907 str(entity): affiliation for entity, affiliation in configuration.items()
908 }
909 )
910 return d
911
912 async def configuration_get(
913 self,
914 client: SatXMPPEntity,
915 service: jid.JID,
916 namespace: Optional[str],
917 path: str
918 ) -> Dict[str, str]:
919 if not path:
920 raise ValueError(f"invalid path: {path!r}")
921 iq_elt = client.IQ("get")
922 iq_elt['to'] = service.full()
923 configuration_elt = iq_elt.addElement((NS_FIS_CONFIGURATION, "configuration"))
924 if namespace:
925 configuration_elt["namespace"] = namespace
926 configuration_elt["path"] = path
927 iq_result_elt = await iq_elt.send()
928 try:
929 configuration_elt = next(iq_result_elt.elements(NS_FIS_CONFIGURATION, "configuration"))
930 except StopIteration:
931 raise exceptions.DataError(f"Invalid result to configuration request: {iq_result_elt.toXml()}")
932
933 form = data_form.findForm(configuration_elt, NS_FIS_CONFIGURATION)
934 configuration = {f.var: f.value for f in form.fields.values()}
935
936 return configuration
937
938 def _configuration_set(self, service_jid_s, namespace, path, configuration, profile):
939 client = self.host.get_client(profile)
940 service = jid.JID(service_jid_s)
941 return defer.ensureDeferred(self.configuration_set(
942 client, service, namespace or None, path, configuration))
943
944 async def configuration_set(
945 self,
946 client: SatXMPPEntity,
947 service: jid.JID,
948 namespace: Optional[str],
949 path: str,
950 configuration: Dict[str, str],
951 ):
952 if not path:
953 raise ValueError(f"invalid path: {path!r}")
954 iq_elt = client.IQ("set")
955 iq_elt['to'] = service.full()
956 configuration_elt = iq_elt.addElement((NS_FIS_CONFIGURATION, "configuration"))
957 if namespace:
958 configuration_elt["namespace"] = namespace
959 configuration_elt["path"] = path
960 form = data_form.Form(formType="submit", formNamespace=NS_FIS_CONFIGURATION)
961 form.makeFields(configuration)
962 configuration_elt.addChild(form.toElement())
963 await iq_elt.send()
964
965 def _on_component_configuration_get(self, iq_elt, client):
966 iq_elt.handled = True
967 defer.ensureDeferred(self.on_component_configuration_get(client, iq_elt))
968
969 async def on_component_configuration_get(self, client, iq_elt):
970 try:
971 (
972 from_jid, configuration_elt, path, namespace, file_data
973 ) = await self._parse_element(client, iq_elt, "configuration", NS_FIS_CONFIGURATION)
974 except exceptions.CancelError:
975 return
976 except RootPathException:
977 client.sendError(iq_elt, 'bad-request', "Root path can't be used")
978 return
979 try:
980 access_type = file_data['access'][C.ACCESS_PERM_READ]['type']
981 except KeyError:
982 access_model = 'whitelist'
983 else:
984 access_model = 'open' if access_type == C.ACCESS_TYPE_PUBLIC else 'whitelist'
985
986 iq_result_elt = xmlstream.toResponse(iq_elt, "result")
987 configuration_elt = iq_result_elt.addElement((NS_FIS_CONFIGURATION, 'configuration'))
988 form = data_form.Form(formType="form", formNamespace=NS_FIS_CONFIGURATION)
989 form.makeFields({'access_model': access_model})
990 configuration_elt.addChild(form.toElement())
991 client.send(iq_result_elt)
992
993 async def _set_configuration(self, client, configuration_elt, file_data):
994 form = data_form.findForm(configuration_elt, NS_FIS_CONFIGURATION)
995 for name, value in form.items():
996 if name == 'access_model':
997 await self.host.memory.set_file_access_model(client, file_data, value)
998 else:
999 # TODO: send a IQ error?
1000 log.warning(
1001 f"Trying to set a not implemented configuration option: {name}")
1002
1003 def _on_component_configuration_set(self, iq_elt, client):
1004 iq_elt.handled = True
1005 defer.ensureDeferred(self.on_component_configuration_set(client, iq_elt))
1006
1007 async def on_component_configuration_set(self, client, iq_elt):
1008 try:
1009 (
1010 from_jid, configuration_elt, path, namespace, file_data
1011 ) = await self._parse_element(client, iq_elt, "configuration", NS_FIS_CONFIGURATION)
1012 except exceptions.CancelError:
1013 return
1014 except RootPathException:
1015 client.sendError(iq_elt, 'bad-request', "Root path can't be used")
1016 return
1017
1018 from_jid_bare = from_jid.userhostJID()
1019 is_owner = from_jid_bare == file_data.get('owner')
1020 if not is_owner:
1021 log.warning(
1022 f"{from_jid} tried to modify {path} configuration while the owner is "
1023 f"{file_data['owner']}"
1024 )
1025 client.sendError(iq_elt, 'forbidden')
1026 return
1027
1028 await self._set_configuration(client, configuration_elt, file_data)
1029
1030 iq_result_elt = xmlstream.toResponse(iq_elt, "result")
1031 client.send(iq_result_elt)
1032
1033 # directory creation
1034
1035 def _create_dir(self, service_jid_s, namespace, path, configuration, profile):
1036 client = self.host.get_client(profile)
1037 service = jid.JID(service_jid_s)
1038 return defer.ensureDeferred(self.create_dir(
1039 client, service, namespace or None, path, configuration or None))
1040
1041 async def create_dir(
1042 self,
1043 client: SatXMPPEntity,
1044 service: jid.JID,
1045 namespace: Optional[str],
1046 path: str,
1047 configuration: Optional[Dict[str, str]],
1048 ):
1049 if not path:
1050 raise ValueError(f"invalid path: {path!r}")
1051 iq_elt = client.IQ("set")
1052 iq_elt['to'] = service.full()
1053 create_dir_elt = iq_elt.addElement((NS_FIS_CREATE, "dir"))
1054 if namespace:
1055 create_dir_elt["namespace"] = namespace
1056 create_dir_elt["path"] = path
1057 if configuration:
1058 configuration_elt = create_dir_elt.addElement((NS_FIS_CONFIGURATION, "configuration"))
1059 form = data_form.Form(formType="submit", formNamespace=NS_FIS_CONFIGURATION)
1060 form.makeFields(configuration)
1061 configuration_elt.addChild(form.toElement())
1062 await iq_elt.send()
1063
1064 def _on_component_create_dir(self, iq_elt, client):
1065 iq_elt.handled = True
1066 defer.ensureDeferred(self.on_component_create_dir(client, iq_elt))
1067
1068 async def on_component_create_dir(self, client, iq_elt):
1069 peer_jid, owner = client.get_owner_and_peer(iq_elt)
1070 if peer_jid.host not in client._file_sharing_allowed_hosts:
1071 client.sendError(iq_elt, 'forbidden')
1072 return
1073 create_dir_elt = next(iq_elt.elements(NS_FIS_CREATE, "dir"))
1074 namespace = create_dir_elt.getAttribute('namespace')
1075 path = Path("/", create_dir_elt['path'])
1076 if len(path.parts) < 2:
1077 client.sendError(iq_elt, 'bad-request', "Root path can't be used")
1078 return
1079 # for root directories, we check permission here
1080 if len(path.parts) == 2 and owner != peer_jid.userhostJID():
1081 log.warning(
1082 f"{peer_jid} is trying to create a dir at {owner}'s repository:\n"
1083 f"path: {path}\nnamespace: {namespace!r}"
1084 )
1085 client.sendError(iq_elt, 'forbidden', "You can't create a directory there")
1086 return
1087 # when going further into the path, the permissions will be checked by get_files
1088 files_data = await self.host.memory.get_files(
1089 client,
1090 peer_jid=peer_jid,
1091 path=path.parent,
1092 namespace=namespace,
1093 owner=owner,
1094 )
1095 if path.name in [d['name'] for d in files_data]:
1096 log.warning(
1097 f"Conflict when trying to create a directory (from: {peer_jid} "
1098 f"namespace: {namespace!r} path: {path!r})"
1099 )
1100 client.sendError(
1101 iq_elt, 'conflict', "there is already a file or dir at this path")
1102 return
1103
1104 try:
1105 configuration_elt = next(
1106 create_dir_elt.elements(NS_FIS_CONFIGURATION, 'configuration'))
1107 except StopIteration:
1108 configuration_elt = None
1109
1110 await self.host.memory.set_file(
1111 client,
1112 path.name,
1113 path=path.parent,
1114 type_=C.FILE_TYPE_DIRECTORY,
1115 namespace=namespace,
1116 owner=owner,
1117 peer_jid=peer_jid
1118 )
1119
1120 if configuration_elt is not None:
1121 file_data = (await self.host.memory.get_files(
1122 client,
1123 peer_jid=peer_jid,
1124 path=path.parent,
1125 name=path.name,
1126 namespace=namespace,
1127 owner=owner,
1128 ))[0]
1129
1130 await self._set_configuration(client, configuration_elt, file_data)
1131
1132 iq_result_elt = xmlstream.toResponse(iq_elt, "result")
1133 client.send(iq_result_elt)
1134
1135 # file methods #
1136
1137 def _serialize_data(self, files_data):
1138 for file_data in files_data:
1139 for key, value in file_data.items():
1140 file_data[key] = (
1141 json.dumps(value) if key in ("extra",) else str(value)
1142 )
1143 return files_data
1144
1145 def _list_files(self, target_jid, path, extra, profile):
1146 client = self.host.get_client(profile)
1147 target_jid = client.jid if not target_jid else jid.JID(target_jid)
1148 d = defer.ensureDeferred(self.list_files(client, target_jid, path or None))
1149 d.addCallback(self._serialize_data)
1150 return d
1151
1152 async def list_files(self, client, peer_jid, path=None, extra=None):
1153 """List file shared by an entity
1154
1155 @param peer_jid(jid.JID): jid of the sharing entity
1156 @param path(unicode, None): path to the directory containing shared files
1157 None to get root directories
1158 @param extra(dict, None): extra data
1159 @return list(dict): shared files
1160 """
1161 iq_elt = client.IQ("get")
1162 iq_elt["to"] = peer_jid.full()
1163 query_elt = iq_elt.addElement((NS_FIS, "query"))
1164 if path:
1165 query_elt["node"] = path
1166 iq_result_elt = await iq_elt.send()
1167 return await self._parse_result(client, peer_jid, iq_result_elt)
1168
1169 def _local_shares_get(self, profile):
1170 client = self.host.get_client(profile)
1171 return self.local_shares_get(client)
1172
1173 def local_shares_get(self, client):
1174 return list(client._XEP_0329_root_node.get_shared_paths().keys())
1175
1176 def _share_path(self, name, path, access, profile):
1177 client = self.host.get_client(profile)
1178 access = json.loads(access)
1179 return self.share_path(client, name or None, path, access)
1180
1181 def share_path(self, client, name, path, access):
1182 if client.is_component:
1183 raise exceptions.ClientTypeError
1184 if not os.path.exists(path):
1185 raise ValueError(_("This path doesn't exist!"))
1186 if not path or not path.strip(" /"):
1187 raise ValueError(_("A path need to be specified"))
1188 if not isinstance(access, dict):
1189 raise ValueError(_("access must be a dict"))
1190
1191 node = client._XEP_0329_root_node
1192 node_type = TYPE_PATH
1193 if os.path.isfile(path):
1194 # we have a single file, the workflow is diferrent as we store all single
1195 # files in the same dir
1196 node = node.get_or_create(SINGLE_FILES_DIR)
1197
1198 if not name:
1199 name = os.path.basename(path.rstrip(" /"))
1200 if not name:
1201 raise exceptions.InternalError(_("Can't find a proper name"))
1202
1203 if name in node or name == SINGLE_FILES_DIR:
1204 idx = 1
1205 new_name = name + "_" + str(idx)
1206 while new_name in node:
1207 idx += 1
1208 new_name = name + "_" + str(idx)
1209 name = new_name
1210 log.info(_(
1211 "A directory with this name is already shared, renamed to {new_name} "
1212 "[{profile}]".format( new_name=new_name, profile=client.profile)))
1213
1214 ShareNode(name=name, parent=node, type_=node_type, access=access, path=path)
1215 self.host.bridge.fis_shared_path_new(path, name, client.profile)
1216 return name
1217
1218 def _unshare_path(self, path, profile):
1219 client = self.host.get_client(profile)
1220 return self.unshare_path(client, path)
1221
1222 def unshare_path(self, client, path):
1223 nodes = client._XEP_0329_root_node.find_by_local_path(path)
1224 for node in nodes:
1225 node.remove_from_parent()
1226 self.host.bridge.fis_shared_path_removed(path, client.profile)
1227
1228
1229 @implementer(iwokkel.IDisco)
1230 class XEP_0329_handler(xmlstream.XMPPHandler):
1231
1232 def __init__(self, plugin_parent):
1233 self.plugin_parent = plugin_parent
1234 self.host = plugin_parent.host
1235
1236 def connectionInitialized(self):
1237 if self.parent.is_component:
1238 self.xmlstream.addObserver(
1239 IQ_FIS_REQUEST, self.plugin_parent.on_component_request, client=self.parent
1240 )
1241 self.xmlstream.addObserver(
1242 IQ_FIS_AFFILIATION_GET,
1243 self.plugin_parent._on_component_affiliations_get,
1244 client=self.parent
1245 )
1246 self.xmlstream.addObserver(
1247 IQ_FIS_AFFILIATION_SET,
1248 self.plugin_parent._on_component_affiliations_set,
1249 client=self.parent
1250 )
1251 self.xmlstream.addObserver(
1252 IQ_FIS_CONFIGURATION_GET,
1253 self.plugin_parent._on_component_configuration_get,
1254 client=self.parent
1255 )
1256 self.xmlstream.addObserver(
1257 IQ_FIS_CONFIGURATION_SET,
1258 self.plugin_parent._on_component_configuration_set,
1259 client=self.parent
1260 )
1261 self.xmlstream.addObserver(
1262 IQ_FIS_CREATE_DIR,
1263 self.plugin_parent._on_component_create_dir,
1264 client=self.parent
1265 )
1266 else:
1267 self.xmlstream.addObserver(
1268 IQ_FIS_REQUEST, self.plugin_parent.on_request, client=self.parent
1269 )
1270
1271 def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
1272 return [disco.DiscoFeature(NS_FIS)]
1273
1274 def getDiscoItems(self, requestor, target, nodeIdentifier=""):
1275 return []