Commit 25bf2d4e authored by Philipp Hörist's avatar Philipp Hörist
Browse files

[preview] Rewrite plugin

parent ba0b1129
......@@ -21,7 +21,6 @@ from gi.repository import GObject
from gi.repository import Gtk
from gajim.gtk.settings import SettingsDialog
from gajim.gtk.settings import GenericSetting
from gajim.gtk.settings import SpinSetting
from gajim.gtk.const import Setting
from gajim.gtk.const import SettingKind
......@@ -40,16 +39,11 @@ class UrlImagePreviewConfigDialog(SettingsDialog):
('10485760', '10 MiB')]
actions = [
('open_menuitem', _('Open')),
('save_as_menuitem', _('Save as')),
('open_folder_menuitem', _('Open Folder')),
('copy_link_location_menuitem', _('Copy Link Location')),
('open_link_in_browser_menuitem', _('Open Link in Browser'))]
geo_providers = [
('no_preview', _('No map preview')),
('Google', _('Google Maps')),
('OSM', _('OpenStreetMap'))]
('open', _('Open')),
('save_as', _('Save as')),
('open_folder', _('Open Folder')),
('copy_link_location', _('Copy Link Location')),
('open_link_in_browser', _('Open Link in Browser'))]
self.plugin = plugin
settings = [
......@@ -77,12 +71,6 @@ class UrlImagePreviewConfigDialog(SettingsDialog):
desc=_('Action when left clicking a preview'),
props={'combo_items': actions}),
Setting(SettingKind.COMBO, _('Map Service'),
SettingType.VALUE, self.plugin.config['GEO_PREVIEW_PROVIDER'],
callback=self.on_setting, data='GEO_PREVIEW_PROVIDER',
desc=_('Service used for map previews'),
props={'combo_items': geo_providers}),
Setting(SettingKind.SWITCH, _('HTTPS Verification'),
SettingType.VALUE, self.plugin.config['VERIFY'],
desc=_('Whether to check for a valid certificate'),
......@@ -91,8 +79,8 @@ class UrlImagePreviewConfigDialog(SettingsDialog):
SettingsDialog.__init__(self, parent, _('UrlImagePreview Configuration'),
Gtk.DialogFlags.MODAL, settings, None,
extend=[
('PreviewSizeSpinSetting', SizeSpinSetting)])
extend=[('PreviewSizeSpinSetting',
SizeSpinSetting)])
def on_setting(self, value, data):
self.plugin.config[data] = value
......
......@@ -5,7 +5,7 @@
<object class="GtkMenu" id="context_menu">
<property name="can_focus">False</property>
<child>
<object class="GtkMenuItem" id="open_menuitem">
<object class="GtkMenuItem" id="open">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">_Open</property>
......@@ -13,7 +13,7 @@
</object>
</child>
<child>
<object class="GtkMenuItem" id="save_as_menuitem">
<object class="GtkMenuItem" id="save_as">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">_Save as</property>
......@@ -21,7 +21,7 @@
</object>
</child>
<child>
<object class="GtkMenuItem" id="open_folder_menuitem">
<object class="GtkMenuItem" id="open_folder">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">Open _Folder</property>
......@@ -35,7 +35,7 @@
</object>
</child>
<child>
<object class="GtkMenuItem" id="copy_link_location_menuitem">
<object class="GtkMenuItem" id="copy_link_location">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">_Copy Link</property>
......@@ -43,7 +43,7 @@
</object>
</child>
<child>
<object class="GtkMenuItem" id="open_link_in_browser_menuitem">
<object class="GtkMenuItem" id="open_link_in_browser">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">Open Link in _Browser</property>
......
# -*- 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 <http://www.gnu.org/licenses/>.
##
import urllib.request as urllib2
import socket
import ssl
import logging
import os
from gajim.common import app
from gajim.plugins.plugins_i18n import _
if os.name == 'nt':
import certifi
log = logging.getLogger('gajim.p.preview.http_functions')
def get_http_head(account, url, verify):
return _get_http_head_direct(url, verify)
def get_http_file(account, attrs):
return _get_http_direct(attrs)
def _get_http_head_direct(url, verify):
log.info('Head request direct for URL: %s', url)
try:
req = urllib2.Request(url)
req.get_method = lambda: 'HEAD'
req.add_header('User-Agent', 'Gajim %s' % app.version)
if not verify:
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
log.warning('CERT Verification disabled')
file_ = urllib2.urlopen(req, timeout=30, context=context)
else:
if os.name == 'nt':
file_ = urllib2.urlopen(req, cafile=certifi.where())
else:
file_ = urllib2.urlopen(req)
except Exception as ex:
log.debug('Error', exc_info=True)
return ('', 0)
ctype = file_.headers['Content-Type']
clen = file_.headers['Content-Length']
try:
clen = int(clen)
except (TypeError, ValueError):
pass
return (ctype, clen)
def _get_http_direct(attrs):
'''
Download a file. This function should
be launched in a separated thread.
'''
log.info('Get request direct for URL: %s', attrs['src'])
mem, alt, max_size = b'', '', 2 * 1024 * 1024
if 'max_size' in attrs:
max_size = attrs['max_size']
try:
req = urllib2.Request(attrs['src'])
req.add_header('User-Agent', 'Gajim ' + app.version)
if not attrs['verify']:
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
log.warning('CERT Verification disabled')
file_ = urllib2.urlopen(req, timeout=30, context=context)
else:
if os.name == 'nt':
file_ = urllib2.urlopen(req, cafile=certifi.where())
else:
file_ = urllib2.urlopen(req)
except Exception as ex:
log.debug('Error', exc_info=True)
pixbuf = None
alt = attrs.get('alt', 'Broken image')
else:
while True:
try:
temp = file_.read(100)
except socket.timeout as ex:
log.debug('Timeout loading image %s', attrs['src'] + str(ex))
alt = attrs.get('alt', '')
if alt:
alt += '\n'
alt += _('Timeout loading image')
break
if temp:
mem += temp
else:
break
if len(mem) > max_size:
alt = attrs.get('alt', '')
if alt:
alt += '\n'
alt += _('Image is too big')
break
return (mem, alt)
from io import BytesIO
from PIL import Image
def resize_gif(mem, path, resize_to):
frames, result = extract_and_resize_frames(mem, resize_to)
if len(frames) == 1:
frames[0].save(path, optimize=True)
else:
frames[0].save(path,
optimize=True,
save_all=True,
append_images=frames[1:],
duration=result['duration'],
loop=1000)
def analyse_image(mem):
'''
Pre-process pass over the image to determine the mode (full or additive).
Necessary as assessing single frames isn't reliable. Need to know the mode
before processing all frames.
'''
image = Image.open(BytesIO(mem))
results = {
'size': image.size,
'mode': 'full',
'duration': image.info.get('duration', 0)
}
try:
while True:
if image.tile:
tile = image.tile[0]
update_region = tile[1]
update_region_dimensions = update_region[2:]
if update_region_dimensions != image.size:
results['mode'] = 'partial'
break
image.seek(image.tell() + 1)
except EOFError:
pass
return results
def extract_and_resize_frames(mem, resize_to):
result = analyse_image(mem)
image = Image.open(BytesIO(mem))
i = 0
palette = image.getpalette()
last_frame = image.convert('RGBA')
frames = []
try:
while True:
'''
If the GIF uses local colour tables,
each frame will have its own palette.
If not, we need to apply the global palette to the new frame.
'''
if not image.getpalette():
image.putpalette(palette)
new_frame = Image.new('RGBA', image.size)
'''
Is this file a "partial"-mode GIF where frames update a region
of a different size to the entire image?
If so, we need to construct the new frame by
pasting it on top of the preceding frames.
'''
if result['mode'] == 'partial':
new_frame.paste(last_frame)
new_frame.paste(image, (0, 0), image.convert('RGBA'))
# This method preservs aspect ratio
new_frame.thumbnail(resize_to, Image.ANTIALIAS)
frames.append(new_frame)
i += 1
last_frame = new_frame
image.seek(image.tell() + 1)
except EOFError:
pass
return frames, result
# -*- 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 <http://www.gnu.org/licenses/>.
##
# This file is part of Image Preview Gajim Plugin.
#
# Image Preview Gajim Plugin 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.
#
# Image Preview Gajim Plugin 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 Image Preview Gajim Plugin.
# If not, see <http://www.gnu.org/licenses/>.
import os
import hashlib
import binascii
import logging
import math
import shutil
from pathlib import Path
from functools import partial
from urllib.parse import urlparse
from urllib.parse import unquote
from io import BytesIO
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import GLib
from gi.repository import GdkPixbuf
from gi.repository import Soup
from gajim.common import app
from gajim.common import configpaths
from gajim.common.helpers import open_file
from gajim.common.helpers import open_uri
from gajim.gtkgui_helpers import add_css_to_widget
from gajim.common.helpers import write_file_async
from gajim.common.helpers import load_file_async
from gajim.common.helpers import get_tls_error_phrase
from gajim.gtk.dialogs import ErrorDialog
from gajim.gtk.filechoosers import FileSaveDialog
from gajim.gtk.util import get_cursor
from gajim.gtk.util import load_icon
from gajim.gtk.util import get_monitor_scale_factor
from gajim.plugins import GajimPlugin
from gajim.plugins.helpers import log_calls
from gajim.plugins.helpers import get_builder
from gajim.plugins.plugins_i18n import _
from url_image_preview.http_functions import get_http_head
from url_image_preview.http_functions import get_http_file
from url_image_preview.config_dialog import UrlImagePreviewConfigDialog
......@@ -54,658 +50,503 @@ log = logging.getLogger('gajim.p.preview')
ERROR_MSG = None
try:
from PIL import Image
from url_image_preview.resize_gif import resize_gif
from PIL import Image # pylint: disable=unused-import
except ImportError:
log.debug('Pillow not available')
log.error('Pillow not available')
ERROR_MSG = _('Please install python-pillow')
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
decryption_available = True
import cryptography # pylint: disable=unused-import
except Exception:
DEP_MSG = _('To enable previews for encrypted images, '
'please install python-cryptography!')
log.exception('Error')
log.info('Decryption/Encryption disabled due to errors')
decryption_available = False
ACCEPTED_MIME_TYPES = ('image/png', 'image/jpeg', 'image/gif', 'image/raw',
'image/svg+xml', 'image/x-ms-bmp')
ERROR_MSG = _('Please install python-cryptography')
log.error('python-cryptography not available')
# pylint: disable=ungrouped-imports
if ERROR_MSG is None:
from url_image_preview.utils import aes_decrypt
from url_image_preview.utils import get_image_paths
from url_image_preview.utils import split_geo_uri
from url_image_preview.utils import parse_fragment
from url_image_preview.utils import create_thumbnail
from url_image_preview.utils import pixbuf_from_data
from url_image_preview.utils import create_clickable_image
from url_image_preview.utils import filename_from_uri
# pylint: enable=ungrouped-imports
ACCEPTED_MIME_TYPES = [
'image/png',
'image/jpeg',
'image/gif',
'image/raw',
'image/svg+xml',
'image/x-ms-bmp',
]
class UrlImagePreviewPlugin(GajimPlugin):
@log_calls('UrlImagePreviewPlugin')
def init(self):
# pylint: disable=attribute-defined-outside-init
if ERROR_MSG:
self.activatable = False
self.available_text = ERROR_MSG
self.config_dialog = None
return
if not decryption_available:
self.available_text = DEP_MSG
self.config_dialog = partial(UrlImagePreviewConfigDialog, self)
self.gui_extension_points = {
'chat_control_base': (self.connect_with_chat_control,
self.disconnect_from_chat_control),
'history_window':
(self.connect_with_history, self.disconnect_from_history),
'print_real_text': (self.print_real_text, None), }
'chat_control_base': (self._on_connect_chat_control_base,
self._on_disconnect_chat_control_base),
'history_window': (self._on_connect_history_window,
self._on_disconnect_history_window),
'print_real_text': (self._print_real_text, None), }
self.config_default_values = {
'PREVIEW_SIZE': (150, 'Preview size (100-1000)'),
'MAX_FILE_SIZE': (5242880, 'Max file size for image preview'),
'ALLOW_ALL_IMAGES': (False, ''),
'LEFTCLICK_ACTION': ('open_menuitem', 'Open'),
'ANONYMOUS_MUC': (False, ''),
'GEO_PREVIEW_PROVIDER': ('Google', 'Google Maps'),
'VERIFY': (True, ''),}
self.controls = {}
self.history_window_control = None
@log_calls('UrlImagePreviewPlugin')
def connect_with_chat_control(self, chat_control):
account = chat_control.contact.account.name
jid = chat_control.contact.jid
if account not in self.controls:
self.controls[account] = {}
self.controls[account][jid] = Base(self, chat_control.conv_textview)
@log_calls('UrlImagePreviewPlugin')
def disconnect_from_chat_control(self, chat_control):
account = chat_control.contact.account.name
jid = chat_control.contact.jid
self.controls[account][jid].deinit_handlers()
del self.controls[account][jid]
@log_calls('UrlImagePreviewPlugin')
def connect_with_history(self, history_window):
if self.history_window_control:
self.history_window_control.deinit_handlers()
self.history_window_control = Base(
self, history_window.history_textview)
@log_calls('UrlImagePreviewPlugin')
def disconnect_from_history(self, history_window):
if self.history_window_control:
self.history_window_control.deinit_handlers()
self.history_window_control = None
def print_real_text(self, tv, real_text, text_tags, graphics,
iter_, additional_data):
if tv.used_in_history_window and self.history_window_control:
self.history_window_control.print_real_text(
real_text, text_tags, graphics, iter_, additional_data)
account = tv.account
for jid in self.controls[account]:
if self.controls[account][jid].textview != tv:
continue
self.controls[account][jid].print_real_text(
real_text, text_tags, graphics, iter_, additional_data)
return
self._textviews = {}
class Base:
def __init__(self, plugin, textview):
self.plugin = plugin
self.textview = textview
self.handlers = {}
self._session = Soup.Session()
self._session.add_feature_by_type(Soup.ContentSniffer)
self._session.props.https_aliases = ['aesgcm']
self._session.props.ssl_strict = False
self.directory = os.path.join(configpaths.get('MY_DATA'),
'downloads')
self.thumbpath = os.path.join(configpaths.get('MY_CACHE'),
'downloads.thumb')
try:
self._create_path(self.directory)
self._create_path(self.thumbpath)
except Exception:
log.error('Error creating download and/or thumbnail folder!')
raise
def deinit_handlers(self):
# Remove all register handlers on wigets, created by self.xml
# to prevent circular references among objects
for i in list(self.handlers.keys()):
if self.handlers[i].handler_is_connected(i):
self.handlers[i].disconnect(i)
del self.handlers[i]
def print_real_text(self, real_text, text_tags, graphics, iter_,
additional_data):
if len(real_text.split(' ')) > 1:
if GLib.mkdir_with_parents(self.directory, 0o700) != 0:
log.error('Failed to create: %s', self.directory)
if GLib.mkdir_with_parents(self.thumbpath, 0o700) != 0:
log.error('Failed to create: %s', self.directory)
self._migrate_config()
def _migrate_config(self):
action = self.config['LEFTCLICK_ACTION']
if action.endswith('_menuitem'):
self.config['LEFTCLICK_ACTION'] = action[:-9]
def _on_connect_chat_control_base(self, chat_control):
self._textviews[chat_control.control_id] = chat_control.conv_textview
def _on_disconnect_chat_control_base(self, chat_control):
self._textviews.pop(chat_control.control_id, None)
def _on_connect_history_window(self, history_window):
self._textviews[id(history_window)] = history_window.history_textview
def _on_disconnect_history_window(self, history_window):
self._textviews.pop(id(history_window), None)
def _get_control_id(self, textview):
for control_id, textview_ in self._textviews.items():
if textview == textview_:
return control_id
def _print_real_text(self, textview, text, _text_tags, _graphics,
iter_, additional_data):
if len(text.split(' ')) > 1:
# urlparse doesn't recognise spaces as URL delimiter
log.debug('Url with text will not be displayed: %s', real_text)
log.debug('Text is not an uri: %s', text[:15])
return
urlparts = urlparse(unquote(real_text))
if not self._accept_uri(urlparts, real_text, additional_data):
uri = text
urlparts = urlparse(unquote(uri))
if not self._accept_uri(urlparts, uri, additional_data):
return
# Don't print the URL in the message window (in the calling function)
self.textview.plugin_modified = True
textview.plugin_modified = True
control_id = self._get_control_id(textview)
buffer_ = self.textview.tv.get_buffer()
if not iter_: