import * as http from "http" ; import * as https from "https" ; import * as url from "url" ; import * as qs from "qs" ; import * as fs from "fs" ; import * as zlib from "zlib" ; import * as path from "path" ; import * as formidable from "formidable" ; import * as Mime from "mime" ; import * as uuid from "uuid" ; import * as etag from "etag" ; import { BrowserFingerprint } from "browser_fingerprint" ; import { api , config , utils , Server , Connection } from "../index" ; import { ActionsStatus , ActionProcessor } from "../classes/actionProcessor" ; export class WebServer extends Server { server : http . Server | https . Server ; fingerPrinter : BrowserFingerprint ; sockets : { [ id ] : any ; } ; constructor ( ) { super ( ) ; this . type = "web" ; this . sockets = { } ; this . attributes = { canChat : false , logConnections : false , logExits : false , sendWelcomeMessage : false , verbs : [ ] , } ; this . connectionCustomMethods = { setHeader : ( connection , key , value ) => { connection . rawConnection . res . setHeader ( key , value ) ; } , setStatusCode : ( connection , value ) => { connection . rawConnection . responseHttpCode = value ; } , pipe : ( connection , buffer , headers ) => { for ( const k in headers ) { connection . setHeader ( k , headers [ k ] ) ; } if ( typeof buffer === "string" ) { buffer = Buffer . from ( buffer ) ; } connection . rawConnection . res . end ( buffer ) ; } , } ; } async initialize ( ) { if ( [ "api" , "file" ] . indexOf ( this . config . rootEndpointType ) < 0 ) { throw new Error ( "rootEndpointType can only be 'api' or 'file'" ) ; } if ( ! this . config . urlPathForFiles && this . config . rootEndpointType === "file" ) { throw new Error ( 'rootEndpointType cannot be "file" without a urlPathForFiles' ) ; } this . fingerPrinter = new BrowserFingerprint ( this . config . fingerprintOptions ) ; } async start ( ) { let bootAttempts = 0 ; if ( this . config . secure === false ) { this . server = http . createServer ( ( req , res ) => { this . handleRequest ( req , res ) ; } ) ; } else { this . server = https . createServer ( this . config . serverOptions , ( req , res ) => { this . handleRequest ( req , res ) ; } ) ; } this . server . on ( "error" , ( error ) => { bootAttempts ++ ; if ( bootAttempts < this . config . bootAttempts ) { this . log ( `cannot boot web server; trying again [${ error }]` , "error" ) ; if ( bootAttempts === 1 ) { this . cleanSocket ( this . config . bindIP , this . config . port ) ; } setTimeout ( ( ) => { this . log ( "attempting to boot again.." ) ; this . server . listen ( this . config . port , this . config . bindIP ) ; } , 1000 ) ; } else { throw new Error ( `cannot start web server @ ${ this . config . bindIP }:${ this . config . port } => ${ error }` ) ; } } ) ; let socketCounter = 0 ; this . server . on ( "connection" , ( socket ) => { const id = socketCounter ; this . sockets [ id ] = socket ; socket . on ( "close" , ( ) => delete this . sockets [ id ] ) ; socketCounter ++ ; } ) ; await new Promise ( ( resolve ) => { this . server . listen ( this . config . port , this . config . bindIP , ( ) => { this . chmodSocket ( this . config . bindIP , this . config . port ) ; resolve ( null ) ; } ) ; } ) ; this . on ( "connection" , async ( connection ) => { const requestMode = await this . determineRequestParams ( connection ) ; if ( requestMode === "api" ) { this . processAction ( connection ) ; } else if ( requestMode === "file" ) { this . processFile ( connection ) ; } else if ( requestMode === "options" ) { this . respondToOptions ( connection ) ; } else if ( requestMode === "trace" ) { this . respondToTrace ( connection ) ; } } ) ; this . on ( "actionComplete" , this . completeResponse ) ; } async stop ( ) { if ( ! this . server ) return ; await new Promise ( ( resolve ) => { this . server . close ( resolve ) ; for ( const socket of Object . values ( this . sockets ) ) { socket . destroy ( ) ; } } ) ; } async sendMessage ( connection , message ) { let stringResponse = "" ; if ( connection . rawConnection . method !== "HEAD" ) { stringResponse = String ( message ) ; } this . cleanHeaders ( connection ) ; const headers = connection . rawConnection . responseHeaders ; const responseHttpCode = parseInt ( connection . rawConnection . responseHttpCode ) ; this . sendWithCompression ( connection , responseHttpCode , headers , stringResponse ) ; } async sendFile ( connection , error , fileStream , mime , length , lastModified ) { let foundCacheControl = false ; let ifModifiedSince ; connection . rawConnection . responseHeaders . forEach ( ( pair ) => { if ( pair [ 0 ] . toLowerCase ( ) === "cache-control" ) { foundCacheControl = true ; } } ) ; connection . rawConnection . responseHeaders . push ( [ "Content-Type" , mime ] ) ; if ( fileStream ) { if ( ! foundCacheControl ) { connection . rawConnection . responseHeaders . push ( [ "Cache-Control" , "max-age=" + this . config . flatFileCacheDuration + ", must-revalidate, public" , ] ) ; } } if ( fileStream && ! this . config . enableEtag ) { if ( lastModified ) { connection . rawConnection . responseHeaders . push ( [ "Last-Modified" , new Date ( lastModified ) . toUTCString ( ) , ] ) ; } } this . cleanHeaders ( connection ) ; const headers = connection . rawConnection . responseHeaders ; const reqHeaders = connection . rawConnection . req . headers ; const sendRequestResult = ( ) => { const responseHttpCode = parseInt ( connection . rawConnection . responseHttpCode , 10 ) ; if ( error ) { this . sendWithCompression ( connection , responseHttpCode , headers , String ( error ) ) ; } else if ( responseHttpCode !== 304 ) { this . sendWithCompression ( connection , responseHttpCode , headers , null , fileStream , length ) ; } else { connection . rawConnection . res . writeHead ( responseHttpCode , this . transformHeaders ( headers ) ) ; connection . rawConnection . res . end ( ) ; connection . destroy ( ) ; fileStream . close ( ) ; } } ; if ( error ) { connection . rawConnection . responseHttpCode = 404 ; return sendRequestResult ( ) ; } if ( reqHeaders [ "if-modified-since" ] ) { ifModifiedSince = new Date ( reqHeaders [ "if-modified-since" ] ) ; lastModified . setMilliseconds ( 0 ) ; if ( lastModified <= ifModifiedSince ) { connection . rawConnection . responseHttpCode = 304 ; } return sendRequestResult ( ) ; } if ( this . config . enableEtag && fileStream && fileStream . path ) { const fileStats = await new Promise ( ( resolve ) => { fs . stat ( fileStream . path , ( error , fileStats ) => { if ( error || ! fileStats ) { this . log ( "Error receving file statistics: " + String ( error ) , "error" ) ; } return resolve ( fileStats ) ; } ) ; } ) ; if ( ! fileStats ) return sendRequestResult ( ) ; const fileEtag = etag ( fileStats , { weak : true } ) ; connection . rawConnection . responseHeaders . push ( [ "ETag" , fileEtag ] ) ; let noneMatchHeader = reqHeaders [ "if-none-match" ] ; const cacheCtrlHeader = reqHeaders [ "cache-control" ] ; let noCache = false ; let etagMatches ; if ( cacheCtrlHeader && cacheCtrlHeader . indexOf ( "no-cache" ) !== - 1 ) { noCache = true ; } if ( noneMatchHeader ) { noneMatchHeader = noneMatchHeader . split ( / *, */ ) ; } if ( noneMatchHeader ) { etagMatches = noneMatchHeader . some ( ( match ) => { return ( match === "*" || match === fileEtag || match === "W/" + fileEtag ) ; } ) ; } if ( etagMatches && ! noCache ) { connection . rawConnection . responseHttpCode = 304 ; } sendRequestResult ( ) ; } else { sendRequestResult ( ) ; } } sendWithCompression ( connection , responseHttpCode , headers , stringResponse , fileStream ? , fileLength ? ) { let acceptEncoding = connection . rawConnection . req . headers [ "accept-encoding" ] ; let compressor ; let stringEncoder ; if ( ! acceptEncoding ) { acceptEncoding = "" ; } if ( this . config . compress === true ) { const gzipMatch = acceptEncoding . match ( /\bgzip\b/ ) ; const deflateMatch = acceptEncoding . match ( /\bdeflate\b/ ) ; if ( ( gzipMatch && ! deflateMatch ) || ( gzipMatch && deflateMatch && gzipMatch . index < deflateMatch . index ) ) { headers . push ( [ "Content-Encoding" , "gzip" ] ) ; compressor = zlib . createGzip ( ) ; stringEncoder = zlib . gzip ; } else if ( ( ! gzipMatch && deflateMatch ) || ( gzipMatch && deflateMatch && deflateMatch . index < gzipMatch . index ) ) { headers . push ( [ "Content-Encoding" , "deflate" ] ) ; compressor = zlib . createDeflate ( ) ; stringEncoder = zlib . deflate ; } } connection . rawConnection . res . on ( "finish" , ( ) => { connection . destroy ( ) ; } ) ; connection . rawConnection . res . on ( "close" , ( ) => { connection . destroy ( ) ; } ) ; if ( fileStream ) { if ( compressor ) { connection . rawConnection . res . writeHead ( responseHttpCode , this . transformHeaders ( headers ) ) ; fileStream . pipe ( compressor ) . pipe ( connection . rawConnection . res ) ; } else { if ( fileLength ) { headers . push ( [ "Content-Length" , fileLength ] ) ; } connection . rawConnection . res . writeHead ( responseHttpCode , this . transformHeaders ( headers ) ) ; fileStream . pipe ( connection . rawConnection . res ) ; } } else { if ( stringEncoder ) { stringEncoder ( stringResponse , ( error , zippedString ) => { if ( error ) { console . error ( error ) ; } headers . push ( [ "Content-Length" , zippedString . length ] ) ; connection . rawConnection . res . writeHead ( responseHttpCode , this . transformHeaders ( headers ) ) ; connection . rawConnection . res . end ( zippedString ) ; } ) ; } else { headers . push ( [ "Content-Length" , Buffer . byteLength ( stringResponse ) ] ) ; connection . rawConnection . res . writeHead ( responseHttpCode , this . transformHeaders ( headers ) ) ; connection . rawConnection . res . end ( stringResponse ) ; } } } handleRequest ( req , res ) { const { fingerprint , headersHash , } = this . fingerPrinter . fingerprint ( req ) ; const responseHeaders = [ ] ; const cookies = utils . parseCookies ( req ) ; const responseHttpCode = 200 ; const method = req . method . toUpperCase ( ) ; const parsedURL = url . parse ( req . url , true ) ; let i ; for ( i in headersHash ) { responseHeaders . push ( [ i , headersHash [ i ] ] ) ; } responseHeaders . push ( [ "Content-Type" , "application/json; charset=utf-8" ] ) ; for ( i in this . config . httpHeaders ) { if ( this . config . httpHeaders [ i ] ) { responseHeaders . push ( [ i , this . config . httpHeaders [ i ] ] ) ; } } if ( this . config . allowedRequestHosts && this . config . allowedRequestHosts . length > 0 ) { const requestHost = req . headers [ "x-forwarded-proto" ] ? req . headers [ "x-forwarded-proto" ] + "://" + req . headers . host : ( this . config . secure ? "https://" : "http://" ) + req . headers . host ; if ( ! this . config . allowedRequestHosts . includes ( requestHost ) ) { const newHost = this . config . allowedRequestHosts [ 0 ] ; res . statusCode = 302 ; res . setHeader ( "Location" , newHost + req . url ) ; return res . end ( `You are being redirected to ${ newHost + req . url }\r\n` ) ; } } const { ip , port } = utils . parseHeadersForClientAddress ( req . headers ) ; const messageId = uuid . v4 ( ) ; this . buildConnection ( { rawConnection : { req : req , res : res , params : { } , method : method , cookies : cookies , responseHeaders : responseHeaders , responseHttpCode : responseHttpCode , parsedURL : parsedURL , } , id : `${ fingerprint }-${ messageId }` , messageId : messageId , fingerprint : fingerprint , remoteAddress : ip || req . connection . remoteAddress || "0.0.0.0" , remotePort : port || req . connection . remotePort || "0" , } ) ; } async completeResponse ( data ) { if ( data . toRender !== true ) { if ( data . connection . rawConnection . res . finished ) { data . connection . destroy ( ) ; } else { data . connection . rawConnection . res . on ( "finish" , ( ) => data . connection . destroy ( ) ) ; data . connection . rawConnection . res . on ( "close" , ( ) => data . connection . destroy ( ) ) ; } return ; } if ( this . config . metadataOptions . serverInformation && typeof data . response !== "string" ) { data . response . serverInformation = this . buildServerInformation ( data . connection . connectedAt ) ; } if ( this . config . metadataOptions . requesterInformation && typeof data . response !== "string" ) { data . response . requesterInformation = this . buildRequesterInformation ( data . connection ) ; } if ( data . response . error ) { if ( this . config . returnErrorCodes === true && data . connection . rawConnection . responseHttpCode === 200 ) { const customErrorCode = parseInt ( data . response . error . code , 10 ) ; const isValidCustomResponseCode = customErrorCode >= 100 && customErrorCode < 600 ; if ( isValidCustomResponseCode ) { data . connection . rawConnection . responseHttpCode = customErrorCode ; } else if ( data . actionStatus === ActionsStatus . UnknownAction ) { data . connection . rawConnection . responseHttpCode = 404 ; } else if ( data . actionStatus === ActionsStatus . MissingParams ) { data . connection . rawConnection . responseHttpCode = 422 ; } else { data . connection . rawConnection . responseHttpCode = this . config . defaultErrorStatusCode ?? 500 ; } } } if ( ! data . response . error && data . action && data . params . apiVersion && api . actions . actions [ data . params . action ] [ data . params . apiVersion ] . matchExtensionMimeType === true && data . connection . extension ) { const mime = Mime . getType ( data . connection . extension ) ; if ( mime ) { data . connection . rawConnection . responseHeaders . push ( [ "Content-Type" , mime , ] ) ; } } if ( data . response . error ) { data . response . error = await config . errors . serializers . servers . web ( data . response . error ) ; } let stringResponse = "" ; if ( this . extractHeader ( data . connection , "Content-Type" ) . match ( /json/ ) ) { stringResponse = JSON . stringify ( data . response , null , this . config . padding ) ; if ( data . params . callback ) { data . connection . rawConnection . responseHeaders . push ( [ "Content-Type" , "application/javascript" , ] ) ; stringResponse = this . callbackHtmlEscape ( data . connection . params . callback ) + "(" + stringResponse + ");" ; } } else { stringResponse = data . response as unknown as string ; } this . sendMessage ( data . connection , stringResponse ) ; } extractHeader ( connection , match ) { let i = connection . rawConnection . responseHeaders . length - 1 ; while ( i >= 0 ) { if ( connection . rawConnection . responseHeaders [ i ] [ 0 ] . toLowerCase ( ) === match . toLowerCase ( ) ) { return connection . rawConnection . responseHeaders [ i ] [ 1 ] ; } i -- ; } return null ; } respondToOptions ( connection ) { if ( ! this . config . httpHeaders [ "Access-Control-Allow-Methods" ] && ! this . extractHeader ( connection , "Access-Control-Allow-Methods" ) ) { const methods = "HEAD, GET, POST, PUT, DELETE, OPTIONS, TRACE" ; connection . rawConnection . responseHeaders . push ( [ "Access-Control-Allow-Methods" , methods , ] ) ; } if ( ! this . config . httpHeaders [ "Access-Control-Allow-Origin" ] && ! this . extractHeader ( connection , "Access-Control-Allow-Origin" ) ) { const origin = "*" ; connection . rawConnection . responseHeaders . push ( [ "Access-Control-Allow-Origin" , origin , ] ) ; } this . sendMessage ( connection , "" ) ; } respondToTrace ( connection ) { const data = this . buildRequesterInformation ( connection ) ; const stringResponse = JSON . stringify ( data , null , this . config . padding ) ; this . sendMessage ( connection , stringResponse ) ; } async determineRequestParams ( connection ) { let requestMode = this . config . rootEndpointType ; const pathname = connection . rawConnection . parsedURL . pathname ; const pathParts = pathname . split ( "/" ) ; let i ; while ( pathParts [ 0 ] === "" ) { pathParts . shift ( ) ; } if ( pathParts [ pathParts . length - 1 ] === "" ) { pathParts . pop ( ) ; } let urlPathForActionsParts = [ ] ; if ( this . config . urlPathForActions ) { urlPathForActionsParts = this . config . urlPathForActions . split ( "/" ) ; while ( urlPathForActionsParts [ 0 ] === "" ) { urlPathForActionsParts . shift ( ) ; } } let urlPathForFilesParts = [ ] ; if ( this . config . urlPathForFiles ) { urlPathForFilesParts = this . config . urlPathForFiles . split ( "/" ) ; while ( urlPathForFilesParts [ 0 ] === "" ) { urlPathForFilesParts . shift ( ) ; } } if ( pathParts [ 0 ] && utils . arrayStartingMatch ( urlPathForActionsParts , pathParts ) ) { requestMode = "api" ; for ( i = 0 ; i < urlPathForActionsParts . length ; i ++ ) { pathParts . shift ( ) ; } } else if ( pathParts [ 0 ] && utils . arrayStartingMatch ( urlPathForFilesParts , pathParts ) ) { requestMode = "file" ; for ( i = 0 ; i < urlPathForFilesParts . length ; i ++ ) { pathParts . shift ( ) ; } } const extensionParts = connection . rawConnection . parsedURL . pathname . split ( "." ) ; if ( extensionParts . length > 1 ) { connection . extension = extensionParts [ extensionParts . length - 1 ] ; } if ( connection . rawConnection . method === "OPTIONS" ) { requestMode = "options" ; return requestMode ; } if ( requestMode === "api" ) { if ( connection . rawConnection . method === "TRACE" ) { requestMode = "trace" ; } let search = "" ; if ( connection . rawConnection . parsedURL . search ) { search = connection . rawConnection . parsedURL . search . slice ( 1 ) ; } this . fillParamsFromWebRequest ( connection , qs . parse ( search , this . config . queryParseOptions ) ) ; connection . rawConnection . params . query = connection . rawConnection . parsedURL . query ; if ( connection . rawConnection . method !== "GET" && connection . rawConnection . method !== "HEAD" && ( connection . rawConnection . req . headers [ "content-type" ] || connection . rawConnection . req . headers [ "Content-Type" ] ) ) { connection . rawConnection . form = new formidable . IncomingForm ( ) ; for ( i in this . config . formOptions ) { connection . rawConnection . form [ i ] = this . config . formOptions [ i ] ; } let rawBody = Promise . resolve ( Buffer . alloc ( 0 ) ) ; if ( this . config . saveRawBody ) { rawBody = new Promise ( ( resolve , reject ) => { let fullBody = Buffer . alloc ( 0 ) ; connection . rawConnection . req . on ( "data" , ( chunk ) => { fullBody = Buffer . concat ( [ fullBody , chunk ] ) ; } ) . on ( "end" , ( ) => { resolve ( fullBody ) ; } ) ; } ) ; } const { fields , files } = await new Promise ( ( resolve ) => { connection . rawConnection . form . parse ( connection . rawConnection . req , ( error , fields , files ) => { if ( error ) { this . log ( "error processing form: " + String ( error ) , "error" ) ; connection . error = new Error ( "There was an error processing this form." ) ; } resolve ( { fields , files } ) ; } ) ; } ) ; connection . rawConnection . params . body = fields ; connection . rawConnection . params . rawBody = await rawBody ; connection . rawConnection . params . files = files ; this . fillParamsFromWebRequest ( connection , files ) ; this . fillParamsFromWebRequest ( connection , fields ) ; connection . params . action = null ; api . routes . processRoute ( connection , pathParts ) ; return requestMode ; } else { connection . params . action = null ; api . routes . processRoute ( connection , pathParts ) ; return requestMode ; } } if ( requestMode === "file" ) { api . routes . processRoute ( connection , pathParts ) ; if ( ! connection . params . file ) { connection . params . file = pathParts . join ( path . sep ) ; } if ( connection . params . file === "" || connection . params . file [ connection . params . file . length - 1 ] === "/" ) { connection . params . file = connection . params . file + config . general . directoryFileType ; } try { connection . params . file = decodeURIComponent ( connection . params . file ) ; } catch ( e ) { connection . error = new Error ( "There was an error decoding URI: " + e ) ; } return requestMode ; } } fillParamsFromWebRequest ( connection , varsHash ) { const collapsedVarsHash = utils . collapseObjectToArray ( varsHash ) ; if ( collapsedVarsHash !== false ) { varsHash = { payload : collapsedVarsHash } ; } for ( const v in varsHash ) { connection . params [ v ] = varsHash [ v ] ; } } transformHeaders ( headersArray ) { return headersArray . reduce ( ( headers , currentHeader ) => { const currentHeaderKey = currentHeader [ 0 ] . toLowerCase ( ) ; if ( currentHeaderKey === "set-cookie" ) { if ( headers [ currentHeaderKey ] ) { headers [ currentHeaderKey ] . push ( currentHeader [ 1 ] . toString ( ) ) ; } else { headers [ currentHeaderKey ] = [ currentHeader [ 1 ] . toString ( ) ] ; } } else { headers [ currentHeaderKey ] = [ currentHeader [ 1 ] . toString ( ) ] ; } return headers ; } , { } ) ; } buildServerInformation ( connectedAt ) { const stopTime = new Date ( ) . getTime ( ) ; return { serverName : config . general . serverName , apiVersion : config . general . apiVersion , requestDuration : stopTime - connectedAt , currentTime : stopTime , } ; } buildRequesterInformation ( connection ) { const requesterInformation = { id : connection . id , fingerprint : connection . fingerprint , messageId : connection . messageId , remoteIP : connection . remoteIP , receivedParams : { } as { [ key ] : any ; } , } ; for ( const p in connection . params ) { if ( config . general . disableParamScrubbing === true || api . params . postVariables . indexOf ( p ) >= 0 ) { requesterInformation . receivedParams [ p ] = connection . params [ p ] ; } } return requesterInformation ; } cleanHeaders ( connection ) { const originalHeaders = connection . rawConnection . responseHeaders . reverse ( ) ; const foundHeaders = [ ] ; const cleanedHeaders = [ ] ; for ( const i in originalHeaders ) { const key = originalHeaders [ i ] [ 0 ] ; const value = originalHeaders [ i ] [ 1 ] ; if ( foundHeaders . indexOf ( key . toLowerCase ( ) ) >= 0 && key . toLowerCase ( ) . indexOf ( "set-cookie" ) < 0 ) { } else if ( connection . rawConnection . method === "HEAD" && key === "Transfer-Encoding" ) { } else { foundHeaders . push ( key . toLowerCase ( ) ) ; cleanedHeaders . push ( [ key , value ] ) ; } } connection . rawConnection . responseHeaders = cleanedHeaders ; } cleanSocket ( bindIP , port ) { if ( ! bindIP && typeof port === "string" && port . indexOf ( "/" ) >= 0 ) { fs . unlink ( port , ( error ) => { if ( error ) { this . log ( `cannot remove stale socket @ ${ port }: ${ error }` , "error" ) ; } else { this . log ( `removed stale unix socket @ ${ port }` ) ; } } ) ; } } chmodSocket ( bindIP , port ) { if ( ! bindIP && typeof port === "string" && port . indexOf ( "/" ) >= 0 ) { fs . chmodSync ( port , "0777" ) ; } } callbackHtmlEscape ( s ) { return s . replace ( /&/g , "&amp;" ) . replace ( /"/g , "&quot;" ) . replace ( /'/g , "&#39;" ) . replace ( /</g , "&lt;" ) . replace ( />/g , "&gt;" ) . replace ( /\)/g , "" ) . replace ( /\(/g , "" ) ; } }