import { i18n } from '@kbn/i18n' ; import { map , truncate } from 'lodash' ; import open from 'opn' ; import puppeteer , { ElementHandle , EvaluateFn , Page , SerializableOrJSHandle } from 'puppeteer' ; import { parse as parseUrl } from 'url' ; import { Logger } from 'src/core/server' ; import type { Layout } from 'src/plugins/screenshot_mode/common' ; import { KBN_SCREENSHOT_MODE_HEADER , ScreenshotModePluginSetup , } from '../../../../../../src/plugins/screenshot_mode/server' ; import { Context , SCREENSHOTTING_CONTEXT_KEY } from '../../../common/context' ; import { ConfigType } from '../../config' ; import { allowRequest } from '../network_policy' ; export interface ConditionalHeadersConditions { protocol : string ; hostname : string ; port : number ; basePath : string ; } export interface ConditionalHeaders { headers : Record < string , string > ; conditions : ConditionalHeadersConditions ; } export interface ElementPosition { boundingClientRect : { top : number ; left : number ; width : number ; height : number ; } ; scroll : { x : number ; y : number ; } ; } export interface Viewport { zoom : number ; width : number ; height : number ; } interface OpenOptions { conditionalHeaders : ConditionalHeaders ; context ? : Context ; waitForSelector : string ; timeout : number ; layout ? : Layout ; } interface WaitForSelectorOpts { timeout : number ; } interface EvaluateOpts { fn : EvaluateFn ; args : SerializableOrJSHandle [ ] ; } interface EvaluateMetaOpts { context : string ; } interface InterceptedRequest { requestId : string ; request : { url : string ; method : string ; headers : { [ key ] : string ; } ; initialPriority : string ; referrerPolicy : string ; } ; frameId : string ; resourceType : string ; } const WAIT_FOR_DELAY_MS = 100 ; function getDisallowedOutgoingUrlError ( interceptedUrl ) { return new Error ( i18n . translate ( 'xpack.screenshotting.chromiumDriver.disallowedOutgoingUrl' , { defaultMessage : `Received disallowed outgoing URL: "{interceptedUrl}". Failing the request and closing the browser.` , values : { interceptedUrl } , } ) ) ; } internal export class HeadlessChromiumDriver { private listenersAttached = false ; private interceptedCount = 0 ; constructor ( private screenshotMode , private config , private readonly page ) { } private allowRequest ( url ) { return ! this . config . networkPolicy . enabled || allowRequest ( url , this . config . networkPolicy . rules ) ; } private truncateUrl ( url ) { return truncate ( url , { length : 100 , omission : '[truncated]' , } ) ; } async open ( url , { conditionalHeaders , context , layout , waitForSelector : pageLoadSelector , timeout , } , logger ) { logger . info ( `opening url ${ url }` ) ; this . interceptedCount = 0 ; /**
         * Integrate with the screenshot mode plugin contract by calling this function before any other
         * scripts have run on the browser page.
         */ await this . page . evaluateOnNewDocument ( this . screenshotMode . setScreenshotModeEnabled ) ; if ( context ) { await this . page . evaluateOnNewDocument ( ( key , value ) => { Object . defineProperty ( window , key , { configurable : false , writable : true , enumerable : true , value , } ) ; } , SCREENSHOTTING_CONTEXT_KEY , context ) ; } if ( layout ) { await this . page . evaluateOnNewDocument ( this . screenshotMode . setScreenshotLayout , layout ) ; } await this . page . setRequestInterception ( true ) ; this . registerListeners ( conditionalHeaders , logger ) ; await this . page . goto ( url , { waitUntil : 'domcontentloaded' } ) ; if ( this . config . browser . chromium . inspect ) { await this . launchDebugger ( ) ; } await this . waitForSelector ( pageLoadSelector , { timeout } , { context : 'waiting for page load selector' } , logger ) ; logger . info ( `handled ${ this . interceptedCount } page requests` ) ; } isPageOpen ( ) { return ! this . page . isClosed ( ) ; } async screenshot ( elementPosition ) { const { boundingClientRect , scroll } = elementPosition ; const screenshot = await this . page . screenshot ( { clip : { x : boundingClientRect . left + scroll . x , y : boundingClientRect . top + scroll . y , height : boundingClientRect . height , width : boundingClientRect . width , } , } ) ; if ( Buffer . isBuffer ( screenshot ) ) { return screenshot ; } if ( typeof screenshot === 'string' ) { return Buffer . from ( screenshot , 'base64' ) ; } return undefined ; } evaluate ( { fn , args = [ ] } , meta , logger ) { logger . debug ( `evaluate ${ meta . context }` ) ; return this . page . evaluate ( fn , ... args ) ; } async waitForSelector ( selector , opts , context , logger ) { const { timeout } = opts ; logger . debug ( `waitForSelector ${ selector }` ) ; const response = await this . page . waitForSelector ( selector , { timeout } ) ; if ( ! response ) { throw new Error ( `Failure in waitForSelector: void response! Context: ${ context . context }` ) ; } logger . debug ( `waitForSelector ${ selector } resolved` ) ; return response ; } public async waitFor ( { fn , args , timeout , } ) { await this . page . waitForFunction ( fn , { timeout , polling : WAIT_FOR_DELAY_MS } , ... args ) ; } async setViewport ( { width : _width , height : _height , zoom } , logger ) { const width = Math . floor ( _width ) ; const height = Math . floor ( _height ) ; logger . debug ( `Setting viewport to: width=${ width } height=${ height } zoom=${ zoom }` ) ; await this . page . setViewport ( { width , height , deviceScaleFactor : zoom , isMobile : false , } ) ; } private registerListeners ( conditionalHeaders , logger ) { if ( this . listenersAttached ) { return ; } const client = this . page . _client ; client . on ( 'Fetch.requestPaused' , async ( interceptedRequest ) => { const { requestId , request : { url : interceptedUrl } , } = interceptedRequest ; const allowed = ! interceptedUrl . startsWith ( 'file://' ) ; const isData = interceptedUrl . startsWith ( 'data:' ) ; if ( ! allowed || ! this . allowRequest ( interceptedUrl ) ) { await client . send ( 'Fetch.failRequest' , { errorReason : 'Aborted' , requestId , } ) ; this . page . browser ( ) . close ( ) ; logger . error ( getDisallowedOutgoingUrlError ( interceptedUrl ) ) ; return ; } if ( this . _shouldUseCustomHeaders ( conditionalHeaders . conditions , interceptedUrl ) ) { logger . trace ( `Using custom headers for ${ interceptedUrl }` ) ; const headers = map ( { ... interceptedRequest . request . headers , ... conditionalHeaders . headers , [ KBN_SCREENSHOT_MODE_HEADER ] : 'true' , } , ( value , name ) => ( { name , value , } ) ) ; try { await client . send ( 'Fetch.continueRequest' , { requestId , headers , } ) ; } catch ( err ) { logger . error ( i18n . translate ( 'xpack.screenshotting.chromiumDriver.failedToCompleteRequestUsingHeaders' , { defaultMessage : 'Failed to complete a request using headers: {error}' , values : { error : err } , } ) ) ; } } else { const loggedUrl = isData ? this . truncateUrl ( interceptedUrl ) : interceptedUrl ; logger . trace ( `No custom headers for ${ loggedUrl }` ) ; try { await client . send ( 'Fetch.continueRequest' , { requestId } ) ; } catch ( err ) { logger . error ( i18n . translate ( 'xpack.screenshotting.chromiumDriver.failedToCompleteRequest' , { defaultMessage : 'Failed to complete a request: {error}' , values : { error : err } , } ) ) ; } } this . interceptedCount = this . interceptedCount + ( isData ? 0 : 1 ) ; } ) ; this . page . on ( 'response' , ( interceptedResponse ) => { const interceptedUrl = interceptedResponse . url ( ) ; const allowed = ! interceptedUrl . startsWith ( 'file://' ) ; if ( ! interceptedResponse . ok ( ) ) { logger . warn ( `Chromium received a non-OK response (${ interceptedResponse . status ( ) }) for request ${ interceptedUrl }` ) ; } if ( ! allowed || ! this . allowRequest ( interceptedUrl ) ) { this . page . browser ( ) . close ( ) ; logger . error ( getDisallowedOutgoingUrlError ( interceptedUrl ) ) ; return ; } } ) ; this . listenersAttached = true ; } private async launchDebugger ( ) { const target = this . page . target ( ) ; const client = await target . createCDPSession ( ) ; await client . send ( 'Debugger.enable' ) ; await client . send ( 'Debugger.pause' ) ; const targetId = target . _targetId ; const wsEndpoint = this . page . browser ( ) . wsEndpoint ( ) ; const { port } = parseUrl ( wsEndpoint ) ; open ( `http://localhost:${ port }/devtools/inspector.html?ws=localhost:${ port }/devtools/page/${ targetId }` ) ; } private _shouldUseCustomHeaders ( conditions , url ) { const { hostname , protocol , port , pathname } = parseUrl ( url ) ; const derivedPort = ( ( ) => { if ( port ) { return port ; } if ( protocol === 'http:' ) { return '80' ; } if ( protocol === 'https:' ) { return '443' ; } return null ; } ) ( ) ; if ( derivedPort === null ) throw new Error ( `URL missing port: ${ url }` ) ; if ( pathname === null ) throw new Error ( `URL missing pathname: ${ url }` ) ; return ( hostname === conditions . hostname && protocol === `${ conditions . protocol }:` && this . _shouldUseCustomHeadersForPort ( conditions , derivedPort ) && pathname . startsWith ( `${ conditions . basePath }/` ) ) ; } private _shouldUseCustomHeadersForPort ( conditions , port ) { if ( conditions . protocol === 'http' && conditions . port === 80 ) { return ( port === undefined || port === null || port === '' || port === conditions . port . toString ( ) ) ; } if ( conditions . protocol === 'https' && conditions . port === 443 ) { return ( port === undefined || port === null || port === '' || port === conditions . port . toString ( ) ) ; } return port === conditions . port . toString ( ) ; } }