import { validateAuthorizationHeader , getAuthenticationScopes , AuthScopeValues } from './authentication' ; import { ValidationError , DoesNotExist , PayloadTooLargeError , PreconditionFailedError } from './errors' ; import { ProofChecker } from './ProofChecker' ; import { AuthTimestampCache } from './revocations' ; import { Readable } from 'stream' ; import { DriverModel , PerformWriteArgs , WriteResult , PerformRenameArgs , PerformDeleteArgs , PerformListFilesArgs , ListFilesStatResult , ListFilesResult } from './driverModel' ; import { HubConfigInterface } from './config' ; import { logger , generateUniqueID , bytesToMegabytes , megabytesToBytes , monitorStreamProgress } from './utils' ; export class HubServer { driver : DriverModel ; proofChecker : ProofChecker ; whitelist ? : Array < string > ; serverName : string ; readURL ? : string ; requireCorrectHubUrl : boolean ; validHubUrls ? : Array < string > ; authTimestampCache : AuthTimestampCache ; config : HubConfigInterface ; maxFileUploadSizeMB : number ; maxFileUploadSizeBytes : number ; constructor ( driver , proofChecker , config ) { this . driver = driver ; this . proofChecker = proofChecker ; this . config = config ; this . whitelist = config . whitelist ; this . serverName = config . serverName ; this . validHubUrls = config . validHubUrls ; this . readURL = config . readURL ; this . requireCorrectHubUrl = config . requireCorrectHubUrl || false ; this . authTimestampCache = new AuthTimestampCache ( this . getReadURLPrefix ( ) , driver , config . authTimestampCacheSize ) ; this . maxFileUploadSizeMB = ( config . maxFileUploadSize || 20 ) ; this . maxFileUploadSizeBytes = megabytesToBytes ( this . maxFileUploadSizeMB ) ; } async handleAuthBump ( address , oldestValidTimestamp , requestHeaders ) { this . validate ( address , requestHeaders ) ; await this . authTimestampCache . setAuthTimestamp ( address , oldestValidTimestamp ) ; } validate ( address , requestHeaders , oldestValidTokenTimestamp ? ) { const signingAddress = validateAuthorizationHeader ( requestHeaders . authorization , this . serverName , address , this . requireCorrectHubUrl , this . validHubUrls , oldestValidTokenTimestamp ) ; if ( this . whitelist && ! ( this . whitelist . includes ( signingAddress ) ) ) { throw new ValidationError ( `Address ${ signingAddress } not authorized for writes` ) ; } } async handleListFiles ( address , page , stat , requestHeaders ) { const oldestValidTokenTimestamp = await this . authTimestampCache . getAuthTimestamp ( address ) ; const scopes = getAuthenticationScopes ( requestHeaders . authorization ) ; const isArchivalRestricted = this . isArchivalRestricted ( scopes ) ; this . validate ( address , requestHeaders , oldestValidTokenTimestamp ) ; const listFilesArgs = { pathPrefix : address , page : page } ; let listFileResult ; if ( stat ) { listFileResult = await this . driver . listFilesStat ( listFilesArgs ) ; } else { listFileResult = await this . driver . listFiles ( listFilesArgs ) ; } if ( isArchivalRestricted && listFileResult . entries . length > 0 ) { if ( stat ) { listFileResult . entries = ( listFileResult as ListFilesStatResult ) . entries . filter ( entry => ! this . isHistoricalFile ( entry . name ) ) ; } else { listFileResult . entries = ( listFileResult as ListFilesResult ) . entries . filter ( entry => ! this . isHistoricalFile ( entry ) ) ; } if ( listFileResult . entries . length === 0 && listFileResult . page ) { listFileResult . entries . push ( null ) ; } } return listFileResult ; } getReadURLPrefix ( ) { if ( this . readURL ) { return this . readURL ; } else { return this . driver . getReadURLPrefix ( ) ; } } getFileName ( filePath ) { const pathParts = filePath . split ( '/' ) ; const fileName = pathParts [ pathParts . length - 1 ] ; return fileName ; } getHistoricalFileName ( filePath ) { const fileName = this . getFileName ( filePath ) ; const filePathPrefix = filePath . slice ( 0 , filePath . length - fileName . length ) ; const historicalName = `.history.${ Date . now ( ) }.${ generateUniqueID ( ) }.${ fileName }` ; const historicalPath = `${ filePathPrefix }${ historicalName }` ; return historicalPath ; } isHistoricalFile ( filePath ) { const fileName = this . getFileName ( filePath ) ; const isHistoricalFile = fileName . startsWith ( '.history.' ) ; return isHistoricalFile ; } async handleDelete ( address , path , requestHeaders ) { const oldestValidTokenTimestamp = await this . authTimestampCache . getAuthTimestamp ( address ) ; this . validate ( address , requestHeaders , oldestValidTokenTimestamp ) ; const scopes = getAuthenticationScopes ( requestHeaders . authorization ) ; const isArchivalRestricted = this . checkArchivalRestrictions ( address , path , scopes ) ; if ( scopes . deletePrefixes . length > 0 || scopes . deletePaths . length > 0 ) { let match = ! ! scopes . deletePrefixes . find ( ( p ) => ( path . startsWith ( p ) ) ) ; if ( ! match ) { match = ! ! scopes . deletePaths . find ( ( p ) => ( path === p ) ) ; } if ( ! match ) { throw new ValidationError ( `Address ${ address } not authorized to delete from ${ path } by scopes` ) ; } } await this . proofChecker . checkProofs ( address , path , this . getReadURLPrefix ( ) ) ; if ( isArchivalRestricted ) { const historicalPath = this . getHistoricalFileName ( path ) ; const renameCommand = { path : path , storageTopLevel : address , newPath : historicalPath } ; await this . driver . performRename ( renameCommand ) ; } else { const deleteCommand = { storageTopLevel : address , path } ; await this . driver . performDelete ( deleteCommand ) ; } } async handleRequest ( address , path , requestHeaders , stream ) { const oldestValidTokenTimestamp = await this . authTimestampCache . getAuthTimestamp ( address ) ; this . validate ( address , requestHeaders , oldestValidTokenTimestamp ) ; let contentType = requestHeaders [ 'content-type' ] ; if ( contentType === null || contentType === undefined ) { contentType = 'application/octet-stream' ; } const scopes = getAuthenticationScopes ( requestHeaders . authorization ) ; const isArchivalRestricted = this . checkArchivalRestrictions ( address , path , scopes ) ; if ( scopes . writePrefixes . length > 0 || scopes . writePaths . length > 0 ) { let match = ! ! scopes . writePrefixes . find ( ( p ) => ( path . startsWith ( p ) ) ) ; if ( ! match ) { match = ! ! scopes . writePaths . find ( ( p ) => ( path === p ) ) ; } if ( ! match ) { throw new ValidationError ( `Address ${ address } not authorized to write to ${ path } by scopes` ) ; } } const ifMatchTag = requestHeaders [ 'if-match' ] ; const ifNoneMatchTag = requestHeaders [ 'if-none-match' ] ; if ( ifMatchTag && ifNoneMatchTag ) { throw new PreconditionFailedError ( 'Request should not contain both if-match and if-none-match headers' ) ; } if ( ifNoneMatchTag && ifNoneMatchTag !== '*' ) { throw new PreconditionFailedError ( 'Misuse of the if-none-match header. Expected to be * on write requests.' ) ; } if ( ! this . driver . supportsETagMatching ) { if ( ifMatchTag && ifMatchTag !== '*' ) { const currentETag = ( await this . driver . performStat ( { path : path , storageTopLevel : address } ) ) . etag ; if ( ifMatchTag !== currentETag ) { throw new PreconditionFailedError ( 'The provided ETag does not match that of the resource on the server' ) ; } } else if ( ifNoneMatchTag && ifNoneMatchTag === '*' ) { const statResult = await this . driver . performStat ( { path : path , storageTopLevel : address } ) ; if ( statResult . exists ) { throw new PreconditionFailedError ( 'The entity you are trying to create already exists' ) ; } } } await this . proofChecker . checkProofs ( address , path , this . getReadURLPrefix ( ) ) ; const contentLengthHeader = requestHeaders [ 'content-length' ] as string ; const contentLengthBytes = parseInt ( contentLengthHeader ) ; const isLengthFinite = Number . isFinite ( contentLengthBytes ) && contentLengthBytes > 0 ; if ( isLengthFinite && contentLengthBytes > this . maxFileUploadSizeBytes ) { const errMsg = `Max file upload size is ${ this . maxFileUploadSizeMB } megabytes. ` + `Rejected Content-Length of ${ bytesToMegabytes ( contentLengthBytes , 4 ) } megabytes` ; logger . warn ( `${ errMsg }, address: ${ address }` ) ; throw new PayloadTooLargeError ( errMsg ) ; } if ( isArchivalRestricted ) { const historicalPath = this . getHistoricalFileName ( path ) ; try { await this . driver . performRename ( { path : path , storageTopLevel : address , newPath : historicalPath } ) ; } catch ( error ) { if ( error instanceof DoesNotExist ) { logger . debug ( '404 on putFileArchival rename attempt -- usually this is okay and ' + 'only indicates that this is the first time the file was written: ' + `${ address }/${ path }` ) ; } else { logger . error ( `Error performing historical file rename: ${ address }/${ path }` ) ; logger . error ( error ) ; throw error ; } } } const maxContentLength = Number . isFinite ( contentLengthBytes ) && contentLengthBytes > 0 ? contentLengthBytes : this . maxFileUploadSizeBytes ; const { monitoredStream , pipelinePromise } = monitorStreamProgress ( stream , totalBytes => { if ( totalBytes > maxContentLength ) { const errMsg = `Max file upload size is ${ this . maxFileUploadSizeMB } megabytes. ` + `Rejected POST body stream of ${ bytesToMegabytes ( totalBytes , 4 ) } megabytes` ; logger . warn ( `${ errMsg }, address: ${ address }` ) ; const error = new PayloadTooLargeError ( errMsg ) ; stream . destroy ( error ) ; throw error ; } } ) ; const writeCommand = { storageTopLevel : address , path , stream : monitoredStream , contentType , contentLength : contentLengthBytes , ifMatch : ifMatchTag , ifNoneMatch : ifNoneMatchTag } ; const [ writeResponse ] = await Promise . all ( [ this . driver . performWrite ( writeCommand ) , pipelinePromise ] ) ; const readURL = writeResponse . publicURL ; const driverPrefix = this . driver . getReadURLPrefix ( ) ; const readURLPrefix = this . getReadURLPrefix ( ) ; if ( readURLPrefix !== driverPrefix && readURL . startsWith ( driverPrefix ) ) { const postFix = readURL . slice ( driverPrefix . length ) ; return { publicURL : `${ readURLPrefix }${ postFix }` , etag : writeResponse . etag } ; } return writeResponse ; } isArchivalRestricted ( scopes ) { return scopes . writeArchivalPaths . length > 0 || scopes . writeArchivalPrefixes . length > 0 ; } checkArchivalRestrictions ( address , path , scopes ) { const isArchivalRestricted = this . isArchivalRestricted ( scopes ) ; if ( isArchivalRestricted ) { let match = ! ! scopes . writeArchivalPrefixes . find ( ( p ) => ( path . startsWith ( p ) ) ) ; if ( ! match ) { match = ! ! scopes . writeArchivalPaths . find ( ( p ) => ( path === p ) ) ; } if ( ! match ) { throw new ValidationError ( `Address ${ address } not authorized to modify ${ path } by scopes` ) ; } } return isArchivalRestricted ; } }