/* global getStyleWithNoCode styleSectionsEqual */ 'use strict'; const RX_NAMESPACE = /\s*(@namespace\s+(?:\S+\s+)?url\(http:\/\/.*?\);)\s*/g; const RX_CHARSET = /\s*@charset\s+(['"]).*?\1\s*;\s*/g; const RX_CSS_COMMENTS = /\/\*[\s\S]*?(?:\*\/|$)/g; // eslint-disable-next-line no-var var SLOPPY_REGEXP_PREFIX = '\0'; // CSS transition bug workaround: since we insert styles asynchronously, // the browsers, especially Firefox, may apply all transitions on page load const CSS_TRANSITION_SUPPRESSOR = '* { transition: none !important; }'; const RX_CSS_TRANSITION_DETECTOR = /([\s\n;/{]|-webkit-|-moz-)transition[\s\n]*:[\s\n]*(?!none)/; // Note, only 'var'-declared variables are visible from another extension page // eslint-disable-next-line no-var var cachedStyles = { list: null, // array of all styles byId: new Map(), // all styles indexed by id filters: new Map(), // filterStyles() parameters mapped to the returned results, 10k max regexps: new Map(), // compiled style regexps exclusions: new Map(), // compiled exclusion regexps urlDomains: new Map(), // getDomain() results for 100 last checked urls needTransitionPatch: new Map(), // FF bug workaround mutex: { inProgress: true, // while getStyles() is reading IndexedDB all subsequent calls // (initially 'true' to prevent rogue getStyles before dbExec.initialized) onDone: [], // to getStyles() are queued and resolved when the first one finishes }, }; function getStyles(options) { if (cachedStyles.list) { return Promise.resolve(filterStyles(options)); } if (cachedStyles.mutex.inProgress) { return new Promise(resolve => { cachedStyles.mutex.onDone.push({options, resolve}); }); } cachedStyles.mutex.inProgress = true; } function filterStyles({ enabled = null, id = null, matchUrl = null, md5Url = null, asHash = null, omitCode, strictRegexp = true, // used by the popup to detect bad regexps } = {}) { if (matchUrl && !URLS.supported(matchUrl)) { return asHash ? {length: 0} : []; } // make sure to use the same order in updateFiltersCache() const cacheKey = enabled + '\t' + id + '\t' + matchUrl + '\t' + md5Url + '\t' + asHash + '\t' + strictRegexp; const cached = cachedStyles.filters.get(cacheKey); let styles; if (cached) { cached.hits++; cached.lastHit = Date.now(); styles = asHash ? Object.assign(blankHash, cached.styles) : cached.styles.slice(); } else { styles = filterStylesInternal({ enabled, id, matchUrl, md5Url, asHash, strictRegexp, blankHash, cacheKey, omitCode, }); } if (!omitCode) return styles; if (!asHash) return styles.map(getStyleWithNoCode); for (const id in styles) { const sections = styles[id]; if (Array.isArray(sections)) { styles[id] = getStyleWithNoCode({sections}).sections; } } return styles; } function filterStylesInternal({ // js engines don't like big functions (V8 often deoptimized the original filterStyles) // it also makes sense to extract the less frequently executed code enabled, id, matchUrl, md5Url, asHash, strictRegexp, blankHash, cacheKey, omitCode, }) { if (matchUrl && !cachedStyles.urlDomains.has(matchUrl)) { cachedStyles.urlDomains.set(matchUrl, getDomains(matchUrl)); for (let i = cachedStyles.urlDomains.size - 100; i > 0; i--) { const firstKey = cachedStyles.urlDomains.keys().next().value; cachedStyles.urlDomains.delete(firstKey); } } const filtered = asHash ? {length: 0} : []; const needSections = asHash || matchUrl !== null; const matchUrlBase = matchUrl && matchUrl.includes('#') && matchUrl.split('#', 1)[0]; let style; for (let i = 0; (style = styles[i]); i++) { if ((enabled === null || style.enabled === enabled) && (md5Url === null || style.md5Url === md5Url) && (id === null || style.id === id)) { const sections = needSections && getApplicableSections({ style, matchUrl, strictRegexp, stopOnFirst: !asHash, skipUrlCheck: true, matchUrlBase, omitCode, }); if (asHash) { if (sections.length && sections[0] !== '') { sections.unshift({included: !isPageExcluded(matchUrl, style.exclusions)}); filtered[style.id] = sections; filtered.length++; } } else if (matchUrl === null || sections.length) { filtered.push(style); } } } cachedStyles.filters.set(cacheKey, { styles: filtered, lastHit: Date.now(), hits: 1, }); if (cachedStyles.filters.size > 10000) { cleanupCachedFilters(); } // a shallow copy is needed because the cache doesn't store options like disableAll return asHash ? Object.assign(blankHash, filtered) : filtered; } function saveStyle(style) { } function deleteStyle({id, notify = true}) { id = Number(id); return dbExec('delete', id).then(() => { invalidateCache({deletedId: id}); if (notify) { notifyAllTabs({method: 'styleDeleted', id}); } return id; }); } function compileExclusionRegexps(exclusions) { exclusions.forEach(exclusion => { if (!cachedStyles.exclusions.get(exclusion)) { cachedStyles.exclusions.set(exclusion, tryRegExp(exclusion) || false); } }); } function isPageExcluded(matchUrl, exclusions = {}) { const keys = Object.keys(exclusions); if (!keys.length) { return false; } compileExclusionRegexps(keys); return keys.some(exclude => { const rx = cachedStyles.exclusions.get(exclude); return rx && rx.test(matchUrl); }); } function getApplicableSections({ style, matchUrl, strictRegexp = true, // filterStylesInternal() sets the following to avoid recalc on each style: stopOnFirst, skipUrlCheck, matchUrlBase = matchUrl.includes('#') && matchUrl.split('#', 1)[0], omitCode, // as per spec the fragment portion is ignored in @-moz-document: // https://www.w3.org/TR/2012/WD-css3-conditional-20120911/#url-of-doc // but the spec is outdated and doesn't account for SPA sites // so we only respect it in case of url("http://exact.url/without/hash") }) { const excluded = isPageExcluded(matchUrl, style.exclusions); if (!skipUrlCheck && !URLS.supported(matchUrl) || omitCode !== false && excluded) { return []; } // Show excluded style in popup if (excluded) { return [{}]; } const sections = []; for (const section of style.sections) { const {urls, domains, urlPrefixes, regexps, code} = section; const isGlobal = !urls.length && !urlPrefixes.length && !domains.length && !regexps.length; const isMatching = !isGlobal && ( urls.length && (urls.includes(matchUrl) || matchUrlBase && urls.includes(matchUrlBase)) || urlPrefixes.length && arraySomeIsPrefix(urlPrefixes, matchUrl) || domains.length && arraySomeIn(cachedStyles.urlDomains.get(matchUrl) || getDomains(matchUrl), domains) || regexps.length && arraySomeMatches(regexps, matchUrl, strictRegexp)); if (isGlobal && !styleCodeEmpty(code) || isMatching) { sections.push(section); if (stopOnFirst) { break; } } } return sections; function arraySomeIsPrefix(array, string) { for (const prefix of array) { if (string.startsWith(prefix)) { return true; } } return false; } function arraySomeIn(array, haystack) { for (const el of array) { if (haystack.indexOf(el) >= 0) { return true; } } return false; } function arraySomeMatches(array, matchUrl, strictRegexp) { for (const regexp of array) { for (let pass = 1; pass <= (strictRegexp ? 1 : 2); pass++) { const cacheKey = pass === 1 ? regexp : SLOPPY_REGEXP_PREFIX + regexp; let rx = cachedStyles.regexps.get(cacheKey); if (rx === false) { // invalid regexp break; } if (!rx) { const anchored = pass === 1 ? '^(?:' + regexp + ')$' : '^' + regexp + '$'; rx = tryRegExp(anchored); cachedStyles.regexps.set(cacheKey, rx || false); if (!rx) { // invalid regexp break; } } if (rx.test(matchUrl)) { return true; } } } return false; } } function styleCodeEmpty(code) { // Collect the global section if it's not empty, not comment-only, not namespace-only. const cmtOpen = code && code.indexOf('/*'); if (cmtOpen >= 0) { const cmtCloseLast = code.lastIndexOf('*/'); if (cmtCloseLast < 0) { code = code.substr(0, cmtOpen); } else { code = code.substr(0, cmtOpen) + code.substring(cmtOpen, cmtCloseLast + 2).replace(RX_CSS_COMMENTS, '') + code.substr(cmtCloseLast + 2); } } if (!code || !code.trim()) return true; if (code.includes('@namespace')) code = code.replace(RX_NAMESPACE, '').trim(); if (code.includes('@charset')) code = code.replace(RX_CHARSET, '').trim(); return !code; } function invalidateCache({added, updated, deletedId} = {}) { if (!cachedStyles.list) return; const id = added ? added.id : updated ? updated.id : deletedId; const cached = cachedStyles.byId.get(id); if (updated) { if (cached) { const isSectionGlobal = section => !section.urls.length && !section.urlPrefixes.length && !section.domains.length && !section.regexps.length; const hadOrHasGlobals = cached.sections.some(isSectionGlobal) || updated.sections.some(isSectionGlobal); const reenabled = !cached.enabled && updated.enabled; const equal = !hadOrHasGlobals && !reenabled && styleSectionsEqual(updated, cached, {ignoreCode: true}); Object.assign(cached, updated); if (equal) { updateFiltersCache(cached); } else { cachedStyles.filters.clear(); } cachedStyles.needTransitionPatch.delete(id); return; } else { added = updated; } } if (added) { if (!cached) { cachedStyles.list.push(added); cachedStyles.byId.set(added.id, added); cachedStyles.filters.clear(); cachedStyles.needTransitionPatch.delete(id); } return; } if (deletedId !== undefined) { if (cached) { const cachedIndex = cachedStyles.list.indexOf(cached); cachedStyles.list.splice(cachedIndex, 1); cachedStyles.byId.delete(deletedId); for (const {styles} of cachedStyles.filters.values()) { if (Array.isArray(styles)) { const index = styles.findIndex(({id}) => id === deletedId); if (index >= 0) styles.splice(index, 1); } else if (deletedId in styles) { delete styles[deletedId]; styles.length--; } } cachedStyles.needTransitionPatch.delete(id); return; } } cachedStyles.list = null; cachedStyles.filters.clear(); cachedStyles.needTransitionPatch.clear(id); } function updateFiltersCache(style) { const {id} = style; for (const [key, {styles}] of cachedStyles.filters.entries()) { if (Array.isArray(styles)) { const index = styles.findIndex(style => style.id === id); if (index >= 0) styles[index] = Object.assign({}, style); continue; } if (id in styles) { const [, , matchUrl, , , strictRegexp] = key.split('\t'); if (!style.enabled) { delete styles[id]; styles.length--; continue; } const matchUrlBase = matchUrl && matchUrl.includes('#') && matchUrl.split('#', 1)[0]; const sections = getApplicableSections({ style, matchUrl, matchUrlBase, strictRegexp, skipUrlCheck: true, omitCode: false }); if (sections.length) { styles[id] = sections; } else { delete styles[id]; styles.length--; } } } } function cleanupCachedFilters({force = false} = {}) { if (!force) { debounce(cleanupCachedFilters, 1000, {force: true}); return; } const size = cachedStyles.filters.size; const oldestHit = cachedStyles.filters.values().next().value.lastHit; const now = Date.now(); const timeSpan = now - oldestHit; const recencyWeight = 5 / size; const hitWeight = 1 / 4; // we make ~4 hits per URL const lastHitWeight = 10; // delete the oldest 10% [...cachedStyles.filters.entries()] .map(([id, v], index) => ({ id, weight: index * recencyWeight + v.hits * hitWeight + (v.lastHit - oldestHit) / timeSpan * lastHitWeight, })) .sort((a, b) => a.weight - b.weight) .slice(0, size / 10 + 1) .forEach(({id}) => cachedStyles.filters.delete(id)); } function getDomains(url) { let d = /.*?:\/*([^/:]+)|$/.exec(url)[1]; if (!d || url.startsWith('file:')) { return []; } const domains = [d]; while (d.indexOf('.') !== -1) { d = d.substring(d.indexOf('.') + 1); domains.push(d); } return domains; } function normalizeStyleSections({sections}) { // retain known properties in an arbitrarily predefined order return (sections || []).map(section => ({ code: section.code || '', urls: section.urls || [], urlPrefixes: section.urlPrefixes || [], domains: section.domains || [], regexps: section.regexps || [], })); } function calcStyleDigest(style) { const jsonString = style.usercssData ? style.sourceCode : JSON.stringify(normalizeStyleSections(style)); const text = new TextEncoder('utf-8').encode(jsonString); return crypto.subtle.digest('SHA-1', text).then(hex); function hex(buffer) { const parts = []; const PAD8 = '00000000'; const view = new DataView(buffer); for (let i = 0; i < view.byteLength; i += 4) { parts.push((PAD8 + view.getUint32(i).toString(16)).slice(-8)); } return parts.join(''); } } function handleCssTransitionBug({tabId, frameId, url, styles}) { for (let id in styles) { id |= 0; if (!id) { continue; } let need = cachedStyles.needTransitionPatch.get(id); if (need === false) { continue; } if (need !== true) { need = styles[id].some(sectionContainsTransitions); cachedStyles.needTransitionPatch.set(id, need); if (!need) { continue; } } if (FIREFOX && !url.startsWith(URLS.ownOrigin)) { patchFirefox(); } else { styles.needTransitionPatch = true; } break; } function patchFirefox() { const options = { frameId, code: CSS_TRANSITION_SUPPRESSOR, matchAboutBlank: true, }; if (FIREFOX >= 53) { options.cssOrigin = 'user'; } browser.tabs.insertCSS(tabId, Object.assign(options, { runAt: 'document_start', })).then(() => setTimeout(() => { browser.tabs.removeCSS(tabId, options).catch(ignoreChromeError); })).catch(ignoreChromeError); } function sectionContainsTransitions(section) { let code = section.code; const firstTransition = code.indexOf('transition'); if (firstTransition < 0) { return false; } const firstCmt = code.indexOf('/*'); // check the part before the first comment if (firstCmt < 0 || firstTransition < firstCmt) { if (quickCheckAround(code, firstTransition)) { return true; } else if (firstCmt < 0) { return false; } } // check the rest const lastCmt = code.lastIndexOf('*/'); if (lastCmt < firstCmt) { // the comment is unclosed and we already checked the preceding part return false; } let mid = code.slice(firstCmt, lastCmt + 2); mid = mid.indexOf('*/') === mid.length - 2 ? '' : mid.replace(RX_CSS_COMMENTS, ''); code = mid + code.slice(lastCmt + 2); return quickCheckAround(code) || RX_CSS_TRANSITION_DETECTOR.test(code); } function quickCheckAround(code, pos = code.indexOf('transition')) { return RX_CSS_TRANSITION_DETECTOR.test(code.substr(Math.max(0, pos - 10), 50)); } } /* According to CSS4 @document specification the entire URL must match. Stylish-for-Chrome implemented it incorrectly since the very beginning. We'll detect styles that abuse the bug by finding the sections that would have been applied by Stylish but not by us as we follow the spec. Additionally we'll check for invalid regexps. */ function detectSloppyRegexps({matchUrl, ids}) { const results = []; for (const id of ids) { const style = cachedStyles.byId.get(id); if (!style) continue; // make sure all regexps are compiled const rxCache = cachedStyles.regexps; let hasRegExp = false; for (const section of style.sections) { for (const regexp of section.regexps) { hasRegExp = true; for (let pass = 1; pass <= 2; pass++) { const cacheKey = pass === 1 ? regexp : SLOPPY_REGEXP_PREFIX + regexp; if (!rxCache.has(cacheKey)) { // according to CSS4 @document specification the entire URL must match const anchored = pass === 1 ? '^(?:' + regexp + ')$' : '^' + regexp + '$'; // create in the bg context to avoid leaking of "dead objects" const rx = tryRegExp(anchored); rxCache.set(cacheKey, rx || false); } } } } if (!hasRegExp) continue; const applied = getApplicableSections({style, matchUrl, omitCode: false}); const wannabe = getApplicableSections({style, matchUrl, omitCode: false, strictRegexp: false}); if (wannabe.length && wannabe[0] !== '') { results.push({ id, applied, skipped: wannabe.length - applied.length, hasInvalidRegexps: wannabe.some(({regexps}) => regexps.some(rx => !rxCache.has(rx))), }); } } return results; }