import S3 from 'aws-sdk/clients/s3' ; import { createHmac } from 'crypto' ; import { DefaultImageRequest , ImageEdits , ImageFormatTypes , ImageHandlerError , ImageHandlerEvent , ImageRequestInfo , Headers , RequestTypes , StatusCodes } from './lib' ; import { SecretProvider } from './secret-provider' ; import { ThumborMapper } from './thumbor-mapper' ; type OriginalImageInfo = Partial < { contentType : string ; expires : string ; lastModified : string ; cacheControl : string ; originalImage : Buffer ; } > ; export class ImageRequest { private static readonly DEFAULT_REDUCTION_EFFORT = 4 ; constructor ( private readonly s3Client , private readonly secretProvider ) { } param event returns public async setup ( event ) { try { await this . validateRequestSignature ( event ) ; let imageRequestInfo = < ImageRequestInfo > { } ; imageRequestInfo . requestType = this . parseRequestType ( event ) ; imageRequestInfo . bucket = this . parseImageBucket ( event , imageRequestInfo . requestType ) ; imageRequestInfo . key = this . parseImageKey ( event , imageRequestInfo . requestType ) ; imageRequestInfo . edits = this . parseImageEdits ( event , imageRequestInfo . requestType ) ; const originalImage = await this . getOriginalImage ( imageRequestInfo . bucket , imageRequestInfo . key ) ; imageRequestInfo = { ... imageRequestInfo , ... originalImage } ; imageRequestInfo . headers = this . parseImageHeaders ( event , imageRequestInfo . requestType ) ; if ( imageRequestInfo . contentType === 'image/svg+xml' && imageRequestInfo . edits && Object . keys ( imageRequestInfo . edits ) . length > 0 && ! imageRequestInfo . edits . toFormat ) { imageRequestInfo . outputFormat = ImageFormatTypes . PNG ; } if ( imageRequestInfo . contentType !== 'image/svg+xml' || imageRequestInfo . edits . toFormat || imageRequestInfo . outputFormat ) { const outputFormat = this . getOutputFormat ( event , imageRequestInfo . requestType ) ; if ( outputFormat === ImageFormatTypes . WEBP && imageRequestInfo . requestType === RequestTypes . DEFAULT ) { const decoded = this . decodeRequest ( event ) ; if ( typeof decoded . reductionEffort !== 'undefined' ) { const reductionEffort = Math . trunc ( decoded . reductionEffort ) ; const isValid = ! isNaN ( reductionEffort ) && reductionEffort >= 0 && reductionEffort <= 6 ; imageRequestInfo . reductionEffort = isValid ? reductionEffort : ImageRequest . DEFAULT_REDUCTION_EFFORT ; } } if ( imageRequestInfo . edits && imageRequestInfo . edits . toFormat ) { imageRequestInfo . outputFormat = imageRequestInfo . edits . toFormat ; } else if ( outputFormat ) { imageRequestInfo . outputFormat = outputFormat ; } } if ( imageRequestInfo . outputFormat ) { const requestType = [ RequestTypes . CUSTOM , RequestTypes . THUMBOR ] ; const acceptedValues = [ ImageFormatTypes . JPEG , ImageFormatTypes . PNG , ImageFormatTypes . WEBP , ImageFormatTypes . TIFF , ImageFormatTypes . HEIF ] ; imageRequestInfo . contentType = `image/${ imageRequestInfo . outputFormat }` ; if ( requestType . includes ( imageRequestInfo . requestType ) && acceptedValues . includes ( imageRequestInfo . outputFormat ) ) { const qualityKey = Object . keys ( imageRequestInfo . edits ) . filter ( key => acceptedValues . includes ( key as ImageFormatTypes ) ) [ 0 ] ; if ( qualityKey && qualityKey !== imageRequestInfo . outputFormat ) { imageRequestInfo . edits [ imageRequestInfo . outputFormat ] = imageRequestInfo . edits [ qualityKey ] ; delete imageRequestInfo . edits [ qualityKey ] ; } } } return imageRequestInfo ; } catch ( error ) { console . error ( error ) ; throw error ; } } param bucket param key returns public async getOriginalImage ( bucket , key ) { try { const result = { } ; const imageLocation = { Bucket : bucket , Key : key } ; const originalImage = await this . s3Client . getObject ( imageLocation ) . promise ( ) ; const imageBuffer = Buffer . from ( originalImage . Body as Uint8Array ) ; if ( originalImage . ContentType ) { if ( [ 'binary/octet-stream' , 'application/octet-stream' ] . includes ( originalImage . ContentType ) ) { result . contentType = this . inferImageType ( imageBuffer ) ; } else { result . contentType = originalImage . ContentType ; } } else { result . contentType = 'image' ; } if ( originalImage . Expires ) { result . expires = new Date ( originalImage . Expires ) . toUTCString ( ) ; } if ( originalImage . LastModified ) { result . lastModified = new Date ( originalImage . LastModified ) . toUTCString ( ) ; } result . cacheControl = originalImage . CacheControl ?? 'max-age=31536000,public' ; result . originalImage = imageBuffer ; return result ; } catch ( error ) { let status = StatusCodes . INTERNAL_SERVER_ERROR ; let message = error . message ; if ( error . code === 'NoSuchKey' ) { status = StatusCodes . NOT_FOUND ; message = `The image ${ key } does not exist or the request may not be base64 encoded properly.` ; } throw new ImageHandlerError ( status , error . code , message ) ; } } param event param requestType returns public parseImageBucket ( event , requestType ) { if ( requestType === RequestTypes . DEFAULT ) { const request = this . decodeRequest ( event ) ; if ( request . bucket !== undefined ) { const sourceBuckets = this . getAllowedSourceBuckets ( ) ; if ( sourceBuckets . includes ( request . bucket ) || request . bucket . match ( new RegExp ( '^' + sourceBuckets [ 0 ] + '$' ) ) ) { return request . bucket ; } else { throw new ImageHandlerError ( StatusCodes . FORBIDDEN , 'ImageBucket::CannotAccessBucket' , 'The bucket you specified could not be accessed. Please check that the bucket is specified in your SOURCE_BUCKETS.' ) ; } } else { const sourceBuckets = this . getAllowedSourceBuckets ( ) ; return sourceBuckets [ 0 ] ; } } else if ( requestType === RequestTypes . THUMBOR || requestType === RequestTypes . CUSTOM ) { const sourceBuckets = this . getAllowedSourceBuckets ( ) ; return sourceBuckets [ 0 ] ; } else { throw new ImageHandlerError ( StatusCodes . NOT_FOUND , 'ImageBucket::CannotFindBucket' , 'The bucket you specified could not be found. Please check the spelling of the bucket name in your request.' ) ; } } param event param requestType returns public parseImageEdits ( event , requestType ) { if ( requestType === RequestTypes . DEFAULT ) { const decoded = this . decodeRequest ( event ) ; return decoded . edits ; } else if ( requestType === RequestTypes . THUMBOR ) { const thumborMapping = new ThumborMapper ( ) ; return thumborMapping . mapPathToEdits ( event . path ) ; } else if ( requestType === RequestTypes . CUSTOM ) { const thumborMapping = new ThumborMapper ( ) ; const parsedPath = thumborMapping . parseCustomPath ( event . path ) ; return thumborMapping . mapPathToEdits ( parsedPath ) ; } else { throw new ImageHandlerError ( StatusCodes . BAD_REQUEST , 'ImageEdits::CannotParseEdits' , 'The edits you provided could not be parsed. Please check the syntax of your request and refer to the documentation for additional guidance.' ) ; } } param event param requestType returns public parseImageKey ( event , requestType ) { if ( requestType === RequestTypes . DEFAULT ) { const { key } = this . decodeRequest ( event ) ; return key ; } if ( requestType === RequestTypes . THUMBOR || requestType === RequestTypes . CUSTOM ) { let { path } = event ; if ( requestType === RequestTypes . CUSTOM ) { const { REWRITE_MATCH_PATTERN , REWRITE_SUBSTITUTION } = process . env ; if ( typeof REWRITE_MATCH_PATTERN === 'string' ) { const patternStrings = REWRITE_MATCH_PATTERN . split ( '/' ) ; const flags = patternStrings . pop ( ) ; const parsedPatternString = REWRITE_MATCH_PATTERN . slice ( 1 , REWRITE_MATCH_PATTERN . length - 1 - flags . length ) ; const regExp = new RegExp ( parsedPatternString , flags ) ; path = path . replace ( regExp , REWRITE_SUBSTITUTION ) ; } else { path = path . replace ( REWRITE_MATCH_PATTERN , REWRITE_SUBSTITUTION ) ; } } return decodeURIComponent ( path . replace ( /\/\d+x\d+:\d+x\d+\/|(?<=\/)\d+x\d+\/|filters:[^/]+|\/fit-in(?=\/)|^\/+/g , '' ) . replace ( /^\/+/ , '' ) ) ; } throw new ImageHandlerError ( StatusCodes . NOT_FOUND , 'ImageEdits::CannotFindImage' , 'The image you specified could not be found. Please check your request syntax as well as the bucket you specified to ensure it exists.' ) ; } param event returns public parseRequestType ( event ) { const { path } = event ; const matchDefault = /^(\/?)([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/ ; const matchThumbor = /^(\/?)((fit-in)?|(filters:.+\(.?\))?|(unsafe)?)(((.(?!(\.[^.\\/]+$)))*$)|.*(\.jpg$|.\.png$|\.webp$|\.tiff$|\.jpeg$|\.svg$))/i ; const { REWRITE_MATCH_PATTERN , REWRITE_SUBSTITUTION } = process . env ; const definedEnvironmentVariables = REWRITE_MATCH_PATTERN !== '' && REWRITE_SUBSTITUTION !== '' && REWRITE_MATCH_PATTERN !== undefined && REWRITE_SUBSTITUTION !== undefined ; let isBase64Encoded = true ; try { this . decodeRequest ( event ) ; } catch ( error ) { console . error ( error ) ; isBase64Encoded = false ; } if ( matchDefault . test ( path ) && isBase64Encoded ) { return RequestTypes . DEFAULT ; } else if ( definedEnvironmentVariables ) { return RequestTypes . CUSTOM ; } else if ( matchThumbor . test ( path ) ) { return RequestTypes . THUMBOR ; } else { throw new ImageHandlerError ( StatusCodes . BAD_REQUEST , 'RequestTypeError' , 'The type of request you are making could not be processed. Please ensure that your original image is of a supported file type (jpg, png, tiff, webp, svg) and that your image request is provided in the correct syntax. Refer to the documentation for additional guidance on forming image requests.' ) ; } } param event param requestType returns public parseImageHeaders ( event , requestType ) { if ( requestType === RequestTypes . DEFAULT ) { const { headers } = this . decodeRequest ( event ) ; if ( headers ) { return headers ; } } } param event returns public decodeRequest ( event ) { const { path } = event ; if ( path ) { const encoded = path . charAt ( 0 ) === '/' ? path . slice ( 1 ) : path ; const toBuffer = Buffer . from ( encoded , 'base64' ) ; try { return JSON . parse ( toBuffer . toString ( ) ) ; } catch ( error ) { throw new ImageHandlerError ( StatusCodes . BAD_REQUEST , 'DecodeRequest::CannotDecodeRequest' , 'The image request you provided could not be decoded. Please check that your request is base64 encoded properly and refer to the documentation for additional guidance.' ) ; } } else { throw new ImageHandlerError ( StatusCodes . BAD_REQUEST , 'DecodeRequest::CannotReadPath' , 'The URL path you provided could not be read. Please ensure that it is properly formed according to the solution documentation.' ) ; } } returns public getAllowedSourceBuckets ( ) { const { SOURCE_BUCKETS } = process . env ; if ( SOURCE_BUCKETS === undefined ) { throw new ImageHandlerError ( StatusCodes . BAD_REQUEST , 'GetAllowedSourceBuckets::NoSourceBuckets' , 'The SOURCE_BUCKETS variable could not be read. Please check that it is not empty and contains at least one source bucket, or multiple buckets separated by commas. Spaces can be provided between commas and bucket names, these will be automatically parsed out when decoding.' ) ; } else { return SOURCE_BUCKETS . replace ( /\s+/g , '' ) . split ( ',' ) ; } } param event param requestType returns public getOutputFormat ( event , requestType = undefined ) { const { AUTO_WEBP } = process . env ; if ( AUTO_WEBP === 'Yes' && event . headers . Accept && event . headers . Accept . includes ( 'image/webp' ) ) { return ImageFormatTypes . WEBP ; } else if ( requestType === RequestTypes . DEFAULT ) { const decoded = this . decodeRequest ( event ) ; return decoded . outputFormat ; } return null ; } param imageBuffer returns public inferImageType ( imageBuffer ) { const imageSignature = imageBuffer . slice ( 0 , 4 ) . toString ( 'hex' ) . toUpperCase ( ) ; switch ( imageSignature ) { case '89504E47' : return 'image/png' ; case 'FFD8FFDB' : case 'FFD8FFE0' : case 'FFD8FFEE' : case 'FFD8FFE1' : return 'image/jpeg' ; case '52494646' : return 'image/webp' ; case '49492A00' : return 'image/tiff' ; case '4D4D002A' : return 'image/tiff' ; default : throw new ImageHandlerError ( StatusCodes . INTERNAL_SERVER_ERROR , 'RequestTypeError' , 'The file does not have an extension and the file type could not be inferred. Please ensure that your original image is of a supported file type (jpg, png, tiff, webp, svg). Refer to the documentation for additional guidance on forming image requests.' ) ; } } param event returns throws private async validateRequestSignature ( event ) { const { ENABLE_SIGNATURE , SECRETS_MANAGER , SECRET_KEY } = process . env ; if ( ENABLE_SIGNATURE === 'Yes' ) { const { path , queryStringParameters } = event ; if ( ! queryStringParameters ?. signature ) { throw new ImageHandlerError ( StatusCodes . BAD_REQUEST , 'AuthorizationQueryParametersError' , 'Query-string requires the signature parameter.' ) ; } try { const { signature } = queryStringParameters ; const secret = JSON . parse ( await this . secretProvider . getSecret ( SECRETS_MANAGER ) ) ; const key = secret [ SECRET_KEY ] ; const hash = createHmac ( 'sha256' , key ) . update ( path ) . digest ( 'hex' ) ; if ( signature !== hash ) { throw new ImageHandlerError ( StatusCodes . FORBIDDEN , 'SignatureDoesNotMatch' , 'Signature does not match.' ) ; } } catch ( error ) { if ( error . code === 'SignatureDoesNotMatch' ) { throw error ; } console . error ( 'Error occurred while checking signature.' , error ) ; throw new ImageHandlerError ( StatusCodes . INTERNAL_SERVER_ERROR , 'SignatureValidationFailure' , 'Signature validation failed.' ) ; } } } }