This repository has been archived on 2023-02-02. You can view files and clone it, but cannot push or open issues or pull requests.
barkshark-web/barkshark_web/component/web_tab_webview_handler.py

705 lines
22 KiB
Python

import asyncio, functools, json, mimetypes, ftplib
from izzylib.exceptions import HttpClientError
from jinja2.exceptions import TemplateNotFound
from json.decoder import JSONDecodeError
from urllib.parse import urlparse, quote, unquote
from ssl import SSLCertVerificationError
from .. import cache, var
from ..enums import EditAction, Javascript, WebviewContextActions
from ..functions import ComponentBase, Thread, connect, run_in_gui_thread
from ..widgets import FileChooser
#context_menu_actions = {
#'audio_copy': WebKit2.ContextMenuAction.COPY_AUDIO_LINK_TO_CLIPBOARD,
#'audio_download': WebKit2.ContextMenuAction.DOWNLOAD_AUDIO_TO_DISK,
#'frame_open': WebKit2.ContextMenuAction.OPEN_FRAME_IN_NEW_WINDOW,
#'audio_tab': WebKit2.ContextMenuAction.OPEN_AUDIO_IN_NEW_WINDOW,
#'go_back': WebKit2.ContextMenuAction.GO_BACK,
#'go_forward': WebKit2.ContextMenuAction.GO_FORWARD,
#'go_reload': WebKit2.ContextMenuAction.RELOAD,
#'go_stop': WebKit2.ContextMenuAction.STOP,
#'image_copy': WebKit2.ContextMenuAction.COPY_IMAGE_URL_TO_CLIPBOARD,
#'image_copy-full': WebKit2.ContextMenuAction.COPY_IMAGE_TO_CLIPBOARD,
#'image_download': WebKit2.ContextMenuAction.DOWNLOAD_IMAGE_TO_DISK,
#'image_tab': WebKit2.ContextMenuAction.OPEN_IMAGE_IN_NEW_WINDOW,
#'inspect_element': WebKit2.ContextMenuAction.INSPECT_ELEMENT,
#'link_copy': WebKit2.ContextMenuAction.COPY_LINK_TO_CLIPBOARD,
#'link_download': WebKit2.ContextMenuAction.DOWNLOAD_LINK_TO_DISK,
#'link_open': WebKit2.ContextMenuAction.OPEN_LINK,
#'link_tab': WebKit2.ContextMenuAction.OPEN_LINK_IN_NEW_WINDOW,
#'media_control': WebKit2.ContextMenuAction.TOGGLE_MEDIA_CONTROLS,
#'media_mute': WebKit2.ContextMenuAction.MEDIA_MUTE,
#'media_loop': WebKit2.ContextMenuAction.TOGGLE_MEDIA_LOOP,
#'media_pause': WebKit2.ContextMenuAction.MEDIA_PAUSE,
#'spell_add': WebKit2.ContextMenuAction.LEARN_SPELLING,
#'media_play': WebKit2.ContextMenuAction.MEDIA_PLAY,
#'spell_guess': WebKit2.ContextMenuAction.SPELLING_GUESS,
#'spell_ignore-grammar': WebKit2.ContextMenuAction.IGNORE_GRAMMAR,
#'spell_ignore-spell': WebKit2.ContextMenuAction.IGNORE_SPELLING,
#'spell_none': WebKit2.ContextMenuAction.NO_GUESSES_FOUND,
#'text_bold': WebKit2.ContextMenuAction.BOLD,
#'text_copy': WebKit2.ContextMenuAction.COPY,
#'text_cut': WebKit2.ContextMenuAction.CUT,
#'text_delete': WebKit2.ContextMenuAction.DELETE,
#'text_emoji': WebKit2.ContextMenuAction.INSERT_EMOJI,
#'text_italic': WebKit2.ContextMenuAction.ITALIC,
#'text_paste': WebKit2.ContextMenuAction.PASTE,
#'text_paste-plain': WebKit2.ContextMenuAction.PASTE_AS_PLAIN_TEXT,
#'text_select': WebKit2.ContextMenuAction.SELECT_ALL,
#'text_underline': WebKit2.ContextMenuAction.UNDERLINE,
#'video_copy': WebKit2.ContextMenuAction.COPY_VIDEO_LINK_TO_CLIPBOARD,
#'video_download': WebKit2.ContextMenuAction.DOWNLOAD_VIDEO_TO_DISK,
#'video_fullscreen': WebKit2.ContextMenuAction.ENTER_VIDEO_FULLSCREEN,
#'video_tab': WebKit2.ContextMenuAction.OPEN_VIDEO_IN_NEW_WINDOW,
#}
cert_error_msg = {
'unknown-ca': 'Unknown certificate authority for url: {url}',
'bad-identity': 'Certificate domain does not match: {url}'
}
class WebviewHandler(ComponentBase):
def __init__(self, tab):
ComponentBase.__init__(self)
self.tab = tab
self.inspector_open = False
signals = {
'close': {'handler': self.tab.close},
'context-menu': {'handler': self.handle_context_menu},
'create': {'handler': self.handle_new_window},
'decide-policy': {'handler': self.handle_decide_policy},
'enter-fullscreen': {'handler': self.handle_fullscreen, 'args': ['enter']},
#'insecure-content-detected': {'handler': self.handle_insecure_content},
'leave-fullscreen': {'handler': self.handle_fullscreen, 'args': ['exit']},
'load-changed': {'handler': self.handle_load_changed},
'load-failed-with-tls-errors': {'handler': self.handle_tls_error},
'mouse-target-changed': {'handler': self.handle_mouse_hover},
'resource-load-started': {'handler': self.handle_resource_load},
'script-dialog': {'handler': self.handle_script_dialog},
#'show-notification': {'handler': self.handle_notification},
#'submit-form': {'handler': self.handle_submit_form},
'permission-request': {'handler': self.handle_permission_request},
'web-process-terminated': {'handler': self.handle_tab_crashed},
'notify::estimated-load-progress': {'handler': self.handle_load_progress},
'notify::favicon': {'handler': self.tab.set_favicon, 'kwargs': {'original_args': False}},
'notify::title': {'handler': self.tab.set_title, 'kwargs': {'original_args': False}},
'notify::uri': {'handler': self.tab.set_url, 'kwargs': {'original_args': False}}
}
for signal, data in signals.items():
if data.get('kwargs', {}).get('original_args') == None:
data['kwargs'] = {'original_args': True}
connect(self.webview, signal, data['handler'], *data.get('args', ''), **data['kwargs'])
connect(self.inspector, 'attach', self.handle_inspector, 'attach', original_args=True)
connect(self.inspector, 'detach', self.handle_inspector, 'detach', original_args=True)
connect(self.inspector, 'closed', self.handle_inspector, 'close', original_args=True)
@property
def inspector(self):
return self.webview.get_inspector()
@property
def settings(self):
return self.tab.settings
@property
def webview(self):
return self.tab.webview
def handle_context_menu(self, webview, context_menu, event, hit):
menu = ContextMenuClass(context_menu, self.tab)
url = DotDict(
page = webview.get_uri(),
link = hit.get_link_uri(),
image = hit.get_image_uri(),
media = hit.get_media_uri()
)
data = DotDict(
link_text = hit.get_link_label(),
selection = hit.context_is_selection(),
editable = hit.context_is_editable(),
link = hit.context_is_link(),
media = hit.context_is_media(),
image = hit.context_is_image(),
scrollbar = hit.context_is_scrollbar()
)
for key, value in url.items():
if not value:
continue
url[key] = Url(value)
if data.link:
menu.new_action('link_open', 'Open Link', self.tab.load_url, url.link)
menu.new_action('link_new', 'Open Link In New Tab', self.window.tab_new, url.link, switch=True)
menu.new_action('link_bg', 'Open Link In Background Tab', self.window.tab_new, url.link)
menu.new_stock('link_copy', 'Copy Link Url', WebviewContextActions.LINK_COPY)
menu.new_stock('link_dl', 'Download Link', WebviewContextActions.LINK_DOWNLOAD)
menu.new_sep()
if data.media:
menu.new_stock('media_play', 'Play', WebviewContextActions.MEDIA_PLAY)
menu.new_stock('media_pause', 'Pause', WebviewContextActions.MEDIA_PAUSE)
menu.new_stock('media_loop', 'Toggle Loop', WebviewContextActions.MEDIA_LOOP)
menu.new_stock('media_ctrl', 'Toggle Controls', WebviewContextActions.MEDIA_TOGGLE)
menu.new_action('media_open', 'Open Media', self.tab.load_url, url.media)
menu.new_action('media_new', 'Open Media In New Tab', self.window.tab_new, url.media, switch=True)
menu.new_action('media_bg', 'Open Media In Background Tab', self.window.tab_new, url.media)
menu.new_action('media_dl', 'Download Media', self.app.context.download_uri, url.media)
menu.new_sep()
if data.image:
menu.new_action('image_open', 'Open Image', self.tab.load_url, url.image)
menu.new_action('image_new', 'Open Image In New Tab', self.window.tab_new, url.image, switch=True)
menu.new_action('image_bg', 'Open Image In Background Tab', self.window.tab_new, url.image)
menu.new_stock('image_dl', 'Download Image', WebviewContextActions.IMAGE_DOWNLOAD)
menu.new_stock('image_url', 'Copy Image Url', WebviewContextActions.IMAGE_COPY)
menu.new_stock('image_copy', 'Copy Image', WebviewContextActions.IMAGE_COPY_FULL)
menu.new_sep()
if data.editable:
menu.new_webview_action('edit_undo', 'Undo', 'Undo')
menu.new_webview_action('edit_redo', 'Redo', 'Redo')
menu.new_stock('edit_paste', 'Paste', WebviewContextActions.TEXT_PASTE)
menu.new_stock('edit_plain', 'Paste Plain Text', WebviewContextActions.TEXT_PASTE_PLAIN)
if data.selection:
menu.new_stock('edit_copy', 'Copy', WebviewContextActions.TEXT_COPY)
if data.selection and data.editable:
menu.new_stock('edit_cut', 'Cut', WebviewContextActions.TEXT_CUT)
menu.new_stock('edit_delete', 'Delete', WebviewContextActions.TEXT_DELETE)
menu.new_stock('edit_select', 'Select All', WebviewContextActions.TEXT_SELECT)
if data.selection or data.editable:
menu.new_sep()
if data.selection:
menu.new_action(
'search',
'Search or Go',
self.tab.run_js,
Javascript.SELECTION,
self.callback_new_tab
)
search_menu = menu.new_sub('search_menu', 'Search via..')
with self.app.db.session as s:
default = s.get_config('default_search')
for row in sorted(s.fetch('search'), key=lambda x:x['name']):
search_menu.new_action(
row.keyword,
row.name,
self.tab.run_js,
Javascript.SELECTION,
self.callback_search,
row.keyword
)
menu.new_sep()
if url.page.proto != var.local:
menu.new_action('print', 'Print Page', self.tab.page_action, 'print')
if url.page.proto in ['http', 'https']:
menu.new_action('source', 'View Page Source', self.tab.page_action, 'source')
menu.new_stock('inspect', 'Inspect Element', WebviewContextActions.INSPECT)
return False
def handle_decide_policy(self, webview, decision, decision_type):
url = self.tab.url
if decision_type == WebKit2.PolicyDecisionType.NAVIGATION_ACTION:
if url.proto == 'file':
decision.ignore()
webview.load_uri(url.replace_property('proto', 'local'))
return True
elif url.proto == 'ftp':
decision.ignore()
webview.load_uri(url.replace_property('proto', 'filetp'))
return True
## Can't just pass oauth:// urls to mastodon's oauth, so here's a workaround
if url.startswith('https://pyweb-oauth'):
url = url.replace('https://pyweb-oauth', 'oauth://', 1)
webview.load_uri(url)
decision.ignore()
return True
try:
if self.tab.url.top in ['facebook.com']:
decision.ignore()
return True
except:
pass
if not url.startswith(var.local):
with self.app.db.session as s:
permissions = s.get_permission(url.hostname())
decision.use_with_policies(WebKit2.WebsitePolicies(autoplay = bool(permissions.autoplay)))
def handle_fullscreen(self, webview, state):
domain = self.tab.url.domain
navbar = self.tab['navbar']
statusbar = self.window['statusbar']
tabs = self.window['tabs']
with self.app.db.session as s:
row = s.get_permission(domain)
if not row.fullscreen:
self.window.notification(f'Denied fullscreen for domain: {domain}', 'error')
return True
# the navbar and status bars don't hide for some reason
if state == 'enter':
self.window.fullscreen()
navbar.hide()
statusbar.hide()
tabs.set_show_tabs(False)
else:
self.window.unfullscreen()
navbar.show()
statusbar.show()
tabs.set_show_tabs(True)
def handle_insecure_content(self, webview, event):
logging.debug('insecure-content:', event.value_nick)
return True
def handle_inspector(self, inspector, action):
with self.app.db.session as s:
config = s.get_config('detach_inspector')
if action == 'attach':
if config and inspector.is_attached():
inspector.detach()
return True
elif action == 'detach':
if not config and not inspector.is_attached():
inspector.attach()
return True
else:
self.inspector_open = False
return
self.inspector_open = True
def handle_load_changed(self, webview, event):
if not (url := self.tab.url):
return
## Only enable the page cache for http(s) urls
self.settings.set('enable-page-cache', url.proto in ['http', 'https'])
try:
host = url.hostname()
except AttributeError:
host = url
with self.app.db.session as s:
permissions = s.get_permission(host)
if event.value_nick == 'started':
self.tab._data.favicon = False
self.settings.set('media-playback-requires-user-gesture', not permissions.autoplay)
self.tab['navbar-stop'].set_sensitive(True)
self.tab['navbar-refresh'].set_sensitive(False)
self.tab.search_action('close')
elif event.value_nick == 'committed':
self.tab.set_button_state()
elif event.value_nick == 'redirected':
logging.debug(f'handle_load_changed: redirect {url}')
pass
elif event.value_nick == 'finished':
if isinstance(url, Url):
if url.proto == 'https':
FediverseCheck(None, self.tab, permissions).start()
elif url.proto in ['http', 'sftp', 'filetp']:
with self.app.db.session as s:
s.put_history_from_tab(self.tab)
if self.window.active_tab == self.tab:
self.window.set_button_state(self.tab.id)
if not self.tab._data.favicon:
self.tab.set_favicon()
def handle_load_failed(self, webview, event, uri, error):
logging.error('Load failed:', uri, error.value_nick)
def handle_load_progress(self, webview, _):
progress = webview.get_estimated_load_progress()
finished = True if progress == 1.0 else False
self.tab['navbar-url'].set_progress_fraction(0 if progress == 1.0 else progress)
self.tab['navbar-stop'].set_sensitive(not finished)
self.tab['navbar-refresh'].set_sensitive(finished)
def handle_mouse_hover(self, webview, hit, _):
link_url = hit.get_property('link-uri')
self.window['statusbar-url'].set_text(link_url if link_url else '')
def handle_resource_load(self, webview, resource, request):
resource.connect('failed', self.handle_resource_failed)
resource.connect('finished', self.handle_resource_finished)
def handle_resource_failed(self, resource, error):
try:
url = Url(resource.get_uri())
except Exception as e:
print(f'{class_name(e)}: {e}')
return
if error.domain == 'WebKitPolicyError' and 'interrupted' in error.message.lower():
if not url.mimetype:
self.window.notification(f'Cannot open url: {url}', 'warning')
elif not self.webview.can_show_mime_type(url.mimetype):
self.app.context.download_uri(url)
return
if error.domain not in ['HandlerError', 'soup-http-error-quark']:
if error.domain == 'WebKitNetworkError' and error.message.lower() == 'load request cancelled':
return
logging.debug(f'''Resource failed to load: {resource.get_uri()}
- domain: {error.domain}
- message: {error.message}''')
def handle_resource_finished(self, resource):
return
def handle_new_window(self, webview, action):
navtype = action.get_navigation_type()
user_action = action.is_user_gesture()
url = action.get_request().get_uri()
if not user_action:
logging.debug('handle_new_window: Not a user action: "{url}"')
return
if navtype in [WebKit2.NavigationType.FORM_SUBMITTED, WebKit2.NavigationType.LINK_CLICKED, WebKit2.NavigationType.OTHER]:
self.window.tab_new(url, switch=True)
else:
logging.debug('handle_new_window: unhandled nav type:', navtype)
def handle_notification(self, webview, notif):
try:
domain = self.tab.url.domain
## Not a url, so deny permission
except AttributeError:
return True
with self.app.db.session as s:
row = s.get_permission(domain)
if not row.notification:
logging.debug(f'Denied notification for domain: {domain}')
return True
def handle_permission_request(self, webview, permrequest):
try:
domain = self.tab.url.domain
## Not a url, so deny perlmission
except AttributeError:
permrequest.deny()
return
with self.db.session as s:
row = s.get_permission(domain)
if type(permrequest) == WebKit2.NotificationPermissionRequest and row.notification:
logging.verbose(f'Allowed notification permission request from {domain}')
permrequest.allow()
elif type(permrequest) == WebKit2.GeolocationPermissionRequest and row.location:
logging.verbose(f'Allowed location permission request from {domain}')
permrequest.allow()
elif type(permrequest) == WebKit2.UserMediaPermissionRequest:
if permrequest.props.is_for_audio_device and row.microphone:
logging.verbose(f'Allowed microphone permission request from {domain}')
permrequest.allow()
elif permrequest.props.is_for_video_device and row.camera:
logging.verbose(f'Allowed webcam permission request from {domain}')
permrequest.allow()
else:
logging.verbose(f'Rejected permission request from {domain}: {type(permrequest)}')
permrequest.deny()
def handle_script_dialog(self, webview, dialog):
try:
domain = self.tab.url.domain
except:
return True
with self.db.session as s:
row = s.get_permission(domain)
if not row.dialog:
logging.verbose('JS dialogs not allowed for domain:', domain)
return True
## calling list_text_fields causes a crash half the time
def handle_submit_form(self, webview, form):
form_data = form.list_text_fields()
data = DotDict()
if form_data[0]:
keys = form_data[1]
values = form_data[2]
for index, key in enumerate(keys):
data[key] = values[index]
form.submit()
def handle_tab_crashed(self, webview, reason):
reasons = {
'crashed': 'Web process crashed. Reload the page.',
'exceeded_memory_limit': 'Web process exceeded the memory limit. Reload the page.'
}
self.window.notification(reasons[reason.value_nick], 'ERROR')
def handle_tls_error(self, webview, url, cert, error):
url = Url(url)
err = error.first_value_nick
with self.db.session as s:
if url.address.is_type('private') and s.get_config('allow_local_unsigned'):
self.tab.context.allow_tls_certificate_for_host(cert, url.domain)
webview.load_uri(url)
return True
msg = cert_error_msg.get(err, 'TLS error: {url}')
if not msg:
logging.debug('handlers.signal.TlsError: Unhandled error:', err)
context = {'error_message': msg.format(url=url), 'title': 'TLS Error'}
webview.load_html(self.app.template.render('error.haml', context))
return True
def handle_set_url(self, webview, url):
self.tab.set_url()
def handle_set_favicon(self, *_):
favicondb = self.window.context.get_favicon_database()
favicondb.get_favicon(self.tab.url, None, self.callback_set_favicon)
def handle_set_title(self, *_):
self.tab.set_title()
def callback_new_tab(self, url, switch=True):
if not url:
self.window.notification('No selection found', 'error')
return
self.window.new_tab(url, switch=switch)
def callback_search(self, text, key=None):
if not text:
self.window.notification('No selection found', 'error')
return
with self.app.db.session as s:
keyword = key or s.get_config('default_search')
search = s.get_search(keyword)
self.window.new_tab(search.compile(text), switch=True)
def callback_set_favicon(self, favicondb, task):
try:
favicon = favicondb.get_favicon_finish(task)
except GLib.Error:
favicon = None
self.tab.favicon = favicon
## I'd like to subclass WebKit2.ContextMenu if possible
class ContextMenuClass(DotDict):
def __init__(self, menu, tab):
super().__init__()
self.menu = menu or WebKit2.ContextMenu.new()
self.tab = tab
self.window = tab.window
self.webview = tab.webview
self.menu.remove_all()
def new_action(self, name, label, callback, *args, **kwargs):
action = Gio.SimpleAction(name=name)
connect(action, 'activate', callback, *args, **kwargs)
item = WebKit2.ContextMenuItem().new_from_gaction(action, label, None)
self[name] = item
self.menu.append(item)
def new_stock(self, name, label, action):
if not isinstance(action, WebviewContextActions):
raise TypeError(f'Webview context action must be a WebviewContextActions, not {class_name(action)}')
#action = WebviewContextActions[action.upper()]
item = WebKit2.ContextMenuItem.new_from_stock_action_with_label(action.value, label)
self[name] = item
self.menu.append(item)
def new_webview_action(self, name, label, action):
action = Gio.SimpleAction(name=name)
connect(action, 'activate', self.webview.execute_editing_command, action)
item = WebKit2.ContextMenuItem().new_from_gaction(action, label, None)
self[name] = item
self.menu.append(item)
## I'll probably have to mess with the class itself to use this properly
def new_sub(self, name, label):
menu = WebKit2.ContextMenu.new()
menu_class = ContextMenuClass(menu, self.tab)
self[name] = menu_class
self.menu.append(WebKit2.ContextMenuItem.new_with_submenu(label, menu))
return menu_class
def new_sep(self):
self.menu.append(WebKit2.ContextMenuItem.new_separator())
class FediverseCheck(Thread):
def run_func(self, tab, permissions):
acct = tab.app.get_default_account()
url = tab.url
post = {}
with tab.db.session as s:
history_row = s.get_history(url)
is_post = history_row.post if history_row else None
nodeinfo = None
if not acct:
s.put_history_from_tab(tab)
return
if s.get_config('ap_check') and permissions.instance == None:
try:
nodeinfo = tab.app.http_client.fetch_nodeinfo(permissions.domain)
except SSLCertVerificationError:
logging.verbose(f'FediverseCheck: SSL Error for domain: {url.domain}')
return
except ConnectionRefusedError:
logging.verbose(f'FediverseCheck: Failed to connect to domain: {url.domain}')
return
except HttpClientError as e:
if e.status in range(500, 600):
logging.verbose(f'FediverseCheck: Server error: {e.status}: {e.message}')
return
if e.status == 404:
is_post = False
except JSONDecodeError:
is_post = False
except Exception as e:
logging.error(f'FediverseCheck: {type(e).__name__}: {e}')
return
is_instance = True if nodeinfo and nodeinfo.get('software') else False
logging.debug(f'Result of instance check for {permissions.domain}: {is_instance}')
permissions = s.put_permission(permissions.domain, 'instance', is_instance)
if s.get_config('post_check') and permissions and permissions.instance and (is_post in [None, True]):
if url.path.startswith('/web'):
is_post = False
elif not (post := cache.posts.fetch(url)):
post = acct.fetch_post(url)
cache.posts.store(url, post)
is_post = True if post else False
logging.verbose(f'Result of post check for {url}: {is_post}')
s.put_history(url, tab.title, is_post)
tab._data.post = post