httpupload.py 16.7 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
61
62
63
64
        self.events_handlers = {
            'agent-info-received': (
                ged.PRECORE, self.handle_agent_info_received),
            'stanza-message-outgoing': (
65
                99, self.handle_outgoing_stanza),
66
            'gc-stanza-message-outgoing': (
67
                99, self.handle_outgoing_stanza),
68
69
            'raw-iq-received': (
                ged.PRECORE, self.handle_iq_received)}
Linus's avatar
Linus committed
70
71
        self.gui_extension_points = {
            'chat_control_base': (self.connect_with_chat_control,
72
73
74
75
                                  self.disconnect_from_chat_control),
            'chat_control_base_update_toolbar': (self.update_chat_control,
                                                 None)}
        self.gui_interfaces = {}
76
        self.messages = []
Linus's avatar
Linus committed
77

78
79
    @staticmethod
    def handle_iq_received(event):
Linus's avatar
Linus committed
80
        id_ = event.stanza.getAttr("id")
81
        if id_ in IQ_CALLBACK:
Linus's avatar
Linus committed
82
            try:
83
                IQ_CALLBACK[id_](event.stanza)
Linus's avatar
Linus committed
84
85
86
            except:
                raise
            finally:
87
                del IQ_CALLBACK[id_]
Linus's avatar
Linus committed
88
89

    def handle_agent_info_received(self, event):
90
91
92
93
94
95
96
97
        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)

98
99
100
101
102
103
104
    def handle_outgoing_stanza(self, event):
        message = event.msg_iq.getTagData('body')
        if message and message in self.messages:
            self.messages.remove(message)
            oob = event.msg_iq.addChild('x', namespace=nbxmpp.NS_X_OOB)
            oob.addChild('url').setData(message)

105
106
107
108
    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
109
    def disconnect_from_chat_control(self, chat_control):
110
111
112
113
114
115
116
        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
117

118
119
120
121
122
123
124
125
126
    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:
127
            self.gui_interfaces[account] = Base(self, account)
128
            return self.gui_interfaces[account]
Linus's avatar
Linus committed
129
130
131


class Base(object):
132
    def __init__(self, plugin, account):
Linus's avatar
Linus committed
133
        self.plugin = plugin
134
        self.account = account
Linus's avatar
Linus committed
135
        self.encrypted_upload = False
136
137
138
139
140
141
142
        self.enabled = False
        self.component = None
        self.controls = {}

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

Linus's avatar
Linus committed
143
        img = Gtk.Image()
Linus's avatar
Linus committed
144
        img.set_from_file(self.plugin.local_file_path('httpupload.png'))
145
146
147
148
149
        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)
150

151
        actions_hbox.add(button)
Linus's avatar
Linus committed
152
        send_button = chat_control.xml.get_object('send_button')
153
154
155
156
        button_pos = actions_hbox.child_get_property(send_button, 'position')
        actions_hbox.child_set_property(button, 'position', button_pos - 1)

        self.controls[jid] = button
157
158
        id_ = button.connect(
            'clicked', self.on_file_button_clicked, jid, chat_control)
159
160
161
162
163
164
165
166
167
168
169
170
171
        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
172

173
174
175
    def update_button_states(self, state):
        for jid in self.controls:
            self.set_button_state(state, self.controls[jid])
Linus's avatar
Linus committed
176

177
    def encryption_activated(self, jid):
Linus's avatar
Linus committed
178
179
        for plugin in gajim.plugin_manager.active_plugins:
            if type(plugin).__name__ == 'OmemoPlugin':
180
181
                state = plugin.get_omemo_state(self.account)
                encryption = state.encryption.is_active(jid)
182
183
                log.info('Encryption is: %s', bool(encryption))
                return bool(encryption)
184
        log.info('OMEMO not found, encryption disabled')
Linus's avatar
Linus committed
185
186
        return False

187
    def on_file_dialog_ok(self, widget, jid, chat_control):
Philipp Hörist's avatar
Philipp Hörist committed
188
        path = widget.get_filename()
189
        widget.destroy()
Linus's avatar
Linus committed
190

Philipp Hörist's avatar
Philipp Hörist committed
191
        if not path or not os.path.exists(path):
Linus's avatar
Linus committed
192
            return
193

Linus's avatar
Linus committed
194
        invalid_file = False
Philipp Hörist's avatar
Philipp Hörist committed
195
196
        if os.path.isfile(path):
            stat = os.stat(path)
Linus's avatar
Linus committed
197
198
199
200
201
202
203
            if stat[6] == 0:
                invalid_file = True
                msg = _('File is empty')
        else:
            invalid_file = True
            msg = _('File does not exist')
        if invalid_file:
204
205
            ErrorDialog(_('Could not open file'), msg,
                        transient_for=chat_control.parent_win.window)
Linus's avatar
Linus committed
206
207
            return

208
        encrypted = self.encryption_activated(jid)
209
210
211
212
213
214
        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
215
216
        size = os.path.getsize(path)
        key, iv = None, None
217
        if encrypted:
Philipp Hörist's avatar
Philipp Hörist committed
218
219
220
            key = os.urandom(32)
            iv = os.urandom(16)
            size += TAGSIZE
221

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

227
228
229
        event = threading.Event()
        progress = ProgressWindow(
            self.plugin, chat_control.parent_win.window, event)
230

Philipp Hörist's avatar
Philipp Hörist committed
231
232
        file = File(path=path, size=size, mime=mime, encrypted=encrypted,
                    key=key, iv=iv, control=chat_control,
233
                    progress=progress, event=event)
Philipp Hörist's avatar
Philipp Hörist committed
234
        self.request_slot(file)
235

236
    def on_file_button_clicked(self, widget, jid, chat_control):
237
238
239
240
241
242
243
244
245
        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
246

Philipp Hörist's avatar
Philipp Hörist committed
247
    def request_slot(self, file):
248
249
250
251
        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
252
253
254
        request.addChild('filename', payload=os.path.basename(file.path))
        request.addChild('size', payload=file.size)
        request.addChild('content-type', payload=file.mime)
255
256

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

260
    def received_slot(self, stanza, file):
261
        log.info("Received slot")
262
263
264
265
266
267
268
269
270
        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:
271
272
            file.put = stanza.getTag("slot").getTag("put").getData()
            file.get = stanza.getTag("slot").getTag("get").getData()
273
        except Exception:
274
            file.progress.close_dialog()
275
            log.error("Got unexpected stanza: %s", stanza)
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
            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

292
293
294
295
296
297
298
299
        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):
300
301
        GLib.idle_add(file.progress.label.set_text,
                      _('Uploading file via HTTP...'))
302
303
304
        try:
            headers = {'User-Agent': 'Gajim %s' % gajim.version,
                       'Content-Type': file.mime}
305
306
307
            request = Request(
                file.put, data=file.stream, headers=headers, method='PUT')
            log.info("Opening Urllib upload request...")
308
309
310
311
312
            if os.name == 'nt':
                transfer = urlopen(request, cafile=certifi.where(), timeout=30)
            else:
                transfer = urlopen(request, timeout=30)
            file.stream.close()
313
            log.info('Urllib upload request done, response code: %s',
314
                     transfer.getcode())
315
            GLib.idle_add(self.upload_complete, transfer.getcode(), file)
316
317
318
319
320
321
322
323
324
            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')
325
326
327
            else:
                log.exception('URLError')
                error_msg = exc.reason
328
329
330
331
332
333
        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)

334
    def upload_complete(self, response_code, file):
335
        file.progress.close_dialog()
336
337
338
339
        if 200 <= response_code < 300:
            log.info("Upload completed successfully")
            message = file.get
            if file.encrypted:
340
                message += '#' + hexlify(file.iv + file.key).decode('utf-8')
341
342
            else:
                self.plugin.messages.append(message)
343
344
345
            file.control.send_message(message=message)
            file.control.msg_textview.grab_focus()
        else:
346
            log.error('Got unexpected http upload response code: %s',
347
348
349
350
351
352
                      response_code)
            ErrorDialog(
                _('Could not upload file'),
                _('HTTP response code from server: %s') % response_code,
                transient_for=file.control.parent_win.window)

353
354
    @staticmethod
    def on_upload_error(file, reason):
355
        file.progress.close_dialog()
356
        ErrorDialog(_('Error'), str(reason),
357
358
                    transient_for=file.control.parent_win.window)

Philipp Hörist's avatar
Philipp Hörist committed
359
360
361
362
363
364
365
366
367
368

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
369
class StreamFileWithProgress:
370
    def __init__(self, file, mode, *args):
371
        self.event = file.event
372
        self.backing = open(file.path, mode)
373
        self.encrypted = file.encrypted
Linus's avatar
Linus committed
374
        self.backing.seek(0, os.SEEK_END)
375
        if self.encrypted:
Linus's avatar
Linus committed
376
            self.encryptor = Cipher(
377
378
                algorithms.AES(file.key),
                GCM(file.iv),
379
                backend=default_backend()).encryptor()
Linus's avatar
Linus committed
380
            self._total = self.backing.tell() + TAGSIZE
Linus's avatar
Linus committed
381
        else:
Linus's avatar
Linus committed
382
383
            self._total = self.backing.tell()
        self.backing.seek(0)
384
        self._callback = file.progress.update_progress
Linus's avatar
Linus committed
385
386
387
388
389
390
391
        self._args = args
        self._seen = 0

    def __len__(self):
        return self._total

    def read(self, size):
392
393
        if self.event.isSet():
            raise UploadAbortedException
394
        if self.encrypted:
Linus's avatar
Linus committed
395
            data = self.backing.read(size)
Linus's avatar
Linus committed
396
397
398
399
400
401
402
403
            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:
404
405
                    GLib.idle_add(
                        self._callback, self._seen, self._total, *self._args)
Linus's avatar
Linus committed
406
407
            return data
        else:
Linus's avatar
Linus committed
408
            data = self.backing.read(size)
Linus's avatar
Linus committed
409
410
            self._seen += len(data)
            if self._callback:
411
412
                GLib.idle_add(
                    self._callback, self._seen, self._total, *self._args)
Linus's avatar
Linus committed
413
414
            return data

Linus's avatar
Linus committed
415
416
417
    def close(self):
        return self.backing.close()

Linus's avatar
Linus committed
418
419

class ProgressWindow:
420
    def __init__(self, plugin, parent, event):
Linus's avatar
Linus committed
421
        self.plugin = plugin
422
        self.event = event
423
424
425
        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
426
        self.dialog = self.xml.get_object('progress_dialog')
Linus's avatar
Linus committed
427
        self.dialog.set_transient_for(parent)
428
        self.dialog.set_title('HTTP Upload')
Linus's avatar
Linus committed
429
        self.label = self.xml.get_object('label')
430
        self.label.set_text(_('Requesting HTTP Upload Slot...'))
Linus's avatar
Linus committed
431
432
433
434
        self.progressbar = self.xml.get_object('progressbar')
        self.dialog.show_all()
        self.xml.connect_signals(self)

435
        self.pulse = GLib.timeout_add(100, self.pulse_progressbar)
Linus's avatar
Linus committed
436
437
438
439

    def pulse_progressbar(self):
        if self.dialog:
            self.progressbar.pulse()
440
            return True
Linus's avatar
Linus committed
441
442
        return False

443
444
445
446
    def on_destroy(self, *args):
        self.event.set()
        if self.pulse:
            GLib.source_remove(self.pulse)
Linus's avatar
Linus committed
447
448

    def update_progress(self, seen, total):
449
450
451
452
453
        if self.event.isSet():
            return
        if self.pulse:
            GLib.source_remove(self.pulse)
            self.pulse = None
Linus's avatar
Linus committed
454
455
456
457
        pct = (float(seen) / total) * 100.0
        self.progressbar.set_fraction(float(seen) / total)
        self.progressbar.set_text(str(int(pct)) + "%")

458
459
    def close_dialog(self, *args):
        self.dialog.destroy()
Linus's avatar
Linus committed
460

461

Linus's avatar
Linus committed
462
463
class UploadAbortedException(Exception):
    def __str__(self):
464
        return "Upload Aborted"