changeset 2929:e0429ff7f6b6

plugin comp file sharing: file sharing management first draft: the new "component file sharing management" plugin add ad-hoc commands to changes permission of one or several shared files, delete one or more files, or regenerate thumbnails. This is a temporary plugin to make file sharing through a component usable with other entities, but should be removed (at least permission management and file deletion) in the future if we move on a pubsub based solution.
author Goffi <goffi@goffi.org>
date Sun, 28 Apr 2019 09:00:51 +0200
parents c0f6fd75af5f
children 32b6893240e0
files sat/plugins/plugin_adhoc_dbus.py sat/plugins/plugin_comp_file_sharing.py sat/plugins/plugin_comp_file_sharing_management.py
diffstat 3 files changed, 445 insertions(+), 2 deletions(-) [+]
line wrap: on
line diff
--- a/sat/plugins/plugin_adhoc_dbus.py	Sun Apr 28 08:55:13 2019 +0200
+++ b/sat/plugins/plugin_adhoc_dbus.py	Sun Apr 28 09:00:51 2019 +0200
@@ -474,5 +474,3 @@
             payload = form.toElement()
             status = self._c.STATUS.EXECUTING
             defer.returnValue((payload, status, None, None))
-
-
--- a/sat/plugins/plugin_comp_file_sharing.py	Sun Apr 28 08:55:13 2019 +0200
+++ b/sat/plugins/plugin_comp_file_sharing.py	Sun Apr 28 09:00:51 2019 +0200
@@ -44,6 +44,7 @@
     C.PI_PROTOCOLS: [],
     C.PI_DEPENDENCIES: [
         "FILE",
+        "FILE_SHARING_MANAGEMENT",
         "XEP-0231",
         "XEP-0234",
         "XEP-0260",
@@ -219,6 +220,9 @@
 
         # we only use the first found file
         found_file = found_files[0]
+        if found_file[u'type'] != C.FILE_TYPE_FILE:
+            raise TypeError(u"a file was expected, type is {type_}".format(
+                type_=found_file[u'type']))
         file_hash = found_file[u"file_hash"]
         file_path = os.path.join(self.files_path, file_hash)
         file_data[u"hash_hasher"] = hasher = self._h.getHasher(found_file[u"hash_algo"])
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sat/plugins/plugin_comp_file_sharing_management.py	Sun Apr 28 09:00:51 2019 +0200
@@ -0,0 +1,441 @@
+#!/usr/bin/env python2
+# -*- coding: utf-8 -*-
+
+# SAT plugin to detect language (experimental)
+# Copyright (C) 2009-2019 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import os.path
+from functools import partial
+from sat.core.i18n import _, D_
+from sat.core import exceptions
+from sat.core.constants import Const as C
+from sat.core.log import getLogger
+from wokkel import data_form
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: u"File Sharing Management",
+    C.PI_IMPORT_NAME: u"FILE_SHARING_MANAGEMENT",
+    C.PI_MODES: [C.PLUG_MODE_COMPONENT],
+    C.PI_TYPE: u"EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: [u"XEP-0050", u"XEP-0264"],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: u"FileSharingManagement",
+    C.PI_HANDLER: u"no",
+    C.PI_DESCRIPTION: _(
+        u"Experimental handling of file management for file sharing. This plugins allows "
+        u"to change permissions of stored files/directories or remove them."
+    ),
+}
+
+NS_FILE_MANAGEMENT = u"https://salut-a-toi.org/protocol/file-management:0"
+NS_FILE_MANAGEMENT_PERM = u"https://salut-a-toi.org/protocol/file-management:0#perm"
+NS_FILE_MANAGEMENT_DELETE = u"https://salut-a-toi.org/protocol/file-management:0#delete"
+NS_FILE_MANAGEMENT_THUMB = u"https://salut-a-toi.org/protocol/file-management:0#thumb"
+
+
+class WorkflowError(Exception):
+    """Raised when workflow can't be completed"""
+
+    def __init__(self, err_args):
+        """
+        @param err_args(tuple): arguments to return to finish the command workflow
+        """
+        Exception.__init__(self)
+        self.err_args = err_args
+
+
+class FileSharingManagement(object):
+    # This is a temporary way (Q&D) to handle stored files, a better way (using pubsub
+    # syntax?) should be elaborated and proposed as a standard.
+
+    def __init__(self, host):
+        log.info(_(u"File Sharing Management plugin initialization"))
+        self.host = host
+        self._c = host.plugins["XEP-0050"]
+        self._t = host.plugins["XEP-0264"]
+        self.files_path = host.getLocalPath(None, C.FILES_DIR, profile=False)
+
+    def profileConnected(self, client):
+        self._c.addAdHocCommand(
+            client, self._onChangeFile, u"Change Permissions of File(s)",
+            node=NS_FILE_MANAGEMENT_PERM,
+            allowed_magics=C.ENTITY_ALL,
+        )
+        self._c.addAdHocCommand(
+            client, self._onDeleteFile, u"Delete File(s)",
+            node=NS_FILE_MANAGEMENT_DELETE,
+            allowed_magics=C.ENTITY_ALL,
+        )
+        self._c.addAdHocCommand(
+            client, self._onGenThumbnails, u"Generate Thumbnails",
+            node=NS_FILE_MANAGEMENT_THUMB,
+            allowed_magics=C.ENTITY_ALL,
+        )
+
+    def _err(self, reason):
+        """Helper method to get argument to return for error
+
+        workflow will be interrupted with an error note
+        @param reason(unicode): reason of the error
+        @return (tuple): arguments to use in defer.returnValue
+        """
+        status = self._c.STATUS.COMPLETED
+        payload = None
+        note = (self._c.NOTE.ERROR, reason)
+        return payload, status, None, note
+
+    def _getRootArgs(self):
+        """Create the form to select the file to use
+
+        @return (tuple): arguments to use in defer.returnValue
+        """
+        status = self._c.STATUS.EXECUTING
+        form = data_form.Form("form", title=u"File Management",
+                              formNamespace=NS_FILE_MANAGEMENT)
+
+        field = data_form.Field(
+            "text-single", "path", required=True
+        )
+        form.addField(field)
+
+        field = data_form.Field(
+            "text-single", "namespace", required=False
+        )
+        form.addField(field)
+
+        payload = form.toElement()
+        return payload, status, None, None
+
+    @defer.inlineCallbacks
+    def _getFileData(self, client, session_data, command_form):
+        """Retrieve field requested in root form
+
+        "found_file" will also be set in session_data
+        @param command_form(data_form.Form): response to root form
+        @return (D(dict)): found file data
+        @raise WorkflowError: something is wrong
+        """
+        fields = command_form.fields
+        try:
+            path = fields[u'path'].value.strip()
+            namespace = fields[u'namespace'].value or None
+        except KeyError:
+            self._c.adHocError(self._c.ERROR.BAD_PAYLOAD)
+
+        if not path:
+            self._c.adHocError(self._c.ERROR.BAD_PAYLOAD)
+
+        requestor = session_data[u'requestor']
+        requestor_bare = requestor.userhostJID()
+        path = path.rstrip(u'/')
+        parent_path, basename = os.path.split(path)
+
+        # TODO: if parent_path and basename are empty, we ask for root directory
+        #       this must be managed
+
+        try:
+            found_files = yield self.host.memory.getFiles(
+                client, requestor_bare, path=parent_path, name=basename,
+                namespace=namespace)
+            found_file = found_files[0]
+        except (exceptions.NotFound, IndexError):
+            raise WorkflowError(self._err(_(u"file not found")))
+        except exceptions.PermissionError:
+            raise WorkflowError(self._err(_(u"forbidden")))
+
+        if found_file['owner'] != requestor_bare:
+            # only owner can manage files
+            log.warning(_(u"Only owner can manage files"))
+            raise WorkflowError(self._err(_(u"forbidden")))
+
+        session_data[u'found_file'] = found_file
+        session_data[u'namespace'] = namespace
+        defer.returnValue(found_file)
+
+    def _updateReadPermission(self, access, allowed_jids):
+        if not allowed_jids:
+            if C.ACCESS_PERM_READ in access:
+                del access[C.ACCESS_PERM_READ]
+        elif allowed_jids == u'PUBLIC':
+            access[C.ACCESS_PERM_READ] = {
+                u"type": C.ACCESS_TYPE_PUBLIC
+            }
+        else:
+            access[C.ACCESS_PERM_READ] = {
+                u"type": C.ACCESS_TYPE_WHITELIST,
+                u"jids": [j.full() for j in allowed_jids]
+            }
+
+    @defer.inlineCallbacks
+    def _updateDir(self, client, requestor, namespace, file_data, allowed_jids):
+        """Recursively update permission of a directory and all subdirectories
+
+        @param file_data(dict): metadata of the file
+        @param allowed_jids(list[jid.JID]): list of entities allowed to read the file
+        """
+        assert file_data[u'type'] == C.FILE_TYPE_DIRECTORY
+        files_data = yield self.host.memory.getFiles(
+            client, requestor, parent=file_data[u'id'], namespace=namespace)
+
+        for file_data in files_data:
+            if not file_data[u'access'].get(C.ACCESS_PERM_READ, {}):
+                log.debug(u"setting {perm} read permission for {name}".format(
+                    perm=allowed_jids, name=file_data[u'name']))
+                yield self.host.memory.fileUpdate(
+                    file_data[u'id'], u'access',
+                    partial(self._updateReadPermission, allowed_jids=allowed_jids))
+            if file_data[u'type'] == C.FILE_TYPE_DIRECTORY:
+                yield self._updateDir(client, requestor, namespace, file_data, u'PUBLIC')
+
+    @defer.inlineCallbacks
+    def _onChangeFile(self, client, command_elt, session_data, action, node):
+        try:
+            x_elt = command_elt.elements(data_form.NS_X_DATA, "x").next()
+            command_form = data_form.Form.fromElement(x_elt)
+        except StopIteration:
+            command_form = None
+
+        found_file = session_data.get('found_file')
+        requestor = session_data[u'requestor']
+        requestor_bare = requestor.userhostJID()
+
+        if command_form is None or len(command_form.fields) == 0:
+            # root request
+            defer.returnValue(self._getRootArgs())
+
+        elif found_file is None:
+            # file selected, we retrieve it and ask for permissions
+            try:
+                found_file = yield self._getFileData(client, session_data, command_form)
+            except WorkflowError as e:
+                defer.returnValue(e.err_args)
+
+            # management request
+            if found_file[u'type'] == C.FILE_TYPE_DIRECTORY:
+                instructions = D_(u"Please select permissions for this directory")
+            else:
+                instructions = D_(u"Please select permissions for this file")
+
+            form = data_form.Form("form", title=u"File Management",
+                                  instructions=[instructions],
+                                  formNamespace=NS_FILE_MANAGEMENT)
+            field = data_form.Field(
+                "text-multi", "read_allowed", required=False,
+                desc=u'list of jids allowed to read this file (beside yourself), or '
+                     u'"PUBLIC" to let a public access'
+            )
+            read_access = found_file[u"access"].get(C.ACCESS_PERM_READ, {})
+            access_type = read_access.get(u'type', C.ACCESS_TYPE_WHITELIST)
+            if access_type == C.ACCESS_TYPE_PUBLIC:
+                field.values = [u'PUBLIC']
+            else:
+                field.values = read_access.get('jids', [])
+            form.addField(field)
+            if found_file[u'type'] == C.FILE_TYPE_DIRECTORY:
+                field = data_form.Field(
+                    "boolean", "recursive", value=False, required=False,
+                    desc=u"Files under it will be made public to follow this dir "
+                         u"permission (only if they don't have already a permission set)."
+                )
+                form.addField(field)
+
+            status = self._c.STATUS.EXECUTING
+            payload = form.toElement()
+            defer.returnValue((payload, status, None, None))
+
+        else:
+            # final phase, we'll do permission change here
+            try:
+                read_allowed = command_form.fields['read_allowed']
+            except KeyError:
+                self._c.adHocError(self._c.ERROR.BAD_PAYLOAD)
+
+            if read_allowed.value == u'PUBLIC':
+                allowed_jids = u'PUBLIC'
+            elif read_allowed.value.strip() == u'':
+                allowed_jids = None
+            else:
+                try:
+                    allowed_jids = [jid.JID(v.strip()) for v in read_allowed.values]
+                except RuntimeError as e:
+                    log.warning(_(u"Can't use read_allowed values: {reason}").format(
+                        reason=e))
+                    self._c.adHocError(self._c.ERROR.BAD_PAYLOAD)
+
+            if found_file[u'type'] == C.FILE_TYPE_FILE:
+                yield self.host.memory.fileUpdate(
+                    found_file[u'id'], u'access',
+                    partial(self._updateReadPermission, allowed_jids=allowed_jids))
+            else:
+                try:
+                    recursive = command_form.fields['recursive']
+                except KeyError:
+                    self._c.adHocError(self._c.ERROR.BAD_PAYLOAD)
+                yield self.host.memory.fileUpdate(
+                    found_file[u'id'], u'access',
+                    partial(self._updateReadPermission, allowed_jids=allowed_jids))
+                if recursive:
+                    # we set all file under the directory as public (if they haven't
+                    # already a permission set), so allowed entities of root directory
+                    # can read them.
+                    namespace = session_data[u'namespace']
+                    yield self._updateDir(
+                        client, requestor_bare, namespace, found_file, u'PUBLIC')
+
+            # job done, we can end the session
+            status = self._c.STATUS.COMPLETED
+            payload = None
+            note = (self._c.NOTE.INFO, _(u"management session done"))
+            defer.returnValue((payload, status, None, note))
+
+    @defer.inlineCallbacks
+    def _onDeleteFile(self, client, command_elt, session_data, action, node):
+        try:
+            x_elt = command_elt.elements(data_form.NS_X_DATA, "x").next()
+            command_form = data_form.Form.fromElement(x_elt)
+        except StopIteration:
+            command_form = None
+
+        found_file = session_data.get('found_file')
+        requestor = session_data[u'requestor']
+        requestor_bare = requestor.userhostJID()
+
+        if command_form is None or len(command_form.fields) == 0:
+            # root request
+            defer.returnValue(self._getRootArgs())
+
+        elif found_file is None:
+            # file selected, we need confirmation before actually deleting
+            try:
+                found_file = yield self._getFileData(client, session_data, command_form)
+            except WorkflowError as e:
+                defer.returnValue(e.err_args)
+            if found_file[u'type'] == C.FILE_TYPE_DIRECTORY:
+                msg = D_(u"Are you sure to delete directory {name} and all files and "
+                         u"directories under it?").format(name=found_file[u'name'])
+            else:
+                msg = D_(u"Are you sure to delete file {name}?"
+                    .format(name=found_file[u'name']))
+            form = data_form.Form("form", title=u"File Management",
+                                  instructions = [msg],
+                                  formNamespace=NS_FILE_MANAGEMENT)
+            field = data_form.Field(
+                "boolean", "confirm", value=False, required=True,
+                desc=u"check this box to confirm"
+            )
+            form.addField(field)
+            status = self._c.STATUS.EXECUTING
+            payload = form.toElement()
+            defer.returnValue((payload, status, None, None))
+
+        else:
+            # final phase, we'll do deletion here
+            try:
+                confirmed = command_form.fields['confirm']
+            except KeyError:
+                self._c.adHocError(self._c.ERROR.BAD_PAYLOAD)
+            if not confirmed:
+                note = None
+            else:
+                recursive = found_file[u'type'] == C.FILE_TYPE_DIRECTORY
+                yield self.host.memory.fileDelete(
+                    client, requestor_bare, found_file[u'id'], recursive)
+                note = (self._c.NOTE.INFO, _(u"file deleted"))
+            status = self._c.STATUS.COMPLETED
+            payload = None
+            defer.returnValue((payload, status, None, note))
+
+    def _updateThumbs(self, extra, thumbnails):
+        extra[C.KEY_THUMBNAILS] = thumbnails
+
+    @defer.inlineCallbacks
+    def _genThumbs(self, client, requestor, namespace, file_data):
+        """Recursively generate thumbnails
+
+        @param file_data(dict): metadata of the file
+        """
+        if file_data[u'type'] == C.FILE_TYPE_DIRECTORY:
+            sub_files_data = yield self.host.memory.getFiles(
+                client, requestor, parent=file_data[u'id'], namespace=namespace)
+            for sub_file_data in sub_files_data:
+                yield self._genThumbs(client, requestor, namespace, sub_file_data)
+
+        elif file_data[u'type'] == C.FILE_TYPE_FILE:
+            mime_type = file_data[u'mime_type']
+            file_path = os.path.join(self.files_path, file_data[u'file_hash'])
+            if mime_type is not None and mime_type.startswith(u"image"):
+                thumbnails = []
+
+                for max_thumb_size in (self._t.SIZE_SMALL, self._t.SIZE_MEDIUM):
+                    try:
+                        thumb_size, thumb_id = yield self._t.generateThumbnail(
+                            file_path,
+                            max_thumb_size,
+                            #  we keep thumbnails for 6 months
+                            60 * 60 * 24 * 31 * 6,
+                        )
+                    except Exception as e:
+                        log.warning(_(u"Can't create thumbnail: {reason}")
+                            .format(reason=e))
+                        break
+                    thumbnails.append({u"id": thumb_id, u"size": thumb_size})
+
+                yield self.host.memory.fileUpdate(
+                    file_data[u'id'], u'extra',
+                    partial(self._updateThumbs, thumbnails=thumbnails))
+
+                log.info(u"thumbnails for [{file_name}] generated"
+                    .format(file_name=file_data[u'name']))
+
+        else:
+            log.warning(u"unmanaged file type: {type_}".format(type_=file_data[u'type']))
+
+    @defer.inlineCallbacks
+    def _onGenThumbnails(self, client, command_elt, session_data, action, node):
+        try:
+            x_elt = command_elt.elements(data_form.NS_X_DATA, "x").next()
+            command_form = data_form.Form.fromElement(x_elt)
+        except StopIteration:
+            command_form = None
+
+        found_file = session_data.get('found_file')
+        requestor = session_data[u'requestor']
+
+        if command_form is None or len(command_form.fields) == 0:
+            # root request
+            defer.returnValue(self._getRootArgs())
+
+        elif found_file is None:
+            # file selected, we retrieve it and ask for permissions
+            try:
+                found_file = yield self._getFileData(client, session_data, command_form)
+            except WorkflowError as e:
+                defer.returnValue(e.err_args)
+
+            log.info(u"Generating thumbnails as requested")
+            yield self._genThumbs(client, requestor, found_file[u'namespace'], found_file)
+
+            # job done, we can end the session
+            status = self._c.STATUS.COMPLETED
+            payload = None
+            note = (self._c.NOTE.INFO, _(u"thumbnails generated"))
+            defer.returnValue((payload, status, None, note))