accounts.py 38 KB
Newer Older
1

Adrian Georgescu's avatar
Adrian Georgescu committed
2
import json
3 4
import os
import re
5
import sys
Adrian Georgescu's avatar
Adrian Georgescu committed
6
import urllib.request, urllib.parse, urllib.error
7

Dan Pascu's avatar
Dan Pascu committed
8 9
from PyQt5 import uic
from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex, QSortFilterProxyModel, QUrl, QUrlQuery
10
from PyQt5.QtGui import QIcon
Dan Pascu's avatar
Dan Pascu committed
11
from PyQt5.QtNetwork import QNetworkAccessManager
12 13 14
from PyQt5.QtWebKit import QWebSettings
from PyQt5.QtWebKitWidgets import QWebView, QWebPage
from PyQt5.QtWidgets import QApplication, QButtonGroup, QComboBox, QMenu
15 16

from application.notification import IObserver, NotificationCenter
17
from application.python import Null, limit
Dan Pascu's avatar
Dan Pascu committed
18
from application.system import makedirs
Dan Pascu's avatar
Dan Pascu committed
19
from collections import defaultdict
Dan Pascu's avatar
Dan Pascu committed
20
from gnutls.crypto import X509Certificate, X509PrivateKey
21
from gnutls.errors import GNUTLSError
22
from zope.interface import implementer
23

24 25
from sipsimple.account import Account, AccountManager, BonjourAccount
from sipsimple.configuration import DuplicateIDError
26
from sipsimple.configuration.settings import SIPSimpleSettings
27
from sipsimple.threading import run_in_thread
28
from sipsimple.util import user_info
29

30 31 32 33
from blink.configuration.settings import BlinkSettings
from blink.contacts import URIUtils
from blink.resources import ApplicationData, IconManager, Resources
from blink.sessions import SessionManager, StreamDescription
34
from blink.widgets.labels import Status
35
from blink.util import QSingleton, call_in_gui_thread, run_in_gui_thread
36 37


38 39 40
__all__ = ['AccountModel', 'ActiveAccountModel', 'AccountSelector', 'AddAccountDialog', 'ServerToolsAccountModel', 'ServerToolsWindow']


41 42 43 44
class IconDescriptor(object):
    def __init__(self, filename):
        self.filename = filename
        self.icon = None
45 46

    def __get__(self, instance, owner):
47 48 49 50
        if self.icon is None:
            self.icon = QIcon(self.filename)
            self.icon.filename = self.filename
        return self.icon
51

52 53
    def __set__(self, obj, value):
        raise AttributeError("attribute cannot be set")
54

55 56 57 58
    def __delete__(self, obj):
        raise AttributeError("attribute cannot be deleted")


59
class AccountInfo(object):
60 61 62 63 64
    active_icon = IconDescriptor(Resources.get('icons/circle-dot.svg'))
    inactive_icon = IconDescriptor(Resources.get('icons/circle-grey.svg'))
    activity_icon = IconDescriptor(Resources.get('icons/circle-progress.svg'))

    def __init__(self, account):
65 66
        self.account = account
        self.registration_state = None
67
        self.registrar = None
68

69 70
    @property
    def name(self):
Adrian Georgescu's avatar
Adrian Georgescu committed
71
        return 'Bonjour' if self.account is BonjourAccount() else str(self.account.id)
72

73 74 75 76 77 78 79 80 81
    @property
    def icon(self):
        if self.registration_state == 'started':
            return self.activity_icon
        elif self.registration_state == 'succeeded':
            return self.active_icon
        else:
            return self.inactive_icon

82
    def __eq__(self, other):
Adrian Georgescu's avatar
Adrian Georgescu committed
83
        if isinstance(other, str):
84 85 86
            return self.name == other
        elif isinstance(other, (Account, BonjourAccount)):
            return self.account == other
87 88
        elif isinstance(other, AccountInfo):
            return self.account == other.account
89 90 91 92 93 94
        return False

    def __ne__(self, other):
        return not self.__eq__(other)


95
@implementer(IObserver)
96 97 98 99 100 101 102
class AccountModel(QAbstractListModel):

    def __init__(self, parent=None):
        super(AccountModel, self).__init__(parent)
        self.accounts = []

        notification_center = NotificationCenter()
103
        notification_center.add_observer(self, name='CFGSettingsObjectDidChange')
104 105 106 107
        notification_center.add_observer(self, name='SIPAccountWillRegister')
        notification_center.add_observer(self, name='SIPAccountRegistrationDidSucceed')
        notification_center.add_observer(self, name='SIPAccountRegistrationDidFail')
        notification_center.add_observer(self, name='SIPAccountRegistrationDidEnd')
108
        notification_center.add_observer(self, name='SIPAccountDidDeactivate')
109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137
        notification_center.add_observer(self, name='BonjourAccountWillRegister')
        notification_center.add_observer(self, name='BonjourAccountRegistrationDidSucceed')
        notification_center.add_observer(self, name='BonjourAccountRegistrationDidFail')
        notification_center.add_observer(self, name='BonjourAccountRegistrationDidEnd')
        notification_center.add_observer(self, sender=AccountManager())

    def rowCount(self, parent=QModelIndex()):
        return len(self.accounts)

    def data(self, index, role=Qt.DisplayRole):
        if not index.isValid():
            return None
        account_info = self.accounts[index.row()]
        if role == Qt.DisplayRole:
            return account_info.name
        elif role == Qt.DecorationRole:
            return account_info.icon
        elif role == Qt.UserRole:
            return account_info
        return None

    @run_in_gui_thread
    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_SIPAccountManagerDidAddAccount(self, notification):
        account = notification.data.account
        self.beginInsertRows(QModelIndex(), len(self.accounts), len(self.accounts))
138
        self.accounts.append(AccountInfo(account))
139 140
        self.endInsertRows()

141 142 143 144 145
    def _NH_CFGSettingsObjectDidChange(self, notification):
        if isinstance(notification.sender, (Account, BonjourAccount)):
            position = self.accounts.index(notification.sender)
            self.dataChanged.emit(self.index(position), self.index(position))

146 147 148 149 150 151 152
    def _NH_SIPAccountManagerDidRemoveAccount(self, notification):
        position = self.accounts.index(notification.data.account)
        self.beginRemoveRows(QModelIndex(), position, position)
        del self.accounts[position]
        self.endRemoveRows()

    def _NH_SIPAccountWillRegister(self, notification):
153 154 155 156
        try:
            position = self.accounts.index(notification.sender)
        except ValueError:
            return
157
        self.accounts[position].registration_state = 'started'
158
        self.accounts[position].registrar = None
159 160 161
        self.dataChanged.emit(self.index(position), self.index(position))

    def _NH_SIPAccountRegistrationDidSucceed(self, notification):
162 163 164 165
        try:
            position = self.accounts.index(notification.sender)
        except ValueError:
            return
166
        self.accounts[position].registration_state = 'succeeded'
167 168 169
        if notification.sender is not BonjourAccount():
            registrar = notification.data.registrar
            self.accounts[position].registrar = "%s:%s:%s" % (registrar.transport, registrar.address, registrar.port)
Tijmen de Mes's avatar
Tijmen de Mes committed
170

171
        self.dataChanged.emit(self.index(position), self.index(position))
172 173 174 175 176 177 178
        notification.center.post_notification('SIPRegistrationInfoDidChange', sender=notification.sender)

    def _NH_SIPAccountDidDeactivate(self, notification):
        try:
            position = self.accounts.index(notification.sender)
        except ValueError:
            return
Tijmen de Mes's avatar
Tijmen de Mes committed
179

180 181 182 183
        self.accounts[position].registration_state = None
        self.accounts[position].registrar = None
        self.dataChanged.emit(self.index(position), self.index(position))
        notification.center.post_notification('SIPRegistrationInfoDidChange', sender=notification.sender)
184 185

    def _NH_SIPAccountRegistrationDidFail(self, notification):
186 187 188 189
        try:
            position = self.accounts.index(notification.sender)
        except ValueError:
            return
Tijmen de Mes's avatar
Tijmen de Mes committed
190

191 192 193 194 195 196
        reason = 'Unknown reason'

        if hasattr(notification.data, 'error'):
            reason = notification.data.error
        elif hasattr(notification.data, 'reason'):
            reason = notification.data.reason
Tijmen de Mes's avatar
Tijmen de Mes committed
197

198 199
        self.accounts[position].registration_state = 'failed (%s)' % (reason.decode() if isinstance(reason, bytes) else reason)
        self.accounts[position].registrar = None
200
        self.dataChanged.emit(self.index(position), self.index(position))
201
        notification.center.post_notification('SIPRegistrationInfoDidChange', sender=notification.sender)
202 203

    def _NH_SIPAccountRegistrationDidEnd(self, notification):
204 205 206 207
        try:
            position = self.accounts.index(notification.sender)
        except ValueError:
            return
208
        self.accounts[position].registration_state = 'ended'
209
        self.accounts[position].registrar = None
210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230
        self.dataChanged.emit(self.index(position), self.index(position))

    _NH_BonjourAccountWillRegister = _NH_SIPAccountWillRegister
    _NH_BonjourAccountRegistrationDidSucceed = _NH_SIPAccountRegistrationDidSucceed
    _NH_BonjourAccountRegistrationDidFail = _NH_SIPAccountRegistrationDidFail
    _NH_BonjourAccountRegistrationDidEnd = _NH_SIPAccountRegistrationDidEnd


class ActiveAccountModel(QSortFilterProxyModel):
    def __init__(self, model, parent=None):
        super(ActiveAccountModel, self).__init__(parent)
        self.setSourceModel(model)
        self.setDynamicSortFilter(True)

    def filterAcceptsRow(self, source_row, source_parent):
        source_model = self.sourceModel()
        source_index = source_model.index(source_row, 0, source_parent)
        account_info = source_model.data(source_index, Qt.UserRole)
        return account_info.account.enabled


231
@implementer(IObserver)
232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260
class AccountSelector(QComboBox):

    def __init__(self, parent=None):
        super(AccountSelector, self).__init__(parent)

        notification_center = NotificationCenter()
        notification_center.add_observer(self, name="SIPAccountManagerDidChangeDefaultAccount")
        notification_center.add_observer(self, name="SIPAccountManagerDidStart")

    @run_in_gui_thread
    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_SIPAccountManagerDidStart(self, notification):
        account = AccountManager().default_account
        if account is not None:
            model = self.model()
            source_model = model.sourceModel()
            account_index = source_model.accounts.index(account)
            self.setCurrentIndex(model.mapFromSource(source_model.index(account_index)).row())

    def _NH_SIPAccountManagerDidChangeDefaultAccount(self, notification):
        account = notification.data.account
        if account is not None:
            model = self.model()
            source_model = model.sourceModel()
            account_index = source_model.accounts.index(account)
            self.setCurrentIndex(model.mapFromSource(source_model.index(account_index)).row())
261 262 263 264


ui_class, base_class = uic.loadUiType(Resources.get('add_account.ui'))

265

266
@implementer(IObserver)
Adrian Georgescu's avatar
Adrian Georgescu committed
267
class AddAccountDialog(base_class, ui_class, metaclass=QSingleton):
268

269 270 271 272 273 274 275 276 277
    def __init__(self, parent=None):
        super(AddAccountDialog, self).__init__(parent)
        with Resources.directory:
            self.setupUi(self)
        self.background_frame.setStyleSheet("")
        self.button_group = QButtonGroup(self)
        self.button_group.setObjectName("button_group")
        self.button_group.addButton(self.add_account_button, self.panel_view.indexOf(self.add_account_panel))
        self.button_group.addButton(self.create_account_button, self.panel_view.indexOf(self.create_account_panel))
278 279
        default_font_size = self.info_label.fontInfo().pointSizeF()
        title_font_size = limit(default_font_size + 3, max=14)
280
        font = self.title_label.font()
281
        font.setPointSizeF(title_font_size)
282 283
        self.title_label.setFont(font)
        font_metrics = self.create_status_label.fontMetrics()
284
        self.create_status_label.setMinimumHeight(font_metrics.height() + 2*(font_metrics.height() + font_metrics.leading()))   # reserve space for 3 lines
285
        font_metrics = self.email_note_label.fontMetrics()
Adrian Georgescu's avatar
Adrian Georgescu committed
286
        self.email_note_label.setMinimumWidth(font_metrics.width('The E-mail address is used when sending voicemail'))  # hack to make text justification look nice everywhere
287 288 289 290 291 292 293 294 295 296 297 298 299 300 301
        self.add_account_button.setChecked(True)
        self.panel_view.setCurrentWidget(self.add_account_panel)
        self.new_password_editor.textChanged.connect(self._SH_PasswordTextChanged)
        self.button_group.buttonClicked[int].connect(self._SH_PanelChangeRequest)
        self.accept_button.clicked.connect(self._SH_AcceptButtonClicked)
        self.display_name_editor.statusChanged.connect(self._SH_ValidityStatusChanged)
        self.name_editor.statusChanged.connect(self._SH_ValidityStatusChanged)
        self.username_editor.statusChanged.connect(self._SH_ValidityStatusChanged)
        self.sip_address_editor.statusChanged.connect(self._SH_ValidityStatusChanged)
        self.password_editor.statusChanged.connect(self._SH_ValidityStatusChanged)
        self.new_password_editor.statusChanged.connect(self._SH_ValidityStatusChanged)
        self.verify_password_editor.statusChanged.connect(self._SH_ValidityStatusChanged)
        self.email_address_editor.statusChanged.connect(self._SH_ValidityStatusChanged)
        self.display_name_editor.regexp = re.compile('^.*$')
        self.name_editor.regexp = re.compile('^.+$')
302
        self.username_editor.regexp = re.compile('^\w(?<=[^0_])[\w.-]{4,31}(?<=[^_.-])$', re.IGNORECASE)  # in order to enable unicode characters add re.UNICODE to flags
303
        self.sip_address_editor.regexp = re.compile('^[^@\s]+@[^@\s]+$')
304 305 306
        self.password_editor.regexp = re.compile('^.*$')
        self.new_password_editor.regexp = re.compile('^.{8,}$')
        self.verify_password_editor.regexp = re.compile('^$')
307
        self.email_address_editor.regexp = re.compile('^[^@\s]+@[^@\s]+$')
308

309 310 311 312
        account_manager = AccountManager()
        notification_center = NotificationCenter()
        notification_center.add_observer(self, sender=account_manager)

313 314
    def _get_display_name(self):
        if self.panel_view.currentWidget() is self.add_account_panel:
315
            return self.display_name_editor.text()
316
        else:
317
            return self.name_editor.text()
318 319 320 321 322 323

    def _set_display_name(self, value):
        self.display_name_editor.setText(value)
        self.name_editor.setText(value)

    def _get_username(self):
324
        return self.username_editor.text()
325 326 327 328 329

    def _set_username(self, value):
        self.username_editor.setText(value)

    def _get_sip_address(self):
330
        return self.sip_address_editor.text()
331 332 333 334 335 336

    def _set_sip_address(self, value):
        self.sip_address_editor.setText(value)

    def _get_password(self):
        if self.panel_view.currentWidget() is self.add_account_panel:
337
            return self.password_editor.text()
338
        else:
339
            return self.new_password_editor.text()
340 341 342 343 344 345

    def _set_password(self, value):
        self.password_editor.setText(value)
        self.new_password_editor.setText(value)

    def _get_verify_password(self):
346
        return self.verify_password_editor.text()
347 348 349 350 351

    def _set_verify_password(self, value):
        self.verify_password_editor.setText(value)

    def _get_email_address(self):
352
        return self.email_address_editor.text()
353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371

    def _set_email_address(self, value):
        self.email_address_editor.setText(value)

    display_name    = property(_get_display_name, _set_display_name)
    username        = property(_get_username, _set_username)
    sip_address     = property(_get_sip_address, _set_sip_address)
    password        = property(_get_password, _set_password)
    verify_password = property(_get_verify_password, _set_verify_password)
    email_address   = property(_get_email_address, _set_email_address)

    del _get_display_name, _set_display_name, _get_username, _set_username
    del _get_sip_address, _set_sip_address, _get_email_address, _set_email_address
    del _get_password, _set_password, _get_verify_password, _set_verify_password

    def _SH_AcceptButtonClicked(self):
        if self.panel_view.currentWidget() is self.add_account_panel:
            account = Account(self.sip_address)
            account.enabled = True
372
            account.display_name = self.display_name or None
373
            account.auth.password = self.password
374 375
            if account.id.domain == 'sip2sip.info':
                account.server.settings_url = "https://blink.sipthor.net/settings.phtml"
376
            account.save()
377 378 379 380 381 382 383 384 385 386 387 388 389 390
            account_manager = AccountManager()
            account_manager.default_account = account
            self.accept()
        else:
            self.setEnabled(False)
            self.create_status_label.value = Status('Creating account on server...')
            self._create_sip_account(self.username, self.password, self.email_address, self.display_name)

    def _SH_PanelChangeRequest(self, index):
        self.panel_view.setCurrentIndex(index)
        if self.panel_view.currentWidget() is self.add_account_panel:
            inputs = [self.display_name_editor, self.sip_address_editor, self.password_editor]
        else:
            inputs = [self.name_editor, self.username_editor, self.new_password_editor, self.verify_password_editor, self.email_address_editor]
391
        self.accept_button.setEnabled(all(input.text_valid for input in inputs))
392 393

    def _SH_PasswordTextChanged(self, text):
Adrian Georgescu's avatar
Adrian Georgescu committed
394
        self.verify_password_editor.regexp = re.compile('^%s$' % re.escape(text))
395 396 397 398

    def _SH_ValidityStatusChanged(self):
        red = '#cc0000'
        # validate the add panel
399
        if not self.display_name_editor.text_valid:
400
            self.add_status_label.value = Status("Display name cannot be empty", color=red)
401
        elif not self.sip_address_editor.text_correct:
402
            self.add_status_label.value = Status("SIP address should be specified as user@domain", color=red)
403
        elif not self.sip_address_editor.text_allowed:
Luci Stanescu's avatar
Luci Stanescu committed
404
            self.add_status_label.value = Status("An account with this SIP address was already added", color=red)
405
        elif not self.password_editor.text_valid:
406 407 408 409
            self.add_status_label.value = Status("Password cannot be empty", color=red)
        else:
            self.add_status_label.value = None
        # validate the create panel
410
        if not self.name_editor.text_valid:
Dan Pascu's avatar
Dan Pascu committed
411
            self.create_status_label.value = Status("Name cannot be empty", color=red)
412
        elif not self.username_editor.text_correct:
413
            self.create_status_label.value = Status("Username should have 5 to 32 characters, start with a letter or non-zero digit, contain only letters, digits or .-_ and end with a letter or digit", color=red)
414 415 416
        elif not self.username_editor.text_allowed:
            self.create_status_label.value = Status("The username you requested is already taken. Please choose another one and try again.", color=red)
        elif not self.new_password_editor.text_valid:
417
            self.create_status_label.value = Status("Password should contain at least 8 characters", color=red)
418
        elif not self.verify_password_editor.text_valid:
419
            self.create_status_label.value = Status("Passwords do not match", color=red)
420
        elif not self.email_address_editor.text_valid:
421 422 423 424 425 426 427 428
            self.create_status_label.value = Status("E-mail address should be specified as user@domain", color=red)
        else:
            self.create_status_label.value = None
        # enable the accept button if everything is valid in the current panel
        if self.panel_view.currentWidget() is self.add_account_panel:
            inputs = [self.display_name_editor, self.sip_address_editor, self.password_editor]
        else:
            inputs = [self.name_editor, self.username_editor, self.new_password_editor, self.verify_password_editor, self.email_address_editor]
429
        self.accept_button.setEnabled(all(input.text_valid for input in inputs))
430 431 432

    def _initialize(self):
        self.display_name = user_info.fullname
Adrian Georgescu's avatar
Adrian Georgescu committed
433 434 435 436 437
        self.username = user_info.username.lower().replace(' ', '.')
        self.sip_address = ''
        self.password = ''
        self.verify_password = ''
        self.email_address = ''
438

439
    @run_in_thread('network-io')
440 441
    def _create_sip_account(self, username, password, email_address, display_name, timezone=None):
        red = '#cc0000'
442
        if timezone is None and sys.platform != 'win32':
443 444 445 446 447 448 449
            try:
                timezone = open('/etc/timezone').read().strip()
            except (OSError, IOError):
                try:
                    timezone = '/'.join(os.readlink('/etc/localtime').split('/')[-2:])
                except (OSError, IOError):
                    pass
450
        enrollment_data = dict(username=username.lower().encode('utf-8'),
451 452 453 454 455 456
                               password=password.encode('utf-8'),
                               email=email_address.encode('utf-8'),
                               display_name=display_name.encode('utf-8'),
                               tzinfo=timezone)
        try:
            settings = SIPSimpleSettings()
Adrian Georgescu's avatar
Adrian Georgescu committed
457 458 459
            data = urllib.parse.urlencode(dict(enrollment_data))
            response = urllib.request.urlopen(settings.server.enrollment_url, data.encode())
            response_data = json.loads(response.read().decode('utf-8').replace(r'\/', '/'))
460 461 462 463 464
            response_data = defaultdict(lambda: None, response_data)
            if response_data['success']:
                try:
                    passport = response_data['passport']
                    if passport is not None:
Dan Pascu's avatar
Dan Pascu committed
465 466 467
                        certificate_path = self._save_certificates(response_data['sip_address'], passport['crt'], passport['key'], passport['ca'])
                    else:
                        certificate_path = None
468
                except (GNUTLSError, IOError, OSError):
Dan Pascu's avatar
Dan Pascu committed
469
                    certificate_path = None
470 471 472
                account_manager = AccountManager()
                try:
                    account = Account(response_data['sip_address'])
473
                except DuplicateIDError:
474
                    account = account_manager.get_account(response_data['sip_address'])
475
                account.enabled = True
476
                account.display_name = display_name or None
477 478 479 480
                account.auth.password = password
                account.sip.outbound_proxy = response_data['outbound_proxy']
                account.nat_traversal.msrp_relay = response_data['msrp_relay']
                account.xcap.xcap_root = response_data['xcap_root']
481
                account.server.conference_server = response_data['conference_server']
482 483 484 485 486
                account.server.settings_url = response_data['settings_url']
                account.save()
                account_manager.default_account = account
                call_in_gui_thread(self.accept)
            elif response_data['error'] == 'user_exists':
487
                call_in_gui_thread(self.username_editor.addException, username)
488 489
            else:
                call_in_gui_thread(setattr, self.create_status_label, 'value', Status(response_data['error_message'], color=red))
Adrian Georgescu's avatar
Adrian Georgescu committed
490
        except (json.decoder.JSONDecodeError, KeyError):
491
            call_in_gui_thread(setattr, self.create_status_label, 'value', Status('Illegal server response', color=red))
Adrian Georgescu's avatar
Adrian Georgescu committed
492
        except urllib.error.URLError as e:
493 494 495 496
            call_in_gui_thread(setattr, self.create_status_label, 'value', Status('Failed to contact server: %s' % e.reason, color=red))
        finally:
            call_in_gui_thread(self.setEnabled, True)

Dan Pascu's avatar
Dan Pascu committed
497 498 499 500 501 502 503 504 505 506 507
    @staticmethod
    def _save_certificates(sip_address, crt, key, ca):
        crt = crt.strip() + os.linesep
        key = key.strip() + os.linesep
        ca = ca.strip() + os.linesep
        X509Certificate(crt)
        X509PrivateKey(key)
        X509Certificate(ca)
        makedirs(ApplicationData.get('tls'))
        certificate_path = ApplicationData.get(os.path.join('tls', sip_address+'.crt'))
        certificate_file = open(certificate_path, 'w')
Adrian Georgescu's avatar
Adrian Georgescu committed
508
        os.chmod(certificate_path, 0o600)
Dan Pascu's avatar
Dan Pascu committed
509 510 511 512 513 514 515 516 517 518 519 520
        certificate_file.write(crt+key)
        certificate_file.close()
        ca_path = ApplicationData.get(os.path.join('tls', 'ca.crt'))
        try:
            existing_cas = open(ca_path).read().strip() + os.linesep
        except:
            certificate_file = open(ca_path, 'w')
            certificate_file.write(ca)
            certificate_file.close()
        else:
            if ca not in existing_cas:
                certificate_file = open(ca_path, 'w')
Tijmen de Mes's avatar
Tijmen de Mes committed
521
                certificate_file.write(existing_cas + ca)
Dan Pascu's avatar
Dan Pascu committed
522 523 524 525 526 527
                certificate_file.close()
        settings = SIPSimpleSettings()
        settings.tls.ca_list = ca_path
        settings.save()
        return certificate_path

528 529 530 531 532 533 534 535 536 537 538
    @run_in_gui_thread
    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_SIPAccountManagerDidAddAccount(self, notification):
        self.sip_address_editor.addException(notification.data.account.id)

    def _NH_SIPAccountManagerDidRemoveAccount(self, notification):
        self.sip_address_editor.removeException(notification.data.account.id)

539 540 541 542 543 544 545 546 547 548 549 550 551 552
    def open_for_add(self):
        self.add_account_button.click()
        self.add_account_button.setFocus()
        self.accept_button.setEnabled(False)
        self._initialize()
        self.show()

    def open_for_create(self):
        self.create_account_button.click()
        self.create_account_button.setFocus()
        self.accept_button.setEnabled(False)
        self._initialize()
        self.show()

Tijmen de Mes's avatar
Tijmen de Mes committed
553

554 555 556
del ui_class, base_class


557 558 559
# Account server tools
#

560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582
class WebPage(QWebPage):
    def __init__(self, parent=None):
        super(WebPage, self).__init__(parent)
        disable_actions = {QWebPage.OpenLink, QWebPage.OpenLinkInNewWindow, QWebPage.OpenLinkInThisWindow, QWebPage.OpenFrameInNewWindow, QWebPage.DownloadLinkToDisk,
                           QWebPage.OpenImageInNewWindow, QWebPage.DownloadImageToDisk, QWebPage.DownloadMediaToDisk}
        for action in (self.action(action) for action in disable_actions):
            action.setVisible(False)

    def createWindow(self, type):
        return self

    def acceptNavigationRequest(self, frame, request, navigation_type):
        if navigation_type == QWebPage.NavigationTypeLinkClicked and self.linkDelegationPolicy() == QWebPage.DontDelegateLinks and request.url().scheme() in ('sip', 'sips'):
            blink = QApplication.instance()
            contact, contact_uri = URIUtils.find_contact(request.url().toString())
            session_manager = SessionManager()
            session_manager.create_session(contact, contact_uri, [StreamDescription('audio')])
            blink.main_window.raise_()
            blink.main_window.activateWindow()
            return False
        return super(WebPage, self).acceptNavigationRequest(frame, request, navigation_type)


583 584 585 586 587 588 589 590 591 592 593 594 595
class ServerToolsAccountModel(QSortFilterProxyModel):
    def __init__(self, model, parent=None):
        super(ServerToolsAccountModel, self).__init__(parent)
        self.setSourceModel(model)
        self.setDynamicSortFilter(True)

    def filterAcceptsRow(self, source_row, source_parent):
        source_model = self.sourceModel()
        source_index = source_model.index(source_row, 0, source_parent)
        account_info = source_model.data(source_index, Qt.UserRole)
        return bool(account_info.account is not BonjourAccount() and account_info.account.enabled and account_info.account.server.settings_url)


596
@implementer(IObserver)
597 598 599 600
class ServerToolsWebView(QWebView):

    def __init__(self, parent=None):
        super(ServerToolsWebView, self).__init__(parent)
601
        self.setPage(WebPage(self))
602 603 604 605 606 607
        self.access_manager = Null
        self.authenticated = False
        self.account = None
        self.user_agent = 'blink'
        self.tab = None
        self.task = None
608
        self.last_error = None
609
        self.realm = None
610
        self.homepage = None
611
        self.urlChanged.connect(self._SH_URLChanged)
612 613
        self.settings().setAttribute(QWebSettings.JavascriptEnabled, True)
        self.settings().setAttribute(QWebSettings.JavascriptCanOpenWindows, True)
614 615 616

    @property
    def query_items(self):
617
        all_items = ('user_agent', 'tab', 'task', 'realm')
Adrian Georgescu's avatar
Adrian Georgescu committed
618
        return [(name, value) for name, value in self.__dict__.items() if name in all_items and value is not None]
619 620 621 622 623 624 625 626 627 628 629 630 631 632 633

    def _get_account(self):
        return self.__dict__['account']

    def _set_account(self, account):
        notification_center = NotificationCenter()
        old_account = self.__dict__.get('account', Null)
        if account is old_account:
            return
        self.__dict__['account'] = account
        self.authenticated = False
        if old_account:
            notification_center.remove_observer(self, sender=old_account)
        if account:
            notification_center.add_observer(self, sender=account)
634 635 636
            self.realm = account.id.domain
        else:
            self.realm = None
637
        self.access_manager.authenticationRequired.disconnect(self._SH_AuthenticationRequired)
638
        self.access_manager.finished.disconnect(self._SH_Finished)
639 640
        self.access_manager = QNetworkAccessManager(self)
        self.access_manager.authenticationRequired.connect(self._SH_AuthenticationRequired)
641
        self.access_manager.finished.connect(self._SH_Finished)
642 643 644 645 646 647 648 649 650 651 652
        self.page().setNetworkAccessManager(self.access_manager)

    account = property(_get_account, _set_account)
    del _get_account, _set_account

    @run_in_gui_thread
    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_CFGSettingsObjectDidChange(self, notification):
653
        if '__id__' in notification.data.modified or 'auth.password' in notification.data.modified:
654 655 656 657 658
            self.authenticated = False
            self.reload()

    def _SH_AuthenticationRequired(self, reply, auth):
        if self.account and not self.authenticated:
659
            auth.setUser(self.account.id.username)
660 661 662 663 664 665 666 667
            auth.setPassword(self.account.auth.password)
            self.authenticated = True
        else:
            # we were already authenticated, yet it asks for the auth again. this means our credentials are not good.
            # we do not provide credentials anymore in order to fail and not try indefinitely, but we also reset the
            # authenticated status so that we try again when the page is reloaded.
            self.authenticated = False

668 669 670 671 672 673
    def _SH_Finished(self, reply):
        if reply.error() != reply.NoError:
            self.last_error = reply.errorString()
        else:
            self.last_error = None

674
    def _SH_URLChanged(self, url):
Dan Pascu's avatar
Dan Pascu committed
675
        query_items = dict(QUrlQuery(url).queryItems())
676 677 678
        self.tab = query_items.get('tab') or self.tab
        self.task = query_items.get('task') or self.task

679
    def load_account_page(self, account, tab=None, task=None, reset_history=False, set_home=False):
680 681 682 683
        self.tab = tab
        self.task = task
        self.account = account
        url = QUrl(account.server.settings_url)
Dan Pascu's avatar
Dan Pascu committed
684
        url_query = QUrlQuery()
685
        for name, value in self.query_items:
Dan Pascu's avatar
Dan Pascu committed
686 687
            url_query.addQueryItem(name, value)
        url.setQuery(url_query)
688 689 690 691 692 693 694 695 696 697
        if set_home:
            self.homepage = url
        if reset_history:
            self.history().clear()
            self.page().mainFrame().evaluateJavaScript('window.location.replace("{}");'.format(url.toString()))  # this will replace the current url in the history
        else:
            self.load(url)

    def load_homepage(self):
        self.load(self.homepage or self.history().itemAt(0).url())
698 699 700 701


ui_class, base_class = uic.loadUiType(Resources.get('server_tools.ui'))

702

703
@implementer(IObserver)
Adrian Georgescu's avatar
Adrian Georgescu committed
704
class ServerToolsWindow(base_class, ui_class, metaclass=QSingleton):
705

706 707 708
    def __init__(self, model, parent=None):
        super(ServerToolsWindow, self).__init__(parent)
        with Resources.directory:
709
            self.setupUi()
Dan Pascu's avatar
Dan Pascu committed
710
        self.setWindowTitle('Blink Server Tools')
711
        self.setWindowIcon(QIcon(Resources.get('icons/blink48.png')))
712 713 714 715
        self.model = model
        self.model.rowsInserted.connect(self._SH_ModelChanged)
        self.model.rowsRemoved.connect(self._SH_ModelChanged)
        self.account_button.menu().triggered.connect(self._SH_AccountButtonMenuTriggered)
716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735
        self.back_button.clicked.connect(self._SH_BackButtonClicked)
        self.back_button.triggered.connect(self._SH_NavigationButtonTriggered)
        self.forward_button.clicked.connect(self._SH_ForwardButtonClicked)
        self.forward_button.triggered.connect(self._SH_NavigationButtonTriggered)
        self.home_button.clicked.connect(self._SH_HomeButtonClicked)
        self.web_view.loadStarted.connect(self._SH_WebViewLoadStarted)
        self.web_view.loadFinished.connect(self._SH_WebViewLoadFinished)
        self.web_view.titleChanged.connect(self._SH_WebViewTitleChanged)
        notification_center = NotificationCenter()
        notification_center.add_observer(self, name='SIPApplicationDidStart')

    def setupUi(self):
        super(ServerToolsWindow, self).setupUi(self)
        self.account_button.default_avatar = QIcon(Resources.get('icons/default-avatar.png'))
        self.account_button.setIcon(IconManager().get('avatar') or self.account_button.default_avatar)
        self.account_button.setMenu(QMenu(self.account_button))
        self.back_button.setMenu(QMenu(self.back_button))
        self.back_button.setEnabled(False)
        self.forward_button.setMenu(QMenu(self.forward_button))
        self.forward_button.setEnabled(False)
736 737

    def _SH_AccountButtonMenuTriggered(self, action):
738
        account = action.data()
739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756
        account_changed = account is not self.web_view.account
        if account_changed:
            self.back_button.setEnabled(False)
            self.forward_button.setEnabled(False)
        self.account_button.setText(account.id)
        self.web_view.load_account_page(account, tab=self.web_view.tab, task=self.web_view.task, reset_history=account_changed, set_home=account_changed)

    def _SH_BackButtonClicked(self):
        self.web_view.history().back()

    def _SH_ForwardButtonClicked(self):
        self.web_view.history().forward()

    def _SH_NavigationButtonTriggered(self, action):
        self.web_view.history().goToItem(action.history_item)

    def _SH_HomeButtonClicked(self):
        self.web_view.load_homepage()
757

758
    def _SH_WebViewLoadStarted(self):
759
        self.spinner.show()
760 761

    def _SH_WebViewLoadFinished(self, load_ok):
762
        self.spinner.hide()
763 764
        if not load_ok:
            icon_path = Resources.get('icons/invalid.png')
765
            error_message = self.web_view.last_error or 'Unknown error'
766 767 768 769 770 771 772 773 774 775 776 777 778 779
            html = """
            <html>
             <head>
              <style>
                .icon    { width: 64px; height: 64px; float: left; }
                .message { margin-left: 74px; line-height: 64px; vertical-align: middle; }
              </style>
             </head>
             <body>
              <img class="icon" src="file:%s" />
              <div class="message">Failed to load web page: <b>%s</b></div>
             </body>
            </html>
            """ % (icon_path, error_message)
780 781 782 783
            self.web_view.blockSignals(True)
            self.web_view.setHtml(html)
            self.web_view.blockSignals(False)
        self._update_navigation_buttons()
784

785
    def _SH_WebViewTitleChanged(self, title):
Adrian Georgescu's avatar
Adrian Georgescu committed
786
        self.window().setWindowTitle('Blink Server Tools: {}'.format(title))
787

788 789 790
    def _SH_ModelChanged(self, parent_index, start, end):
        menu = self.account_button.menu()
        menu.clear()
Adrian Georgescu's avatar
Adrian Georgescu committed
791
        for row in range(self.model.rowCount()):
792
            account_info = self.model.data(self.model.index(row, 0), Qt.UserRole)
793
            action = menu.addAction(account_info.name)
794
            action.setData(account_info.account)
795 796

    def open_settings_page(self, account):
797
        account = account or self.web_view.account
798
        if account is None or account.server.settings_url is None:
799
            account = self.account_button.menu().actions()[0].data()
800 801 802 803 804 805
        account_changed = account is not self.web_view.account
        if account_changed:
            self.back_button.setEnabled(False)
            self.forward_button.setEnabled(False)
        self.account_button.setText(account.id)
        self.web_view.load_account_page(account, tab='settings', reset_history=account_changed, set_home=True)
806 807 808
        self.show()

    def open_search_for_people_page(self, account):
809
        account = account or self.web_view.account
810
        if account is None or account.server.settings_url is None:
811
            account = self.account_button.menu().actions()[0].data()
812 813 814 815 816 817
        account_changed = account is not self.web_view.account
        if account_changed:
            self.back_button.setEnabled(False)
            self.forward_button.setEnabled(False)
        self.account_button.setText(account.id)
        self.web_view.load_account_page(account, tab='contacts', task='directory', reset_history=account_changed, set_home=True)
818 819 820
        self.show()

    def open_history_page(self, account):
821
        account = account or self.web_view.account
822
        if account is None or account.server.settings_url is None:
823
            account = self.account_button.menu().actions()[0].data()
824 825 826 827 828 829
        account_changed = account is not self.web_view.account
        if account_changed:
            self.back_button.setEnabled(False)
            self.forward_button.setEnabled(False)
        self.account_button.setText(account.id)
        self.web_view.load_account_page(account, tab='calls', reset_history=account_changed, set_home=True)
830 831
        self.show()

832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857
    def _update_navigation_buttons(self):
        history = self.web_view.history()
        self.back_button.setEnabled(history.canGoBack())
        self.forward_button.setEnabled(history.canGoForward())
        back_menu = self.back_button.menu()
        back_menu.clear()
        for item in reversed(history.backItems(7)):
            action = back_menu.addAction(item.title())
            action.history_item = item
        forward_menu = self.forward_button.menu()
        forward_menu.clear()
        for item in history.forwardItems(7):
            action = forward_menu.addAction(item.title())
            action.history_item = item

    @run_in_gui_thread
    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_SIPApplicationDidStart(self, notification):
        notification.center.add_observer(self, name='CFGSettingsObjectDidChange', sender=BlinkSettings())

    def _NH_CFGSettingsObjectDidChange(self, notification):
        if 'presence.icon' in notification.data.modified:
            self.account_button.setIcon(IconManager().get('avatar') or self.account_button.default_avatar)
858 859


860
del ui_class, base_class