Codebase list gajim-urlimagepreview / 737d424
Import upstream 2.4.1 Martin 4 years ago
7 changed file(s) with 916 addition(s) and 1021 deletion(s). Raw diff Collapse all Expand all
1919 from gi.repository import GObject
2020 from gi.repository import Gtk
2121
22 from gajim.options_dialog import OptionsDialog, GenericOption, SpinOption
23 from gajim.common.const import Option, OptionType, OptionKind
22 from gajim.gtk.settings import SettingsDialog
23 from gajim.gtk.settings import SpinSetting
24 from gajim.gtk.const import Setting
25 from gajim.gtk.const import SettingKind
26 from gajim.gtk.const import SettingType
27
2428 from gajim.plugins.plugins_i18n import _
2529
2630
27 class UrlImagePreviewConfigDialog(OptionsDialog):
31 class UrlImagePreviewConfigDialog(SettingsDialog):
2832 def __init__(self, plugin, parent):
2933
30 sizes = [('256 KiB', '262144'),
31 ('512 KiB', '524288'),
32 ('1 MiB', '1048576'),
33 ('5 MiB', '5242880'),
34 ('10 MiB', '10485760')]
34 sizes = [('262144', '256 KiB'),
35 ('524288', '512 KiB'),
36 ('1048576', '1 MiB'),
37 ('5242880', '5 MiB'),
38 ('10485760', '10 MiB')]
39
3540 actions = [
36 (_('Open'), 'open_menuitem'),
37 (_('Save as'), 'save_as_menuitem'),
38 (_('Copy Link Location'), 'copy_link_location_menuitem'),
39 (_('Open Link in Browser'), 'open_link_in_browser_menuitem'),
40 (_('Open File in Browser'), 'open_file_in_browser_menuitem')]
41
42 geo_providers = [
43 (_('No map preview'), 'no_preview'),
44 ('Google Maps', 'Google'),
45 ('OpenStreetMap', 'OSM')]
41 ('open', _('Open')),
42 ('save_as', _('Save as')),
43 ('open_folder', _('Open Folder')),
44 ('copy_link_location', _('Copy Link Location')),
45 ('open_link_in_browser', _('Open Link in Browser'))]
4646
4747 self.plugin = plugin
48 options = [
49 Option('PreviewSizeSpinOption', _('Preview size'),
50 OptionType.VALUE, self.plugin.config['PREVIEW_SIZE'],
51 callback=self.on_option, data='PREVIEW_SIZE',
52 props={'range_': (100, 1000)}),
48 settings = [
49 Setting('PreviewSizeSpinSetting', _('Preview Size'),
50 SettingType.VALUE, self.plugin.config['PREVIEW_SIZE'],
51 callback=self.on_setting, data='PREVIEW_SIZE',
52 desc=_('Size of preview image'),
53 props={'range_': (100, 1000)}),
5354
54 Option('PreviewComboOption', _('Accepted filesize'),
55 OptionType.VALUE, self.plugin.config['MAX_FILE_SIZE'],
56 callback=self.on_option, data='MAX_FILE_SIZE',
57 props={'items': sizes,
58 'plugin': self.plugin}),
55 Setting(SettingKind.COMBO, _('File Size'),
56 SettingType.VALUE, self.plugin.config['MAX_FILE_SIZE'],
57 callback=self.on_setting, data='MAX_FILE_SIZE',
58 desc=_('Maximum file size for preview generation'),
59 props={'combo_items': sizes}),
5960
60 Option(OptionKind.SWITCH, _('Preview all Image URLs'),
61 OptionType.VALUE, self.plugin.config['ALLOW_ALL_IMAGES'],
62 callback=self.on_option, data='ALLOW_ALL_IMAGES'),
61 Setting(SettingKind.SWITCH, _('Preview all Image URLs'),
62 SettingType.VALUE, self.plugin.config['ALLOW_ALL_IMAGES'],
63 callback=self.on_setting, data='ALLOW_ALL_IMAGES',
64 desc=_('Generate preview for any URL containing images '
65 '(may be unsafe)')),
6366
64 Option('PreviewComboOption', _('Left click action'),
65 OptionType.VALUE, self.plugin.config['LEFTCLICK_ACTION'],
66 callback=self.on_option, data='LEFTCLICK_ACTION',
67 props={'items': actions,
68 'plugin': self.plugin}),
67 Setting(SettingKind.COMBO, _('Left Click'),
68 SettingType.VALUE, self.plugin.config['LEFTCLICK_ACTION'],
69 callback=self.on_setting, data='LEFTCLICK_ACTION',
70 desc=_('Action when left clicking a preview'),
71 props={'combo_items': actions}),
6972
70 Option('PreviewComboOption', _('Map service for preview'),
71 OptionType.VALUE, self.plugin.config['GEO_PREVIEW_PROVIDER'],
72 callback=self.on_option, data='GEO_PREVIEW_PROVIDER',
73 props={'items': geo_providers,
74 'plugin': self.plugin}),
75
76 Option(OptionKind.SWITCH, _('Enable HTTPS Verification'),
77 OptionType.VALUE, self.plugin.config['VERIFY'],
78 callback=self.on_option, data='VERIFY'),
73 Setting(SettingKind.SWITCH, _('HTTPS Verification'),
74 SettingType.VALUE, self.plugin.config['VERIFY'],
75 desc=_('Whether to check for a valid certificate'),
76 callback=self.on_setting, data='VERIFY'),
7977 ]
8078
81 OptionsDialog.__init__(self, parent, _('UrlImagePreview Options'),
82 Gtk.DialogFlags.MODAL, options, None,
83 extend=[
84 ('PreviewComboOption', ComboOption),
85 ('PreviewSizeSpinOption', SizeSpinOption)])
79 SettingsDialog.__init__(self, parent, _('UrlImagePreview Configuration'),
80 Gtk.DialogFlags.MODAL, settings, None,
81 extend=[('PreviewSizeSpinSetting',
82 SizeSpinSetting)])
8683
87 def on_option(self, value, data):
84 def on_setting(self, value, data):
8885 self.plugin.config[data] = value
8986
9087
91 class SizeSpinOption(SpinOption):
88 class SizeSpinSetting(SpinSetting):
9289
9390 __gproperties__ = {
94 "option-value": (int, 'Size', '', 100, 1000, 300,
95 GObject.ParamFlags.READWRITE), }
91 "setting-value": (int, 'Size', '', 100, 1000, 300,
92 GObject.ParamFlags.READWRITE), }
9693
9794 def __init__(self, *args, **kwargs):
98 SpinOption.__init__(self, *args, **kwargs)
99
100
101 class ComboOption(GenericOption):
102
103 __gproperties__ = {
104 "option-value": (str, 'Value', '', '',
105 GObject.ParamFlags.READWRITE), }
106
107 def __init__(self, *args, items, plugin):
108 GenericOption.__init__(self, *args)
109 self.plugin = plugin
110 self.combo = Gtk.ComboBox()
111 text_renderer = Gtk.CellRendererText()
112 self.combo.pack_start(text_renderer, True)
113 self.combo.add_attribute(text_renderer, 'text', 0)
114
115 self.store = Gtk.ListStore(str, str)
116 for item in items:
117 self.store.append(item)
118
119 self.combo.set_model(self.store)
120 self.combo.set_id_column(1)
121 self.combo.set_active_id(str(self.option_value))
122
123 self.combo.connect('changed', self.on_value_change)
124 self.combo.set_valign(Gtk.Align.CENTER)
125
126 self.option_box.pack_start(self.combo, True, True, 0)
127 self.show_all()
128
129 def on_value_change(self, combo):
130 self.set_value(combo.get_active_id())
131
132 def on_row_activated(self):
133 pass
95 SpinSetting.__init__(self, *args, **kwargs)
00 <?xml version="1.0" encoding="UTF-8"?>
1 <!-- Generated with glade 3.22.1 -->
12 <interface>
2 <requires lib="gtk+" version="3.0"/>
3 <object class="GtkImage" id="image1">
4 <property name="visible">True</property>
5 <property name="can_focus">False</property>
6 <property name="stock">gtk-open</property>
7 </object>
8 <object class="GtkImage" id="image2">
9 <property name="visible">True</property>
10 <property name="can_focus">False</property>
11 <property name="stock">gtk-save-as</property>
12 </object>
13 <object class="GtkImage" id="image3">
14 <property name="visible">True</property>
15 <property name="can_focus">False</property>
16 <property name="stock">gtk-copy</property>
17 </object>
18 <object class="GtkImage" id="image4">
19 <property name="visible">True</property>
20 <property name="can_focus">False</property>
21 <property name="stock">gtk-jump-to</property>
22 </object>
23 <object class="GtkImage" id="image5">
24 <property name="visible">True</property>
25 <property name="can_focus">False</property>
26 <property name="stock">gtk-jump-to</property>
27 </object>
3 <requires lib="gtk+" version="3.20"/>
284 <object class="GtkMenu" id="context_menu">
295 <property name="can_focus">False</property>
306 <child>
31 <object class="GtkImageMenuItem" id="open_menuitem">
32 <property name="label" translatable="yes">_Open</property>
7 <object class="GtkMenuItem" id="open">
338 <property name="visible">True</property>
349 <property name="can_focus">False</property>
10 <property name="label" translatable="yes">_Open</property>
3511 <property name="use_underline">True</property>
36 <property name="image">image1</property>
37 <property name="use_stock">False</property>
38 <property name="always_show_image">True</property>
3912 </object>
4013 </child>
4114 <child>
42 <object class="GtkImageMenuItem" id="save_as_menuitem">
43 <property name="label" translatable="yes">_Save as</property>
15 <object class="GtkMenuItem" id="save_as">
4416 <property name="visible">True</property>
4517 <property name="can_focus">False</property>
18 <property name="label" translatable="yes">_Save as</property>
4619 <property name="use_underline">True</property>
47 <property name="image">image2</property>
48 <property name="use_stock">False</property>
49 <property name="always_show_image">True</property>
20 </object>
21 </child>
22 <child>
23 <object class="GtkMenuItem" id="open_folder">
24 <property name="visible">True</property>
25 <property name="can_focus">False</property>
26 <property name="label" translatable="yes">Open _Folder</property>
27 <property name="use_underline">True</property>
5028 </object>
5129 </child>
5230 <child>
5634 </object>
5735 </child>
5836 <child>
59 <object class="GtkImageMenuItem" id="copy_link_location_menuitem">
60 <property name="label" translatable="yes">_Copy Link Location</property>
37 <object class="GtkMenuItem" id="copy_link_location">
6138 <property name="visible">True</property>
6239 <property name="can_focus">False</property>
40 <property name="label" translatable="yes">_Copy Link</property>
6341 <property name="use_underline">True</property>
64 <property name="image">image3</property>
65 <property name="use_stock">False</property>
66 <property name="always_show_image">True</property>
6742 </object>
6843 </child>
6944 <child>
70 <object class="GtkImageMenuItem" id="open_link_in_browser_menuitem">
71 <property name="label" translatable="yes">Open Link in _Browser</property>
45 <object class="GtkMenuItem" id="open_link_in_browser">
7246 <property name="visible">True</property>
7347 <property name="can_focus">False</property>
48 <property name="label" translatable="yes">Open Link in _Browser</property>
7449 <property name="use_underline">True</property>
75 <property name="image">image4</property>
76 <property name="use_stock">False</property>
77 <property name="always_show_image">True</property>
78 </object>
79 </child>
80 <child>
81 <object class="GtkSeparatorMenuItem" id="extras_separator">
82 <property name="visible">True</property>
83 <property name="can_focus">False</property>
84 </object>
85 </child>
86 <child>
87 <object class="GtkImageMenuItem" id="open_file_in_browser_menuitem">
88 <property name="label" translatable="yes">Open _Downloaded File in Browser</property>
89 <property name="visible">True</property>
90 <property name="can_focus">False</property>
91 <property name="use_underline">True</property>
92 <property name="image">image5</property>
93 <property name="use_stock">False</property>
94 <property name="always_show_image">True</property>
9550 </object>
9651 </child>
9752 </object>
+0
-115
http_functions.py less more
0 # -*- coding: utf-8 -*-
1 ##
2 ## This file is part of Gajim.
3 ##
4 ## Gajim is free software; you can redistribute it and/or modify
5 ## it under the terms of the GNU General Public License as published
6 ## by the Free Software Foundation; version 3 only.
7 ##
8 ## Gajim is distributed in the hope that it will be useful,
9 ## but WITHOUT ANY WARRANTY; without even the implied warranty of
10 ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 ## GNU General Public License for more details.
12 ##
13 ## You should have received a copy of the GNU General Public License
14 ## along with Gajim. If not, see <http://www.gnu.org/licenses/>.
15 ##
16
17 import urllib.request as urllib2
18 import socket
19 import ssl
20 import logging
21 import os
22
23 from gajim.common import app
24 from gajim.plugins.plugins_i18n import _
25
26
27 if os.name == 'nt':
28 import certifi
29
30 log = logging.getLogger('gajim.plugin_system.preview.http_functions')
31
32 def get_http_head(account, url, verify):
33 return _get_http_head_direct(url, verify)
34
35 def get_http_file(account, attrs):
36 return _get_http_direct(attrs)
37
38 def _get_http_head_direct(url, verify):
39 log.info('Head request direct for URL: %s', url)
40 try:
41 req = urllib2.Request(url)
42 req.get_method = lambda: 'HEAD'
43 req.add_header('User-Agent', 'Gajim %s' % app.version)
44 if not verify:
45 context = ssl.create_default_context()
46 context.check_hostname = False
47 context.verify_mode = ssl.CERT_NONE
48 log.warning('CERT Verification disabled')
49 f = urllib2.urlopen(req, timeout=30, context=context)
50 else:
51 if os.name == 'nt':
52 f = urllib2.urlopen(req, cafile=certifi.where())
53 else:
54 f = urllib2.urlopen(req)
55 except Exception as ex:
56 log.debug('Error', exc_info=True)
57 return ('', 0)
58 ctype = f.headers['Content-Type']
59 clen = f.headers['Content-Length']
60 try:
61 clen = int(clen)
62 except (TypeError, ValueError):
63 pass
64 return (ctype, clen)
65
66 def _get_http_direct(attrs):
67 """
68 Download a file. This function should
69 be launched in a separated thread.
70 """
71 log.info('Get request direct for URL: %s', attrs['src'])
72 mem, alt, max_size = b'', '', 2 * 1024 * 1024
73 if 'max_size' in attrs:
74 max_size = attrs['max_size']
75 try:
76 req = urllib2.Request(attrs['src'])
77 req.add_header('User-Agent', 'Gajim ' + app.version)
78 if not attrs['verify']:
79 context = ssl.create_default_context()
80 context.check_hostname = False
81 context.verify_mode = ssl.CERT_NONE
82 log.warning('CERT Verification disabled')
83 f = urllib2.urlopen(req, timeout=30, context=context)
84 else:
85 if os.name == 'nt':
86 f = urllib2.urlopen(req, cafile=certifi.where())
87 else:
88 f = urllib2.urlopen(req)
89 except Exception as ex:
90 log.debug('Error', exc_info=True)
91 pixbuf = None
92 alt = attrs.get('alt', 'Broken image')
93 else:
94 while True:
95 try:
96 temp = f.read(100)
97 except socket.timeout as ex:
98 log.debug('Timeout loading image %s', attrs['src'] + str(ex))
99 alt = attrs.get('alt', '')
100 if alt:
101 alt += '\n'
102 alt += _('Timeout loading image')
103 break
104 if temp:
105 mem += temp
106 else:
107 break
108 if len(mem) > max_size:
109 alt = attrs.get('alt', '')
110 if alt:
111 alt += '\n'
112 alt += _('Image is too big')
113 break
114 return (mem, alt)
00 [info]
11 name: Url image preview
22 short_name: url_image_preview
3 version: 2.3.23
4 description: Displays a preview of links to images
3 version: 2.4.1
4 description: Displays a preview of image links.
55 authors = Denis Fomin <fominde@gmail.com>
66 Yann Leboulanger <asterix@lagaule.org>
77 Anders Sandblad <runeson@gmail.com>
88 Thilo Molitor <thilo@eightysoft.de>
99 Philipp Hoerist <philipp@hoerist.com>
1010 homepage = https://dev.gajim.org/gajim/gajim-plugins/wikis/UrlImagePreviewPlugin
11 min_gajim_version: 1.0.99
12 max_gajim_version: 1.1.90
11 min_gajim_version: 1.1.91
12 max_gajim_version: 1.2.90
+0
-90
resize_gif.py less more
0 from io import BytesIO
1 from PIL import Image
2
3
4 def resize_gif(mem, path, resize_to):
5 frames, result = extract_and_resize_frames(mem, resize_to)
6
7 if len(frames) == 1:
8 frames[0].save(path, optimize=True)
9 else:
10 frames[0].save(path,
11 optimize=True,
12 save_all=True,
13 append_images=frames[1:],
14 duration=result['duration'],
15 loop=1000)
16
17
18 def analyse_image(mem):
19 """
20 Pre-process pass over the image to determine the mode (full or additive).
21 Necessary as assessing single frames isn't reliable. Need to know the mode
22 before processing all frames.
23 """
24 image = Image.open(BytesIO(mem))
25 results = {
26 'size': image.size,
27 'mode': 'full',
28 'duration': image.info.get('duration', 0)
29 }
30
31 try:
32 while True:
33 if image.tile:
34 tile = image.tile[0]
35 update_region = tile[1]
36 update_region_dimensions = update_region[2:]
37 if update_region_dimensions != image.size:
38 results['mode'] = 'partial'
39 break
40 image.seek(image.tell() + 1)
41 except EOFError:
42 pass
43 return results
44
45
46 def extract_and_resize_frames(mem, resize_to):
47 result = analyse_image(mem)
48 image = Image.open(BytesIO(mem))
49
50 i = 0
51 palette = image.getpalette()
52 last_frame = image.convert('RGBA')
53
54 frames = []
55
56 try:
57 while True:
58 '''
59 If the GIF uses local colour tables,
60 each frame will have its own palette.
61 If not, we need to apply the global palette to the new frame.
62 '''
63 if not image.getpalette():
64 image.putpalette(palette)
65
66 new_frame = Image.new('RGBA', image.size)
67
68 '''
69 Is this file a "partial"-mode GIF where frames update a region
70 of a different size to the entire image?
71 If so, we need to construct the new frame by
72 pasting it on top of the preceding frames.
73 '''
74 if result['mode'] == 'partial':
75 new_frame.paste(last_frame)
76
77 new_frame.paste(image, (0, 0), image.convert('RGBA'))
78
79 # This method preservs aspect ratio
80 new_frame.thumbnail(resize_to, Image.ANTIALIAS)
81 frames.append(new_frame)
82
83 i += 1
84 last_frame = new_frame
85 image.seek(image.tell() + 1)
86 except EOFError:
87 pass
88
89 return frames, result
0 # -*- coding: utf-8 -*-
1 ##
2 ## This file is part of Gajim.
3 ##
4 ## Gajim is free software; you can redistribute it and/or modify
5 ## it under the terms of the GNU General Public License as published
6 ## by the Free Software Foundation; version 3 only.
7 ##
8 ## Gajim is distributed in the hope that it will be useful,
9 ## but WITHOUT ANY WARRANTY; without even the implied warranty of
10 ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 ## GNU General Public License for more details.
12 ##
13 ## You should have received a copy of the GNU General Public License
14 ## along with Gajim. If not, see <http://www.gnu.org/licenses/>.
15 ##
0 # This file is part of Image Preview Gajim Plugin.
1 #
2 # Image Preview Gajim Plugin is free software;
3 # you can redistribute it and/or modify
4 # it under the terms of the GNU General Public License as published
5 # by the Free Software Foundation; version 3 only.
6 #
7 # Image Preview Gajim Plugin is distributed in the hope that it will be useful,
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 # GNU General Public License for more details.
11 #
12 # You should have received a copy of the GNU General Public License
13 # along with Image Preview Gajim Plugin.
14 # If not, see <http://www.gnu.org/licenses/>.
1615
1716 import os
18 import hashlib
19 import binascii
2017 import logging
21 import math
18 import shutil
19 from pathlib import Path
20 from functools import partial
2221 from urllib.parse import urlparse
2322 from urllib.parse import unquote
24 from io import BytesIO
25 import shutil
26 from functools import partial
27
28 from gi.repository import Gtk, Gdk, GLib, GdkPixbuf
23
24 from gi.repository import Gtk
25 from gi.repository import Gdk
26 from gi.repository import GLib
27 from gi.repository import Soup
2928
3029 from gajim.common import app
31 from gajim.common import helpers
3230 from gajim.common import configpaths
33
34 from gajim import dialogs
35 from gajim import gtkgui_helpers
31 from gajim.common.helpers import open_file
32 from gajim.common.helpers import open_uri
33 from gajim.common.helpers import write_file_async
34 from gajim.common.helpers import load_file_async
35 from gajim.common.helpers import get_tls_error_phrase
36 from gajim.gtk.dialogs import ErrorDialog
37 from gajim.gtk.filechoosers import FileSaveDialog
38 from gajim.gtk.util import load_icon
39 from gajim.gtk.util import get_monitor_scale_factor
3640
3741 from gajim.plugins import GajimPlugin
38 from gajim.plugins.helpers import log_calls
42 from gajim.plugins.helpers import get_builder
3943 from gajim.plugins.plugins_i18n import _
4044
41 from url_image_preview.http_functions import get_http_head, get_http_file
4245 from url_image_preview.config_dialog import UrlImagePreviewConfigDialog
4346
44 from gajim.gtk.filechoosers import FileSaveDialog
45 from gajim.gtk.util import get_cursor
46
47
48 log = logging.getLogger('gajim.plugin_system.preview')
47
48 log = logging.getLogger('gajim.p.preview')
4949
5050 ERROR_MSG = None
5151 try:
52 from PIL import Image
53 from url_image_preview.resize_gif import resize_gif
54 except:
55 log.debug('Pillow not available')
56 ERROR_MSG = 'Please install python-pillow'
52 from PIL import Image # pylint: disable=unused-import
53 except ImportError:
54 log.error('Pillow not available')
55 ERROR_MSG = _('Please install python-pillow')
5756
5857 try:
59 if os.name == 'nt':
60 from cryptography.hazmat.backends.openssl import backend
61 else:
62 from cryptography.hazmat.backends import default_backend
63 from cryptography.hazmat.primitives.ciphers import Cipher
64 from cryptography.hazmat.primitives.ciphers import algorithms
65 from cryptography.hazmat.primitives.ciphers.modes import GCM
66 decryption_available = True
58 import cryptography # pylint: disable=unused-import
6759 except Exception:
68 DEP_MSG = 'For preview of encrypted images, ' \
69 'please install python-cryptography!'
70 log.exception('Error')
71 log.info('Decryption/Encryption disabled due to errors')
72 decryption_available = False
73
74 ACCEPTED_MIME_TYPES = ('image/png', 'image/jpeg', 'image/gif', 'image/raw',
75 'image/svg+xml', 'image/x-ms-bmp')
60 ERROR_MSG = _('Please install python-cryptography')
61 log.error('python-cryptography not available')
62
63 # pylint: disable=ungrouped-imports
64 if ERROR_MSG is None:
65 from url_image_preview.utils import aes_decrypt
66 from url_image_preview.utils import get_image_paths
67 from url_image_preview.utils import split_geo_uri
68 from url_image_preview.utils import parse_fragment
69 from url_image_preview.utils import create_thumbnail
70 from url_image_preview.utils import pixbuf_from_data
71 from url_image_preview.utils import create_clickable_image
72 from url_image_preview.utils import filename_from_uri
73 # pylint: enable=ungrouped-imports
74
75 ACCEPTED_MIME_TYPES = [
76 'image/png',
77 'image/jpeg',
78 'image/gif',
79 'image/raw',
80 'image/svg+xml',
81 'image/x-ms-bmp',
82 ]
7683
7784
7885 class UrlImagePreviewPlugin(GajimPlugin):
79 @log_calls('UrlImagePreviewPlugin')
8086 def init(self):
87 # pylint: disable=attribute-defined-outside-init
8188 if ERROR_MSG:
8289 self.activatable = False
8390 self.available_text = ERROR_MSG
8491 self.config_dialog = None
8592 return
8693
87 if not decryption_available:
88 self.available_text = DEP_MSG
8994 self.config_dialog = partial(UrlImagePreviewConfigDialog, self)
95
9096 self.gui_extension_points = {
91 'chat_control_base': (self.connect_with_chat_control,
92 self.disconnect_from_chat_control),
93 'history_window':
94 (self.connect_with_history, self.disconnect_from_history),
95 'print_real_text': (self.print_real_text, None), }
97 'chat_control_base': (self._on_connect_chat_control_base,
98 self._on_disconnect_chat_control_base),
99 'history_window': (self._on_connect_history_window,
100 self._on_disconnect_history_window),
101 'print_real_text': (self._print_real_text, None), }
102
96103 self.config_default_values = {
97 'PREVIEW_SIZE': (150, 'Preview size(10-512)'),
104 'PREVIEW_SIZE': (150, 'Preview size (100-1000)'),
98105 'MAX_FILE_SIZE': (5242880, 'Max file size for image preview'),
99106 'ALLOW_ALL_IMAGES': (False, ''),
100107 'LEFTCLICK_ACTION': ('open_menuitem', 'Open'),
101108 'ANONYMOUS_MUC': (False, ''),
102 'GEO_PREVIEW_PROVIDER': ('Google', 'Google Maps'),
103109 'VERIFY': (True, ''),}
104 self.controls = {}
105 self.history_window_control = None
106
107 @log_calls('UrlImagePreviewPlugin')
108 def connect_with_chat_control(self, chat_control):
109 account = chat_control.contact.account.name
110 jid = chat_control.contact.jid
111 if account not in self.controls:
112 self.controls[account] = {}
113 self.controls[account][jid] = Base(self, chat_control.conv_textview)
114
115 @log_calls('UrlImagePreviewPlugin')
116 def disconnect_from_chat_control(self, chat_control):
117 account = chat_control.contact.account.name
118 jid = chat_control.contact.jid
119 self.controls[account][jid].deinit_handlers()
120 del self.controls[account][jid]
121
122 @log_calls('UrlImagePreviewPlugin')
123 def connect_with_history(self, history_window):
124 if self.history_window_control:
125 self.history_window_control.deinit_handlers()
126 self.history_window_control = Base(
127 self, history_window.history_textview)
128
129 @log_calls('UrlImagePreviewPlugin')
130 def disconnect_from_history(self, history_window):
131 if self.history_window_control:
132 self.history_window_control.deinit_handlers()
133 self.history_window_control = None
134
135 def print_real_text(self, tv, real_text, text_tags, graphics,
136 iter_, additional_data):
137 if tv.used_in_history_window and self.history_window_control:
138 self.history_window_control.print_real_text(
139 real_text, text_tags, graphics, iter_, additional_data)
140
141 account = tv.account
142 for jid in self.controls[account]:
143 if self.controls[account][jid].textview != tv:
144 continue
145 self.controls[account][jid].print_real_text(
146 real_text, text_tags, graphics, iter_, additional_data)
147 return
148
149
150 class Base(object):
151 def __init__(self, plugin, textview):
152 self.plugin = plugin
153 self.textview = textview
154 self.handlers = {}
155
156 self.directory = os.path.join(configpaths.get('MY_DATA'),
157 'downloads')
158 self.thumbpath = os.path.join(configpaths.get('MY_CACHE'),
159 'downloads.thumb')
160
161 try:
162 self._create_path(self.directory)
163 self._create_path(self.thumbpath)
164 except Exception:
165 log.error("Error creating download and/or thumbnail folder!")
166 raise
167
168 def deinit_handlers(self):
169 # remove all register handlers on wigets, created by self.xml
170 # to prevent circular references among objects
171 for i in list(self.handlers.keys()):
172 if self.handlers[i].handler_is_connected(i):
173 self.handlers[i].disconnect(i)
174 del self.handlers[i]
175
176 def print_real_text(self, real_text, text_tags, graphics, iter_,
177 additional_data):
178
179 if len(real_text.split(' ')) > 1:
180 # urlparse dont recognises spaces as URL delimiter
181 log.debug('Url with text will not be displayed: %s', real_text)
182 return
183
184 urlparts = urlparse(unquote(real_text))
185 if not self._accept_uri(urlparts, real_text, additional_data):
186 return
187
188 # Don't print the URL in the message window (in the calling function)
189 self.textview.plugin_modified = True
190
191 buffer_ = self.textview.tv.get_buffer()
110
111 self._textviews = {}
112
113 self._session = Soup.Session()
114 self._session.add_feature_by_type(Soup.ContentSniffer)
115 self._session.props.https_aliases = ['aesgcm']
116 self._session.props.ssl_strict = False
117
118 self._orig_dir = Path(configpaths.get('MY_DATA')) / 'downloads'
119 self._thumb_dir = Path(configpaths.get('MY_CACHE')) / 'downloads.thumb'
120
121 if GLib.mkdir_with_parents(str(self._orig_dir), 0o700) != 0:
122 log.error('Failed to create: %s', self._orig_dir)
123
124 if GLib.mkdir_with_parents(str(self._thumb_dir), 0o700) != 0:
125 log.error('Failed to create: %s', self._thumb_dir)
126
127 self._migrate_config()
128
129 def _migrate_config(self):
130 action = self.config['LEFTCLICK_ACTION']
131 if action.endswith('_menuitem'):
132 self.config['LEFTCLICK_ACTION'] = action[:-9]
133
134 def _on_connect_chat_control_base(self, chat_control):
135 self._textviews[chat_control.control_id] = chat_control.conv_textview
136
137 def _on_disconnect_chat_control_base(self, chat_control):
138 self._textviews.pop(chat_control.control_id, None)
139
140 def _on_connect_history_window(self, history_window):
141 self._textviews[id(history_window)] = history_window.history_textview
142
143 def _on_disconnect_history_window(self, history_window):
144 self._textviews.pop(id(history_window), None)
145
146 def _get_control_id(self, textview):
147 for control_id, textview_ in self._textviews.items():
148 if textview == textview_:
149 return control_id
150
151 def _print_real_text(self, textview, text, _text_tags, _graphics,
152 iter_, additional_data):
153
154 if len(text.split(' ')) > 1:
155 # urlparse doesn't recognise spaces as URL delimiter
156 log.debug('Text is not an uri: %s...', text[:15])
157 return
158
159 uri = text
160 urlparts = urlparse(unquote(uri))
161 if not self._accept_uri(urlparts, uri, additional_data):
162 return
163
164 textview.plugin_modified = True
165 control_id = self._get_control_id(textview)
166
167 start_mark, end_mark = self._print_text(textview.tv.get_buffer(),
168 iter_,
169 uri)
170
171 if uri.startswith('geo:'):
172 preview = self._process_geo_uri(uri,
173 start_mark,
174 end_mark,
175 control_id)
176 if preview is None:
177 return
178 pixbuf = load_icon('map',
179 size=preview.size,
180 scale=get_monitor_scale_factor(),
181 pixbuf=True)
182 self._update_textview(pixbuf, preview)
183 return
184
185 preview = self._process_web_uri(uri,
186 urlparts,
187 start_mark,
188 end_mark,
189 control_id)
190
191 if not preview.orig_exists():
192 self._download_content(preview)
193
194 elif not preview.thumb_exists():
195 load_file_async(preview.orig_path,
196 self._on_orig_load_finished,
197 preview)
198
199 else:
200 load_file_async(preview.thumb_path,
201 self._on_thumb_load_finished,
202 preview)
203
204 @staticmethod
205 def _print_text(buffer_, iter_, text):
192206 if not iter_:
193207 iter_ = buffer_.get_end_iter()
194208
195 # Show URL, until image is loaded (if ever)
196 ttt = buffer_.get_tag_table()
197 repl_start = buffer_.create_mark(None, iter_, True)
198 buffer_.insert_with_tags(iter_, real_text,
199 *[(ttt.lookup(t) if isinstance(t, str) else t) for t in ["url"]])
200 repl_end = buffer_.create_mark(None, iter_, True)
201
202 # Handle geo:-URIs
203 if real_text.startswith('geo:'):
204 if self.plugin.config['GEO_PREVIEW_PROVIDER'] == 'no_preview':
205 return
206 size = self.plugin.config['PREVIEW_SIZE']
207 geo_provider = self.plugin.config['GEO_PREVIEW_PROVIDER']
208 key = ''
209 iv = ''
210 encrypted = False
211 ext = '.png'
212 color = 'blue'
213 zoom = 16
214 location = real_text[4:]
215 lat, _, lon = location.partition(',')
216 if lon == '':
217 return
218
219 filename = 'location_' + geo_provider + '_' \
220 + location.replace(',', '_').replace('.', '-')
221 newfilename = filename + ext
222 thumbfilename = filename + '_thumb_' \
223 + str(self.plugin.config['PREVIEW_SIZE']) + ext
224 filepath = os.path.join(self.directory, newfilename)
225 thumbpath = os.path.join(self.thumbpath, thumbfilename)
226 filepaths = [filepath, thumbpath]
227
228 # Google
229 if geo_provider == 'Google':
230 url = 'https://maps.googleapis.com/maps/api/staticmap?' \
231 'center={}&zoom={}&size={}x{}&markers=color:{}' \
232 '|label:.|{}'.format(location, zoom, size, size,
233 color, location)
234 weburl = 'https://www.google.com/maps/place/{}' \
235 .format(location)
236 real_text = url
237 else:
238 # OpenStreetMap / MapQuest
239 apikey = 'F7x36jLVv2hiANVAXmhwvUB044XvGASh'
240
241 url = 'https://open.mapquestapi.com/staticmap/v4/' \
242 'getmap?key={}&center={}&zoom={}&size={},{}&type=map' \
243 '&imagetype=png&pois={},{}&scalebar=false' \
244 .format(apikey, location, zoom, size, size, color,
245 location)
246 weburl = 'http://www.openstreetmap.org/' \
247 '?mlat={}&mlon={}#map={}/{}/{}&layers=N' \
248 .format(lat, lon, zoom, lat, lon)
249 real_text = url
250 else:
251 weburl = real_text
252 filename = os.path.basename(urlparts.path)
253 ext = os.path.splitext(filename)[1]
254 name = os.path.splitext(filename)[0]
255 if len(name) > 90:
256 # Many Filesystems have a limit on filename length
257 # Most have 255, some encrypted ones only 143
258 # We add around 50 chars for the hash,
259 # so the filename should not exceed 90
260 name = name[:90]
261 namehash = hashlib.sha1(real_text.encode('utf-8')).hexdigest()
262 newfilename = name + '_' + namehash + ext
263 thumbfilename = name + '_' + namehash + '_thumb_' \
264 + str(self.plugin.config['PREVIEW_SIZE']) + ext
265
266 filepath = os.path.join(self.directory, newfilename)
267 thumbpath = os.path.join(self.thumbpath, thumbfilename)
268 filepaths = [filepath, thumbpath]
269
270 key = ''
271 iv = ''
272 encrypted = False
273 if urlparts.fragment:
274 fragment = binascii.unhexlify(urlparts.fragment)
275 key = fragment[16:]
276 iv = fragment[:16]
277 if len(key) == 32 and len(iv) == 16:
278 encrypted = True
279 if not encrypted:
280 key = fragment[12:]
281 iv = fragment[:12]
282 if len(key) == 32 and len(iv) == 12:
283 encrypted = True
284
285 # file exists but thumbnail got deleted
286 if os.path.exists(filepath) and not os.path.exists(thumbpath):
287 if urlparts.scheme == 'geo':
288 real_text = weburl
289 with open(filepath, 'rb') as f:
290 mem = f.read()
291 app.thread_interface(
292 self._save_thumbnail, [thumbpath, mem],
293 self._update_img, [real_text, repl_start,
294 repl_end, filepath, encrypted])
295
296 # display thumbnail if already downloadeded
297 # (but only if file also exists)
298 elif os.path.exists(filepath) and os.path.exists(thumbpath):
299 if urlparts.scheme == 'geo':
300 real_text = weburl
301 app.thread_interface(
302 self._load_thumbnail, [thumbpath],
303 self._update_img, [real_text, repl_start,
304 repl_end, filepath, encrypted])
305
306 # or download file, calculate thumbnail and finally display it
307 else:
308 if encrypted and not decryption_available:
309 log.debug('Please install Crytography to decrypt pictures')
310 else:
311 # First get the http head request
312 # which does not fetch data, just headers
313 # then check the mime type and filesize
314 if urlparts.scheme == 'aesgcm':
315 real_text = 'https://' + real_text[9:]
316 verify = self.plugin.config['VERIFY']
317 app.thread_interface(
318 get_http_head, [self.textview.account, real_text, verify],
319 self._check_mime_size, [real_text, weburl, repl_start,
320 repl_end, filepaths, key, iv,
321 encrypted])
322
323 def _accept_uri(self, urlparts, real_text, additional_data):
209 start_mark = buffer_.create_mark(None, iter_, True)
210 buffer_.insert_with_tags_by_name(iter_, text, 'url')
211 end_mark = buffer_.create_mark(None, iter_, True)
212 return start_mark, end_mark
213
214 def _accept_uri(self, urlparts, uri, additional_data):
324215 try:
325 oob_url = additional_data["gajim"]["oob_url"]
216 oob_url = additional_data['gajim']['oob_url']
326217 except (KeyError, AttributeError):
327218 oob_url = None
328219
220 # geo
221 if urlparts.scheme == 'geo':
222 return True
223
329224 if not urlparts.netloc:
330 log.info('No netloc found in URL %s', real_text)
225 log.info('No netloc found in URL: %s', uri)
331226 return False
332
333 # geo
334 if urlparts.scheme == "geo":
335 if self.plugin.config['GEO_PREVIEW_PROVIDER'] == 'no_preview':
336 log.info('geo: link preview is disabled')
337 return False
338 return True
339227
340228 # aesgcm
341229 if urlparts.scheme == 'aesgcm':
342230 return True
343231
344 # https
345 if urlparts.scheme == 'https':
346 if real_text == oob_url or self.plugin.config['ALLOW_ALL_IMAGES']:
232 # http/https
233 if urlparts.scheme in ('https', 'http'):
234 if self.config['ALLOW_ALL_IMAGES']:
347235 return True
348 log.info('Incorrect oob data found')
349 return False
350
351 log.info('Not supported URI scheme found: %s', real_text)
236
237 if oob_url is None:
238 log.info('No oob url for: %s', uri)
239 return False
240
241 if uri != oob_url:
242 log.info('uri != oob url: %s != %s', uri, oob_url)
243 return False
244 return True
245
246 log.info('Unsupported URI scheme: %s', uri)
352247 return False
353248
354 def _save_thumbnail(self, thumbpath, mem):
355 size = self.plugin.config['PREVIEW_SIZE']
356
249 @staticmethod
250 def _process_geo_uri(uri, start_mark, end_mark, control_id):
357251 try:
358 loader = GdkPixbuf.PixbufLoader()
359 loader.write(mem)
360 loader.close()
361 if loader.get_format().get_name() == 'gif':
362 pixbuf = loader.get_animation()
363 else:
364 pixbuf = loader.get_pixbuf()
365 except GLib.GError as error:
366 log.info('Failed to load image using Gdk.Pixbuf')
367 log.debug(error)
368
369 # Try Pillow
370 image = Image.open(BytesIO(mem)).convert("RGBA")
371 array = GLib.Bytes.new(image.tobytes())
372 width, height = image.size
373 pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(
374 array, GdkPixbuf.Colorspace.RGB, True,
375 8, width, height, width * 4)
376
377 try:
378 self._create_path(os.path.dirname(thumbpath))
379 thumbnail = pixbuf
380 if isinstance(pixbuf, GdkPixbuf.PixbufAnimation):
381 if size < pixbuf.get_width() or size < pixbuf.get_height():
382 resize_gif(mem, thumbpath, (size, size))
383 thumbnail = self._load_thumbnail(thumbpath)
384 else:
385 self._write_file(thumbpath, mem)
386 else:
387 width, height = self._get_thumbnail_size(pixbuf, size)
388 thumbnail = pixbuf.scale_simple(
389 width, height, GdkPixbuf.InterpType.BILINEAR)
390 thumbnail.savev(thumbpath, 'png', [], [])
252 split_geo_uri(uri)
391253 except Exception as error:
392 GLib.idle_add(
393 self._raise_error_dialog,
394 _('Could not save file'),
395 _('Exception raised while saving thumbnail '
396 'for image file (see error log for more '
397 'information)'))
398 log.exception(error)
399 return
400 return thumbnail
401
402 @staticmethod
403 def _get_thumbnail_size(pixbuf, size):
404 # Calculates the new thumbnail size while preserving the aspect ratio
405 image_width = pixbuf.get_width()
406 image_height = pixbuf.get_height()
407
408 if image_width > image_height:
409 if image_width > size:
410 image_height = math.ceil((size / float(image_width) * image_height))
411 image_width = int(size)
412 else:
413 if image_height > size:
414 image_width = math.ceil((size / float(image_height) * image_width))
415 image_height = int(size)
416
417 return image_width, image_height
418
419 @staticmethod
420 def _load_thumbnail(thumbpath):
421 ext = os.path.splitext(thumbpath)[1]
422 if ext == '.gif':
423 return GdkPixbuf.PixbufAnimation.new_from_file(thumbpath)
424 return GdkPixbuf.Pixbuf.new_from_file(thumbpath)
425
426 @staticmethod
427 def _write_file(path, data):
428 log.info("Writing '%s' of size %d...", path, len(data))
429 try:
430 with open(path, "wb") as output_file:
431 output_file.write(data)
432 output_file.closed
433 except Exception as e:
434 log.error("Failed to write file '%s'!", path)
435 raise
436
437 def _get_at_end(self):
438 try:
439 # Gajim 1.0.0
440 return self.textview.at_the_end()
441 except AttributeError:
442 # Gajim 1.0.1
443 return self.textview.autoscroll
444
445 def _scroll_to_end(self):
446 try:
447 # Gajim 1.0.0
448 self.textview.scroll_to_end_iter()
449 except AttributeError:
450 # Gajim 1.0.1
451 self.textview.scroll_to_end()
452
453 def _update_img(self, pixbuf, url, repl_start, repl_end,
454 filepath, encrypted):
455 if pixbuf is None:
456 # If image could not be downloaded, URL is already displayed
457 log.error('Could not download image for URL: %s', url)
458 return
459
460 urlparts = urlparse(unquote(url))
461 filename = os.path.basename(urlparts.path)
462 if os.path.basename(filepath).startswith('location_'):
463 filename = os.path.basename(filepath)
464
465 def add_to_textview():
466 try:
467 at_end = self._get_at_end()
468
469 buffer_ = repl_start.get_buffer()
470 iter_ = buffer_.get_iter_at_mark(repl_start)
471 buffer_.insert(iter_, "\n")
472 anchor = buffer_.create_child_anchor(iter_)
473 anchor.plaintext = url
474
475 image = self._create_clickable_image(pixbuf, url)
476
477 self.textview.tv.add_child_at_anchor(image, anchor)
478 buffer_.delete(iter_,
479 buffer_.get_iter_at_mark(repl_end))
480
481 image.connect(
482 'button-press-event', self.on_button_press_event,
483 filepath, filename, url, encrypted)
484 image.get_window().set_cursor(get_cursor('HAND2'))
485
486 if at_end:
487 self._scroll_to_end()
488 except Exception as ex:
489 log.exception("Exception while loading %s: %s", url, ex)
490 return False
491 # add to mainloop --> make call threadsafe
492 GLib.idle_add(add_to_textview)
493
494 def _create_clickable_image(self, pixbuf, url):
495 if isinstance(pixbuf, GdkPixbuf.PixbufAnimation):
496 image = Gtk.Image.new_from_animation(pixbuf)
497 else:
498 image = Gtk.Image.new_from_pixbuf(pixbuf)
499
500 css = '''#Preview {
501 box-shadow: 0px 0px 3px 0px alpha(@theme_text_color, 0.2);
502 margin: 5px 10px 5px 10px; }'''
503 gtkgui_helpers.add_css_to_widget(image, css)
504 image.set_name('Preview')
505
506 event_box = Gtk.EventBox()
507 event_box.set_tooltip_text(url)
508 event_box.add(image)
509 event_box.show_all()
510 return event_box
511
512 def _check_mime_size(self, tuple_arg,
513 url, weburl, repl_start, repl_end, filepaths,
514 key, iv, encrypted):
515 file_mime, file_size = tuple_arg
516 # Check if mime type is acceptable
517 if not file_mime or not file_size:
518 log.info("Failed to load HEAD Request for URL: '%s' "
519 "mime: %s, size: %s", url, file_mime, file_size)
520 # URL is already displayed
521 return
522 if file_mime.lower() not in ACCEPTED_MIME_TYPES:
523 log.info("Not accepted mime type '%s' for URL: '%s'",
524 file_mime.lower(), url)
525 # URL is already displayed
526 return
527 # Check if file size is acceptable
528 max_size = int(self.plugin.config['MAX_FILE_SIZE'])
529 if file_size > max_size or file_size == 0:
530 log.info("File size (%s) too big or unknown (zero) for URL: '%s'",
531 file_size, url)
532 # URL is already displayed
533 return
534
535 attributes = {'src': url,
536 'verify': self.plugin.config['VERIFY'],
537 'max_size': max_size,
538 'filepaths': filepaths,
539 'key': key,
540 'iv': iv}
541
542 app.thread_interface(
543 self._download_image, [self.textview.account,
544 attributes, encrypted],
545 self._update_img, [weburl, repl_start, repl_end,
546 filepaths[0], encrypted])
547
548 def _download_image(self, account, attributes, encrypted):
549 filepath = attributes['filepaths'][0]
550 thumbpath = attributes['filepaths'][1]
551 key = attributes['key']
552 iv = attributes['iv']
553 mem, alt = get_http_file(account, attributes)
554
555 # Decrypt file if necessary
556 if encrypted:
557 mem = self._aes_decrypt_fast(key, iv, mem)
558
559 try:
560 # Write file to harddisk
561 self._write_file(filepath, mem)
562 except Exception as e:
563 GLib.idle_add(
564 self._raise_error_dialog,
565 _('Could not save file'),
566 _('Exception raised while saving image file'
567 ' (see error log for more information)'))
568 log.error(str(e))
569
570 # Create thumbnail, write it to harddisk and return it
571 return self._save_thumbnail(thumbpath, mem)
572
573 def _create_path(self, folder):
574 if os.path.exists(folder):
575 return
576 log.debug("creating folder '%s'" % folder)
577 os.mkdir(folder, 0o700)
578
579 def _aes_decrypt_fast(self, key, iv, payload):
580 # Use AES128 GCM with the given key and iv to decrypt the payload.
581 if os.name == 'nt':
582 be = backend
583 else:
584 be = default_backend()
585 data = payload[:-16]
586 tag = payload[-16:]
587 decryptor = Cipher(
588 algorithms.AES(key),
589 GCM(iv, tag=tag),
590 backend=be).decryptor()
591 return decryptor.update(data) + decryptor.finalize()
592
593 def make_rightclick_menu(self, event, data):
594 xml = Gtk.Builder()
595 xml.set_translation_domain('gajim_plugins')
596 xml.add_from_file(self.plugin.local_file_path('context_menu.ui'))
597 menu = xml.get_object('context_menu')
598
599 open_menuitem = xml.get_object('open_menuitem')
600 save_as_menuitem = xml.get_object('save_as_menuitem')
601 copy_link_location_menuitem = \
602 xml.get_object('copy_link_location_menuitem')
603 open_link_in_browser_menuitem = \
604 xml.get_object('open_link_in_browser_menuitem')
605 open_file_in_browser_menuitem = \
606 xml.get_object('open_file_in_browser_menuitem')
607 extras_separator = \
608 xml.get_object('extras_separator')
609
610 if data["encrypted"]:
611 open_link_in_browser_menuitem.hide()
612 if app.config.get('autodetect_browser_mailer') \
613 or app.config.get('custombrowser') == '':
614 extras_separator.hide()
615 open_file_in_browser_menuitem.hide()
616
617 id_ = open_menuitem.connect(
618 'activate', self.on_open_menuitem_activate, data)
619 self.handlers[id_] = open_menuitem
620 id_ = save_as_menuitem.connect(
621 'activate', self.on_save_as_menuitem_activate_new, data)
622 self.handlers[id_] = save_as_menuitem
623 id_ = copy_link_location_menuitem.connect(
624 'activate', self.on_copy_link_location_menuitem_activate, data)
625 self.handlers[id_] = copy_link_location_menuitem
626 id_ = open_link_in_browser_menuitem.connect(
627 'activate', self.on_open_link_in_browser_menuitem_activate, data)
628 self.handlers[id_] = open_link_in_browser_menuitem
629 id_ = open_file_in_browser_menuitem.connect(
630 'activate', self.on_open_file_in_browser_menuitem_activate, data)
631 self.handlers[id_] = open_file_in_browser_menuitem
632
633 return menu
634
635 def on_open_menuitem_activate(self, menu, data):
636 filepath = data["filepath"]
637 original_filename = data["original_filename"]
638 url = data["url"]
639 if original_filename.startswith('location_'):
640 helpers.launch_browser_mailer('url', url)
641 return
642 helpers.launch_file_manager(filepath)
643
644 def on_save_as_menuitem_activate_new(self, menu, data):
645 filepath = data["filepath"]
646 original_filename = data["original_filename"]
647
254 log.error(uri)
255 log.error(error)
256 return
257
258 return Preview(uri,
259 None,
260 None,
261 None,
262 start_mark,
263 end_mark,
264 96,
265 control_id)
266
267 def _process_web_uri(self, uri, urlparts, start_mark, end_mark, control_id):
268 size = self.config['PREVIEW_SIZE']
269 orig_path, thumb_path = get_image_paths(uri,
270 urlparts,
271 size,
272 self._orig_dir,
273 self._thumb_dir)
274 return Preview(uri,
275 urlparts,
276 orig_path,
277 thumb_path,
278 start_mark,
279 end_mark,
280 size,
281 control_id)
282
283 def _on_orig_load_finished(self, data, error, preview):
284 if data is None:
285 log.error('%s: %s', preview.orig_path.name, error)
286 return
287
288 if preview.create_thumbnail(data):
289 write_file_async(preview.thumb_path,
290 preview.thumbnail,
291 self._on_thumb_write_finished,
292 preview)
293
294 def _on_thumb_load_finished(self, data, error, preview):
295 if data is None:
296 log.error('%s: %s', preview.thumb_path.name, error)
297 return
298
299 preview.thumbnail = data
300
301 pixbuf = pixbuf_from_data(preview.thumbnail)
302 self._update_textview(pixbuf, preview)
303
304 def _download_content(self, preview):
305 log.info('Start downloading: %s', preview.request_uri)
306 message = Soup.Message.new('GET', preview.request_uri)
307 message.connect('starting', self._check_certificate)
308 message.connect('content-sniffed', self._on_content_sniffed)
309 self._session.queue_message(message, self._on_finished, preview)
310
311 def _check_certificate(self, message):
312 _https_used, _tls_certificate, tls_errors = message.get_https_status()
313
314 if not self.config['VERIFY']:
315 return
316
317 if tls_errors:
318 phrase = get_tls_error_phrase(tls_errors)
319 log.warning('TLS verification failed: %s', phrase)
320 self._session.cancel_message(message, Soup.Status.CANCELLED)
321 return
322
323 def _on_content_sniffed(self, message, type_, _params):
324 size = message.props.response_headers.get_content_length()
325 uri = message.props.uri.to_string(False)
326 if type_ not in ACCEPTED_MIME_TYPES:
327 log.info('Not allowed content type: %s, %s', type_, uri)
328 self._session.cancel_message(message, Soup.Status.CANCELLED)
329 return
330
331 if size == 0 or size > int(self.config['MAX_FILE_SIZE']):
332 log.info('File size (%s) too big or unknown (zero) for URL: \'%s\'',
333 size, uri)
334 self._session.cancel_message(message, Soup.Status.CANCELLED)
335 return
336
337 def _on_finished(self, _session, message, preview):
338 if message.status_code != Soup.Status.OK:
339 log.warning('Download failed: %s', preview.request_uri)
340 log.warning(Soup.Status.get_phrase(message.status_code))
341 return
342
343 data = message.props.response_body_data.get_data()
344 if data is None:
345 return
346
347 if preview.is_aes_encrypted:
348 data = aes_decrypt(preview, data)
349
350 write_file_async(preview.orig_path,
351 data,
352 self._on_orig_write_finished,
353 preview)
354
355 if preview.create_thumbnail(data):
356 write_file_async(preview.thumb_path,
357 preview.thumbnail,
358 self._on_thumb_write_finished,
359 preview)
360
361 @staticmethod
362 def _on_orig_write_finished(_result, error, preview):
363 if error is not None:
364 log.error('%s: %s', preview.orig_path.name, error)
365 return
366
367 log.info('File stored: %s', preview.orig_path.name)
368
369 def _on_thumb_write_finished(self, _result, error, preview):
370 if error is not None:
371 log.error('%s: %s', preview.thumb_path.name, error)
372 return
373
374 log.info('Thumbnail stored: %s ', preview.thumb_path.name)
375 pixbuf = pixbuf_from_data(preview.thumbnail)
376 self._update_textview(pixbuf, preview)
377
378 def _update_textview(self, pixbuf, preview):
379 textview = self._textviews.get(preview.control_id)
380 if textview is None:
381 # Control closed
382 return
383
384 buffer_ = preview.start_mark.get_buffer()
385 iter_ = buffer_.get_iter_at_mark(preview.start_mark)
386 buffer_.insert(iter_, '\n')
387 anchor = buffer_.create_child_anchor(iter_)
388 anchor.plaintext = preview.uri
389
390 image = create_clickable_image(pixbuf, preview)
391
392 textview.tv.add_child_at_anchor(image, anchor)
393 buffer_.delete(iter_,
394 buffer_.get_iter_at_mark(preview.end_mark))
395
396 image.connect('button-press-event',
397 self._on_button_press_event,
398 preview)
399
400 if textview.autoscroll:
401 textview.scroll_to_end()
402
403 def _get_context_menu(self, preview):
404 path = self.local_file_path('context_menu.ui')
405 ui = get_builder(path)
406 if preview.is_aes_encrypted:
407 ui.open_link_in_browser.hide()
408
409 if preview.is_geo_uri:
410 ui.open_link_in_browser.hide()
411 ui.save_as.hide()
412 ui.open_folder.hide()
413
414 ui.open.connect(
415 'activate', self._on_open, preview)
416 ui.save_as.connect(
417 'activate', self._on_save_as, preview)
418 ui.open_folder.connect(
419 'activate', self._on_open_folder, preview)
420 ui.open_link_in_browser.connect(
421 'activate', self._on_open_link_in_browser, preview)
422 ui.copy_link_location.connect(
423 'activate', self._on_copy_link_location, preview)
424
425 def destroy(menu, _pspec):
426 visible = menu.get_property('visible')
427 if not visible:
428 GLib.idle_add(menu.destroy)
429
430 ui.context_menu.connect('notify::visible', destroy)
431 return ui.context_menu
432
433 @staticmethod
434 def _on_open(_menu, preview):
435 if preview.is_geo_uri:
436 open_uri(preview.uri)
437 return
438 open_file(preview.orig_path)
439
440 @staticmethod
441 def _on_save_as(_menu, preview):
648442 def on_ok(target_path):
649 dirname = os.path.dirname(target_path)
443 dirname = Path(target_path).parent
650444 if not os.access(dirname, os.W_OK):
651 dialogs.ErrorDialog(
652 _('Directory "%s" is not writable') % dirname,
653 _('You do not have permission to '
654 'create files in this directory.'))
445 ErrorDialog(
446 _('Directory \'%s\' is not writable') % dirname,
447 _('You do not have the proper permissions to '
448 'create files in this directory.'),
449 transient_for=app.app.get_active_window())
655450 return
656 shutil.copy(filepath, target_path)
451 shutil.copy(str(preview.orig_path), target_path)
657452
658453 FileSaveDialog(on_ok,
659454 path=app.config.get('last_save_dir'),
660 file_name=original_filename,
455 file_name=preview.filename,
661456 transient_for=app.app.get_active_window())
662457
663 def on_copy_link_location_menuitem_activate(self, menu, data):
664 url = data["url"]
665 clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
666 clipboard.set_text(url, -1)
667 clipboard.store()
668
669 def on_open_link_in_browser_menuitem_activate(self, menu, data):
670 url = data["url"]
671 if data["encrypted"]:
672 dialogs.ErrorDialog(
673 _('Encrypted file'),
674 _('You cannot open encrypted files in your '
675 'browser directly. Try "Open Downloaded File '
676 'in Browser" instead.'),
677 transient_for=app.app.get_active_window())
458 @staticmethod
459 def _on_open_folder(_menu, preview):
460 open_file(preview.orig_path.parent)
461
462 @staticmethod
463 def _on_copy_link_location(_menu, preview):
464 clipboard = Gtk.Clipboard.get_default(Gdk.Display.get_default())
465 clipboard.set_text(preview.uri, -1)
466
467 @staticmethod
468 def _on_open_link_in_browser(_menu, preview):
469 if preview.is_aes_encrypted:
470 if preview.is_geo_uri:
471 open_uri(preview.uri)
472 return
473 open_file(preview.orig_path)
678474 else:
679 helpers.launch_browser_mailer('url', url)
680
681 def on_open_file_in_browser_menuitem_activate(self, menu, data):
682 if os.name == "nt":
683 filepath = "file://" + os.path.abspath(data["filepath"])
684 else:
685 filepath = "file://" + data["filepath"]
686 if app.config.get('autodetect_browser_mailer') \
687 or app.config.get('custombrowser') == '':
688 dialogs.ErrorDialog(
689 _('Cannot open downloaded file in browser'),
690 _('You have to set a custom browser executable '
691 'in your gajim settings for this to work.'),
692 transient_for=app.app.get_active_window())
693 return
694 command = app.config.get('custombrowser')
695 command = helpers.build_command(command, filepath)
696 try:
697 helpers.exec_command(command)
698 except Exception:
699 pass
700
701 def on_button_press_event(self, eb, event, filepath,
702 original_filename, url, encrypted):
703 data = {"filepath": filepath,
704 "original_filename": original_filename,
705 "url": url,
706 "encrypted": encrypted}
707 # left click
475 open_uri(preview.uri)
476
477 def _on_button_press_event(self, _image, event, preview):
708478 if event.type == Gdk.EventType.BUTTON_PRESS and event.button == 1:
709 method = getattr(self, "on_"
710 + self.plugin.config['LEFTCLICK_ACTION']
711 + "_activate")
712 method(event, data)
713 # right klick
479 # Left click
480 action = self.config['LEFTCLICK_ACTION']
481 method = getattr(self, '_on_%s' % action)
482 method(event, preview)
483
714484 elif event.type == Gdk.EventType.BUTTON_PRESS and event.button == 3:
715 menu = self.make_rightclick_menu(event, data)
716 # menu.attach_to_widget(self.tv, None)
717 # menu.popup(None, None, None, event.button, event.time)
485 # Right klick
486 menu = self._get_context_menu(preview)
718487 menu.popup_at_pointer(event)
719488
720 @staticmethod
721 def _raise_error_dialog(pritext, sectext):
722 # Used by methods that run in a different thread
723 dialogs.ErrorDialog(pritext,
724 sectext,
725 transient_for=app.app.get_active_window())
726
727 def disconnect_from_chat_control(self):
728 pass
489
490 class Preview:
491 def __init__(self, uri, urlparts, orig_path, thumb_path,
492 start_mark, end_mark, size, control_id):
493 self._uri = uri
494 self._urlparts = urlparts
495 self._filename = filename_from_uri(self._uri)
496 self.size = size
497 self.control_id = control_id
498 self.orig_path = orig_path
499 self.thumb_path = thumb_path
500 self.start_mark = start_mark
501 self.end_mark = end_mark
502 self.thumbnail = None
503
504 self.key, self.iv = None, None
505 if self.is_aes_encrypted:
506 self.key, self.iv = parse_fragment(urlparts.fragment)
507
508 @property
509 def is_geo_uri(self):
510 return self._uri.startswith('geo:')
511
512 @property
513 def is_web_uri(self):
514 return not self.is_geo_uri
515
516 @property
517 def uri(self):
518 return self._uri
519
520 @property
521 def filename(self):
522 return self._filename
523
524 @property
525 def request_uri(self):
526 if self.is_aes_encrypted:
527 # Remove fragments so we dont transmit it to the server
528 urlparts = self._urlparts._replace(scheme='https', fragment='')
529 return urlparts.geturl()
530 return self._urlparts.geturl()
531
532 @property
533 def is_aes_encrypted(self):
534 if self._urlparts is None:
535 return False
536 return self._urlparts.scheme == 'aesgcm'
537
538 def thumb_exists(self):
539 return self.thumb_path.exists()
540
541 def orig_exists(self):
542 return self.orig_path.exists()
543
544 def create_thumbnail(self, data):
545 self.thumbnail = create_thumbnail(data, self.size)
546 if self.thumbnail is None:
547 log.warning('creating thumbnail failed for: %s', self.orig_path)
548 return False
549 return True
0 # This file is part of Image Preview Gajim Plugin.
1 #
2 # Image Preview Gajim Plugin is free software;
3 # you can redistribute it and/or modify
4 # it under the terms of the GNU General Public License as published
5 # by the Free Software Foundation; version 3 only.
6 #
7 # Image Preview Gajim Plugin is distributed in the hope that it will be useful,
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 # GNU General Public License for more details.
11 #
12 # You should have received a copy of the GNU General Public License
13 # along with Image Preview Gajim Plugin.
14 # If not, see <http://www.gnu.org/licenses/>.
15
16 import math
17 import logging
18 import binascii
19 import hashlib
20 from io import BytesIO
21 from collections import namedtuple
22 from pathlib import Path
23 from urllib.parse import urlparse
24 from urllib.parse import unquote
25
26 from gi.repository import GdkPixbuf
27 from gi.repository import GLib
28 from gi.repository import Gtk
29
30 from PIL import Image
31
32 from cryptography.hazmat.backends import default_backend
33 from cryptography.hazmat.primitives.ciphers import Cipher
34 from cryptography.hazmat.primitives.ciphers import algorithms
35 from cryptography.hazmat.primitives.ciphers.modes import GCM
36
37 from gajim.gtk.util import get_cursor
38
39 log = logging.getLogger('gajim.p.preview.utils')
40
41 Coords = namedtuple('Coords', 'location lat lon')
42
43
44 def resize_gif(image, output_file, resize_to):
45 frames, result = extract_and_resize_frames(image, resize_to)
46
47 frames[0].save(output_file,
48 format='GIF',
49 optimize=True,
50 save_all=True,
51 append_images=frames[1:],
52 duration=result['duration'],
53 loop=1000)
54
55
56 def analyse_image(image):
57 '''
58 Pre-process pass over the image to determine the mode (full or additive).
59 Necessary as assessing single frames isn't reliable. Need to know the mode
60 before processing all frames.
61 '''
62
63 result = {
64 'size': image.size,
65 'mode': 'full',
66 'duration': image.info.get('duration', 0)
67 }
68
69 try:
70 while True:
71 if image.tile:
72 tile = image.tile[0]
73 update_region = tile[1]
74 update_region_dimensions = update_region[2:]
75 if update_region_dimensions != image.size:
76 result['mode'] = 'partial'
77 break
78 image.seek(image.tell() + 1)
79 except EOFError:
80 image.seek(0)
81 return image, result
82
83
84 def extract_and_resize_frames(image, resize_to):
85 image, result = analyse_image(image)
86
87 i = 0
88 palette = image.getpalette()
89 last_frame = image.convert('RGBA')
90
91 frames = []
92
93 try:
94 while True:
95 '''
96 If the GIF uses local colour tables,
97 each frame will have its own palette.
98 If not, we need to apply the global palette to the new frame.
99 '''
100 if not image.getpalette():
101 image.putpalette(palette)
102
103 new_frame = Image.new('RGBA', image.size)
104
105 '''
106 Is this file a "partial"-mode GIF where frames update a region
107 of a different size to the entire image?
108 If so, we need to construct the new frame by
109 pasting it on top of the preceding frames.
110 '''
111 if result['mode'] == 'partial':
112 new_frame.paste(last_frame)
113
114 new_frame.paste(image, (0, 0), image.convert('RGBA'))
115
116 # This method preservs aspect ratio
117 new_frame.thumbnail(resize_to, Image.ANTIALIAS)
118 frames.append(new_frame)
119
120 i += 1
121 last_frame = new_frame
122 image.seek(image.tell() + 1)
123 except EOFError:
124 pass
125
126 return frames, result
127
128
129 def create_thumbnail(data, size):
130 thumbnail = create_thumbnail_with_pil(data, size)
131 if thumbnail is not None:
132 return thumbnail
133 return create_thumbnail_with_pixbuf(data, size)
134
135
136 def create_thumbnail_with_pixbuf(data, size):
137 loader = GdkPixbuf.PixbufLoader()
138 try:
139 loader.write(data)
140 except GLib.Error as error:
141 log.warning('making pixbuf failed: %s', error)
142 return None
143
144 loader.close()
145 pixbuf = loader.get_pixbuf()
146
147 if size > pixbuf.get_width() and size > pixbuf.get_height():
148 return data
149
150 width, height = get_thumbnail_size(pixbuf, size)
151 thumbnail = pixbuf.scale_simple(width,
152 height,
153 GdkPixbuf.InterpType.BILINEAR)
154 has_error, bytes_ = thumbnail.save_to_bufferv('png', [], [])
155 if has_error:
156 log.warning('saving pixbuf to buffer failed')
157 return None
158 return bytes_
159
160
161 def create_thumbnail_with_pil(data, size):
162 input_file = BytesIO(data)
163 output_file = BytesIO()
164 try:
165 image = Image.open(input_file)
166 except OSError as error:
167 log.warning('making pil thumbnail failed: %s', error)
168 log.warning('fallback to pixbuf')
169 input_file.close()
170 output_file.close()
171 return
172
173 image_width, image_height = image.size
174 if size > image_width and size > image_height:
175 image.close()
176 input_file.close()
177 output_file.close()
178 return data
179
180 if image.format == 'GIF' and image.n_frames > 1:
181 resize_gif(image, output_file, (size, size))
182 else:
183 image.thumbnail((size, size))
184 image.save(output_file,
185 format=image.format,
186 optimize=True)
187
188 bytes_ = output_file.getvalue()
189
190 image.close()
191 input_file.close()
192 output_file.close()
193
194 return bytes_
195
196
197 def get_thumbnail_size(pixbuf, size):
198 # Calculates the new thumbnail size while preserving the aspect ratio
199 image_width = pixbuf.get_width()
200 image_height = pixbuf.get_height()
201
202 if image_width > image_height:
203 if image_width > size:
204 image_height = math.ceil((size / float(image_width) * image_height))
205 image_width = int(size)
206 else:
207 if image_height > size:
208 image_width = math.ceil((size / float(image_height) * image_width))
209 image_height = int(size)
210
211 return image_width, image_height
212
213
214 def pixbuf_from_data(data):
215 loader = GdkPixbuf.PixbufLoader()
216 try:
217 loader.write(data)
218 except GLib.Error:
219 # Fallback to Pillow
220 input_file = BytesIO(data)
221 image = Image.open(BytesIO(data)).convert('RGBA')
222 array = GLib.Bytes.new(image.tobytes())
223 width, height = image.size
224 pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(array,
225 GdkPixbuf.Colorspace.RGB,
226 True,
227 8,
228 width,
229 height,
230 width * 4)
231 image.close()
232 input_file.close()
233 return pixbuf
234
235 loader.close()
236 return loader.get_pixbuf()
237
238
239 def create_clickable_image(pixbuf, preview):
240 if isinstance(pixbuf, GdkPixbuf.PixbufAnimation):
241 image = Gtk.Image.new_from_animation(pixbuf)
242 else:
243 image = Gtk.Image.new_from_pixbuf(pixbuf)
244
245 css = '''#Preview {
246 box-shadow: 0px 0px 3px 0px alpha(@theme_text_color, 0.2);
247 margin: 5px 10px 5px 10px; }'''
248
249 provider = Gtk.CssProvider()
250 provider.load_from_data(bytes(css.encode()))
251 context = image.get_style_context()
252 context.add_provider(provider,
253 Gtk.STYLE_PROVIDER_PRIORITY_USER)
254
255 image.set_name('Preview')
256
257 def _on_realize(box):
258 box.get_window().set_cursor(get_cursor('pointer'))
259
260 event_box = Gtk.EventBox()
261 event_box.connect('realize', _on_realize)
262 event_box.set_tooltip_text(preview.uri)
263 event_box.add(image)
264 event_box.show_all()
265 return event_box
266
267
268 def parse_fragment(fragment):
269 if not fragment:
270 raise ValueError('Invalid fragment')
271
272 fragment = binascii.unhexlify(fragment)
273 size = len(fragment)
274 # Clients started out with using a 16 byte IV but long term
275 # want to swtich to the more performant 12 byte IV
276 # We have to support both
277 if size == 48:
278 key = fragment[16:]
279 iv = fragment[:16]
280 elif size == 44:
281 key = fragment[12:]
282 iv = fragment[:12]
283 else:
284 raise ValueError('Invalid fragment size: %s' % size)
285
286 return key, iv
287
288
289 def get_image_paths(uri, urlparts, size, orig_dir, thumb_dir):
290 path = Path(urlparts.path)
291 web_stem = path.stem
292 extension = path.suffix
293
294 if len(web_stem) > 90:
295 # Many Filesystems have a limit on filename length
296 # Most have 255, some encrypted ones only 143
297 # We add around 50 chars for the hash,
298 # so the filename should not exceed 90
299 web_stem = web_stem[:90]
300
301 name_hash = hashlib.sha1(str(uri).encode()).hexdigest()
302
303 orig_filename = '%s_%s%s' % (web_stem, name_hash, extension)
304
305 thumb_filename = '%s_%s_thumb_%s%s' % (web_stem,
306 name_hash,
307 size,
308 extension)
309
310 orig_path = orig_dir / orig_filename
311 thumb_path = thumb_dir / thumb_filename
312 return orig_path, thumb_path
313
314
315 def split_geo_uri(uri):
316 # Example:
317 # geo:37.786971,-122.399677,122.3;CRS=epsg:32718;U=20;mapcolors=abc
318 # Assumption is all coordinates are CRS=WGS-84
319
320 # Remove "geo:"
321 coords = uri[4:]
322
323 # Remove arguments
324 if ';' in coords:
325 coords, _ = coords.split(';', maxsplit=1)
326
327 # Split coords
328 coords = coords.split(',')
329 if len(coords) not in (2, 3):
330 raise ValueError('Invalid geo uri: invalid coord count')
331
332 # Remoove coord-c (altitude)
333 if len(coords) == 3:
334 coords.pop(2)
335
336 lat, lon = coords
337 if float(lat) < -90 or float(lat) > 90:
338 raise ValueError('Invalid geo_uri: invalid latitude %s' % lat)
339
340 if float(lon) < -180 or float(lon) > 180:
341 raise ValueError('Invalid geo_uri: invalid longitude %s' % lon)
342
343 location = ','.join(coords)
344 return Coords(location=location, lat=lat, lon=lon)
345
346
347 def filename_from_uri(uri):
348 urlparts = urlparse(unquote(uri))
349 path = Path(urlparts.path)
350 return path.name
351
352
353 def aes_decrypt(preview, payload):
354 # Use AES128 GCM with the given key and iv to decrypt the payload
355 data = payload[:-16]
356 tag = payload[-16:]
357 decryptor = Cipher(
358 algorithms.AES(preview.key),
359 GCM(preview.iv, tag=tag),
360 backend=default_backend()).decryptor()
361 return decryptor.update(data) + decryptor.finalize()