Skip to content

Commit

Permalink
Open external links and PDFs outside zimit sandbox #1183 (#1201)
Browse files Browse the repository at this point in the history
  • Loading branch information
Jaifroid authored Jan 24, 2024
1 parent a0a898a commit 30e6215
Show file tree
Hide file tree
Showing 3 changed files with 145 additions and 71 deletions.
168 changes: 114 additions & 54 deletions www/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* app.js : The main Kiwix User Interface implementation
* This file handles the interaction between the Kiwix JS back end and the user
*
* Copyright 2013-2023 Mossroy, Jaifroid and contributors
* Copyright 2013-2024 Mossroy, Jaifroid and contributors
* Licence GPL v3:
*
* This file is part of Kiwix.
Expand Down Expand Up @@ -1991,6 +1991,51 @@ function articleLoader () {
}
}

// Add event listener to iframe window to check for links to external resources
function filterClickEvent (event) {
// Find the closest enclosing A tag (if any)
var clickedAnchor = uiUtil.closestAnchorEnclosingElement(event.target);
// If the anchor has a passthrough property, then we have already checked it is safe, so we can return
if (clickedAnchor && clickedAnchor.passthrough) {
clickedAnchor.passthrough = false;
return;
}
if (clickedAnchor) {
// Check for Zimit links that would normally be handled by the Replay Worker
// DEV: '__WB_pmw' is a function inserted by wombat.js, so this detects links that have been rewritten in zimit2 archives
if (appstate.isReplayWorkerAvailable || '__WB_pmw' in clickedAnchor) {
return handleClickOnReplayLink(event, clickedAnchor);
}
var href = clickedAnchor.getAttribute('href');
// We assume that, if an absolute http(s) link is hardcoded inside an HTML string, it means it's a link to an external website.
// We also do it for ftp even if it's not supported any more by recent browsers...
if (/^(?:http|ftp)/i.test(href)) {
console.debug('filterClickEvent opening external link in new tab');
clickedAnchor.newcontainer = true;
uiUtil.warnAndOpenExternalLinkInNewTab(event, clickedAnchor);
} else if (/\.pdf([?#]|$)/i.test(href) && selectedArchive.zimType !== 'zimit') {
// Due to the iframe sandbox, we have to prevent the PDF viewer from opening in the iframe and instead open it in a new tab
event.preventDefault();
event.stopPropagation();
console.debug('filterClickEvent opening new window for PDF');
clickedAnchor.newcontainer = true;
window.open(clickedAnchor.href, '_blank');
} else if (/\/[-ABCIJMUVWX]\/.+$/.test(clickedAnchor.href)) {
// Show the spinner if it's a ZIM link, but not an anchor
if (!~href.indexOf('#')) {
var message = href.match(/(?:^|\/)([^/]{1,13})[^/]*?$/);
message = message ? message[1] + '...' : '...';
uiUtil.spinnerDisplay(true, (translateUI.t('spinner-loading') || 'Loading') + ' ' + message);
// In case of false positive, ensure spinner is eventually hidden
setTimeout(function () {
uiUtil.spinnerDisplay(false);
}, 4000);
uiUtil.showSlidingUIElements();
}
}
}
};

/**
* Postprocessing required after the article contents are loaded
* @param {HTMLIFrameElement} iframeArticleContent The iframe containing the article content
Expand Down Expand Up @@ -2022,45 +2067,7 @@ function articleLoadedSW (iframeArticleContent) {
if (params.useHomeKeyToFocusSearchBar) { iframeArticleContent.contentWindow.onkeydown = focusPrefixOnHomeKey; }
if (params.openExternalLinksInNewTabs) {
// Add event listener to iframe window to check for links to external resources
iframeArticleContent.contentWindow.onclick = function (event) {
// Find the closest enclosing A tag (if any)
var clickedAnchor = uiUtil.closestAnchorEnclosingElement(event.target);
// If the anchor has a passthrough property, then we have already checked it is safe, so we can return
if (clickedAnchor && clickedAnchor.passthrough) {
clickedAnchor.passthrough = false;
return;
}
if (clickedAnchor) {
// Check for Zimit links that would normally be handled by the Replay Worker
if (appstate.isReplayWorkerAvailable) {
handleClickOnReplayLink(event, clickedAnchor);
return;
}
// We assume that, if an absolute http(s) link is hardcoded inside an HTML string,
// it means it's a link to an external website.
// We also do it for ftp even if it's not supported any more by recent browsers...
var href = clickedAnchor.getAttribute('href');
if (/^(?:http|ftp)/i.test(href)) {
uiUtil.warnAndOpenExternalLinkInNewTab(event, clickedAnchor);
} else if (/\.pdf([?#]|$)/i.test(href) && selectedArchive.zimType !== 'zimit') {
// Due to the iframe sandbox, we have to prevent the PDF viewer from opening in the iframe and instead open it in a new tab
event.preventDefault();
window.open(clickedAnchor.href, '_blank');
} else if (/\/[-ABCIJMUVWX]\/.+$/.test(clickedAnchor.href)) {
// Show the spinner if it's a ZIM link, but not an anchor
if (!~href.indexOf('#')) {
var message = href.match(/(?:^|\/)([^/]{1,13})[^/]*?$/);
message = message ? message[1] + '...' : '...';
uiUtil.spinnerDisplay(true, (translateUI.t('spinner-loading') || 'Loading') + ' ' + message);
// In case of false positive, ensure spinner is eventually hidden
setTimeout(function () {
uiUtil.spinnerDisplay(false);
}, 4000);
uiUtil.showSlidingUIElements();
}
}
}
};
iframeArticleContent.contentWindow.onclick = filterClickEvent;
}
// Reset UI when the article is unloaded
iframeArticleContent.contentWindow.onunload = function () {
Expand All @@ -2080,45 +2087,98 @@ function articleLoadedSW (iframeArticleContent) {
// Handles a click on a Zimit link that has been processed by Wombat
function handleClickOnReplayLink (ev, anchor) {
var pseudoNamespace = selectedArchive.zimitPseudoContentNamespace;
var pseudoDomainPath = anchor.hostname + anchor.pathname;
var pseudoDomainPath = (anchor.hostname === window.location.hostname ? selectedArchive.zimitPrefix.replace(/\/$/, '') : anchor.hostname) + anchor.pathname;
var containingDocDomainPath = anchor.ownerDocument.location.hostname + anchor.ownerDocument.location.pathname;
// If it's for a different protocol (e.g. javascript:) we should let Replay handle that, or if the paths are identical, then we are dealing
// with a link to an anchor in the same document, or if the user has pressed the ctrl or command key, the document will open in a new window
// anyway, so we can return. Note that some PDFs are served with a protocol of http: instead of https:, so we need to account for that.
if (anchor.protocol.replace(/s:/, ':') !== document.location.protocol.replace(/s:/, ':') || pseudoDomainPath === containingDocDomainPath ||
ev.ctrlKey || ev.metaKey || ev.button === 1) return;
if (anchor.protocol.replace(/s:/, ':') !== document.location.protocol.replace(/s:/, ':') || pseudoDomainPath === containingDocDomainPath) return;
var zimUrl = pseudoNamespace + pseudoDomainPath + anchor.search;
// We are dealing with a ZIM link transformed by Wombat, so we need to reconstruct the ZIM link
if (zimUrl) {
ev.preventDefault();
ev.stopPropagation();
selectedArchive.getDirEntryByPath(zimUrl).then(function (dirEntry) {
// Note that true in the fourth argument instructs getDirEntryByPath to follow redirects by looking up the Header
// DEV: CURRENTLY NON-FUNCTION IN KIWIX-JS -- NEEDS FIXING
return selectedArchive.getDirEntryByPath(zimUrl, null, null, true).then(function (dirEntry) {
if (dirEntry) {
var pathToArticleDocumentRoot = document.location.href.replace(/www\/index\.html.*$/, selectedArchive.file.name + '/');
var mimetype = dirEntry.getMimetype();
// Due to the iframe sandbox, we have to prevent the PDF viewer from opening in the iframe and instead open it in a new tab
// Note that some Replay PDFs have html mimetypes, or can be redirects to PDFs, we need to check the URL as well
if (/pdf/i.test(mimetype) || /\.pdf(?:[#?]|$)/i.test(anchor.href) || /\.pdf(?:[#?]|$)/i.test(dirEntry.url)) {
window.open(pathToArticleDocumentRoot + zimUrl, '_blank');
/*
} else if (/\bx?html\b/i.test(mimetype)) {
// If the SW has gone to sleep, loading this way gives it a chance to reload configuration
params.isLandingPage = false;
readArticle(dirEntry); */
if (/Android/.test(params.appType) || window.nw) {
// User is on an Android device, where opening a PDF in a new tab is not sufficient to evade the sandbox
// so we need to download the PDF instead
var readAndDownloadBinaryContent = function (zimUrl) {
return selectedArchive.getDirEntryByPath(zimUrl).then(function (dirEntry) {
if (dirEntry) {
selectedArchive.readBinaryFile(dirEntry, function (fileDirEntry, content) {
var mimetype = fileDirEntry.getMimetype();
uiUtil.displayFileDownloadAlert(zimUrl, true, mimetype, content);
uiUtil.clearSpinner();
});
} else {
return uiUtil.systemAlert('We could not find a PDF document at ' + zimUrl, 'PDF not found');
}
});
};
// If the document is in fact an html redirect, we need to follow it first till we get the underlying PDF document
if (/\bx?html\b/.test(mimetype)) {
selectedArchive.readUtf8File(dirEntry, function (fileDirEntry, data) {
var redirectURL = data.match(/<meta[^>]*http-equiv="refresh"[^>]*content="[^;]*;url='?([^"']+)/i);
if (redirectURL) {
redirectURL = redirectURL[1];
var contentUrl = pseudoNamespace + redirectURL.replace(/^[^/]+\/\//, '');
return readAndDownloadBinaryContent(contentUrl);
} else {
return readAndDownloadBinaryContent(zimUrl);
}
});
} else {
return readAndDownloadBinaryContent(zimUrl);
}
} else {
window.open(pathToArticleDocumentRoot + zimUrl, params.windowOpener === 'tab' ? '_blank' : dirEntry.title,
params.windowOpener === 'window' ? 'toolbar=0,location=0,menubar=0,width=800,height=600,resizable=1,scrollbars=1' : null);
}
} else {
// Fingers crossed, let Replay handle this link
anchor.passthrough = true;
// Handle middle-clicks and ctrl-clicks (these should be filtered out above, but...)
// Handle middle-clicks and ctrl-clicks
if (ev.ctrlKey || ev.metaKey || ev.button === 1) {
window.open(pathToArticleDocumentRoot + zimUrl, '_blank');
var encodedTitle = encodeURIComponent(dirEntry.getTitleOrUrl());
var articleContainer = window.open(pathToArticleDocumentRoot + zimUrl,
params.windowOpener === 'tab' ? '_blank' : encodedTitle,
params.windowOpener === 'window' ? 'toolbar=0,location=0,menubar=0,width=800,height=600,resizable=1,scrollbars=1' : null
);
// Conditional, because opening a new window can be blocked by the browser
if (articleContainer) {
appstate.target = 'window';
articleContainer.kiwixType = appstate.target;
}
uiUtil.clearSpinner();
} else {
// Let Replay handle this link
anchor.passthrough = true;
articleContainer = document.getElementById('articleContent');
appstate.target = 'iframe';
articleContainer.kiwixType = appstate.target;
if (selectedArchive.zimType === 'zimit2') {
// Since we know the URL works, normalize the href (this is needed for zimit2 relative links)
// NB We mustn't do this for zimit classic because it breaks wombat rewriting of absolute links!
anchor.href = pathToArticleDocumentRoot + zimUrl;
}
anchor.click();
// Poll spinner with abbreviated title
uiUtil.spinnerDisplay(true, 'Loading ' + dirEntry.getTitleOrUrl().replace(/([^/]+)$/, '$1').substring(0, 18) + '...');
}
}
} else {
// If dirEntry was not-found, it's probably an external link, so warn user before opening a new tab/window
uiUtil.warnAndOpenExternalLinkInNewTab(null, anchor);
}
}).catch(function (err) {
console.error('Error getting dirEntry for ' + zimUrl, err);
appstate.isReplayWorkerAvailable = true;
});
}
}
Expand Down
7 changes: 6 additions & 1 deletion www/js/lib/uiUtil.js
Original file line number Diff line number Diff line change
Expand Up @@ -944,17 +944,22 @@ function reportSearchProviderToAPIStatusPanel (provider) {
}

/**
* Warn the user that he/she clicked on an external link, and open it in a new tab
* Warn the user that they clicked on an external link, and open it in a new tab
*
* @param {Event} event The click event (on an anchor) to handle. If not provided, then clickedAnchor must be provided.
* @param {Element} clickedAnchor The DOM anchor that has been clicked (optional, defaults to event.target).
*/
function warnAndOpenExternalLinkInNewTab (event, clickedAnchor) {
if (event) {
// We have to prevent any blank target from firing on the original event
event.target.removeAttribute('target');
event.preventDefault();
event.stopPropagation();
}
if (!clickedAnchor) clickedAnchor = event.target;
if (articleContainer.contentWindow && clickedAnchor.origin === articleContainer.contentWindow.location.origin) {
clickedAnchor.href = clickedAnchor.href.replace(clickedAnchor.origin, appstate.selectedArchive.source.replace(/\/$/, ''));
}
var target = clickedAnchor.target;
var message = translateUI.t('dialog-open-externalurl-message') || '<p>Do you want to open this external link?';
if (!target || target === '_blank') {
Expand Down
Loading

0 comments on commit 30e6215

Please sign in to comment.