430 lines
16 KiB
Python
430 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
|
#
|
|
# 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/>.
|
|
|
|
import os
|
|
import threading
|
|
import ssl
|
|
import urllib
|
|
from urllib.request import Request, urlopen
|
|
from urllib.parse import urlparse, quote
|
|
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.connection_handlers_events import InformationEvent
|
|
from gajim.common.connection_handlers_events import HTTPUploadProgressEvent
|
|
from gajim.common.connection_handlers_events import MessageOutgoingEvent
|
|
from gajim.common.connection_handlers_events import GcMessageOutgoingEvent
|
|
|
|
if os.name == 'nt':
|
|
import certifi
|
|
|
|
log = logging.getLogger('gajim.c.httpupload')
|
|
|
|
NS_HTTPUPLOAD_0 = NS_HTTPUPLOAD + ':0'
|
|
|
|
class ConnectionHTTPUpload:
|
|
"""
|
|
Implement HTTP File Upload
|
|
(XEP-0363, https://xmpp.org/extensions/xep-0363.html)
|
|
"""
|
|
def __init__(self):
|
|
self.httpupload = False
|
|
self.encrypted_upload = 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('agent-info-received',
|
|
ged.GUI1,
|
|
self.handle_agent_info_received)
|
|
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('agent-info-received',
|
|
ged.GUI1,
|
|
self.handle_agent_info_received)
|
|
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 handle_agent_info_received(self, event):
|
|
account = event.conn.name
|
|
if account != self.name:
|
|
return
|
|
|
|
if not app.jid_is_transport(event.jid):
|
|
return
|
|
|
|
if not event.id_.startswith('Gajim_'):
|
|
return
|
|
|
|
if NS_HTTPUPLOAD_0 in event.features:
|
|
self.httpupload_namespace = NS_HTTPUPLOAD_0
|
|
elif NS_HTTPUPLOAD in event.features:
|
|
self.httpupload_namespace = NS_HTTPUPLOAD
|
|
else:
|
|
return
|
|
|
|
self.component = event.jid
|
|
|
|
for form in event.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', account)
|
|
else:
|
|
log.info('%s has a maximum file size of: %s MiB',
|
|
account, self.max_file_size/(1024*1024))
|
|
|
|
self.httpupload = True
|
|
for ctrl in app.interface.msg_win_mgr.get_controls(acct=self.name):
|
|
ctrl.update_actions()
|
|
|
|
def handle_outgoing_stanza(self, event):
|
|
if event.conn.name != self.name:
|
|
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.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")
|
|
app.connections[self.name].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, conn, 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.name, '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 os.name == 'nt':
|
|
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.name, jid=file.contact.jid,
|
|
message=message, automatic_message=False,
|
|
session=file.session))
|
|
else:
|
|
app.nec.push_outgoing_event(MessageOutgoingEvent(
|
|
None, account=self.name, jid=file.contact.jid,
|
|
message=message, keyID=file.keyID, 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 k, v in kwargs.items():
|
|
setattr(self, k, v)
|
|
self.encrypted = False
|
|
self.contact = contact
|
|
self.keyID = None
|
|
if hasattr(contact, 'keyID'):
|
|
self.keyID = 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"
|