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.
This commit is contained in:
Goffi 2025-10-10 17:44:04 +02:00
commit 1b3593d95b
9 changed files with 625 additions and 863 deletions

View file

@ -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
"""

View file

@ -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)

View file

@ -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)
],
)

View file

@ -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)])

View file

@ -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."))]

View file

@ -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])

View file

@ -16,7 +16,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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:

View file

@ -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,

View file

@ -17,13 +17,14 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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)