#!/usr/bin/env python3 # SAT plugin for uploading files # 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 . import os import os.path from pathlib import Path from typing import Callable, NamedTuple, TYPE_CHECKING from twisted.internet import defer from twisted.words.protocols.jabber import jid from twisted.words.protocols.jabber import error as jabber_error 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 D_, _ from libervia.backend.core.log import getLogger from libervia.backend.tools import xml_tools from libervia.backend.tools.common import data_format if TYPE_CHECKING: from libervia.backend.core.main import LiberviaBackend class UploadTracking(NamedTuple): """Tracking data for an ongoing file upload""" progress_id: str download_d: defer.Deferred class UploadResult(NamedTuple): """Result of a completed file upload""" url: str hash: str | None = None hash_algo: str | None = None log = getLogger(__name__) PLUGIN_INFO = { C.PI_NAME: "File Upload", C.PI_IMPORT_NAME: "UPLOAD", C.PI_TYPE: C.PLUG_TYPE_MISC, C.PI_MODES: C.PLUG_MODE_BOTH, C.PI_MAIN: "UploadPlugin", C.PI_HANDLER: "no", C.PI_DESCRIPTION: _("""File upload management"""), } UPLOADING = D_("Please select a file to upload") UPLOADING_TITLE = D_("File upload") class UploadPlugin(object): # TODO: plugin unload def __init__(self, host: "LiberviaBackend") -> None: log.info(_("plugin Upload initialization")) self.host = host host.bridge.add_method( "file_upload", ".plugin", in_sign="sssss", out_sign="a{ss}", method=self._file_upload, async_=True, ) self._upload_callbacks = [] def _file_upload( self, filepath: str, filename: str, upload_jid_s: str, options_s: str, profile: str = C.PROF_KEY_NONE, ) -> defer.Deferred: """Bridge method to upload a file. @param filepath: absolute path to the file @param filename: name to use for the upload @param upload_jid_s: upload capable entity jid as string @param options_s: serialised upload options @param profile: profile key @return: Deferred which fires with progress id or error """ client = self.host.get_client(profile) upload_jid = jid.JID(upload_jid_s) if upload_jid_s else None options: dict = data_format.deserialise(options_s) return defer.ensureDeferred( self.file_upload(client, filepath, filename or None, upload_jid, options) ) async def file_upload( self, client: SatXMPPEntity, filepath: Path | str, filename: str | None, upload_jid: jid.JID | None, options: dict, ) -> dict[str, str]: """Send a file using best available method. @param client: XMPP client @param filepath: absolute path to the file @param filename: name to use for the upload @param upload_jid: upload capable entity jid @param options: upload options @return: action dictionary with progress id in case of success, else xmlui message """ try: result = await self.upload(client, filepath, filename, upload_jid, options) except Exception as e: if ( isinstance(e, jabber_error.StanzaError) and e.condition == "not-acceptable" ): reason = e.text else: reason = str(e) msg = D_("Can't upload file: {reason}").format(reason=reason) log.warning(msg) return { "xmlui": xml_tools.note( msg, D_("Can't upload file"), C.XMLUI_DATA_LVL_WARNING ).toXml() } else: return {"progress": result.progress_id} async def upload( self, client: SatXMPPEntity, filepath: Path | str, filename: str | None = None, upload_jid: jid.JID | None = None, extra: dict | None = None, ) -> UploadTracking: """Send a file using best available method @param filepath: absolute path to the file @param filename: name to use for the upload None to use basename of the path @param upload_jid: upload capable entity jid, or None to use autodetected, if possible @param extra: extra data/options to use for the upload, may be: - ignore_tls_errors(bool): True to ignore SSL/TLS certificate verification used only if HTTPS transport is needed - progress_id(str): id to use for progression if not specified, one will be generated @return: UploadTracking with progress_id and deferred which fires download URL when upload is finished """ if extra is None: extra = {} if not os.path.isfile(filepath): raise exceptions.DataError("The given path doesn't link to a file") for method_name, available_cb, upload_cb, priority in self._upload_callbacks: if upload_jid is None: try: upload_jid = await available_cb(client, upload_jid) except exceptions.NotFound: continue # no entity managing this extension found log.info( "{name} method will be used to upload the file".format(name=method_name) ) result = await upload_cb(client, filepath, filename, upload_jid, extra) return UploadTracking( progress_id=result.progress_id, download_d=result.download_d, ) raise exceptions.NotFound("Can't find any method to upload a file") def register( self, method_name: str, available_cb: Callable[[SatXMPPEntity, jid.JID | None], defer.Deferred[jid.JID]], upload_cb: Callable[ [SatXMPPEntity, Path | str, str | None, jid.JID, dict], UploadTracking, ], priority: int = 0, ) -> None: """Register a file uploading method. @param method_name: short name for the method, must be unique @param available_cb: method to call to check if this method is usable must take two arguments: client and upload_jid (can be None) must return the first entity found (being upload_jid or one of its components) exceptions.NotFound must be raised if no entity has been found @param upload_cb: method to upload a file must have the same signature as [upload] must return an UploadTracking with progress_id and deferred which fires download URL when upload is finished @param priority: priority of this method, the higher available will be used """ assert method_name for data in self._upload_callbacks: if method_name == data[0]: raise exceptions.ConflictError( "A method with this name is already registered" ) self._upload_callbacks.append((method_name, available_cb, upload_cb, priority)) self._upload_callbacks.sort(key=lambda data: data[3], reverse=True) def unregister(self, method_name: str) -> None: """Unregister a previously registered upload method. @param method_name: name of the method to unregister @raise NotFound: if the method is not registered """ for idx, data in enumerate(self._upload_callbacks): if data[0] == method_name: del self._upload_callbacks[idx] return raise exceptions.NotFound("The name to unregister doesn't exist")