gajim3/gajim/common/modules/httpupload.py

405 lines
13 KiB
Python

# This file is part of Gajim.
#
# Gajim is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation; version 3 only.
#
# Gajim 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
# XEP-0363: HTTP File Upload
import os
import io
from urllib.parse import urlparse
import mimetypes
from nbxmpp.namespaces import Namespace
from nbxmpp.errors import StanzaError
from nbxmpp.errors import MalformedStanzaError
from nbxmpp.errors import HTTPUploadStanzaError
from nbxmpp.util import convert_tls_error_flags
from gi.repository import GLib
from gi.repository import Soup
from gajim.common import app
from gajim.common.i18n import _
from gajim.common.helpers import get_tls_error_phrase
from gajim.common.helpers import get_user_proxy
from gajim.common.const import FTState
from gajim.common.filetransfer import FileTransfer
from gajim.common.modules.base import BaseModule
from gajim.common.exceptions import FileError
class HTTPUpload(BaseModule):
_nbxmpp_extends = 'HTTPUpload'
def __init__(self, con):
BaseModule.__init__(self, con)
self.available = False
self.component = None
self.httpupload_namespace = None
self.max_file_size = None # maximum file size in bytes
self._proxy_resolver = None
self._queued_messages = {}
self._session = Soup.Session()
self._session.props.ssl_strict = False
self._session.props.user_agent = 'Gajim %s' % app.version
def _set_proxy_if_available(self):
proxy = get_user_proxy(self._account)
if proxy is None:
self._proxy_resolver = None
self._session.props.proxy_resolver = None
else:
self._proxy_resolver = proxy.get_resolver()
self._session.props.proxy_resolver = self._proxy_resolver
def pass_disco(self, info):
if not info.has_httpupload:
return
self.available = True
self.httpupload_namespace = Namespace.HTTPUPLOAD_0
self.component = info.jid
self.max_file_size = info.httpupload_max_file_size
self._log.info('Discovered component: %s', info.jid)
if self.max_file_size is None:
self._log.warning('Component does not provide maximum file size')
else:
size = GLib.format_size_full(self.max_file_size,
GLib.FormatSizeFlags.IEC_UNITS)
self._log.info('Component has a maximum file size of: %s', size)
for ctrl in app.interface.msg_win_mgr.get_controls(acct=self._account):
ctrl.update_actions()
def make_transfer(self, path, encryption, contact, groupchat=False):
if not path or not os.path.exists(path):
raise FileError(_('Could not access file'))
invalid_file = False
stat = os.stat(path)
if os.path.isfile(path):
if stat[6] == 0:
invalid_file = True
msg = _('File is empty')
else:
invalid_file = True
msg = _('File does not exist')
if self.max_file_size is not None and \
stat.st_size > self.max_file_size:
invalid_file = True
size = GLib.format_size_full(self.max_file_size,
GLib.FormatSizeFlags.IEC_UNITS)
msg = _('File is too large, '
'maximum allowed file size is: %s') % size
if invalid_file:
raise FileError(msg)
mime = mimetypes.MimeTypes().guess_type(path)[0]
if not mime:
mime = 'application/octet-stream' # fallback mime type
self._log.info("Detected MIME type of file: %s", mime)
return HTTPFileTransfer(self._account,
path,
contact,
mime,
encryption,
groupchat)
def cancel_transfer(self, transfer):
transfer.set_cancelled()
message = self._queued_messages.get(id(transfer))
if message is None:
return
self._session.cancel_message(message, Soup.Status.CANCELLED)
def start_transfer(self, transfer):
if transfer.encryption is not None and not transfer.is_encrypted:
transfer.set_encrypting()
plugin = app.plugin_manager.encryption_plugins[transfer.encryption]
if hasattr(plugin, 'encrypt_file'):
plugin.encrypt_file(transfer,
self._account,
self.start_transfer)
else:
transfer.set_error('encryption-not-available')
return
transfer.set_preparing()
self._log.info('Sending request for slot')
self._nbxmpp('HTTPUpload').request_slot(
jid=self.component,
filename=transfer.filename,
size=transfer.size,
content_type=transfer.mime,
callback=self._received_slot,
user_data=transfer)
def _received_slot(self, task):
transfer = task.get_user_data()
try:
result = task.finish()
except (StanzaError,
HTTPUploadStanzaError,
MalformedStanzaError) as error:
if error.app_condition == 'file-too-large':
size_text = GLib.format_size_full(
error.get_max_file_size(),
GLib.FormatSizeFlags.IEC_UNITS)
error_text = _('File is too large, '
'maximum allowed file size is: %s' % size_text)
transfer.set_error('file-too-large', error_text)
else:
transfer.set_error('misc', str(error))
return
transfer.process_result(result)
if (urlparse(transfer.put_uri).scheme != 'https' or
urlparse(transfer.get_uri).scheme != 'https'):
transfer.set_error('unsecure')
return
self._log.info('Uploading file to %s', transfer.put_uri)
self._log.info('Please download from %s', transfer.get_uri)
self._upload_file(transfer)
def _upload_file(self, transfer):
transfer.set_started()
message = Soup.Message.new('PUT', transfer.put_uri)
message.connect('starting', self._check_certificate, transfer)
# Set CAN_REBUILD so chunks get discarded after they have been
# written to the network
message.set_flags(Soup.MessageFlags.CAN_REBUILD |
Soup.MessageFlags.NO_REDIRECT)
message.props.request_body.set_accumulate(False)
message.props.request_headers.set_content_type(transfer.mime, None)
message.props.request_headers.set_content_length(transfer.size)
for name, value in transfer.headers.items():
message.props.request_headers.append(name, value)
message.connect('wrote-headers', self._on_wrote_headers, transfer)
message.connect('wrote-chunk', self._on_wrote_chunk, transfer)
self._queued_messages[id(transfer)] = message
self._set_proxy_if_available()
self._session.queue_message(message, self._on_finish, transfer)
def _check_certificate(self, message, transfer):
https_used, tls_certificate, tls_errors = message.get_https_status()
if not https_used:
self._log.warning('HTTPS was not used for upload')
transfer.set_error('unsecure')
self._session.cancel_message(message, Soup.Status.CANCELLED)
return
tls_errors = convert_tls_error_flags(tls_errors)
if app.cert_store.verify(tls_certificate, tls_errors):
return
for error in tls_errors:
phrase = get_tls_error_phrase(error)
self._log.warning('TLS verification failed: %s', phrase)
transfer.set_error('tls-verification-failed', phrase)
self._session.cancel_message(message, Soup.Status.CANCELLED)
def _on_finish(self, _session, message, transfer):
self._queued_messages.pop(id(transfer), None)
if message.props.status_code == Soup.Status.CANCELLED:
self._log.info('Upload cancelled')
return
if message.props.status_code in (Soup.Status.OK, Soup.Status.CREATED):
self._log.info('Upload completed successfully')
transfer.set_finished()
else:
phrase = Soup.Status.get_phrase(message.props.status_code)
self._log.error('Got unexpected http upload response code: %s',
phrase)
transfer.set_error('http-response', phrase)
def _on_wrote_chunk(self, message, transfer):
transfer.update_progress()
if transfer.is_complete:
message.props.request_body.complete()
return
bytes_ = transfer.get_chunk()
self._session.pause_message(message)
GLib.idle_add(self._append, message, bytes_)
def _append(self, message, bytes_):
if message.props.status_code == Soup.Status.CANCELLED:
return
self._session.unpause_message(message)
message.props.request_body.append(bytes_)
@staticmethod
def _on_wrote_headers(message, transfer):
message.props.request_body.append(transfer.get_chunk())
class HTTPFileTransfer(FileTransfer):
_state_descriptions = {
FTState.ENCRYPTING: _('Encrypting file…'),
FTState.PREPARING: _('Requesting HTTP File Upload Slot…'),
FTState.STARTED: _('Uploading via HTTP File Upload…'),
}
_errors = {
'unsecure': _('The server returned an insecure transport (HTTP).'),
'encryption-not-available': _('There is no encryption method available '
'for the chosen encryption.')
}
def __init__(self,
account,
path,
contact,
mime,
encryption,
groupchat):
FileTransfer.__init__(self, account)
self._path = path
self._encryption = encryption
self._groupchat = groupchat
self._contact = contact
self._mime = mime
self.size = os.stat(path).st_size
self.put_uri = None
self.get_uri = None
self._uri_transform_func = None
self._stream = None
self._data = None
self._headers = {}
self._is_encrypted = False
@property
def mime(self):
return self._mime
@property
def contact(self):
return self._contact
@property
def is_groupchat(self):
return self._groupchat
@property
def encryption(self):
return self._encryption
@property
def headers(self):
return self._headers
@property
def path(self):
return self._path
@property
def is_encrypted(self):
return self._is_encrypted
def get_transformed_uri(self):
if self._uri_transform_func is not None:
return self._uri_transform_func(self.get_uri)
return self.get_uri
def set_uri_transform_func(self, func):
self._uri_transform_func = func
@property
def filename(self):
return os.path.basename(self._path)
def set_error(self, domain, text=''):
if not text:
text = self._errors[domain]
self._close()
super().set_error(domain, text)
def set_finished(self):
self._close()
super().set_finished()
def set_encrypted_data(self, data):
self._data = data
self._is_encrypted = True
def _close(self):
if self._stream is not None:
self._stream.close()
def get_chunk(self):
if self._stream is None:
if self._encryption is None:
self._stream = open(self._path, 'rb')
else:
self._stream = io.BytesIO(self._data)
data = self._stream.read(16384)
if not data:
self._close()
return None
self._seen += len(data)
if self.is_complete:
self._close()
return data
def get_data(self):
with open(self._path, 'rb') as file:
data = file.read()
return data
def process_result(self, result):
self.put_uri = result.put_uri
self.get_uri = result.get_uri
self._headers = result.headers
def get_instance(*args, **kwargs):
return HTTPUpload(*args, **kwargs), 'HTTPUpload'