gajim-plural/gajim/common/modules/httpupload.py

430 lines
16 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 sys
import threading
import ssl
import urllib
from urllib.request import Request, urlopen
from urllib.parse import urlparse
import io
import mimetypes
import logging
import nbxmpp
from nbxmpp import NS_HTTPUPLOAD
from gi.repository import GLib
from gajim.common import app
from gajim.common import ged
from gajim.common.i18n import _
from gajim.common.nec import NetworkIncomingEvent
from gajim.common.connection_handlers_events import InformationEvent
from gajim.common.connection_handlers_events import MessageOutgoingEvent
from gajim.common.connection_handlers_events import GcMessageOutgoingEvent
if sys.platform in ('win32', 'darwin'):
import certifi
log = logging.getLogger('gajim.c.m.httpupload')
NS_HTTPUPLOAD_0 = NS_HTTPUPLOAD + ':0'
class HTTPUpload:
def __init__(self, con):
self._con = con
self._account = con.name
self.handlers = []
self.available = False
self.component = None
self.httpupload_namespace = None
self._allowed_headers = ['Authorization', 'Cookie', 'Expires']
self.max_file_size = None # maximum file size in bytes
app.ged.register_event_handler('stanza-message-outgoing',
ged.OUT_PREGUI,
self.handle_outgoing_stanza)
app.ged.register_event_handler('gc-stanza-message-outgoing',
ged.OUT_PREGUI,
self.handle_outgoing_stanza)
self.messages = []
def cleanup(self):
app.ged.remove_event_handler('stanza-message-outgoing',
ged.OUT_PREGUI,
self.handle_outgoing_stanza)
app.ged.remove_event_handler('gc-stanza-message-outgoing',
ged.OUT_PREGUI,
self.handle_outgoing_stanza)
def pass_disco(self, from_, _identities, features, data, _node):
if NS_HTTPUPLOAD_0 in features:
self.httpupload_namespace = NS_HTTPUPLOAD_0
elif NS_HTTPUPLOAD in features:
self.httpupload_namespace = NS_HTTPUPLOAD
else:
return
self.component = from_
log.info('Discovered component: %s', from_)
for form in data:
form_dict = form.asDict()
if form_dict.get('FORM_TYPE', None) != self.httpupload_namespace:
continue
size = form_dict.get('max-file-size', None)
if size is not None:
self.max_file_size = int(size)
break
if self.max_file_size is None:
log.warning('%s does not provide maximum file size', self._account)
else:
log.info('%s has a maximum file size of: %s MiB',
self._account, self.max_file_size / (1024 * 1024))
self.available = True
for ctrl in app.interface.msg_win_mgr.get_controls(acct=self._account):
ctrl.update_actions()
def handle_outgoing_stanza(self, event):
if event.conn.name != self._account:
return
message = event.msg_iq.getTagData('body')
if message and message in self.messages:
self.messages.remove(message)
# Add oob information before sending message to recipient,
# to distinguish HTTP File Upload Link from pasted URL
oob = event.msg_iq.addChild('x', namespace=nbxmpp.NS_X_OOB)
oob.addChild('url').setData(message)
if 'gajim' in event.additional_data:
event.additional_data['gajim']['oob_url'] = message
else:
event.additional_data['gajim'] = {'oob_url': message}
def check_file_before_transfer(self, path, encryption, contact, session,
groupchat=False):
if not path or not os.path.exists(path):
return
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:
self.raise_information_event('open-file-error2', msg)
return
mime = mimetypes.MimeTypes().guess_type(path)[0]
if not mime:
mime = 'application/octet-stream' # fallback mime type
log.info("Detected MIME type of file: %s", mime)
try:
file = File(path, contact, mime=mime, encryption=encryption,
update_progress=self.raise_progress_event,
session=session, groupchat=groupchat)
app.interface.show_httpupload_progress(file)
except Exception as error:
log.exception('Error while loading file')
self.raise_information_event('open-file-error2', str(error))
return
if encryption is not None:
app.interface.encrypt_file(file, self._account, self._request_slot)
else:
self._request_slot(file)
@staticmethod
def raise_progress_event(status, file, seen=None, total=None):
app.nec.push_incoming_event(HTTPUploadProgressEvent(
None, status=status, file=file, seen=seen, total=total))
@staticmethod
def raise_information_event(dialog_name, args=None):
app.nec.push_incoming_event(InformationEvent(
None, dialog_name=dialog_name, args=args))
def _request_slot(self, file):
GLib.idle_add(self.raise_progress_event, 'request', file)
iq = self._build_request(file)
log.info("Sending request for slot")
self._con.connection.SendAndCallForResponse(
iq, self._received_slot, {'file': file})
def _build_request(self, file):
iq = nbxmpp.Iq(typ='get', to=self.component)
id_ = app.get_an_id()
iq.setID(id_)
if self.httpupload_namespace == NS_HTTPUPLOAD:
# experimental namespace
request = iq.setTag(name="request",
namespace=self.httpupload_namespace)
request.addChild('filename', payload=os.path.basename(file.path))
request.addChild('size', payload=file.size)
request.addChild('content-type', payload=file.mime)
else:
attr = {'filename': os.path.basename(file.path),
'size': file.size,
'content-type': file.mime}
iq.setTag(name="request",
namespace=self.httpupload_namespace,
attrs=attr)
return iq
@staticmethod
def get_slot_error_message(stanza):
tmp = stanza.getTag('error').getTag('file-too-large')
if tmp is not None:
max_file_size = int(tmp.getTag('max-file-size').getData())
return _('File is too large, maximum allowed file size is: %s') % \
GLib.format_size_full(max_file_size,
GLib.FormatSizeFlags.IEC_UNITS)
return stanza.getErrorMsg()
def _received_slot(self, _con, stanza, file):
log.info("Received slot")
if stanza.getType() == 'error':
self.raise_progress_event('close', file)
self.raise_information_event('request-upload-slot-error',
self.get_slot_error_message(stanza))
log.error(stanza)
return
try:
if self.httpupload_namespace == NS_HTTPUPLOAD:
file.put = stanza.getTag('slot').getTag('put').getData()
file.get = stanza.getTag('slot').getTag('get').getData()
else:
slot = stanza.getTag('slot')
file.put = slot.getTagAttr('put', 'url')
file.get = slot.getTagAttr('get', 'url')
for header in slot.getTag('put').getTags('header'):
name = header.getAttr('name')
if name not in self._allowed_headers:
raise ValueError('Not allowed header')
data = header.getData()
if '\n' in data:
raise ValueError('Newline in header data')
file.headers[name] = data
except Exception:
log.error("Got invalid stanza: %s", stanza)
log.exception('Error')
self.raise_progress_event('close', file)
self.raise_information_event('request-upload-slot-error2')
return
if (urlparse(file.put).scheme != 'https' or
urlparse(file.get).scheme != 'https'):
self.raise_progress_event('close', file)
self.raise_information_event('unsecure-error')
return
try:
file.stream = StreamFileWithProgress(file)
except Exception:
log.exception('Error')
self.raise_progress_event('close', file)
self.raise_information_event('open-file-error')
return
log.info('Uploading file to %s', file.put)
log.info('Please download from %s', file.get)
thread = threading.Thread(target=self._upload_file, args=(file,))
thread.daemon = True
thread.start()
def _upload_file(self, file):
GLib.idle_add(self.raise_progress_event, 'upload', file)
try:
file.headers['User-Agent'] = 'Gajim %s' % app.version
file.headers['Content-Type'] = file.mime
file.headers['Content-Length'] = file.size
request = Request(
file.put, data=file.stream, headers=file.headers, method='PUT')
log.info("Opening Urllib upload request...")
if not app.config.get_per(
'accounts', self._account, 'httpupload_verify'):
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
log.warning('CERT Verification disabled')
transfer = urlopen(request, timeout=30, context=context)
else:
if sys.platform in ('win32', 'darwin'):
transfer = urlopen(
request, cafile=certifi.where(), timeout=30)
else:
transfer = urlopen(request, timeout=30)
file.stream.close()
log.info('Urllib upload request done, response code: %s',
transfer.getcode())
GLib.idle_add(self._upload_complete, transfer.getcode(), file)
return
except UploadAbortedException as exc:
log.info(exc)
error_msg = exc
except urllib.error.URLError as exc:
if isinstance(exc.reason, ssl.SSLError):
error_msg = exc.reason.reason
if error_msg == 'CERTIFICATE_VERIFY_FAILED':
log.exception('Certificate verify failed')
else:
log.exception('URLError')
error_msg = exc.reason
except Exception as exc:
log.exception("Exception during upload")
error_msg = exc
GLib.idle_add(self.raise_progress_event, 'close', file)
GLib.idle_add(self._on_upload_error, file, error_msg)
def _upload_complete(self, response_code, file):
self.raise_progress_event('close', file)
if 200 <= response_code < 300:
log.info("Upload completed successfully")
message = file.get
if file.user_data:
message += '#' + file.user_data
message = self.convert_to_aegscm(message)
else:
self.messages.append(message)
if file.groupchat:
app.nec.push_outgoing_event(GcMessageOutgoingEvent(
None, account=self._account, jid=file.contact.jid,
message=message, automatic_message=False,
session=file.session))
else:
app.nec.push_outgoing_event(MessageOutgoingEvent(
None, account=self._account, jid=file.contact.jid,
message=message, keyID=file.key_id, type_='chat',
automatic_message=False, session=file.session))
else:
log.error('Got unexpected http upload response code: %s',
response_code)
self.raise_information_event('httpupload-response-error',
response_code)
def _on_upload_error(self, file, reason):
self.raise_progress_event('close', file)
self.raise_information_event('httpupload-error', str(reason))
@staticmethod
def convert_to_aegscm(url):
return 'aesgcm' + url[5:]
class File:
def __init__(self, path, contact, **kwargs):
for key, val in kwargs.items():
setattr(self, key, val)
self.encrypted = False
self.contact = contact
self.key_id = None
if hasattr(contact, 'keyID'):
self.key_id = contact.keyID
self.stream = None
self.path = path
self.put = None
self.get = None
self.data = None
self.user_data = None
self.size = None
self.headers = {}
self.event = threading.Event()
self.load_data()
def load_data(self):
with open(self.path, 'rb') as content:
self.data = content.read()
self.size = len(self.data)
def get_data(self, full=False):
if full:
return io.BytesIO(self.data).getvalue()
return io.BytesIO(self.data)
class StreamFileWithProgress:
def __init__(self, file):
self.file = file
self.event = file.event
self.backing = file.get_data()
self.backing.seek(0, os.SEEK_END)
self._total = self.backing.tell()
self.backing.seek(0)
self._callback = file.update_progress
self._seen = 0
def __len__(self):
return self._total
def read(self, size):
if self.event.isSet():
raise UploadAbortedException
data = self.backing.read(size)
self._seen += len(data)
if self._callback:
GLib.idle_add(self._callback, 'update',
self.file, self._seen, self._total)
return data
def close(self):
return self.backing.close()
class UploadAbortedException(Exception):
def __str__(self):
return "Upload Aborted"
class HTTPUploadProgressEvent(NetworkIncomingEvent):
name = 'httpupload-progress'
def get_instance(*args, **kwargs):
return HTTPUpload(*args, **kwargs), 'HTTPUpload'