Mercurial > libervia-backend
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 | 0d7bb4df2343 |
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) |