/* global API */// msg.js /* global CHROME URLS ignoreChromeError */// toolbox.js /* global prefs */ 'use strict'; (() => { const idCSP = 'patchCsp'; const idOFF = 'disableAll'; const idXHR = 'styleViaXhr'; const rxHOST = /^('none'|(https?:\/\/)?[^']+?[^:'])$/; // strips CSP sources covered by * const blobUrlPrefix = 'blob:' + chrome.runtime.getURL('/'); /** @type {Object} */ const stylesToPass = {}; const state = {}; const injectedCode = CHROME && `${data => { if (self.INJECTED !== 1) { // storing data only if apply.js hasn't run yet window[Symbol.for('styles')] = data; } }}`; toggle(); prefs.subscribe([idXHR, idOFF, idCSP], toggle); function toggle() { const off = prefs.get(idOFF); const csp = prefs.get(idCSP) && !off; const xhr = prefs.get(idXHR) && !off; if (xhr === state.xhr && csp === state.csp && off === state.off) { return; } const reqFilter = { urls: ['*://*/*'], types: ['main_frame', 'sub_frame'], }; chrome.webNavigation.onCommitted.removeListener(injectData); chrome.webRequest.onBeforeRequest.removeListener(prepareStyles); chrome.webRequest.onHeadersReceived.removeListener(modifyHeaders); if (xhr || csp) { // We unregistered it above so that the optional EXTRA_HEADERS is properly re-registered chrome.webRequest.onHeadersReceived.addListener(modifyHeaders, reqFilter, [ 'blocking', 'responseHeaders', xhr && chrome.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS, ].filter(Boolean)); } if (CHROME ? !off : xhr || csp) { chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter); } if (CHROME && !off) { chrome.webNavigation.onCommitted.addListener(injectData, {url: [{urlPrefix: 'http'}]}); } if (CHROME) { chrome.webRequest.onBeforeRequest.addListener(openNamedStyle, { urls: [URLS.ownOrigin + '*.user.css'], types: ['main_frame'], }, ['blocking']); } state.csp = csp; state.off = off; state.xhr = xhr; } /** @param {chrome.webRequest.WebRequestBodyDetails} req */ async function prepareStyles(req) { const sections = await API.styles.getSectionsByUrl(req.url); stylesToPass[req2key(req)] = /** @namespace StylesToPass */ { blobId: '', str: JSON.stringify(sections), timer: setTimeout(cleanUp, 600e3, req), }; } function injectData(req) { const data = stylesToPass[req2key(req)]; if (data && !data.injected) { data.injected = true; chrome.tabs.executeScript(req.tabId, { frameId: req.frameId, runAt: 'document_start', code: `(${injectedCode})(${data.str})`, }, ignoreChromeError); if (!state.xhr) cleanUp(req); } } /** @param {chrome.webRequest.WebResponseHeadersDetails} req */ function modifyHeaders(req) { const {responseHeaders} = req; const data = stylesToPass[req2key(req)]; if (!data || data.str === '{}') { cleanUp(req); return; } if (state.xhr) { data.blobId = URL.createObjectURL(new Blob([data.str])).slice(blobUrlPrefix.length); responseHeaders.push({ name: 'Set-Cookie', value: `${chrome.runtime.id}=${data.blobId}`, }); } const csp = state.csp && responseHeaders.find(h => h.name.toLowerCase() === 'content-security-policy'); if (csp) { patchCsp(csp); } if (state.xhr || csp) { return {responseHeaders}; } } /** @param {chrome.webRequest.HttpHeader} csp */ function patchCsp(csp) { const src = {}; for (let p of csp.value.split(';')) { p = p.trim().split(/\s+/); src[p[0]] = p.slice(1); } // Allow style assets patchCspSrc(src, 'img-src', 'data:', '*'); patchCspSrc(src, 'font-src', 'data:', '*'); // Allow our DOM styles, allow @import from any URL patchCspSrc(src, 'style-src', "'unsafe-inline'", '*'); // Allow our XHR cookies in CSP sandbox (known case: raw github urls) if (src.sandbox && !src.sandbox.includes('allow-same-origin')) { src.sandbox.push('allow-same-origin'); } csp.value = Object.entries(src).map(([k, v]) => `${k}${v.length ? ' ' : ''}${v.join(' ')}`).join('; '); } function patchCspSrc(src, name, ...values) { let def = src['default-src']; let list = src[name]; if (def || list) { if (!def) def = []; if (!list) list = [...def]; if (values.includes('*')) list = src[name] = list.filter(v => !rxHOST.test(v)); list.push(...values.filter(v => !list.includes(v) && !def.includes(v))); if (!list.length) delete src[name]; } } function cleanUp(req) { const key = req2key(req); const data = stylesToPass[key]; if (data) { delete stylesToPass[key]; clearTimeout(data.timer); if (data.blobId) { URL.revokeObjectURL(blobUrlPrefix + data.blobId); } } } /** @param {chrome.webRequest.WebRequestBodyDetails} req */ function openNamedStyle(req) { if (!req.url.includes('?')) { // skipping our usercss installer chrome.tabs.update(req.tabId, {url: 'edit.html?id=' + req.url.split('#')[1]}); return {cancel: true}; } } function req2key(req) { return req.tabId + ':' + req.frameId; } })();