Mercurial > libervia-backend
view sat/plugins/plugin_comp_file_sharing_management.py @ 3696:52ee22d78e18
plugin XEP-0384: fix encryption for user own devices:
our own jid is added automatically by OMEMO library, but during the generation of the
stanza, our list of jids was used to get the keys (and thus our own jid and devices keys
was missing).
fix 402
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 22 Oct 2021 18:46:51 +0200 |
parents | a8259a1f89b2 |
children | 888109774673 |
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 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 sat.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.getLocalPath(None, C.FILES_DIR, profile=False) host.bridge.addMethod( "fileSharingDelete", ".plugin", in_sign="ssss", out_sign="", method=self._delete, async_=True, ) 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, ) self._c.addAdHocCommand( client, self._onQuota, "Get Quota", node=NS_FILE_MANAGEMENT_QUOTA, allowed_magics=C.ENTITY_ALL, ) def _delete(self, service_jid_s, path, namespace, profile): client = self.host.getClient(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 _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 = C.bool(command_form.fields['confirm'].value) 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: 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 = 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)) async def _onQuota(self, client, command_elt, session_data, action, node): requestor = session_data['requestor'] quota = self.host.plugins["file_sharing"].getQuota(client, requestor) try: size_used = await self.host.memory.fileGetUsedSpace(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.getHumanSize(size_used), size_quota = ( _("unlimited quota") if quota is None else utils.getHumanSize(quota) ) ) ) return (payload, status, None, note)