Mercurial > libervia-backend
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) |