view sat/plugins/plugin_comp_file_sharing_management.py @ 3254:6cf4bd6972c2

core, frontends: avatar refactoring: /!\ huge commit Avatar logic has been reworked around the IDENTITY plugin: plugins able to handle avatar or other identity related metadata (like nicknames) register to IDENTITY plugin in the same way as for other features like download/upload. Once registered, IDENTITY plugin will call them when suitable in order of priority, and handle caching. Methods to manage those metadata from frontend now use serialised data. For now `avatar` and `nicknames` are handled: - `avatar` is now a dict with `path` + metadata like `media_type`, instead of just a string path - `nicknames` is now a list of nicknames in order of priority. This list is never empty, and `nicknames[0]` should be the preferred nickname to use by frontends in most cases. In addition to contact specified nicknames, user set nickname (the one set in roster) is used in priority when available. Among the side changes done with this commit, there are: - a new `contactGet` bridge method to get roster metadata for a single contact - SatPresenceProtocol.send returns a Deferred to check when it has actually been sent - memory's methods to handle entities data now use `client` as first argument - metadata filter can be specified with `getIdentity` - `getAvatar` and `setAvatar` are now part of the IDENTITY plugin instead of XEP-0054 (and there signature has changed) - `isRoom` and `getBareOrFull` are now part of XEP-0045 plugin - jp avatar/get command uses `xdg-open` first when available for `--show` flag - `--no-cache` has been added to jp avatar/get and identity/get - jp identity/set has been simplified, explicit options (`--nickname` only for now) are used instead of `--field`. `--field` may come back in the future if necessary for extra data. - QuickContactList `SetContact` now handle None as a value, and doesn't use it to delete the metadata anymore - improved cache handling for `metadata` and `nicknames` in quick frontend - new `default` argument in QuickContactList `getCache`
author Goffi <goffi@goffi.org>
date Tue, 14 Apr 2020 21:00:33 +0200
parents 559a625a236b
children 780fb8dd07ef
line wrap: on
line source

#!/usr/bin/env python3


# SAT plugin to detect language (experimental)
# Copyright (C) 2009-2020 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: "File Sharing Management",
    C.PI_IMPORT_NAME: "FILE_SHARING_MANAGEMENT",
    C.PI_MODES: [C.PLUG_MODE_COMPONENT],
    C.PI_TYPE: "EXP",
    C.PI_PROTOCOLS: [],
    C.PI_DEPENDENCIES: ["XEP-0050", "XEP-0264"],
    C.PI_RECOMMENDATIONS: [],
    C.PI_MAIN: "FileSharingManagement",
    C.PI_HANDLER: "no",
    C.PI_DESCRIPTION: _(
        "Experimental handling of file management for file sharing. This plugins allows "
        "to change permissions of stored files/directories or remove them."
    ),
}

NS_FILE_MANAGEMENT = "https://salut-a-toi.org/protocol/file-management:0"
NS_FILE_MANAGEMENT_PERM = "https://salut-a-toi.org/protocol/file-management:0#perm"
NS_FILE_MANAGEMENT_DELETE = "https://salut-a-toi.org/protocol/file-management:0#delete"
NS_FILE_MANAGEMENT_THUMB = "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(_("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, "Change Permissions of File(s)",
            node=NS_FILE_MANAGEMENT_PERM,
            allowed_magics=C.ENTITY_ALL,
        )
        self._c.addAdHocCommand(
            client, self._onDeleteFile, "Delete File(s)",
            node=NS_FILE_MANAGEMENT_DELETE,
            allowed_magics=C.ENTITY_ALL,
        )
        self._c.addAdHocCommand(
            client, self._onGenThumbnails, "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="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['path'].value.strip()
            namespace = fields['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['requestor']
        requestor_bare = requestor.userhostJID()
        path = path.rstrip('/')
        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(_("file not found")))
        except exceptions.PermissionError:
            raise WorkflowError(self._err(_("forbidden")))

        if found_file['owner'] != requestor_bare:
            # only owner can manage files
            log.warning(_("Only owner can manage files"))
            raise WorkflowError(self._err(_("forbidden")))

        session_data['found_file'] = found_file
        session_data['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 == 'PUBLIC':
            access[C.ACCESS_PERM_READ] = {
                "type": C.ACCESS_TYPE_PUBLIC
            }
        else:
            access[C.ACCESS_PERM_READ] = {
                "type": C.ACCESS_TYPE_WHITELIST,
                "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['type'] == C.FILE_TYPE_DIRECTORY
        files_data = yield self.host.memory.getFiles(
            client, requestor, parent=file_data['id'], namespace=namespace)

        for file_data in files_data:
            if not file_data['access'].get(C.ACCESS_PERM_READ, {}):
                log.debug("setting {perm} read permission for {name}".format(
                    perm=allowed_jids, name=file_data['name']))
                yield self.host.memory.fileUpdate(
                    file_data['id'], 'access',
                    partial(self._updateReadPermission, allowed_jids=allowed_jids))
            if file_data['type'] == C.FILE_TYPE_DIRECTORY:
                yield self._updateDir(client, requestor, namespace, file_data, 'PUBLIC')

    @defer.inlineCallbacks
    def _onChangeFile(self, client, command_elt, session_data, action, node):
        try:
            x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
            command_form = data_form.Form.fromElement(x_elt)
        except StopIteration:
            command_form = None

        found_file = session_data.get('found_file')
        requestor = session_data['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['type'] == C.FILE_TYPE_DIRECTORY:
                instructions = D_("Please select permissions for this directory")
            else:
                instructions = D_("Please select permissions for this file")

            form = data_form.Form("form", title="File Management",
                                  instructions=[instructions],
                                  formNamespace=NS_FILE_MANAGEMENT)
            field = data_form.Field(
                "text-multi", "read_allowed", required=False,
                desc='list of jids allowed to read this file (beside yourself), or '
                     '"PUBLIC" to let a public access'
            )
            read_access = found_file["access"].get(C.ACCESS_PERM_READ, {})
            access_type = read_access.get('type', C.ACCESS_TYPE_WHITELIST)
            if access_type == C.ACCESS_TYPE_PUBLIC:
                field.values = ['PUBLIC']
            else:
                field.values = read_access.get('jids', [])
            form.addField(field)
            if found_file['type'] == C.FILE_TYPE_DIRECTORY:
                field = data_form.Field(
                    "boolean", "recursive", value=False, required=False,
                    desc="Files under it will be made public to follow this dir "
                         "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 == 'PUBLIC':
                allowed_jids = 'PUBLIC'
            elif read_allowed.value.strip() == '':
                allowed_jids = None
            else:
                try:
                    allowed_jids = [jid.JID(v.strip()) for v in read_allowed.values
                                    if v.strip()]
                except RuntimeError as e:
                    log.warning(_("Can't use read_allowed values: {reason}").format(
                        reason=e))
                    self._c.adHocError(self._c.ERROR.BAD_PAYLOAD)

            if found_file['type'] == C.FILE_TYPE_FILE:
                yield self.host.memory.fileUpdate(
                    found_file['id'], '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['id'], '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['namespace']
                    yield self._updateDir(
                        client, requestor_bare, namespace, found_file, 'PUBLIC')

            # job done, we can end the session
            status = self._c.STATUS.COMPLETED
            payload = None
            note = (self._c.NOTE.INFO, _("management session done"))
            defer.returnValue((payload, status, None, note))

    @defer.inlineCallbacks
    def _onDeleteFile(self, client, command_elt, session_data, action, node):
        try:
            x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
            command_form = data_form.Form.fromElement(x_elt)
        except StopIteration:
            command_form = None

        found_file = session_data.get('found_file')
        requestor = session_data['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['type'] == C.FILE_TYPE_DIRECTORY:
                msg = D_("Are you sure to delete directory {name} and all files and "
                         "directories under it?").format(name=found_file['name'])
            else:
                msg = D_("Are you sure to delete file {name}?"
                    .format(name=found_file['name']))
            form = data_form.Form("form", title="File Management",
                                  instructions = [msg],
                                  formNamespace=NS_FILE_MANAGEMENT)
            field = data_form.Field(
                "boolean", "confirm", value=False, required=True,
                desc="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['type'] == C.FILE_TYPE_DIRECTORY
                yield self.host.memory.fileDelete(
                    client, requestor_bare, found_file['id'], recursive)
                note = (self._c.NOTE.INFO, _("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['type'] == C.FILE_TYPE_DIRECTORY:
            sub_files_data = yield self.host.memory.getFiles(
                client, requestor, parent=file_data['id'], namespace=namespace)
            for sub_file_data in sub_files_data:
                yield self._genThumbs(client, requestor, namespace, sub_file_data)

        elif file_data['type'] == C.FILE_TYPE_FILE:
            mime_type = file_data['mime_type']
            file_path = os.path.join(self.files_path, file_data['file_hash'])
            if mime_type is not None and mime_type.startswith("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(_("Can't create thumbnail: {reason}")
                            .format(reason=e))
                        break
                    thumbnails.append({"id": thumb_id, "size": thumb_size})

                yield self.host.memory.fileUpdate(
                    file_data['id'], 'extra',
                    partial(self._updateThumbs, thumbnails=thumbnails))

                log.info("thumbnails for [{file_name}] generated"
                    .format(file_name=file_data['name']))

        else:
            log.warning("unmanaged file type: {type_}".format(type_=file_data['type']))

    @defer.inlineCallbacks
    def _onGenThumbnails(self, client, command_elt, session_data, action, node):
        try:
            x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
            command_form = data_form.Form.fromElement(x_elt)
        except StopIteration:
            command_form = None

        found_file = session_data.get('found_file')
        requestor = session_data['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("Generating thumbnails as requested")
            yield self._genThumbs(client, requestor, found_file['namespace'], found_file)

            # job done, we can end the session
            status = self._c.STATUS.COMPLETED
            payload = None
            note = (self._c.NOTE.INFO, _("thumbnails generated"))
            defer.returnValue((payload, status, None, note))