# HG changeset patch # User Goffi # Date 1556434851 -7200 # Node ID e0429ff7f6b666e7bd0beb8b945edeb662aa86f6 # Parent c0f6fd75af5f9b5c624bd6404ca596b180f3706f 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. diff -r c0f6fd75af5f -r e0429ff7f6b6 sat/plugins/plugin_adhoc_dbus.py --- 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)) - - diff -r c0f6fd75af5f -r e0429ff7f6b6 sat/plugins/plugin_comp_file_sharing.py --- 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"]) diff -r c0f6fd75af5f -r e0429ff7f6b6 sat/plugins/plugin_comp_file_sharing_management.py --- /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 . + +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))