httpupload.py 16.2 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
127
128
129
130
        self.enabled = False
        self.component = None
        self.controls = {}

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

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

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

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

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

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

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

Philipp Hörist's avatar
Philipp Hörist committed
179
        if not path or not os.path.exists(path):
Linus's avatar
Linus committed
180
            return
181

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

196
        encrypted = self.encryption_activated(jid)
197
198
199
200
201
202
        if encrypted and not ENCRYPTION_AVAILABLE:
            ErrorDialog(
                _('Error'),
                'Please install python-cryptography for encrypted uploads',
                transient_for=chat_control.parent_win.window)
            return
Philipp Hörist's avatar
Philipp Hörist committed
203
204
        size = os.path.getsize(path)
        key, iv = None, None
205
        if encrypted:
Philipp Hörist's avatar
Philipp Hörist committed
206
207
208
            key = os.urandom(32)
            iv = os.urandom(16)
            size += TAGSIZE
209

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

215
216
217
        event = threading.Event()
        progress = ProgressWindow(
            self.plugin, chat_control.parent_win.window, event)
218

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

224
    def on_file_button_clicked(self, widget, jid, chat_control):
225
226
227
228
229
230
231
232
233
        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
234

Philipp Hörist's avatar
Philipp Hörist committed
235
    def request_slot(self, file):
236
237
238
239
        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
240
241
242
        request.addChild('filename', payload=os.path.basename(file.path))
        request.addChild('size', payload=file.size)
        request.addChild('content-type', payload=file.mime)
243
244

        log.info("Sending request for slot")
Philipp Hörist's avatar
Philipp Hörist committed
245
        IQ_CALLBACK[id_] = lambda stanza: self.received_slot(stanza, file)
246
        gajim.connections[self.account].connection.send(iq)
247

248
    def received_slot(self, stanza, file):
249
        log.info("Received slot")
250
251
252
253
254
255
256
257
258
        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:
259
260
            file.put = stanza.getTag("slot").getTag("put").getData()
            file.get = stanza.getTag("slot").getTag("get").getData()
261
        except Exception:
262
            file.progress.close_dialog()
263
            log.error("Got unexpected stanza: %s", stanza)
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
            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

280
281
282
283
284
285
286
287
        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):
288
289
        GLib.idle_add(file.progress.label.set_text,
                      _('Uploading file via HTTP...'))
290
291
292
        try:
            headers = {'User-Agent': 'Gajim %s' % gajim.version,
                       'Content-Type': file.mime}
293
294
295
            request = Request(
                file.put, data=file.stream, headers=headers, method='PUT')
            log.info("Opening Urllib upload request...")
296
297
298
299
300
            if os.name == 'nt':
                transfer = urlopen(request, cafile=certifi.where(), timeout=30)
            else:
                transfer = urlopen(request, timeout=30)
            file.stream.close()
301
            log.info('Urllib upload request done, response code: %s',
302
                     transfer.getcode())
303
            GLib.idle_add(self.upload_complete, transfer.getcode(), file)
304
305
306
307
308
309
310
311
312
            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')
313
314
315
            else:
                log.exception('URLError')
                error_msg = exc.reason
316
317
318
319
320
321
322
323
324
        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()
325
326
327
328
        if 200 <= response_code < 300:
            log.info("Upload completed successfully")
            message = file.get
            if file.encrypted:
329
                message += '#' + hexlify(file.iv + file.key).decode('utf-8')
330
331
332
            file.control.send_message(message=message)
            file.control.msg_textview.grab_focus()
        else:
333
            log.error('Got unexpected http upload response code: %s',
334
335
336
337
338
339
                      response_code)
            ErrorDialog(
                _('Could not upload file'),
                _('HTTP response code from server: %s') % response_code,
                transient_for=file.control.parent_win.window)

340
341
    @staticmethod
    def on_upload_error(file, reason):
342
        file.progress.close_dialog()
343
        ErrorDialog(_('Error'), str(reason),
344
345
                    transient_for=file.control.parent_win.window)

Philipp Hörist's avatar
Philipp Hörist committed
346
347
348
349
350
351
352
353
354
355

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
356
class StreamFileWithProgress:
357
    def __init__(self, file, mode, *args):
358
        self.event = file.event
359
        self.backing = open(file.path, mode)
360
        self.encrypted = file.encrypted
Linus's avatar
Linus committed
361
        self.backing.seek(0, os.SEEK_END)
362
        if self.encrypted:
Linus's avatar
Linus committed
363
            self.encryptor = Cipher(
364
365
                algorithms.AES(file.key),
                GCM(file.iv),
366
                backend=default_backend()).encryptor()
Linus's avatar
Linus committed
367
            self._total = self.backing.tell() + TAGSIZE
Linus's avatar
Linus committed
368
        else:
Linus's avatar
Linus committed
369
370
            self._total = self.backing.tell()
        self.backing.seek(0)
371
        self._callback = file.progress.update_progress
Linus's avatar
Linus committed
372
373
374
375
376
377
378
        self._args = args
        self._seen = 0

    def __len__(self):
        return self._total

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

Linus's avatar
Linus committed
402
403
404
    def close(self):
        return self.backing.close()

Linus's avatar
Linus committed
405
406

class ProgressWindow:
407
    def __init__(self, plugin, parent, event):
Linus's avatar
Linus committed
408
        self.plugin = plugin
409
        self.event = event
410
411
412
        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
413
        self.dialog = self.xml.get_object('progress_dialog')
Linus's avatar
Linus committed
414
        self.dialog.set_transient_for(parent)
415
        self.dialog.set_title('HTTP Upload')
Linus's avatar
Linus committed
416
        self.label = self.xml.get_object('label')
417
        self.label.set_text(_('Requesting HTTP Upload Slot...'))
Linus's avatar
Linus committed
418
419
420
421
        self.progressbar = self.xml.get_object('progressbar')
        self.dialog.show_all()
        self.xml.connect_signals(self)

422
        self.pulse = GLib.timeout_add(100, self.pulse_progressbar)
Linus's avatar
Linus committed
423
424
425
426

    def pulse_progressbar(self):
        if self.dialog:
            self.progressbar.pulse()
427
            return True
Linus's avatar
Linus committed
428
429
        return False

430
431
432
433
    def on_destroy(self, *args):
        self.event.set()
        if self.pulse:
            GLib.source_remove(self.pulse)
Linus's avatar
Linus committed
434
435

    def update_progress(self, seen, total):
436
437
438
439
440
        if self.event.isSet():
            return
        if self.pulse:
            GLib.source_remove(self.pulse)
            self.pulse = None
Linus's avatar
Linus committed
441
442
443
444
        pct = (float(seen) / total) * 100.0
        self.progressbar.set_fraction(float(seen) / total)
        self.progressbar.set_text(str(int(pct)) + "%")

445
446
    def close_dialog(self, *args):
        self.dialog.destroy()
Linus's avatar
Linus committed
447

448

Linus's avatar
Linus committed
449
450
class UploadAbortedException(Exception):
    def __str__(self):
451
        return "Upload Aborted"