This repository has been archived on 2022-10-14. You can view files and clone it, but cannot push or open issues/pull-requests.
melonybot/resources/metastream-extension-0.4.0_0/background.js

691 lines
21 KiB
JavaScript

'use strict'
//
// The background script provides monitoring of tabs for an active Metastream
// webapp. When activated, web requests originating from the app are modified
// to allow bypassing browser security limitations. This script also provides
// message brokering between embedded websites and the app itself.
//
//=============================================================================
// Helpers
//=============================================================================
const TOP_FRAME = 0
const HEADER_PREFIX = 'x-metastream'
const METASTREAM_APP_URL = 'https://app.getmetastream.com'
const isMetastreamUrl = url =>
url.startsWith(METASTREAM_APP_URL) ||
url.startsWith('http://local.getmetastream.com') ||
url.startsWith('http://localhost:8080') ||
url.startsWith('https://localhost:8080')
const isTopFrame = details => details.frameId === TOP_FRAME
const isDirectChild = details => details.parentFrameId === TOP_FRAME
const isValidAction = action => typeof action === 'object' && typeof action.type === 'string'
const isFirefox = () => navigator.userAgent.toLowerCase().includes('firefox')
const asyncTimeout = (promise, timeout = 5000) => {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeout))
])
}
const escapePattern = pattern => pattern.replace(/[\\^$+?.()|[\]{}]/g, '\\$&')
// Check whether pattern matches.
// https://developer.chrome.com/extensions/match_patterns
const matchesPattern = function(url, pattern) {
if (pattern === '<all_urls>') return true
const regexp = new RegExp(
`^${pattern
.split('*')
.map(escapePattern)
.join('.*')}$`
)
return url.match(regexp)
}
// Memoized frame paths
const framePaths = {}
// Get path from top level frame to subframe.
const getFramePath = async (tabId, frameId) => {
if (framePaths[frameId]) return framePaths[frameId]
let path = [frameId]
let currentFrameId = frameId
while (currentFrameId > 0) {
const result = await new Promise(resolve => {
const details = { tabId, frameId: currentFrameId }
chrome.webNavigation.getFrame(details, details => {
if (chrome.runtime.lastError) {
console.error(`Error in getFramePath: ${chrome.runtime.lastError.message}`)
resolve()
return
}
resolve(details)
})
})
if (!result) return []
const { parentFrameId } = result
path.push(parentFrameId)
currentFrameId = parentFrameId
}
path = path.reverse()
framePaths[frameId] = path
return path
}
const sendToFrame = (tabId, frameId, message) =>
chrome.tabs.sendMessage(tabId, message, { frameId })
const sendToHost = (tabId, message) => sendToFrame(tabId, TOP_FRAME, message)
const sendWebviewEventToHost = async (tabId, frameId, message) => {
const framePath = await getFramePath(tabId, frameId)
sendToHost(
tabId,
{ type: 'metastream-webview-event', payload: message, framePath },
{ frameId: TOP_FRAME }
)
}
//=============================================================================
// Locals
//=============================================================================
// Observed tabs on Metastream URL
const watchedTabs = new Set()
// Store for active tabs state
const tabStore = {}
// Used to know which metastream instance to sent browser badge requests to
let lastActiveTabId
// Map from popup webview ID to parent tab ID
// Used for popups pending initialization
const popupParents = {}
// Map from popup tab ID to parent tab ID
// Used for routing messages between popup and parent app
const popupParentTabs = {}
// List of popup tab IDs
const popupTabs = new Set()
//=============================================================================
// Content scripts
//=============================================================================
const CONTENT_SCRIPTS = [
{
matches: ['https://*.netflix.com/*'],
file: '/scripts/netflix.js'
},
{
matches: ['https://*.hulu.com/*'],
file: '/scripts/hulu.js'
},
{
matches: ['https://www.dcuniverse.com/*'],
file: '/scripts/dcuniverse.js'
},
{
matches: ['https://docs.google.com/*', 'https://drive.google.com/*'],
file: '/scripts/googledrive.js'
},
{
matches: ['https://www.disneyplus.com/*'],
file: '/scripts/disneyplus.js'
}
]
//=============================================================================
// Event listeners
//=============================================================================
// Add Metastream header overwrites
const onBeforeSendHeaders = details => {
const { tabId, requestHeaders: headers } = details
const shouldModify = (watchedTabs.has(tabId) && isTopFrame(details)) || tabId === -1
if (shouldModify) {
for (let i = headers.length - 1; i >= 0; --i) {
const header = headers[i].name.toLowerCase()
if (header.startsWith(HEADER_PREFIX)) {
const name = header.substr(HEADER_PREFIX.length + 1)
const value = headers[i].value
headers.push({ name, value })
headers.splice(i, 1)
}
}
}
return { requestHeaders: headers }
}
// Allow embedding any website in Metastream iframe
const onHeadersReceived = details => {
const { tabId, frameId, responseHeaders: headers } = details
let permitted = false
const isMetastreamTab = watchedTabs.has(tabId) && isDirectChild(details)
const isServiceWorkerRequest = watchedTabs.size > 0 && tabId === -1 && frameId === -1
const shouldModify = isMetastreamTab || isServiceWorkerRequest
// TODO: HTTP 301 redirects don't get captured. Try https://reddit.com/
if (shouldModify) {
for (let i = headers.length - 1; i >= 0; --i) {
const header = headers[i].name.toLowerCase()
const value = headers[i].value
if (header === 'x-frame-options' || header === 'frame-options') {
headers.splice(i, 1)
permitted = true
} else if (header === 'content-security-policy' && value.includes('frame-ancestors')) {
const policies = value.split(';').filter(value => !value.includes('frame-ancestors'))
headers[i].value = policies.join(';')
permitted = true
}
}
}
if (permitted) {
console.log(`Permitting iframe embedded in tabId=${tabId}, url=${details.url}`)
}
return { responseHeaders: headers }
}
const onTabRemove = (tabId, removeInfo) => {
if (watchedTabs.has(tabId)) {
stopWatchingTab(tabId)
}
}
const onBeforeNavigate = details => {
const { tabId, frameId, url } = details
if (!watchedTabs.has(tabId)) return
if (isTopFrame(frameId)) return
;(async () => {
const framePath = await getFramePath(tabId, frameId)
const isWebviewFrame = framePath[1] === frameId
if (isWebviewFrame) {
sendWebviewEventToHost(tabId, frameId, { type: 'will-navigate', payload: { url } })
}
})()
}
// Programmatically inject content scripts into Metastream subframes
const initScripts = details => {
const { tabId, frameId, url } = details
if (url.startsWith('about:blank?webview')) {
initializeWebview(details)
return
}
if (!watchedTabs.has(tabId)) return
if (isTopFrame(details) && !popupTabs.has(tabId)) {
// Listen for top frame navigating away from Metastream
if (!isMetastreamUrl(details.url)) {
stopWatchingTab(tabId)
}
} else {
injectContentScripts(details)
}
}
const onCompleted = details => {
const { tabId, frameId, url } = details
if (!watchedTabs.has(tabId)) return
if (isTopFrame(frameId)) return
;(async () => {
const framePath = await getFramePath(tabId, frameId)
const isWebviewFrame = framePath[1] === frameId
if (isWebviewFrame) {
sendWebviewEventToHost(tabId, frameId, { type: 'did-navigate', payload: { url } })
}
})()
}
const onHistoryStateUpdated = details => {
const { tabId, frameId, url } = details
if (!watchedTabs.has(tabId)) return
if (isTopFrame(frameId)) return
;(async () => {
const framePath = await getFramePath(tabId, frameId)
const isWebviewFrame = framePath[1] === frameId
if (isWebviewFrame) {
sendWebviewEventToHost(tabId, frameId, { type: 'did-navigate-in-page', payload: { url } })
}
})()
}
const initializeWebview = details => {
console.log('Initialize webview', details)
const { tabId, frameId, url } = details
const { searchParams } = new URL(url)
const isPopup = searchParams.get('popup') === 'true'
const webviewId = searchParams.get('webview')
let hostId
if (isPopup) {
const parentTabId = popupParents[webviewId]
if (!parentTabId) {
console.error(`No parent tab ID found for popup webview #${webviewId}`)
return
}
hostId = parentTabId
popupTabs.add(tabId)
watchedTabs.add(tabId)
popupParentTabs[tabId] = hostId
} else if (watchedTabs.has(tabId)) {
hostId = tabId
} else {
console.warn(`Ignoring webview with tabId=${tabId}, frameId=${frameId}`)
return
}
sendToHost(hostId, { type: `metastream-webview-init${webviewId}`, payload: { tabId, frameId } })
const tabState = tabStore[hostId]
const allowScripts = searchParams.get('allowScripts') === 'true'
if (allowScripts && tabState && !isPopup) {
tabState.scriptableFrames.add(frameId)
}
}
// TODO: error injecting scripts into popup windows
const executeScript = (opts, attempt = 0) => {
chrome.tabs.executeScript(
opts.tabId,
{
file: opts.file,
runAt: opts.runAt || 'document_start',
frameId: opts.frameId
},
result => {
if (chrome.runtime.lastError) {
console.log(`executeScript error [${opts.file}]: ${chrome.runtime.lastError.message}`)
if (opts.retry !== false) {
if (attempt < 20) {
setTimeout(() => executeScript(opts, attempt + 1), 5)
} else {
console.error('Reached max attempts while injecting content script.', opts)
}
} else {
console.error('Failed to inject content script', chrome.runtime.lastError, opts)
}
} else {
console.log(`executeScript ${opts.file}`)
}
}
)
}
const injectContentScripts = async details => {
const { tabId, frameId, url } = details
if (url === 'about:blank') return
// Inject common webview script
executeScript({ tabId, frameId, file: '/webview.js' })
const framePath = await getFramePath(tabId, frameId)
const topIFrameId = framePath[1]
const tabState = tabStore[tabId]
const scriptable =
(tabState && tabState.scriptableFrames.has(topIFrameId)) || popupTabs.has(tabId)
if (scriptable) {
console.log(`Injecting player script tabId=${tabId}, frameId=${frameId}, url=${url}`)
executeScript({ tabId, frameId, file: '/player.js' })
CONTENT_SCRIPTS.forEach(script => {
if (!script.matches.some(matchesPattern.bind(null, url))) return
executeScript({ tabId, frameId, file: script.file })
})
}
}
//=============================================================================
// Metastream tab management
//=============================================================================
const startWatchingTab = tab => {
const { id: tabId } = tab
console.log(`Metastream watching tabId=${tabId}`)
watchedTabs.add(tabId)
const state = {
// Webview frames which allow scripts to be injected
scriptableFrames: new Set(),
// Event handlers
onHeadersReceived: onHeadersReceived.bind(null)
}
tabStore[tabId] = state
chrome.webRequest.onHeadersReceived.addListener(
state.onHeadersReceived,
{
tabId,
urls: ['<all_urls>'],
types: ['sub_frame', 'xmlhttprequest', 'script']
},
[
chrome.webRequest.OnHeadersReceivedOptions.BLOCKING,
chrome.webRequest.OnHeadersReceivedOptions.RESPONSEHEADERS, // firefox
chrome.webRequest.OnHeadersReceivedOptions.RESPONSE_HEADERS // chromium
].filter(Boolean)
)
const shouldAddGlobalListeners = watchedTabs.size === 1
if (shouldAddGlobalListeners) {
chrome.webNavigation.onBeforeNavigate.addListener(onBeforeNavigate)
if (isFirefox()) {
chrome.webNavigation.onDOMContentLoaded.addListener(initScripts)
} else {
chrome.webNavigation.onCommitted.addListener(initScripts)
}
chrome.webNavigation.onCompleted.addListener(onCompleted)
chrome.webNavigation.onHistoryStateUpdated.addListener(onHistoryStateUpdated)
chrome.tabs.onRemoved.addListener(onTabRemove)
// Listen for requests from background script
chrome.webRequest.onBeforeSendHeaders.addListener(
onBeforeSendHeaders,
{ tabId: -1, urls: ['<all_urls>'] },
[
chrome.webRequest.OnBeforeSendHeadersOptions.BLOCKING,
chrome.webRequest.OnBeforeSendHeadersOptions.REQUESTHEADERS, // firefox
chrome.webRequest.OnBeforeSendHeadersOptions.REQUEST_HEADERS, // chromium
chrome.webRequest.OnBeforeSendHeadersOptions.EXTRA_HEADERS // chromium
].filter(Boolean)
)
}
}
const stopWatchingTab = tabId => {
watchedTabs.delete(tabId)
const state = tabStore[tabId]
if (state) {
chrome.webRequest.onHeadersReceived.removeListener(state.onHeadersReceived)
delete tabStore[tabId]
}
const shouldRemoveGlobalListeners = watchedTabs.size === 0
if (shouldRemoveGlobalListeners) {
chrome.webNavigation.onBeforeNavigate.removeListener(onBeforeNavigate)
if (isFirefox()) {
chrome.webNavigation.onDOMContentLoaded.removeListener(initScripts)
} else {
chrome.webNavigation.onCommitted.removeListener(initScripts)
}
chrome.webNavigation.onCompleted.removeListener(onCompleted)
chrome.webNavigation.onHistoryStateUpdated.removeListener(onHistoryStateUpdated)
chrome.tabs.onRemoved.removeListener(onTabRemove)
chrome.webRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders)
}
console.log(`Metastream stopped watching tabId=${tabId}`)
}
//=============================================================================
// Background fetch proxy
//=============================================================================
const serializeResponse = async response => {
let body
let headers = {}
const contentType = (response.headers.get('content-type') || '').toLowerCase()
if (contentType && contentType.indexOf('application/json') !== -1) {
try {
body = await response.json()
} catch (e) {}
} else {
body = await response.text()
}
for (let pair of response.headers.entries()) {
headers[pair[0]] = pair[1]
}
return {
...response,
headers,
body
}
}
// Fetch on behalf of Metastream app, skips cross-domain security restrictions
const request = async (tabId, requestId, url, options) => {
const { timeout } = options || {}
const controller = new AbortController()
const { signal } = controller
let response, err
try {
console.debug(`Requesting ${url}`)
response = await asyncTimeout(fetch(url, { ...options, signal }), timeout)
} catch (e) {
controller.abort()
err = e.message
}
const action = {
type: `metastream-fetch-response${requestId}`,
payload: {
err,
resp: response ? await serializeResponse(response) : null
}
}
sendToHost(tabId, action)
}
//=============================================================================
// Message passing interface
//=============================================================================
const handleWebviewEvent = async (sender, action) => {
const { frameId } = sender
const { id: tabId } = sender.tab
// popups always send webview events back to host app
if (popupTabs.has(tabId)) {
const parentId = popupParentTabs[tabId]
sendWebviewEventToHost(parentId, TOP_FRAME, action.payload)
return
}
if (isTopFrame(sender)) {
// sent from app
sendToFrame(action.tabId || tabId, action.frameId, action.payload)
} else {
// sent from embedded frame
sendWebviewEventToHost(tabId, frameId, action.payload)
}
}
chrome.runtime.onMessage.addListener((action, sender, sendResponse) => {
const { id: tabId } = sender.tab
if (!isValidAction(action)) return
// Listen for Metastream app initialization signal
if (action.type === 'metastream-init' && isMetastreamUrl(sender.tab.url)) {
startWatchingTab(sender.tab)
sendResponse(true)
return
}
// Filter out messages from non-Metastream app tabs
if (!watchedTabs.has(tabId)) return
switch (action.type) {
case 'metastream-webview-event':
handleWebviewEvent(sender, action)
break
case 'metastream-host-event': {
// DEPRECATED: used by app v0.5.0
handleWebviewEvent(sender, {
type: 'metastream-webview-event',
payload: action
})
break
}
case 'metastream-fetch': {
const { requestId, url, options } = action.payload
request(tabId, requestId, url, options)
break
}
case 'metastream-remove-data': {
const { options, dataToRemove } = action.payload
chrome.browsingData.remove(options, dataToRemove)
break
}
case 'metastream-popup-init': {
const { id: webviewId } = action.payload
popupParents[webviewId] = tabId
break
}
}
lastActiveTabId = tabId
})
//=============================================================================
// Inject content scripts into existing tabs on startup
//=============================================================================
const { content_scripts: contentScripts = [] } = chrome.runtime.getManifest()
const appContentScript = contentScripts.find(
script => script.js && script.js.some(file => file.endsWith('app.js'))
)
if (appContentScript) {
chrome.tabs.query({ url: appContentScript.matches }, tabs => {
tabs.forEach(tab => {
chrome.tabs.executeScript(tab.id, { file: appContentScript.js[0] })
})
})
}
//=============================================================================
// Add URL to session on badge click
//=============================================================================
const getMediaTimeInTab = tabId =>
new Promise(resolve => {
chrome.tabs.executeScript(
tabId,
{
file: '/get-media-time.js',
allFrames: true
},
results => {
let time = (results.length > 0 && !isNaN(results[0]) && results[0]) || undefined
resolve(time)
}
)
})
// pause media in all non-metastream tabs
const pauseMediaInOtherTabs = () => {
chrome.tabs.query({ audible: true }, tabs => {
tabs.forEach(({ id: tabId }) => {
if (watchedTabs.has(tabId)) return
chrome.tabs.executeScript(tabId, {
file: '/pause-media.js',
allFrames: true
})
})
})
}
const openLinkInMetastream = details => {
const { url: requestUrl, currentTime, source } = details
const { protocol } = new URL(requestUrl)
if (protocol !== 'http:' && protocol !== 'https:') return
console.log(`Opening URL in Metastream: ${requestUrl}${currentTime ? ` @ ${currentTime}` : ''}`)
const isMetastreamOpen = watchedTabs.size > 0
if (isMetastreamOpen) {
const targetTabId = watchedTabs.has(lastActiveTabId)
? lastActiveTabId
: Array.from(watchedTabs)[0]
sendToHost(targetTabId, {
type: 'metastream-extension-request',
payload: { url: requestUrl, time: currentTime, source }
})
chrome.tabs.update(targetTabId, { active: true }) // focus tab
} else {
const params = new URLSearchParams()
params.append('url', requestUrl)
if (currentTime) params.append('t', currentTime)
if (source) params.append('source', source)
const url = `${METASTREAM_APP_URL}/?${params.toString()}`
// const url = `http://localhost:8080/#?${params.toString()}` // dev
chrome.tabs.create({ url })
}
pauseMediaInOtherTabs()
}
const openTabInMetastream = async ({ tab, source }) => {
const { id: tabId, url } = tab
if (tabId < 0) return
// ignore badge presses from Metastream tabs
if (watchedTabs.has(tabId)) return
const currentTime = await getMediaTimeInTab(tabId)
openLinkInMetastream({ url, currentTime, source })
}
chrome.browserAction.onClicked.addListener(tab =>
openTabInMetastream({ tab, source: 'browser-action' })
)
//=============================================================================
// Create context menu items to add links to metastream session
//=============================================================================
const TARGET_URL_PATTERNS = ['https://*/*']
chrome.contextMenus.create({
title: 'Open link in Metastream session',
contexts: ['link'],
targetUrlPatterns: TARGET_URL_PATTERNS,
onclick(info, tab) {
const { linkUrl: url } = info
if (url) openLinkInMetastream({ url, source: 'context-menu-link' })
}
})
chrome.contextMenus.create({
title: 'Open link in Metastream session',
contexts: ['browser_action'],
documentUrlPatterns: TARGET_URL_PATTERNS,
onclick(info, tab) {
openTabInMetastream({ tab, source: 'context-menu-browser-action' })
}
})
chrome.contextMenus.create({
title: 'Open video in Metastream session',
contexts: ['video'],
targetUrlPatterns: TARGET_URL_PATTERNS,
onclick(info, tab) {
const { srcUrl: url } = info
if (url) openLinkInMetastream({ url, source: 'context-menu-video' })
}
})