# -*- 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 . from gi.repository import GObject, Gtk, GLib import os import sys import time from urllib.request import Request, urlopen import mimetypes # better use the magic packet, but that's not a standard lib import gtkgui_helpers import logging from queue import Queue import binascii from common import gajim from common import ged from plugins import GajimPlugin from plugins.helpers import log_calls from dialogs import FileChooserDialog, ErrorDialog import nbxmpp log = logging.getLogger('gajim.plugin_system.httpupload') try: if os.name == 'nt': from cryptography.hazmat.backends.openssl import backend else: from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher from cryptography.hazmat.primitives.ciphers import algorithms from cryptography.hazmat.primitives.ciphers.modes import GCM ENCRYPTION_AVAILABLE = True except Exception as exc: DEP_MSG = 'For encryption of files, ' \ 'please install python-cryptography!' log.debug('Cryptography Import Error: %s', exc) log.info('Decryption/Encryption disabled due to errors') ENCRYPTION_AVAILABLE = False # XEP-0363 (http://xmpp.org/extensions/xep-0363.html) IQ_CALLBACK = {} NS_HTTPUPLOAD = 'urn:xmpp:http:upload' TAGSIZE = 16 class HttpuploadPlugin(GajimPlugin): def init(self): if not ENCRYPTION_AVAILABLE: self.available_text = DEP_MSG self.config_dialog = None # HttpuploadPluginConfigDialog(self) self.events_handlers = {} self.events_handlers['agent-info-received'] = ( ged.PRECORE, self.handle_agent_info_received) self.events_handlers['raw-iq-received'] = ( ged.PRECORE, self.handle_iq_received) self.gui_extension_points = { 'chat_control_base': (self.connect_with_chat_control, self.disconnect_from_chat_control), 'chat_control_base_update_toolbar': (self.update_chat_control, None)} self.gui_interfaces = {} @staticmethod def handle_iq_received(event): id_ = event.stanza.getAttr("id") if id_ in IQ_CALLBACK: try: IQ_CALLBACK[id_](event.stanza) except: raise finally: del IQ_CALLBACK[id_] def handle_agent_info_received(self, event): if (NS_HTTPUPLOAD in event.features and gajim.jid_is_transport(event.jid)): account = event.conn.name interface = self.get_interface(account) interface.enabled = True interface.component = event.jid interface.update_button_states(True) def connect_with_chat_control(self, chat_control): account = chat_control.contact.account.name self.get_interface(account).add_button(chat_control) def disconnect_from_chat_control(self, chat_control): jid = chat_control.contact.jid account = chat_control.account interface = self.get_interface(account) if jid not in interface.controls: return actions_hbox = chat_control.xml.get_object('actions_hbox') actions_hbox.remove(interface.controls[jid]) def update_chat_control(self, chat_control): account = chat_control.account if gajim.connections[account].connection is None: self.get_interface(account).update_button_states(False) def get_interface(self, account): try: return self.gui_interfaces[account] except KeyError: self.gui_interfaces[account] = Base(self, account) return self.gui_interfaces[account] class Base(object): def __init__(self, plugin, account): self.plugin = plugin self.account = account self.encrypted_upload = False self.enabled = False self.component = None self.controls = {} self.conn = gajim.connections[account].connection def add_button(self, chat_control): jid = chat_control.contact.jid img = Gtk.Image() img.set_from_file(self.plugin.local_file_path('httpupload.png')) actions_hbox = chat_control.xml.get_object('actions_hbox') button = Gtk.Button(label=None, stock=None, use_underline=True) button.set_property('can-focus', False) button.set_image(img) button.set_relief(Gtk.ReliefStyle.NONE) actions_hbox.add(button) send_button = chat_control.xml.get_object('send_button') button_pos = actions_hbox.child_get_property(send_button, 'position') actions_hbox.child_set_property(button, 'position', button_pos - 1) self.controls[jid] = button id_ = button.connect('clicked', self.on_file_button_clicked, jid, chat_control) chat_control.handlers[id_] = button self.set_button_state(self.enabled, button) button.show() @staticmethod def set_button_state(state, button): if state: button.set_sensitive(state) button.set_tooltip_text(_('Send file via http upload')) else: button.set_sensitive(state) button.set_tooltip_text( _('Your server does not support http uploads')) def update_button_states(self, state): for jid in self.controls: self.set_button_state(state, self.controls[jid]) def encryption_activated(self, jid): if not ENCRYPTION_AVAILABLE: return False for plugin in gajim.plugin_manager.active_plugins: if type(plugin).__name__ == 'OmemoPlugin': state = plugin.get_omemo_state(self.account) encryption = state.encryption.is_active(jid) log.info('Encryption is: %s', encryption) return encryption log.info('OMEMO not found, encryption disabled') return False def on_file_dialog_ok(self, widget, jid, chat_control): path_to_file = widget.get_filename() widget.destroy() if not path_to_file or not os.path.exists(path_to_file): return invalid_file = False if os.path.isfile(path_to_file): stat = os.stat(path_to_file) if stat[6] == 0: invalid_file = True msg = _('File is empty') else: invalid_file = True msg = _('File does not exist') if invalid_file: ErrorDialog(_('Could not open file'), msg, transient_for=chat_control.parent_win.window) return encrypted = self.encryption_activated(jid) filesize = os.path.getsize(path_to_file) if encrypted: filesize += TAGSIZE mime_type = mimetypes.MimeTypes().guess_type(path_to_file)[0] if not mime_type: mime_type = 'application/octet-stream' # fallback mime type log.info("Detected MIME type of file: ", mime_type) progress_messages = Queue(8) progress_window = ProgressWindow(_('HTTP Upload'), _('Requesting HTTP Upload Slot...'), progress_messages, self.plugin, parent=chat_control.parent_win.window) self.request_slot(path_to_file, filesize, mime_type, encrypted) def upload_file(stanza): slot = stanza.getTag("slot") if not slot: log.error("got unexpected stanza: "+str(stanza)) progress_window.close_dialog() error = stanza.getTag("error") if error and error.getTag("text"): ErrorDialog(_('Could not request upload slot'), _('Got unexpected response from server: %s') % str(error.getTagData("text")), transient_for=self.chat_control.parent_win.window) else: ErrorDialog(_('Could not request upload slot'), _('Got unexpected response from server (protocol mismatch??)'), transient_for=self.chat_control.parent_win.window) return try: if self.encrypted_upload: key = os.urandom(32) iv = os.urandom(16) data = StreamFileWithProgress(path_to_file, "rb", progress_window.update_progress, self.encrypted_upload, key, iv) else: data = StreamFileWithProgress(path_to_file, "rb", progress_window.update_progress) except: log.error("Could not open file") progress_window.close_dialog() ErrorDialog(_('Could not open file'), _('Exception raised while opening file (see error log for more information)'), transient_for=self.chat_control.parent_win.window) raise # fill error log with useful information put = slot.getTag("put") get = slot.getTag("get") if not put or not get: log.error("got unexpected stanza: " + str(stanza)) progress_window.close_dialog() ErrorDialog(_('Could not request upload slot'), _('Got unexpected response from server (protocol mismatch??)'), transient_for=self.chat_control.parent_win.window) return def upload_complete(response_code): if response_code == 0: return # Upload was aborted if 200 <= response_code < 300: log.info("Upload completed successfully") xhtml = None is_image = mime_type.split('/', 1)[0] == 'image' progress_window.close_dialog() id_ = gajim.get_an_id() def add_oob_tag(): pass if self.encrypted_upload: keyAndIv = '#' + binascii.hexlify(iv) + binascii.hexlify(key) self.chat_control.send_message(message=get.getData() + keyAndIv, xhtml=None) else: self.chat_control.send_message(message=get.getData(), xhtml=xhtml) self.chat_control.msg_textview.grab_focus() else: progress_window.close_dialog() log.error("got unexpected http upload response code: " + str(response_code)) ErrorDialog(_('Could not upload file'), _('Got unexpected http response code from server: ') + str(response_code), transient_for=self.chat_control.parent_win.window) def on_upload_error(): progress_window.close_dialog() ErrorDialog(_('Could not upload file'), _('Got unexpected exception while uploading file' ' (see error log for more information)'), transient_for=self.chat_control.parent_win.window) return 0 def uploader(): progress_messages.put(_('Uploading file via HTTP...')) try: headers = {'User-Agent': 'Gajim %s' % gajim.version, 'Content-Type': mime_type} request = Request(put.getData(), data=data, headers=headers, method='PUT') log.debug("opening urllib upload request...") transfer = urlopen(request, timeout=30) data.close() log.debug("urllib upload request done, response code: " + str(transfer.getcode())) return transfer.getcode() except UploadAbortedException: log.info("Upload aborted") except: log.error("Exception during upload", exc_info=sys.exc_info()) GLib.idle_add(on_upload_error) return 0 log.info("Uploading file to '%s'..." % str(put.getData())) log.info("Please download from '%s' later..." % str(get.getData())) gajim.thread_interface(uploader, [], upload_complete) is_supported = gajim.get_jid_from_account(self.chat_control.account) in jid_to_servers and \ gajim.connections[self.chat_control.account].connection != None log.info("jid_to_servers of %s: %s ; connection: %s", gajim.get_jid_from_account(self.chat_control.account), str(jid_to_servers[gajim.get_jid_from_account(self.chat_control.account)]), str(gajim.connections[self.chat_control.account].connection)) if not is_supported: progress_window.close_dialog() log.error("upload component vanished, account got disconnected??") ErrorDialog(_('Your server does not support http uploads or you just got disconnected.\nPlease try to reconnect or reopen the chat window to fix this.'), transient_for=self.chat_control.parent_win.window) return self.chat_control.msg_textview.grab_focus() def on_file_button_clicked(self, widget, jid, chat_control): FileChooserDialog( on_response_ok=lambda widget: self.on_file_dialog_ok(widget, jid, 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) def request_slot(self, path_to_file, filesize, mime_type, encrypted): iq = nbxmpp.Iq(typ='get', to=self.component) id_ = gajim.get_an_id() iq.setID(id_) request = iq.setTag(name="request", namespace=NS_HTTPUPLOAD) request.addChild('filename', payload=os.path.basename(path_to_file)) request.addChild('size', payload=filesize) request.addChild('content-type', payload=mime_type) log.info("Sending request for slot") IQ_CALLBACK[id_] = \ lambda stanza: self.upload_file( stanza, path_to_file, filesize, mime_type, encrypted) self.conn.send(iq) class StreamFileWithProgress: def __init__(self, path, mode, callback=None, encrypted_upload=False, key=None, iv=None, *args): self.backing = open(path, mode) self.encrypted_upload = encrypted_upload self.backing.seek(0, os.SEEK_END) if self.encrypted_upload: if os.name == 'nt': self.backend = backend else: self.backend = default_backend() self.encryptor = Cipher( algorithms.AES(key), GCM(iv), backend=self.backend).encryptor() self._total = self.backing.tell() + TAGSIZE else: self._total = self.backing.tell() self.backing.seek(0) self._callback = callback self._args = args self._seen = 0 def __len__(self): return self._total def read(self, size): if self.encrypted_upload: data = self.backing.read(size) if len(data) > 0: data = self.encryptor.update(data) self._seen += len(data) if (self._seen + TAGSIZE) == self._total: self.encryptor.finalize() data += self.encryptor.tag self._seen += TAGSIZE if self._callback: self._callback(self._seen, self._total, *self._args) return data else: data = self.backing.read(size) self._seen += len(data) if self._callback: self._callback(self._seen, self._total, *self._args) return data def close(self): return self.backing.close() class ProgressWindow: def __init__(self, title_text, during_text, messages_queue, plugin, parent): self.plugin = plugin self.xml = gtkgui_helpers.get_gtk_builder(self.plugin.local_file_path('upload_progress_dialog.ui')) self.messages_queue = messages_queue self.dialog = self.xml.get_object('progress_dialog') self.dialog.set_transient_for(parent) self.label = self.xml.get_object('label') self.cancel_button = self.xml.get_object('close_button') self.label.set_markup('' + during_text + '') self.progressbar = self.xml.get_object('progressbar') self.progressbar.set_text("") self.dialog.set_title(title_text) #self.dialog.set_geometry_hints(min_width=400, min_height=96) #self.dialog.set_position(Gtk.WIN_POS_CENTER_ON_PARENT) self.dialog.show_all() self.xml.connect_signals(self) self.stopped = False self.pulse_progressbar_timeout_id = GLib.timeout_add(100, self.pulse_progressbar) self.process_messages_queue_timeout_id = GLib.timeout_add(100, self.process_messages_queue) def pulse_progressbar(self): if self.dialog: self.progressbar.pulse() return True # loop forever return False def process_messages_queue(self): if not self.messages_queue.empty(): self.label.set_markup('' + self.messages_queue.get() + '') if self.dialog: return True # loop forever return False def on_progress_dialog_delete_event(self, widget, event): self.stopped = True if self.pulse_progressbar_timeout_id: GLib.source_remove(self.pulse_progressbar_timeout_id) GLib.source_remove(self.process_messages_queue_timeout_id) def on_cancel(self, widget): self.stopped = True if self.pulse_progressbar_timeout_id: GLib.source_remove(self.pulse_progressbar_timeout_id) GLib.source_remove(self.process_messages_queue_timeout_id) self.dialog.destroy() def update_progress(self, seen, total): if self.stopped == True: raise UploadAbortedException if self.pulse_progressbar_timeout_id: GLib.source_remove(self.pulse_progressbar_timeout_id) self.pulse_progressbar_timeout_id = None pct = (float(seen) / total) * 100.0 self.progressbar.set_fraction(float(seen) / total) self.progressbar.set_text(str(int(pct)) + "%") log.debug('upload progress: %.2f%% (%d of %d bytes)' % (pct, seen, total)) def close_dialog(self): self.on_cancel(None) class UploadAbortedException(Exception): def __str__(self): return "Upload Aborted"