From 1b3593d95ba2011ae8b28cfecd385c904d67593e Mon Sep 17 00:00:00 2001 From: Goffi Date: Fri, 10 Oct 2025 17:44:04 +0200 Subject: [PATCH] plugin XEP-0050: Some more refactoring: - Arguments are now grouped in a new `PageData` NamedTuple, making it simpler to use, and easier to extend. - Page index is automatically calculated, and can be checked in callback to know which page to show. - Form send by calling entity is automatically parsed, using original form to instantiate the right field types. - A new `DataForm.get_field` method use Generic type as second argument to retrieve the right field when getting a `submit` form. - Updated all classes registering Ad-Hoc commands to use the new system and simplify the code. --- libervia/backend/memory/memory.py | 18 +- libervia/backend/plugins/plugin_adhoc_dbus.py | 262 ++++------- .../plugins/plugin_adhoc_registration.py | 350 ++++++-------- .../plugins/plugin_comp_ap_gateway/ad_hoc.py | 75 ++- .../plugin_comp_email_gateway/ad_hoc.py | 118 ++--- .../plugin_comp_file_sharing_management.py | 429 +++++++----------- libervia/backend/plugins/plugin_xep_0004.py | 39 +- libervia/backend/plugins/plugin_xep_0050.py | 129 +++--- libervia/backend/plugins/plugin_xep_0264.py | 64 +-- 9 files changed, 623 insertions(+), 861 deletions(-) diff --git a/libervia/backend/memory/memory.py b/libervia/backend/memory/memory.py index fceb0271..c37765cb 100644 --- a/libervia/backend/memory/memory.py +++ b/libervia/backend/memory/memory.py @@ -24,7 +24,7 @@ import mimetypes import os.path from pathlib import Path import time -from typing import Any, Dict, NamedTuple, Optional, Tuple +from typing import Any, Callable, Dict, NamedTuple, Optional, Tuple from uuid import uuid4 import shortuuid @@ -144,7 +144,7 @@ class Sessions: "You need to use __getitem__ when profile is not set" ) except KeyError: - raise failure.Failure(KeyError(MSG_NO_SESSION)) + raise KeyError(MSG_NO_SESSION) if profile_set != profile: raise exceptions.InternalError("current profile differ from set profile !") if self.resettable_timeout: @@ -155,7 +155,7 @@ class Sessions: try: session_info = self._sessions[session_id] except KeyError: - raise failure.Failure(KeyError(MSG_NO_SESSION)) + raise KeyError(MSG_NO_SESSION) if session_info.profile is not None: raise exceptions.InternalError( "You need to use profile_get instead of __getitem__ when profile is set." @@ -1620,7 +1620,7 @@ class Memory: if remaining_path_elts: # if we have remaining path elements, # the parent directory is not found - raise failure.Failure(exceptions.NotFound()) + raise exceptions.NotFound() if parent and peer_jid: # if parent is given directly and permission check is requested, # we need to check all the parents @@ -1823,13 +1823,15 @@ class Memory: raise exceptions.PermissionError("You are not allowed to check this size") return await self.storage.file_get_used_space(client, owner) - def file_update(self, file_id, column, update_cb): + def file_update( + self, file_id: str, column: str, update_cb: Callable[[dict], None] + ) -> Any: """Update a file column taking care of race condition access is NOT checked in this method, it must be checked beforehand - @param file_id(unicode): id of the file to update - @param column(unicode): one of "access" or "extra" - @param update_cb(callable): method to update the value of the colum + @param file_id: id of the file to update + @param column: one of "access" or "extra" + @param update_cb: method to update the value of the colum the method will take older value as argument, and must update it in place Note that the callable must be thread-safe """ diff --git a/libervia/backend/plugins/plugin_adhoc_dbus.py b/libervia/backend/plugins/plugin_adhoc_dbus.py index 3d9dffd6..c22de126 100644 --- a/libervia/backend/plugins/plugin_adhoc_dbus.py +++ b/libervia/backend/plugins/plugin_adhoc_dbus.py @@ -26,23 +26,22 @@ import uuid from twisted.internet import defer from twisted.internet import reactor from twisted.words.protocols.jabber import jid -from twisted.words.xish import domish from wokkel import data_form from libervia.backend.core.constants import Const as C from libervia.backend.core.core_types import SatXMPPEntity from libervia.backend.core.i18n import D_, _ from libervia.backend.core.log import getLogger -from libervia.backend.memory.memory import SessionData -from libervia.backend.plugins.plugin_xep_0004 import DataForm +from libervia.backend.plugins.plugin_xep_0004 import DataForm, ListSingle, Option, Text from libervia.backend.plugins.plugin_xep_0050 import ( XEP_0050, Action, AdHocCallbackData, + AdHocCommand, AdHocError, Error, Note, - NoteType, + PageData, Status, ) @@ -97,7 +96,7 @@ MPRIS_PROPERTIES = OrderedDict( ) ) MPRIS_METADATA_KEY = "Metadata" -MPRIS_METADATA_MAP = OrderedDict((("xesam:title", "Title"),)) +MPRIS_METADATA_MAP = {"xesam:title": "Title"} INTROSPECT_METHOD = "Introspect" IGNORED_IFACES_START = ( @@ -152,13 +151,15 @@ class AdHocDBus: self.session_con = await dbus_client.connect(reactor, "session") self.fd_object = await self.session_con.getRemoteObject(FD_NAME, FD_PATH) - self._c.add_ad_hoc_command( + self._c.register_ad_hoc_command( client, - self.local_media_cb, - D_("Media Players"), - node=NS_MEDIA_PLAYER, - timeout=60 * 60 * 6, # 6 hours timeout, to avoid breaking remote - # in the middle of a movie + AdHocCommand( + callback=self.local_media_cb, + label=D_("Media Players"), + node=NS_MEDIA_PLAYER, + # 6 hours timeout, to avoid breaking remote, in the middle of a movie + timeout=60 * 60 * 6, + ), ) async def _dbus_async_call(self, proxy, method, *args, **kwargs): @@ -275,17 +276,15 @@ class AdHocDBus: await self._introspect(methods, bus_name, proxy) if methods: - self._add_command( + self._c.add_ad_hoc_command( client, + partial(self.dbus_callback, methods, bus_name, flags or set()), prog_name, - bus_name, - methods, allowed_jids=allowed_jids, allowed_groups=allowed_groups, allowed_magics=allowed_magics, forbidden_jids=forbidden_jids, forbidden_groups=forbidden_groups, - flags=flags, ) return (str(bus_name), methods) @@ -296,10 +295,7 @@ class AdHocDBus: bus_name: str, flags: set[str], client: SatXMPPEntity, - command_elt: domish.Element, - session_data: SessionData, - action: Action, - node: str, + page_data: PageData, ) -> AdHocCallbackData: """Ad-hoc command for executing D-Bus methods. @@ -307,49 +303,41 @@ class AdHocDBus: @param bus_name: Name of the D-Bus service. @param flags: Command execution flags. @param client: Libervia client instance. - @param command_elt: The command element from the request. - @param session_data: Session data for this command execution. - @param action: The action being performed (EXECUTE, CANCEL, etc.) - @param node: The command node identifier. + @param page_data: Ad-Hoc page data. @return: Command results. @raise AdHocError: When bad payload or internal errors occur. """ - actions = session_data.setdefault("actions", []) + session_data = page_data.session_data names_map = session_data.setdefault("names_map", {}) - actions.append(action) - if len(actions) == 1: - # it's our first request, we ask the desired new status - status = Status.EXECUTING - wokkel_form = data_form.Form("form", title=_("Command selection")) + if page_data.idx == 0: + # It's our first request, we ask the desired new status. + session_data["restart"] = False + form = DataForm(title=D_("Command selection")) options = [] for path, iface, command in methods: label = command.rsplit(".", 1)[-1] - name = str(uuid.uuid4()) - names_map[name] = (path, iface, command) - options.append(data_form.Option(name, label)) + value = str(uuid.uuid4()) + names_map[value] = (path, iface, command) + options.append(Option(value=value, label=label)) - field = data_form.Field( - "list-single", "command", options=options, required=True - ) - wokkel_form.addField(field) + form.fields.append(ListSingle(var="command", options=options, required=True)) + session_data["command_form"] = form - return AdHocCallbackData( - form=DataForm.from_wokkel_form(wokkel_form), status=status, notes=[] - ) + return AdHocCallbackData(form=form, status=Status.EXECUTING) - elif len(actions) == 2: - # we should have the answer here + else: + # We should have the form here. try: - x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x")) - answer_form = data_form.Form.fromElement(x_elt) - command = answer_form["command"] - except (KeyError, StopIteration): - raise self._c.ad_hoc_error(Error.BAD_PAYLOAD) + command = page_data.form.get_field("command", ListSingle).value + if not command: + raise KeyError + except KeyError: + raise AdHocError(Error.BAD_PAYLOAD) if command not in names_map: - raise self._c.ad_hoc_error(Error.BAD_PAYLOAD) + raise AdHocError(Error.BAD_PAYLOAD, text=f"Invalid command: {command!r}.") path, iface, command = names_map[command] assert self.session_con is not None @@ -357,63 +345,29 @@ class AdHocDBus: await self._dbus_async_call(proxy, command, interface=iface) - # job done, we can end the session, except if we have FLAG_LOOP + # Job done, we can end the session, except if we have FLAG_LOOP. if FLAG_LOOP in flags: - # We have a loop, so we clear everything and we execute again the command - # as we had a first call (command_elt is not used). - del actions[:] - names_map.clear() - return await self.dbus_callback( - methods, - bus_name, - flags, - client, - command_elt, - session_data, - Action.EXECUTE, - node, + return AdHocCallbackData( + form=session_data["command_form"], + status=Status.EXECUTING, + notes=[Note(text=D_("Command sent."))], + ) + else: + return AdHocCallbackData( + status=Status.COMPLETED, notes=[Note(text=D_("Command sent."))] ) - return AdHocCallbackData( - status=Status.COMPLETED, notes=[Note(text=_("Command sent"))] - ) - else: - raise AdHocError(Error.INTERNAL) - - def _add_command( - self, - client, - adhoc_name, - bus_name, - methods, - allowed_jids=None, - allowed_groups=None, - allowed_magics=None, - forbidden_jids=None, - forbidden_groups=None, - flags=None, - ): - if flags is None: - flags = set() - - self._c.add_ad_hoc_command( - client, - partial(self.dbus_callback, methods, bus_name, flags), - adhoc_name, - allowed_jids=allowed_jids, - allowed_groups=allowed_groups, - allowed_magics=allowed_magics, - forbidden_jids=forbidden_jids, - forbidden_groups=forbidden_groups, - ) ## Local media ## def _ad_hoc_remotes_get(self, profile): return self.ad_hoc_remotes_get(self.host.get_client(profile)) - async def ad_hoc_remotes_get(self, client): - """Retrieve available remote media controlers in our devices - @return (list[tuple[unicode, unicode, unicode]]): list of devices with: + async def ad_hoc_remotes_get( + self, client: SatXMPPEntity + ) -> list[tuple[str, str, str]]: + """Retrieve available remote media controlers in our devices. + + @return: list of devices with: - entity full jid - device name - device label @@ -484,134 +438,112 @@ class AdHocDBus: args = [] return await self._dbus_async_call(proxy, command, *args, interface=iface) - def add_mpris_metadata(self, form, metadata): + def add_mpris_metadata(self, form: DataForm, metadata: dict): """Serialise MRPIS Metadata according to MPRIS_METADATA_MAP""" for mpris_key, name in MPRIS_METADATA_MAP.items(): if mpris_key in metadata: value = str(metadata[mpris_key]) - form.addField(data_form.Field(fieldType="fixed", var=name, value=value)) + form.fields.append(Text(type="fixed", var=name, value=value)) async def local_media_cb( self, client: SatXMPPEntity, - command_elt: domish.Element, - session_data: SessionData, - action: Action, - node: str, + page_data: PageData, ) -> AdHocCallbackData: """Ad-hoc command for controlling media players via D-Bus. @param client: Libervia client instance. - @param command_elt: The command element from the request. - @param session_data: Session data for this command execution. - @param action: The action being performed (EXECUTE, CANCEL, etc.) - @param node: The command node identifier. + @param page_data: Ad-Hoc page data. @return: Command results. - @raise ValueError: When media_player value is missing. - @raise AdHocError: When invalid player name or other internal errors occur. + + @raise AdHocError: Something went wrong. """ assert txdbus is not None - 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 - if command_form is None or len(command_form.fields) == 0: + if page_data.idx == 0: # root request, we looks for media players bus_names = await self._dbus_list_names() bus_names = [b for b in bus_names if b.startswith(MPRIS_PREFIX)] if len(bus_names) == 0: return AdHocCallbackData( status=Status.COMPLETED, - notes=[Note(type=NoteType.INFO, text=D_("No media player found."))], + notes=[Note(text=D_("No media player found."))], ) options = [] - status = Status.EXECUTING - wokkel_form = data_form.Form( - "form", title=D_("Media Player Selection"), formNamespace=NS_MEDIA_PLAYER + form = DataForm( + title=D_("Media Player Selection"), + namespace=NS_MEDIA_PLAYER, ) for bus in bus_names: player_name = bus[len(MPRIS_PREFIX) + 1 :] if not player_name: - log.warning(_("Ignoring MPRIS bus without suffix")) + log.warning(_("Ignoring MPRIS bus without suffix.")) continue - options.append(data_form.Option(bus, player_name)) - field = data_form.Field( - "list-single", "media_player", options=options, required=True - ) - wokkel_form.addField(field) - return AdHocCallbackData( - form=DataForm.from_wokkel_form(wokkel_form), status=status, notes=[] + options.append(Option(value=bus, label=player_name)) + form.fields.append( + ListSingle(var="media_player", options=options, required=True) ) + return AdHocCallbackData(form=form, status=Status.EXECUTING) else: # player request try: - bus_name = command_form["media_player"] + bus_name = page_data.session_data["bus_name"] except KeyError: - raise ValueError(_("missing media_player value")) + try: + bus_name = page_data.form.get_field("media_player", ListSingle).value + if not bus_name: + raise KeyError + except KeyError: + raise AdHocError( + Error.BAD_PAYLOAD, text=D_("missing media_player value") + ) + else: + page_data.session_data["bus_name"] = bus_name if not bus_name.startswith(MPRIS_PREFIX): log.warning( - _( - "Media player ad-hoc command trying to use non MPRIS bus. " - "Hack attempt? Refused bus: {bus_name}" - ).format(bus_name=bus_name) + "Media player ad-hoc command trying to use non MPRIS bus. Hack " + f"attempt? Refused bus: {bus_name}" ) - note = Note(type=NoteType.ERROR, text=D_("Invalid player name.")) - return AdHocCallbackData(status=Status.COMPLETED, notes=[note]) - + raise AdHocError(Error.BAD_PAYLOAD, text=D_("Invalid player name.")) + assert self.session_con is not None try: proxy = await self.session_con.getRemoteObject( bus_name, MPRIS_PATH, "org.mpris.MediaPlayer2.Player" ) except Exception as e: - log.warning(_("Can't get D-Bus proxy: {reason}").format(reason=e)) - note = Note( - type=NoteType.ERROR, text=D_("Media player is not available anymore") + log.exception(f"Can't get D-Bus proxy.") + raise AdHocError( + Error.ITEM_NOT_FOUND, D_("Media player is not available anymore") ) - return AdHocCallbackData(status=Status.COMPLETED, notes=[note]) try: - command = command_form["command"] + command = page_data.form.get_field("command", ListSingle).value + if not command: + raise KeyError except KeyError: pass else: await self.do_mpris_command(proxy, command) # we construct the remote control form - wokkel_form = data_form.Form("form", title=D_("Media Player Selection")) - wokkel_form.addField( - data_form.Field(fieldType="hidden", var="media_player", value=bus_name) + form = DataForm( + title=D_("Media Player Selection"), ) for iface, properties_names in MPRIS_PROPERTIES.items(): for name in properties_names: try: value = await self._dbus_get_property(proxy, iface, name) except Exception as e: - log.warning( - _("Can't retrieve attribute {name}: {reason}").format( - name=name, reason=e - ) - ) + log.warning(f"Can't retrieve attribute {name}: {e}") continue if name == MPRIS_METADATA_KEY: - self.add_mpris_metadata(wokkel_form, value) + self.add_mpris_metadata(form, value) else: - wokkel_form.addField( - data_form.Field(fieldType="fixed", var=name, value=str(value)) - ) + form.fields.append(Text(type="fixed", var=name, value=str(value))) - commands = [data_form.Option(c, c.rsplit(".", 1)[1]) for c in MPRIS_COMMANDS] - wokkel_form.addField( - data_form.Field( - fieldType="list-single", - var="command", - options=commands, - required=True, - ) - ) + commands = [ + Option(value=c, label=c.rsplit(".", 1)[1]) for c in MPRIS_COMMANDS + ] + form.fields.append(ListSingle(var="command", options=commands, required=True)) - status = Status.EXECUTING - return AdHocCallbackData( - form=DataForm.from_wokkel_form(wokkel_form), status=status, notes=[] - ) + return AdHocCallbackData(form=form, status=Status.EXECUTING) diff --git a/libervia/backend/plugins/plugin_adhoc_registration.py b/libervia/backend/plugins/plugin_adhoc_registration.py index a04ea8f2..e55a5b5c 100644 --- a/libervia/backend/plugins/plugin_adhoc_registration.py +++ b/libervia/backend/plugins/plugin_adhoc_registration.py @@ -22,8 +22,6 @@ from typing import cast from uuid import uuid4 from twisted.internet import defer -from twisted.words.xish import domish -from wokkel import data_form as wokkel_data_form from libervia.backend.core import exceptions from libervia.backend.core.constants import Const as C @@ -31,15 +29,15 @@ from libervia.backend.core.core_types import SatXMPPEntity from libervia.backend.core.i18n import D_, _ from libervia.backend.core.log import getLogger from libervia.backend.memory import persistent -from libervia.backend.memory.memory import SessionData -from libervia.backend.plugins.plugin_xep_0004 import DataForm +from libervia.backend.plugins.plugin_xep_0004 import DataForm, ListSingle, Option, Text from libervia.backend.plugins.plugin_xep_0050 import ( XEP_0050, AdHocCallbackData, + AdHocCommand, AdHocError, Error, Note, - NoteType, + PageData, Status, ) from libervia.backend.tools.common import data_format, date_utils @@ -100,32 +98,70 @@ class AdHocRegistration: async def profile_connected(self, client): if client.is_admin: - self._c.add_ad_hoc_command( + self._c.register_ad_hoc_command( client, - self.create_registration_link, - D_("Create Registration Link"), - node="https://libervia.org/registration/create", + AdHocCommand( + callback=self.create_registration_link, + label=D_("Create Registration Link"), + node="https://libervia.org/registration/create", + form=DataForm( + title=D_("Create Registration Link"), + fields=[ + Text(var="recipient", label=D_("Recipient Name")), + Text( + var="expiration_time_pattern", + label=D_("Expiration Date"), + desc=D_( + "Set the expiry duration for this link. Use the" + " Libervia Time Pattern (e.g., '1 week'). The link" + " will expire after this period." + ), + value="1 week", + required=True, + ), + Text( + var="registration_limit", + label=D_("Maximum registrations limit"), + desc=D_( + "How many accounts can be registered using this link." + " Set to 0 for unlimited registrations." + ), + value="1", + required=True, + ), + Text( + type="text-multi", + var="welcome_message", + label=D_("Welcome Message"), + ), + Text( + type="text-multi", + var="extra_details", + label=D_("Additional Details"), + ), + ], + ), + ), ) - self._c.add_ad_hoc_command( + self._c.register_ad_hoc_command( client, - self.list_registration_links, - D_("List Registration Links"), - node="https://libervia.org/registration/list", + AdHocCommand( + callback=self.list_registration_links, + label=D_("List Registration Links"), + node="https://libervia.org/registration/list", + ), ) - self._c.add_ad_hoc_command( + self._c.register_ad_hoc_command( client, - self.delete_registration_link, - D_("Delete Registration Link"), - node="https://libervia.org/registration/delete", + AdHocCommand( + callback=self.delete_registration_link, + label=D_("Delete Registration Link"), + node="https://libervia.org/registration/delete", + ), ) async def create_registration_link( - self, - client: SatXMPPEntity, - command_elt: domish.Element, - session_data: SessionData, - action: str, - node: str, + self, client: SatXMPPEntity, page_data: PageData ) -> AdHocCallbackData: """Ad-hoc command used to create a registration link. @@ -134,175 +170,91 @@ class AdHocRegistration: associated data. @param client: The XMPP client instance. - @param command_elt: The command element. - @param session_data: Data associated with the current session. - @param action: The action being performed. - @param node: The node identifier. + @param page_data: Ad-Hoc page data. @return: Command results. + @raise AdHocError: If the payload is invalid or internal error occurs. """ - actions = session_data.setdefault("actions", []) - actions.append(action) - - if len(actions) == 1: - # First request, presenting the form to the user - status = Status.EXECUTING - form = wokkel_data_form.Form("form", title=D_("Create Registration Link")) - - form.addField( - wokkel_data_form.Field( - "text-single", "recipient", label=D_("Recipient Name") + form = page_data.form + try: + recipient = form.get_field("recipient", Text).value + expiration_time_pattern = form.get_field( + "expiration_time_pattern", Text + ).value + if not expiration_time_pattern: + raise KeyError + if expiration_time_pattern == "never": + expiration_timestamp = None + else: + expiration_timestamp = date_utils.date_parse_ext( + expiration_time_pattern, default_tz=date_utils.TZ_LOCAL ) - ) - form.addField( - wokkel_data_form.Field( - "text-single", - "expiration_time_pattern", - label=D_("Expiration Date"), - desc=D_( - "Set the expiry duration for this link. Use the Libervia Time " - "Pattern (e.g., '1 week'). The link will expire after this " - "period." + registration_limit = form.get_field("registration_limit", Text).value + if registration_limit is None: + raise KeyError + registration_limit = int(registration_limit) + + welcome_message = form.get_field("welcome_message", Text).value or "" + extra_details = form.get_field("extra_details", Text).value or "" + + link_id = str(uuid4()) + + link_data = { + "recipient": recipient, + "registration_limit": registration_limit, + "welcome_message": welcome_message, + "extra_details": extra_details, + "creator_jid": page_data.requestor.full(), + "use_count": 0, + "creation_timestamp": int(time.time()), + } + if expiration_timestamp is not None: + link_data["expiration_timestamp"] = expiration_timestamp + + await self.ad_hoc_registration_data.aset(link_id, link_data) + + notes = [ + Note( + text=D_("Registration link created successfully: {link_id}").format( + link_id=link_id ), - value="1 week", - required=True, ) - ) - form.addField( - wokkel_data_form.Field( - "text-single", - "registration_limit", - label=D_("Maximum registrations limit"), - desc=D_( - "How many accounts can be registered using this link. Set to 0 " - "for unlimited registrations." - ), - value="1", - required=True, - ) - ) - form.addField( - wokkel_data_form.Field( - "text-multi", "welcome_message", label=D_("Welcome Message") - ) - ) - form.addField( - wokkel_data_form.Field( - "text-multi", - "extra_details", - label=D_("Additional Details"), - ) - ) + ] + except (KeyError, ValueError): + raise AdHocError(Error.BAD_PAYLOAD) - payload = form.toElement() - notes = [] - - elif len(actions) == 2: - try: - x_elt = next(command_elt.elements(wokkel_data_form.NS_X_DATA, "x")) - answer_form = wokkel_data_form.Form.fromElement(x_elt) - - recipient = answer_form.get("recipient", None) - expiration_time_pattern = answer_form["expiration_time_pattern"] - if expiration_time_pattern == "never": - expiration_timestamp = None - else: - expiration_timestamp = date_utils.date_parse_ext( - expiration_time_pattern, default_tz=date_utils.TZ_LOCAL - ) - registration_limit = int(answer_form["registration_limit"]) - welcome_message = answer_form.get("welcome_message", "") - extra_details = answer_form.get("extra_details", "") - - link_id = str(uuid4()) - - link_data = { - "recipient": recipient, - "registration_limit": registration_limit, - "welcome_message": welcome_message, - "extra_details": extra_details, - "creator_jid": session_data["requestor"].full(), - "use_count": 0, - "creation_timestamp": int(time.time()), - } - if expiration_timestamp is not None: - link_data["expiration_timestamp"] = expiration_timestamp - - await self.ad_hoc_registration_data.aset(link_id, link_data) - - status = Status.COMPLETED - payload = None - notes = [ - Note( - type=NoteType.INFO, - text=D_( - "Registration link created successfully: {link_id}" - ).format(link_id=link_id), - ) - ] - except (KeyError, StopIteration, ValueError): - raise AdHocError(Error.BAD_PAYLOAD) - else: - raise AdHocError(Error.INTERNAL) - - return AdHocCallbackData(payload=payload, status=status, notes=notes) + return AdHocCallbackData(status=Status.COMPLETED, notes=notes) async def list_registration_links( - self, - client: SatXMPPEntity, - command_elt: domish.Element, - session_data: SessionData, - action: str, - node: str, + self, client: SatXMPPEntity, page_data: PageData ) -> AdHocCallbackData: """Ad-hoc command used to list all registration links. This method retrieves all the registration links and presents them to the user. @param client: The XMPP client instance. - @param command_elt: The command element. - @param session_data: Data associated with the current session. - @param action: The action being performed. - @param node: The node identifier. + @param page_data: Ad-Hoc page data. @return: Command results. + @raise AdHocError: If internal error occurs. """ - actions = session_data.setdefault("actions", []) - actions.append(action) + all_links = await self.ad_hoc_registration_data.all() - if len(actions) == 1: - all_links = await self.ad_hoc_registration_data.all() - status = Status.EXECUTING - - wokkel_form = wokkel_data_form.Form("form", title=D_("Registered Links")) - for link_id, link_data in all_links.items(): - wokkel_form.addField( - wokkel_data_form.Field( - "text-multi", - var=f"link_{link_id}", - label=D_("Link ID: {link_id}").format(link_id=link_id), - value=str(link_data), - ) + form = DataForm(type="result", title=D_("Registered Links")) + for link_id, link_data in all_links.items(): + form.fields.append( + Text( + type="text-multi", + var=f"link_{link_id}", + label=D_("Link ID: {link_id}").format(link_id=link_id), + value=str(link_data), ) + ) - notes = [] - - status = Status.COMPLETED - - else: - raise AdHocError(Error.INTERNAL) - - return AdHocCallbackData( - form=DataForm.from_wokkel_form(wokkel_form), status=status, notes=notes - ) + return AdHocCallbackData(form=form, status=Status.COMPLETED) async def delete_registration_link( - self, - client: SatXMPPEntity, - command_elt: domish.Element, - session_data: SessionData, - action: str, - node: str, + self, client: SatXMPPEntity, page_data: PageData ) -> AdHocCallbackData: """Ad-hoc command used to delete a registration link. @@ -310,60 +262,40 @@ class AdHocRegistration: delete, and processes the submitted form to delete the selected link. @param client: The XMPP client instance. - @param command_elt: The command element. - @param session_data: Data associated with the current session. - @param action: The action being performed. - @param node: The node identifier. + @param page_data: Ad-Hoc page data. @return: Command results. + @raise AdHocError: If the payload is invalid or internal error occurs. """ - actions = session_data.setdefault("actions", []) - actions.append(action) - - if len(actions) == 1: + if page_data.idx == 0: all_links = await self.ad_hoc_registration_data.all() - status = Status.EXECUTING - wokkel_form = wokkel_data_form.Form( - "form", title=D_("Delete Registration Link") + form = DataForm( + title=D_("Delete Registration Link"), ) - link_options = [ - wokkel_data_form.Option(link_id, link_id) for link_id in all_links - ] - wokkel_form.addField( - wokkel_data_form.Field( - "list-single", - "link_to_delete", + link_options = [Option(value=link_id, label=link_id) for link_id in all_links] + form.fields.append( + ListSingle( + var="link_to_delete", label=D_("Select Link to Delete"), options=link_options, required=True, ) ) + return AdHocCallbackData(form=form, status=Status.EXECUTING) - form = DataForm.from_wokkel_form(wokkel_form) - notes = [] - - elif len(actions) == 2: - try: - x_elt = next(command_elt.elements(wokkel_data_form.NS_X_DATA, "x")) - answer_form = wokkel_data_form.Form.fromElement(x_elt) - link_to_delete = answer_form["link_to_delete"] - - await self.ad_hoc_registration_data.adel(link_to_delete) - - status = Status.COMPLETED - form = None - notes = [ + else: + link_to_delete = page_data.form.get_field("link_to_delete", ListSingle).value + if not link_to_delete: + raise AdHocError(Error.BAD_PAYLOAD) + await self.ad_hoc_registration_data.adel(link_to_delete) + return AdHocCallbackData( + status=Status.COMPLETED, + notes=[ Note( - type=NoteType.INFO, text=D_( "Registration link {link_id} deleted successfully." ).format(link_id=link_to_delete), ) - ] - except (KeyError, StopIteration, ValueError): - raise AdHocError(Error.BAD_PAYLOAD) - else: - raise AdHocError(Error.INTERNAL) - - return AdHocCallbackData(form=form, status=status, notes=notes) + ], + ) diff --git a/libervia/backend/plugins/plugin_comp_ap_gateway/ad_hoc.py b/libervia/backend/plugins/plugin_comp_ap_gateway/ad_hoc.py index 59020f3d..a2c51f6c 100644 --- a/libervia/backend/plugins/plugin_comp_ap_gateway/ad_hoc.py +++ b/libervia/backend/plugins/plugin_comp_ap_gateway/ad_hoc.py @@ -18,20 +18,22 @@ from typing import cast from twisted.words.protocols.jabber import jid -from twisted.words.xish import domish from wokkel import data_form from libervia.backend.core.constants import Const as C from libervia.backend.core.core_types import SatXMPPEntity -from libervia.backend.core.i18n import _ +from libervia.backend.core.i18n import D_, _ from libervia.backend.core.log import getLogger -from libervia.backend.memory.memory import SessionData -from libervia.backend.plugins.plugin_xep_0004 import DataForm +from libervia.backend.plugins.plugin_xep_0004 import DataForm, Text from libervia.backend.plugins.plugin_xep_0050 import ( XEP_0050, AdHocCallbackData, + AdHocCommand, + AdHocError, + Error, Note, NoteType, + PageData, Status, ) @@ -49,59 +51,36 @@ class APAdHocService: self._c = cast(XEP_0050, self.host.plugins["XEP-0050"]) def init(self, client: SatXMPPEntity) -> None: - self._c.add_ad_hoc_command( + self._c.register_ad_hoc_command( client, - self.xmpp_jid_node_2_ap_actor, - "Convert XMPP JID/Node to AP actor", - node=NS_XMPP_JID_NODE_2_AP, - allowed_magics=[C.ENTITY_ALL], + AdHocCommand( + callback=self.xmpp_jid_node_2_ap_actor, + label=D_("Convert XMPP JID/Node to AP actor"), + node=NS_XMPP_JID_NODE_2_AP, + allowed_magics=[C.ENTITY_ALL], + form=DataForm( + title=D_("XMPP JID/node to AP actor conversion"), + namespace=NS_XMPP_JID_NODE_2_AP, + fields=[Text(var="jid", required=True), Text(var="node")], + ), + ), ) async def xmpp_jid_node_2_ap_actor( self, client: SatXMPPEntity, - command_elt: domish.Element, - session_data: SessionData, - action: str, - node: str, + page_data: PageData, ) -> AdHocCallbackData: """Convert XMPP JID/Node to AP actor. - @param client: Libervia client instance. - @param command_elt: The command element from the request. - @param session_data: Session data for this command execution. - @param action: The action being performed (EXECUTE, CANCEL, etc.) - @param node: The command node identifier. + @param page_data: Ad-Hoc page data. @return: Command results. @raise AdHocError: When the JID is invalid or when no actor is found. """ - 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 - if command_form is None or len(command_form.fields) == 0: - # root request - status = Status.EXECUTING - wokkel_form = data_form.Form( - "form", - title="XMPP JID/node to AP actor conversion", - formNamespace=NS_XMPP_JID_NODE_2_AP, - ) - - field = data_form.Field("text-single", "jid", required=True) - wokkel_form.addField(field) - - field = data_form.Field("text-single", "node", required=False) - wokkel_form.addField(field) - - return AdHocCallbackData( - form=DataForm.from_wokkel_form(wokkel_form), status=status, notes=[] - ) - else: - xmpp_jid = jid.JID(command_form["jid"]) - xmpp_node = command_form.get("node") - actor = await self.apg.get_ap_account_from_jid_and_node(xmpp_jid, xmpp_node) - return AdHocCallbackData( - status=Status.COMPLETED, notes=[Note(type=NoteType.INFO, text=actor)] - ) + jid_s = page_data.form.get_field("jid", Text).value + if not jid_s: + raise AdHocError(Error.BAD_PAYLOAD, text=f'Invalid "jid": {jid_s!r}.') + xmpp_jid = jid.JID(jid_s) + xmpp_node = page_data.form.get_field("node", Text).value + actor = await self.apg.get_ap_account_from_jid_and_node(xmpp_jid, xmpp_node) + return AdHocCallbackData(status=Status.COMPLETED, notes=[Note(text=actor)]) diff --git a/libervia/backend/plugins/plugin_comp_email_gateway/ad_hoc.py b/libervia/backend/plugins/plugin_comp_email_gateway/ad_hoc.py index e23fb38c..e448d30a 100644 --- a/libervia/backend/plugins/plugin_comp_email_gateway/ad_hoc.py +++ b/libervia/backend/plugins/plugin_comp_email_gateway/ad_hoc.py @@ -19,14 +19,11 @@ from typing import TYPE_CHECKING, NamedTuple, cast from twisted.mail.imap4 import IMAP4Exception -from twisted.words.protocols.jabber import jid -from twisted.words.xish import domish from libervia.backend.core.constants import Const as C from libervia.backend.core.core_types import SatXMPPEntity from libervia.backend.core.i18n import D_, _ from libervia.backend.core.log import getLogger -from libervia.backend.memory.memory import SessionData from libervia.backend.plugins.plugin_xep_0004 import ( Boolean, DataForm, @@ -41,6 +38,7 @@ from libervia.backend.plugins.plugin_xep_0050 import ( Error, Note, NoteType, + PageData, Status, XEP_0050, ) @@ -55,19 +53,26 @@ if TYPE_CHECKING: class ImapInfo(NamedTuple): client: "IMAPClient" - uid: str - form: DataForm + uid: int class EmailAdHocService: """Ad-Hoc commands for AP Gateway""" def __init__(self, email_gw: "EmailGatewayComponent"): + """Initialize the EmailAdHocService. + + @param email_gw: The EmailGatewayComponent instance. + """ self.host = email_gw.host self.email_gw = email_gw self._c = cast(XEP_0050, self.host.plugins["XEP-0050"]) def init(self, client: SatXMPPEntity) -> None: + """Initialize ad-hoc commands for email gateway. + + @param client: Libervia client instance. + """ self._c.register_ad_hoc_command( client, AdHocCommand( @@ -121,17 +126,19 @@ class EmailAdHocService: ), ) - async def get_uid(self, command_elt: domish.Element) -> ImapInfo: - iq_elt = command_elt.parent - assert iq_elt is not None - requestor = jid.JID(iq_elt["from"]).userhostJID() - form = DataForm.from_element(command_elt) - data = form.get_values() + async def get_uid(self, page_data: PageData) -> ImapInfo: + """Get IMAP UID for a given message ID. + + @param page_data: Ad-Hoc page data containing the message ID. + @return: ImapInfo instance, with UID and client. + """ try: - message_id = cast(str, data["uid"][0]) - except (KeyError, IndexError): - log.warning('"uid" field is mandatory.') - raise AdHocError(Error.BAD_PAYLOAD) + message_id = page_data.form.get_field("uid", Text).value + if message_id is None: + raise ValueError('"uid" must be set.') + except Exception as e: + raise AdHocError(Error.BAD_PAYLOAD, text=f'"uid" field is mandatory. {e}') + requestor = page_data.requestor try: user_data = self.email_gw.users_data[requestor] except KeyError: @@ -146,32 +153,24 @@ class EmailAdHocService: raise AdHocError( Error.ITEM_NOT_FOUND, f"The message {message_id!r} is not found." ) - return ImapInfo(user_data.imap_client, uid, form) + return ImapInfo(user_data.imap_client, uid) async def delete_email( self, client: SatXMPPEntity, - command_elt: domish.Element, - session_data: SessionData, - action: str, - node: str, + page_data: PageData, ) -> AdHocCallbackData: """Delete an email from IMAP server. @param client: Libervia client instance. - @param command_elt: The command element from the request. - @param session_data: Session data for this command execution. - @param action: The action being performed (EXECUTE, CANCEL, etc.) - @param node: The command node identifier. + @param page_data: Ad-Hoc page data. @return: Command results. """ - imap_info = await self.get_uid(command_elt) - values = imap_info.form.get_values() - try: - confirm_str = cast(str, values["confirm"][0] or C.BOOL_FALSE) - except IndexError: - confirm_str = C.BOOL_FALSE - if not C.bool(confirm_str): + imap_info = await self.get_uid(page_data) + confirm = page_data.form.get_field("confirm", Boolean).value + if confirm is None: + raise AdHocError(Error.BAD_PAYLOAD, text='"confirm" must be set.') + if not confirm: return AdHocCallbackData( status=Status.CANCELED, notes=[Note(text=f"User didn't confirm deletion.")], @@ -188,27 +187,17 @@ class EmailAdHocService: async def move_email( self, client: SatXMPPEntity, - command_elt: domish.Element, - session_data: SessionData, - action: str, - node: str, + page_data: PageData, ) -> AdHocCallbackData: """Move an email to another IMAP mailbox. @param client: Libervia client instance. - @param command_elt: The command element from the request. - @param session_data: Session data for this command execution. - @param action: The action being performed (EXECUTE, CANCEL, etc.) - @param node: The command node identifier. + @param page_data: Ad-Hoc page data. @return: Command results. """ - imap_info = await self.get_uid(command_elt) - values = imap_info.form.get_values() - try: - dest_mailbox = cast(str, values["dest"][0]).strip() - if not dest_mailbox: - raise IndexError - except IndexError: + imap_info = await self.get_uid(page_data) + dest_mailbox = page_data.form.get_field("dest", Text).value + if dest_mailbox is None or not (dest_mailbox := dest_mailbox.strip()): raise AdHocError( Error.BAD_PAYLOAD, D_("You must indicate a destination mailbox.") ) @@ -240,26 +229,18 @@ class EmailAdHocService: async def change_flags( self, client: SatXMPPEntity, - command_elt: domish.Element, - session_data: SessionData, - action: str, - node: str, + page_data: PageData, ) -> AdHocCallbackData: """Change flags of an email. @param client: Libervia client instance. - @param command_elt: The command element from the request. - @param session_data: Session data for this command execution. - @param action: The action being performed (EXECUTE, CANCEL, etc.) - @param node: The command node identifier. + @param page_data: Ad-Hoc page data. @return: Command results. """ - try: - imap_info = session_data["imap_info"] - except KeyError: + if page_data.idx == 1: # We are on the second page of the commands, were user select flags. - imap_info = await self.get_uid(command_elt) - session_data["imap_info"] = imap_info + imap_info = await self.get_uid(page_data) + page_data.session_data["imap_info"] = imap_info # FIXME: We only support INBOX for now. mailbox = "INBOX" mailbox_data = imap_info.client.mailboxes_data.get(mailbox, {}) @@ -276,9 +257,12 @@ class EmailAdHocService: ) flags_data = await imap_info.client.fetchFlags(imap_info.uid, uid=True) - try: - current_flags = flags_data[imap_info.uid]["FLAGS"] - except KeyError: + str_uid = str(imap_info.uid) + for flags_value in flags_data.values(): + if flags_value["UID"] == str_uid: + current_flags = flags_value["FLAGS"] + break + else: return AdHocCallbackData( status=Status.CANCELED, notes=[ @@ -301,12 +285,10 @@ class EmailAdHocService: ) return AdHocCallbackData(form=form, status=Status.EXECUTING) else: - form = DataForm.from_element(command_elt) - data = form.get_values() - try: - new_flags = data["flags"] - except KeyError: - raise AdHocError(Error.BAD_PAYLOAD) + # This is the third and last page of the command, where flags are actually + # set. + imap_info = page_data.session_data["imap_info"] + new_flags = page_data.form.get_field("flags", ListMulti).values await imap_info.client.setFlags(imap_info.uid, flags=new_flags, uid=True) return AdHocCallbackData( status=Status.COMPLETED, notes=[Note(text=D_("Flags set successfully."))] diff --git a/libervia/backend/plugins/plugin_comp_file_sharing_management.py b/libervia/backend/plugins/plugin_comp_file_sharing_management.py index b43eaaf9..4c158ec3 100644 --- a/libervia/backend/plugins/plugin_comp_file_sharing_management.py +++ b/libervia/backend/plugins/plugin_comp_file_sharing_management.py @@ -19,8 +19,6 @@ import os.path from functools import partial from typing import cast -from twisted.words.xish import domish -from wokkel import data_form from twisted.internet import defer from twisted.words.protocols.jabber import jid from libervia.backend.core.core_types import SatXMPPEntity @@ -28,15 +26,14 @@ 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.memory.memory import SessionData -from libervia.backend.plugins.plugin_xep_0004 import DataForm, Text +from libervia.backend.plugins.plugin_xep_0004 import Boolean, DataForm, Text from libervia.backend.plugins.plugin_xep_0050 import ( XEP_0050, - Action, AdHocCallbackData, + AdHocCommand, AdHocError, Note, - NoteType, + PageData, Status, Error, ) @@ -69,18 +66,18 @@ NS_FILE_MANAGEMENT_THUMB = "https://salut-a-toi.org/protocol/file-management:0#t 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 +ROOT_FORM = DataForm( + type="form", + title="File Management", + namespace=NS_FILE_MANAGEMENT, + fields=[ + Text(var="path", required=True), + Text(var="namespace", required=True), + ], +) -class FileSharingManagement(object): +class FileSharingManagement: # 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. @@ -100,33 +97,44 @@ class FileSharingManagement(object): ) def profile_connected(self, client): - self._c.add_ad_hoc_command( + self._c.register_ad_hoc_command( client, - self._on_change_file, - "Change Permissions of File(s)", - node=NS_FILE_MANAGEMENT_PERM, - allowed_magics=[C.ENTITY_ALL], + AdHocCommand( + callback=self._on_change_file, + label=D_("Change Permissions of File(s)"), + node=NS_FILE_MANAGEMENT_PERM, + allowed_magics=[C.ENTITY_ALL], + form=ROOT_FORM, + ), ) - self._c.add_ad_hoc_command( + self._c.register_ad_hoc_command( client, - self._on_delete_file, - "Delete File(s)", - node=NS_FILE_MANAGEMENT_DELETE, - allowed_magics=[C.ENTITY_ALL], + AdHocCommand( + callback=self._on_delete_file, + label=D_("Delete File(s)"), + node=NS_FILE_MANAGEMENT_DELETE, + allowed_magics=[C.ENTITY_ALL], + form=ROOT_FORM, + ), ) - self._c.add_ad_hoc_command( + self._c.register_ad_hoc_command( client, - self._on_gen_thumbnails, - "Generate Thumbnails", - node=NS_FILE_MANAGEMENT_THUMB, - allowed_magics=[C.ENTITY_ALL], + AdHocCommand( + callback=self._on_gen_thumbnails, + label=D_("Generate Thumbnails"), + node=NS_FILE_MANAGEMENT_THUMB, + allowed_magics=[C.ENTITY_ALL], + form=ROOT_FORM, + ), ) - self._c.add_ad_hoc_command( + self._c.register_ad_hoc_command( client, - self._on_quota, - "Get Quota", - node=NS_FILE_MANAGEMENT_QUOTA, - allowed_magics=[C.ENTITY_ALL], + AdHocCommand( + callback=self._on_quota, + label=D_("Get Quota"), + node=NS_FILE_MANAGEMENT_QUOTA, + allowed_magics=[C.ENTITY_ALL], + ), ) def _delete(self, service_jid_s, path, namespace, profile): @@ -141,56 +149,28 @@ class FileSharingManagement(object): ) ) - def _err(self, reason): - """Helper method to get argument to return for error + async def _get_file_data(self, client: SatXMPPEntity, page_data: PageData) -> dict: + """Retrieve field requested in root form. - workflow will be interrupted with an error note - @param reason(unicode): reason of the error - @return (tuple): value to return + "found_file" will also be set in session_data. + @param client: SatXMPPEntity + @param page_data: Data of the page. + @return: found file data + + @raise AdHocError: something is wrong """ - status = Status.COMPLETED - form = None - note = (NoteType.ERROR, reason) - return form, status, None, note - - def _get_root_args(self) -> tuple[DataForm, Status, None, None]: - """Create the form to select the file to use - - @return (tuple): value to return - """ - status = Status.EXECUTING - form = DataForm( - type="form", - title="File Management", - form_namespace=NS_FILE_MANAGEMENT, - fields=[ - Text(var="path", required=True), - Text(var="namespace", required=True), - ], - ) - - return form, 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(Error.BAD_PAYLOAD) + path = page_data.form.get_field("path", Text).value + namespace = page_data.form.get_field("namespace", Text).value + except KeyError as e: + raise AdHocError(Error.BAD_PAYLOAD) from e - if not path: - self._c.ad_hoc_error(Error.BAD_PAYLOAD) + if path is None: + raise AdHocError(Error.BAD_PAYLOAD) + + if not (path := path.strip()): + raise AdHocError(Error.BAD_PAYLOAD) - requestor = session_data["requestor"] - requestor_bare = requestor.userhostJID() path = path.rstrip("/") parent_path, basename = os.path.split(path) @@ -200,24 +180,24 @@ class FileSharingManagement(object): try: found_files = await self.host.memory.get_files( client, - requestor_bare, + page_data.requestor, path=parent_path, name=basename, - namespace=namespace, + namespace=namespace or None, ) found_file = found_files[0] except (exceptions.NotFound, IndexError): - raise WorkflowError(self._err(_("file not found"))) + raise AdHocError(Error.ITEM_NOT_FOUND, text=_("file not found")) except exceptions.PermissionError: - raise WorkflowError(self._err(_("forbidden"))) + raise AdHocError(Error.FORBIDDEN) - if found_file["owner"] != requestor_bare: + if found_file["owner"] != page_data.requestor: # only owner can manage files log.warning(_("Only owner can manage files")) - raise WorkflowError(self._err(_("forbidden"))) + raise AdHocError(Error.FORBIDDEN) - session_data["found_file"] = found_file - session_data["namespace"] = namespace + page_data.session_data["found_file"] = found_file + page_data.session_data["namespace"] = namespace return found_file def _update_read_permission(self, access, allowed_jids): @@ -240,7 +220,7 @@ class FileSharingManagement(object): """ assert file_data["type"] == C.FILE_TYPE_DIRECTORY files_data = await self.host.memory.get_files( - client, requestor, parent=file_data["id"], namespace=namespace + client, requestor, parent=file_data["id"], namespace=namespace or None ) for file_data in files_data: @@ -261,44 +241,20 @@ class FileSharingManagement(object): async def _on_change_file( self, client: SatXMPPEntity, - command_elt: domish.Element, - session_data: SessionData, - action: Action, - node: str, + page_data: PageData, ) -> AdHocCallbackData: """Change permissions of a file or directory. @param client: Libervia client instance. - @param command_elt: The command element from the request. - @param session_data: Session data for this command execution. - @param action: The action being performed (EXECUTE, CANCEL, etc.) - @param node: The command node identifier. + @param page_data: Ad-Hoc page data. @return: Command results. @raise AdHocError: Something went wrong during the workflow. """ - 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 - form, status, __, __ = self._get_root_args() - return AdHocCallbackData(form=form, status=status, notes=[]) - - 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: - form, status, __, note = e.err_args - notes = [Note(type=note[0], text=note[1])] if note else [] - return AdHocCallbackData(form=form, status=status, notes=notes) + form = page_data.form + session_data = page_data.session_data + if page_data.idx == 1: + # We retrieve the file and ask for permissions. + found_file = await self._get_file_data(client, page_data) # management request if found_file["type"] == C.FILE_TYPE_DIRECTORY: @@ -306,46 +262,49 @@ class FileSharingManagement(object): else: instructions = D_("Please select permissions for this file") - form = data_form.Form( - "form", - title="File Management", - instructions=[instructions], - formNamespace=NS_FILE_MANAGEMENT, + read_allowed_field = Text( + type="text-multi", + var="read_allowed", + desc=D_( + "list of jids allowed to read this file (beside yourself), or " + '"PUBLIC" to let a public access' + ), ) - 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', + form = DataForm( + title=D_("File Management"), + instructions=instructions, + namespace=NS_FILE_MANAGEMENT, + fields=[read_allowed_field], ) 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"] + read_allowed_field.value = "PUBLIC" else: - field.values = read_access.get("jids", []) - form.addField(field) + read_allowed_field.value = "\n".join(read_access.get("jids", [])) 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.fields.append( + Boolean( + var="recursive", + value=False, + desc=D_( + "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 = Status.EXECUTING - return AdHocCallbackData( - form=DataForm.from_wokkel_form(form), status=status, notes=[] - ) + return AdHocCallbackData(form=form, status=Status.EXECUTING) else: # final phase, we'll do permission change here + found_file = session_data["found_file"] + try: - read_allowed = command_form.fields["read_allowed"] + read_allowed = form.get_field("read_allowed", Text) + if read_allowed.value is None: + raise KeyError except KeyError: raise AdHocError(Error.BAD_PAYLOAD) @@ -355,14 +314,13 @@ class FileSharingManagement(object): allowed_jids = None else: try: - allowed_jids = [ - jid.JID(v.strip()) for v in read_allowed.values if v.strip() - ] + lines = [line.strip() for line in read_allowed.value.splitlines()] + allowed_jids = [jid.JID(v) for v in lines if v] except RuntimeError as e: log.warning( _("Can't use read_allowed values: {reason}").format(reason=e) ) - raise AdHocError(Error.BAD_PAYLOAD) + raise AdHocError(Error.BAD_PAYLOAD, text=str(e)) if found_file["type"] == C.FILE_TYPE_FILE: await self.host.memory.file_update( @@ -372,9 +330,9 @@ class FileSharingManagement(object): ) else: try: - recursive = command_form.fields["recursive"] + recursive = form.get_field("recursive", Boolean) except KeyError: - raise AdHocError(Error.BAD_PAYLOAD) + recursive = False await self.host.memory.file_update( found_file["id"], "access", @@ -386,56 +344,32 @@ class FileSharingManagement(object): # can read them. namespace = session_data["namespace"] await self._update_dir( - client, requestor_bare, namespace, found_file, "PUBLIC" + client, page_data.requestor, namespace, found_file, "PUBLIC" ) # job done, we can end the session return AdHocCallbackData( status=Status.COMPLETED, - notes=[Note(type=NoteType.INFO, text=_("management session done"))], + notes=[Note(text=_("management session done"))], ) async def _on_delete_file( self, client: SatXMPPEntity, - command_elt: domish.Element, - session_data: SessionData, - action: Action, - node: str, + page_data: PageData, ) -> AdHocCallbackData: """Delete a file or directory. @param client: Libervia client instance. - @param command_elt: The command element from the request. - @param session_data: Session data for this command execution. - @param action: The action being performed (EXECUTE, CANCEL, etc.) - @param node: The command node identifier. + @param page_data: Ad-Hoc page data. @return: Command results. @raise AdHocError: Something went wrong during the workflow. """ - 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 + session_data = page_data.session_data - 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 - form, status, __, __ = self._get_root_args() - return AdHocCallbackData(form=form, status=status, notes=[]) - - 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: - form, status, __, note = e.err_args - notes = [Note(type=note[0], text=note[1])] if note else [] - return AdHocCallbackData(form=form, status=status, notes=notes) + if page_data.idx == 1: + found_file = await self._get_file_data(client, page_data) + session_data["found_file"] = found_file if found_file["type"] == C.FILE_TYPE_DIRECTORY: msg = D_( "Are you sure to delete directory {name} and all files and " @@ -445,41 +379,36 @@ class FileSharingManagement(object): 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) - return AdHocCallbackData( - form=DataForm.from_wokkel_form(form), status=Status.EXECUTING + form = DataForm( + title=D_("File Management"), + instructions=msg, + namespace=NS_FILE_MANAGEMENT, + fields=[ + Boolean( + var="confirm", + value=False, + required=True, + desc=D_("Check this box to confirm."), + ) + ], ) + return AdHocCallbackData(form=form, status=Status.EXECUTING) else: # final phase, we'll do deletion here + found_file = session_data["found_file"] try: - confirmed = C.bool(command_form.fields["confirm"].value) + confirmed = page_data.form.get_field("confirm", Boolean).value except KeyError: raise AdHocError(Error.BAD_PAYLOAD) if not confirmed: - note = None + notes = [] else: recursive = found_file["type"] == C.FILE_TYPE_DIRECTORY await self.host.memory.file_delete( - client, requestor_bare, found_file["id"], recursive + client, page_data.requestor, found_file["id"], recursive ) - note = Note(type=NoteType.INFO, text=_("file deleted")) - status = Status.COMPLETED - form = None - notes = [note] if note else [] + notes = [Note(text=D_("file deleted"))] return AdHocCallbackData(status=Status.COMPLETED, notes=notes) def _update_thumbs(self, extra, thumbnails): @@ -492,7 +421,7 @@ class FileSharingManagement(object): """ 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 + client, requestor, parent=file_data["id"], namespace=namespace or None ) for sub_file_data in sub_files_data: await self._gen_thumbs(client, requestor, namespace, sub_file_data) @@ -536,91 +465,57 @@ class FileSharingManagement(object): async def _on_gen_thumbnails( self, client: SatXMPPEntity, - command_elt: domish.Element, - session_data: SessionData, - action: Action, - node: str, + page_data: PageData, ) -> AdHocCallbackData: """Generate thumbnails for a file or directory. @param client: Libervia client instance. - @param command_elt: The command element from the request. - @param session_data: Session data for this command execution. - @param action: The action being performed (EXECUTE, CANCEL, etc.) - @param node: The command node identifier. + @param page_data: Ad-Hoc page data. @return: Command results. @raise AdHocError: Something went wrong during the workflow. """ - 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 = await self._get_file_data(client, page_data) - found_file = session_data.get("found_file") - requestor = session_data["requestor"] + log.info("Generating thumbnails.") + await self._gen_thumbs( + client, page_data.requestor, found_file["namespace"], found_file + ) - if command_form is None or len(command_form.fields) == 0: - # root request - form, status, __, __ = self._get_root_args() - return AdHocCallbackData(form=form, status=status, notes=[]) - - 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: - form, status, __, note = e.err_args - notes = [Note(type=note[0], text=note[1])] if note else [] - return AdHocCallbackData(form=form, status=status, notes=notes) - - log.info("Generating thumbnails as requested") - await self._gen_thumbs(client, requestor, found_file["namespace"], found_file) - - # job done, we can end the session - return AdHocCallbackData( - status=Status.COMPLETED, - notes=[Note(type=NoteType.INFO, text=_("thumbnails generated"))], - ) - else: - raise exceptions.InternalError("This code should not be reached.") + return AdHocCallbackData( + status=Status.COMPLETED, notes=[Note(text=D_("thumbnails generated"))] + ) async def _on_quota( self, client: SatXMPPEntity, - command_elt: domish.Element, - session_data: SessionData, - action: Action, - node: str, + page_data: PageData, ) -> AdHocCallbackData: """Get current quota information. @param client: Libervia client instance. - @param command_elt: The command element from the request. - @param session_data: Session data for this command execution. - @param action: The action being performed (EXECUTE, CANCEL, etc.) - @param node: The command node identifier. + @param page_data: Ad-Hoc page data. @return: Command results. @raise AdHocError: Something went wrong during the workflow. """ - requestor = session_data["requestor"] - quota = self.host.plugins["file_sharing"].get_quota(client, requestor) + requestor = page_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 AdHocError(Error.FORBIDDEN) - status = Status.COMPLETED - wokkel_form = data_form.Form("result") - wokkel_form.makeFields({"quota": quota, "user": size_used}) + form = DataForm( + type="result", + fields=[ + Text(var="quota", value=str(quota) if quota is not None else "unlimited"), + Text(var="used", value=str(size_used)), + ], + ) note = Note( - type=NoteType.INFO, - text=_("You are currently using {size_used} on {size_quota}").format( + text=D_("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 AdHocCallbackData( - form=DataForm.from_wokkel_form(wokkel_form), status=status, notes=[note] - ) + return AdHocCallbackData(form=form, status=Status.COMPLETED, notes=[note]) diff --git a/libervia/backend/plugins/plugin_xep_0004.py b/libervia/backend/plugins/plugin_xep_0004.py index 93d51174..0e528e6a 100644 --- a/libervia/backend/plugins/plugin_xep_0004.py +++ b/libervia/backend/plugins/plugin_xep_0004.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Self, TYPE_CHECKING +from typing import Self, TYPE_CHECKING, Type, TypeVar from typing import Annotated, Literal from pydantic import BaseModel, Field @@ -116,12 +116,14 @@ DataField = Annotated[ Field(discriminator="type"), ] +T = TypeVar("T", bound=DataField) + class DataForm(BaseModel): type: Literal["form", "submit", "result", "cancel"] = "form" title: str | None = None instructions: str | None = None - form_namespace: str | None = None + namespace: str | None = None fields: list[DataField] = Field(default_factory=list) def get_values(self) -> dict[str, list[None | str | bool | jid.JID | domish.Element]]: @@ -140,6 +142,35 @@ class DataForm(BaseModel): if field.var == field_var: return field + def get_field(self, field_var: str, expected_type: Type[T]) -> T: + """Retrieve a field with static type verification. + + This method solves linter complaints by providing static type guarantees. + Always use this when you know the expected field type. + Example:: + + text_field = form.get_field("username", Text) + username = text_field.value + + @param field_var: Field var to retrieve. + @param expected_type: Expected field type (Text, Boolean, etc.). + @return: Field instance strictly typed as expected_type. + @raise KeyError: If field doesn't exist. + @raise TypeError: If field exists but is wrong type. + + """ + for field in self.fields: + if field.var == field_var: + if not isinstance(field, expected_type): + raise TypeError( + f"Field {field_var!r} is {type(field).__name__} " + f"(XEP-0004 type: {getattr(field, 'type', 'unknown')}), " + f"expected {expected_type.__name__}" + ) + return field + + raise KeyError(f"Field '{field_var}' not found in data form") + @classmethod def from_wokkel_form( cls, form: data_form.Form, source_form: Self | None = None @@ -242,7 +273,7 @@ class DataForm(BaseModel): type=form.formType, title=form.title, instructions="\n".join(form.instructions) if form.instructions else None, - form_namespace=form.formNamespace, + namespace=form.formNamespace, fields=fields, ) @@ -252,7 +283,7 @@ class DataForm(BaseModel): formType=self.type, title=self.title, instructions=self.instructions.splitlines() if self.instructions else None, - formNamespace=self.form_namespace, + formNamespace=self.namespace, ) for field in self.fields: diff --git a/libervia/backend/plugins/plugin_xep_0050.py b/libervia/backend/plugins/plugin_xep_0050.py index f6669cc9..b4892c71 100644 --- a/libervia/backend/plugins/plugin_xep_0050.py +++ b/libervia/backend/plugins/plugin_xep_0050.py @@ -19,10 +19,10 @@ from collections.abc import Callable, Coroutine from enum import Enum, StrEnum, auto -from typing import Any, NamedTuple, Self, cast +from typing import Any, NamedTuple, cast import importlib -from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator +from pydantic import BaseModel, Field, ValidationInfo, field_validator from pydantic import field_serializer from twisted.internet import defer from twisted.words.protocols.jabber import jid @@ -38,8 +38,8 @@ from libervia.backend.core.core_types import SatXMPPClient, SatXMPPEntity from libervia.backend.core.i18n import D_, _ from libervia.backend.core.log import getLogger from libervia.backend.memory.memory import SessionData, Sessions -from libervia.backend.models.types import DomishElementType, JIDType -from libervia.backend.plugins.plugin_xep_0004 import DataForm +from libervia.backend.models.types import JIDType +from libervia.backend.plugins.plugin_xep_0004 import DataForm, ListSingle, Option from libervia.backend.tools import utils, xml_tools from libervia.backend.tools.common import data_format @@ -162,7 +162,7 @@ class PageData(NamedTuple): with its sources specified, meaning that field types should be set correctly. In the rare case when there is no form (may happen for first page if form is specified in ``AdHocCommand``), an empty form will be used. - @param requestor: The JID of the entity requesting the command execution. + @param requestor: Bare JID of the entity requesting the command execution. @param session_data: A dictionary persisting for the whole command session that can be used by the callback function to store any kind of data. @param action: The action being performed as defined in the Action enum. @@ -181,16 +181,19 @@ class PageData(NamedTuple): idx: int +AdHocCallback = Callable[ + [SatXMPPEntity, PageData], + Coroutine[None, None, AdHocCallbackData] | AdHocCallbackData, +] + + class AdHocCommand(BaseModel): """Pydantic model for ad-hoc command parameters. This model represents the parameters used to create an AdHocCommand instance. """ - callback: Callable[ - [SatXMPPEntity, PageData], - Coroutine[None, None, AdHocCallbackData] | AdHocCallbackData, - ] = Field( + callback: AdHocCallback = Field( description=( "The callback function to execute when command is invoked. The " 'import string "path.to.module.function_to_call" can be used.\n\n' @@ -516,7 +519,7 @@ class AdHocCommandHandler(XMPPHandler): """Handle an incoming command request. @param command_elt: The command element from the request - @param requestor: JID of the entity making the request + @param requestor: Full JID of the entity making the request @param action: The action to perform (execute, cancel, etc.) @param session_id: Session ID if continuing an existing session @return: Deferred for handling the response @@ -541,6 +544,21 @@ class AdHocCommandHandler(XMPPHandler): if action == Action.PREV: if session_data_private["page_idx"] > 0: session_data_private["page_idx"] -= 1 + if ( + session_data_private["page_idx"] == 0 + and self.command.form is not None + ): + # we're back on the first page with original form, we send id + # again. + await self._send_answer( + AdHocCallbackData( + form=self.command.form, status=Status.EXECUTING + ), + session_id, + session_data_private, + iq_elt, + ) + return else: session_data_private["page_idx"] += 1 page_idx = session_data_private["page_idx"] @@ -554,7 +572,7 @@ class AdHocCommandHandler(XMPPHandler): session_data_private["requestor"] = requestor page_idx = session_data_private["page_idx"] = 0 # Maps page_idx to corresponding form. - forms = session_data_private["forms"] + forms = session_data_private["forms"] = {} if action != Action.EXECUTE: log.warning( "Unexpected action on first page request of ad-hoc command: " @@ -588,7 +606,7 @@ class AdHocCommandHandler(XMPPHandler): page_data = PageData( iq_elt=iq_elt, form=form, - requestor=requestor, + requestor=requestor.userhostJID(), session_data=session_data_public, action=action, node=self.command.node, @@ -603,9 +621,11 @@ class AdHocCommandHandler(XMPPHandler): e.callback_error, session_id, iq_elt, text=e.text, lang=e.lang ) return - except Exception: + except Exception as e: log.exception(f"Unexpected error while handling request.") - await self._send_error(Error.INTERNAL, session_id, iq_elt) + await self._send_error( + Error.INTERNAL, session_id, iq_elt, text=f"Internal Error: {e}" + ) return await self._send_answer(ret_data, session_id, session_data_private, iq_elt) @@ -659,7 +679,26 @@ class XEP_0050: # map from node to AdHocCommandHandler instance client._XEP_0050_commands = {} if not client.is_component: - self.add_ad_hoc_command(client, self._status_callback, _("Status")) + self.register_ad_hoc_command( + client, + AdHocCommand( + callback=self._status_callback, + label=D_("Status"), + form=DataForm( + title=D_("Status Selection"), + fields=[ + ListSingle( + var="show", + options=[ + Option(value=value, label=label) + for value, label in list(SHOWS.items()) + ], + required=True, + ) + ], + ), + ), + ) def do( self, @@ -934,61 +973,27 @@ class XEP_0050: def _status_callback( self, client: SatXMPPEntity, - command_elt: domish.Element, - session_data: SessionData, - action: Action, - node: str, + page_data: PageData, ) -> AdHocCallbackData: """Ad-hoc command used to change the "show" part of status. @param client: Libervia client instance. - @param command_elt: The command element from the request. - @param session_data: Session data for this command execution. - @param action: The action being performed (EXECUTE, CANCEL, etc.) - @param node: The command node identifier. + @param page_data: Ad-Hoc page data. @return: Command results. @raise AdHocError: Something went wrong. """ - actions = session_data.setdefault("actions", []) - actions.append(action) - - if len(actions) == 1: - # it's our first request, we ask the desired new status - wokkel_form = data_form.Form("form", title=_("status selection")) - show_options = [ - data_form.Option(name, label) for name, label in list(SHOWS.items()) - ] - field = data_form.Field( - "list-single", "show", options=show_options, required=True - ) - wokkel_form.addField(field) - - return AdHocCallbackData( - form=DataForm.from_wokkel_form(wokkel_form), status=Status.EXECUTING - ) - - elif len(actions) == 2: - # we should have the answer here - try: - x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x")) - answer_form = data_form.Form.fromElement(x_elt) - show = answer_form["show"] - except (KeyError, StopIteration): - self.ad_hoc_error(Error.BAD_PAYLOAD) - if show not in SHOWS: - self.ad_hoc_error(Error.BAD_PAYLOAD) - if show == "disconnect": - self.host.disconnect(client.profile) - else: - self.host.presence_set(show=show, profile_key=client.profile) - - # job done, we can end the session - return AdHocCallbackData( - status=Status.COMPLETED, notes=[Note(text=_("Status updated"))] - ) + show = page_data.form.get_field("show", ListSingle).value + if show not in SHOWS: + raise AdHocError(Error.BAD_PAYLOAD, text=f"Invalid value: {show=}") + if show == "disconnect": + self.host.disconnect(client.profile) else: - raise AdHocError(Error.INTERNAL) + self.host.presence_set(show=show, profile_key=client.profile) + + return AdHocCallbackData( + status=Status.COMPLETED, notes=[Note(text=_("Status updated"))] + ) def _run(self, service_jid_s="", node="", profile_key=C.PROF_KEY_NONE): client = self.host.get_client(profile_key) @@ -1139,7 +1144,7 @@ class XEP_0050: def add_ad_hoc_command( self, client: SatXMPPEntity, - callback: Callable, + callback: AdHocCallback, label: str, node: str | None = None, features: list[str] | None = None, diff --git a/libervia/backend/plugins/plugin_xep_0264.py b/libervia/backend/plugins/plugin_xep_0264.py index 477b42ba..87cf3fc6 100644 --- a/libervia/backend/plugins/plugin_xep_0264.py +++ b/libervia/backend/plugins/plugin_xep_0264.py @@ -17,13 +17,14 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from typing import Literal +from twisted.words.protocols.jabber.xmlstream import XMPPHandler from libervia.backend.core.i18n import _ from libervia.backend.core.constants import Const as C from libervia.backend.core.log import getLogger log = getLogger(__name__) -from twisted.internet import threads -from twisted.python.failure import Failure +from twisted.internet import defer, threads from zope.interface import implementer @@ -44,11 +45,6 @@ from PIL import ImageFile ImageFile.LOAD_TRUNCATED_IMAGES = True -try: - from twisted.words.protocols.xmlstream import XMPPHandler -except ImportError: - from wokkel.subprotocols import XMPPHandler - MIME_TYPE = "image/jpeg" SAVE_FORMAT = "JPEG" # (cf. Pillow documentation) @@ -78,22 +74,22 @@ class XEP_0264(object): # TODO: use an other mechanism than BoB for bigger files SIZES = (SIZE_SMALL, SIZE_MEDIUM, SIZE_BIG) - def __init__(self, host): + def __init__(self, host) -> None: log.info(_("Plugin XEP_0264 initialization")) self.host = host host.trigger.add("XEP-0234_buildFileElement", self._add_file_thumbnails) host.trigger.add("XEP-0234_parseFileElement", self._get_file_thumbnails) - def get_handler(self, client): + def get_handler(self, client) -> "XEP_0264_handler": return XEP_0264_handler() ## triggers ## - def _add_file_thumbnails(self, client, file_elt, extra_args): + def _add_file_thumbnails(self, client, file_elt, extra_args) -> Literal[True]: try: thumbnails = extra_args["extra"][C.KEY_THUMBNAILS] except KeyError: - return + return True for thumbnail in thumbnails: thumbnail_elt = file_elt.addElement((NS_THUMBS, "thumbnail")) thumbnail_elt["uri"] = "cid:" + thumbnail["id"] @@ -103,7 +99,7 @@ class XEP_0264(object): thumbnail_elt["height"] = str(height) return True - def _get_file_thumbnails(self, client, file_elt, file_data): + def _get_file_thumbnails(self, client, file_elt, file_data) -> Literal[True]: thumbnails = [] for thumbnail_elt in file_elt.elements(NS_THUMBS, "thumbnail"): uri = thumbnail_elt["uri"] @@ -141,19 +137,26 @@ class XEP_0264(object): return hashlib.sha256(repr((image_uid, size)).encode()).hexdigest() def _blocking_gen_thumb( - self, source_path, size=None, max_age=None, image_uid=None, fix_orientation=True - ): + self, + source_path: str, + size: tuple[int, int] | None = None, + max_age: int | None = None, + image_uid: str | None = None, + fix_orientation: bool = True, + ) -> tuple[tuple[int, int], str]: """Generate a thumbnail for image This is a blocking method and must be executed in a thread params are the same as for [generate_thumbnail] + + @raise exception.DataError: Can't generate thumbnail. """ if size is None: size = self.SIZE_SMALL try: img = Image.open(source_path) - except IOError: - return Failure(exceptions.DataError("Can't open image")) + except IOError as e: + raise exceptions.DataError("Can't open image") from e img.thumbnail(size) if fix_orientation: @@ -170,25 +173,30 @@ class XEP_0264(object): return img.size, uid - def generate_thumbnail( - self, source_path, size=None, max_age=None, image_uid=None, fix_orientation=True - ): + async def generate_thumbnail( + self, + source_path: str, + size: int | None = None, + max_age: int | None = None, + image_uid: str | None = None, + fix_orientation: bool = True, + ) -> tuple[tuple[int, int], str]: """Generate a thumbnail of image - @param source_path(unicode): absolute path to source image - @param size(int, None): max size of the thumbnail + @param source_path: absolute path to source image + @param size: max size of the thumbnail can be one of self.SIZE_* None to use default value (i.e. self.SIZE_SMALL) - @param max_age(int, None): same as for [memory.cache.Cache.cache_data]) - @param image_uid(unicode, None): unique ID to identify the image + @param max_age: same as for [memory.cache.Cache.cache_data] + @param image_uid: unique ID to identify the image use hash whenever possible if None, source_path will be used - @param fix_orientation(bool): if True, fix orientation using EXIF data - @return D(tuple[tuple[int,int], unicode]): tuple with: + @param fix_orientation: if True, fix orientation using EXIF data + @return: tuple with: - size of the thumbnail - unique Id of the thumbnail """ - d = threads.deferToThread( + return await threads.deferToThread( self._blocking_gen_thumb, source_path, size, @@ -196,10 +204,6 @@ class XEP_0264(object): image_uid=image_uid, fix_orientation=fix_orientation, ) - d.addErrback( - lambda failure_: log.error("thumbnail generation error: {}".format(failure_)) - ) - return d @implementer(iwokkel.IDisco)