diff --git a/extension/background.js b/extension/background.js index 4a16974..be295f9 100644 --- a/extension/background.js +++ b/extension/background.js @@ -11,6 +11,13 @@ function isManifestV3() { const extensionUrl = chrome.runtime.getURL('viewer.html'); const httpRedirectRuleId = 1; const fileRedirectRuleId = 2; +const wikiPageAllowRuleId = 3; +const optOutAllowRuleId = 4; + +// Append ?djvu-js=off (or &djvu-js=off) to any URL to make the extension ignore it, +// so that the browser opens it as a usual page. See getOptOutUrl() and initializer.js +const optOutParamName = 'djvu-js'; +const optOutParamValue = 'off'; function updateContextMenu() { chrome.contextMenus.removeAll(); @@ -134,11 +141,65 @@ function enableFileOpeningInterception() { } } +const isOptedOut = url => { + try { + return new URL(url).searchParams.get(optOutParamName) === optOutParamValue; + } catch (e) { + return false; + } +}; + +/* + * MediaWiki serves HTML pages *about* a file at URLs which end with the file name, + * e.g. https://commons.wikimedia.org/wiki/File:Foo.djvu or + * https://en.wikisource.org/wiki/Index:Foo.djvu - these are not DjVu files at all. + * The actual files live on upload.wikimedia.org under /wikipedia/... , which doesn't + * match the /wiki/ prefix below, so real files are still intercepted as usual. + * See https://github.com/RussCoder/djvujs/issues/103 + */ +const wikiDomains = [ + 'wikipedia.org', + 'wikisource.org', + 'wikimedia.org', + 'wikibooks.org', + 'wikiquote.org', + 'wikinews.org', + 'wikiversity.org', + 'wikivoyage.org', + 'wiktionary.org', + 'wikidata.org', + 'wikifunctions.org', + 'mediawiki.org', + 'wikimediafoundation.org', +]; + +// /wiki/ and /w/index.php?title= are the two ways to address a wiki page +const wikiPagePathRegex = /^\/(?:wiki\/|w\/index\.php)/; + +const isWikiPageUrl = url => { + try { + const { hostname, pathname } = new URL(url); + if (hostname === 'upload.wikimedia.org') return false; // that's where the real files are + if (!wikiDomains.some(domain => hostname === domain || hostname.endsWith('.' + domain))) return false; + return wikiPagePathRegex.test(pathname); + } catch (e) { + return false; + } +}; + // it shouldn't be the same function as the file opening interceptor, // since this event listener can be removed independently of the file opening interceptor const requestInterceptor = details => { // http://*/*.djvu also corresponds to "http://localhost/page.php?file=doc.djvu" // so we have to add this additional check, because it's not a link to a file. + if (isOptedOut(details.url)) { + return; // the user explicitly asked to not intercept this URL + } + + if (isWikiPageUrl(details.url)) { + return; // it's an HTML page about a file, not a file + } + if (/\.djvu?$/i.test(new URL(details.url).pathname)) { return { redirectUrl: getViewerUrl(details.url) }; } @@ -152,16 +213,39 @@ const onHeadersReceived = chrome.webRequest?.onHeadersReceived; const enableHttpIntercepting = () => { if (isManifestV3()) { chrome.declarativeNetRequest.updateDynamicRules({ - removeRuleIds: [httpRedirectRuleId], + removeRuleIds: [httpRedirectRuleId, wikiPageAllowRuleId, optOutAllowRuleId], addRules: [{ id: httpRedirectRuleId, + priority: 1, action: { type: 'redirect', redirect: { regexSubstitution: `${extensionUrl}?url=\\0` }, }, condition: { isUrlFilterCaseSensitive: false, - regexFilter: '^https?://[^?]+\\.djvu?(\\?.*)?', + regexFilter: '^https?://[^?]+\\.djvu?(\\?.*)?$', + resourceTypes: ['main_frame', 'sub_frame'], + }, + }, { + // an explicit opt-out marker in the URL - the manual escape hatch + id: optOutAllowRuleId, + priority: 3, + action: { type: 'allow' }, + condition: { + isUrlFilterCaseSensitive: false, + regexFilter: `[?&]${optOutParamName}=${optOutParamValue}(&|$)`, + resourceTypes: ['main_frame', 'sub_frame'], + }, + }, { + // a higher priority "allow" rule wins over the redirect one above + id: wikiPageAllowRuleId, + priority: 2, + action: { type: 'allow' }, + condition: { + isUrlFilterCaseSensitive: false, + regexFilter: '^https?://[^/]+/(wiki/|w/index\\.php)', + requestDomains: wikiDomains, + excludedRequestDomains: ['upload.wikimedia.org'], resourceTypes: ['main_frame', 'sub_frame'], }, }], @@ -196,7 +280,7 @@ const enableHttpIntercepting = () => { const disableHttpIntercepting = () => { if (isManifestV3()) { - chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: [httpRedirectRuleId] }); + chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: [httpRedirectRuleId, wikiPageAllowRuleId, optOutAllowRuleId] }); } else { onBeforeRequest.hasListener(requestInterceptor) && onBeforeRequest.removeListener(requestInterceptor); } diff --git a/extension/initializer.js b/extension/initializer.js index 688a78d..32f28f7 100644 --- a/extension/initializer.js +++ b/extension/initializer.js @@ -1,5 +1,41 @@ 'use strict'; +// Must be kept in sync with background.js +const optOutParamName = 'djvu-js'; +const optOutParamValue = 'off'; + +/** + * The extension decides whether something is a DjVu file by its URL only, so an HTML page + * whose address ends with ".djvu" (e.g. https://commons.wikimedia.org/wiki/File:Foo.djvu) + * may be captured by mistake. Appending this marker to the URL makes the interceptors + * ignore it, so that the browser opens it as a usual page. + * See https://github.com/RussCoder/djvujs/issues/103 + */ +const getOptOutUrl = url => { + const urlObject = new URL(url, location.href); + urlObject.searchParams.set(optOutParamName, optOutParamValue); + return urlObject.href; +}; + +const isOptedOut = url => { + try { + return new URL(url, location.href).searchParams.get(optOutParamName) === optOutParamValue; + } catch (e) { + return false; + } +}; + +const showOptOutLink = url => { + const link = document.createElement('a'); + link.href = getOptOutUrl(url); + link.textContent = "Not a DjVu file? Open this URL as a usual web page →"; + link.style.cssText = 'position:fixed;left:0;right:0;bottom:0;z-index:2147483647;' + + 'padding:10px 12px;text-align:center;font:14px sans-serif;' + + 'background:#ffe9a8;color:#333;text-decoration:underline;' + + 'border-top:1px solid #d9c37e;'; + document.body.appendChild(link); +}; + window.onload = () => { const viewer = new DjVu.Viewer({ uiOptions: { @@ -17,7 +53,20 @@ window.onload = () => { }); const params = new URLSearchParams(location.search.slice(1)); - if (params.get('url')) { - viewer.loadDocumentByUrl(params.get('url'), params.get('name') ? { name: params.get('name') } : undefined); - } -}; \ No newline at end of file + const url = params.get('url'); + if (!url) return; + + viewer.loadDocumentByUrl(url, params.get('name') ? { name: params.get('name') } : undefined) + .then(() => { + // the promise is resolved even if the loading has failed, so check the state + let hasFailed = false; + try { + hasFailed = !!DjVu.Viewer.get.error(viewer.store.getState()); + } catch (e) { + return; // the viewer internals have changed - just leave the error as is + } + if (hasFailed && !isOptedOut(url)) { + showOptOutLink(url); + } + }); +};