plugin_installer.py 20.4 KB
Newer Older
1 2 3 4
# -*- coding: utf-8 -*-
#
## plugins/plugin_installer/plugin_installer.py
##
Dicson's avatar
Dicson committed
5 6
## Copyright (C) 2010-2012 Denis Fomin <fominde AT gmail.com>
## Copyright (C) 2011-2012 Yann Leboulanger <asterix AT lagaule.org>
7
## Copyright (C) 2017      Philipp Hörist <philipp AT hoerist.com>
8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
##
## 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/>.
##
23

24 25
import io
import threading
Dicson's avatar
Dicson committed
26
import configparser
27
import os
28
import ssl
29
import logging
30
import posixpath
31
from enum import IntEnum
32
from zipfile import ZipFile
33
from distutils.version import LooseVersion as V
34
import urllib.error
35
from urllib.request import urlopen
36 37 38 39 40

from gi.repository import Gtk
from gi.repository import GdkPixbuf
from gi.repository import GLib

41 42 43 44 45 46 47 48 49 50
from gajim.common import app
from gajim.common import configpaths
from gajim.plugins import GajimPlugin
from gajim.plugins.gui import GajimPluginConfigDialog
from gajim.plugins.plugins_i18n import _
from gajim.plugins.helpers import get_builder
from gajim.gtk.dialogs import WarningDialog
from gajim.gtk.dialogs import HigDialog
from gajim.gtk.dialogs import YesNoDialog
from gajim.gtkgui_helpers import get_action
51

52 53
log = logging.getLogger('gajim.plugin_system.plugin_installer')

54 55 56
PLUGINS_URL = 'https://ftp.gajim.org/plugins_master_zip/'
MANIFEST_URL = 'https://ftp.gajim.org/plugins_master_zip/manifests.zip'
MANIFEST_IMAGE_URL = 'https://ftp.gajim.org/plugins_master_zip/manifests_images.zip'
57
MANDATORY_FIELDS = ['name', 'version', 'description', 'authors', 'homepage']
58 59
FALLBACK_ICON = Gtk.IconTheme.get_default().load_icon(
    'preferences-system', Gtk.IconSize.MENU, 0)
60

61

62 63 64 65 66 67 68 69 70 71 72
class Column(IntEnum):
    PIXBUF = 0
    DIR = 1
    NAME = 2
    LOCAL_VERSION = 3
    VERSION = 4
    UPGRADE = 5
    DESCRIPTION = 6
    AUTHORS = 7
    HOMEPAGE = 8

Dicson's avatar
Dicson committed
73

74 75 76 77 78 79
def get_local_version(plugin_manifest):
    name = plugin_manifest['name']
    short_name = plugin_manifest['short_name']

    for plugin in app.plugin_manager.plugins:
        if plugin.name == name:
80 81
            return plugin.version

82 83 84 85 86 87 88 89 90
    # Fallback:
    # If the plugin has errors and is not loaded by the
    # PluginManager. Look in the Gajim config if the plugin is
    # known and active, if yes load the manifest from the Plugin
    # dir and parse the version
    active = app.config.get_per('plugins', short_name, 'active')
    if not active:
        return
    manifest_path = os.path.join(
91
        configpaths.get('PLUGINS_USER'), short_name, 'manifest.ini')
92 93 94 95 96 97 98 99 100 101 102 103 104 105
    if not os.path.exists(manifest_path):
        return
    conf = configparser.ConfigParser()
    with open(manifest_path, encoding='utf-8') as conf_file:
        try:
            conf.read_file(conf_file)
        except configparser.Error:
            log.warning('Cant parse version for %s from manifest',
                        short_name)
            return

    version = conf.get('info', 'version', fallback=None)
    return version

106

107 108
class PluginInstaller(GajimPlugin):
    def init(self):
109
        self.description = _('Install and Upgrade Plugins')
Dicson's avatar
Dicson committed
110
        self.config_dialog = PluginInstallerPluginConfigDialog(self)
111
        self.config_default_values = {'check_update': (True, '')}
112
        self.gui_extension_points = {'plugin_window': (self.on_activate, None)}
113 114 115
        self.window = None
        self.progressbar = None
        self.available_plugins_model = None
116 117
        self.timeout_id = 0
        self.connected_ids = {}
118 119

    def activate(self):
120
        if self.config['check_update']:
121
            self.timeout_id = GLib.timeout_add_seconds(30, self.check_update)
122 123
        if 'plugins' in app.interface.instances:
            self.on_activate(app.interface.instances['plugins'])
124 125

    def warn_update(self, plugins):
126
        def open_update(dummy):
127
            get_action('plugins').activate()
Daniel Brötzmann's avatar
Daniel Brötzmann committed
128
            page = self.notebook.page_num(self.available_plugins_box)
129
            self.notebook.set_current_page(page)
130
        if plugins:
131
            plugins_str = '\n' + '\n'.join(plugins)
132
            YesNoDialog(
133 134 135 136
                _('Plugins updates'),
                _('Some updates are available for your installer plugins. '
                  'Do you want to update those plugins:\n%s')
                % plugins_str, on_response_yes=open_update)
137
        else:
138
            log.info('No updates found')
139 140
            if hasattr(self, 'thread'):
                del self.thread
141 142

    def check_update(self):
143 144
        if hasattr(self, 'thread'):
            return
145
        log.info('Checking for Updates...')
146
        self.start_download(check_update=True)
147
        self.timeout_id = 0
148 149

    def deactivate(self):
150
        if hasattr(self, 'available_page'):
Daniel Brötzmann's avatar
Daniel Brötzmann committed
151
            self.notebook.remove_page(self.notebook.page_num(self.available_plugins_box))
152
            self.notebook.set_current_page(0)
Dicson's avatar
Dicson committed
153
            for id_, widget in list(self.connected_ids.items()):
154
                widget.disconnect(id_)
155
            del self.available_page
156 157
        if hasattr(self, 'thread'):
            del self.thread
158
        if self.timeout_id > 0:
159
            GLib.source_remove(self.timeout_id)
160
            self.timeout_id = 0
161

162
    def on_activate(self, plugin_win):
163
        if hasattr(self, 'available_page'):
Dicson's avatar
Dicson committed
164 165
            # 'Available' tab exists
            return
166 167
        if hasattr(self, 'thread'):
            del self.thread
168 169
        self.installed_plugins_model = plugin_win.installed_plugins_model
        self.notebook = plugin_win.plugins_notebook
170 171
        id_ = self.notebook.connect('switch-page', self.on_notebook_switch_page)
        self.connected_ids[id_] = self.notebook
172
        self.window = plugin_win.window
173 174
        id_ = self.window.connect('destroy', self.on_win_destroy)
        self.connected_ids[id_] = self.window
175 176 177
        path = self.local_file_path('installer.ui')
        self.xml = get_builder(
            path, widgets=['refresh', 'available_plugins_box', 'plugin_store'])
178

179
        widgets_to_extract = (
Daniel Brötzmann's avatar
Daniel Brötzmann committed
180 181 182 183
            'available_plugins_box', 'install_plugin_button', 'plugin_name_label',
            'plugin_version_label', 'plugin_authors_label', 'plugin_description',
            'plugin_homepage_linkbutton', 'progressbar', 'available_plugins_treeview',
            'available_text', 'available_text_label')
184 185 186 187

        for widget_name in widgets_to_extract:
            setattr(self, widget_name, self.xml.get_object(widget_name))

188
        self.available_page = self.notebook.append_page(
Daniel Brötzmann's avatar
Daniel Brötzmann committed
189
            self.available_plugins_box, Gtk.Label.new(_('Available')))
190 191

        self.available_plugins_model = self.xml.get_object('plugin_store')
192 193
        self.available_plugins_model.set_sort_column_id(
            2, Gtk.SortType.ASCENDING)
194 195 196 197 198

        self.xml.connect_signals(self)
        self.window.show_all()

    def on_win_destroy(self, widget):
199 200
        if hasattr(self, 'thread'):
            del self.thread
201 202
        if hasattr(self, 'available_page'):
            del self.available_page
203 204

    def available_plugins_toggled_cb(self, cell, path):
205 206
        is_active = self.available_plugins_model[path][Column.UPGRADE]
        self.available_plugins_model[path][Column.UPGRADE] = not is_active
207
        dir_list = []
Dicson's avatar
Dicson committed
208
        for i in range(len(self.available_plugins_model)):
209 210
            if self.available_plugins_model[i][Column.UPGRADE]:
                dir_list.append(self.available_plugins_model[i][Column.DIR])
Daniel Brötzmann's avatar
Daniel Brötzmann committed
211
        self.install_plugin_button.set_property('sensitive', bool(dir_list))
212 213

    def on_notebook_switch_page(self, widget, page, page_num):
214
        tab_label_text = self.notebook.get_tab_label_text(page)
215 216
        if tab_label_text != (_('Available')):
            return
217
        if not hasattr(self, 'thread'):
218
            self.available_plugins_model.clear()
219
            self.start_download(upgrading=True)
220

221
    def on_install_upgrade_clicked(self, widget):
Daniel Brötzmann's avatar
Daniel Brötzmann committed
222
        self.install_plugin_button.set_property('sensitive', False)
223
        dir_list = []
Dicson's avatar
Dicson committed
224
        for i in range(len(self.available_plugins_model)):
225 226
            if self.available_plugins_model[i][Column.UPGRADE]:
                dir_list.append(self.available_plugins_model[i][Column.DIR])
227

228
        self.start_download(remote_dirs=dir_list)
229 230 231 232 233 234 235 236 237 238 239 240 241

    def on_error(self, reason):
        if reason == 'CERTIFICATE_VERIFY_FAILED':
            YesNoDialog(
                _('Security error during download'),
                _('A security error occurred when '
                  'downloading. The certificate of the '
                  'plugin archive could not be verified. '
                  'this might be a security attack. '
                  '\n\nYou can continue at your risk. '
                  'Do you want to do so? '
                  '(not recommended)'
                  ),
242 243
                on_response_yes=lambda dlg:
                self.start_download(secure=False, upgrading=True))
244 245 246 247 248 249 250 251 252 253
        else:
            if self.available_plugins_model:
                for i in range(len(self.available_plugins_model)):
                    self.available_plugins_model[i][Column.UPGRADE] = False
                self.progressbar.hide()
            text = GLib.markup_escape_text(reason)
            WarningDialog(_('Error in download'),
                          _('An error occurred when downloading\n\n'
                          '<tt>[%s]</tt>' % (str(text))), self.window)

254 255
    def start_download(self, secure=True, remote_dirs=False,
                       upgrading=False, check_update=False):
256 257
        log.info('Start Download...')
        log.debug(
258 259
            'secure: %s, remote_dirs: %s, upgrading: %s, check_update: %s',
            secure, remote_dirs, upgrading, check_update)
260 261
        self.thread = DownloadAsync(
            self, secure=secure, remote_dirs=remote_dirs,
262
            upgrading=upgrading, check_update=check_update)
263
        self.thread.start()
264

265
    def on_plugin_downloaded(self, plugin_dirs):
266
        need_restart = False
267
        for _dir in plugin_dirs:
268 269 270 271 272 273 274 275
            updated = app.plugin_manager.update_plugins(replace=False, activate=True, plugin_name=_dir)
            if updated:
                plugin = app.plugin_manager.get_active_plugin(updated[0])
                for row in range(len(self.available_plugins_model)):
                    model_row = self.available_plugins_model[row]
                    if plugin.name == model_row[Column.NAME]:
                        model_row[Column.LOCAL_VERSION] = plugin.version
                        model_row[Column.UPGRADE] = False
276
                        break
277 278 279 280 281 282 283 284 285 286
                # get plugin icon
                icon_file = os.path.join(plugin.__path__, os.path.split(
                    plugin.__path__)[1]) + '.png'
                icon = FALLBACK_ICON
                if os.path.isfile(icon_file):
                    icon = GdkPixbuf.Pixbuf.new_from_file_at_size(icon_file, 16, 16)
                row = [plugin, plugin.name, True, plugin.activatable, icon]
                self.installed_plugins_model.append(row)
            else:
                need_restart = True
287

288 289 290 291 292
        if need_restart:
            txt = _('All plugins downloaded.\nThe updates will '
                'be installed on next Gajim restart.')
        else:
            txt = _('All selected plugins downloaded and activated')
293
        dialog = HigDialog(
294
            self.window, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, '', txt)
295 296
        dialog.set_modal(False)
        dialog.popup()
297 298

    def available_plugins_treeview_selection_changed(self, treeview_selection):
299 300
        model, iter_ = treeview_selection.get_selected()
        if not iter_:
Daniel Brötzmann's avatar
Daniel Brötzmann committed
301 302 303 304 305
            self.plugin_name_label.set_text('')
            self.plugin_version_label.set_text('')
            self.plugin_authors_label.set_text('')
            self.plugin_homepage_linkbutton.set_text('')
            self.install_plugin_button.set_sensitive(False)
306
            return
Daniel Brötzmann's avatar
Daniel Brötzmann committed
307 308 309 310 311 312 313
        self.plugin_name_label.set_text(model.get_value(iter_, Column.NAME))
        self.plugin_version_label.set_text(model.get_value(iter_, Column.VERSION))
        self.plugin_authors_label.set_text(model.get_value(iter_, Column.AUTHORS))
        homepage = model.get_value(iter_, Column.HOMEPAGE)
        markup = '<a href="%s">%s</a>' % (homepage, homepage)
        self.plugin_homepage_linkbutton.set_markup(markup)
        self.plugin_description.set_text(model.get_value(iter_, Column.DESCRIPTION))
314

315
    def select_root_iter(self):
Daniel Brötzmann's avatar
Daniel Brötzmann committed
316
        selection = self.available_plugins_treeview.get_selection()
317 318 319 320
        model, iter_ = selection.get_selected()
        if not iter_:
            iter_ = self.available_plugins_model.get_iter_first()
            selection.select_iter(iter_)
Daniel Brötzmann's avatar
Daniel Brötzmann committed
321 322
        self.plugin_name_label.show()
        self.plugin_homepage_linkbutton.show()
323
        path = self.available_plugins_model.get_path(iter_)
Daniel Brötzmann's avatar
Daniel Brötzmann committed
324
        self.available_plugins_treeview.scroll_to_cell(path)
325

326

327
class DownloadAsync(threading.Thread):
328
    def __init__(self, plugin, secure, remote_dirs, upgrading, check_update):
329
        threading.Thread.__init__(self)
330 331 332 333
        self.plugin = plugin
        self.window = plugin.window
        self.progressbar = plugin.progressbar
        self.model = plugin.available_plugins_model
334 335
        self.remote_dirs = remote_dirs
        self.upgrading = upgrading
336
        self.secure = secure
337
        self.check_update = check_update
338
        self.pulse = None
339 340

    def model_append(self, row):
341 342 343 344 345 346
        row_data = [
            row['icon'], row['remote_dir'], row['name'], row['local_version'],
            row['version'], row['upgrade'], row['description'], row['authors'],
            row['homepage']
            ]
        self.model.append(row_data)
347 348 349 350 351 352 353
        return False

    def progressbar_pulse(self):
        self.progressbar.pulse()
        return True

    def run(self):
354 355 356 357
        try:
            if self.check_update:
                self.run_check_update()
            else:
358 359 360
                GLib.idle_add(self.progressbar.show)
                self.pulse = GLib.timeout_add(150, self.progressbar_pulse)
                self.run_download_plugin_list()
361 362 363 364 365 366 367 368
        except urllib.error.URLError as exc:
            if isinstance(exc.reason, ssl.SSLError):
                ssl_reason = exc.reason.reason
                if ssl_reason == 'CERTIFICATE_VERIFY_FAILED':
                    log.exception('Certificate verify failed')
                    GLib.idle_add(self.plugin.on_error, ssl_reason)
        except Exception as exc:
            GLib.idle_add(self.plugin.on_error, str(exc))
369
            log.exception('Error fetching plugin list')
370
        finally:
371
            if self.pulse:
372 373
                GLib.source_remove(self.pulse)
                GLib.idle_add(self.progressbar.hide)
374
                self.pulse = None
375 376 377 378 379

    def parse_manifest(self, buf):
        '''
        given the buffer of the zipfile, returns the list of plugin manifests
        '''
380
        zip_file = ZipFile(buf)
381 382 383
        manifest_list = zip_file.namelist()
        plugins = []
        for filename in manifest_list:
384 385 386
            # Parse manifest
            if not filename.endswith('manifest.ini'):
                continue
387 388 389 390 391
            config = configparser.ConfigParser()
            conf_file = zip_file.open(filename)
            config.read_file(io.TextIOWrapper(conf_file, encoding='utf-8'))
            conf_file.close()
            if not config.has_section('info'):
392
                log.warning('Plugin is missing INFO section in manifest.ini. '
393 394 395 396
                         'Plugin not loaded.')
                continue
            opts = config.options('info')
            if not set(MANDATORY_FIELDS).issubset(opts):
397 398 399 400 401
                log.warning(
                        '%s is missing mandatory fields %s. '
                        'Plugin not loaded.',
                        filename,
                        set(MANDATORY_FIELDS).difference(opts))
402
                continue
403 404 405 406
            # Add icon and remote dir
            icon = None
            remote_dir = filename.split('/')[0]
            png_filename = '{0}/{0}.png'.format(remote_dir)
407
            icon = FALLBACK_ICON
408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424
            if png_filename in manifest_list:
                data = zip_file.open(png_filename).read()
                pix = GdkPixbuf.PixbufLoader()
                pix.set_size(16, 16)
                pix.write(data)
                pix.close()
                icon = pix.get_pixbuf()

            # transform to dictonary
            config_dict = {}
            for key, value in config.items('info'):
                config_dict[key] = value
            config_dict['icon'] = icon
            config_dict['remote_dir'] = remote_dir
            config_dict['upgrade'] = False

            plugins.append(config_dict)
425 426
        return plugins

427
    def download_url(self, url):
428
        log.info('Fetching %s', url)
429 430 431 432 433 434 435 436 437 438 439 440 441
        ssl_args = {}
        if self.secure:
            ssl_args['context'] = ssl.create_default_context(
                cafile=self.plugin.local_file_path('DST_Root_CA_X3.pem'))
        else:
            ssl_args['context'] = ssl.create_default_context()
            ssl_args['context'].check_hostname = False
            ssl_args['context'].verify_mode = ssl.CERT_NONE

        for flag in ('OP_NO_SSLv2', 'OP_NO_SSLv3',
                     'OP_NO_TLSv1', 'OP_NO_TLSv1_1',
                     'OP_NO_COMPRESSION',
                     ):
442
            log.debug('SSL Options: +%s' % flag)
443 444 445
            ssl_args['context'].options |= getattr(ssl, flag)
        request = urlopen(url, **ssl_args)

446
        return io.BytesIO(request.read())
447 448

    def run_check_update(self):
449 450
        to_update = []
        zipbuf = self.download_url(MANIFEST_URL)
451 452
        plugin_list = self.parse_manifest(zipbuf)
        for plugin in plugin_list:
453
            local_version = get_local_version(plugin)
454
            if local_version:
455
                gajim_v = V(app.config.get('version'))
456 457 458 459 460
                min_v = plugin.get('min_gajim_version', None)
                min_v = V(min_v) if min_v else gajim_v
                max_v = plugin.get('max_gajim_version', None)
                max_v = V(max_v) if max_v else gajim_v
                if (V(plugin['version']) > V(local_version)) and \
461
                (gajim_v >= min_v) and (gajim_v <= max_v):
462
                    to_update.append(plugin['name'])
463
        GLib.idle_add(self.plugin.warn_update, to_update)
464 465 466

    def run_download_plugin_list(self):
        if not self.remote_dirs:
467
            log.info('Downloading Pluginlist...')
468 469 470
            zipbuf = self.download_url(MANIFEST_IMAGE_URL)
            plugin_list = self.parse_manifest(zipbuf)
            for plugin in plugin_list:
471
                plugin['local_version'] = get_local_version(plugin)
472 473 474
                if self.upgrading and plugin['local_version']:
                    if V(plugin['version']) > V(plugin['local_version']):
                        plugin['upgrade'] = True
475
                        GLib.idle_add(
Daniel Brötzmann's avatar
Daniel Brötzmann committed
476
                            self.plugin.install_plugin_button.set_property,
477
                            'sensitive', True)
478
                GLib.idle_add(self.model_append, plugin)
479
            GLib.idle_add(self.plugin.select_root_iter)
480 481
        else:
            self.download_plugin()
482 483 484

    def download_plugin(self):
        for remote_dir in self.remote_dirs:
485
            filename = remote_dir + '.zip'
486
            log.info('Download: %s', filename)
487 488

            user_dir = configpaths.get('PLUGINS_DOWNLOAD')
489
            local_dir = os.path.join(user_dir, remote_dir)
490 491
            if not os.path.isdir(local_dir):
                os.mkdir(local_dir)
492
            local_dir = os.path.dirname(local_dir)
493

494 495
            # downloading zip file
            try:
496 497
                plugin = posixpath.join(PLUGINS_URL, filename)
                buf = self.download_url(plugin)
498 499 500
            except:
                log.exception("Error downloading plugin %s" % filename)
                continue
501
            with ZipFile(buf) as zip_file:
502
                zip_file.extractall(local_dir)
503
        GLib.idle_add(self.plugin.on_plugin_downloaded, self.remote_dirs)
504 505 506 507


class PluginInstallerPluginConfigDialog(GajimPluginConfigDialog):
    def init(self):
508 509 510
        glade_file_path = self.plugin.local_file_path('config.ui')
        self.xml = get_builder(glade_file_path)
        self.get_child().pack_start(self.xml.config_grid, True, True, 0)
511 512 513 514

        self.xml.connect_signals(self)

    def on_run(self):
515
        self.xml.check_update.set_active(self.plugin.config['check_update'])
516

517 518
    def on_check_update_toggled(self, widget):
        self.plugin.config['check_update'] = widget.get_active()