comparison libervia/backend/plugins/plugin_comp_file_sharing.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_comp_file_sharing.py@524856bd7b19
children 0f6fd28fde0d
comparison
equal deleted inserted replaced
4070:d10748475025 4071:4b842c1fb686
1 #!/usr/bin/env python3
2
3 # Libervia File Sharing component
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 os
20 import os.path
21 import mimetypes
22 import tempfile
23 from functools import partial
24 import shortuuid
25 import unicodedata
26 from urllib.parse import urljoin, urlparse, quote, unquote
27 from pathlib import Path
28 from libervia.backend.core.i18n import _, D_
29 from libervia.backend.core.constants import Const as C
30 from libervia.backend.core import exceptions
31 from libervia.backend.core.log import getLogger
32 from libervia.backend.tools import stream
33 from libervia.backend.tools import video
34 from libervia.backend.tools.utils import ensure_deferred
35 from libervia.backend.tools.common import regex
36 from libervia.backend.tools.common import uri
37 from libervia.backend.tools.common import files_utils
38 from libervia.backend.tools.common import utils
39 from libervia.backend.tools.common import tls
40 from twisted.internet import defer, reactor
41 from twisted.words.protocols.jabber import error
42 from twisted.web import server, resource, static, http
43 from wokkel import pubsub
44 from wokkel import generic
45
46
47 log = getLogger(__name__)
48
49
50 PLUGIN_INFO = {
51 C.PI_NAME: "File sharing component",
52 C.PI_IMPORT_NAME: "file-sharing",
53 C.PI_MODES: [C.PLUG_MODE_COMPONENT],
54 C.PI_TYPE: C.PLUG_TYPE_ENTRY_POINT,
55 C.PI_PROTOCOLS: [],
56 C.PI_DEPENDENCIES: [
57 "FILE",
58 "FILE_SHARING_MANAGEMENT",
59 "XEP-0106",
60 "XEP-0234",
61 "XEP-0260",
62 "XEP-0261",
63 "XEP-0264",
64 "XEP-0329",
65 "XEP-0363",
66 ],
67 C.PI_RECOMMENDATIONS: [],
68 C.PI_MAIN: "FileSharing",
69 C.PI_HANDLER: C.BOOL_TRUE,
70 C.PI_DESCRIPTION: _("""Component hosting and sharing files"""),
71 }
72
73 HASH_ALGO = "sha-256"
74 NS_COMMENTS = "org.salut-a-toi.comments"
75 NS_FS_AFFILIATION = "org.salut-a-toi.file-sharing-affiliation"
76 COMMENT_NODE_PREFIX = "org.salut-a-toi.file_comments/"
77 # Directory used to buffer request body (i.e. file in case of PUT) we use more than one @
78 # there, to be sure than it's not conflicting with a JID
79 TMP_BUFFER_DIR = "@@tmp@@"
80 OVER_QUOTA_TXT = D_(
81 "You are over quota, your maximum allowed size is {quota} and you are already using "
82 "{used_space}, you can't upload {file_size} more."
83 )
84
85 HTTP_VERSION = unicodedata.normalize(
86 'NFKD',
87 f"{C.APP_NAME} file sharing {C.APP_VERSION}"
88 )
89
90
91 class HTTPFileServer(resource.Resource):
92 isLeaf = True
93
94 def errorPage(self, request, code):
95 request.setResponseCode(code)
96 if code == http.BAD_REQUEST:
97 brief = 'Bad Request'
98 details = "Your request is invalid"
99 elif code == http.FORBIDDEN:
100 brief = 'Forbidden'
101 details = "You're not allowed to use this resource"
102 elif code == http.NOT_FOUND:
103 brief = 'Not Found'
104 details = "No resource found at this URL"
105 else:
106 brief = 'Error'
107 details = "This resource can't be used"
108 log.error(f"Unexpected return code used: {code}")
109 log.warning(
110 f'Error returned while trying to access url {request.uri.decode()}: '
111 f'"{brief}" ({code}): {details}'
112 )
113
114 return resource.ErrorPage(code, brief, details).render(request)
115
116 def get_disposition_type(self, media_type, media_subtype):
117 if media_type in ('image', 'video'):
118 return 'inline'
119 elif media_type == 'application' and media_subtype == 'pdf':
120 return 'inline'
121 else:
122 return 'attachment'
123
124 def render(self, request):
125 request.setHeader("server", HTTP_VERSION)
126 request.setHeader("Access-Control-Allow-Origin", "*")
127 request.setHeader("Access-Control-Allow-Methods", "OPTIONS, HEAD, GET, PUT")
128 request.setHeader(
129 "Access-Control-Allow-Headers",
130 "Content-Type, Range, Xmpp-File-Path, Xmpp-File-No-Http")
131 request.setHeader("Access-Control-Allow-Credentials", "true")
132 request.setHeader("Accept-Ranges", "bytes")
133
134 request.setHeader(
135 "Access-Control-Expose-Headers",
136 "Date, Content-Length, Content-Range")
137 return super().render(request)
138
139 def render_options(self, request):
140 request.setResponseCode(http.OK)
141 return b""
142
143 def render_GET(self, request):
144 try:
145 request.upload_data
146 except exceptions.DataError:
147 return self.errorPage(request, http.NOT_FOUND)
148
149 defer.ensureDeferred(self.render_get(request))
150 return server.NOT_DONE_YET
151
152 async def render_get(self, request):
153 try:
154 upload_id, filename = request.upload_data
155 except exceptions.DataError:
156 request.write(self.errorPage(request, http.FORBIDDEN))
157 request.finish()
158 return
159 found_files = await request.file_sharing.host.memory.get_files(
160 client=None, peer_jid=None, perms_to_check=None, public_id=upload_id)
161 if not found_files:
162 request.write(self.errorPage(request, http.NOT_FOUND))
163 request.finish()
164 return
165 if len(found_files) > 1:
166 log.error(f"more that one files found for public id {upload_id!r}")
167
168 found_file = found_files[0]
169 file_path = request.file_sharing.files_path/found_file['file_hash']
170 file_res = static.File(file_path)
171 file_res.type = f'{found_file["media_type"]}/{found_file["media_subtype"]}'
172 file_res.encoding = file_res.contentEncodings.get(Path(found_file['name']).suffix)
173 disp_type = self.get_disposition_type(
174 found_file['media_type'], found_file['media_subtype'])
175 # the URL is percent encoded, and not all browsers/tools unquote the file name,
176 # thus we add a content disposition header
177 request.setHeader(
178 'Content-Disposition',
179 f"{disp_type}; filename*=UTF-8''{quote(found_file['name'])}"
180 )
181 # cf. https://xmpp.org/extensions/xep-0363.html#server
182 request.setHeader(
183 'Content-Security-Policy',
184 "default-src 'none'; frame-ancestors 'none';"
185 )
186 ret = file_res.render(request)
187 if ret != server.NOT_DONE_YET:
188 # HEAD returns directly the result (while GET use a produced)
189 request.write(ret)
190 request.finish()
191
192 def render_PUT(self, request):
193 defer.ensureDeferred(self.render_put(request))
194 return server.NOT_DONE_YET
195
196 async def render_put(self, request):
197 try:
198 client, upload_request = request.upload_request_data
199 upload_id, filename = request.upload_data
200 except AttributeError:
201 request.write(self.errorPage(request, http.BAD_REQUEST))
202 request.finish()
203 return
204
205 # at this point request is checked and file is buffered, we can store it
206 # we close the content here, before registering the file
207 request.content.close()
208 tmp_file_path = Path(request.content.name)
209 request.content = None
210
211 # the 2 following headers are not standard, but useful in the context of file
212 # sharing with HTTP Upload: first one allow uploader to specify the path
213 # and second one will disable public exposure of the file through HTTP
214 path = request.getHeader("Xmpp-File-Path")
215 if path:
216 path = unquote(path)
217 else:
218 path = "/uploads"
219 if request.getHeader("Xmpp-File-No-Http") is not None:
220 public_id = None
221 else:
222 public_id = upload_id
223
224 file_data = {
225 "name": unquote(upload_request.filename),
226 "mime_type": upload_request.content_type,
227 "size": upload_request.size,
228 "path": path
229 }
230
231 await request.file_sharing.register_received_file(
232 client, upload_request.from_, file_data, tmp_file_path,
233 public_id=public_id,
234 )
235
236 request.setResponseCode(http.CREATED)
237 request.finish()
238
239
240 class FileSharingRequest(server.Request):
241
242 def __init__(self, *args, **kwargs):
243 super().__init__(*args, **kwargs)
244 self._upload_data = None
245
246 @property
247 def upload_data(self):
248 """A tuple with upload_id and filename retrieved from requested path"""
249 if self._upload_data is not None:
250 return self._upload_data
251
252 # self.path is not available if we are early in the request (e.g. when gotLength
253 # is called), in which case channel._path must be used. On the other hand, when
254 # render_[VERB] is called, only self.path is available
255 path = self.channel._path if self.path is None else self.path
256 # we normalise the path
257 path = urlparse(path.decode()).path
258 try:
259 __, upload_id, filename = path.split('/')
260 except ValueError:
261 raise exceptions.DataError("no enought path elements")
262 if len(upload_id) < 10:
263 raise exceptions.DataError(f"invalid upload ID received for a PUT: {upload_id!r}")
264
265 self._upload_data = (upload_id, filename)
266 return self._upload_data
267
268 @property
269 def file_sharing(self):
270 return self.channel.site.file_sharing
271
272 @property
273 def file_tmp_dir(self):
274 return self.channel.site.file_tmp_dir
275
276 def refuse_request(self):
277 if self.content is not None:
278 self.content.close()
279 self.content = open(os.devnull, 'w+b')
280 self.channel._respondToBadRequestAndDisconnect()
281
282 def gotLength(self, length):
283 if self.channel._command.decode().upper() == 'PUT':
284 # for PUT we check early if upload_id is fine, to avoid buffering a file we'll refuse
285 # we buffer the file in component's TMP_BUFFER_DIR, so we just have to rename it at the end
286 try:
287 upload_id, filename = self.upload_data
288 except exceptions.DataError as e:
289 log.warning(f"Invalid PUT request, we stop here: {e}")
290 return self.refuse_request()
291 try:
292 client, upload_request, timer = self.file_sharing.expected_uploads.pop(upload_id)
293 except KeyError:
294 log.warning(f"unknown (expired?) upload ID received for a PUT: {upload_id!r}")
295 return self.refuse_request()
296
297 if not timer.active:
298 log.warning(f"upload id {upload_id!r} used for a PUT, but it is expired")
299 return self.refuse_request()
300
301 timer.cancel()
302
303 if upload_request.filename != filename:
304 log.warning(
305 f"invalid filename for PUT (upload id: {upload_id!r}, URL: {self.channel._path.decode()}). Original "
306 f"{upload_request.filename!r} doesn't match {filename!r}"
307 )
308 return self.refuse_request()
309
310 self.upload_request_data = (client, upload_request)
311
312 file_tmp_path = files_utils.get_unique_name(
313 self.file_tmp_dir/upload_id)
314
315 self.content = open(file_tmp_path, 'w+b')
316 else:
317 return super().gotLength(length)
318
319
320 class FileSharingSite(server.Site):
321 requestFactory = FileSharingRequest
322
323 def __init__(self, file_sharing):
324 self.file_sharing = file_sharing
325 self.file_tmp_dir = file_sharing.host.get_local_path(
326 None, C.FILES_TMP_DIR, TMP_BUFFER_DIR, component=True
327 )
328 for old_file in self.file_tmp_dir.iterdir():
329 log.debug(f"purging old buffer file at {old_file}")
330 old_file.unlink()
331 super().__init__(HTTPFileServer())
332
333 def getContentFile(self, length):
334 file_tmp_path = self.file_tmp_dir/shortuuid.uuid()
335 return open(file_tmp_path, 'w+b')
336
337
338 class FileSharing:
339
340 def __init__(self, host):
341 self.host = host
342 self.initialised = False
343
344 def init(self):
345 # we init once on first component connection,
346 # there is not need to init this plugin if not component use it
347 # TODO: this plugin should not be loaded at all if no component uses it
348 # and should be loaded dynamically as soon as a suitable profile is created
349 if self.initialised:
350 return
351 self.initialised = True
352 log.info(_("File Sharing initialization"))
353 self._f = self.host.plugins["FILE"]
354 self._jf = self.host.plugins["XEP-0234"]
355 self._h = self.host.plugins["XEP-0300"]
356 self._t = self.host.plugins["XEP-0264"]
357 self._hu = self.host.plugins["XEP-0363"]
358 self._hu.register_handler(self._on_http_upload)
359 self.host.trigger.add("FILE_getDestDir", self._get_dest_dir_trigger)
360 self.host.trigger.add(
361 "XEP-0234_fileSendingRequest", self._file_sending_request_trigger, priority=1000
362 )
363 self.host.trigger.add("XEP-0234_buildFileElement", self._add_file_metadata_elts)
364 self.host.trigger.add("XEP-0234_parseFileElement", self._get_file_metadata_elts)
365 self.host.trigger.add("XEP-0329_compGetFilesFromNode", self._add_file_metadata)
366 self.host.trigger.add(
367 "XEP-0329_compGetFilesFromNode_build_directory",
368 self._add_directory_metadata_elts)
369 self.host.trigger.add(
370 "XEP-0329_parseResult_directory",
371 self._get_directory_metadata_elts)
372 self.files_path = self.host.get_local_path(None, C.FILES_DIR)
373 self.http_port = int(self.host.memory.config_get(
374 'component file-sharing', 'http_upload_port', 8888))
375 connection_type = self.host.memory.config_get(
376 'component file-sharing', 'http_upload_connection_type', 'https')
377 if connection_type not in ('http', 'https'):
378 raise exceptions.ConfigError(
379 'bad http_upload_connection_type, you must use one of "http" or "https"'
380 )
381 self.server = FileSharingSite(self)
382 self.expected_uploads = {}
383 if connection_type == 'http':
384 reactor.listenTCP(self.http_port, self.server)
385 else:
386 options = tls.get_options_from_config(
387 self.host.memory.config, "component file-sharing")
388 tls.tls_options_check(options)
389 context_factory = tls.get_tls_context_factory(options)
390 reactor.listenSSL(self.http_port, self.server, context_factory)
391
392 def get_handler(self, client):
393 return Comments_handler(self)
394
395 def profile_connecting(self, client):
396 # we activate HTTP upload
397 client.enabled_features.add("XEP-0363")
398
399 self.init()
400 public_base_url = self.host.memory.config_get(
401 'component file-sharing', 'http_upload_public_facing_url')
402 if public_base_url is None:
403 client._file_sharing_base_url = f"https://{client.host}:{self.http_port}"
404 else:
405 client._file_sharing_base_url = public_base_url
406 path = client.file_tmp_dir = os.path.join(
407 self.host.memory.config_get("", "local_dir"),
408 C.FILES_TMP_DIR,
409 regex.path_escape(client.profile),
410 )
411 if not os.path.exists(path):
412 os.makedirs(path)
413
414 def get_quota(self, client, entity):
415 """Return maximum size allowed for all files for entity"""
416 quotas = self.host.memory.config_get("component file-sharing", "quotas_json", {})
417 if self.host.memory.is_admin_jid(entity):
418 quota = quotas.get("admins")
419 else:
420 try:
421 quota = quotas["jids"][entity.userhost()]
422 except KeyError:
423 quota = quotas.get("users")
424 return None if quota is None else utils.parse_size(quota)
425
426 async def generate_thumbnails(self, extra: dict, image_path: Path):
427 thumbnails = extra.setdefault(C.KEY_THUMBNAILS, [])
428 for max_thumb_size in self._t.SIZES:
429 try:
430 thumb_size, thumb_id = await self._t.generate_thumbnail(
431 image_path,
432 max_thumb_size,
433 #  we keep thumbnails for 6 months
434 60 * 60 * 24 * 31 * 6,
435 )
436 except Exception as e:
437 log.warning(_("Can't create thumbnail: {reason}").format(reason=e))
438 break
439 thumbnails.append({"id": thumb_id, "size": thumb_size})
440
441 async def register_received_file(
442 self, client, peer_jid, file_data, file_path, public_id=None, extra=None):
443 """Post file reception tasks
444
445 once file is received, this method create hash/thumbnails if necessary
446 move the file to the right location, and create metadata entry in database
447 """
448 name = file_data["name"]
449 if extra is None:
450 extra = {}
451
452 mime_type = file_data.get("mime_type")
453 if not mime_type or mime_type == "application/octet-stream":
454 mime_type = mimetypes.guess_type(name)[0]
455
456 is_image = mime_type is not None and mime_type.startswith("image")
457 is_video = mime_type is not None and mime_type.startswith("video")
458
459 if file_data.get("hash_algo") == HASH_ALGO:
460 log.debug(_("Reusing already generated hash"))
461 file_hash = file_data["hash_hasher"].hexdigest()
462 else:
463 hasher = self._h.get_hasher(HASH_ALGO)
464 with file_path.open('rb') as f:
465 file_hash = await self._h.calculate_hash(f, hasher)
466 final_path = self.files_path/file_hash
467
468 if final_path.is_file():
469 log.debug(
470 "file [{file_hash}] already exists, we can remove temporary one".format(
471 file_hash=file_hash
472 )
473 )
474 file_path.unlink()
475 else:
476 file_path.rename(final_path)
477 log.debug(
478 "file [{file_hash}] moved to {files_path}".format(
479 file_hash=file_hash, files_path=self.files_path
480 )
481 )
482
483 if is_image:
484 await self.generate_thumbnails(extra, final_path)
485 elif is_video:
486 with tempfile.TemporaryDirectory() as tmp_dir:
487 thumb_path = Path(tmp_dir) / "thumbnail.jpg"
488 try:
489 await video.get_thumbnail(final_path, thumb_path)
490 except Exception as e:
491 log.warning(_("Can't get thumbnail for {final_path}: {e}").format(
492 final_path=final_path, e=e))
493 else:
494 await self.generate_thumbnails(extra, thumb_path)
495
496 await self.host.memory.set_file(
497 client,
498 name=name,
499 version="",
500 file_hash=file_hash,
501 hash_algo=HASH_ALGO,
502 size=file_data["size"],
503 path=file_data.get("path"),
504 namespace=file_data.get("namespace"),
505 mime_type=mime_type,
506 public_id=public_id,
507 owner=peer_jid,
508 extra=extra,
509 )
510
511 async def _get_dest_dir_trigger(
512 self, client, peer_jid, transfer_data, file_data, stream_object
513 ):
514 """This trigger accept file sending request, and store file locally"""
515 if not client.is_component:
516 return True, None
517 # client._file_sharing_allowed_hosts is set in plugin XEP-0329
518 if peer_jid.host not in client._file_sharing_allowed_hosts:
519 raise error.StanzaError("forbidden")
520 assert stream_object
521 assert "stream_object" not in transfer_data
522 assert C.KEY_PROGRESS_ID in file_data
523 filename = file_data["name"]
524 assert filename and not "/" in filename
525 quota = self.get_quota(client, peer_jid)
526 if quota is not None:
527 used_space = await self.host.memory.file_get_used_space(client, peer_jid)
528
529 if (used_space + file_data["size"]) > quota:
530 raise error.StanzaError(
531 "not-acceptable",
532 text=OVER_QUOTA_TXT.format(
533 quota=utils.get_human_size(quota),
534 used_space=utils.get_human_size(used_space),
535 file_size=utils.get_human_size(file_data['size'])
536 )
537 )
538 file_tmp_dir = self.host.get_local_path(
539 None, C.FILES_TMP_DIR, peer_jid.userhost(), component=True
540 )
541 file_tmp_path = file_data['file_path'] = files_utils.get_unique_name(
542 file_tmp_dir/filename)
543
544 transfer_data["finished_d"].addCallback(
545 lambda __: defer.ensureDeferred(
546 self.register_received_file(client, peer_jid, file_data, file_tmp_path)
547 )
548 )
549
550 self._f.open_file_write(
551 client, file_tmp_path, transfer_data, file_data, stream_object
552 )
553 return False, True
554
555 async def _retrieve_files(
556 self, client, session, content_data, content_name, file_data, file_elt
557 ):
558 """This method retrieve a file on request, and send if after checking permissions"""
559 peer_jid = session["peer_jid"]
560 if session['local_jid'].user:
561 owner = client.get_owner_from_jid(session['local_jid'])
562 else:
563 owner = peer_jid
564 try:
565 found_files = await self.host.memory.get_files(
566 client,
567 peer_jid=peer_jid,
568 name=file_data.get("name"),
569 file_hash=file_data.get("file_hash"),
570 hash_algo=file_data.get("hash_algo"),
571 path=file_data.get("path"),
572 namespace=file_data.get("namespace"),
573 owner=owner,
574 )
575 except exceptions.NotFound:
576 found_files = None
577 except exceptions.PermissionError:
578 log.warning(
579 _("{peer_jid} is trying to access an unauthorized file: {name}").format(
580 peer_jid=peer_jid, name=file_data.get("name")
581 )
582 )
583 return False
584
585 if not found_files:
586 log.warning(
587 _("no matching file found ({file_data})").format(file_data=file_data)
588 )
589 return False
590
591 # we only use the first found file
592 found_file = found_files[0]
593 if found_file['type'] != C.FILE_TYPE_FILE:
594 raise TypeError("a file was expected, type is {type_}".format(
595 type_=found_file['type']))
596 file_hash = found_file["file_hash"]
597 file_path = self.files_path / file_hash
598 file_data["hash_hasher"] = hasher = self._h.get_hasher(found_file["hash_algo"])
599 size = file_data["size"] = found_file["size"]
600 file_data["file_hash"] = file_hash
601 file_data["hash_algo"] = found_file["hash_algo"]
602
603 # we complete file_elt so peer can have some details on the file
604 if "name" not in file_data:
605 file_elt.addElement("name", content=found_file["name"])
606 file_elt.addElement("size", content=str(size))
607 content_data["stream_object"] = stream.FileStreamObject(
608 self.host,
609 client,
610 file_path,
611 uid=self._jf.get_progress_id(session, content_name),
612 size=size,
613 data_cb=lambda data: hasher.update(data),
614 )
615 return True
616
617 def _file_sending_request_trigger(
618 self, client, session, content_data, content_name, file_data, file_elt
619 ):
620 if not client.is_component:
621 return True, None
622 else:
623 return (
624 False,
625 defer.ensureDeferred(self._retrieve_files(
626 client, session, content_data, content_name, file_data, file_elt
627 )),
628 )
629
630 ## HTTP Upload ##
631
632 def _purge_slot(self, upload_id):
633 try:
634 del self.expected_uploads[upload_id]
635 except KeyError:
636 log.error(f"trying to purge an inexisting upload slot ({upload_id})")
637
638 async def _on_http_upload(self, client, request):
639 # filename should be already cleaned, but it's better to double check
640 assert '/' not in request.filename
641 # client._file_sharing_allowed_hosts is set in plugin XEP-0329
642 if request.from_.host not in client._file_sharing_allowed_hosts:
643 raise error.StanzaError("forbidden")
644
645 quota = self.get_quota(client, request.from_)
646 if quota is not None:
647 used_space = await self.host.memory.file_get_used_space(client, request.from_)
648
649 if (used_space + request.size) > quota:
650 raise error.StanzaError(
651 "not-acceptable",
652 text=OVER_QUOTA_TXT.format(
653 quota=utils.get_human_size(quota),
654 used_space=utils.get_human_size(used_space),
655 file_size=utils.get_human_size(request.size)
656 ),
657 appCondition = self._hu.get_file_too_large_elt(max(quota - used_space, 0))
658 )
659
660 upload_id = shortuuid.ShortUUID().random(length=30)
661 assert '/' not in upload_id
662 timer = reactor.callLater(30, self._purge_slot, upload_id)
663 self.expected_uploads[upload_id] = (client, request, timer)
664 url = urljoin(client._file_sharing_base_url, f"{upload_id}/{request.filename}")
665 slot = self._hu.Slot(
666 put=url,
667 get=url,
668 headers=[],
669 )
670 return slot
671
672 ## metadata triggers ##
673
674 def _add_file_metadata_elts(self, client, file_elt, extra_args):
675 # affiliation
676 affiliation = extra_args.get('affiliation')
677 if affiliation is not None:
678 file_elt.addElement((NS_FS_AFFILIATION, "affiliation"), content=affiliation)
679
680 # comments
681 try:
682 comments_url = extra_args.pop("comments_url")
683 except KeyError:
684 return
685
686 comment_elt = file_elt.addElement((NS_COMMENTS, "comments"), content=comments_url)
687
688 try:
689 count = len(extra_args["extra"]["comments"])
690 except KeyError:
691 count = 0
692
693 comment_elt["count"] = str(count)
694 return True
695
696 def _get_file_metadata_elts(self, client, file_elt, file_data):
697 # affiliation
698 try:
699 affiliation_elt = next(file_elt.elements(NS_FS_AFFILIATION, "affiliation"))
700 except StopIteration:
701 pass
702 else:
703 file_data["affiliation"] = str(affiliation_elt)
704
705 # comments
706 try:
707 comments_elt = next(file_elt.elements(NS_COMMENTS, "comments"))
708 except StopIteration:
709 pass
710 else:
711 file_data["comments_url"] = str(comments_elt)
712 file_data["comments_count"] = comments_elt["count"]
713 return True
714
715 def _add_file_metadata(
716 self, client, iq_elt, iq_result_elt, owner, node_path, files_data):
717 for file_data in files_data:
718 file_data["comments_url"] = uri.build_xmpp_uri(
719 "pubsub",
720 path=client.jid.full(),
721 node=COMMENT_NODE_PREFIX + file_data["id"],
722 )
723 return True
724
725 def _add_directory_metadata_elts(
726 self, client, file_data, directory_elt, owner, node_path):
727 affiliation = file_data.get('affiliation')
728 if affiliation is not None:
729 directory_elt.addElement(
730 (NS_FS_AFFILIATION, "affiliation"),
731 content=affiliation
732 )
733
734 def _get_directory_metadata_elts(
735 self, client, elt, file_data):
736 try:
737 affiliation_elt = next(elt.elements(NS_FS_AFFILIATION, "affiliation"))
738 except StopIteration:
739 pass
740 else:
741 file_data['affiliation'] = str(affiliation_elt)
742
743
744 class Comments_handler(pubsub.PubSubService):
745 """This class is a minimal Pubsub service handling virtual nodes for comments"""
746
747 def __init__(self, plugin_parent):
748 super(Comments_handler, self).__init__()
749 self.host = plugin_parent.host
750 self.plugin_parent = plugin_parent
751 self.discoIdentity = {
752 "category": "pubsub",
753 "type": "virtual", # FIXME: non standard, here to avoid this service being considered as main pubsub one
754 "name": "files commenting service",
755 }
756
757 def _get_file_id(self, nodeIdentifier):
758 if not nodeIdentifier.startswith(COMMENT_NODE_PREFIX):
759 raise error.StanzaError("item-not-found")
760 file_id = nodeIdentifier[len(COMMENT_NODE_PREFIX) :]
761 if not file_id:
762 raise error.StanzaError("item-not-found")
763 return file_id
764
765 async def get_file_data(self, requestor, nodeIdentifier):
766 file_id = self._get_file_id(nodeIdentifier)
767 try:
768 files = await self.host.memory.get_files(self.parent, requestor, file_id)
769 except (exceptions.NotFound, exceptions.PermissionError):
770 # we don't differenciate between NotFound and PermissionError
771 # to avoid leaking information on existing files
772 raise error.StanzaError("item-not-found")
773 if not files:
774 raise error.StanzaError("item-not-found")
775 if len(files) > 1:
776 raise error.InternalError("there should be only one file")
777 return files[0]
778
779 def comments_update(self, extra, new_comments, peer_jid):
780 """update comments (replace or insert new_comments)
781
782 @param extra(dict): extra data to update
783 @param new_comments(list[tuple(unicode, unicode, unicode)]): comments to update or insert
784 @param peer_jid(unicode, None): bare jid of the requestor, or None if request is done by owner
785 """
786 current_comments = extra.setdefault("comments", [])
787 new_comments_by_id = {c[0]: c for c in new_comments}
788 updated = []
789 # we now check every current comment, to see if one id in new ones
790 # exist, in which case we must update
791 for idx, comment in enumerate(current_comments):
792 comment_id = comment[0]
793 if comment_id in new_comments_by_id:
794 # a new comment has an existing id, update is requested
795 if peer_jid and comment[1] != peer_jid:
796 # requestor has not the right to modify the comment
797 raise exceptions.PermissionError
798 # we replace old_comment with updated one
799 new_comment = new_comments_by_id[comment_id]
800 current_comments[idx] = new_comment
801 updated.append(new_comment)
802
803 # we now remove every updated comments, to only keep
804 # the ones to insert
805 for comment in updated:
806 new_comments.remove(comment)
807
808 current_comments.extend(new_comments)
809
810 def comments_delete(self, extra, comments):
811 try:
812 comments_dict = extra["comments"]
813 except KeyError:
814 return
815 for comment in comments:
816 try:
817 comments_dict.remove(comment)
818 except ValueError:
819 continue
820
821 def _get_from(self, item_elt):
822 """retrieve publisher of an item
823
824 @param item_elt(domish.element): <item> element
825 @return (unicode): full jid as string
826 """
827 iq_elt = item_elt
828 while iq_elt.parent != None:
829 iq_elt = iq_elt.parent
830 return iq_elt["from"]
831
832 @ensure_deferred
833 async def publish(self, requestor, service, nodeIdentifier, items):
834 #  we retrieve file a first time to check authorisations
835 file_data = await self.get_file_data(requestor, nodeIdentifier)
836 file_id = file_data["id"]
837 comments = [(item["id"], self._get_from(item), item.toXml()) for item in items]
838 if requestor.userhostJID() == file_data["owner"]:
839 peer_jid = None
840 else:
841 peer_jid = requestor.userhost()
842 update_cb = partial(self.comments_update, new_comments=comments, peer_jid=peer_jid)
843 try:
844 await self.host.memory.file_update(file_id, "extra", update_cb)
845 except exceptions.PermissionError:
846 raise error.StanzaError("not-authorized")
847
848 @ensure_deferred
849 async def items(self, requestor, service, nodeIdentifier, maxItems, itemIdentifiers):
850 file_data = await self.get_file_data(requestor, nodeIdentifier)
851 comments = file_data["extra"].get("comments", [])
852 if itemIdentifiers:
853 return [generic.parseXml(c[2]) for c in comments if c[0] in itemIdentifiers]
854 else:
855 return [generic.parseXml(c[2]) for c in comments]
856
857 @ensure_deferred
858 async def retract(self, requestor, service, nodeIdentifier, itemIdentifiers):
859 file_data = await self.get_file_data(requestor, nodeIdentifier)
860 file_id = file_data["id"]
861 try:
862 comments = file_data["extra"]["comments"]
863 except KeyError:
864 raise error.StanzaError("item-not-found")
865
866 to_remove = []
867 for comment in comments:
868 comment_id = comment[0]
869 if comment_id in itemIdentifiers:
870 to_remove.append(comment)
871 itemIdentifiers.remove(comment_id)
872 if not itemIdentifiers:
873 break
874
875 if itemIdentifiers:
876 # not all items have been to_remove, we can't continue
877 raise error.StanzaError("item-not-found")
878
879 if requestor.userhostJID() != file_data["owner"]:
880 if not all([c[1] == requestor.userhost() for c in to_remove]):
881 raise error.StanzaError("not-authorized")
882
883 remove_cb = partial(self.comments_delete, comments=to_remove)
884 await self.host.memory.file_update(file_id, "extra", remove_cb)