UNPKG

@microsoft/windows-admin-center-sdk

Version:

Microsoft - Windows Admin Center Shell

610 lines (608 loc) 28.1 kB
import { from, throwError } from 'rxjs'; import { catchError, map, mergeMap } from 'rxjs/operators'; import { CancellationToken } from '../async/cancellation'; import { ErrorMonitor } from '../diagnostics/error-monitor'; import { EnvironmentModule } from '../manifest/environment-modules'; import { Cookie } from './cookie'; import { ApiVersion } from './gateway-url-builder'; import { headerConstants, HttpResponseTypes } from './http-constants'; import { NativeQ } from './native-q'; import { UriBuilder } from './uri-builder'; /** Represents a object that transfers files */ export class FileTransfer { nodeConnection; gatewayConnection; authorizationManager; moduleName = null; /** * Downloads a blob of data * * @param blob the blob of data to download * @param fileName the name of the file for the user to download. */ static downloadBlob(blob, fileName) { let useAnchorTagForDownload = true; const windowNagivator = window.navigator; if (windowNagivator.msSaveOrOpenBlob) { // This is for IE and Microsoft Edge < 16 // for those cases the download anchor tag doesn't generate the right name so we use the MS download system instead // "5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.79 Safari/537.36 Edge/14.14393" const ua = navigator.userAgent; const edgeIndex = ua.indexOf('Edge'); if (edgeIndex > 0) { const dotIndex = ua.indexOf('.', edgeIndex); let versionNumber = 0; if (dotIndex > 0) { const versionString = ua.substring(edgeIndex + 'Edge'.length + 1, dotIndex); versionNumber = Number(versionString); } useAnchorTagForDownload = versionNumber > 15; } else { useAnchorTagForDownload = false; } } if (useAnchorTagForDownload) { const downloadLink = document.createElement('a'); downloadLink.style.display = 'none'; const url = URL.createObjectURL(blob); downloadLink.setAttribute('href', url); downloadLink.setAttribute('download', fileName); downloadLink.click(); downloadLink.remove(); } else { windowNagivator.msSaveOrOpenBlob(blob, fileName); } } /** * Navigates to a file. * * @param filePath the file path we are navigating to. */ static navigateToFile(filePath) { const downloadLink = document.createElement('a'); downloadLink.style.display = 'none'; downloadLink.setAttribute('href', filePath); downloadLink.click(); downloadLink.remove(); } /** * Initializes a new instance of the FileTransfer class. * * @param nodeConnection the NodeConnection class instance. * @param gatewayConnection the GatewayConnection class instance. * @param authorizationManager the AuthorizationManager class instance. */ constructor(nodeConnection, gatewayConnection, authorizationManager) { this.nodeConnection = nodeConnection; this.gatewayConnection = gatewayConnection; this.authorizationManager = authorizationManager; } static generateRanges(ranges, segmentSize, totalSize) { const offset = segmentSize - 1; const max = totalSize - 1; for (let first = 0, last = offset; first < max; first += offset + 1, last = Math.min(++last + offset, max)) { ranges.push(new ContentRange(first, last, totalSize)); } } static extractNode(input) { const url = new URL(input); const segments = url.pathname.split('/'); return segments[3]; } /** * The GET call to file transfer endpoint and return a Blob of the requested file * * @param nodeName the node to transfer the file from. * @param sourcePath the path of the remote file to transfer. * @param fileOptions the file options for the action. * @return Observable<Blob> the observable Blob object. */ transferBlob(nodeName, sourcePath, fileOptions) { const relativeUrl = this.gatewayConnection.url().node(nodeName).makeRelative().fileTransfer().file(sourcePath).build(); const headers = { Accept: 'application/octet-stream' }; const token = Cookie.getCrossSiteRequestForgeryToken(); if (token) { headers[headerConstants.CROSS_SITE_REQUEST_FORGERY_TOKEN] = token; } const request = { headers: headers, responseType: 'blob' }; if (fileOptions) { request.logAudit = fileOptions.logAudit; request.logTelemetry = fileOptions.logTelemetry; } return this.nodeConnection.get(nodeName, relativeUrl, request); } /** * The GET call to file transfer endpoint and manual download of stream * * @param nodeName the node to transfer the file from. * @param sourcePath the path of the remote file to transfer. * @param targetName the desired name for the downloaded file. * @param fileOptions the file options for the action. * @return Observable<Blob> the observable Blob object. */ transferFile(nodeName, sourcePath, targetName, fileOptions) { return this.transferBlob(nodeName, sourcePath, fileOptions) .pipe(catchError((error) => { if (error.response && error.response.type === HttpResponseTypes.Json) { return from(error.response.text()).pipe(mergeMap((errorJson) => throwError(JSON.parse(errorJson)?.error))); } return throwError(error); }), map((responseBlob) => { FileTransfer.downloadBlob(responseBlob, targetName); return responseBlob; })); } /** * Upload a file from fileObject. * * @deprecated Use upload instead. * @param nodeName the node to upload the file to. * @param path the file path to store on the target node. * @param fileObject the file object created on the UI. * @param fileOptions the file options for the action. * @return Observable<any> the observable object. */ uploadFile(nodeName, path, fileObject, fileOptions) { const deferred = NativeQ.defer(); const formData = new FormData(); formData.append('file-0', fileObject); const request = new XMLHttpRequest(); const url = this.gatewayConnection.url().node(nodeName).fileTransfer().file(path).build(); const handler = () => { if (request.readyState === 4 /* complete */) { if (request.status === 200 /* HttpStatusCode.Ok */ || request.status === 201 /* HttpStatusCode.Created */) { deferred.resolve(request.responseText); } else { ErrorMonitor.current.reportErrorFromAjax({ status: request.status, xhr: request, request: { url, method: 'POST' } }); const uploadError = MsftSme.getStrings().MsftSmeShell.Core.DirectoryList.Upload.Error; const message = request.status === 0 /* HttpStatusCode.CorsRequestFailed */ ? uploadError.FileNotFound : (request.status === 400 /* HttpStatusCode.BadRequest */ ? uploadError.OperationBlocked : uploadError.Unknown.format(request.status)); deferred.reject({ xhr: request, message: message }); } } }; let tokenValue; const ajaxRequest = { headers: {} }; this.authorizationManager.addAuthorizationRequestHeader(ajaxRequest, nodeName); request.open('PUT', url); request.withCredentials = true; tokenValue = ajaxRequest.headers[headerConstants.SME_AUTHORIZATION]; if (tokenValue) { request.setRequestHeader(headerConstants.SME_AUTHORIZATION, tokenValue); } tokenValue = ajaxRequest.headers[headerConstants.USE_LAPS]; if (tokenValue) { request.setRequestHeader(headerConstants.USE_LAPS, tokenValue); // If ajaxRequest.headers[LAPS_LOCALADMINNAME] will always have default of 'administrator', // so no need to check if it exists and not null request.setRequestHeader(headerConstants.LAPS_LOCALADMINNAME, ajaxRequest.headers[headerConstants.LAPS_LOCALADMINNAME]); } if (fileOptions) { if (fileOptions.logAudit === true || fileOptions.logAudit === false) { request.setRequestHeader(headerConstants.LOG_AUDIT, fileOptions.logAudit ? 'true' : 'false'); } if (fileOptions.logTelemetry === true || fileOptions.logTelemetry === false) { request.setRequestHeader(headerConstants.LOG_TELEMETRY, fileOptions.logTelemetry ? 'true' : 'false'); } } const token = Cookie.getCrossSiteRequestForgeryToken(); if (token) { request.setRequestHeader(headerConstants.CROSS_SITE_REQUEST_FORGERY_TOKEN, token); } request.setRequestHeader(headerConstants.MODULE_NAME, this.nameOfModule); request.onreadystatechange = handler; request.send(formData); return from(deferred.promise); } /** * Uploads the specified file. * @param node The target computer to upload the file to. * @param path The path on the target computer to upload the file to. * @param file The file to upload. * @param options The file upload options. * @param cancellationToken The token that can be used to cancel the operation. * @returns An observable object that contains the result of the file transfer. */ upload(node, path, file, options = new FileUploadOptions(), cancellationToken = CancellationToken.NONE) { return from(this.uploadAsync(node, path, file, options, cancellationToken)); } uploadAsync(node, path, file, options, cancellationToken) { // if the file size <= transfer size, then just transfer the file all at once, // which will be much faster than the multi - request flow for a single transfer if (file.size <= options.transferSize) { return this.uploadAllAtOnce(node, path, file, options, cancellationToken); } return this.uploadInParts(node, path, file, options, cancellationToken); } async uploadAllAtOnce(node, path, file, options, cancellationToken) { const url = this.gatewayConnection.url().node(node).fileTransfer().file(path).build(); const headers = this.newRequestHeaders(node, options); const transfer = new FileTransferInfo(file.size, 1); const result = new FileTransferResult(new FileTransferContinuation(null, null, file, transfer)); do { const response = await fetch(url, { method: 'PUT', credentials: 'include', headers: headers, body: file }); switch (response.status) { case 200 /* HttpStatusCode.Ok */: case 201 /* HttpStatusCode.Created */: case 204 /* HttpStatusCode.NoContent */: transfer.incrementCompleted(); return result; case 403 /* HttpStatusCode.Forbidden */: case 409 /* HttpStatusCode.Conflict */: const blockedMessage = MsftSme.getStrings().MsftSmeShell.Core.DirectoryList.Upload.Error.OperationBlocked; throw new Error(blockedMessage); default: if (cancellationToken.isCancellationRequested) { break; } const unknownMessage = MsftSme.getStrings().MsftSmeShell.Core.DirectoryList.Upload.Error.Unknown; throw new Error(unknownMessage.format(response.status, response.statusText)); } } while (!cancellationToken.isCancellationRequested); cancellationToken.throwIfCancellationRequested(); } async uploadInParts(node, path, file, options, cancellationToken = CancellationToken.NONE) { const continuation = await this.newUpload(node, path, file, options, cancellationToken); let canceled = cancellationToken.isCancellationRequested; let error = null; if (!canceled) { error = await this.uploadRanges(continuation, options, cancellationToken); canceled = error !== null || cancellationToken.isCancellationRequested; } if (canceled && options.cancellationBehavior === FileTransferCancellationBehavior.Cancel) { try { // cancel the file transfer if an error occurred or the caller requested cancellation await this.cancelUpload(continuation); } catch (e) { // unable to cancel (e.g. clean up) the file transfer // if cancellation occurred from an error, rethrow it now if (error !== null) { throw error; } throw e; } return new FileTransferResult(); } return new FileTransferResult(continuation); } /** * Resumes a previously started file upload. * @param continuation The information required to continue the file transfer. * @param progress The object used to report the progress of the upload. * @param cancellationToken The token that can be used to cancel the operation. */ resumeUpload(continuation, progress = new NoProgress(), cancellationToken = CancellationToken.NONE) { return from(this.resumeUploadAsync(continuation, progress, cancellationToken)); } async resumeUploadAsync(continuation, progress = new NoProgress(), cancellationToken = CancellationToken.NONE) { const transferSize = continuation.transfer.size; const options = new FileUploadOptions(transferSize, FileTransferCancellationBehavior.Pause, progress); const error = await this.uploadRanges(continuation, options, cancellationToken); if (error !== null) { throw error; } return null; } /** * Cancels a previously started file upload. * @param continuation The information required to cancel the file transfer. */ cancelUpload(continuation) { return from(this.cancelUploadAsync(continuation)); } async cancelUploadAsync(continuation) { const url = continuation.location; const node = FileTransfer.extractNode(url); const headers = this.newRequestHeaders(node, new FileUploadOptions()); let attempts = 0; headers.append(headerConstants.IF_MATCH, continuation.token); do { const response = await fetch(url, { method: 'DELETE', credentials: 'include', headers: headers }); const statusCode = response.status; // canceled or a new transfer was started if (statusCode === 204 /* HttpStatusCode.NoContent */ || statusCode === 412 /* HttpStatusCode.PreconditionFailed */) { break; } switch (statusCode) { case 409 /* HttpStatusCode.Conflict */: break; case 502 /* HttpStatusCode.BadGateway */: case 503 /* HttpStatusCode.ServiceUnavailable */: if (++attempts > 3) { const giveUpMessage = MsftSme.getStrings().MsftSmeShell.Core.DirectoryList.Upload.Error.Unknown; throw new Error(giveUpMessage.format(statusCode, response.statusText)); } break; default: const unknownMessage = MsftSme.getStrings().MsftSmeShell.Core.DirectoryList.Upload.Error.Unknown; throw new Error(unknownMessage.format(statusCode, response.statusText)); } } while (true); return null; } newRequestHeaders(node, options) { const request = this.gatewayConnection.defaultHttpSecureOptions; this.authorizationManager.addAuthorizationRequestHeader(request, node); const headers = new Headers(); const authAadToken = request.headers[headerConstants.SME_AAD_AUTHORIZATION]; const authToken = request.headers[headerConstants.SME_AUTHORIZATION]; const useLaps = request.headers[headerConstants.USE_LAPS]; const xsfrToken = Cookie.getCrossSiteRequestForgeryToken(); const { logAudit, logTelemetry } = options.logging; headers.append(headerConstants.MODULE_NAME, this.nameOfModule); if (authAadToken) { headers.append(headerConstants.SME_AAD_AUTHORIZATION, authAadToken); } if (authToken) { headers.append(headerConstants.SME_AUTHORIZATION, authToken); } if (useLaps) { headers.append(headerConstants.USE_LAPS, useLaps); headers.append(headerConstants.LAPS_LOCALADMINNAME, request.headers[headerConstants.LAPS_LOCALADMINNAME]); } if (xsfrToken) { headers.append(headerConstants.CROSS_SITE_REQUEST_FORGERY_TOKEN, xsfrToken); } if (logAudit !== undefined) { headers.append(headerConstants.LOG_AUDIT, logAudit.toString()); } if (logTelemetry !== undefined) { headers.append(headerConstants.LOG_TELEMETRY, logTelemetry.toString()); } return headers; } async newUpload(node, path, file, options, cancellationToken = CancellationToken.NONE) { const url = this.gatewayConnection.url().node(node).fileTransfer().file(path).build(); const lastModified = new Date(file.lastModified).toISOString(); const headers = this.newRequestHeaders(node, options); let response; let retry = false; headers.append(headerConstants.CONTENT_DISPOSITION, `create; size=${file.size}; modification-date="${lastModified}"`); do { response = await fetch(url, { method: 'POST', credentials: 'include', headers: headers }); retry = response.status === 409 /* HttpStatusCode.Conflict */; } while (retry && !cancellationToken.isCancellationRequested); switch (response.status) { case 201 /* HttpStatusCode.Created */: const builder = new UriBuilder(response.headers.get(headerConstants.LOCATION)); // it's the client's job to tell the server which api version they want to use; // the location header will not contain the query parameter builder.setQueryParameter('api-version', ApiVersion.Latest); const location = builder.toString(); const etag = response.headers.get(headerConstants.ETAG); const ranges = new Array(); FileTransfer.generateRanges(ranges, options.transferSize, file.size); const transfer = new FileTransferInfo(options.transferSize, ranges.length); const continuation = new FileTransferContinuation(location, etag, file, transfer, ranges); return continuation; case 403 /* HttpStatusCode.Forbidden */: case 409 /* HttpStatusCode.Conflict */: const blockedMessage = MsftSme.getStrings().MsftSmeShell.Core.DirectoryList.Upload.Error.OperationBlocked; throw new Error(blockedMessage); default: const unknownMessage = MsftSme.getStrings().MsftSmeShell.Core.DirectoryList.Upload.Error.Unknown; throw new Error(unknownMessage.format(response.status, response.statusText)); } } async uploadRanges(continuation, options, cancellationToken) { const node = FileTransfer.extractNode(continuation.location); const progress = options.progress; const ranges = continuation.ranges; const transfer = continuation.transfer; const remaining = continuation.ranges.length; const promises = new Map(); // start up to 3 uploads in parallel for (let i = 0; i < remaining && i < 3; i++) { promises.set(i, this.uploadPartialFile(i, node, continuation, ranges.shift(), options)); } while (promises.size > 0 && !cancellationToken.isCancellationRequested) { // wait for first completed upload const result = await Promise.race(promises.values()); switch (result.statusCode) { case 204 /* HttpStatusCode.NoContent */: if (ranges.length > 0) { if (!cancellationToken.isCancellationRequested) { // start next upload promises.set(result.index, this.uploadPartialFile(result.index, node, continuation, ranges.shift(), options)); } } else { promises.delete(result.index); } transfer.incrementCompleted(); progress.report(transfer.percentComplete); break; case 401 /* HttpStatusCode.Unauthorized */: case 409 /* HttpStatusCode.Conflict */: case 502 /* HttpStatusCode.BadGateway */: case 503 /* HttpStatusCode.ServiceUnavailable */: if (!cancellationToken.isCancellationRequested) { // retry upload promises.set(result.index, this.uploadPartialFile(result.index, node, continuation, result.range, options)); } break; case 410 /* HttpStatusCode.Gone */: case 412 /* HttpStatusCode.PreconditionFailed */: // burn down promises promises.delete(result.index); await Promise.all(promises.values()); promises.clear(); // clear remaining ranges; the transfer cannot be completed if (ranges.length > 0) { ranges.splice(0, ranges.length); } break; default: const unknownMessage = MsftSme.getStrings().MsftSmeShell.Core.DirectoryList.Upload.Error.UnknownNoMessage; return new Error(unknownMessage.format(result.statusCode)); } } return null; } async uploadPartialFile(index, node, continuation, range, options) { const blob = continuation.file.slice(range.from, range.to + 1); const headers = this.newRequestHeaders(node, options); headers.append(headerConstants.CONTENT_TYPE, 'application/octet-stream'); headers.append(headerConstants.CONTENT_RANGE, range.toString()); headers.append(headerConstants.IF_MATCH, continuation.token); const response = await fetch(continuation.location, { method: 'PATCH', credentials: 'include', headers: headers, body: blob }); return new HttpTransferResult(index, response.status, range); } /** * Gets the name of current shell or module. */ get nameOfModule() { if (!this.moduleName) { this.moduleName = EnvironmentModule.getModuleName(); } return this.moduleName; } } /** Represents the possible cancellation behaviors */ export var FileTransferCancellationBehavior; (function (FileTransferCancellationBehavior) { /** Indicates the file transfer is cancelled or aborted on cancellation */ FileTransferCancellationBehavior[FileTransferCancellationBehavior["Cancel"] = 0] = "Cancel"; /** Indicates the file transfer is paused on cancellation */ FileTransferCancellationBehavior[FileTransferCancellationBehavior["Pause"] = 1] = "Pause"; })(FileTransferCancellationBehavior || (FileTransferCancellationBehavior = {})); /** Represents file upload options. */ export class FileUploadOptions { transferSize; cancellationBehavior; progress; logging; constructor( /** Gets the size of transfer content. The default value is 10MB. */ transferSize = 10485760, /** Indicates the behavior to perform if the operation is canceled. */ cancellationBehavior = FileTransferCancellationBehavior.Cancel, /** A token that can be used to cancel the operation. */ progress = new NoProgress(), /** Gets the associated logging options. */ logging = { logAudit: false, logTelemetry: false }) { this.transferSize = transferSize; this.cancellationBehavior = cancellationBehavior; this.progress = progress; this.logging = logging; } } /** Represents the size and progress of a file transfer. */ export class FileTransferInfo { size; count; completed = 0; constructor( /** Gets the size used in the file transfer. */ size, /** Gets the total number of items in the file transfer. */ count) { this.size = size; this.count = count; } /** Gets the percentage of the file transfer completed. */ get percentComplete() { return (this.completed / this.count) * 100; } /** Increments the number of completed items in the file transfer. */ incrementCompleted() { this.completed = Math.min(this.completed + 1, this.count); } } /** Represents a continuation for a file transfer */ export class FileTransferContinuation { location; token; file; transfer; ranges; constructor( /** Gets the URl representing the location of the file transfer */ location, /** Gets the continuation token for the file transfer */ token, /** Gets the file being transferred */ file, /** Gets the file transfer information. */ transfer, /** Gets the remaining ranges in the file transfer */ ranges = new Array()) { this.location = location; this.token = token; this.file = file; this.transfer = transfer; this.ranges = ranges; } } /** Represents the result of a file transfer */ export class FileTransferResult { continuation; constructor( /** Gets the continuation token associated with the file transfer */ continuation = new FileTransferContinuation(null, null, null, new FileTransferInfo(0, 0))) { this.continuation = continuation; } /** Gets a value indicating whether the file transfer is complete */ get completed() { return this.continuation.ranges.length === 0; } /** Gets a value indicating whether the file transfer is resumable */ get resumable() { return !this.completed && this.continuation.token !== null && this.continuation.location !== null && this.continuation.file !== null; } } /** Represent a range of content in a file transfer */ export class ContentRange { from; to; totalSize; constructor( /** Gets the zero-based start of the content, in bytes */ from, /** Gets the zero-based end of the content, in bytes */ to, /** Gets the total size of the content in bytes */ totalSize) { this.from = from; this.to = to; this.totalSize = totalSize; } /** Returns the string representation of the object */ toString() { return `bytes ${this.from}-${this.to}/${this.totalSize}`; } } class NoProgress { report() { } } class HttpTransferResult { index; statusCode; range; constructor(index, statusCode, range) { this.index = index; this.statusCode = statusCode; this.range = range; } } //# sourceMappingURL=file-transfer.js.map