#!/usr/bin/env python3 # SAT: a jabber client # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from collections import OrderedDict import html.entities import re from typing import Dict, Optional, Tuple, Union, Literal, overload, Iterable from xml.dom import NotFoundErr, minidom import xml.etree.ElementTree as ET from lxml import etree from twisted.internet import defer from twisted.words.protocols.jabber import jid from twisted.words.xish import domish from wokkel import data_form from sat.core import exceptions from sat.core.constants import Const as C from sat.core.i18n import _ from sat.core.log import getLogger log = getLogger(__name__) """This library help manage XML used in SàT (parameters, registration, etc)""" SAT_FORM_PREFIX = "SAT_FORM_" SAT_PARAM_SEPARATOR = "_XMLUI_PARAM_" # used to have unique elements names html_entity_re = re.compile(r"&([a-zA-Z]+?);") XML_ENTITIES = ("quot", "amp", "apos", "lt", "gt") # method to clean XHTML, receive raw unsecure XML or HTML, must return cleaned raw XHTML # this method must be set during runtime clean_xhtml = None # TODO: move XMLUI stuff in a separate module # TODO: rewrite this with lxml or ElementTree or domish.Element: it's complicated and difficult to maintain with current minidom implementation # Helper functions def _data_form_field_2_xmlui_data(field, read_only=False): """Get data needed to create an XMLUI's Widget from Wokkel's data_form's Field. The attribute field can be modified (if it's fixed and it has no value). @param field (data_form.Field): a field with attributes "value", "fieldType", "label" and "var" @param read_only (bool): if True and it makes sense, create a read only input widget @return: a tuple (widget_type, widget_args, widget_kwargs) """ widget_args = field.values or [None] widget_kwargs = {} if field.fieldType is None and field.ext_type is not None: # we have an extended field if field.ext_type == "xml": element = field.value if element.uri == C.NS_XHTML: widget_type = "xhtmlbox" widget_args[0] = element.toXml() widget_kwargs["read_only"] = read_only else: log.warning("unknown XML element, falling back to textbox") widget_type = "textbox" widget_args[0] = element.toXml() widget_kwargs["read_only"] = read_only else: raise exceptions.DataError("unknown extended type {ext_type}".format( ext_type = field.ext_type)) elif field.fieldType == "fixed" or field.fieldType is None: widget_type = "text" if field.value is None: if field.label is None: log.warning(_("Fixed field has neither value nor label, ignoring it")) field.value = "" else: field.value = field.label field.label = None widget_args = [field.value] elif field.fieldType == "text-single": widget_type = "string" widget_kwargs["read_only"] = read_only elif field.fieldType == "jid-single": widget_type = "jid_input" widget_kwargs["read_only"] = read_only elif field.fieldType == "text-multi": widget_type = "textbox" widget_args = ["\n".join(field.values)] widget_kwargs["read_only"] = read_only elif field.fieldType == "hidden": widget_type = "hidden" elif field.fieldType == "text-private": widget_type = "password" widget_kwargs["read_only"] = read_only elif field.fieldType == "boolean": widget_type = "bool" if widget_args[0] is None: widget_args = ["false"] widget_kwargs["read_only"] = read_only elif field.fieldType == "integer": widget_type = "integer" widget_kwargs["read_only"] = read_only elif field.fieldType == "list-single": widget_type = "list" widget_kwargs["options"] = [ (option.value, option.label or option.value) for option in field.options ] widget_kwargs["selected"] = widget_args widget_args = [] elif field.fieldType == "list-multi": widget_type = "list" widget_kwargs["options"] = [ (option.value, option.label or option.value) for option in field.options ] widget_kwargs["selected"] = widget_args widget_kwargs["styles"] = ["multi"] widget_args = [] else: log.error( "FIXME FIXME FIXME: Type [%s] is not managed yet by SàT" % field.fieldType ) widget_type = "string" widget_kwargs["read_only"] = read_only if field.var: widget_kwargs["name"] = field.var return widget_type, widget_args, widget_kwargs def data_form_2_widgets(form_ui, form, read_only=False, prepend=None, filters=None): """Complete an existing XMLUI with widget converted from XEP-0004 data forms. @param form_ui (XMLUI): XMLUI instance @param form (data_form.Form): Wokkel's implementation of data form @param read_only (bool): if True and it makes sense, create a read only input widget @param prepend(iterable, None): widgets to prepend to main LabelContainer if not None, must be an iterable of *args for add_widget. Those widgets will be added first to the container. @param filters(dict, None): if not None, a dictionary of callable: key is the name of the widget to filter the value is a callable, it will get form's XMLUI, widget's type, args and kwargs and must return widget's type, args and kwargs (which can be modified) This is especially useful to modify well known fields @return: the completed XMLUI instance """ if filters is None: filters = {} if form.instructions: form_ui.addText("\n".join(form.instructions), "instructions") form_ui.change_container("label") if prepend is not None: for widget_args in prepend: form_ui.add_widget(*widget_args) for field in form.fieldList: widget_type, widget_args, widget_kwargs = _data_form_field_2_xmlui_data( field, read_only ) try: widget_filter = filters[widget_kwargs["name"]] except KeyError: pass else: widget_type, widget_args, widget_kwargs = widget_filter( form_ui, widget_type, widget_args, widget_kwargs ) if widget_type != "hidden": label = field.label or field.var if label: form_ui.addLabel(label) else: form_ui.addEmpty() form_ui.add_widget(widget_type, *widget_args, **widget_kwargs) return form_ui def data_form_2_xmlui(form, submit_id, session_id=None, read_only=False): """Take a data form (Wokkel's XEP-0004 implementation) and convert it to a SàT XMLUI. @param form (data_form.Form): a Form instance @param submit_id (unicode): callback id to call when submitting form @param session_id (unicode): session id to return with the data @param read_only (bool): if True and it makes sense, create a read only input widget @return: XMLUI instance """ form_ui = XMLUI("form", "vertical", submit_id=submit_id, session_id=session_id) return data_form_2_widgets(form_ui, form, read_only=read_only) def data_form_2_data_dict(form: data_form.Form) -> dict: """Convert data form to a simple dict, easily serialisable see data_dict_2_data_form for a description of the format """ fields = [] data_dict = { "fields": fields } if form.formNamespace: data_dict["namespace"] = form.formNamespace for form_field in form.fieldList: field = {"type": form_field.fieldType} fields.append(field) for src_name, dest_name in ( ('var', 'name'), ('label', 'label'), ('value', 'value'), # FIXME: we probably should have only "values" ('values', 'values') ): value = getattr(form_field, src_name, None) if value: field[dest_name] = value if form_field.options: options = field["options"] = [] for form_opt in form_field.options: opt = {"value": form_opt.value} if form_opt.label: opt["label"] = form_opt.label options.append(opt) if form_field.fieldType is None and form_field.ext_type == "xml": if isinstance(form_field.value, domish.Element): if ((form_field.value.uri == C.NS_XHTML and form_field.value.name == "div")): field["type"] = "xhtml" if form_field.value.children: log.warning( "children are not managed for XHTML fields: " f"{form_field.value.toXml()}" ) return data_dict def data_dict_2_data_form(data_dict): """Convert serialisable dict of data to a data form The format of the dict is as follow: - an optional "namespace" key with form namespace - a mandatory "fields" key with list of fields as follow: - "type" is mostly the same as data_form.Field.fieldType - "name" is used to set the "var" attribute of data_form.Field - "label", and "value" follow same attribude in data_form.Field - "xhtml" is used for "xml" fields with child in the C.NS_XHTML namespace - "options" are list of dict with optional "label" and mandatory "value" following suitable attributes from data_form.Option - "required" is the same as data_form.Field.required """ # TODO: describe format fields = [] for field_data in data_dict["fields"]: field_type = field_data.get('type', 'text-single') kwargs = { "fieldType": field_type, "var": field_data["name"], "label": field_data.get('label'), "value": field_data.get("value"), "required": field_data.get("required") } if field_type == "xhtml": kwargs.update({ "fieldType": None, "ext_type": "xml", }) if kwargs["value"] is None: kwargs["value"] = domish.Element((C.NS_XHTML, "div")) elif "options" in field_data: kwargs["options"] = [ data_form.Option(o["value"], o.get("label")) for o in field_data["options"] ] field = data_form.Field(**kwargs) fields.append(field) return data_form.Form( "form", formNamespace=data_dict.get("namespace"), fields=fields ) def data_form_elt_result_2_xmlui_data(form_xml): """Parse a data form result (not parsed by Wokkel's XEP-0004 implementation). The raw data form is used because Wokkel doesn't manage result items parsing yet. @param form_xml (domish.Element): element of the data form @return: a couple (headers, result_list): - headers (dict{unicode: unicode}): form headers (field labels and types) - xmlui_data (list[tuple]): list of (widget_type, widget_args, widget_kwargs) """ headers = OrderedDict() try: reported_elt = next(form_xml.elements("jabber:x:data", "reported")) except StopIteration: raise exceptions.DataError( "Couldn't find expected tag in %s" % form_xml.toXml() ) for elt in reported_elt.elements(): if elt.name != "field": raise exceptions.DataError("Unexpected tag") name = elt["var"] label = elt.attributes.get("label", "") type_ = elt.attributes.get("type") headers[name] = (label, type_) if not headers: raise exceptions.DataError("No reported fields (see XEP-0004 §3.4)") xmlui_data = [] item_elts = form_xml.elements("jabber:x:data", "item") for item_elt in item_elts: for elt in item_elt.elements(): if elt.name != "field": log.warning("Unexpected tag (%s)" % elt.name) continue field = data_form.Field.fromElement(elt) xmlui_data.append(_data_form_field_2_xmlui_data(field)) return headers, xmlui_data def xmlui_data_2_advanced_list(xmlui, headers, xmlui_data): """Take a raw data form result (not parsed by Wokkel's XEP-0004 implementation) and convert it to an advanced list. The raw data form is used because Wokkel doesn't manage result items parsing yet. @param xmlui (XMLUI): the XMLUI where the AdvancedList will be added @param headers (dict{unicode: unicode}): form headers (field labels and types) @param xmlui_data (list[tuple]): list of (widget_type, widget_args, widget_kwargs) @return: the completed XMLUI instance """ adv_list = AdvancedListContainer( xmlui, headers=headers, columns=len(headers), parent=xmlui.current_container ) xmlui.change_container(adv_list) for widget_type, widget_args, widget_kwargs in xmlui_data: xmlui.add_widget(widget_type, *widget_args, **widget_kwargs) return xmlui def data_form_result_2_advanced_list(xmlui, form_xml): """Take a raw data form result (not parsed by Wokkel's XEP-0004 implementation) and convert it to an advanced list. The raw data form is used because Wokkel doesn't manage result items parsing yet. @param xmlui (XMLUI): the XMLUI where the AdvancedList will be added @param form_xml (domish.Element): element of the data form @return: the completed XMLUI instance """ headers, xmlui_data = data_form_elt_result_2_xmlui_data(form_xml) xmlui_data_2_advanced_list(xmlui, headers, xmlui_data) def data_form_elt_result_2_xmlui(form_elt, session_id=None): """Take a raw data form (not parsed by XEP-0004) and convert it to a SàT XMLUI. The raw data form is used because Wokkel doesn't manage result items parsing yet. @param form_elt (domish.Element): element of the data form @param session_id (unicode): session id to return with the data @return: XMLUI instance """ xml_ui = XMLUI("window", "vertical", session_id=session_id) try: data_form_result_2_advanced_list(xml_ui, form_elt) except exceptions.DataError: parsed_form = data_form.Form.fromElement(form_elt) data_form_2_widgets(xml_ui, parsed_form, read_only=True) return xml_ui def data_form_result_2_xmlui(result_form, base_form, session_id=None, prepend=None, filters=None, read_only=True): """Convert data form result to SàT XMLUI. @param result_form (data_form.Form): result form to convert @param base_form (data_form.Form): initial form (i.e. of form type "form") this one is necessary to reconstruct options when needed (e.g. list elements) @param session_id (unicode): session id to return with the data @param prepend: same as for [data_form_2_widgets] @param filters: same as for [data_form_2_widgets] @param read_only: same as for [data_form_2_widgets] @return: XMLUI instance """ # we deepcopy the form because _data_form_field_2_xmlui_data can modify the value # FIXME: check if it's really important, the only modified value seems to be # the replacement of None by "" on fixed fields # form = deepcopy(result_form) form = result_form for name, field in form.fields.items(): try: base_field = base_form.fields[name] except KeyError: continue field.options = base_field.options[:] xml_ui = XMLUI("window", "vertical", session_id=session_id) data_form_2_widgets(xml_ui, form, read_only=read_only, prepend=prepend, filters=filters) return xml_ui def _clean_value(value): """Workaround method to avoid DBus types with D-Bus bridge. @param value: value to clean @return: value in a non DBus type (only clean string yet) """ # XXX: must be removed when DBus types will no cause problems anymore # FIXME: should be cleaned inside D-Bus bridge itself if isinstance(value, str): return str(value) return value def xmlui_result_2_data_form_result(xmlui_data): """ Extract form data from a XMLUI return. @param xmlui_data (dict): data returned by frontends for XMLUI form @return: dict of data usable by Wokkel's data form """ ret = {} for key, value in xmlui_data.items(): if not key.startswith(SAT_FORM_PREFIX): continue if isinstance(value, str): if "\n" in value: # data form expects multi-lines text to be in separated values value = value.split('\n') elif "\t" in value: # FIXME: workaround to handle multiple values. Proper serialisation must # be done in XMLUI value = value.split("\t") ret[key[len(SAT_FORM_PREFIX) :]] = _clean_value(value) return ret def form_escape(name): """Return escaped name for forms. @param name (unicode): form name @return: unicode """ return "%s%s" % (SAT_FORM_PREFIX, name) def is_xmlui_cancelled(raw_xmlui): """Tell if an XMLUI has been cancelled by checking raw XML""" return C.bool(raw_xmlui.get('cancelled', C.BOOL_FALSE)) def xmlui_result_to_elt(xmlui_data): """Construct result domish.Element from XMLUI result. @param xmlui_data (dict): data returned by frontends for XMLUI form @return: domish.Element """ form = data_form.Form("submit") form.makeFields(xmlui_result_2_data_form_result(xmlui_data)) return form.toElement() def tuple_list_2_data_form(values): """Convert a list of tuples (name, value) to a wokkel submit data form. @param values (list): list of tuples @return: data_form.Form """ form = data_form.Form("submit") for value in values: field = data_form.Field(var=value[0], value=value[1]) form.addField(field) return form def params_xml_2_xmlui(xml): """Convert the XML for parameter to a SàT XML User Interface. @param xml (unicode) @return: XMLUI """ # TODO: refactor params and use Twisted directly to parse XML params_doc = minidom.parseString(xml.encode("utf-8")) top = params_doc.documentElement if top.nodeName != "params": raise exceptions.DataError(_("INTERNAL ERROR: parameters xml not valid")) param_ui = XMLUI("param", "tabs") tabs_cont = param_ui.current_container for category in top.getElementsByTagName("category"): category_name = category.getAttribute("name") label = category.getAttribute("label") if not category_name: raise exceptions.DataError( _("INTERNAL ERROR: params categories must have a name") ) tabs_cont.add_tab(category_name, label=label, container=LabelContainer) for param in category.getElementsByTagName("param"): widget_kwargs = {} param_name = param.getAttribute("name") param_label = param.getAttribute("label") type_ = param.getAttribute("type") if not param_name and type_ != "text": raise exceptions.DataError(_("INTERNAL ERROR: params must have a name")) value = param.getAttribute("value") or None callback_id = param.getAttribute("callback_id") or None if type_ == "list": options, selected = _params_get_list_options(param) widget_kwargs["options"] = options widget_kwargs["selected"] = selected widget_kwargs["styles"] = ["extensible"] elif type_ == "jids_list": widget_kwargs["jids"] = _params_get_list_jids(param) if type_ in ("button", "text"): param_ui.addEmpty() value = param_label else: param_ui.addLabel(param_label or param_name) if value: widget_kwargs["value"] = value if callback_id: widget_kwargs["callback_id"] = callback_id others = [ "%s%s%s" % (category_name, SAT_PARAM_SEPARATOR, other.getAttribute("name")) for other in category.getElementsByTagName("param") if other.getAttribute("type") != "button" ] widget_kwargs["fields_back"] = others widget_kwargs["name"] = "%s%s%s" % ( category_name, SAT_PARAM_SEPARATOR, param_name, ) param_ui.add_widget(type_, **widget_kwargs) return param_ui.toXml() def _params_get_list_options(param): """Retrieve the options for list element. The