#!/usr/bin/env python3
# Copyright (C) 2009-2026 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 abc import ABC, abstractmethod
from dataclasses import dataclass, field
from functools import partial
import mimetypes
from pathlib import Path
import time
from typing import Any, ClassVar, NamedTuple, Self, cast
from pydantic import BaseModel, Field
import shortuuid
from sqlalchemy.orm.attributes import flag_modified
from treq import collect
from twisted.internet import defer
from twisted.web.http_headers import Headers
from twisted.web.iweb import IResponse
from twisted.words.protocols.jabber import jid
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 MessageData, SatXMPPEntity
from libervia.backend.core.i18n import _
from libervia.backend.core.log import getLogger
from libervia.backend.plugins.plugin_misc_attach import AttachPlugin
from libervia.backend.plugins.plugin_xep_0066 import XEP_0066
from libervia.backend.plugins.plugin_xep_0103 import URLData, XEP_0103
from libervia.backend.plugins.plugin_xep_0334 import XEP_0334
from libervia.backend.plugins.plugin_xep_0358 import JinglePub, XEP_0358
from libervia.backend.plugins.plugin_xep_0363 import XEP_0363
from libervia.backend.plugins.plugin_xep_0367 import AttachedTo, XEP_0367
from libervia.backend.plugins.plugin_xep_0428 import XEP_0428
from libervia.backend.plugins.plugin_xep_0446 import FileMetadata, XEP_0446
from libervia.backend.tools import stream
from libervia.backend.tools.common import data_format
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-0066", "XEP-0363", "XEP-0367", "XEP-0428"],
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 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 element from this instance's data.
@return: 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 element or its parent.
@param file_sharing_elt: The element or a parent element.
@return: FileSharing instance.
@raise exceptions.NotFound: If the 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(" 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 LegacyAttachment(NamedTuple):
"""Single attachment entry for a backward-compatibility message.
Keys used when building elements for legacy clients.
"""
file_id: str
url: str
class LegacyAttachments(NamedTuple):
"""Data stored for a pending MUC legacy attach message.
Kept until the MUC echo arrives with a stanza-id, after which backward-compatibility
messages are sent using that stanza-id as the target.
"""
mess_data: dict[str, Any]
attachments: list[LegacyAttachment]
timestamp: float
@dataclass
class SFSClientData:
"""Per-client data for XEP-0447 plugin.
Stores pending legacy attach entries for MUC messages (waiting for stanza-id echo).
"""
# origin_id -> LegacyEntry
pending_legacy: dict[str, LegacyAttachments] = field(default_factory=dict)
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 = cast(XEP_0334, host.plugins["XEP-0334"])
self._m = cast(XEP_0446, host.plugins["XEP-0446"])
self._http_upload = cast(XEP_0363 | None, host.plugins.get("XEP-0363"))
self._oob = cast(XEP_0066 | None, host.plugins.get("XEP-0066"))
self._ma = cast(XEP_0367 | None, host.plugins.get("XEP-0367"))
self._fb = cast(XEP_0428 | None, host.plugins.get("XEP-0428"))
self._attach = cast(AttachPlugin, 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)
host.trigger.add("message_parse", self._message_parse_trigger)
host.trigger.add("message_sent", self._message_sent_trigger)
host.trigger.add("disconnected", self._on_connection_closed)
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: Path | str,
extra: dict[str, Any] | None = 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 = cast(IResponse, await treq_client.head(download_url))
content_length = cast(Headers, head_data.headers).getRawHeaders(
"content-length"
)
if content_length is None:
raise ValueError(
f"No content-length header in HEAD response for {download_url}"
)
file_size = int(content_length[0])
file_obj = stream.SatFile(
self.host,
client,
dest_path,
mode="wb",
size=file_size,
)
progress_id = file_obj.uid
resp = cast(IResponse, 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: SatXMPPEntity, data: dict[str, Any]
) -> bool:
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: list[domish.Element] | None = None
) -> domish.Element:
"""Generate 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: str | None = None,
file_id: str | None = None,
name: str | None = None,
media_type: str | None = None,
desc: str | None = None,
size: int | None = None,
file_hash: tuple[str, str] | None = None,
date: float | int | None = None,
width: int | None = None,
height: int | None = None,
length: int | None = None,
thumbnail: str | None = None,
**kwargs,
) -> domish.Element:
"""Generate the element
@param sources: list of source data dicts
@param disposition: disposition of the file
@param file_id: unique identifier for the file-sharing element
@param name: filename
@param media_type: media type
@param desc: description
@param size: file size
@param file_hash: tuple of (hash_algo, hash)
@param date: file date
@param width: width (for images)
@param height: height (for images)
@param length: length (for audio/video)
@param thumbnail: thumbnail data
@return: ```` element
"""
file_sharing_elt = domish.Element((NS_SFS, "file-sharing"))
if disposition is not None:
file_sharing_elt["disposition"] = disposition
if file_id is not None:
file_sharing_elt["id"] = file_id
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 element
@param sources_elt: element, or a direct parent element
@return: list of found sources data
@raise: exceptions.NotFound: Can't find 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" 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 element and return file-sharing data
@param file_sharing_elt: 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
file_id = file_sharing_elt.getAttribute("id")
if file_id is not None:
data["id"] = file_id
try:
data["sources"] = [
source.model_dump() for source in self.parse_sources_elt(file_sharing_elt)
]
except exceptions.NotFound:
# XEP-0447 allows without (e.g. upload in progress)
data["sources"] = []
return data
def _add_file_sharing_attachments(
self, client: SatXMPPEntity, message_elt: domish.Element, data: dict[str, Any]
) -> dict[str, Any]:
"""Check for shared files, and add them as attachments."""
# check for pending legacy attachments
origin_id = data["extra"].get("origin_id")
if origin_id:
sfs_data = client.get_plugin_data("XEP-0447", SFSClientData)
if origin_id in sfs_data.pending_legacy:
stanza_id = data["extra"].get("stanza_id")
if stanza_id:
entry_data = sfs_data.pending_legacy.pop(origin_id)
for entry in entry_data.attachments:
d = defer.ensureDeferred(
self._send_legacy_attach_message(
client,
entry_data.mess_data,
stanza_id,
entry.file_id,
entry.url,
)
)
d.addErrback(
lambda __: log.exception(
f"Error sending legacy attach message"
)
)
else:
log.warning(
f"MUC message for origin_id={origin_id!r} has no stanza-id, "
"skipping legacy attach messages."
)
del sfs_data.pending_legacy[origin_id]
# purge expired entries
now = time.monotonic()
expired = [
k for k, v in sfs_data.pending_legacy.items() if now - v.timestamp > 60
]
if expired:
log.warning(f"Purging {len(expired)} expired legacy attach entries")
for k in expired:
del sfs_data.pending_legacy[k]
for file_sharing_elt in message_elt.elements(NS_SFS, "file-sharing"):
attachment = self.parse_file_sharing_elt(file_sharing_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: SatXMPPEntity, data: dict[str, Any]) -> None:
"""Attach files to a message.
All files are uploaded and included in a single message with multiple
elements, each identified by a unique id.
Backward-compatible attach messages are sent for legacy clients.
"""
attachments = data["extra"][C.KEY_ATTACHMENTS]
# upload all files
await self._attach.upload_files(client, data)
self._hints.add_hint_elements(data["xml"], [self._hints.HINT_STORE])
# build element for each attachment
for attachment in attachments:
file_id = attachment.get("name") or shortuuid.uuid()
try:
file_hash = (attachment["hash_algo"], attachment["hash"])
except KeyError:
file_hash = None
file_sharing_elt = self.get_file_sharing_elt(
[{"url": attachment["url"]}],
file_id=file_id,
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)
# store legacy info for deferred sending after main message
if self._oob is not None and self._ma is not None and self._fb is not None:
legacy_attachments: list[LegacyAttachment] = []
for attachment in attachments:
file_id = attachment.get("name") or shortuuid.uuid()
legacy_attachments.append(
LegacyAttachment(file_id=file_id, url=attachment["url"])
)
data["extra"]["_sfs_legacy"] = legacy_attachments
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")
async def _send_legacy_attach_message(
self,
client: SatXMPPEntity,
data: dict[str, Any],
main_msg_id: str,
file_id: str,
url: str,
) -> None:
"""Send a backward-compatible attach message for legacy clients.
@param client: Client instance.
@param data: Original message data (for to, type)
@param main_msg_id: Uid of the main message containing
@param file_id: Id of the element this attaches to
@param url: Download url for the file
"""
assert self._oob is not None and self._ma is not None and self._fb is not None
mess_data: dict[str, Any] = {
"from": client.jid,
"to": data["to"],
"uid": "",
"message": {"": url},
"type": data["type"],
"subject": {},
"extra": {},
}
client.generate_message_xml(cast(MessageData, mess_data))
message_elt = mess_data["xml"]
#
message_elt.addChild(
AttachedTo(uid=main_msg_id, stanza_id=main_msg_id).to_element()
)
#
sources_elt = self.get_sources_elt()
sources_elt["id"] = file_id
sources_elt.addChild(self._u.generate_url_data(url=url).to_element())
message_elt.addChild(sources_elt)
#
self._fb.add_fallback_elt(message_elt, NS_SFS, url)
# OOB
message_elt.addChild(self._oob.generate_oob(url))
await client.send_message_data(cast(MessageData, mess_data))
def _message_received_trigger(
self,
client: SatXMPPEntity,
message_elt: domish.Element,
post_treat: defer.Deferred,
) -> bool:
"""Register post-treatment to add file-sharing attachments to message data.
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)
)
post_treat.addCallback(
lambda data: defer.ensureDeferred(
self._handle_source_attachment(client, message_elt, data)
)
)
return True
def _on_connection_closed(self, client: SatXMPPEntity, reason: Any) -> bool:
"""Clean up pending legacy entries when a client disconnects."""
sfs_data = client.get_plugin_data("XEP-0447", SFSClientData)
if sfs_data.pending_legacy:
log.warning(
f"Clearing {len(sfs_data.pending_legacy)} pending legacy entries for "
f"{client.jid.userhost()}"
)
sfs_data.pending_legacy.clear()
return True
async def _message_sent_trigger(
self, client: SatXMPPEntity, mess_data: dict[str, Any]
) -> None:
"""Send backward-compatibility messages after the main message.
For non-groupchat, send immediately using origin_id.
For groupchat, defer until we receive the MUC echo with stanza-id.
"""
legacy_attachments = mess_data["extra"].get("_sfs_legacy")
if legacy_attachments is None:
return
origin_id = mess_data["extra"].get("origin_id") or mess_data["uid"]
if mess_data["type"] == C.MESS_TYPE_GROUPCHAT:
# defer: wait for MUC echo to get stanza-id
sfs_data = client.get_plugin_data("XEP-0447", SFSClientData)
sfs_data.pending_legacy[origin_id] = LegacyAttachments(
mess_data=mess_data,
attachments=legacy_attachments,
timestamp=time.monotonic(),
)
return
for entry in legacy_attachments:
await self._send_legacy_attach_message(
client, mess_data, origin_id, entry.file_id, entry.url
)
def _message_parse_trigger(
self,
client: SatXMPPEntity,
message_elt: domish.Element,
data: dict[str, Any],
) -> bool:
"""Message parsing trigger.
Ignore backward-compatibility messages emitted by our own device.
"""
attached_to = data["extra"].get("attached_to")
sources_elt = next(message_elt.elements(NS_SFS, "sources"), None)
if attached_to is not None and sources_elt is not None:
attach_to_id = attached_to["stanza_id"]
# only block if this is our own backward-compatibility message (from our own
# JID). Incoming source attachments from other clients should be processed
# normally.
if message_elt["from"] == client.jid.full():
log.debug(
f"Ignoring backward-compatibility message for "
f"attach-to id={attach_to_id!r}"
)
raise exceptions.CancelError(
"Cancelled by XEP_0447 (backward-compatibility message)"
)
return True
async def _handle_source_attachment(
self, client: SatXMPPEntity, message_elt: domish.Element, data: dict[str, Any]
) -> dict[str, Any]:
"""Handle deferred source attachment.
When a message is attaching sources to an existing file-sharing message, find the
original message, merge the new sources, update storage, and emit a signal.
@param client: Client instance.
@param message_elt: The element.
@param data: Parsed message data.
@return: Updated message data.
"""
attached_to = data["extra"].get("attached_to")
sources_elts = list(message_elt.elements(NS_SFS, "sources"))
file_sharing_elts = list(message_elt.elements(NS_SFS, "file-sharing"))
# only process if we have attach-to + sources but no file-sharing
if attached_to is None or not sources_elts or file_sharing_elts:
return data
if attached_to.get("uid") is None:
log.warning(
f"Received source attachment with unresolved attach-to id "
f"{attached_to['stanza_id']!r}, ignoring"
)
return data
# find the original message in history
message_type = data.get("type")
history = await self.host.memory.storage.get_history_from_xmpp_id(
client, attached_to["stanza_id"], message_type
)
if history is None:
log.warning(
f"No history entry found for source attachment "
f"attach-to={attached_to['stanza_id']!r}"
)
return data
# verify sender: sources can only be added by the original sender
from_jid = jid.JID(message_elt["from"])
if history.type == C.MESS_TYPE_GROUPCHAT:
if history.source_jid != from_jid:
log.warning(
f"Ignoring source attachment from {from_jid.full()}: "
f"not the original sender ({history.source_jid.full()})"
)
return data
else:
if history.source_jid.userhostJID() != from_jid.userhostJID():
log.warning(
f"Ignoring source attachment from {from_jid.full()}: "
f"not the original sender ({history.source_jid.full()})"
)
return data
# merge new sources into existing attachments
existing_attachments = history.extra.get(C.KEY_ATTACHMENTS, [])
new_sources_added = False
for sources_elt in sources_elts:
sources = self.parse_sources_elt(sources_elt)
file_id = sources_elt.getAttribute("id")
# find matching attachment by file-sharing id
for attachment in existing_attachments:
if file_id is None or attachment.get("id") == file_id:
for source in sources:
source_data = source.model_dump()
if source_data not in attachment["sources"]:
attachment["sources"].append(source_data)
new_sources_added = True
break
if not new_sources_added:
log.debug(
f"No new sources to add for message uid={history.uid!r}, "
f"skipping update"
)
else:
flag_modified(history, "extra")
await self.host.memory.storage.add(history)
# notify frontends
update_data = MessageData(history.serialise())
self.host.bridge.message_update(
history.uid,
C.MESS_UPDATE_SOURCES,
data_format.serialise(update_data),
client.profile,
)
# we stop the workflow here to avoid storage and bridge signal
raise exceptions.CancelError("Source attachment merged, cancelling message")