diff --git a/gajim/chat_control.py b/gajim/chat_control.py index 83b63c867..17e96fe8f 100644 --- a/gajim/chat_control.py +++ b/gajim/chat_control.py @@ -97,6 +97,10 @@ class ChatControl(ChatControlBase): self._on_authentication_button_clicked) self.handlers[id_] = self.authentication_button + self.sendfile_button = self.xml.get_object('sendfile_button') + self.sendfile_button.set_action_name('win.send-file-' + \ + self.control_id) + # Add lock image to show chat encryption self.lock_image = self.xml.get_object('lock_image') @@ -242,7 +246,6 @@ class ChatControl(ChatControlBase): def add_actions(self): actions = [ - ('send-file-', self._on_send_file), ('invite-contacts-', self._on_invite_contacts), ('add-to-roster-', self._on_add_to_roster), ('information-', self._on_information), @@ -288,16 +291,42 @@ class ChatControl(ChatControlBase): win.lookup_action('toggle-video-' + self.control_id).set_enabled( online and self.video_available) + # Send file (HTTP File Upload) + httpupload = win.lookup_action( + 'send-file-httpupload-' + self.control_id) + httpupload.set_enabled( + online and app.connections[self.account].httpupload) + + # Send file (Jingle) + jingle_conditions = ( + (self.contact.supports(NS_FILE) or + self.contact.supports(NS_JINGLE_FILE_TRANSFER_5)) and + self.contact.show != 'offline') + jingle = win.lookup_action('send-file-jingle-' + self.control_id) + jingle.set_enabled(online and jingle_conditions) + # Send file - if ((self.contact.supports(NS_FILE) or \ - self.contact.supports(NS_JINGLE_FILE_TRANSFER_5)) and \ - (self.type_id == 'chat' or self.gc_contact.resource)) and \ - self.contact.show != 'offline' and online: - win.lookup_action('send-file-' + self.control_id).set_enabled( - True) - else: - win.lookup_action('send-file-' + self.control_id).set_enabled( - False) + win.lookup_action( + 'send-file-' + self.control_id).set_enabled( + jingle.get_enabled() or httpupload.get_enabled()) + + # Set File Transfer Button tooltip + ft_pref = app.config.get_per('accounts', self.account, + 'filetransfer_preference') + + tooltip_text = None + if httpupload.get_enabled() and jingle.get_enabled(): + if ft_pref == 'httpupload': + tooltip_text = _('HTTP File Upload') + else: + tooltip_text = _('Jingle File Transfer') + elif httpupload.get_enabled(): + tooltip_text = _('HTTP File Upload') + elif jingle.get_enabled(): + tooltip_text = _('Jingle File Transfer') + elif online: + tooltip_text = _('No File Transfer available') + self.sendfile_button.set_tooltip_text(tooltip_text) # Convert to GC if app.config.get_per('accounts', self.account, 'is_zeroconf'): @@ -315,9 +344,6 @@ class ChatControl(ChatControlBase): win.lookup_action( 'information-' + self.control_id).set_enabled(online) - def _on_send_file(self, action, param): - super()._on_send_file() - def _on_add_to_roster(self, action, param): dialogs.AddNewContactWindow(self.account, self.contact.jid) @@ -1265,43 +1291,37 @@ class ChatControl(ChatControlBase): self.show_avatar() def _on_drag_data_received(self, widget, context, x, y, selection, - target_type, timestamp): + target_type, timestamp): if not selection.get_data(): return + + # get contact info (check for PM = private chat) if self.TYPE_ID == message_control.TYPE_PM: - c = self.gc_contact + c = self.gc_contact.as_contact() else: c = self.contact + if target_type == self.TARGET_TYPE_URI_LIST: - if not c.resource: # If no resource is known, we can't send a file + # file drag and drop (handled in chat_control_base) + self.drag_data_file_transfer(c, selection, self) + else: + # chat2muc + treeview = app.interface.roster.tree + model = treeview.get_model() + data = selection.get_data() + path = treeview.get_selection().get_selected_rows()[1][0] + iter_ = model.get_iter(path) + type_ = model[iter_][2] + if type_ != 'contact': # source is not a contact return + dropped_jid = data - # we may have more than one file dropped - uri_splitted = selection.get_uris() - for uri in uri_splitted: - path = helpers.get_file_path_from_dnd_dropped_uri(uri) - if os.path.isfile(path): # is it file? - ft = app.interface.instances['file_transfers'] - ft.send_file(self.account, c, path) - return + dropped_transport = app.get_transport_name_from_jid(dropped_jid) + c_transport = app.get_transport_name_from_jid(c.jid) + if dropped_transport or c_transport: + return # transport contacts cannot be invited - # chat2muc - treeview = app.interface.roster.tree - model = treeview.get_model() - data = selection.get_data() - path = treeview.get_selection().get_selected_rows()[1][0] - iter_ = model.get_iter(path) - type_ = model[iter_][2] - if type_ != 'contact': # source is not a contact - return - dropped_jid = data - - dropped_transport = app.get_transport_name_from_jid(dropped_jid) - c_transport = app.get_transport_name_from_jid(c.jid) - if dropped_transport or c_transport: - return # transport contacts cannot be invited - - dialogs.TransformChatToMUC(self.account, [c.jid], [dropped_jid]) + dialogs.TransformChatToMUC(self.account, [c.jid], [dropped_jid]) def _on_message_tv_buffer_changed(self, textbuffer): super()._on_message_tv_buffer_changed(textbuffer) diff --git a/gajim/chat_control_base.py b/gajim/chat_control_base.py index 1b4c18ba2..325835f76 100644 --- a/gajim/chat_control_base.py +++ b/gajim/chat_control_base.py @@ -27,6 +27,7 @@ ## along with Gajim. If not, see . ## +import os import time from gi.repository import Gtk from gi.repository import Gdk @@ -403,6 +404,24 @@ class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools): action.connect('activate', self._on_history) self.parent_win.window.add_action(action) + action = Gio.SimpleAction.new( + 'send-file-%s' % self.control_id, None) + action.connect('activate', self._on_send_file) + action.set_enabled(False) + self.parent_win.window.add_action(action) + + action = Gio.SimpleAction.new( + 'send-file-httpupload-%s' % self.control_id, None) + action.connect('activate', self._on_send_httpupload) + action.set_enabled(False) + self.parent_win.window.add_action(action) + + action = Gio.SimpleAction.new( + 'send-file-jingle-%s' % self.control_id, None) + action.connect('activate', self._on_send_jingle) + action.set_enabled(False) + self.parent_win.window.add_action(action) + # Actions def _on_history(self, action, param): @@ -730,6 +749,44 @@ class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools): self.drag_entered_conv = True self.conv_textview.tv.set_editable(True) + def drag_data_file_transfer(self, contact, selection, widget): + # get file transfer preference + ft_pref = app.config.get_per('accounts', self.account, + 'filetransfer_preference') + win = self.parent_win.window + con = app.connections[self.account] + httpupload = win.lookup_action( + 'send-file-httpupload-%s' % self.control_id) + jingle = win.lookup_action('send-file-jingle-%s' % self.control_id) + + # we may have more than one file dropped + uri_splitted = selection.get_uris() + for uri in uri_splitted: + path = helpers.get_file_path_from_dnd_dropped_uri(uri) + if not os.path.isfile(path): # is it a file? + continue + if self.type_id == message_control.TYPE_GC: + # groupchat only supports httpupload on drag and drop + if httpupload.get_enabled(): + # use httpupload + con.check_file_before_transfer( + path, self.encryption, contact, + self.session, groupchat=True) + else: + if httpupload.get_enabled() and jingle.get_enabled(): + if ft_pref == 'httpupload': + con.check_file_before_transfer( + path, self.encryption, contact, self.session) + else: + ft = app.interface.instances['file_transfers'] + ft.send_file(self.account, contact, path) + elif httpupload.get_enabled(): + con.check_file_before_transfer( + path, self.encryption, contact, self.session) + elif jingle.get_enabled(): + ft = app.interface.instances['file_transfers'] + ft.send_file(self.account, contact, path) + def get_seclabel(self): label = None if self.seclabel_combo is not None: @@ -1065,14 +1122,40 @@ class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools): app.interface.instances['logs'] = \ history_window.HistoryWindow(jid, self.account) - def _on_send_file(self, gc_contact=None): + def _on_send_file(self, action, param): + # get file transfer preference + ft_pref = app.config.get_per('accounts', self.account, + 'filetransfer_preference') + + win = self.parent_win.window + httpupload = win.lookup_action( + 'send-file-httpupload-%s' % self.control_id) + jingle = win.lookup_action('send-file-jingle-%s' % self.control_id) + + if httpupload.get_enabled() and jingle.get_enabled(): + if ft_pref == 'httpupload': + httpupload.activate() + else: + jingle.activate() + elif httpupload.get_enabled(): + httpupload.activate() + elif jingle.get_enabled(): + jingle.activate() + + def _on_send_httpupload(self, action, param): + app.interface.send_httpupload(self) + + def _on_send_jingle(self, action, param): + self._on_send_file_jingle() + + def _on_send_file_jingle(self, gc_contact=None): """ gc_contact can be set when we are in a groupchat control """ def _on_ok(c): app.interface.instances['file_transfers'].show_file_send_request( self.account, c) - if self.TYPE_ID == message_control.TYPE_PM: + if self.type_id == message_control.TYPE_PM: gc_contact = self.gc_contact if gc_contact: # gc or pm diff --git a/gajim/common/config.py b/gajim/common/config.py index 224faabd8..e9d590ebb 100644 --- a/gajim/common/config.py +++ b/gajim/common/config.py @@ -408,6 +408,8 @@ class Config: 'oauth2_redirect_url': [ opt_str, 'https%3A%2F%2Fgajim.org%2Fmsnauth%2Findex.cgi', _('redirect_url for OAuth 2.0 authentication.')], 'opened_chat_controls': [opt_str, '', _('Space separated list of JIDs for which we want to re-open a chat window on next startup.')], 'recent_groupchats': [ opt_str, '' ], + 'httpupload_verify': [ opt_bool, True, _('HTTP Upload: Enable HTTPS Verification')], + 'filetransfer_preference' : [ opt_str, 'httpupload', _('Preferred file transfer mechanism for file drag&drop on chat window. Can be \'httpupload\' (default) or \'jingle\'')], }, {}), 'statusmsg': ({ 'message': [ opt_str, '' ], diff --git a/gajim/common/connection.py b/gajim/common/connection.py index ddeafad62..20da1165e 100644 --- a/gajim/common/connection.py +++ b/gajim/common/connection.py @@ -422,7 +422,10 @@ class CommonConnection: if not obj.is_loggable: return - if obj.forward_from or not obj.session or not obj.session.is_loggable(): + if obj.forward_from: + return + + if obj.session and not obj.session.is_loggable(): return if not app.config.should_log(self.name, jid): @@ -2662,7 +2665,7 @@ class Connection(CommonConnection, ConnectionHandlers): if not obj.xhtml and app.config.get('rst_formatting_outgoing_messages'): from gajim.common.rst_xhtml_generator import create_xhtml obj.xhtml = create_xhtml(obj.message) - + msg_iq = nbxmpp.Message(obj.jid, obj.message, typ='groupchat', xhtml=obj.xhtml) diff --git a/gajim/common/connection_handlers.py b/gajim/common/connection_handlers.py index 7a40be15d..3d3c2d1af 100644 --- a/gajim/common/connection_handlers.py +++ b/gajim/common/connection_handlers.py @@ -54,6 +54,7 @@ from gajim.common.protocol.caps import ConnectionCaps from gajim.common.protocol.bytestream import ConnectionSocks5Bytestream from gajim.common.protocol.bytestream import ConnectionIBBytestream from gajim.common.message_archiving import ConnectionArchive313 +from gajim.common.httpupload import ConnectionHTTPUpload from gajim.common.connection_handlers_events import * from gajim.common import ged @@ -1249,7 +1250,8 @@ class ConnectionHandlersBase: class ConnectionHandlers(ConnectionArchive313, ConnectionVcard, ConnectionSocks5Bytestream, ConnectionDisco, ConnectionCommands, ConnectionPubSub, ConnectionPEP, ConnectionCaps, -ConnectionHandlersBase, ConnectionJingle, ConnectionIBBytestream): +ConnectionHandlersBase, ConnectionJingle, ConnectionIBBytestream, +ConnectionHTTPUpload): def __init__(self): ConnectionArchive313.__init__(self) ConnectionVcard.__init__(self) @@ -1259,6 +1261,7 @@ ConnectionHandlersBase, ConnectionJingle, ConnectionIBBytestream): ConnectionPubSub.__init__(self) ConnectionPEP.__init__(self, account=self.name, dispatcher=self, pubsub_connection=self) + ConnectionHTTPUpload.__init__(self, account=self.name) # Handle presences BEFORE caps app.nec.register_incoming_event(PresenceReceivedEvent) @@ -1343,6 +1346,7 @@ ConnectionHandlersBase, ConnectionJingle, ConnectionIBBytestream): ConnectionCaps.cleanup(self) ConnectionArchive313.cleanup(self) ConnectionPubSub.cleanup(self) + ConnectionHTTPUpload.cleanup(self) app.ged.remove_event_handler('http-auth-received', ged.CORE, self._nec_http_auth_received) app.ged.remove_event_handler('version-request-received', ged.CORE, diff --git a/gajim/common/connection_handlers_events.py b/gajim/common/connection_handlers_events.py index 2ad7b78bf..98d03a64d 100644 --- a/gajim/common/connection_handlers_events.py +++ b/gajim/common/connection_handlers_events.py @@ -2958,3 +2958,17 @@ class BlockingEvent(nec.NetworkIncomingEvent): app.log('blocking').info( 'Blocking Push - unblocked JIDs: %s', self.unblocked_jids) return True + +class HTTPUploadStartEvent(nec.NetworkIncomingEvent): + name = 'httpupload-start' + base_network_events = [] + + def generate(self): + return True + +class HTTPUploadProgressEvent(nec.NetworkIncomingEvent): + name = 'httpupload-progress' + base_network_events = [] + + def generate(self): + return True \ No newline at end of file diff --git a/gajim/common/httpupload.py b/gajim/common/httpupload.py new file mode 100644 index 000000000..49ea37b58 --- /dev/null +++ b/gajim/common/httpupload.py @@ -0,0 +1,384 @@ +# -*- 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 . + +import os +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.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') + + +class ConnectionHTTPUpload: + """ + Implement HTTP File Upload + (XEP-0363, https://xmpp.org/extensions/xep-0363.html) + """ + def __init__(self, account): + self.name = account + self.encrypted_upload = False + self.component = None + 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): + if (NS_HTTPUPLOAD not in event.features or not + app.jid_is_transport(event.jid)): + return + + if not event.id_.startswith('Gajim_'): + return + + account = event.conn.name + self.component = event.jid + + for form in event.data: + form_dict = form.asDict() + if form_dict.get('FORM_TYPE', None) != NS_HTTPUPLOAD: + 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)) + + def handle_outgoing_stanza(self, event): + 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 = nbxmpp.Iq(typ='get', to=self.component) + id_ = app.get_an_id() + iq.setID(id_) + request = iq.setTag(name="request", namespace=NS_HTTPUPLOAD) + request.addChild('filename', payload=os.path.basename(file.path)) + request.addChild('size', payload=file.size) + request.addChild('content-type', payload=file.mime) + + log.info("Sending request for slot") + app.connections[self.name].connection.SendAndCallForResponse( + iq, self.received_slot, {'file': file}) + + @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: + file.put = stanza.getTag("slot").getTag("put").getData() + file.get = stanza.getTag("slot").getTag("get").getData() + except Exception: + log.error("Got unexpected 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: + headers = {'User-Agent': 'Gajim %s' % app.version, + 'Content-Type': file.mime, + 'Content-Length': file.size} + + request = Request( + file.put, data=file.stream, headers=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.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" diff --git a/gajim/data/gui/chat_control.ui b/gajim/data/gui/chat_control.ui index 5bf3e2303..74f560bae 100644 --- a/gajim/data/gui/chat_control.ui +++ b/gajim/data/gui/chat_control.ui @@ -1,5 +1,5 @@ - + @@ -821,9 +821,6 @@ audio-mic-volume-low 4 - - - True @@ -846,9 +843,39 @@ audio-mic-volume-low False True end + 5 + + + + + True + True + False + True + GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK + none + + + True + False + mail-attachment-symbolic + 1 + + + + + + False + False + end 6 + + + False diff --git a/gajim/data/gui/groupchat_control.ui b/gajim/data/gui/groupchat_control.ui index 6115478e9..8e0169d09 100644 --- a/gajim/data/gui/groupchat_control.ui +++ b/gajim/data/gui/groupchat_control.ui @@ -1,5 +1,5 @@ - + @@ -340,6 +340,31 @@ 5 + + + True + True + False + True + none + + + True + False + mail-attachment-symbolic + + + + + + False + False + end + 6 + + False diff --git a/gajim/data/gui/httpupload_progress_dialog.ui b/gajim/data/gui/httpupload_progress_dialog.ui new file mode 100644 index 000000000..2ee9d43b8 --- /dev/null +++ b/gajim/data/gui/httpupload_progress_dialog.ui @@ -0,0 +1,58 @@ + + + + + + True + False + vertical + + + True + False + 8 + 4 + 8 + 8 + + + True + False + + + + + + + + + False + True + 0 + + + + + True + False + 4 + 4 + 8 + 8 + + + True + False + 0.10000000149 + True + + + + + False + True + 1 + + + + diff --git a/gajim/dialog_messages.py b/gajim/dialog_messages.py index a58c91b64..4e2dd29d9 100644 --- a/gajim/dialog_messages.py +++ b/gajim/dialog_messages.py @@ -133,6 +133,46 @@ messages = { _('%s\nLink-local messaging might not work properly.'), ErrorDialog), + 'request-upload-slot-error': Message( + _('Could not request upload slot'), + '%s', + ErrorDialog), + + 'request-upload-slot-error2': Message( + _('Could not request upload slot'), + _('Got unexpected response from server (see log)'), + ErrorDialog), + + 'open-file-error': Message( + _('Could not open file'), + _('Exception raised while opening file (see log)'), + ErrorDialog), + + 'open-file-error2': Message( + _('Could not open file'), + '%s', + ErrorDialog), + + 'unsecure-error': Message( + _('Unsecure'), + _('Server returned unsecure transport (http)'), + ErrorDialog), + + 'httpupload-response-error': Message( + _('Could not upload file'), + _('HTTP response code from server: %s'), + ErrorDialog), + + 'httpupload-error': Message( + _('Upload Error'), + '%s', + ErrorDialog), + + 'httpupload-encryption-not-available': Message( + _('Encryption Error'), + _('For the choosen encryption is no encryption method available'), + ErrorDialog), + } diff --git a/gajim/dialogs.py b/gajim/dialogs.py index 56b6f4bfd..1e8ea8c83 100644 --- a/gajim/dialogs.py +++ b/gajim/dialogs.py @@ -5417,3 +5417,65 @@ class SSLErrorDialog(ConfirmationDialogDoubleCheck): def on_cert_clicked(self, button): d = CertificatDialog(self, self.account, self.cert) + +class ProgressWindow(Gtk.ApplicationWindow): + def __init__(self, file): + Gtk.ApplicationWindow.__init__(self) + self.set_name('HTTPUploadProgressWindow') + self.set_application(app.app) + self.set_position(Gtk.WindowPosition.CENTER) + self.set_show_menubar(False) + self.set_title(_('File Transfer')) + self.set_default_size(250, -1) + + self.event = file.event + self.file = file + self.xml = gtkgui_helpers.get_gtk_builder( + 'httpupload_progress_dialog.ui') + + self.label = self.xml.get_object('label') + self.progressbar = self.xml.get_object('progressbar') + + self.add(self.xml.get_object('box')) + + self.pulse = GLib.timeout_add(100, self._pulse_progressbar) + self.show_all() + + self.connect('destroy', self._on_destroy) + app.ged.register_event_handler('httpupload-progress', ged.CORE, + self._on_httpupload_progress) + + def _on_httpupload_progress(self, obj): + if self.file != obj.file: + return + if obj.status == 'request': + self.label.set_text(_('Requesting HTTP Upload Slot...')) + elif obj.status == 'close': + self.destroy() + elif obj.status == 'upload': + self.label.set_text(_('Uploading file via HTTP File Upload...')) + elif obj.status == 'update': + self.update_progress(obj.seen, obj.total) + elif obj.status == 'encrypt': + self.label.set_text(_('Encrypting file...')) + + def _pulse_progressbar(self): + self.progressbar.pulse() + return True + + def _on_destroy(self, *args): + self.event.set() + if self.pulse: + GLib.source_remove(self.pulse) + app.ged.remove_event_handler('httpupload-progress', ged.CORE, + self._on_httpupload_progress) + + def update_progress(self, seen, total): + if self.event.isSet(): + return + if self.pulse: + GLib.source_remove(self.pulse) + self.pulse = None + pct = (float(seen) / total) * 100.0 + self.progressbar.set_fraction(float(seen) / total) + self.progressbar.set_text(str(int(pct)) + "%") diff --git a/gajim/groupchat_control.py b/gajim/groupchat_control.py index ca210c5db..aa91b616a 100644 --- a/gajim/groupchat_control.py +++ b/gajim/groupchat_control.py @@ -58,6 +58,7 @@ from gajim.common import helpers from gajim.common import dataforms from gajim.common import ged from gajim.common import i18n +from gajim.common import contacts from gajim.chat_control import ChatControl from gajim.chat_control_base import ChatControlBase @@ -444,6 +445,11 @@ class GroupchatControl(ChatControlBase): self.form_widget = None + # Send file + self.sendfile_button = self.xml.get_object('sendfile_button') + self.sendfile_button.set_action_name('win.send-file-' + \ + self.control_id) + # Encryption self.lock_image = self.xml.get_object('lock_image') self.authentication_button = self.xml.get_object( @@ -589,6 +595,24 @@ class GroupchatControl(ChatControlBase): win.lookup_action('execute-command-' + self.control_id).set_enabled( online) + # Send file (HTTP File Upload) + httpupload = win.lookup_action( + 'send-file-httpupload-' + self.control_id) + httpupload.set_enabled( + online and app.connections[self.account].httpupload) + + win.lookup_action('send-file-' + self.control_id).set_enabled( + httpupload.get_enabled()) + + tooltip_text = None + if online: + if httpupload.get_enabled(): + tooltip_text = _('HTTP File Upload') + else: + tooltip_text = _('HTTP File Upload not supported ' + 'by your server') + self.sendfile_button.set_tooltip_text(tooltip_text) + # Actions def _on_change_subject(self, action, param): @@ -1565,12 +1589,6 @@ class GroupchatControl(ChatControlBase): if ctrl and msg: ctrl.send_message(msg) - def on_send_file(self, widget, gc_contact): - """ - Send a file to a contact in the room - """ - self._on_send_file(gc_contact) - def draw_contact(self, nick, selected=False, focus=False): iter_ = self.get_contact_iter(nick) if not iter_: @@ -2294,23 +2312,31 @@ class GroupchatControl(ChatControlBase): ok_handler=on_ok, transient_for=self.parent_win.window) def _on_drag_data_received(self, widget, context, x, y, selection, - target_type, timestamp): - # Invite contact to groupchat - treeview = app.interface.roster.tree - model = treeview.get_model() - if not selection.get_data() or target_type == 80: - # target_type = 80 means a file is dropped + target_type, timestamp): + if not selection.get_data(): return - data = selection.get_data() - path = treeview.get_selection().get_selected_rows()[1][0] - iter_ = model.get_iter(path) - type_ = model[iter_][2] - if type_ != 'contact': # source is not a contact - return - contact_jid = data - app.connections[self.account].send_invite(self.room_jid, contact_jid) - self.print_conversation(_('%(jid)s has been invited in this room') % { - 'jid': contact_jid}, graphics=False) + + # get contact info + contact = contacts.Contact(jid=self.room_jid, account=self.account) + + if target_type == self.TARGET_TYPE_URI_LIST: + # file drag and drop (handled in chat_control_base) + self.drag_data_file_transfer(contact, selection, self) + else: + # Invite contact to groupchat + treeview = app.interface.roster.tree + model = treeview.get_model() + data = selection.get_data() + path = treeview.get_selection().get_selected_rows()[1][0] + iter_ = model.get_iter(path) + type_ = model[iter_][2] + if type_ != 'contact': # source is not a contact + return + contact_jid = data + + app.connections[self.account].send_invite(self.room_jid, contact_jid) + self.print_conversation(_('%(jid)s has been invited in this room') % + {'jid': contact_jid}, graphics=False) def _on_message_textview_key_press_event(self, widget, event): res = ChatControlBase._on_message_textview_key_press_event(self, widget, @@ -2605,7 +2631,9 @@ class GroupchatControl(ChatControlBase): if not c.resource: item.set_sensitive(False) else: - id_ = item.connect('activate', self.on_send_file, c) + item.set_sensitive(False) + # ToDo: integrate HTTP File Upload + id_ = item.connect('activate', self._on_send_file_jingle, c) self.handlers[id_] = item # show the popup now! diff --git a/gajim/gui_interface.py b/gajim/gui_interface.py index 9d52c03e6..8bb11fdd5 100644 --- a/gajim/gui_interface.py +++ b/gajim/gui_interface.py @@ -64,6 +64,8 @@ from gajim import dialogs from gajim import notify from gajim import message_control from gajim.dialog_messages import get_dialog +from gajim.dialogs import ProgressWindow +from gajim.dialogs import FileChooserDialog from gajim.chat_control_base import ChatControlBase from gajim.chat_control import ChatControl @@ -88,7 +90,7 @@ from gajim.common import passwords from gajim.common import logging_helpers from gajim.common.connection_handlers_events import ( OurShowEvent, FileRequestErrorEvent, FileTransferCompletedEvent, - UpdateRosterAvatarEvent, UpdateGCAvatarEvent) + UpdateRosterAvatarEvent, UpdateGCAvatarEvent, HTTPUploadProgressEvent) from gajim.common.connection import Connection from gajim.common.file_props import FilesProp from gajim.common import pep @@ -1139,6 +1141,45 @@ class Interface: app.config.get_per('accounts', account, 'publish_location')): location_listener.enable() + @staticmethod + def show_httpupload_progress(file): + ProgressWindow(file) + + def send_httpupload(self, chat_control): + FileChooserDialog( + on_response_ok=lambda widget: self.on_file_dialog_ok(widget, + chat_control), + title_text=_('Choose file to send'), + action=Gtk.FileChooserAction.OPEN, + buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_OPEN, Gtk.ResponseType.OK), + default_response=Gtk.ResponseType.OK, + transient_for=chat_control.parent_win.window) + + @staticmethod + def on_file_dialog_ok(widget, chat_control): + path = widget.get_filename() + widget.destroy() + con = app.connections[chat_control.account] + groupchat = chat_control.type_id == message_control.TYPE_GC + con.check_file_before_transfer(path, + chat_control.encryption, + chat_control.contact, + chat_control.session, + groupchat) + + def encrypt_file(self, file, callback): + app.nec.push_incoming_event(HTTPUploadProgressEvent( + None, status='encrypt', file=file)) + encryption = file.encryption + plugin = app.plugin_manager.encryption_plugins[encryption] + if hasattr(plugin, 'encrypt_file'): + plugin.encrypt_file(file, None, callback) + else: + app.nec.push_incoming_event(HTTPUploadProgressEvent( + None, status='close', file=file)) + self.raise_dialog('httpupload-encryption-not-available') + @staticmethod def handle_event_metacontacts(obj): app.contacts.define_metacontacts(obj.conn.name, obj.meta_list) diff --git a/gajim/gui_menu_builder.py b/gajim/gui_menu_builder.py index 011da1ec3..ce1980fa5 100644 --- a/gajim/gui_menu_builder.py +++ b/gajim/gui_menu_builder.py @@ -608,7 +608,10 @@ Build dynamic Application Menus def get_singlechat_menu(control_id): singlechat_menu = [ - ('win.send-file-', _('Send File...')), + (_('Send File...'), [ + ('win.send-file-httpupload-', 'HTTP Upload'), + ('win.send-file-jingle-', 'Jingle'), + ]), ('win.invite-contacts-', _('Invite Contacts')), ('win.add-to-roster-', _('Add to Roster')), ('win.toggle-audio-', _('Audio Session')), @@ -620,11 +623,17 @@ def get_singlechat_menu(control_id): def build_menu(preset): menu = Gio.Menu() for item in preset: - action_name, label = item - if action_name == 'win.browse-history-': - menu.append(label, action_name + control_id + '::none') + if isinstance(item[1], str): + action_name, label = item + if action_name == 'win.browse-history-': + menu.append(label, action_name + control_id + '::none') + else: + menu.append(label, action_name + control_id) else: - menu.append(label, action_name + control_id) + label, sub_menu = item + # This is a submenu + submenu = build_menu(sub_menu) + menu.append_submenu(label, submenu) return menu return build_menu(singlechat_menu)