comparison libervia/backend/plugins/plugin_comp_file_sharing_management.py @ 4071:4b842c1fb686

refactoring: renamed `sat` package to `libervia.backend`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 11:49:51 +0200
parents sat/plugins/plugin_comp_file_sharing_management.py@524856bd7b19
children
comparison
equal deleted inserted replaced
4070:d10748475025 4071:4b842c1fb686
1 #!/usr/bin/env python3
2
3 # Libervia plugin to manage file sharing component through ad-hoc commands
4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 import os.path
20 from functools import partial
21 from wokkel import data_form
22 from twisted.internet import defer
23 from twisted.words.protocols.jabber import jid
24 from libervia.backend.core.i18n import _, D_
25 from libervia.backend.core import exceptions
26 from libervia.backend.core.constants import Const as C
27 from libervia.backend.core.log import getLogger
28 from libervia.backend.tools.common import utils
29
30 log = getLogger(__name__)
31
32
33 PLUGIN_INFO = {
34 C.PI_NAME: "File Sharing Management",
35 C.PI_IMPORT_NAME: "FILE_SHARING_MANAGEMENT",
36 C.PI_MODES: [C.PLUG_MODE_COMPONENT],
37 C.PI_TYPE: "EXP",
38 C.PI_PROTOCOLS: [],
39 C.PI_DEPENDENCIES: ["XEP-0050", "XEP-0264"],
40 C.PI_RECOMMENDATIONS: [],
41 C.PI_MAIN: "FileSharingManagement",
42 C.PI_HANDLER: "no",
43 C.PI_DESCRIPTION: _(
44 "Experimental handling of file management for file sharing. This plugins allows "
45 "to change permissions of stored files/directories or remove them."
46 ),
47 }
48
49 NS_FILE_MANAGEMENT = "https://salut-a-toi.org/protocol/file-management:0"
50 NS_FILE_MANAGEMENT_PERM = "https://salut-a-toi.org/protocol/file-management:0#perm"
51 NS_FILE_MANAGEMENT_DELETE = "https://salut-a-toi.org/protocol/file-management:0#delete"
52 NS_FILE_MANAGEMENT_THUMB = "https://salut-a-toi.org/protocol/file-management:0#thumb"
53 NS_FILE_MANAGEMENT_QUOTA = "https://salut-a-toi.org/protocol/file-management:0#quota"
54
55
56 class WorkflowError(Exception):
57 """Raised when workflow can't be completed"""
58
59 def __init__(self, err_args):
60 """
61 @param err_args(tuple): arguments to return to finish the command workflow
62 """
63 Exception.__init__(self)
64 self.err_args = err_args
65
66
67 class FileSharingManagement(object):
68 # This is a temporary way (Q&D) to handle stored files, a better way (using pubsub
69 # syntax?) should be elaborated and proposed as a standard.
70
71 def __init__(self, host):
72 log.info(_("File Sharing Management plugin initialization"))
73 self.host = host
74 self._c = host.plugins["XEP-0050"]
75 self._t = host.plugins["XEP-0264"]
76 self.files_path = host.get_local_path(None, C.FILES_DIR)
77 host.bridge.add_method(
78 "file_sharing_delete",
79 ".plugin",
80 in_sign="ssss",
81 out_sign="",
82 method=self._delete,
83 async_=True,
84 )
85
86 def profile_connected(self, client):
87 self._c.add_ad_hoc_command(
88 client, self._on_change_file, "Change Permissions of File(s)",
89 node=NS_FILE_MANAGEMENT_PERM,
90 allowed_magics=C.ENTITY_ALL,
91 )
92 self._c.add_ad_hoc_command(
93 client, self._on_delete_file, "Delete File(s)",
94 node=NS_FILE_MANAGEMENT_DELETE,
95 allowed_magics=C.ENTITY_ALL,
96 )
97 self._c.add_ad_hoc_command(
98 client, self._on_gen_thumbnails, "Generate Thumbnails",
99 node=NS_FILE_MANAGEMENT_THUMB,
100 allowed_magics=C.ENTITY_ALL,
101 )
102 self._c.add_ad_hoc_command(
103 client, self._on_quota, "Get Quota",
104 node=NS_FILE_MANAGEMENT_QUOTA,
105 allowed_magics=C.ENTITY_ALL,
106 )
107
108 def _delete(self, service_jid_s, path, namespace, profile):
109 client = self.host.get_client(profile)
110 service_jid = jid.JID(service_jid_s) if service_jid_s else None
111 return defer.ensureDeferred(self._c.sequence(
112 client,
113 [{"path": path, "namespace": namespace}, {"confirm": True}],
114 NS_FILE_MANAGEMENT_DELETE,
115 service_jid,
116 ))
117
118 def _err(self, reason):
119 """Helper method to get argument to return for error
120
121 workflow will be interrupted with an error note
122 @param reason(unicode): reason of the error
123 @return (tuple): arguments to use in defer.returnValue
124 """
125 status = self._c.STATUS.COMPLETED
126 payload = None
127 note = (self._c.NOTE.ERROR, reason)
128 return payload, status, None, note
129
130 def _get_root_args(self):
131 """Create the form to select the file to use
132
133 @return (tuple): arguments to use in defer.returnValue
134 """
135 status = self._c.STATUS.EXECUTING
136 form = data_form.Form("form", title="File Management",
137 formNamespace=NS_FILE_MANAGEMENT)
138
139 field = data_form.Field(
140 "text-single", "path", required=True
141 )
142 form.addField(field)
143
144 field = data_form.Field(
145 "text-single", "namespace", required=False
146 )
147 form.addField(field)
148
149 payload = form.toElement()
150 return payload, status, None, None
151
152 async def _get_file_data(self, client, session_data, command_form):
153 """Retrieve field requested in root form
154
155 "found_file" will also be set in session_data
156 @param command_form(data_form.Form): response to root form
157 @return (D(dict)): found file data
158 @raise WorkflowError: something is wrong
159 """
160 fields = command_form.fields
161 try:
162 path = fields['path'].value.strip()
163 namespace = fields['namespace'].value or None
164 except KeyError:
165 self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
166
167 if not path:
168 self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
169
170 requestor = session_data['requestor']
171 requestor_bare = requestor.userhostJID()
172 path = path.rstrip('/')
173 parent_path, basename = os.path.split(path)
174
175 # TODO: if parent_path and basename are empty, we ask for root directory
176 # this must be managed
177
178 try:
179 found_files = await self.host.memory.get_files(
180 client, requestor_bare, path=parent_path, name=basename,
181 namespace=namespace)
182 found_file = found_files[0]
183 except (exceptions.NotFound, IndexError):
184 raise WorkflowError(self._err(_("file not found")))
185 except exceptions.PermissionError:
186 raise WorkflowError(self._err(_("forbidden")))
187
188 if found_file['owner'] != requestor_bare:
189 # only owner can manage files
190 log.warning(_("Only owner can manage files"))
191 raise WorkflowError(self._err(_("forbidden")))
192
193 session_data['found_file'] = found_file
194 session_data['namespace'] = namespace
195 return found_file
196
197 def _update_read_permission(self, access, allowed_jids):
198 if not allowed_jids:
199 if C.ACCESS_PERM_READ in access:
200 del access[C.ACCESS_PERM_READ]
201 elif allowed_jids == 'PUBLIC':
202 access[C.ACCESS_PERM_READ] = {
203 "type": C.ACCESS_TYPE_PUBLIC
204 }
205 else:
206 access[C.ACCESS_PERM_READ] = {
207 "type": C.ACCESS_TYPE_WHITELIST,
208 "jids": [j.full() for j in allowed_jids]
209 }
210
211 async def _update_dir(self, client, requestor, namespace, file_data, allowed_jids):
212 """Recursively update permission of a directory and all subdirectories
213
214 @param file_data(dict): metadata of the file
215 @param allowed_jids(list[jid.JID]): list of entities allowed to read the file
216 """
217 assert file_data['type'] == C.FILE_TYPE_DIRECTORY
218 files_data = await self.host.memory.get_files(
219 client, requestor, parent=file_data['id'], namespace=namespace)
220
221 for file_data in files_data:
222 if not file_data['access'].get(C.ACCESS_PERM_READ, {}):
223 log.debug("setting {perm} read permission for {name}".format(
224 perm=allowed_jids, name=file_data['name']))
225 await self.host.memory.file_update(
226 file_data['id'], 'access',
227 partial(self._update_read_permission, allowed_jids=allowed_jids))
228 if file_data['type'] == C.FILE_TYPE_DIRECTORY:
229 await self._update_dir(client, requestor, namespace, file_data, 'PUBLIC')
230
231 async def _on_change_file(self, client, command_elt, session_data, action, node):
232 try:
233 x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
234 command_form = data_form.Form.fromElement(x_elt)
235 except StopIteration:
236 command_form = None
237
238 found_file = session_data.get('found_file')
239 requestor = session_data['requestor']
240 requestor_bare = requestor.userhostJID()
241
242 if command_form is None or len(command_form.fields) == 0:
243 # root request
244 return self._get_root_args()
245
246 elif found_file is None:
247 # file selected, we retrieve it and ask for permissions
248 try:
249 found_file = await self._get_file_data(client, session_data, command_form)
250 except WorkflowError as e:
251 return e.err_args
252
253 # management request
254 if found_file['type'] == C.FILE_TYPE_DIRECTORY:
255 instructions = D_("Please select permissions for this directory")
256 else:
257 instructions = D_("Please select permissions for this file")
258
259 form = data_form.Form("form", title="File Management",
260 instructions=[instructions],
261 formNamespace=NS_FILE_MANAGEMENT)
262 field = data_form.Field(
263 "text-multi", "read_allowed", required=False,
264 desc='list of jids allowed to read this file (beside yourself), or '
265 '"PUBLIC" to let a public access'
266 )
267 read_access = found_file["access"].get(C.ACCESS_PERM_READ, {})
268 access_type = read_access.get('type', C.ACCESS_TYPE_WHITELIST)
269 if access_type == C.ACCESS_TYPE_PUBLIC:
270 field.values = ['PUBLIC']
271 else:
272 field.values = read_access.get('jids', [])
273 form.addField(field)
274 if found_file['type'] == C.FILE_TYPE_DIRECTORY:
275 field = data_form.Field(
276 "boolean", "recursive", value=False, required=False,
277 desc="Files under it will be made public to follow this dir "
278 "permission (only if they don't have already a permission set)."
279 )
280 form.addField(field)
281
282 status = self._c.STATUS.EXECUTING
283 payload = form.toElement()
284 return (payload, status, None, None)
285
286 else:
287 # final phase, we'll do permission change here
288 try:
289 read_allowed = command_form.fields['read_allowed']
290 except KeyError:
291 self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
292
293 if read_allowed.value == 'PUBLIC':
294 allowed_jids = 'PUBLIC'
295 elif read_allowed.value.strip() == '':
296 allowed_jids = None
297 else:
298 try:
299 allowed_jids = [jid.JID(v.strip()) for v in read_allowed.values
300 if v.strip()]
301 except RuntimeError as e:
302 log.warning(_("Can't use read_allowed values: {reason}").format(
303 reason=e))
304 self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
305
306 if found_file['type'] == C.FILE_TYPE_FILE:
307 await self.host.memory.file_update(
308 found_file['id'], 'access',
309 partial(self._update_read_permission, allowed_jids=allowed_jids))
310 else:
311 try:
312 recursive = command_form.fields['recursive']
313 except KeyError:
314 self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
315 await self.host.memory.file_update(
316 found_file['id'], 'access',
317 partial(self._update_read_permission, allowed_jids=allowed_jids))
318 if recursive:
319 # we set all file under the directory as public (if they haven't
320 # already a permission set), so allowed entities of root directory
321 # can read them.
322 namespace = session_data['namespace']
323 await self._update_dir(
324 client, requestor_bare, namespace, found_file, 'PUBLIC')
325
326 # job done, we can end the session
327 status = self._c.STATUS.COMPLETED
328 payload = None
329 note = (self._c.NOTE.INFO, _("management session done"))
330 return (payload, status, None, note)
331
332 async def _on_delete_file(self, client, command_elt, session_data, action, node):
333 try:
334 x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
335 command_form = data_form.Form.fromElement(x_elt)
336 except StopIteration:
337 command_form = None
338
339 found_file = session_data.get('found_file')
340 requestor = session_data['requestor']
341 requestor_bare = requestor.userhostJID()
342
343 if command_form is None or len(command_form.fields) == 0:
344 # root request
345 return self._get_root_args()
346
347 elif found_file is None:
348 # file selected, we need confirmation before actually deleting
349 try:
350 found_file = await self._get_file_data(client, session_data, command_form)
351 except WorkflowError as e:
352 return e.err_args
353 if found_file['type'] == C.FILE_TYPE_DIRECTORY:
354 msg = D_("Are you sure to delete directory {name} and all files and "
355 "directories under it?").format(name=found_file['name'])
356 else:
357 msg = D_("Are you sure to delete file {name}?"
358 .format(name=found_file['name']))
359 form = data_form.Form("form", title="File Management",
360 instructions = [msg],
361 formNamespace=NS_FILE_MANAGEMENT)
362 field = data_form.Field(
363 "boolean", "confirm", value=False, required=True,
364 desc="check this box to confirm"
365 )
366 form.addField(field)
367 status = self._c.STATUS.EXECUTING
368 payload = form.toElement()
369 return (payload, status, None, None)
370
371 else:
372 # final phase, we'll do deletion here
373 try:
374 confirmed = C.bool(command_form.fields['confirm'].value)
375 except KeyError:
376 self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
377 if not confirmed:
378 note = None
379 else:
380 recursive = found_file['type'] == C.FILE_TYPE_DIRECTORY
381 await self.host.memory.file_delete(
382 client, requestor_bare, found_file['id'], recursive)
383 note = (self._c.NOTE.INFO, _("file deleted"))
384 status = self._c.STATUS.COMPLETED
385 payload = None
386 return (payload, status, None, note)
387
388 def _update_thumbs(self, extra, thumbnails):
389 extra[C.KEY_THUMBNAILS] = thumbnails
390
391 async def _gen_thumbs(self, client, requestor, namespace, file_data):
392 """Recursively generate thumbnails
393
394 @param file_data(dict): metadata of the file
395 """
396 if file_data['type'] == C.FILE_TYPE_DIRECTORY:
397 sub_files_data = await self.host.memory.get_files(
398 client, requestor, parent=file_data['id'], namespace=namespace)
399 for sub_file_data in sub_files_data:
400 await self._gen_thumbs(client, requestor, namespace, sub_file_data)
401
402 elif file_data['type'] == C.FILE_TYPE_FILE:
403 media_type = file_data['media_type']
404 file_path = os.path.join(self.files_path, file_data['file_hash'])
405 if media_type == 'image':
406 thumbnails = []
407
408 for max_thumb_size in self._t.SIZES:
409 try:
410 thumb_size, thumb_id = await self._t.generate_thumbnail(
411 file_path,
412 max_thumb_size,
413 #  we keep thumbnails for 6 months
414 60 * 60 * 24 * 31 * 6,
415 )
416 except Exception as e:
417 log.warning(_("Can't create thumbnail: {reason}")
418 .format(reason=e))
419 break
420 thumbnails.append({"id": thumb_id, "size": thumb_size})
421
422 await self.host.memory.file_update(
423 file_data['id'], 'extra',
424 partial(self._update_thumbs, thumbnails=thumbnails))
425
426 log.info("thumbnails for [{file_name}] generated"
427 .format(file_name=file_data['name']))
428
429 else:
430 log.warning("unmanaged file type: {type_}".format(type_=file_data['type']))
431
432 async def _on_gen_thumbnails(self, client, command_elt, session_data, action, node):
433 try:
434 x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
435 command_form = data_form.Form.fromElement(x_elt)
436 except StopIteration:
437 command_form = None
438
439 found_file = session_data.get('found_file')
440 requestor = session_data['requestor']
441
442 if command_form is None or len(command_form.fields) == 0:
443 # root request
444 return self._get_root_args()
445
446 elif found_file is None:
447 # file selected, we retrieve it and ask for permissions
448 try:
449 found_file = await self._get_file_data(client, session_data, command_form)
450 except WorkflowError as e:
451 return e.err_args
452
453 log.info("Generating thumbnails as requested")
454 await self._gen_thumbs(client, requestor, found_file['namespace'], found_file)
455
456 # job done, we can end the session
457 status = self._c.STATUS.COMPLETED
458 payload = None
459 note = (self._c.NOTE.INFO, _("thumbnails generated"))
460 return (payload, status, None, note)
461
462 async def _on_quota(self, client, command_elt, session_data, action, node):
463 requestor = session_data['requestor']
464 quota = self.host.plugins["file_sharing"].get_quota(client, requestor)
465 try:
466 size_used = await self.host.memory.file_get_used_space(client, requestor)
467 except exceptions.PermissionError:
468 raise WorkflowError(self._err(_("forbidden")))
469 status = self._c.STATUS.COMPLETED
470 form = data_form.Form("result")
471 form.makeFields({"quota": quota, "user": size_used})
472 payload = form.toElement()
473 note = (
474 self._c.NOTE.INFO,
475 _("You are currently using {size_used} on {size_quota}").format(
476 size_used = utils.get_human_size(size_used),
477 size_quota = (
478 _("unlimited quota") if quota is None
479 else utils.get_human_size(quota)
480 )
481 )
482 )
483 return (payload, status, None, note)