Skip to content
Snippets Groups Projects
Commit 9064e4a8 authored by Philipp Hörist's avatar Philipp Hörist
Browse files

[omemo] Refactor file downloads

- Use Gajim FileTransferProgress Dialog
- Use libsoup
- Fixes #467, #419
parent 4f70e9b4
No related branches found
No related tags found
No related merge requests found
......@@ -14,136 +14,145 @@
# You should have received a copy of the GNU General Public License
# along with OMEMO Gajim Plugin. If not, see <http://www.gnu.org/licenses/>.
import os
import sys
import hashlib
import logging
import socket
import threading
import binascii
import ssl
from urllib.request import urlopen
from urllib.error import URLError
from urllib.parse import urlparse, urldefrag
from io import BufferedWriter, FileIO, BytesIO
from pathlib import Path
from urllib.parse import urlparse, unquote
from gi.repository import GLib
from gi.repository import Soup
from gajim.common import app
from gajim.common import configpaths
from gajim.common import helpers
from gajim.common.helpers import write_file_async
from gajim.common.helpers import open_file
from gajim.common.const import URIType
from gajim.common.const import FTState
from gajim.common.filetransfer import FileTransfer
from gajim.plugins.plugins_i18n import _
from gajim.gtk.dialogs import ErrorDialog
from gajim.gtk.dialogs import DialogButton
from gajim.gtk.dialogs import NewConfirmationDialog
from omemo.gtk.progress import ProgressWindow
from omemo.backend.aes import aes_decrypt_file
if sys.platform in ('win32', 'darwin'):
import certifi
log = logging.getLogger('gajim.p.omemo.filedecryption')
DIRECTORY = os.path.join(configpaths.get('MY_DATA'), 'downloads')
ERROR = False
try:
if not os.path.exists(DIRECTORY):
os.makedirs(DIRECTORY)
except Exception:
ERROR = True
log.exception('Error')
class File:
def __init__(self, url, account):
self.account = account
self.url, self.fragment = urldefrag(url)
self.key = None
self.iv = None
self.filepath = None
self.filename = None
DIRECTORY = Path(configpaths.get('MY_DATA')) / 'downloads'
class FileDecryption:
def __init__(self, plugin):
self.plugin = plugin
self.window = None
self._session = Soup.Session()
def hyperlink_handler(self, uri, instance, window):
if ERROR or uri.type != URIType.WEB:
if uri.type != URIType.WEB:
return
self.window = window
urlparts = urlparse(uri.data)
file = File(urlparts.geturl(), instance.account)
if urlparts.scheme not in ['https', 'aesgcm'] or not urlparts.netloc:
log.info("Not accepting URL for decryption: %s", uri.data)
urlparts = urlparse(unquote(uri.data))
if urlparts.scheme != 'aesgcm':
log.info('URL not encrypted: %s', uri.data)
return
if urlparts.scheme == 'aesgcm':
log.debug('aesgcm scheme detected')
file.url = 'https://' + file.url[9:]
if not self.is_encrypted(file):
try:
key, iv = self._parse_fragment(urlparts.fragment)
except ValueError:
log.info('URL not encrypted: %s', uri.data)
return
self.create_paths(file)
if os.path.exists(file.filepath):
file_path = self._get_file_path(uri.data, urlparts)
if file_path.exists():
instance.plugin_modified = True
self.finished(file)
self._show_file_open_dialog(file_path)
return
event = threading.Event()
progressbar = ProgressWindow(self.plugin, self.window, event)
thread = threading.Thread(target=Download,
args=(file, progressbar, self.window,
event, self))
thread.daemon = True
thread.start()
file_path.parent.mkdir(mode=0o700, exist_ok=True)
transfer = OMEMODownload(instance.account,
self._cancel_download,
urlparts,
file_path,
key,
iv)
app.interface.show_httpupload_progress(transfer)
self._download_content(transfer)
instance.plugin_modified = True
def is_encrypted(self, file):
if file.fragment:
try:
fragment = binascii.unhexlify(file.fragment)
file.key = fragment[16:]
file.iv = fragment[:16]
if len(file.key) == 32 and len(file.iv) == 16:
return True
file.key = fragment[12:]
file.iv = fragment[:12]
if len(file.key) == 32 and len(file.iv) == 12:
return True
except:
return False
return False
def create_paths(self, file):
file.filename = os.path.basename(file.url)
ext = os.path.splitext(file.filename)[1]
name = os.path.splitext(file.filename)[0]
urlhash = hashlib.sha1(file.url.encode('utf-8')).hexdigest()
newfilename = name + '_' + urlhash[:10] + ext
file.filepath = os.path.join(DIRECTORY, newfilename)
def finished(self, file):
def _download_content(self, transfer):
log.info('Start downloading: %s', transfer.request_uri)
transfer.set_started()
message = transfer.get_soup_message()
message.connect('got-headers', self._on_got_headers, transfer)
message.connect('got-chunk', self._on_got_chunk, transfer)
self._session.queue_message(message, self._on_finished, transfer)
def _cancel_download(self, transfer):
message = transfer.get_soup_message()
self._session.cancel_message(message, Soup.Status.CANCELLED)
@staticmethod
def _on_got_headers(message, transfer):
transfer.set_in_progress()
size = message.props.response_headers.get_content_length()
transfer.size = size
def _on_got_chunk(self, message, chunk, transfer):
transfer.set_chunk(chunk.get_data())
transfer.update_progress()
self._session.pause_message(message)
GLib.idle_add(self._session.unpause_message, message)
def _on_finished(self, _session, message, transfer):
if message.props.status_code == Soup.Status.CANCELLED:
log.info('Download cancelled')
return
if message.status_code != Soup.Status.OK:
log.warning('Download failed: %s', transfer.request_uri)
log.warning(Soup.Status.get_phrase(message.status_code))
return
data = message.props.response_body_data.get_data()
if data is None:
return
decrypted_data = aes_decrypt_file(transfer.key,
transfer.iv,
data)
write_file_async(transfer.path,
decrypted_data,
self._on_decrypted,
transfer)
transfer.set_decrypting()
def _on_decrypted(self, _result, error, transfer):
if error is not None:
log.error('%s: %s', transfer.path, error)
return
transfer.set_finished()
self._show_file_open_dialog(transfer.path)
def _show_file_open_dialog(self, file_path):
def _open_file():
helpers.open_file(file.filepath)
open_file(file_path)
def _open_folder():
directory = os.path.dirname(file.filepath)
helpers.open_file(directory)
open_file(file_path.parent)
NewConfirmationDialog(
_('Open File'),
_('Open File?'),
_('Do you want to open %s?') % file.filename,
_('Do you want to open %s?') % file_path.name,
[DialogButton.make('Cancel',
text=_('_No')),
DialogButton.make('OK',
......@@ -154,104 +163,69 @@ class FileDecryption:
callback=_open_file)],
transient_for=self.window).show()
return False
@staticmethod
def _parse_fragment(fragment):
if not fragment:
raise ValueError('Invalid fragment')
fragment = binascii.unhexlify(fragment)
key = fragment[16:]
iv = fragment[:16]
if len(key) != 32 or len(iv) != 16:
raise ValueError('Invalid fragment')
return key, iv
class Download:
def __init__(self, file, progressbar, window, event, base):
self.file = file
self.progressbar = progressbar
self.window = window
self.event = event
self.base = base
self.download()
def download(self):
GLib.idle_add(self.progressbar.set_text, _('Downloading...'))
data = self.load_url()
if isinstance(data, str):
GLib.idle_add(self.progressbar.close_dialog)
GLib.idle_add(self.error, data)
return
@staticmethod
def _get_file_path(uri, urlparts):
path = Path(urlparts.path)
stem = path.stem
extension = path.suffix
GLib.idle_add(self.progressbar.set_text, _('Decrypting...'))
if len(stem) > 90:
# Many Filesystems have a limit on filename length
# Most have 255, some encrypted ones only 143
# We add around 50 chars for the hash,
# so the filename should not exceed 90
stem = stem[:90]
decrypted_data = aes_decrypt_file(self.file.key,
self.file.iv,
data.getvalue())
name_hash = hashlib.sha1(str(uri).encode()).hexdigest()
GLib.idle_add(
self.progressbar.set_text, _('Writing file to harddisk...'))
self.write_file(decrypted_data)
hash_filename = '%s_%s%s' % (stem, name_hash, extension)
GLib.idle_add(self.progressbar.close_dialog)
file_path = DIRECTORY / hash_filename
return file_path
GLib.idle_add(self.base.finished, self.file)
def load_url(self):
try:
stream = BytesIO()
if not app.config.get_per('accounts',
self.file.account,
'httpupload_verify'):
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
log.warning('CERT Verification disabled')
get_request = urlopen(self.file.url, timeout=30, context=context)
else:
cafile = None
if sys.platform in ('win32', 'darwin'):
cafile = certifi.where()
context = ssl.create_default_context(cafile=cafile)
get_request = urlopen(self.file.url, timeout=30, context=context)
size = get_request.info()['Content-Length']
if not size:
errormsg = 'Content-Length not found in header'
log.error(errormsg)
return errormsg
while True:
try:
if self.event.isSet():
raise DownloadAbortedException
temp = get_request.read(10000)
GLib.idle_add(
self.progressbar.update_progress, len(temp), size)
except socket.timeout:
errormsg = 'Request timeout'
log.error(errormsg)
return errormsg
if temp:
stream.write(temp)
else:
return stream
except DownloadAbortedException as error:
log.info('Download Aborted')
errormsg = error
except URLError as error:
log.exception('URLError')
errormsg = error.reason
except Exception as error:
log.exception('Error')
errormsg = error
stream.close()
return str(errormsg)
def write_file(self, data):
log.info('Writing data to %s', self.file.filepath)
try:
with BufferedWriter(FileIO(self.file.filepath, "wb")) as output:
output.write(data)
output.close()
except Exception:
log.exception('Failed to write file')
class OMEMODownload(FileTransfer):
_state_descriptions = {
FTState.DECRYPTING: _('Decrypting file…'),
FTState.STARTED: _('Downloading…'),
}
def __init__(self, account, cancel_func, urlparts, path, key, iv):
FileTransfer.__init__(self, account, cancel_func=cancel_func)
self._urlparts = urlparts
self.path = path
self.iv = iv
self.key = key
self._message = None
@property
def request_uri(self):
urlparts = self._urlparts._replace(scheme='https', fragment='')
return urlparts.geturl()
def error(self, error):
ErrorDialog(_('Error'), error, transient_for=self.window)
return False
@property
def filename(self):
return Path(self._urlparts.path).name
def set_chunk(self, bytes_):
self._seen += len(bytes_)
class DownloadAbortedException(Exception):
def __str__(self):
return _('Download Aborted')
def get_soup_message(self):
if self._message is None:
self._message = Soup.Message.new('GET', self.request_uri)
return self._message
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment