httpupload.py 15.9 KB
Newer Older
Linus's avatar
Linus committed
1
# -*- coding: utf-8 -*-
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#
# 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/>.

Linus's avatar
Linus committed
17
import os
18
import threading
19
import ssl
20
import urllib
Linus's avatar
Linus committed
21
from urllib.request import Request, urlopen
22
import mimetypes
Linus's avatar
Linus committed
23
import logging
24
from binascii import hexlify
25
26
if os.name == 'nt':
    import certifi
Linus's avatar
Linus committed
27

28
29
30
import nbxmpp
from gi.repository import Gtk, GLib

Linus's avatar
Linus committed
31
32
33
from common import gajim
from common import ged
from plugins import GajimPlugin
34
from dialogs import FileChooserDialog, ErrorDialog
Linus's avatar
Linus committed
35
36
37

log = logging.getLogger('gajim.plugin_system.httpupload')

38
try:
39
    from cryptography.hazmat.backends import default_backend
40
41
42
    from cryptography.hazmat.primitives.ciphers import Cipher
    from cryptography.hazmat.primitives.ciphers import algorithms
    from cryptography.hazmat.primitives.ciphers.modes import GCM
43
44
    ENCRYPTION_AVAILABLE = True
except Exception as exc:
45
46
    DEP_MSG = 'For encryption of files, ' \
              'please install python-cryptography!'
47
    log.error('Cryptography Import Error: %s', exc)
48
    log.info('Decryption/Encryption disabled due to errors')
49
    ENCRYPTION_AVAILABLE = False
Linus's avatar
Linus committed
50

51
IQ_CALLBACK = {}
Linus's avatar
Linus committed
52
53
54
55
56
57
NS_HTTPUPLOAD = 'urn:xmpp:http:upload'
TAGSIZE = 16


class HttpuploadPlugin(GajimPlugin):
    def init(self):
58
        if not ENCRYPTION_AVAILABLE:
59
            self.available_text = DEP_MSG
60
        self.config_dialog = None
Linus's avatar
Linus committed
61
        self.events_handlers = {}
62
63
64
65
        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)
Linus's avatar
Linus committed
66
67
        self.gui_extension_points = {
            'chat_control_base': (self.connect_with_chat_control,
68
69
70
71
                                  self.disconnect_from_chat_control),
            'chat_control_base_update_toolbar': (self.update_chat_control,
                                                 None)}
        self.gui_interfaces = {}
Linus's avatar
Linus committed
72

73
74
    @staticmethod
    def handle_iq_received(event):
Linus's avatar
Linus committed
75
        id_ = event.stanza.getAttr("id")
76
        if id_ in IQ_CALLBACK:
Linus's avatar
Linus committed
77
            try:
78
                IQ_CALLBACK[id_](event.stanza)
Linus's avatar
Linus committed
79
80
81
            except:
                raise
            finally:
82
                del IQ_CALLBACK[id_]
Linus's avatar
Linus committed
83
84

    def handle_agent_info_received(self, event):
85
86
87
88
89
90
91
92
93
94
95
96
        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)

Linus's avatar
Linus committed
97
    def disconnect_from_chat_control(self, chat_control):
98
99
100
101
102
103
104
        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])
Linus's avatar
Linus committed
105

106
107
108
109
110
111
112
113
114
    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:
115
            self.gui_interfaces[account] = Base(self, account)
116
            return self.gui_interfaces[account]
Linus's avatar
Linus committed
117
118
119


class Base(object):
120
    def __init__(self, plugin, account):
Linus's avatar
Linus committed
121
        self.plugin = plugin
122
        self.account = account
Linus's avatar
Linus committed
123
        self.encrypted_upload = False
124
125
126
        self.enabled = False
        self.component = None
        self.controls = {}
127
        self.conn = gajim.connections[account].connection
128
129
130
131

    def add_button(self, chat_control):
        jid = chat_control.contact.jid

Linus's avatar
Linus committed
132
        img = Gtk.Image()
Linus's avatar
Linus committed
133
        img.set_from_file(self.plugin.local_file_path('httpupload.png'))
134
135
136
137
138
        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)
139

140
        actions_hbox.add(button)
Linus's avatar
Linus committed
141
        send_button = chat_control.xml.get_object('send_button')
142
143
144
145
        button_pos = actions_hbox.child_get_property(send_button, 'position')
        actions_hbox.child_set_property(button, 'position', button_pos - 1)

        self.controls[jid] = button
146
147
        id_ = button.connect(
            'clicked', self.on_file_button_clicked, jid, chat_control)
148
149
150
151
152
153
154
155
156
157
158
159
160
        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'))
Linus's avatar
Linus committed
161

162
163
164
    def update_button_states(self, state):
        for jid in self.controls:
            self.set_button_state(state, self.controls[jid])
Linus's avatar
Linus committed
165

166
167
    def encryption_activated(self, jid):
        if not ENCRYPTION_AVAILABLE:
Linus's avatar
Linus committed
168
169
170
            return False
        for plugin in gajim.plugin_manager.active_plugins:
            if type(plugin).__name__ == 'OmemoPlugin':
171
172
                state = plugin.get_omemo_state(self.account)
                encryption = state.encryption.is_active(jid)
173
174
                log.info('Encryption is: %s', bool(encryption))
                return bool(encryption)
175
        log.info('OMEMO not found, encryption disabled')
Linus's avatar
Linus committed
176
177
        return False

178
    def on_file_dialog_ok(self, widget, jid, chat_control):
Philipp Hörist's avatar
Philipp Hörist committed
179
        path = widget.get_filename()
180
        widget.destroy()
Linus's avatar
Linus committed
181

Philipp Hörist's avatar
Philipp Hörist committed
182
        if not path or not os.path.exists(path):
Linus's avatar
Linus committed
183
            return
184

Linus's avatar
Linus committed
185
        invalid_file = False
Philipp Hörist's avatar
Philipp Hörist committed
186
187
        if os.path.isfile(path):
            stat = os.stat(path)
Linus's avatar
Linus committed
188
189
190
191
192
193
194
            if stat[6] == 0:
                invalid_file = True
                msg = _('File is empty')
        else:
            invalid_file = True
            msg = _('File does not exist')
        if invalid_file:
195
196
            ErrorDialog(_('Could not open file'), msg,
                        transient_for=chat_control.parent_win.window)
Linus's avatar
Linus committed
197
198
            return

199
        encrypted = self.encryption_activated(jid)
Philipp Hörist's avatar
Philipp Hörist committed
200
201
        size = os.path.getsize(path)
        key, iv = None, None
202
        if encrypted:
Philipp Hörist's avatar
Philipp Hörist committed
203
204
205
            key = os.urandom(32)
            iv = os.urandom(16)
            size += TAGSIZE
206

Philipp Hörist's avatar
Philipp Hörist committed
207
208
209
        mime = mimetypes.MimeTypes().guess_type(path)[0]
        if not mime:
            mime = 'application/octet-stream'  # fallback mime type
210
        log.info("Detected MIME type of file: %s", mime)
211

212
213
214
        event = threading.Event()
        progress = ProgressWindow(
            self.plugin, chat_control.parent_win.window, event)
215

Philipp Hörist's avatar
Philipp Hörist committed
216
217
        file = File(path=path, size=size, mime=mime, encrypted=encrypted,
                    key=key, iv=iv, control=chat_control,
218
                    progress=progress, event=event)
Philipp Hörist's avatar
Philipp Hörist committed
219
        self.request_slot(file)
220

221
    def on_file_button_clicked(self, widget, jid, chat_control):
222
223
224
225
226
227
228
229
230
        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)
Linus's avatar
Linus committed
231

Philipp Hörist's avatar
Philipp Hörist committed
232
    def request_slot(self, file):
233
234
235
236
        iq = nbxmpp.Iq(typ='get', to=self.component)
        id_ = gajim.get_an_id()
        iq.setID(id_)
        request = iq.setTag(name="request", namespace=NS_HTTPUPLOAD)
Philipp Hörist's avatar
Philipp Hörist committed
237
238
239
        request.addChild('filename', payload=os.path.basename(file.path))
        request.addChild('size', payload=file.size)
        request.addChild('content-type', payload=file.mime)
240
241

        log.info("Sending request for slot")
Philipp Hörist's avatar
Philipp Hörist committed
242
        IQ_CALLBACK[id_] = lambda stanza: self.received_slot(stanza, file)
243
244
        self.conn.send(iq)

245
    def received_slot(self, stanza, file):
246
        log.info("Received slot")
247
248
249
250
251
252
253
254
255
        if stanza.getType() == 'error':
            file.progress.close_dialog()
            ErrorDialog(_('Could not request upload slot'),
                        stanza.getErrorMsg(),
                        transient_for=file.control.parent_win.window)
            log.error(stanza)
            return

        try:
256
257
            file.put = stanza.getTag("slot").getTag("put").getData()
            file.get = stanza.getTag("slot").getTag("get").getData()
258
        except Exception:
259
            file.progress.close_dialog()
260
            log.error("Got unexpected stanza: %s", stanza)
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
            log.exception('Error')
            ErrorDialog(_('Could not request upload slot'),
                        _('Got unexpected response from server (see log)'),
                        transient_for=file.control.parent_win.window)
            return

        try:
            file.stream = StreamFileWithProgress(file, "rb")
        except Exception as exc:
            file.progress.close_dialog()
            log.exception("Could not open file")
            ErrorDialog(_('Could not open file'),
                        _('Exception raised while opening file (see log)'),
                        transient_for=file.control.parent_win.window)
            return

277
278
279
280
281
282
283
284
        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):
285
286
        GLib.idle_add(file.progress.label.set_text,
                      _('Uploading file via HTTP...'))
287
288
289
        try:
            headers = {'User-Agent': 'Gajim %s' % gajim.version,
                       'Content-Type': file.mime}
290
291
292
            request = Request(
                file.put, data=file.stream, headers=headers, method='PUT')
            log.info("Opening Urllib upload request...")
293
294
295
296
297
            if os.name == 'nt':
                transfer = urlopen(request, cafile=certifi.where(), timeout=30)
            else:
                transfer = urlopen(request, timeout=30)
            file.stream.close()
298
            log.info('Urllib upload request done, response code: %s',
299
                     transfer.getcode())
300
            GLib.idle_add(self.upload_complete, transfer.getcode(), file)
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
            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')
        except Exception as exc:
            log.exception("Exception during upload")
            error_msg = exc
        GLib.idle_add(file.progress.close_dialog)
        GLib.idle_add(self.on_upload_error, file, error_msg)

    @staticmethod
    def upload_complete(response_code, file):
        file.progress.close_dialog()
319
320
321
322
        if 200 <= response_code < 300:
            log.info("Upload completed successfully")
            message = file.get
            if file.encrypted:
323
                message += '#' + hexlify(file.iv + file.key).decode('utf-8')
324
325
326
            file.control.send_message(message=message)
            file.control.msg_textview.grab_focus()
        else:
327
            log.error('Got unexpected http upload response code: %s',
328
329
330
331
332
333
                      response_code)
            ErrorDialog(
                _('Could not upload file'),
                _('HTTP response code from server: %s') % response_code,
                transient_for=file.control.parent_win.window)

334
335
    @staticmethod
    def on_upload_error(file, reason):
336
        file.progress.close_dialog()
337
        ErrorDialog(_('Error'), str(reason),
338
339
                    transient_for=file.control.parent_win.window)

Philipp Hörist's avatar
Philipp Hörist committed
340
341
342
343
344
345
346
347
348
349

class File:
    def __init__(self, **kwargs):
        for k, v in kwargs.items():
            setattr(self, k, v)
        self.stream = None
        self.put = None
        self.get = None


Linus's avatar
Linus committed
350
class StreamFileWithProgress:
351
    def __init__(self, file, mode, *args):
352
        self.event = file.event
353
        self.backing = open(file.path, mode)
354
        self.encrypted = file.encrypted
Linus's avatar
Linus committed
355
        self.backing.seek(0, os.SEEK_END)
356
        if self.encrypted:
Linus's avatar
Linus committed
357
            self.encryptor = Cipher(
358
359
                algorithms.AES(file.key),
                GCM(file.iv),
360
                backend=default_backend()).encryptor()
Linus's avatar
Linus committed
361
            self._total = self.backing.tell() + TAGSIZE
Linus's avatar
Linus committed
362
        else:
Linus's avatar
Linus committed
363
364
            self._total = self.backing.tell()
        self.backing.seek(0)
365
        self._callback = file.progress.update_progress
Linus's avatar
Linus committed
366
367
368
369
370
371
372
        self._args = args
        self._seen = 0

    def __len__(self):
        return self._total

    def read(self, size):
373
374
        if self.event.isSet():
            raise UploadAbortedException
375
        if self.encrypted:
Linus's avatar
Linus committed
376
            data = self.backing.read(size)
Linus's avatar
Linus committed
377
378
379
380
381
382
383
384
            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:
385
386
                    GLib.idle_add(
                        self._callback, self._seen, self._total, *self._args)
Linus's avatar
Linus committed
387
388
            return data
        else:
Linus's avatar
Linus committed
389
            data = self.backing.read(size)
Linus's avatar
Linus committed
390
391
            self._seen += len(data)
            if self._callback:
392
393
                GLib.idle_add(
                    self._callback, self._seen, self._total, *self._args)
Linus's avatar
Linus committed
394
395
            return data

Linus's avatar
Linus committed
396
397
398
    def close(self):
        return self.backing.close()

Linus's avatar
Linus committed
399
400

class ProgressWindow:
401
    def __init__(self, plugin, parent, event):
Linus's avatar
Linus committed
402
        self.plugin = plugin
403
        self.event = event
404
405
406
        glade_file = self.plugin.local_file_path('upload_progress_dialog.ui')
        self.xml = Gtk.Builder()
        self.xml.add_from_file(glade_file)
Linus's avatar
Linus committed
407
        self.dialog = self.xml.get_object('progress_dialog')
Linus's avatar
Linus committed
408
        self.dialog.set_transient_for(parent)
409
        self.dialog.set_title('HTTP Upload')
Linus's avatar
Linus committed
410
        self.label = self.xml.get_object('label')
411
        self.label.set_text(_('Requesting HTTP Upload Slot...'))
Linus's avatar
Linus committed
412
413
414
415
        self.progressbar = self.xml.get_object('progressbar')
        self.dialog.show_all()
        self.xml.connect_signals(self)

416
        self.pulse = GLib.timeout_add(100, self.pulse_progressbar)
Linus's avatar
Linus committed
417
418
419
420

    def pulse_progressbar(self):
        if self.dialog:
            self.progressbar.pulse()
421
            return True
Linus's avatar
Linus committed
422
423
        return False

424
425
426
427
    def on_destroy(self, *args):
        self.event.set()
        if self.pulse:
            GLib.source_remove(self.pulse)
Linus's avatar
Linus committed
428
429

    def update_progress(self, seen, total):
430
431
432
433
434
        if self.event.isSet():
            return
        if self.pulse:
            GLib.source_remove(self.pulse)
            self.pulse = None
Linus's avatar
Linus committed
435
436
437
438
        pct = (float(seen) / total) * 100.0
        self.progressbar.set_fraction(float(seen) / total)
        self.progressbar.set_text(str(int(pct)) + "%")

439
440
    def close_dialog(self, *args):
        self.dialog.destroy()
Linus's avatar
Linus committed
441

442

Linus's avatar
Linus committed
443
444
class UploadAbortedException(Exception):
    def __str__(self):
445
        return "Upload Aborted"