- Add the `txtorcon` optional dependency, used with `tor` extra. - Add settings for Tor. - Use Tor endpoint when an `.onion` XMPP domain is used, of when Tor is explicitely enabled in conf. - Disable WebRTC, for now it's the safer option as WebRTC can leak IP. It should be possible to use it over Tor by forcing the use of a .onion relay, but this is to be explored later (A/V should be to slow to be usable, but that stay relevant for file transfer or other WebRTC powered things). - A new `treq` instance using Tor WebAgent is used when Tor is enabled. Properties has been added to SatXMPPEntity to conveniently get the right instance. - Another `tor_mode` property has been added with corresponding bridge methods, to see if Tor is activated and in which mode for a given profile. For now there are only two modes (disabled, and enabled with WebRTC disabled), but in the future it may be possible to activate WebRTC over Tor, or to use Tor only to connect to XMPP server, while using clearnet for other connections. - Tor dedicated models have been added, and bridge methods have been added to build and get them. - Fix tests. rel 473
508 lines
18 KiB
Python
508 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# Copyright (C) 2009-2022 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 <http://www.gnu.org/licenses/>.
|
|
|
|
from abc import ABC, abstractmethod
|
|
from functools import partial
|
|
import mimetypes
|
|
from pathlib import Path
|
|
from typing import (
|
|
Any,
|
|
ClassVar,
|
|
Dict,
|
|
List,
|
|
Optional,
|
|
Self,
|
|
Tuple,
|
|
Union,
|
|
cast,
|
|
)
|
|
|
|
from pydantic import BaseModel, Field
|
|
from treq import collect
|
|
from twisted.internet import defer
|
|
from twisted.words.xish import domish
|
|
|
|
from libervia.backend.core import exceptions
|
|
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.log import getLogger
|
|
from libervia.backend.plugins.plugin_xep_0103 import URLData, XEP_0103
|
|
from libervia.backend.plugins.plugin_xep_0358 import JinglePub, XEP_0358
|
|
from libervia.backend.plugins.plugin_xep_0446 import FileMetadata, XEP_0446
|
|
from libervia.backend.tools import stream
|
|
|
|
log = getLogger(__name__)
|
|
|
|
|
|
PLUGIN_INFO = {
|
|
C.PI_NAME: "Stateless File Sharing",
|
|
C.PI_IMPORT_NAME: "XEP-0447",
|
|
C.PI_TYPE: "XEP",
|
|
C.PI_MODES: C.PLUG_MODE_BOTH,
|
|
C.PI_PROTOCOLS: ["XEP-0447"],
|
|
C.PI_DEPENDENCIES: [
|
|
"XEP-0103",
|
|
"XEP-0334",
|
|
"XEP-0358",
|
|
"XEP-0446",
|
|
"ATTACH",
|
|
"DOWNLOAD",
|
|
],
|
|
C.PI_RECOMMENDATIONS: ["XEP-0363"],
|
|
C.PI_MAIN: "XEP_0447",
|
|
C.PI_HANDLER: "no",
|
|
C.PI_DESCRIPTION: _("""Implementation of XEP-0447 (Stateless File Sharing)"""),
|
|
}
|
|
|
|
NS_SFS = "urn:xmpp:sfs:0"
|
|
|
|
|
|
class Source(ABC, BaseModel):
|
|
|
|
type: ClassVar[str]
|
|
encrypted: ClassVar[bool] = False
|
|
|
|
def __init_subclass__(cls) -> None:
|
|
super().__init_subclass__()
|
|
if not hasattr(cls, "type"):
|
|
raise TypeError(
|
|
f'Can\'t instantiate {cls.__name__} without "type" class attribute.'
|
|
)
|
|
|
|
@classmethod
|
|
@abstractmethod
|
|
def from_element(cls, element: domish.Element) -> Self:
|
|
"""Parse an element and return corresponding model
|
|
|
|
@param element: element to parse
|
|
@raise exceptions.DataError: the element is invalid
|
|
"""
|
|
|
|
@abstractmethod
|
|
def to_element(self) -> domish.Element:
|
|
"""Convert model to an element
|
|
|
|
@return: domish.Element representing the model
|
|
"""
|
|
|
|
|
|
class FileSharing(BaseModel):
|
|
"""
|
|
Model for handling XEP-0447 <file-sharing> element.
|
|
"""
|
|
|
|
file: FileMetadata
|
|
sources: list[Source]
|
|
disposition: str | None = Field(
|
|
default=None,
|
|
description="Disposition of the file, either 'attachment' or 'inline'.",
|
|
)
|
|
id: str | None = Field(
|
|
default=None, description="Unique identifier for the file-sharing element."
|
|
)
|
|
_sfs: "XEP_0447 | None" = None
|
|
|
|
def to_element(self) -> domish.Element:
|
|
"""Build the <file-sharing> element from this instance's data.
|
|
|
|
@return: <file-sharing> element.
|
|
"""
|
|
file_sharing_elt = domish.Element((NS_SFS, "file-sharing"))
|
|
|
|
if self.disposition:
|
|
file_sharing_elt["disposition"] = self.disposition
|
|
|
|
if self.id:
|
|
file_sharing_elt["id"] = self.id
|
|
|
|
file_sharing_elt.addChild(self.file.to_element())
|
|
|
|
sources_elt = file_sharing_elt.addElement("sources")
|
|
for source in self.sources:
|
|
sources_elt.addChild(source.to_element())
|
|
|
|
return file_sharing_elt
|
|
|
|
@classmethod
|
|
def from_element(cls, file_sharing_elt: domish.Element) -> Self:
|
|
"""Create a FileSharing instance from a <file-sharing> element or its parent.
|
|
|
|
@param file_sharing_elt: The <file-sharing> element or a parent element.
|
|
@return: FileSharing instance.
|
|
@raise exceptions.NotFound: If the <file-sharing> element is not found.
|
|
"""
|
|
assert cls._sfs is not None
|
|
if file_sharing_elt.uri != NS_SFS or file_sharing_elt.name != "file-sharing":
|
|
child_file_sharing_elt = next(
|
|
file_sharing_elt.elements(NS_SFS, "file-sharing"), None
|
|
)
|
|
if child_file_sharing_elt is None:
|
|
raise exceptions.NotFound("<file-sharing> element not found")
|
|
else:
|
|
file_sharing_elt = child_file_sharing_elt
|
|
|
|
kwargs = {}
|
|
disposition = file_sharing_elt.getAttribute("disposition")
|
|
if disposition:
|
|
kwargs["disposition"] = disposition
|
|
|
|
file_id = file_sharing_elt.getAttribute("id")
|
|
if file_id:
|
|
kwargs["id"] = file_id
|
|
|
|
kwargs["file"] = FileMetadata.from_element(file_sharing_elt)
|
|
kwargs["sources"] = cls._sfs.parse_sources_elt(file_sharing_elt)
|
|
|
|
return cls(**kwargs)
|
|
|
|
|
|
class URLDataSource(URLData, Source):
|
|
type = "url"
|
|
|
|
@classmethod
|
|
def from_element(cls, element: domish.Element) -> Self:
|
|
return super().from_element(element)
|
|
|
|
def to_element(self) -> domish.Element:
|
|
return super().to_element()
|
|
|
|
|
|
class JinglePubSource(JinglePub, Source):
|
|
type = "jingle"
|
|
|
|
@classmethod
|
|
def from_element(cls, element: domish.Element) -> Self:
|
|
return super().from_element(element)
|
|
|
|
def to_element(self) -> domish.Element:
|
|
return super().to_element()
|
|
|
|
|
|
class XEP_0447:
|
|
namespace = NS_SFS
|
|
|
|
def __init__(self, host):
|
|
self.host = host
|
|
log.info(_("XEP-0447 (Stateless File Sharing) plugin initialization"))
|
|
host.register_namespace("sfs", NS_SFS)
|
|
FileSharing._sfs = self
|
|
self._sources_handlers: dict[tuple[str, str], type[Source]] = {}
|
|
self._u = cast(XEP_0103, host.plugins["XEP-0103"])
|
|
self._jp = cast(XEP_0358, host.plugins["XEP-0358"])
|
|
self._hints = host.plugins["XEP-0334"]
|
|
self._m = cast(XEP_0446, host.plugins["XEP-0446"])
|
|
self._http_upload = host.plugins.get("XEP-0363")
|
|
self._attach = host.plugins["ATTACH"]
|
|
self._attach.register(self.can_handle_attachment, self.attach, priority=1000)
|
|
self.register_source(
|
|
self._u.namespace,
|
|
"url-data",
|
|
URLDataSource,
|
|
)
|
|
self.register_source(
|
|
self._jp.namespace,
|
|
"jinglepub",
|
|
JinglePubSource,
|
|
)
|
|
host.plugins["DOWNLOAD"].register_download_handler(
|
|
self._u.namespace, self.download
|
|
)
|
|
host.trigger.add("message_received", self._message_received_trigger)
|
|
|
|
def register_source(
|
|
self,
|
|
namespace: str,
|
|
element_name: str,
|
|
source: type[Source],
|
|
) -> None:
|
|
"""Register a handler for file source
|
|
|
|
@param namespace: namespace of the element supported
|
|
@param element_name: name of the element supported
|
|
@param callback: method to call to parse the element
|
|
get the matching element as argument, must return the parsed data
|
|
@param encrypted: if True, the source is encrypted (the transmitting channel
|
|
should then be end2end encrypted to avoir leaking decrypting data to servers).
|
|
"""
|
|
key = (namespace, element_name)
|
|
if key in self._sources_handlers:
|
|
raise exceptions.ConflictError(
|
|
f"There is already a resource handler for namespace {namespace!r} and "
|
|
f"name {element_name!r}"
|
|
)
|
|
self._sources_handlers[key] = source
|
|
|
|
async def download(
|
|
self,
|
|
client: SatXMPPEntity,
|
|
attachment: Dict[str, Any],
|
|
source: Dict[str, Any],
|
|
dest_path: Union[Path, str],
|
|
extra: Optional[Dict[str, Any]] = None,
|
|
) -> Tuple[str, defer.Deferred]:
|
|
# TODO: handle url-data headers
|
|
if extra is None:
|
|
extra = {}
|
|
try:
|
|
download_url = source["url"]
|
|
except KeyError:
|
|
raise ValueError(f"{source} has missing URL")
|
|
|
|
if extra.get("ignore_tls_errors", False):
|
|
log.warning("TLS certificate check disabled, this is highly insecure")
|
|
treq_client = client.treq_ignore_tls
|
|
else:
|
|
treq_client = client.treq
|
|
|
|
try:
|
|
file_size = int(attachment["size"])
|
|
except (KeyError, ValueError):
|
|
head_data = await treq_client.head(download_url)
|
|
file_size = int(head_data.headers.getRawHeaders("content-length")[0])
|
|
|
|
file_obj = stream.SatFile(
|
|
self.host,
|
|
client,
|
|
dest_path,
|
|
mode="wb",
|
|
size=file_size,
|
|
)
|
|
|
|
progress_id = file_obj.uid
|
|
|
|
resp = await treq_client.get(download_url, unbuffered=True)
|
|
if resp.code == 200:
|
|
d = collect(resp, file_obj.write)
|
|
d.addCallback(lambda __: file_obj.close())
|
|
else:
|
|
d = defer.Deferred()
|
|
self.host.plugins["DOWNLOAD"].errback_download(file_obj, d, resp)
|
|
return progress_id, d
|
|
|
|
async def can_handle_attachment(self, client, data):
|
|
if self._http_upload is None:
|
|
return False
|
|
try:
|
|
await self._http_upload.get_http_upload_entity(client)
|
|
except exceptions.NotFound:
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
def get_sources_elt(
|
|
self, children: Optional[List[domish.Element]] = None
|
|
) -> domish.Element:
|
|
"""Generate <sources> element"""
|
|
sources_elt = domish.Element((NS_SFS, "sources"))
|
|
if children:
|
|
for child in children:
|
|
sources_elt.addChild(child)
|
|
return sources_elt
|
|
|
|
def get_file_sharing_elt(
|
|
self,
|
|
sources: List[Dict[str, Any]],
|
|
disposition: Optional[str] = None,
|
|
name: Optional[str] = None,
|
|
media_type: Optional[str] = None,
|
|
desc: Optional[str] = None,
|
|
size: Optional[int] = None,
|
|
file_hash: Optional[Tuple[str, str]] = None,
|
|
date: Optional[Union[float, int]] = None,
|
|
width: Optional[int] = None,
|
|
height: Optional[int] = None,
|
|
length: Optional[int] = None,
|
|
thumbnail: Optional[str] = None,
|
|
**kwargs,
|
|
) -> domish.Element:
|
|
"""Generate the <file-sharing/> element
|
|
|
|
@param extra: extra metadata describing how to access the URL
|
|
@return: ``<sfs/>`` element
|
|
"""
|
|
file_sharing_elt = domish.Element((NS_SFS, "file-sharing"))
|
|
if disposition is not None:
|
|
file_sharing_elt["disposition"] = disposition
|
|
if media_type is None and name:
|
|
media_type = mimetypes.guess_type(name, strict=False)[0]
|
|
file_sharing_elt.addChild(
|
|
self._m.generate_file_metadata(
|
|
name=name,
|
|
media_type=media_type,
|
|
desc=desc,
|
|
size=size,
|
|
file_hash=file_hash,
|
|
date=date,
|
|
width=width,
|
|
height=height,
|
|
length=length,
|
|
thumbnail=thumbnail,
|
|
).to_element()
|
|
)
|
|
sources_elt = self.get_sources_elt()
|
|
file_sharing_elt.addChild(sources_elt)
|
|
for source_data in sources:
|
|
if "url" in source_data:
|
|
sources_elt.addChild(
|
|
self._u.generate_url_data(**source_data).to_element()
|
|
)
|
|
else:
|
|
raise NotImplementedError(f"source data not implemented: {source_data}")
|
|
|
|
return file_sharing_elt
|
|
|
|
def parse_sources_elt(self, sources_elt: domish.Element) -> List[Source]:
|
|
"""Parse <sources/> element
|
|
|
|
@param sources_elt: <sources/> element, or a direct parent element
|
|
@return: list of found sources data
|
|
@raise: exceptions.NotFound: Can't find <sources/> element
|
|
"""
|
|
if sources_elt.name != "sources" or sources_elt.uri != NS_SFS:
|
|
try:
|
|
sources_elt = next(sources_elt.elements(NS_SFS, "sources"))
|
|
except StopIteration:
|
|
raise exceptions.NotFound(
|
|
f"<sources/> element is missing: {sources_elt.toXml()}"
|
|
)
|
|
sources = []
|
|
for elt in sources_elt.elements():
|
|
if not elt.uri:
|
|
log.warning("ignoring source element {elt.toXml()}")
|
|
continue
|
|
key = (elt.uri, elt.name)
|
|
try:
|
|
source_handler = self._sources_handlers[key]
|
|
except KeyError:
|
|
log.warning(f"unmanaged file sharing element: {elt.toXml}")
|
|
continue
|
|
else:
|
|
source = source_handler.from_element(elt)
|
|
sources.append(source)
|
|
return sources
|
|
|
|
def parse_file_sharing_elt(self, file_sharing_elt: domish.Element) -> Dict[str, Any]:
|
|
"""Parse <file-sharing/> element and return file-sharing data
|
|
|
|
@param file_sharing_elt: <file-sharing/> element
|
|
@return: file-sharing data. It a dict whose keys correspond to
|
|
[get_file_sharing_elt] parameters
|
|
"""
|
|
if file_sharing_elt.name != "file-sharing" or file_sharing_elt.uri != NS_SFS:
|
|
try:
|
|
file_sharing_elt = next(file_sharing_elt.elements(NS_SFS, "file-sharing"))
|
|
except StopIteration:
|
|
raise exceptions.NotFound
|
|
try:
|
|
data = self._m.parse_file_metadata_elt(file_sharing_elt).model_dump()
|
|
except exceptions.NotFound:
|
|
data = {}
|
|
disposition = file_sharing_elt.getAttribute("disposition")
|
|
if disposition is not None:
|
|
data["disposition"] = disposition
|
|
try:
|
|
data["sources"] = self.parse_sources_elt(file_sharing_elt)
|
|
except exceptions.NotFound as e:
|
|
raise ValueError(str(e))
|
|
|
|
return data
|
|
|
|
def _add_file_sharing_attachments(
|
|
self, client: SatXMPPEntity, message_elt: domish.Element, data: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""Check <message> for a shared file, and add it as an attachment"""
|
|
# XXX: XEP-0447 doesn't support several attachments in a single message, for now
|
|
# however that should be fixed in future version, and so we accept several
|
|
# <file-sharing> element in a message.
|
|
for file_sharing_elt in message_elt.elements(NS_SFS, "file-sharing"):
|
|
attachment = self.parse_file_sharing_elt(message_elt)
|
|
|
|
if any(
|
|
s.get(C.MESS_KEY_ENCRYPTED, False) for s in attachment["sources"]
|
|
) and client.encryption.isEncrypted(data):
|
|
# we don't add the encrypted flag if the message itself is not encrypted,
|
|
# because the decryption key is part of the link, so sending it over
|
|
# unencrypted channel is like having no encryption at all.
|
|
attachment[C.MESS_KEY_ENCRYPTED] = True
|
|
|
|
attachments = data["extra"].setdefault(C.KEY_ATTACHMENTS, [])
|
|
attachments.append(attachment)
|
|
|
|
return data
|
|
|
|
async def attach(self, client, data):
|
|
# XXX: for now, XEP-0447 only allow to send one file per <message/>, thus we need
|
|
# to send each file in a separate message
|
|
attachments = data["extra"][C.KEY_ATTACHMENTS]
|
|
if not data["message"] or data["message"] == {"": ""}:
|
|
extra_attachments = attachments[1:]
|
|
del attachments[1:]
|
|
else:
|
|
# we have a message, we must send first attachment separately
|
|
extra_attachments = attachments[:]
|
|
attachments.clear()
|
|
del data["extra"][C.KEY_ATTACHMENTS]
|
|
|
|
if attachments:
|
|
if len(attachments) > 1:
|
|
raise exceptions.InternalError(
|
|
"There should not be more that one attachment at this point"
|
|
)
|
|
await self._attach.upload_files(client, data)
|
|
self._hints.add_hint_elements(data["xml"], [self._hints.HINT_STORE])
|
|
for attachment in attachments:
|
|
try:
|
|
file_hash = (attachment["hash_algo"], attachment["hash"])
|
|
except KeyError:
|
|
file_hash = None
|
|
file_sharing_elt = self.get_file_sharing_elt(
|
|
[{"url": attachment["url"]}],
|
|
name=attachment.get("name"),
|
|
size=attachment.get("size"),
|
|
desc=attachment.get("desc"),
|
|
media_type=attachment.get("media_type"),
|
|
file_hash=file_hash,
|
|
)
|
|
data["xml"].addChild(file_sharing_elt)
|
|
|
|
for attachment in extra_attachments:
|
|
# we send all remaining attachment in a separate message
|
|
await client.sendMessage(
|
|
to_jid=data["to"],
|
|
message={"": ""},
|
|
subject=data["subject"],
|
|
mess_type=data["type"],
|
|
extra={C.KEY_ATTACHMENTS: [attachment]},
|
|
)
|
|
|
|
if (
|
|
not data["extra"]
|
|
and (not data["message"] or data["message"] == {"": ""})
|
|
and not data["subject"]
|
|
):
|
|
# nothing left to send, we can cancel the message
|
|
raise exceptions.CancelError("Cancelled by XEP_0447 attachment handling")
|
|
|
|
def _message_received_trigger(self, client, message_elt, post_treat):
|
|
# we use a post_treat callback instead of "message_parse" trigger because we need
|
|
# to check if the "encrypted" flag is set to decide if we add the same flag to the
|
|
# attachment
|
|
post_treat.addCallback(
|
|
partial(self._add_file_sharing_attachments, client, message_elt)
|
|
)
|
|
return True
|