diff options
author | Philipp Hörist <forenjunkie@chello.at> | 2017-11-01 02:00:40 +0300 |
---|---|---|
committer | Philipp Hörist <forenjunkie@chello.at> | 2017-11-01 02:00:40 +0300 |
commit | 377c9fc9b83c710eaadbe880c0d153d376591ea6 (patch) | |
tree | 9b28510364d3a4d3b982c477e8ebf760a6b4b104 | |
parent | a4b62296ea43585c9d26c3d85eaec58d52953908 (diff) | |
parent | 371690176d6c8eb9b9e6b19b06c89f17824362a2 (diff) |
Merge branch 'gtk3' into 'gtk3'
UrlImagePreview python3/gtk3 port
See merge request gajim/gajim-plugins!46
-rw-r--r-- | url_image_preview/__init__.py | 1 | ||||
-rw-r--r-- | url_image_preview/config_dialog.ui | 204 | ||||
-rw-r--r-- | url_image_preview/context_menu.ui | 99 | ||||
-rw-r--r-- | url_image_preview/http_functions.py | 222 | ||||
-rw-r--r-- | url_image_preview/manifest.ini | 7 | ||||
-rw-r--r-- | url_image_preview/url_image_preview.py | 672 |
6 files changed, 1094 insertions, 111 deletions
diff --git a/url_image_preview/__init__.py b/url_image_preview/__init__.py index 58a9544..b2c8bee 100644 --- a/url_image_preview/__init__.py +++ b/url_image_preview/__init__.py @@ -1 +1,2 @@ +# simple redirect from .url_image_preview import UrlImagePreviewPlugin diff --git a/url_image_preview/config_dialog.ui b/url_image_preview/config_dialog.ui index 438dd93..c43e685 100644 --- a/url_image_preview/config_dialog.ui +++ b/url_image_preview/config_dialog.ui @@ -1,55 +1,187 @@ -<?xml version="1.0"?> +<?xml version="1.0" encoding="UTF-8"?> <interface> - <requires lib="gtk+" version="2.16"/> - <!-- interface-naming-policy toplevel-contextual --> + <requires lib="gtk+" version="3.0"/> + <object class="GtkListStore" id="liststore1"> + <columns> + <!-- column-name Text --> + <column type="gchararray"/> + </columns> + <data> + <row> + <col id="0" translatable="yes">256 KiB</col> + </row> + <row> + <col id="0" translatable="yes">512 KiB</col> + </row> + <row> + <col id="0" translatable="yes">1 MiB</col> + </row> + <row> + <col id="0" translatable="yes">5 MiB</col> + </row> + <row> + <col id="0" translatable="yes">10 MiB</col> + </row> + </data> + </object> + <object class="GtkListStore" id="liststore2"> + <columns> + <!-- column-name Text --> + <column type="gchararray"/> + </columns> + <data> + <row> + <col id="0" translatable="yes">Open</col> + </row> + <row> + <col id="0" translatable="yes">Save as</col> + </row> + <row> + <col id="0" translatable="yes">Copy Link Location</col> + </row> + <row> + <col id="0" translatable="yes">Open Link in Browser</col> + </row> + <row> + <col id="0" translatable="yes">Open Downloaded File in Browser</col> + </row> + </data> + </object> <object class="GtkWindow" id="window1"> + <property name="can_focus">False</property> <child> <object class="GtkVBox" id="vbox1"> <property name="visible">True</property> - <property name="orientation">vertical</property> + <property name="can_focus">False</property> + <property name="border_width">9</property> <child> - <object class="GtkHBox" id="hbox2"> + <object class="GtkFrame" id="frame1"> <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label_xalign">0</property> + <property name="shadow_type">none</property> <child> - <object class="GtkLabel" id="preview_size_lebel"> - <property name="width_request">133</property> - <property name="visible">True</property> - <property name="xalign">0.029999999329447746</property> - <property name="label" translatable="yes">Preview size</property> - <property name="ellipsize">start</property> - <property name="single_line_mode">True</property> - <property name="track_visited_links">False</property> - </object> - <packing> - <property name="position">0</property> - </packing> - </child> - <child> - <object class="GtkSpinButton" id="preview_size"> + <object class="GtkTable" id="table1"> <property name="visible">True</property> - <property name="can_focus">True</property> - <property name="has_tooltip">True</property> - <property name="tooltip_text" translatable="yes">Preview size(10-512)</property> - <property name="invisible_char">●</property> - <property name="width_chars">6</property> - <property name="snap_to_ticks">True</property> - <property name="numeric">True</property> - <signal name="value_changed" handler="preview_size_value_changed"/> + <property name="can_focus">False</property> + <property name="n_rows">3</property> + <property name="n_columns">2</property> + <child> + <object class="GtkSpinButton" id="preview_size"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="invisible_char">●</property> + <property name="width_chars">6</property> + <property name="primary_icon_activatable">False</property> + <property name="secondary_icon_activatable">False</property> + <property name="primary_icon_sensitive">True</property> + <property name="secondary_icon_sensitive">True</property> + <property name="snap_to_ticks">True</property> + <property name="numeric">True</property> + <signal name="value-changed" handler="preview_size_value_changed" swapped="no"/> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="y_options"/> + </packing> + </child> + <child> + <object class="GtkComboBox" id="max_size_combobox"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="model">liststore1</property> + <signal name="changed" handler="max_size_value_changed" swapped="no"/> + <child> + <object class="GtkCellRendererText" id="cellrenderertext1"/> + <attributes> + <attribute name="text">0</attribute> + </attributes> + </child> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">1</property> + <property name="bottom_attach">2</property> + <property name="y_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkComboBox" id="leftclick_action_combobox"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="model">liststore2</property> + <signal name="changed" handler="leftclick_action_changed" swapped="no"/> + <child> + <object class="GtkCellRendererText" id="cellrenderertext2"/> + <attributes> + <attribute name="text">0</attribute> + </attributes> + </child> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">2</property> + <property name="bottom_attach">3</property> + <property name="y_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="max_size_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">0</property> + <property name="xpad">13</property> + <property name="label" translatable="yes">Accept files smaller then</property> + <property name="track_visited_links">False</property> + </object> + <packing> + <property name="top_attach">1</property> + <property name="bottom_attach">2</property> + <property name="y_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="preview_size_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">0</property> + <property name="xpad">12</property> + <property name="label" translatable="yes">Preview size</property> + <property name="track_visited_links">False</property> + </object> + <packing> + <property name="y_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="leftclick_action_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">0</property> + <property name="xpad">12</property> + <property name="label" translatable="yes">Left click action</property> + <property name="track_visited_links">False</property> + </object> + <packing> + <property name="y_options">GTK_EXPAND</property> + <property name="top_attach">2</property> + <property name="bottom_attach">3</property> + <property name="y_options">GTK_EXPAND</property> + </packing> + </child> </object> - <packing> - <property name="expand">False</property> - <property name="fill">False</property> - <property name="position">1</property> - </packing> </child> </object> <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="padding">6</property> <property name="position">0</property> </packing> </child> - <child> - <placeholder/> - </child> </object> </child> </object> diff --git a/url_image_preview/context_menu.ui b/url_image_preview/context_menu.ui new file mode 100644 index 0000000..cbb0390 --- /dev/null +++ b/url_image_preview/context_menu.ui @@ -0,0 +1,99 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.0"/> + <object class="GtkImage" id="image1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="stock">gtk-open</property> + </object> + <object class="GtkImage" id="image2"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="stock">gtk-save-as</property> + </object> + <object class="GtkImage" id="image3"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="stock">gtk-copy</property> + </object> + <object class="GtkImage" id="image4"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="stock">gtk-jump-to</property> + </object> + <object class="GtkImage" id="image5"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="stock">gtk-jump-to</property> + </object> + <object class="GtkMenu" id="context_menu"> + <property name="can_focus">False</property> + <child> + <object class="GtkImageMenuItem" id="open_menuitem"> + <property name="label" translatable="yes">_Open</property> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="use_underline">True</property> + <property name="image">image1</property> + <property name="use_stock">False</property> + <property name="always_show_image">True</property> + </object> + </child> + <child> + <object class="GtkImageMenuItem" id="save_as_menuitem"> + <property name="label" translatable="yes">_Save as</property> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="use_underline">True</property> + <property name="image">image2</property> + <property name="use_stock">False</property> + <property name="always_show_image">True</property> + </object> + </child> + <child> + <object class="GtkSeparatorMenuItem" id="encryption_separator"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + </child> + <child> + <object class="GtkImageMenuItem" id="copy_link_location_menuitem"> + <property name="label" translatable="yes">_Copy Link Location</property> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="use_underline">True</property> + <property name="image">image3</property> + <property name="use_stock">False</property> + <property name="always_show_image">True</property> + </object> + </child> + <child> + <object class="GtkImageMenuItem" id="open_link_in_browser_menuitem"> + <property name="label" translatable="yes">Open Link in _Browser</property> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="use_underline">True</property> + <property name="image">image4</property> + <property name="use_stock">False</property> + <property name="always_show_image">True</property> + </object> + </child> + <child> + <object class="GtkSeparatorMenuItem" id="extras_separator"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + </child> + <child> + <object class="GtkImageMenuItem" id="open_file_in_browser_menuitem"> + <property name="label" translatable="yes">Open _Downloaded File in Browser</property> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="use_underline">True</property> + <property name="image">image5</property> + <property name="use_stock">False</property> + <property name="always_show_image">True</property> + </object> + </child> + </object> +</interface> diff --git a/url_image_preview/http_functions.py b/url_image_preview/http_functions.py new file mode 100644 index 0000000..bd708b4 --- /dev/null +++ b/url_image_preview/http_functions.py @@ -0,0 +1,222 @@ +# -*- 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 re + +from gajim.common import app +from gajim.common import helpers +import logging + +import os +if os.name == 'nt': + import certifi + +if app.HAVE_PYCURL: + import pycurl + from io import StringIO + + +log = logging.getLogger('gajim.plugin_system.url_image_preview.http_functions') + +def get_http_head(account, url): + # Check if proxy is used + proxy = helpers.get_proxy_info(account) + if proxy and proxy['type'] in ('http', 'socks5'): + return _get_http_head_proxy(url, proxy) + return _get_http_head_direct(url) + +def get_http_file(account, attrs): + # Check if proxy is used + proxy = helpers.get_proxy_info(account) + if proxy and proxy['type'] in ('http', 'socks5'): + return _get_http_proxy(attrs, proxy) + else: + return _get_http_direct(attrs) + +def _get_http_head_direct(url): + log.debug('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 os.name == 'nt': + f = urllib2.urlopen(req, cafile=certifi.where()) + else: + f = urllib2.urlopen(req) + except Exception as ex: + log.debug('Could not get head response for URL: %s' % url) + log.debug("%s" % str(ex)) + return ('', 0) + ctype = f.headers['Content-Type'] + clen = f.headers['Content-Length'] + try: + clen = int(clen) + except ValueError: + pass + return (ctype, clen) + +def _get_http_head_proxy(url, proxy): + log.debug('Head request with proxy for URL: %s' % url) + if not app.HAVE_PYCURL: + log.error('PYCURL not installed') + return ('', 0) + + headers = '' + try: + b = StringIO() + c = pycurl.Curl() + c.setopt(pycurl.URL, url.encode('utf-8')) + c.setopt(pycurl.FOLLOWLOCATION, 1) + # Make a HEAD request: + c.setopt(pycurl.CUSTOMREQUEST, 'HEAD') + c.setopt(pycurl.NOBODY, 1) + c.setopt(pycurl.HEADER, 1) + + c.setopt(pycurl.MAXFILESIZE, 2000000) + c.setopt(pycurl.WRITEFUNCTION, b.write) + c.setopt(pycurl.USERAGENT, 'Gajim ' + app.version) + + # set proxy + c.setopt(pycurl.PROXY, proxy['host'].encode('utf-8')) + c.setopt(pycurl.PROXYPORT, proxy['port']) + if proxy['useauth']: + c.setopt(pycurl.PROXYUSERPWD, proxy['user'].encode('utf-8') + + ':' + proxy['pass'].encode('utf-8')) + c.setopt(pycurl.PROXYAUTH, pycurl.HTTPAUTH_ANY) + if proxy['type'] == 'http': + c.setopt(pycurl.PROXYTYPE, pycurl.PROXYTYPE_HTTP) + elif proxy['type'] == 'socks5': + c.setopt(pycurl.PROXYTYPE, pycurl.PROXYTYPE_SOCKS5) + x = c.perform() + c.close() + headers = b.getvalue() + except pycurl.error as ex: + log.debug('Could not get head response for URL: %s' % url) + log.debug("%s" % str(ex)) + return ('', 0) + + ctype = '' + searchObj = re.search(r'^Content-Type: (.*)$', headers, re.M | re.I) + if searchObj: + ctype = searchObj.group(1).strip() + clen = 0 + searchObj = re.search(r'^Content-Length: (.*)$', headers, re.M | re.I) + if searchObj: + try: + clen = int(searchObj.group(1).strip()) + except ValueError: + pass + return (ctype, clen) + +def _get_http_direct(attrs): + """ + Download a file. This function should + be launched in a separated thread. + """ + log.debug('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 os.name == 'nt': + f = urllib2.urlopen(req, cafile=certifi.where()) + else: + f = urllib2.urlopen(req) + except Exception as ex: + log.debug('Error loading file %s ' + % attrs['src'] + str(ex)) + pixbuf = None + alt = attrs.get('alt', 'Broken image') + else: + while True: + try: + temp = f.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) + +def _get_http_proxy(attrs, proxy): + """ + Download an image through a proxy. + This function should be launched in a + separated thread. + """ + log.debug('Get request with proxy for URL: %s' % attrs['src']) + if not app.HAVE_PYCURL: + log.error('PYCURL not installed') + return '', _('PyCURL is not installed') + mem, alt, max_size = '', '', 2 * 1024 * 1024 + if 'max_size' in attrs: + max_size = attrs['max_size'] + try: + b = StringIO() + c = pycurl.Curl() + c.setopt(pycurl.URL, attrs['src'].encode('utf-8')) + c.setopt(pycurl.FOLLOWLOCATION, 1) + c.setopt(pycurl.MAXFILESIZE, max_size) + c.setopt(pycurl.WRITEFUNCTION, b.write) + c.setopt(pycurl.USERAGENT, 'Gajim ' + app.version) + # set proxy + c.setopt(pycurl.PROXY, proxy['host'].encode('utf-8')) + c.setopt(pycurl.PROXYPORT, proxy['port']) + if proxy['useauth']: + c.setopt(pycurl.PROXYUSERPWD, proxy['user'].encode('utf-8') + + ':' + proxy['pass'].encode('utf-8')) + c.setopt(pycurl.PROXYAUTH, pycurl.HTTPAUTH_ANY) + if proxy['type'] == 'http': + c.setopt(pycurl.PROXYTYPE, pycurl.PROXYTYPE_HTTP) + elif proxy['type'] == 'socks5': + c.setopt(pycurl.PROXYTYPE, pycurl.PROXYTYPE_SOCKS5) + x = c.perform() + c.close() + t = b.getvalue() + return (t, attrs.get('alt', '')) + except pycurl.error as ex: + alt = attrs.get('alt', '') + if alt: + alt += '\n' + if ex[0] == pycurl.E_FILESIZE_EXCEEDED: + alt += _('Image is too big') + elif ex[0] == pycurl.E_OPERATION_TIMEOUTED: + alt += _('Timeout loading image') + else: + alt += _('Error loading image') + except Exception as ex: + log.debug('Error loading file %s ' % attrs['src'] + str(ex)) + pixbuf = None + alt = attrs.get('alt', 'Broken image') + return ('', alt) diff --git a/url_image_preview/manifest.ini b/url_image_preview/manifest.ini index da78a9b..16a352a 100644 --- a/url_image_preview/manifest.ini +++ b/url_image_preview/manifest.ini @@ -2,8 +2,11 @@ name: Url image preview short_name: url_image_preview version: 0.5.6 -description: Url image preview in chatbox. +description: Displays a preview of links to images authors = Denis Fomin <fominde@gmail.com> Yann Leboulanger <asterix@lagaule.org> -homepage = http://trac-plugins.gajim.org/wiki/UrlImagePreviewPlugin + Anders Sandblad <runeson@gmail.com> + Thilo Molitor <thilo@eightysoft.de> + Philipp Hoerist <philipp@hoerist.com> +homepage = https://dev.gajim.org/gajim/gajim-plugins/wikis/UrlImagePreviewPlugin min_gajim_version: 0.16.11 diff --git a/url_image_preview/url_image_preview.py b/url_image_preview/url_image_preview.py index 5db72a6..1ff5c9a 100644 --- a/url_image_preview/url_image_preview.py +++ b/url_image_preview/url_image_preview.py @@ -1,116 +1,442 @@ # -*- 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/>. +## -from gi.repository import Gtk -from gi.repository import GdkPixbuf -import re +from gi.repository import Gtk, Gdk, GLib, GObject, GdkPixbuf, Gio import os +import hashlib +import binascii +from urllib.parse import urlparse +from io import BytesIO +import shutil +import logging +import nbxmpp from gajim.common import app +from gajim.common import ged from gajim.common import helpers +from gajim.common import configpaths +from gajim import dialogs from gajim.plugins import GajimPlugin from gajim.plugins.helpers import log_calls from gajim.plugins.gui import GajimPluginConfigDialog from gajim.conversation_textview import TextViewImage +from .http_functions import get_http_head, get_http_file -EXTENSIONS = ('.png','.jpg','.jpeg','.gif','.raw','.svg') +log = logging.getLogger('gajim.plugin_system.url_image_preview') + +try: + from PIL import Image +except: + log.debug('Pillow not available') + +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 +except Exception as e: + DEP_MSG = 'For preview of encrypted images, ' \ + 'please install python-cryptography!' + log.debug('Cryptography Import Error: ' + str(e)) + 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') class UrlImagePreviewPlugin(GajimPlugin): @log_calls('UrlImagePreviewPlugin') def init(self): - self.description = _('Url image preview in chatbox.\n' - 'Based on patch in ticket #5300:\n' - 'http://trac.gajim.org/attachment/ticket/5300.') + if not decryption_available: + self.available_text = DEP_MSG self.config_dialog = UrlImagePreviewPluginConfigDialog(self) + self.events_handlers = {} + self.events_handlers['message-received'] = ( + ged.PRECORE, self.handle_message_received) self.gui_extension_points = { - 'chat_control_base': (self.connect_with_chat_control, - self.disconnect_from_chat_control), - 'print_special_text': (self.print_special_text, - self.print_special_text1),} + 'chat_control_base': (self.connect_with_chat_control, + self.disconnect_from_chat_control), + 'print_special_text': (self.print_special_text, None), } self.config_default_values = { - 'PREVIEW_SIZE': (150, 'Preview size(10-512)'),} - self.chat_control = None - self.controls = [] + 'PREVIEW_SIZE': (150, 'Preview size(10-512)'), + 'MAX_FILE_SIZE': (524288, 'Max file size for image preview'), + 'LEFTCLICK_ACTION': ('open_menuitem', 'Open')} + self.controls = {} + + # remove oob tag if oob url == message text + def handle_message_received(self, event): + oob_node = event.stanza.getTag('x', namespace=nbxmpp.NS_X_OOB) + oob_url = None + oob_desc = None + if oob_node: + oob_url = oob_node.getTagData('url') + oob_desc = oob_node.getTagData('desc') + if oob_url and oob_url == event.msgtxt and \ + (not oob_desc or oob_desc == ""): + log.debug("Detected oob tag containing same" + "url as the message text, deleting oob tag...") + event.stanza.delChild(oob_node) @log_calls('UrlImagePreviewPlugin') def connect_with_chat_control(self, chat_control): - - self.chat_control = chat_control - control = Base(self, self.chat_control) - self.controls.append(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) @log_calls('UrlImagePreviewPlugin') def disconnect_from_chat_control(self, chat_control): - for control in self.controls: - control.disconnect_from_chat_control() - self.controls = [] + account = chat_control.contact.account.name + jid = chat_control.contact.jid + self.controls[account][jid].deinit() + del self.controls[account][jid] def print_special_text(self, tv, special_text, other_tags, graphics=True, - additional_data={}): - for control in self.controls: - if control.chat_control.conv_textview != tv: + additional_data=None, iter_=None): + account = tv.account + for jid in self.controls[account]: + if self.controls[account][jid].chat_control.conv_textview != tv: continue - control.print_special_text(special_text, other_tags, graphics=True) + self.controls[account][jid].print_special_text( + special_text, other_tags, graphics=graphics, + additional_data=additional_data, iter_=iter_) + return - def print_special_text1(self, chat_control, special_text, other_tags=None, - graphics=True, additional_data={}): - for control in self.controls: - if control.chat_control == chat_control: - control.disconnect_from_chat_control() - self.controls.remove(control) class Base(object): def __init__(self, plugin, chat_control): self.plugin = plugin self.chat_control = chat_control self.textview = self.chat_control.conv_textview + self.handlers = {} - def print_special_text(self, special_text, other_tags, graphics=True): - if not app.interface.basic_pattern_re.match(special_text): - return + self.directory = os.path.join(configpaths.gajimpaths['MY_DATA'], + 'downloads') + self.thumbpath = os.path.join(configpaths.gajimpaths['MY_CACHE'], + 'downloads.thumb') + + try: + self._create_path(self.directory) + self._create_path(self.thumbpath) + except Exception as e: + log.error("Error creating download and/or thumbnail folder!") + raise + + def deinit(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_special_text(self, special_text, other_tags, graphics=True, + additional_data=None, iter_=None): # remove qip bbcode special_text = special_text.rsplit('[/img]')[0] - name, extension = os.path.splitext(special_text) - if extension.lower() not in EXTENSIONS: - return - if not special_text.startswith('http://') and \ - special_text.startswith('www.'): + if special_text.startswith('www.'): special_text = 'http://' + special_text - if not special_text.startswith('ftp://') and \ - special_text.startswith('ftp.'): + if special_text.startswith('ftp.'): special_text = 'ftp://' + special_text - # show pics preview + urlparts = urlparse(special_text) + if urlparts.scheme not in ["https", "http", "ftp", "ftps", 'aesgcm'] or \ + not urlparts.netloc: + log.info("Not accepting URL for image preview: %s" % special_text) + return + + # Don't print the URL in the message window (in the calling function) + self.textview.plugin_modified = True + buffer_ = self.textview.tv.get_buffer() - iter_ = buffer_.get_end_iter() - mark = buffer_.create_mark(None, iter_, True) - # start downloading image - app.thread_interface(helpers.download_image, [ - self.textview.account, {'src': special_text}], self._update_img, - [mark]) - - def _update_img(self, mem_alt, mark): - mem, alt = mem_alt - if mem: + if not iter_: + iter_ = buffer_.get_end_iter() + + # Show URL, until image is loaded (if ever) + ttt = buffer_.get_tag_table() + repl_start = buffer_.create_mark(None, iter_, True) + buffer_.insert_with_tags(iter_, special_text, + *[(ttt.lookup(t) if isinstance(t, str) else t) for t in ["url"]]) + repl_end = buffer_.create_mark(None, iter_, True) + + filename = os.path.basename(urlparts.path) + ext = os.path.splitext(filename)[1] + name = os.path.splitext(filename)[0] + namehash = hashlib.sha1(special_text.encode('utf-8')).hexdigest() + newfilename = name + '_' + namehash + ext + thumbfilename = name + '_' + namehash + '_thumb_' \ + + str(self.plugin.config['PREVIEW_SIZE']) + ext + + filepath = os.path.join(self.directory, newfilename) + thumbpath = os.path.join(self.thumbpath, thumbfilename) + filepaths = [filepath, thumbpath] + + key = '' + iv = '' + encrypted = False + if urlparts.fragment: + fragment = binascii.unhexlify(urlparts.fragment) + key = fragment[16:] + iv = fragment[:16] + if len(key) == 32 and len(iv) == 16: + encrypted = True + if not encrypted: + key = fragment[12:] + iv = fragment[:12] + if len(key) == 32 and len(iv) == 12: + encrypted = True + + # file exists but thumbnail got deleted + if os.path.exists(filepath) and not os.path.exists(thumbpath): + with open(filepath, 'rb') as f: + mem = f.read() + f.closed + app.thread_interface( + self._save_thumbnail, [thumbpath, (mem, '')], + self._update_img, [special_text, repl_start, + repl_end, filepath, encrypted]) + + # display thumbnail if already downloadeded + # (but only if file also exists) + elif os.path.exists(filepath) and os.path.exists(thumbpath): + app.thread_interface( + self._load_thumbnail, [thumbpath], + self._update_img, [special_text, repl_start, + repl_end, filepath, encrypted]) + + # or download file, calculate thumbnail and finally display it + else: + if encrypted and not decryption_available: + log.debug('Please install Crytography to decrypt pictures') + else: + # First get the http head request + # which does not fetch data, just headers + # then check the mime type and filesize + if urlparts.scheme == 'aesgcm': + special_text = 'https://' + special_text[9:] + app.thread_interface( + get_http_head, [self.textview.account, special_text], + self._check_mime_size, [special_text, repl_start, repl_end, + filepaths, key, iv, encrypted]) + + def _save_thumbnail(self, thumbpath, tuple_arg): + mem, alt = tuple_arg + size = self.plugin.config['PREVIEW_SIZE'] + use_Gtk = False + output = None + + try: + output = BytesIO() + im = Image.open(BytesIO(mem)) + im.thumbnail((size, size), Image.ANTIALIAS) + im.save(output, "jpeg", quality=100, optimize=True) + mem = output.getvalue() + output.close() + except Exception as e: + if output: + output.close() + log.info("Failed to load image using pillow, " + "falling back to gdk pixbuf.") + log.debug(e) + use_Gtk = True + + if use_Gtk: + log.info("Pillow not available or file corrupt, " + "trying to load using gdk pixbuf.") try: loader = GdkPixbuf.PixbufLoader() loader.write(mem) loader.close() pixbuf = loader.get_pixbuf() - pixbuf, w, h = self.get_pixbuf_of_size(pixbuf, - self.plugin.config['PREVIEW_SIZE']) - buffer_ = mark.get_buffer() - end_iter = buffer_.get_iter_at_mark(mark) - anchor = buffer_.create_child_anchor(end_iter) - img = TextViewImage(anchor, alt) - img.set_from_pixbuf(pixbuf) - img.show() - self.textview.tv.add_child_at_anchor(img, anchor) + pixbuf, w, h = self._get_pixbuf_of_size(pixbuf, size) + + ok, mem = pixbuf.save_to_bufferv("jpeg", ["quality"], ["100"]) + except Exception as e: + log.info("Failed to load image using gdk pixbuf, " + "ignoring image.") + log.debug(e) + return ('', '') + + try: + self._create_path(os.path.dirname(thumbpath)) + self._write_file(thumbpath, mem) + except Exception as e: + dialogs.ErrorDialog( + _('Could not save file'), + _('Exception raised while saving thumbnail ' + 'for image file (see error log for more ' + 'information)'), + transient_for=self.chat_control.parent_win.window) + log.error(str(e)) + return (mem, alt) + + def _load_thumbnail(self, thumbpath): + with open(thumbpath, 'rb') as f: + mem = f.read() + f.closed + return (mem, '') + + def _write_file(self, path, data): + log.info("Writing '%s' of size %d..." % (path, len(data))) + try: + with open(path, "wb") as output_file: + output_file.write(data) + output_file.closed + except Exception as e: + log.error("Failed to write file '%s'!" % path) + raise + + def _update_img(self, tuple_arg, url, repl_start, repl_end, + filepath, encrypted): + mem, alt = tuple_arg + if mem: + try: + urlparts = urlparse(url) + filename = os.path.basename(urlparts.path) + eb = Gtk.EventBox() + eb.connect('button-press-event', self.on_button_press_event, + filepath, filename, url, encrypted) + eb.connect('enter-notify-event', self.on_enter_event) + eb.connect('leave-notify-event', self.on_leave_event) + + # this is threadsafe + # (Gtk textview is NOT threadsafe by itself!!) + def add_to_textview(): + try: # textview closed in the meantime etc. + buffer_ = repl_start.get_buffer() + iter_ = buffer_.get_iter_at_mark(repl_start) + buffer_.insert(iter_, "\n") + anchor = buffer_.create_child_anchor(iter_) + + # Use url as tooltip for image + img = TextViewImage(anchor, url) + loader = GdkPixbuf.PixbufLoader() + loader.write(mem) + loader.close() + pixbuf = loader.get_pixbuf() + img.set_from_pixbuf(pixbuf) + + eb.add(img) + eb.show_all() + self.textview.tv.add_child_at_anchor(eb, anchor) + buffer_.delete(iter_, + buffer_.get_iter_at_mark(repl_end)) + except Exception as ex: + log.warn("Exception while loading %s: %s" % (str(url), str(ex))) + return False + # add to mainloop --> make call threadsafe + GObject.idle_add(add_to_textview) except Exception: - pass + # URL is already displayed + log.error('Could not display image for URL: %s' + % url) + raise + else: + # If image could not be downloaded, URL is already displayed + log.error('Could not download image for URL: %s -- %s' + % (url, alt)) - def get_pixbuf_of_size(self, pixbuf, size): + def _check_mime_size(self, tuple_arg, + url, repl_start, repl_end, filepaths, + key, iv, encrypted): + file_mime, file_size = tuple_arg + # Check if mime type is acceptable + if file_mime == '' and file_size == 0: + log.info("Failed to load HEAD Request for URL: '%s'" + "(see debug log for more info)" % url) + # URL is already displayed + return + if file_mime.lower() not in ACCEPTED_MIME_TYPES: + log.info("Not accepted mime type '%s' for URL: '%s'" + % (file_mime.lower(), url)) + # URL is already displayed + return + # Check if file size is acceptable + if file_size > self.plugin.config['MAX_FILE_SIZE'] or file_size == 0: + log.info("File size (%s) too big or unknown (zero) for URL: '%s'" + % (str(file_size), url)) + # URL is already displayed + return + + attributes = {'src': url, + 'max_size': self.plugin.config['MAX_FILE_SIZE'], + 'filepaths': filepaths, + 'key': key, + 'iv': iv} + + app.thread_interface( + self._download_image, [self.textview.account, + attributes, encrypted], + self._update_img, [url, repl_start, repl_end, + filepaths[0], encrypted]) + + def _download_image(self, account, attributes, encrypted): + filepath = attributes['filepaths'][0] + thumbpath = attributes['filepaths'][1] + key = attributes['key'] + iv = attributes['iv'] + mem, alt = get_http_file(account, attributes) + + # Decrypt file if necessary + if encrypted: + mem = self._aes_decrypt_fast(key, iv, mem) + + try: + # Write file to harddisk + self._write_file(filepath, mem) + except Exception as e: + dialogs.ErrorDialog( + _('Could not save file'), + _('Exception raised while saving image file' + ' (see error log for more information)'), + transient_for=self.chat_control.parent_win.window) + log.error(str(e)) + + # Create thumbnail, write it to harddisk and return it + return self._save_thumbnail(thumbpath, (mem, alt)) + + def _create_path(self, folder): + if os.path.exists(folder): + return + log.debug("creating folder '%s'" % folder) + os.mkdir(folder, 0o700) + + def _aes_decrypt_fast(self, key, iv, payload): + # Use AES128 GCM with the given key and iv to decrypt the payload. + if os.name == 'nt': + be = backend + else: + be = default_backend() + data = payload[:-16] + tag = payload[-16:] + decryptor = Cipher( + algorithms.AES(key), + GCM(iv, tag=tag), + backend=be).decryptor() + return decryptor.update(data) + decryptor.finalize() + + def _get_pixbuf_of_size(self, pixbuf, size): # Creates a pixbuf that fits in the specified square of sizexsize # while preserving the aspect ratio # Returns tuple: (scaled_pixbuf, actual_width, actual_height) @@ -127,28 +453,198 @@ class Base(object): image_height = int(size) crop_pixbuf = pixbuf.scale_simple(image_width, image_height, - GdkPixbuf.InterpType.BILINEAR) + GdkPixbuf.InterpType.BILINEAR) return (crop_pixbuf, image_width, image_height) + def make_rightclick_menu(self, event, data): + xml = Gtk.Builder() + xml.set_translation_domain('gajim_plugins') + xml.add_from_file(self.plugin.local_file_path('context_menu.ui')) + menu = xml.get_object('context_menu') + + open_menuitem = xml.get_object('open_menuitem') + save_as_menuitem = xml.get_object('save_as_menuitem') + copy_link_location_menuitem = \ + xml.get_object('copy_link_location_menuitem') + open_link_in_browser_menuitem = \ + xml.get_object('open_link_in_browser_menuitem') + open_file_in_browser_menuitem = \ + xml.get_object('open_file_in_browser_menuitem') + extras_separator = \ + xml.get_object('extras_separator') + + if data["encrypted"]: + open_link_in_browser_menuitem.hide() + if app.config.get('autodetect_browser_mailer') \ + or app.config.get('custombrowser') == '': + extras_separator.hide() + open_file_in_browser_menuitem.hide() + + id_ = open_menuitem.connect( + 'activate', self.on_open_menuitem_activate, data) + self.handlers[id_] = open_menuitem + id_ = save_as_menuitem.connect( + 'activate', self.on_save_as_menuitem_activate, data) + self.handlers[id_] = save_as_menuitem + id_ = copy_link_location_menuitem.connect( + 'activate', self.on_copy_link_location_menuitem_activate, data) + self.handlers[id_] = copy_link_location_menuitem + id_ = open_link_in_browser_menuitem.connect( + 'activate', self.on_open_link_in_browser_menuitem_activate, data) + self.handlers[id_] = open_link_in_browser_menuitem + id_ = open_file_in_browser_menuitem.connect( + 'activate', self.on_open_file_in_browser_menuitem_activate, data) + self.handlers[id_] = open_file_in_browser_menuitem + + return menu + + def on_open_menuitem_activate(self, menu, data): + filepath = data["filepath"] + helpers.launch_file_manager(filepath) + + def on_save_as_menuitem_activate(self, menu, data): + filepath = data["filepath"] + original_filename = data["original_filename"] + def on_continue(response, target_path): + if response < 0: + return + shutil.copy(filepath, target_path) + dialog.destroy() + + def on_ok(widget): + target_path = dialog.get_filename() + if os.path.exists(target_path): + # check if we have write permissions + if not os.access(target_path, os.W_OK): + file_name = os.path.basename(target_path) + dialogs.ErrorDialog( + _('Cannot overwrite existing file "%s"') % file_name, + _('A file with this name already exists and you do ' + 'not have permission to overwrite it.')) + return + dialog2 = dialogs.FTOverwriteConfirmationDialog( + _('This file already exists'), + _('What do you want to do?'), + propose_resume=False, + on_response=(on_continue, target_path), + transient_for=dialog) + dialog2.set_destroy_with_parent(True) + else: + dirname = os.path.dirname(target_path) + if not os.access(dirname, os.W_OK): + dialogs.ErrorDialog( + _('Directory "%s" is not writable') % dirname, + _('You do not have permission to ' + 'create files in this directory.')) + return + on_continue(0, target_path) + + def on_cancel(widget): + dialog.destroy() + + dialog = dialogs.FileChooserDialog( + title_text=_('Save Image as...'), + action=Gtk.FileChooserAction.SAVE, + buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_SAVE, Gtk.ResponseType.OK), + default_response=Gtk.ResponseType.OK, + current_folder=app.config.get('last_save_dir'), + on_response_ok=on_ok, + on_response_cancel=on_cancel) + + dialog.set_current_name(original_filename) + dialog.connect('delete-event', lambda widget, event: + on_cancel(widget)) + + def on_copy_link_location_menuitem_activate(self, menu, data): + url = data["url"] + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + clipboard.set_text(url, -1) + clipboard.store() + + def on_open_link_in_browser_menuitem_activate(self, menu, data): + url = data["url"] + if data["encrypted"]: + dialogs.ErrorDialog( + _('Encrypted file'), + _('You cannot open encrypted files in your ' + 'browser directly. Try "Open Downloaded File ' + 'in Browser" instead.'), + transient_for=self.chat_control.parent_win.window) + else: + helpers.launch_browser_mailer('url', url) + + def on_open_file_in_browser_menuitem_activate(self, menu, data): + if os.name == "nt": + filepath = "file://" + os.path.abspath(data["filepath"]) + else: + filepath = "file://" + data["filepath"] + if app.config.get('autodetect_browser_mailer') \ + or app.config.get('custombrowser') == '': + dialogs.ErrorDialog( + _('Cannot open downloaded file in browser'), + _('You have to set a custom browser executable ' + 'in your gajim settings for this to work.'), + transient_for=self.chat_control.parent_win.window) + return + command = app.config.get('custombrowser') + command = helpers.build_command(command, filepath) + try: + helpers.exec_command(command) + except Exception: + pass + + # Change mouse pointer to HAND2 when + # mouse enter the eventbox with the image + def on_enter_event(self, eb, event): + self.textview.tv.get_window( + Gtk.TextWindowType.TEXT).set_cursor(Gdk.Cursor(Gdk.CursorType.HAND2)) + + # Change mouse pointer to default when mouse leaves the eventbox + def on_leave_event(self, eb, event): + self.textview.tv.get_window( + Gtk.TextWindowType.TEXT).set_cursor(Gdk.Cursor(Gdk.CursorType.XTERM)) + + def on_button_press_event(self, eb, event, filepath, + original_filename, url, encrypted): + data = {"filepath": filepath, + "original_filename": original_filename, + "url": url, + "encrypted": encrypted} + # left click + if event.type == Gdk.EventType.BUTTON_PRESS and event.button == 1: + method = getattr(self, "on_" + + self.plugin.config['LEFTCLICK_ACTION'] + + "_activate") + method(event, data) + # right klick + elif event.type == Gdk.EventType.BUTTON_PRESS and event.button == 3: + menu = self.make_rightclick_menu(event, data) + #menu.attach_to_widget(self.tv, None) + #menu.popup(None, None, None, event.button, event.time) + menu.popup_at_pointer(event) + def disconnect_from_chat_control(self): pass class UrlImagePreviewPluginConfigDialog(GajimPluginConfigDialog): + max_file_size = [262144, 524288, 1048576, 5242880, 10485760] + leftclick_action = ['open_menuitem', 'save_as_menuitem', 'copy_link_location_menuitem', + 'open_link_in_browser_menuitem', 'open_file_in_browser_menuitem'] + def init(self): self.GTK_BUILDER_FILE_PATH = self.plugin.local_file_path( 'config_dialog.ui') self.xml = Gtk.Builder() self.xml.set_translation_domain('gajim_plugins') - self.xml.add_objects_from_file(self.GTK_BUILDER_FILE_PATH, ['vbox1']) + self.xml.add_objects_from_file(self.GTK_BUILDER_FILE_PATH, [ + 'vbox1', 'liststore1', 'liststore2']) self.preview_size_spinbutton = self.xml.get_object('preview_size') - adjustment = Gtk.Adjustment(value=20, - lower=10, - upper=512, - step_increment=1, - page_increment=10, - page_size=0) - self.preview_size_spinbutton.set_adjustment(adjustment) + self.preview_size_spinbutton.get_adjustment().configure(20, 10, 512, 1, + 10, 0) + self.max_size_combobox = self.xml.get_object('max_size_combobox') + self.leftclick_action_combobox = self.xml.get_object('leftclick_action_combobox') vbox = self.xml.get_object('vbox1') self.get_child().pack_start(vbox, True, True, 0) @@ -157,7 +653,37 @@ class UrlImagePreviewPluginConfigDialog(GajimPluginConfigDialog): def on_run(self): self.preview_size_spinbutton.set_value(self.plugin.config[ 'PREVIEW_SIZE']) - + value = self.plugin.config['MAX_FILE_SIZE'] + if value: + # this fails if we upgrade from an old version + # which has other file size values than we have now + try: + self.max_size_combobox.set_active( + self.max_file_size.index(value)) + except: + pass + else: + self.max_size_combobox.set_active(-1) + + value = self.plugin.config['LEFTCLICK_ACTION'] + if value: + # this fails if we upgrade from an old version + # which has other file size values than we have now + try: + self.leftclick_action_combobox.set_active( + self.leftclick_action.index(value)) + except: + pass + else: + self.leftclick_action_combobox.set_active(0) def preview_size_value_changed(self, spinbutton): self.plugin.config['PREVIEW_SIZE'] = spinbutton.get_value() + + def max_size_value_changed(self, widget): + self.plugin.config['MAX_FILE_SIZE'] = self.max_file_size[ + self.max_size_combobox.get_active()] + + def leftclick_action_changed(self, widget): + self.plugin.config['LEFTCLICK_ACTION'] = self.leftclick_action[ + self.leftclick_action_combobox.get_active()] |