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)