view libervia/backend/plugins/plugin_comp_file_sharing_management.py @ 4232:0fbe5c605eb6

tests (unit/webrtc,XEP-0176, XEP-0234): Fix tests and add webrtc file transfer tests: fix 441
author Goffi <goffi@goffi.org>
date Sat, 06 Apr 2024 12:59:50 +0200
parents 4b842c1fb686
children 0d7bb4df2343
line wrap: on
line source

#!/usr/bin/env python3

# Libervia plugin to manage file sharing component through ad-hoc commands
# Copyright (C) 2009-2021 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 wokkel import data_form
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
from libervia.backend.core.i18n import _, D_
from libervia.backend.core import exceptions
from libervia.backend.core.constants import Const as C
from libervia.backend.core.log import getLogger
from libervia.backend.tools.common import utils

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"
NS_FILE_MANAGEMENT_QUOTA = "https://salut-a-toi.org/protocol/file-management:0#quota"


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.get_local_path(None, C.FILES_DIR)
        host.bridge.add_method(
            "file_sharing_delete",
            ".plugin",
            in_sign="ssss",
            out_sign="",
            method=self._delete,
            async_=True,
        )

    def profile_connected(self, client):
        self._c.add_ad_hoc_command(
            client, self._on_change_file, "Change Permissions of File(s)",
            node=NS_FILE_MANAGEMENT_PERM,
            allowed_magics=C.ENTITY_ALL,
        )
        self._c.add_ad_hoc_command(
            client, self._on_delete_file, "Delete File(s)",
            node=NS_FILE_MANAGEMENT_DELETE,
            allowed_magics=C.ENTITY_ALL,
        )
        self._c.add_ad_hoc_command(
            client, self._on_gen_thumbnails, "Generate Thumbnails",
            node=NS_FILE_MANAGEMENT_THUMB,
            allowed_magics=C.ENTITY_ALL,
        )
        self._c.add_ad_hoc_command(
            client, self._on_quota, "Get Quota",
            node=NS_FILE_MANAGEMENT_QUOTA,
            allowed_magics=C.ENTITY_ALL,
        )

    def _delete(self, service_jid_s, path, namespace, profile):
        client = self.host.get_client(profile)
        service_jid = jid.JID(service_jid_s) if service_jid_s else None
        return defer.ensureDeferred(self._c.sequence(
            client,
            [{"path": path, "namespace": namespace}, {"confirm": True}],
            NS_FILE_MANAGEMENT_DELETE,
            service_jid,
        ))

    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 _get_root_args(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

    async def _get_file_data(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.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)

        if not path:
            self._c.ad_hoc_error(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 = await self.host.memory.get_files(
                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
        return found_file

    def _update_read_permission(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]
            }

    async def _update_dir(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 = await self.host.memory.get_files(
            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']))
                await self.host.memory.file_update(
                    file_data['id'], 'access',
                    partial(self._update_read_permission, allowed_jids=allowed_jids))
            if file_data['type'] == C.FILE_TYPE_DIRECTORY:
                await self._update_dir(client, requestor, namespace, file_data, 'PUBLIC')

    async def _on_change_file(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
            return self._get_root_args()

        elif found_file is None:
            # file selected, we retrieve it and ask for permissions
            try:
                found_file = await self._get_file_data(client, session_data, command_form)
            except WorkflowError as e:
                return 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()
            return (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.ad_hoc_error(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.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)

            if found_file['type'] == C.FILE_TYPE_FILE:
                await self.host.memory.file_update(
                    found_file['id'], 'access',
                    partial(self._update_read_permission, allowed_jids=allowed_jids))
            else:
                try:
                    recursive = command_form.fields['recursive']
                except KeyError:
                    self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
                await self.host.memory.file_update(
                    found_file['id'], 'access',
                    partial(self._update_read_permission, 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']
                    await self._update_dir(
                        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"))
            return (payload, status, None, note)

    async def _on_delete_file(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
            return self._get_root_args()

        elif found_file is None:
            # file selected, we need confirmation before actually deleting
            try:
                found_file = await self._get_file_data(client, session_data, command_form)
            except WorkflowError as e:
                return 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()
            return (payload, status, None, None)

        else:
            # final phase, we'll do deletion here
            try:
                confirmed = C.bool(command_form.fields['confirm'].value)
            except KeyError:
                self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
            if not confirmed:
                note = None
            else:
                recursive = found_file['type'] == C.FILE_TYPE_DIRECTORY
                await self.host.memory.file_delete(
                    client, requestor_bare, found_file['id'], recursive)
                note = (self._c.NOTE.INFO, _("file deleted"))
            status = self._c.STATUS.COMPLETED
            payload = None
            return (payload, status, None, note)

    def _update_thumbs(self, extra, thumbnails):
        extra[C.KEY_THUMBNAILS] = thumbnails

    async def _gen_thumbs(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 = await self.host.memory.get_files(
                client, requestor, parent=file_data['id'], namespace=namespace)
            for sub_file_data in sub_files_data:
                await self._gen_thumbs(client, requestor, namespace, sub_file_data)

        elif file_data['type'] == C.FILE_TYPE_FILE:
            media_type = file_data['media_type']
            file_path = os.path.join(self.files_path, file_data['file_hash'])
            if media_type == 'image':
                thumbnails = []

                for max_thumb_size in self._t.SIZES:
                    try:
                        thumb_size, thumb_id = await self._t.generate_thumbnail(
                            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})

                await self.host.memory.file_update(
                    file_data['id'], 'extra',
                    partial(self._update_thumbs, 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']))

    async def _on_gen_thumbnails(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
            return self._get_root_args()

        elif found_file is None:
            # file selected, we retrieve it and ask for permissions
            try:
                found_file = await self._get_file_data(client, session_data, command_form)
            except WorkflowError as e:
                return e.err_args

            log.info("Generating thumbnails as requested")
            await self._gen_thumbs(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"))
            return (payload, status, None, note)

    async def _on_quota(self, client, command_elt, session_data, action, node):
        requestor = session_data['requestor']
        quota = self.host.plugins["file_sharing"].get_quota(client, requestor)
        try:
            size_used = await self.host.memory.file_get_used_space(client, requestor)
        except exceptions.PermissionError:
            raise WorkflowError(self._err(_("forbidden")))
        status = self._c.STATUS.COMPLETED
        form = data_form.Form("result")
        form.makeFields({"quota": quota, "user": size_used})
        payload = form.toElement()
        note = (
            self._c.NOTE.INFO,
            _("You are currently using {size_used} on {size_quota}").format(
                size_used = utils.get_human_size(size_used),
                size_quota = (
                    _("unlimited quota") if quota is None
                    else utils.get_human_size(quota)
                )
            )
        )
        return (payload, status, None, note)