Mercurial > libervia-backend
comparison sat/plugins/plugin_comp_file_sharing.py @ 2562:26edcf3a30eb
core, setup: huge cleaning:
- moved directories from src and frontends/src to sat and sat_frontends, which is the recommanded naming convention
- move twisted directory to root
- removed all hacks from setup.py, and added missing dependencies, it is now clean
- use https URL for website in setup.py
- removed "Environment :: X11 Applications :: GTK", as wix is deprecated and removed
- renamed sat.sh to sat and fixed its installation
- added python_requires to specify Python version needed
- replaced glib2reactor which use deprecated code by gtk3reactor
sat can now be installed directly from virtualenv without using --system-site-packages anymore \o/
author | Goffi <goffi@goffi.org> |
---|---|
date | Mon, 02 Apr 2018 19:44:50 +0200 |
parents | src/plugins/plugin_comp_file_sharing.py@cbbf2ff2ef3f |
children | 56f94936df1e |
comparison
equal
deleted
inserted
replaced
2561:bd30dc3ffe5a | 2562:26edcf3a30eb |
---|---|
1 #!/usr/bin/env python2 | |
2 # -*- coding: utf-8 -*- | |
3 | |
4 # SAT plugin for parrot mode (experimental) | |
5 # Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) | |
6 | |
7 # This program is free software: you can redistribute it and/or modify | |
8 # it under the terms of the GNU Affero General Public License as published by | |
9 # the Free Software Foundation, either version 3 of the License, or | |
10 # (at your option) any later version. | |
11 | |
12 # This program is distributed in the hope that it will be useful, | |
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
15 # GNU Affero General Public License for more details. | |
16 | |
17 # You should have received a copy of the GNU Affero General Public License | |
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
19 | |
20 from sat.core.i18n import _ | |
21 from sat.core.constants import Const as C | |
22 from sat.core import exceptions | |
23 from sat.core.log import getLogger | |
24 log = getLogger(__name__) | |
25 from sat.tools.common import regex | |
26 from sat.tools.common import uri | |
27 from sat.tools import stream | |
28 from twisted.internet import defer | |
29 from twisted.words.protocols.jabber import error | |
30 from wokkel import pubsub | |
31 from wokkel import generic | |
32 from functools import partial | |
33 import os | |
34 import os.path | |
35 import mimetypes | |
36 | |
37 | |
38 PLUGIN_INFO = { | |
39 C.PI_NAME: "File sharing component", | |
40 C.PI_IMPORT_NAME: "file_sharing", | |
41 C.PI_MODES: [C.PLUG_MODE_COMPONENT], | |
42 C.PI_TYPE: C.PLUG_TYPE_ENTRY_POINT, | |
43 C.PI_PROTOCOLS: [], | |
44 C.PI_DEPENDENCIES: ["FILE", "XEP-0231", "XEP-0234", "XEP-0260", "XEP-0261", "XEP-0264", "XEP-0329"], | |
45 C.PI_RECOMMENDATIONS: [], | |
46 C.PI_MAIN: "FileSharing", | |
47 C.PI_HANDLER: C.BOOL_TRUE, | |
48 C.PI_DESCRIPTION: _(u"""Component hosting and sharing files""") | |
49 } | |
50 | |
51 HASH_ALGO = u'sha-256' | |
52 NS_COMMENTS = 'org.salut-a-toi.comments' | |
53 COMMENT_NODE_PREFIX = 'org.salut-a-toi.file_comments/' | |
54 | |
55 | |
56 class FileSharing(object): | |
57 | |
58 def __init__(self, host): | |
59 log.info(_(u"File Sharing initialization")) | |
60 self.host = host | |
61 self._f = host.plugins['FILE'] | |
62 self._jf = host.plugins['XEP-0234'] | |
63 self._h = host.plugins['XEP-0300'] | |
64 self._t = host.plugins['XEP-0264'] | |
65 host.trigger.add("FILE_getDestDir", self._getDestDirTrigger) | |
66 host.trigger.add("XEP-0234_fileSendingRequest", self._fileSendingRequestTrigger, priority=1000) | |
67 host.trigger.add("XEP-0234_buildFileElement", self._addFileComments) | |
68 host.trigger.add("XEP-0234_parseFileElement", self._getFileComments) | |
69 host.trigger.add("XEP-0329_compGetFilesFromNode", self._addCommentsData) | |
70 self.files_path = host.getLocalPath(None, C.FILES_DIR, profile=False) | |
71 | |
72 def getHandler(self, client): | |
73 return Comments_handler(self) | |
74 | |
75 def profileConnected(self, client): | |
76 path = client.file_tmp_dir = os.path.join( | |
77 self.host.memory.getConfig('', 'local_dir'), | |
78 C.FILES_TMP_DIR, | |
79 regex.pathEscape(client.profile)) | |
80 if not os.path.exists(path): | |
81 os.makedirs(path) | |
82 | |
83 @defer.inlineCallbacks | |
84 def _fileTransferedCb(self, dummy, client, peer_jid, file_data, file_path): | |
85 """post file reception tasks | |
86 | |
87 on file is received, this method create hash/thumbnails if necessary | |
88 move the file to the right location, and create metadata entry in database | |
89 """ | |
90 name = file_data[u'name'] | |
91 extra = {} | |
92 | |
93 if file_data[u'hash_algo'] == HASH_ALGO: | |
94 log.debug(_(u"Reusing already generated hash")) | |
95 file_hash = file_data[u'hash_hasher'].hexdigest() | |
96 else: | |
97 hasher = self._h.getHasher(HASH_ALGO) | |
98 with open('file_path') as f: | |
99 file_hash = yield self._h.calculateHash(f, hasher) | |
100 final_path = os.path.join(self.files_path, file_hash) | |
101 | |
102 if os.path.isfile(final_path): | |
103 log.debug(u"file [{file_hash}] already exists, we can remove temporary one".format(file_hash = file_hash)) | |
104 os.unlink(file_path) | |
105 else: | |
106 os.rename(file_path, final_path) | |
107 log.debug(u"file [{file_hash}] moved to {files_path}".format(file_hash=file_hash, files_path=self.files_path)) | |
108 | |
109 mime_type = file_data.get(u'mime_type') | |
110 if not mime_type or mime_type == u'application/octet-stream': | |
111 mime_type = mimetypes.guess_type(name)[0] | |
112 | |
113 if mime_type is not None and mime_type.startswith(u'image'): | |
114 thumbnails = extra.setdefault(C.KEY_THUMBNAILS, []) | |
115 for max_thumb_size in (self._t.SIZE_SMALL, self._t.SIZE_MEDIUM): | |
116 try: | |
117 thumb_size, thumb_id = yield self._t.generateThumbnail(final_path, | |
118 max_thumb_size, | |
119 # we keep thumbnails for 6 months | |
120 60*60*24*31*6) | |
121 except Exception as e: | |
122 log.warning(_(u"Can't create thumbnail: {reason}").format(reason=e)) | |
123 break | |
124 thumbnails.append({u'id': thumb_id, u'size': thumb_size}) | |
125 | |
126 self.host.memory.setFile(client, | |
127 name=name, | |
128 version=u'', | |
129 file_hash=file_hash, | |
130 hash_algo=HASH_ALGO, | |
131 size=file_data[u'size'], | |
132 path=file_data.get(u'path'), | |
133 namespace=file_data.get(u'namespace'), | |
134 mime_type=mime_type, | |
135 owner=peer_jid, | |
136 extra=extra) | |
137 | |
138 def _getDestDirTrigger(self, client, peer_jid, transfer_data, file_data, stream_object): | |
139 """This trigger accept file sending request, and store file locally""" | |
140 if not client.is_component: | |
141 return True, None | |
142 assert stream_object | |
143 assert 'stream_object' not in transfer_data | |
144 assert C.KEY_PROGRESS_ID in file_data | |
145 filename = file_data['name'] | |
146 assert filename and not '/' in filename | |
147 file_tmp_dir = self.host.getLocalPath(client, C.FILES_TMP_DIR, peer_jid.userhost(), component=True, profile=False) | |
148 file_tmp_path = file_data['file_path'] = os.path.join(file_tmp_dir, file_data['name']) | |
149 | |
150 transfer_data['finished_d'].addCallback(self._fileTransferedCb, client, peer_jid, file_data, file_tmp_path) | |
151 | |
152 self._f.openFileWrite(client, file_tmp_path, transfer_data, file_data, stream_object) | |
153 return False, defer.succeed(True) | |
154 | |
155 @defer.inlineCallbacks | |
156 def _retrieveFiles(self, client, session, content_data, content_name, file_data, file_elt): | |
157 """This method retrieve a file on request, and send if after checking permissions""" | |
158 peer_jid = session[u'peer_jid'] | |
159 try: | |
160 found_files = yield self.host.memory.getFiles(client, | |
161 peer_jid=peer_jid, | |
162 name=file_data.get(u'name'), | |
163 file_hash=file_data.get(u'file_hash'), | |
164 hash_algo=file_data.get(u'hash_algo'), | |
165 path=file_data.get(u'path'), | |
166 namespace=file_data.get(u'namespace')) | |
167 except exceptions.NotFound: | |
168 found_files = None | |
169 except exceptions.PermissionError: | |
170 log.warning(_(u"{peer_jid} is trying to access an unauthorized file: {name}").format( | |
171 peer_jid=peer_jid, name=file_data.get(u'name'))) | |
172 defer.returnValue(False) | |
173 | |
174 if not found_files: | |
175 log.warning(_(u"no matching file found ({file_data})").format(file_data=file_data)) | |
176 defer.returnValue(False) | |
177 | |
178 # we only use the first found file | |
179 found_file = found_files[0] | |
180 file_hash = found_file[u'file_hash'] | |
181 file_path = os.path.join(self.files_path, file_hash) | |
182 file_data[u'hash_hasher'] = hasher = self._h.getHasher(found_file[u'hash_algo']) | |
183 size = file_data[u'size'] = found_file[u'size'] | |
184 file_data[u'file_hash'] = file_hash | |
185 file_data[u'hash_algo'] = found_file[u'hash_algo'] | |
186 | |
187 # we complete file_elt so peer can have some details on the file | |
188 if u'name' not in file_data: | |
189 file_elt.addElement(u'name', content=found_file[u'name']) | |
190 file_elt.addElement(u'size', content=unicode(size)) | |
191 content_data['stream_object'] = stream.FileStreamObject( | |
192 self.host, | |
193 client, | |
194 file_path, | |
195 uid=self._jf.getProgressId(session, content_name), | |
196 size=size, | |
197 data_cb=lambda data: hasher.update(data), | |
198 ) | |
199 defer.returnValue(True) | |
200 | |
201 def _fileSendingRequestTrigger(self, client, session, content_data, content_name, file_data, file_elt): | |
202 if not client.is_component: | |
203 return True, None | |
204 else: | |
205 return False, self._retrieveFiles(client, session, content_data, content_name, file_data, file_elt) | |
206 | |
207 ## comments triggers ## | |
208 | |
209 def _addFileComments(self, file_elt, extra_args): | |
210 try: | |
211 comments_url = extra_args.pop('comments_url') | |
212 except KeyError: | |
213 return | |
214 | |
215 comment_elt = file_elt.addElement((NS_COMMENTS, 'comments'), content=comments_url) | |
216 | |
217 try: | |
218 count = len(extra_args[u'extra'][u'comments']) | |
219 except KeyError: | |
220 count = 0 | |
221 | |
222 comment_elt['count'] = unicode(count) | |
223 return True | |
224 | |
225 def _getFileComments(self, file_elt, file_data): | |
226 try: | |
227 comments_elt = next(file_elt.elements(NS_COMMENTS, 'comments')) | |
228 except StopIteration: | |
229 return | |
230 file_data['comments_url'] = unicode(comments_elt) | |
231 file_data['comments_count'] = comments_elt['count'] | |
232 return True | |
233 | |
234 def _addCommentsData(self, client, iq_elt, owner, node_path, files_data): | |
235 for file_data in files_data: | |
236 file_data['comments_url'] = uri.buildXMPPUri('pubsub', | |
237 path=client.jid.full(), | |
238 node=COMMENT_NODE_PREFIX + file_data['id']) | |
239 return True | |
240 | |
241 | |
242 class Comments_handler(pubsub.PubSubService): | |
243 """This class is a minimal Pubsub service handling virtual nodes for comments""" | |
244 | |
245 def __init__(self, plugin_parent): | |
246 super(Comments_handler, self).__init__() # PubsubVirtualResource()) | |
247 self.host = plugin_parent.host | |
248 self.plugin_parent = plugin_parent | |
249 self.discoIdentity = {'category': 'pubsub', | |
250 'type': 'virtual', # FIXME: non standard, here to avoid this service being considered as main pubsub one | |
251 'name': 'files commenting service'} | |
252 | |
253 def _getFileId(self, nodeIdentifier): | |
254 if not nodeIdentifier.startswith(COMMENT_NODE_PREFIX): | |
255 raise error.StanzaError('item-not-found') | |
256 file_id = nodeIdentifier[len(COMMENT_NODE_PREFIX):] | |
257 if not file_id: | |
258 raise error.StanzaError('item-not-found') | |
259 return file_id | |
260 | |
261 @defer.inlineCallbacks | |
262 def getFileData(self, requestor, nodeIdentifier): | |
263 file_id = self._getFileId(nodeIdentifier) | |
264 try: | |
265 files = yield self.host.memory.getFiles(self.parent, requestor, file_id) | |
266 except (exceptions.NotFound, exceptions.PermissionError): | |
267 # we don't differenciate between NotFound and PermissionError | |
268 # to avoid leaking information on existing files | |
269 raise error.StanzaError('item-not-found') | |
270 if not files: | |
271 raise error.StanzaError('item-not-found') | |
272 if len(files) > 1: | |
273 raise error.InternalError('there should be only one file') | |
274 defer.returnValue(files[0]) | |
275 | |
276 def commentsUpdate(self, extra, new_comments, peer_jid): | |
277 """update comments (replace or insert new_comments) | |
278 | |
279 @param extra(dict): extra data to update | |
280 @param new_comments(list[tuple(unicode, unicode, unicode)]): comments to update or insert | |
281 @param peer_jid(unicode, None): bare jid of the requestor, or None if request is done by owner | |
282 """ | |
283 current_comments = extra.setdefault('comments', []) | |
284 new_comments_by_id = {c[0]:c for c in new_comments} | |
285 updated = [] | |
286 # we now check every current comment, to see if one id in new ones | |
287 # exist, in which case we must update | |
288 for idx, comment in enumerate(current_comments): | |
289 comment_id = comment[0] | |
290 if comment_id in new_comments_by_id: | |
291 # a new comment has an existing id, update is requested | |
292 if peer_jid and comment[1] != peer_jid: | |
293 # requestor has not the right to modify the comment | |
294 raise exceptions.PermissionError | |
295 # we replace old_comment with updated one | |
296 new_comment = new_comments_by_id[comment_id] | |
297 current_comments[idx] = new_comment | |
298 updated.append(new_comment) | |
299 | |
300 # we now remove every updated comments, to only keep | |
301 # the ones to insert | |
302 for comment in updated: | |
303 new_comments.remove(comment) | |
304 | |
305 current_comments.extend(new_comments) | |
306 | |
307 def commentsDelete(self, extra, comments): | |
308 try: | |
309 comments_dict = extra['comments'] | |
310 except KeyError: | |
311 return | |
312 for comment in comments: | |
313 try: | |
314 comments_dict.remove(comment) | |
315 except ValueError: | |
316 continue | |
317 | |
318 def _getFrom(self, item_elt): | |
319 """retrieve published of an item | |
320 | |
321 @param item_elt(domish.element): <item> element | |
322 @return (unicode): full jid as string | |
323 """ | |
324 iq_elt = item_elt | |
325 while iq_elt.parent != None: | |
326 iq_elt = iq_elt.parent | |
327 return iq_elt['from'] | |
328 | |
329 @defer.inlineCallbacks | |
330 def publish(self, requestor, service, nodeIdentifier, items): | |
331 # we retrieve file a first time to check authorisations | |
332 file_data = yield self.getFileData(requestor, nodeIdentifier) | |
333 file_id = file_data['id'] | |
334 comments = [(item['id'], self._getFrom(item), item.toXml()) for item in items] | |
335 if requestor.userhostJID() == file_data['owner']: | |
336 peer_jid = None | |
337 else: | |
338 peer_jid = requestor.userhost() | |
339 update_cb = partial(self.commentsUpdate, new_comments=comments, peer_jid=peer_jid) | |
340 try: | |
341 yield self.host.memory.fileUpdate(file_id, 'extra', update_cb) | |
342 except exceptions.PermissionError: | |
343 raise error.StanzaError('not-authorized') | |
344 | |
345 @defer.inlineCallbacks | |
346 def items(self, requestor, service, nodeIdentifier, maxItems, | |
347 itemIdentifiers): | |
348 file_data = yield self.getFileData(requestor, nodeIdentifier) | |
349 comments = file_data['extra'].get('comments', []) | |
350 if itemIdentifiers: | |
351 defer.returnValue([generic.parseXml(c[2]) for c in comments if c[0] in itemIdentifiers]) | |
352 else: | |
353 defer.returnValue([generic.parseXml(c[2]) for c in comments]) | |
354 | |
355 @defer.inlineCallbacks | |
356 def retract(self, requestor, service, nodeIdentifier, itemIdentifiers): | |
357 file_data = yield self.getFileData(requestor, nodeIdentifier) | |
358 file_id = file_data['id'] | |
359 try: | |
360 comments = file_data['extra']['comments'] | |
361 except KeyError: | |
362 raise error.StanzaError('item-not-found') | |
363 | |
364 to_remove = [] | |
365 for comment in comments: | |
366 comment_id = comment[0] | |
367 if comment_id in itemIdentifiers: | |
368 to_remove.append(comment) | |
369 itemIdentifiers.remove(comment_id) | |
370 if not itemIdentifiers: | |
371 break | |
372 | |
373 if itemIdentifiers: | |
374 # not all items have been to_remove, we can't continue | |
375 raise error.StanzaError('item-not-found') | |
376 | |
377 if requestor.userhostJID() != file_data['owner']: | |
378 if not all([c[1] == requestor.userhost() for c in to_remove]): | |
379 raise error.StanzaError('not-authorized') | |
380 | |
381 remove_cb = partial(self.commentsDelete, comments=to_remove) | |
382 yield self.host.memory.fileUpdate(file_id, 'extra', remove_cb) |