changeset 2527:a201194fc461

component file sharing: comments handling first draft: comments use a minimal pubsub service which create virtual nodes for each files. A pubsub request to org.salut-a-toi.file_comments/[FILE_ID] allow to handle comments in classic way. Permissions are the same as for files (i.e. if an entity can see a file, she can comment it).
author Goffi <goffi@goffi.org>
date Fri, 16 Mar 2018 17:06:35 +0100
parents 35d591086974
children 65e278997715
files src/core/xmpp.py src/plugins/plugin_comp_file_sharing.py src/plugins/plugin_xep_0231.py src/plugins/plugin_xep_0329.py
diffstat 4 files changed, 150 insertions(+), 4 deletions(-) [+]
line wrap: on
line diff
--- a/src/core/xmpp.py	Fri Mar 16 17:03:46 2018 +0100
+++ b/src/core/xmpp.py	Fri Mar 16 17:06:35 2018 +0100
@@ -629,7 +629,7 @@
             if not required:
                 return
             else:
-                log.error(_(u"Plugin {current_name} if needed for {entry_name}, but it doesn't handle component mode").format(
+                log.error(_(u"Plugin {current_name} is needed for {entry_name}, but it doesn't handle component mode").format(
                     current_name = current._info[u'import_name'],
                     entry_name = self.entry_plugin._info[u'import_name']
                     ))
--- a/src/plugins/plugin_comp_file_sharing.py	Fri Mar 16 17:03:46 2018 +0100
+++ b/src/plugins/plugin_comp_file_sharing.py	Fri Mar 16 17:06:35 2018 +0100
@@ -25,6 +25,10 @@
 from sat.tools.common import regex
 from sat.tools import stream
 from twisted.internet import defer
+from twisted.words.protocols.jabber import error
+from wokkel import pubsub
+from wokkel import generic
+from functools import partial
 import os
 import os.path
 import mimetypes
@@ -39,11 +43,12 @@
     C.PI_DEPENDENCIES: ["FILE", "XEP-0231", "XEP-0234", "XEP-0260", "XEP-0261", "XEP-0264", "XEP-0329"],
     C.PI_RECOMMENDATIONS: [],
     C.PI_MAIN: "FileSharing",
-    C.PI_HANDLER: "no",
+    C.PI_HANDLER: C.BOOL_TRUE,
     C.PI_DESCRIPTION: _(u"""Component hosting and sharing files""")
 }
 
 HASH_ALGO = u'sha-256'
+COMMENT_NODE_PREFIX = 'org.salut-a-toi.file_comments/'
 
 
 class FileSharing(object):
@@ -59,6 +64,9 @@
         host.trigger.add("XEP-0234_fileSendingRequest", self._fileSendingRequestTrigger, priority=1000)
         self.files_path = host.getLocalPath(None, C.FILES_DIR, profile=False)
 
+    def getHandler(self, client):
+        return Comments_handler(self)
+
     def profileConnected(self, client):
         path = client.file_tmp_dir = os.path.join(
             self.host.memory.getConfig('', 'local_dir'),
@@ -183,3 +191,142 @@
             return True, None
         else:
             return False, self._retrieveFiles(client, session, content_data, content_name, file_data, file_elt)
+
+
+class Comments_handler(pubsub.PubSubService):
+
+    def __init__(self, plugin_parent):
+        super(Comments_handler, self).__init__() # PubsubVirtualResource())
+        self.host = plugin_parent.host
+        self.plugin_parent = plugin_parent
+
+    def _getFileId(self, nodeIdentifier):
+        if not nodeIdentifier.startswith(COMMENT_NODE_PREFIX):
+            raise error.StanzaError('item-not-found')
+        file_id = nodeIdentifier[len(COMMENT_NODE_PREFIX):]
+        if not file_id:
+            raise error.StanzaError('item-not-found')
+        return file_id
+
+    @defer.inlineCallbacks
+    def getFileData(self, requestor, nodeIdentifier):
+        file_id = self._getFileId(nodeIdentifier)
+        try:
+            files = yield self.host.memory.getFiles(self.parent, requestor, file_id)
+        except (exceptions.NotFound, exceptions.PermissionError):
+            # we don't differenciate between NotFound and PermissionError
+            # to avoid leaking information on existing files
+            raise error.StanzaError('item-not-found')
+        if not files:
+            raise error.StanzaError('item-not-found')
+        if len(files) > 1:
+            raise error.InternalError('there should be only one file')
+        defer.returnValue(files[0])
+
+    def commentsUpdate(self, extra, new_comments, peer_jid):
+        """update comments (replace or insert new_comments)
+
+        @param extra(dict): extra data to update
+        @param new_comments(list[tuple(unicode, unicode, unicode)]): comments to update or insert
+        @param peer_jid(unicode, None): bare jid of the requestor, or None if request is done by owner
+        """
+        current_comments = extra.setdefault('comments', [])
+        new_comments_by_id = {c[0]:c for c in new_comments}
+        updated = []
+        # we now check every current comment, to see if one id in new ones
+        # exist, in which case we must update
+        for idx, comment in enumerate(current_comments):
+            comment_id = comment[0]
+            if comment_id in new_comments_by_id:
+                # a new comment has an existing id, update is requested
+                if peer_jid and comment[1] != peer_jid:
+                    # requestor has not the right to modify the comment
+                    raise exceptions.PermissionError
+                # we replace old_comment with updated one
+                new_comment = new_comments_by_id[comment_id]
+                current_comments[idx] = new_comment
+                updated.append(new_comment)
+
+        # we now remove every updated comments, to only keep
+        # the ones to insert
+        for comment in updated:
+            new_comments.remove(comment)
+
+        current_comments.extend(new_comments)
+
+    def commentsDelete(self, extra, comments):
+        try:
+            comments_dict = extra['comments']
+        except KeyError:
+            return
+        for comment in comments:
+            try:
+                comments_dict.remove(comment)
+            except ValueError:
+                continue
+
+    def _getFrom(self, item_elt):
+        """retrieve published of an item
+
+        @param item_elt(domish.element): <item> element
+        @return (unicode): full jid as string
+        """
+        iq_elt = item_elt
+        while iq_elt.parent != None:
+            iq_elt = iq_elt.parent
+        return iq_elt['from']
+
+    @defer.inlineCallbacks
+    def publish(self, requestor, service, nodeIdentifier, items):
+        # we retrieve file a first time to check authorisations
+        file_data = yield self.getFileData(requestor, nodeIdentifier)
+        file_id = file_data['id']
+        comments = [(item['id'], self._getFrom(item), item.toXml()) for item in items]
+        if requestor.userhostJID() == file_data['owner']:
+            peer_jid = None
+        else:
+            peer_jid = requestor.userhost()
+        update_cb = partial(self.commentsUpdate, new_comments=comments, peer_jid=peer_jid)
+        try:
+            yield self.host.memory.fileUpdate(file_id, 'extra', update_cb)
+        except exceptions.PermissionError:
+            raise error.StanzaError('not-authorized')
+
+    @defer.inlineCallbacks
+    def items(self, requestor, service, nodeIdentifier, maxItems,
+                    itemIdentifiers):
+        file_data = yield self.getFileData(requestor, nodeIdentifier)
+        comments = file_data['extra'].get('comments', [])
+        if itemIdentifiers:
+            defer.returnValue([generic.parseXml(c[2]) for c in comments if c[0] in itemIdentifiers])
+        else:
+            defer.returnValue([generic.parseXml(c[2]) for c in comments])
+
+    @defer.inlineCallbacks
+    def retract(self, requestor, service, nodeIdentifier, itemIdentifiers):
+        file_data = yield self.getFileData(requestor, nodeIdentifier)
+        file_id = file_data['id']
+        try:
+            comments = file_data['extra']['comments']
+        except KeyError:
+            raise error.StanzaError('item-not-found')
+
+        to_remove = []
+        for comment in comments:
+            comment_id = comment[0]
+            if comment_id in itemIdentifiers:
+                to_remove.append(comment)
+                itemIdentifiers.remove(comment_id)
+                if not itemIdentifiers:
+                    break
+
+        if itemIdentifiers:
+            # not all items have been to_remove, we can't continue
+            raise error.StanzaError('item-not-found')
+
+        if requestor.userhostJID() != file_data['owner']:
+            if not all([c[1] == requestor.userhost() for c in to_remove]):
+                raise error.StanzaError('not-authorized')
+
+        remove_cb = partial(self.commentsDelete, comments=to_remove)
+        yield self.host.memory.fileUpdate(file_id, 'extra', remove_cb)
--- a/src/plugins/plugin_xep_0231.py	Fri Mar 16 17:03:46 2018 +0100
+++ b/src/plugins/plugin_xep_0231.py	Fri Mar 16 17:06:35 2018 +0100
@@ -1,7 +1,7 @@
 #!/usr/bin/env python2
 # -*- coding: utf-8 -*-
 
-# SAT plugin for Jingle File Transfer (XEP-0231)
+# SAT plugin for Bit of Binary handling (XEP-0231)
 # Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org)
 
 # This program is free software: you can redistribute it and/or modify
--- a/src/plugins/plugin_xep_0329.py	Fri Mar 16 17:03:46 2018 +0100
+++ b/src/plugins/plugin_xep_0329.py	Fri Mar 16 17:06:35 2018 +0100
@@ -75,7 +75,6 @@
         self.type = type_
         self.access = {} if access is None else access
         assert isinstance(self.access, dict)
-        self.persistent = False
         self.parent = None
         if parent is not None:
             assert name