diff --git a/source/backend/api/Areas/Documents/DocumentController.cs b/source/backend/api/Areas/Documents/DocumentController.cs index 5df995c6ab..538b7f7c86 100644 --- a/source/backend/api/Areas/Documents/DocumentController.cs +++ b/source/backend/api/Areas/Documents/DocumentController.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; using MapsterMapper; using Microsoft.AspNetCore.Authorization; @@ -125,6 +126,25 @@ public async Task DownloadFile(long mayanDocumentId, long mayanFi return new FileContentResult(fileBytes, result.Payload.Mimetype) { FileDownloadName = result.Payload.FileName }; } + /// + /// Stream the file for the corresponding file and document id and return a stream. + /// + [HttpGet("storage/{mayanDocumentId}/files/{mayanFileId}/stream")] + [HasPermission(Permissions.DocumentView)] + [ProducesResponseType(typeof(File), 200)] + [SwaggerOperation(Tags = new[] { "storage-documents" })] + public async Task StreamFile(long mayanDocumentId, long mayanFileId) + { + var result = await _documentService.StreamFileAsync(mayanDocumentId, mayanFileId); + + if (result?.Payload == null) + { + return new NotFoundResult(); + } + + return File(result.Payload.FilePayload, "application/octet-stream", $"{result.Payload.FileName}"); + } + /// /// Retrieves a list of documents. /// @@ -208,7 +228,7 @@ public async Task DownloadWrappedFile(long mayanDocumentId) [ProducesResponseType(typeof(FileContentResult), 200)] [SwaggerOperation(Tags = new[] { "storage-documents" })] [TypeFilter(typeof(NullJsonResultFilter))] - public async Task DownloadFile(long mayanDocumentId) + public async Task DownloadFileLatest(long mayanDocumentId) { var result = await _documentService.DownloadFileLatestAsync(mayanDocumentId); if (result?.Payload == null) @@ -220,6 +240,25 @@ public async Task DownloadFile(long mayanDocumentId) return new FileContentResult(fileBytes, result.Payload.Mimetype) { FileDownloadName = result.Payload.FileName }; } + /// + /// Streams the latest file for the corresponding document id. + /// + [HttpGet("storage/{mayanDocumentId}/stream")] + [HasPermission(Permissions.DocumentView)] + [ProducesResponseType(typeof(File), 200)] + [SwaggerOperation(Tags = new[] { "storage-documents" })] + [TypeFilter(typeof(NullJsonResultFilter))] + public async Task StreamFileLatest(long mayanDocumentId) + { + var result = await _documentService.StreamFileLatestAsync(mayanDocumentId); + if (result?.Payload == null) + { + return new NotFoundResult(); + } + + return File(result.Payload.FilePayload, "application/octet-stream", $"{result.Payload.FileName}"); + } + /// /// Retrieves a external document metadata. /// diff --git a/source/backend/api/Repositories/Mayan/IEdmsDocumentRepository.cs b/source/backend/api/Repositories/Mayan/IEdmsDocumentRepository.cs index 08ab4a72f1..ffdb94aa35 100644 --- a/source/backend/api/Repositories/Mayan/IEdmsDocumentRepository.cs +++ b/source/backend/api/Repositories/Mayan/IEdmsDocumentRepository.cs @@ -1,7 +1,7 @@ using System.Net.Http; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; - +using Pims.Api.Models; using Pims.Api.Models.Mayan; using Pims.Api.Models.Mayan.Document; using Pims.Api.Models.Requests.Http; @@ -31,6 +31,8 @@ public interface IEdmsDocumentRepository Task> TryDownloadFileAsync(long documentId, long fileId); + Task> TryStreamFileAsync(long documentId, long fileId); + Task> TryDeleteDocument(long documentId); Task> TryUploadDocumentAsync(long documentType, IFormFile file); diff --git a/source/backend/api/Repositories/Mayan/MayanDocumentRepository.cs b/source/backend/api/Repositories/Mayan/MayanDocumentRepository.cs index a628b39a5f..bb304483ee 100644 --- a/source/backend/api/Repositories/Mayan/MayanDocumentRepository.cs +++ b/source/backend/api/Repositories/Mayan/MayanDocumentRepository.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using Pims.Api.Models; using Pims.Api.Models.CodeTypes; using Pims.Api.Models.Mayan; using Pims.Api.Models.Mayan.Document; @@ -170,25 +171,14 @@ public async Task>> TryGet public async Task> TryDownloadFileAsync(long documentId, long fileId) { _logger.LogDebug("Downloading file {documentId}, {fileId}...", documentId, fileId); - string authenticationToken = await _authRepository.GetTokenAsync(); - - using HttpClient client = _httpClientFactory.CreateClient("Pims.Api.Logging"); - client.DefaultRequestHeaders.Accept.Clear(); - AddAuthentication(client, authenticationToken); - client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); - - ExternalResponse result = new ExternalResponse() + ExternalResponse result = new() { Status = ExternalResponseStatus.Error, }; try { - Uri endpoint = new($"{this._config.BaseUri}/documents/{documentId}/files/{fileId}/download/"); - HttpResponseMessage httpResponse = await client.GetAsync(endpoint).ConfigureAwait(true); - - var response = await ProcessDownloadResponse(httpResponse); - + var response = await ProcessDownloadResponse(await GetFileAsync(documentId, fileId)); return response; } catch (Exception e) @@ -202,6 +192,30 @@ public async Task> TryDownloadFileAsync(l return result; } + public async Task> TryStreamFileAsync(long documentId, long fileId) + { + _logger.LogDebug("Streaming file {documentId}, {fileId}...", documentId, fileId); + ExternalResponse result = new() + { + Status = ExternalResponseStatus.Error, + }; + + try + { + var stream = await ProcessStreamResponse(await GetFileAsync(documentId, fileId)); + return stream; + } + catch (Exception e) + { + result.Status = ExternalResponseStatus.Error; + result.Message = "Exception downloading file"; + _logger.LogError("Unexpected exception streaming file {e}", e); + } + + _logger.LogDebug($"Finished streaming file"); + return result; + } + public async Task> TryDeleteDocument(long documentId) { _logger.LogDebug("Deleting document {documentId}...", documentId); @@ -381,5 +395,18 @@ public async Task TryGetFilePageImage(long documentId, long _logger.LogDebug("Finished retrieving mayan file page"); return response; } + + private async Task GetFileAsync(long documentId, long fileId) + { + string authenticationToken = await _authRepository.GetTokenAsync(); + + using HttpClient client = _httpClientFactory.CreateClient("Pims.Api.Logging"); + client.DefaultRequestHeaders.Accept.Clear(); + AddAuthentication(client, authenticationToken); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + + Uri endpoint = new($"{this._config.BaseUri}/documents/{documentId}/files/{fileId}/download/"); + return await client.GetAsync(endpoint).ConfigureAwait(true); + } } } diff --git a/source/backend/api/Repositories/RestCommon/BaseRestRepository.cs b/source/backend/api/Repositories/RestCommon/BaseRestRepository.cs index 012d35ccf7..2b5559b8fe 100644 --- a/source/backend/api/Repositories/RestCommon/BaseRestRepository.cs +++ b/source/backend/api/Repositories/RestCommon/BaseRestRepository.cs @@ -10,6 +10,7 @@ using System.Text.Json; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Pims.Api.Models; using Pims.Api.Models.CodeTypes; using Pims.Api.Models.Requests.Http; @@ -196,7 +197,11 @@ protected async Task> ProcessDownloadResp byte[] responsePayload = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(true); _logger.LogTrace("Response: {response}", response); response.Content.Headers.TryGetValues("Content-Length", out IEnumerable contentLengthHeaders); - long contentLength = contentLengthHeaders?.FirstOrDefault() != null ? int.Parse(contentLengthHeaders.FirstOrDefault(), CultureInfo.InvariantCulture) : responsePayload.Length; + long contentLength = responsePayload.Length; + if (contentLengthHeaders?.FirstOrDefault() != null) + { + contentLength = int.Parse(contentLengthHeaders.FirstOrDefault(), CultureInfo.InvariantCulture); + } result.HttpStatusCode = response.StatusCode; switch (response.StatusCode) { @@ -233,6 +238,57 @@ protected async Task> ProcessDownloadResp return result; } + protected async Task> ProcessStreamResponse(HttpResponseMessage response) + { + ExternalResponse result = new ExternalResponse() + { + Status = ExternalResponseStatus.Error, + }; + + Stream responsePayload = await response.Content.ReadAsStreamAsync().ConfigureAwait(true); + _logger.LogTrace("Response: {response}", response); + response.Content.Headers.TryGetValues("Content-Length", out IEnumerable contentLengthHeaders); + long contentLength = responsePayload.Length; + if (contentLengthHeaders?.FirstOrDefault() != null) + { + contentLength = int.Parse(contentLengthHeaders.FirstOrDefault(), CultureInfo.InvariantCulture); + } + result.HttpStatusCode = response.StatusCode; + switch (response.StatusCode) + { + case HttpStatusCode.OK: + string contentDisposition = response.Content.Headers.GetValues("Content-Disposition").FirstOrDefault(); + string fileName = GetFileNameFromContentDisposition(contentDisposition); + + result.Status = ExternalResponseStatus.Success; + result.Payload = new FileStreamResponse() + { + FilePayload = responsePayload, + Size = contentLength, + Mimetype = response.Content.Headers.GetValues("Content-Type").FirstOrDefault(), + FileName = fileName, + FileNameExtension = Path.GetExtension(fileName).Replace(".", string.Empty), + FileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName), + }; + + break; + case HttpStatusCode.NoContent: + result.Status = ExternalResponseStatus.Success; + result.Message = "No content found"; + break; + case HttpStatusCode.Forbidden: + result.Status = ExternalResponseStatus.Error; + result.Message = "Forbidden"; + break; + default: + result.Status = ExternalResponseStatus.Error; + result.Message = $"Unable to contact endpoint {response.RequestMessage.RequestUri}. Http status {response.StatusCode}"; + break; + } + + return result; + } + private static string GetFileNameFromContentDisposition(string contentDisposition) { const string fileNameFlag = "filename"; diff --git a/source/backend/api/Services/DocumentService.cs b/source/backend/api/Services/DocumentService.cs index 3f51a2a2fa..f9a145ed82 100644 --- a/source/backend/api/Services/DocumentService.cs +++ b/source/backend/api/Services/DocumentService.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Pims.Api.Helpers.Exceptions; +using Pims.Api.Models; using Pims.Api.Models.CodeTypes; using Pims.Api.Models.Concepts.Document; using Pims.Api.Models.Config; @@ -405,6 +406,30 @@ public async Task> DownloadFileAsync(long } } + public async Task> StreamFileAsync(long mayanDocumentId, long mayanFileId) + { + this.Logger.LogInformation("Streaming storage document {mayanDocumentId} {mayanFileId}", mayanDocumentId, mayanFileId); + this.User.ThrowIfNotAuthorized(Permissions.DocumentView); + + ExternalResponse downloadResult = await documentStorageRepository.TryStreamFileAsync(mayanDocumentId, mayanFileId); + if (IsValidDocumentExtension(downloadResult.Payload.FileName)) + { + if (downloadResult.Status != ExternalResponseStatus.Success) + { + throw GetMayanResponseError(downloadResult.Message); + } + return downloadResult; + } + else + { + return new ExternalResponse() + { + Status = ExternalResponseStatus.Error, + Message = $"Document with id ${mayanDocumentId} has an invalid extension", + }; + } + } + public async Task> DownloadFileLatestAsync(long mayanDocumentId) { this.Logger.LogInformation("Downloading storage document latest {mayanDocumentId}", mayanDocumentId); @@ -443,6 +468,44 @@ public async Task> DownloadFileLatestAsyn } } + public async Task> StreamFileLatestAsync(long mayanDocumentId) + { + this.Logger.LogInformation("Streaming storage document latest {mayanDocumentId}", mayanDocumentId); + + ExternalResponse documentResult = await documentStorageRepository.TryGetDocumentAsync(mayanDocumentId); + if (documentResult.Status == ExternalResponseStatus.Success) + { + if (documentResult.Payload != null) + { + if (IsValidDocumentExtension(documentResult.Payload.FileLatest.FileName)) + { + ExternalResponse downloadResult = await documentStorageRepository.TryStreamFileAsync(documentResult.Payload.Id, documentResult.Payload.FileLatest.Id); + return downloadResult; + } + else + { + return new ExternalResponse() + { + Status = ExternalResponseStatus.Error, + Message = $"Document with id ${mayanDocumentId} has an invalid extension", + }; + } + } + else + { + return new ExternalResponse() + { + Status = ExternalResponseStatus.Error, + Message = $"No document with id ${mayanDocumentId} found in the storage", + }; + } + } + else + { + throw GetMayanResponseError(documentResult.Message); + } + } + public async Task>> GetDocumentFilePageListAsync(long documentId, long documentFileId) { this.Logger.LogInformation("Retrieving pages for document: {documentId} file: {documentFileId}", documentId, documentFileId); diff --git a/source/backend/api/Services/IDocumentService.cs b/source/backend/api/Services/IDocumentService.cs index a130e78774..ebcd1ded2e 100644 --- a/source/backend/api/Services/IDocumentService.cs +++ b/source/backend/api/Services/IDocumentService.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; +using Pims.Api.Models; using Pims.Api.Models.CodeTypes; using Pims.Api.Models.Mayan; @@ -27,8 +28,12 @@ public interface IDocumentService Task> DownloadFileAsync(long mayanDocumentId, long mayanFileId); + Task> StreamFileAsync(long mayanDocumentId, long mayanFileId); + Task> DownloadFileLatestAsync(long mayanDocumentId); + Task> StreamFileLatestAsync(long mayanDocumentId); + IList GetPimsDocumentTypes(); IList GetPimsDocumentTypes(DocumentRelationType relationshipType); diff --git a/source/backend/apimodels/Models/Requests/Http/FileStreamResponse.cs b/source/backend/apimodels/Models/Requests/Http/FileStreamResponse.cs new file mode 100644 index 0000000000..acfbde6fbc --- /dev/null +++ b/source/backend/apimodels/Models/Requests/Http/FileStreamResponse.cs @@ -0,0 +1,42 @@ +using System.IO; + +namespace Pims.Api.Models +{ + public class FileStreamResponse + { + /// + /// get/set - The file contents. Could be encoded. + /// + public Stream FilePayload { get; set; } + + /// + /// get/set - The file size. + /// + public long Size { get; set; } + + /// + /// get/set - Name of the file. + /// + public string FileName { get; set; } + + /// + /// get/set - The extension of the file (pdf, docx, etc). + /// + public string FileNameExtension { get; set; } + + /// + /// get/set - Complement of FileNameExtension. + /// + public string FileNameWithoutExtension { get; set; } + + /// + /// get/set - The Mime-Type that the file uses. + /// + public string Mimetype { get; set; } + + /// + /// get/set - The encoding type that the file uses. + /// + public string EncodingType { get; set; } = "base64"; + } +} diff --git a/source/backend/tests/unit/api/Controllers/Document/DocumentControllerTest.cs b/source/backend/tests/unit/api/Controllers/Document/DocumentControllerTest.cs index 3e6b771eb8..5d557f6df8 100644 --- a/source/backend/tests/unit/api/Controllers/Document/DocumentControllerTest.cs +++ b/source/backend/tests/unit/api/Controllers/Document/DocumentControllerTest.cs @@ -190,25 +190,103 @@ public void DownloadWrappedFile_Success() [Fact] public void DownloadFile_Success() + { + // Arrange + this._service.Setup(m => m.DownloadFileAsync(1, 1)).ReturnsAsync(new ExternalResponse() { Payload = new FileDownloadResponse() { FilePayload = "cmVmYWN0b3IgcHJvcGVydHkgdGFicyB0byB1c2Ugcm91dGVyLCBpbmNsdWRpbmcgbWFuYWdlbWVudC4NCkVuc3VyZSB0aGF0IG1hbmFnZW1lbnQgdGFiIHJlZGlyZWN0cyBiYWNrIHRvIG1hbmFnZW1lbnQgdmlldyBhZnRlciBlZGl0aW5nLg0KDQpkaXNjdXNzIDY4MzkgaW4gZGV2IG1lZXRpbmcgdG1ydw0KDQp3aGF0IGlzIGdvaW5nIG9uIHdpdGggaXNfZGlzYWJsZWQ/IHdoeSBhcmUgd2UgYWRkaW5nIGl0IHRvIGpvaW4gdGFibGVzPw0KDQpjb21tZW50IG9uIHJldmlld2luZyB1aSB3aXRoIGFuYSBkdXJpbmcgY29kZSByZXZpZXcuDQoNCmNsZWFuIHVwIG9sZCBnaXRodWIgYWN0aW9ucyAtIA0KDQptYWtlIGdpdGh1YiBhY3Rpb25zIHRvIHByb2QgYW5kIHVhdCByZXN0cmljdGVkIHRvIGEgc21hbGxlciBncm91cA0KDQptYWtlIHByb2QgYW5kIHVhdCBhY3Rpb25zIGhhdmUgYSBkaXNjbGFpbWVyIHdoZW4gcnVubmluZywgYW5kIHRoZW4gaW5jcmVhc2UgdGhlIHZlcmJvc2l0eSBvZiBsb2dnaW5nIHRvIHRlYW1zIHN1Y2ggdGhhdCB0aGUgb3BlcmF0aW9ucyBjb25kdWN0ZWQgYnkgdGhlIGFjdGlvbiBhcmUgbGFpZCBvdXQgZm9yIG5vbi10ZWNobmljYWwgdXNlcnMuDQoNCg0KDQpyZW1vdmU6IGRiLXNjaG1hLnltbA0KbWF5YmU6IHphcC1zY2FuLnltbCwgdGFnLnltbCwgcmVsZWFzZS55bWwsIGltYWdlLXNjYW4tYW5hbHlzaXMsIGNpLWNkLXBpbXMtbWFzdGVyLnltbCwgYXBwLWxvZ2dpbmcueW1sDQoNCndlIHdvdWxkIGxpa2UgdG8gcmVtb3ZlIHRoZSB2ZXJzaW9uIGJ1bXAgZnJvbSBkZXYgd2hlbiB3ZSBoYXZlIGFuIGFsdGVybmF0ZSB3YXkgb2Ygc2VlaW5nIHRoZSBnaXQgY29tbWl0IG9uIHRoZSBpbWFnZS4NCg0KbmVlZCB0byB1cGRhdGUgZW52IGdlbmVyYXRpb24gdG8gYWZmZWN0IG5ldyBsb2NhdGlvbi4=", Mimetype = "text/plain" } }); + + // Act + var result = this._controller.DownloadFile(1, 1); + + // Assert + this._service.Verify(m => m.DownloadFileAsync(1, 1), Times.Once()); + } + + [Fact] + public async Task DownloadFile_NoResultAsync() + { + // Arrange + this._service.Setup(m => m.DownloadFileAsync(1, 1)).ReturnsAsync(new ExternalResponse() { Payload = null }); + + // Act + var result = await this._controller.DownloadFile(1, 1); + + // Assert + var actionResult = Assert.IsType(result); + } + + [Fact] + public void StreamFile_Success() + { + // Arrange + this._service.Setup(m => m.DownloadFileAsync(1, 1)).ReturnsAsync(new ExternalResponse() { Payload = new FileDownloadResponse() { FilePayload = "cmVmYWN0b3IgcHJvcGVydHkgdGFicyB0byB1c2Ugcm91dGVyLCBpbmNsdWRpbmcgbWFuYWdlbWVudC4NCkVuc3VyZSB0aGF0IG1hbmFnZW1lbnQgdGFiIHJlZGlyZWN0cyBiYWNrIHRvIG1hbmFnZW1lbnQgdmlldyBhZnRlciBlZGl0aW5nLg0KDQpkaXNjdXNzIDY4MzkgaW4gZGV2IG1lZXRpbmcgdG1ydw0KDQp3aGF0IGlzIGdvaW5nIG9uIHdpdGggaXNfZGlzYWJsZWQ/IHdoeSBhcmUgd2UgYWRkaW5nIGl0IHRvIGpvaW4gdGFibGVzPw0KDQpjb21tZW50IG9uIHJldmlld2luZyB1aSB3aXRoIGFuYSBkdXJpbmcgY29kZSByZXZpZXcuDQoNCmNsZWFuIHVwIG9sZCBnaXRodWIgYWN0aW9ucyAtIA0KDQptYWtlIGdpdGh1YiBhY3Rpb25zIHRvIHByb2QgYW5kIHVhdCByZXN0cmljdGVkIHRvIGEgc21hbGxlciBncm91cA0KDQptYWtlIHByb2QgYW5kIHVhdCBhY3Rpb25zIGhhdmUgYSBkaXNjbGFpbWVyIHdoZW4gcnVubmluZywgYW5kIHRoZW4gaW5jcmVhc2UgdGhlIHZlcmJvc2l0eSBvZiBsb2dnaW5nIHRvIHRlYW1zIHN1Y2ggdGhhdCB0aGUgb3BlcmF0aW9ucyBjb25kdWN0ZWQgYnkgdGhlIGFjdGlvbiBhcmUgbGFpZCBvdXQgZm9yIG5vbi10ZWNobmljYWwgdXNlcnMuDQoNCg0KDQpyZW1vdmU6IGRiLXNjaG1hLnltbA0KbWF5YmU6IHphcC1zY2FuLnltbCwgdGFnLnltbCwgcmVsZWFzZS55bWwsIGltYWdlLXNjYW4tYW5hbHlzaXMsIGNpLWNkLXBpbXMtbWFzdGVyLnltbCwgYXBwLWxvZ2dpbmcueW1sDQoNCndlIHdvdWxkIGxpa2UgdG8gcmVtb3ZlIHRoZSB2ZXJzaW9uIGJ1bXAgZnJvbSBkZXYgd2hlbiB3ZSBoYXZlIGFuIGFsdGVybmF0ZSB3YXkgb2Ygc2VlaW5nIHRoZSBnaXQgY29tbWl0IG9uIHRoZSBpbWFnZS4NCg0KbmVlZCB0byB1cGRhdGUgZW52IGdlbmVyYXRpb24gdG8gYWZmZWN0IG5ldyBsb2NhdGlvbi4=", Mimetype = "text/plain" } }); + + // Act + var result = this._controller.StreamFile(1, 1); + + // Assert + this._service.Verify(m => m.StreamFileAsync(1, 1), Times.Once()); + } + + [Fact] + public async Task StreamFile_NoResultAsync() + { + // Arrange + this._service.Setup(m => m.DownloadFileAsync(1, 1)).ReturnsAsync(new ExternalResponse() { Payload = null }); + + // Act + var result = await this._controller.StreamFile(1, 1); + + // Assert + var actionResult = Assert.IsType(result); + } + + [Fact] + public void DownloadFileLatest_Success() { // Arrange this._service.Setup(m => m.DownloadFileLatestAsync(1)).ReturnsAsync(new ExternalResponse() { Payload = new FileDownloadResponse() { FilePayload = "cmVmYWN0b3IgcHJvcGVydHkgdGFicyB0byB1c2Ugcm91dGVyLCBpbmNsdWRpbmcgbWFuYWdlbWVudC4NCkVuc3VyZSB0aGF0IG1hbmFnZW1lbnQgdGFiIHJlZGlyZWN0cyBiYWNrIHRvIG1hbmFnZW1lbnQgdmlldyBhZnRlciBlZGl0aW5nLg0KDQpkaXNjdXNzIDY4MzkgaW4gZGV2IG1lZXRpbmcgdG1ydw0KDQp3aGF0IGlzIGdvaW5nIG9uIHdpdGggaXNfZGlzYWJsZWQ/IHdoeSBhcmUgd2UgYWRkaW5nIGl0IHRvIGpvaW4gdGFibGVzPw0KDQpjb21tZW50IG9uIHJldmlld2luZyB1aSB3aXRoIGFuYSBkdXJpbmcgY29kZSByZXZpZXcuDQoNCmNsZWFuIHVwIG9sZCBnaXRodWIgYWN0aW9ucyAtIA0KDQptYWtlIGdpdGh1YiBhY3Rpb25zIHRvIHByb2QgYW5kIHVhdCByZXN0cmljdGVkIHRvIGEgc21hbGxlciBncm91cA0KDQptYWtlIHByb2QgYW5kIHVhdCBhY3Rpb25zIGhhdmUgYSBkaXNjbGFpbWVyIHdoZW4gcnVubmluZywgYW5kIHRoZW4gaW5jcmVhc2UgdGhlIHZlcmJvc2l0eSBvZiBsb2dnaW5nIHRvIHRlYW1zIHN1Y2ggdGhhdCB0aGUgb3BlcmF0aW9ucyBjb25kdWN0ZWQgYnkgdGhlIGFjdGlvbiBhcmUgbGFpZCBvdXQgZm9yIG5vbi10ZWNobmljYWwgdXNlcnMuDQoNCg0KDQpyZW1vdmU6IGRiLXNjaG1hLnltbA0KbWF5YmU6IHphcC1zY2FuLnltbCwgdGFnLnltbCwgcmVsZWFzZS55bWwsIGltYWdlLXNjYW4tYW5hbHlzaXMsIGNpLWNkLXBpbXMtbWFzdGVyLnltbCwgYXBwLWxvZ2dpbmcueW1sDQoNCndlIHdvdWxkIGxpa2UgdG8gcmVtb3ZlIHRoZSB2ZXJzaW9uIGJ1bXAgZnJvbSBkZXYgd2hlbiB3ZSBoYXZlIGFuIGFsdGVybmF0ZSB3YXkgb2Ygc2VlaW5nIHRoZSBnaXQgY29tbWl0IG9uIHRoZSBpbWFnZS4NCg0KbmVlZCB0byB1cGRhdGUgZW52IGdlbmVyYXRpb24gdG8gYWZmZWN0IG5ldyBsb2NhdGlvbi4=", Mimetype = "text/plain" } }); // Act - var result = this._controller.DownloadFile(1); + var result = this._controller.DownloadFileLatest(1); // Assert this._service.Verify(m => m.DownloadFileLatestAsync(1), Times.Once()); } [Fact] - public async Task DownloadFile_NoResultAsync() + public async Task DownloadFileLatest_NoResultAsync() + { + // Arrange + this._service.Setup(m => m.DownloadFileLatestAsync(1)).ReturnsAsync(new ExternalResponse() { Payload = null }); + + // Act + var result = await this._controller.DownloadFileLatest(1); + + // Assert + var actionResult = Assert.IsType(result); + } + + [Fact] + public void StreamFileLatest_Success() + { + // Arrange + this._service.Setup(m => m.DownloadFileLatestAsync(1)).ReturnsAsync(new ExternalResponse() { Payload = new FileDownloadResponse() { FilePayload = "cmVmYWN0b3IgcHJvcGVydHkgdGFicyB0byB1c2Ugcm91dGVyLCBpbmNsdWRpbmcgbWFuYWdlbWVudC4NCkVuc3VyZSB0aGF0IG1hbmFnZW1lbnQgdGFiIHJlZGlyZWN0cyBiYWNrIHRvIG1hbmFnZW1lbnQgdmlldyBhZnRlciBlZGl0aW5nLg0KDQpkaXNjdXNzIDY4MzkgaW4gZGV2IG1lZXRpbmcgdG1ydw0KDQp3aGF0IGlzIGdvaW5nIG9uIHdpdGggaXNfZGlzYWJsZWQ/IHdoeSBhcmUgd2UgYWRkaW5nIGl0IHRvIGpvaW4gdGFibGVzPw0KDQpjb21tZW50IG9uIHJldmlld2luZyB1aSB3aXRoIGFuYSBkdXJpbmcgY29kZSByZXZpZXcuDQoNCmNsZWFuIHVwIG9sZCBnaXRodWIgYWN0aW9ucyAtIA0KDQptYWtlIGdpdGh1YiBhY3Rpb25zIHRvIHByb2QgYW5kIHVhdCByZXN0cmljdGVkIHRvIGEgc21hbGxlciBncm91cA0KDQptYWtlIHByb2QgYW5kIHVhdCBhY3Rpb25zIGhhdmUgYSBkaXNjbGFpbWVyIHdoZW4gcnVubmluZywgYW5kIHRoZW4gaW5jcmVhc2UgdGhlIHZlcmJvc2l0eSBvZiBsb2dnaW5nIHRvIHRlYW1zIHN1Y2ggdGhhdCB0aGUgb3BlcmF0aW9ucyBjb25kdWN0ZWQgYnkgdGhlIGFjdGlvbiBhcmUgbGFpZCBvdXQgZm9yIG5vbi10ZWNobmljYWwgdXNlcnMuDQoNCg0KDQpyZW1vdmU6IGRiLXNjaG1hLnltbA0KbWF5YmU6IHphcC1zY2FuLnltbCwgdGFnLnltbCwgcmVsZWFzZS55bWwsIGltYWdlLXNjYW4tYW5hbHlzaXMsIGNpLWNkLXBpbXMtbWFzdGVyLnltbCwgYXBwLWxvZ2dpbmcueW1sDQoNCndlIHdvdWxkIGxpa2UgdG8gcmVtb3ZlIHRoZSB2ZXJzaW9uIGJ1bXAgZnJvbSBkZXYgd2hlbiB3ZSBoYXZlIGFuIGFsdGVybmF0ZSB3YXkgb2Ygc2VlaW5nIHRoZSBnaXQgY29tbWl0IG9uIHRoZSBpbWFnZS4NCg0KbmVlZCB0byB1cGRhdGUgZW52IGdlbmVyYXRpb24gdG8gYWZmZWN0IG5ldyBsb2NhdGlvbi4=", Mimetype = "text/plain" } }); + + // Act + var result = this._controller.StreamFileLatest(1); + + // Assert + this._service.Verify(m => m.StreamFileLatestAsync(1), Times.Once()); + } + + [Fact] + public async Task StreamFileLatest_NoResultAsync() { // Arrange this._service.Setup(m => m.DownloadFileLatestAsync(1)).ReturnsAsync(new ExternalResponse() { Payload = null }); // Act - var result = await this._controller.DownloadFile(1); + var result = await this._controller.StreamFileLatest(1); // Assert var actionResult = Assert.IsType(result); diff --git a/source/backend/tests/unit/api/Services/DocumentServiceTest.cs b/source/backend/tests/unit/api/Services/DocumentServiceTest.cs index 06389d7b9e..d0934c482e 100644 --- a/source/backend/tests/unit/api/Services/DocumentServiceTest.cs +++ b/source/backend/tests/unit/api/Services/DocumentServiceTest.cs @@ -27,6 +27,7 @@ using Pims.Api.Models.Requests.Document.UpdateMetadata; using Microsoft.Extensions.Configuration; using Pims.Core.Exceptions; +using Pims.Api.Models; namespace Pims.Api.Test.Services { @@ -887,5 +888,225 @@ public async void DownloadFileAsync_InvalidExtension() Assert.Equal(ExternalResponseStatus.Error, result.Status); } + + [Fact] + public async void StreamFileAsync_Success() + { + // Arrange + var service = this.CreateDocumentServiceWithPermissions(Permissions.DocumentView); + var documentStorageRepository = this._helper.GetService>(); + + documentStorageRepository.Setup(x => x.TryStreamFileAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ExternalResponse() + { + HttpStatusCode = System.Net.HttpStatusCode.OK, + Status = ExternalResponseStatus.Success, + Payload = new FileStreamResponse() + { + FileName = "Test", + }, + }); + + // Act + await service.StreamFileAsync(1, 2); + + // Assert + documentStorageRepository.Verify(x => x.TryStreamFileAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void StreamFileLatestAsync_ShouldThrowException_NotAuthorized() + { + // Arrange + var service = this.CreateDocumentServiceWithPermissions(); + + // Act + Func sut = async () => await service.StreamFileLatestAsync(1); + + // Assert + sut.Should().ThrowAsync(); + } + + [Fact] + public async void StreamFileLatestAsync_UnSuccessfull() + { + // Arrange + var service = this.CreateDocumentServiceWithPermissions(Permissions.DocumentView); + var documentStorageRepository = this._helper.GetService>(); + + documentStorageRepository.Setup(x => x.TryGetDocumentAsync(It.IsAny())) + .ReturnsAsync(new ExternalResponse() + { + HttpStatusCode = System.Net.HttpStatusCode.NotFound, + Message = "ERROR", + Status = ExternalResponseStatus.Error, + }); + + // Act + Func act = async () => await service.StreamFileLatestAsync(1); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async void StreamFileLatestAsync_Successfull_PayloadNull() + { + // Arrange + var service = this.CreateDocumentServiceWithPermissions(Permissions.DocumentView); + var documentStorageRepository = this._helper.GetService>(); + + documentStorageRepository.Setup(x => x.TryGetDocumentAsync(It.IsAny())) + .ReturnsAsync(new ExternalResponse() + { + HttpStatusCode = System.Net.HttpStatusCode.OK, + Message = "Ok", + Status = ExternalResponseStatus.Success, + Payload = null, + }); + + // Act + await service.StreamFileLatestAsync(1); + + // Assert + documentStorageRepository.Verify(x => x.TryStreamFileAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async void StreamFileLatestAsync_InvalidExtension() + { + // Arrange + var service = this.CreateDocumentServiceWithPermissions(Permissions.DocumentView); + var documentStorageRepository = this._helper.GetService>(); + + documentStorageRepository.Setup(x => x.TryGetDocumentAsync(It.IsAny())) + .ReturnsAsync(new ExternalResponse() + { + HttpStatusCode = System.Net.HttpStatusCode.OK, + Message = "Ok", + Status = ExternalResponseStatus.Success, + Payload = new() + { + Id = 12, + FileLatest = new FileLatestModel() + { + Id = 2, + Size = 1, + FileName = "MyFile.exe", + }, + }, + }); + + // Act + var result = await service.DownloadFileLatestAsync(1); + + // Assert + documentStorageRepository.Verify(x => x.TryGetDocumentAsync(It.IsAny()), Times.Once); + documentStorageRepository.Verify(x => x.TryStreamFileAsync(It.IsAny(), It.IsAny()), Times.Never); + Assert.Equal(ExternalResponseStatus.Error, result.Status); + } + + [Fact] + public async void StreamFileLatestAsync_ValidExtension() + { + // Arrange + var service = this.CreateDocumentServiceWithPermissions(Permissions.DocumentView); + var documentStorageRepository = this._helper.GetService>(); + + documentStorageRepository.Setup(x => x.TryGetDocumentAsync(It.IsAny())) + .ReturnsAsync(new ExternalResponse() + { + HttpStatusCode = System.Net.HttpStatusCode.OK, + Message = "Ok", + Status = ExternalResponseStatus.Success, + Payload = new() + { + Id = 12, + FileLatest = new FileLatestModel() + { + Id = 2, + Size = 1, + FileName = "MyFile.pdf", + }, + }, + }); + + // Act + await service.StreamFileLatestAsync(1); + + // Assert + documentStorageRepository.Verify(x => x.TryGetDocumentAsync(It.IsAny()), Times.Once); + documentStorageRepository.Verify(x => x.TryStreamFileAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async void StreamFileLatestAsync_Successfull_Payload_Document() + { + // Arrange + var service = this.CreateDocumentServiceWithPermissions(Permissions.DocumentView); + var documentStorageRepository = this._helper.GetService>(); + + documentStorageRepository.Setup(x => x.TryGetDocumentAsync(It.IsAny())) + .ReturnsAsync(new ExternalResponse() + { + HttpStatusCode = System.Net.HttpStatusCode.OK, + Message = "Ok", + Status = ExternalResponseStatus.Success, + Payload = new DocumentDetailModel() + { + Id = 1, + FileLatest = new FileLatestModel() { Id = 2, FileName = "MyFile.pdf" }, + }, + }); + + documentStorageRepository.Setup(x => x.TryStreamFileAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ExternalResponse() + { + HttpStatusCode = System.Net.HttpStatusCode.OK, + Message = "Ok", + Status = ExternalResponseStatus.Success, + Payload = new() + { + Size = 1, + FileName = "MyFile.pdf", + EncodingType = "base64", + }, + }); + + // Act + await service.StreamFileLatestAsync(1); + + // Assert + documentStorageRepository.Verify(x => x.TryStreamFileAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async void StreamFileAsync_InvalidExtension() + { + // Arrange + var service = this.CreateDocumentServiceWithPermissions(Permissions.DocumentView); + var documentStorageRepository = this._helper.GetService>(); + + documentStorageRepository.Setup(x => x.TryStreamFileAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ExternalResponse() + { + HttpStatusCode = System.Net.HttpStatusCode.OK, + Status = ExternalResponseStatus.Success, + Payload = new FileStreamResponse() + { + FileName = "Test.exe", + FileNameExtension = "exe", + FileNameWithoutExtension = "Test", + }, + }); + + // Act + var result = await service.StreamFileAsync(1, 2); + + // Assert + documentStorageRepository.Verify(x => x.TryStreamFileAsync(It.IsAny(), It.IsAny()), Times.Once); + + Assert.Equal(ExternalResponseStatus.Error, result.Status); + } } } diff --git a/source/frontend/src/features/documents/DocumentPreviewContainer.test.tsx b/source/frontend/src/features/documents/DocumentPreviewContainer.test.tsx index 9219dab6c1..7317f0091b 100644 --- a/source/frontend/src/features/documents/DocumentPreviewContainer.test.tsx +++ b/source/frontend/src/features/documents/DocumentPreviewContainer.test.tsx @@ -33,6 +33,15 @@ vi.mocked(useDocumentProvider).mockImplementation(() => ({ downloadWrappedDocumentFileLoading: false, downloadWrappedDocumentFileLatest: vi.fn(), downloadWrappedDocumentFileLatestLoading: false, + streamWrappedDocumentFile: vi.fn(), + streamWrappedDocumentFileLoading: false, + streamWrappedDocumentFileLatest: vi.fn(), + streamWrappedDocumentFileLatestLoading: false, + streamDocumentFile: vi.fn(), + streamDocumentFileLoading: false, + streamDocumentFileLatest: vi.fn(), + streamDocumentFileLatestLoading: false, + streamDocumentFileLatestResponse: null, retrieveDocumentTypeMetadata: vi.fn(), retrieveDocumentTypeMetadataLoading: false, getDocumentTypes: vi.fn(), diff --git a/source/frontend/src/features/documents/DownloadDocumentButton.tsx b/source/frontend/src/features/documents/DownloadDocumentButton.tsx index 10f7a738e3..0f0ac44d08 100644 --- a/source/frontend/src/features/documents/DownloadDocumentButton.tsx +++ b/source/frontend/src/features/documents/DownloadDocumentButton.tsx @@ -1,3 +1,5 @@ +import { AxiosResponse } from 'axios'; +import { File } from 'buffer'; import fileDownload from 'js-file-download'; import { FaDownload } from 'react-icons/fa'; import { toast } from 'react-toastify'; @@ -22,18 +24,18 @@ const DownloadDocumentButton: React.FunctionComponent< async function downloadFile(mayanDocumentId: number, mayanFileId?: number) { if (mayanFileId !== undefined) { - const data = await provider.downloadWrappedDocumentFile(mayanDocumentId, mayanFileId); + const data = await provider.streamDocumentFile(mayanDocumentId, mayanFileId); if (data) { - createFileDownload(data); + showFile(data); } else { toast.error( 'Failed to download document. If this error persists, contact a system administrator.', ); } } else { - const data = await provider.downloadWrappedDocumentFileLatest(mayanDocumentId); + const data = await provider.streamDocumentFileLatest(mayanDocumentId); if (data) { - createFileDownload(data); + showFile(data); } else { toast.error( 'Failed to download document. If this error persists, contact a system administrator.', @@ -42,7 +44,7 @@ const DownloadDocumentButton: React.FunctionComponent< } } - if (!props.isFileAvailable && !provider.downloadWrappedDocumentFileLoading) { + if (!props.isFileAvailable && !provider.streamDocumentFileLoading) { return ( { downloadFile(props.mayanDocumentId, props.mayanFileId); }} @@ -102,6 +98,15 @@ export const createFileDownload = async ( } }; +const showFile = async (response: AxiosResponse, fileName?: string) => { + const groups = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/g.exec( + response.headers['content-disposition'], + ); + if (groups?.length) { + fileDownload(response.data, fileName ?? groups[1].replace(/['"]/g, '')); + } +}; + export const b64toBlob = (b64Data: string, contentType: string, sliceSize = 512) => { const byteCharacters = atob(b64Data); const byteArrays = []; diff --git a/source/frontend/src/features/documents/hooks/useDocumentProvider.ts b/source/frontend/src/features/documents/hooks/useDocumentProvider.ts index 0166102e7d..7bf6269f92 100644 --- a/source/frontend/src/features/documents/hooks/useDocumentProvider.ts +++ b/source/frontend/src/features/documents/hooks/useDocumentProvider.ts @@ -1,4 +1,5 @@ import { AxiosError, AxiosResponse } from 'axios'; +import { File } from 'buffer'; import { useCallback } from 'react'; import { toast } from 'react-toastify'; @@ -28,6 +29,8 @@ export const useDocumentProvider = () => { getDocumentDetailApiCall, downloadWrappedDocumentFileApiCall, downloadWrappedDocumentFileLatestApiCall, + streamDocumentFileLatestApiCall, + streamDocumentFileApiCall, updateDocumentMetadataApiCall, getDocumentTypesApiCall, downloadDocumentFilePageImageApiCall, @@ -208,6 +211,29 @@ export const useDocumentProvider = () => { }, []), }); + // Provides functionality for stream a document file + const { execute: streamDocumentFile, loading: streamDocumentFileLoading } = useApiRequestWrapper< + ( + mayanDocumentId: number, + mayanFileId: number, + ) => Promise>> + >({ + requestFunction: useCallback( + async (mayanDocumentId: number, mayanFileId: number) => + await streamDocumentFileApiCall(mayanDocumentId, mayanFileId), + [streamDocumentFileApiCall], + ) as unknown as () => Promise>>, + requestName: 'StreamDocumentFile', + rawResponse: true, + onError: useCallback((axiosError: AxiosError) => { + if (axiosError?.response?.status === 400) { + toast.error(axiosError?.response.data.error); + return Promise.resolve(); + } + return Promise.reject(axiosError); + }, []), + }); + // Provides functionality for download the latest file for a document const { execute: downloadWrappedDocumentFileLatest, @@ -231,6 +257,29 @@ export const useDocumentProvider = () => { }, []), }); + // Provides functionality for streaming the latest file for a document + const { + execute: streamDocumentFileLatest, + response: streamDocumentFileLatestResponse, + loading: streamDocumentFileLatestLoading, + } = useApiRequestWrapper< + (documendId: number) => Promise>> + >({ + requestFunction: useCallback( + async (mayanDocumentId: number) => await streamDocumentFileLatestApiCall(mayanDocumentId), + [streamDocumentFileLatestApiCall], + ) as unknown as () => Promise>>, + rawResponse: true, + requestName: 'StreamDocumentFileLatest', + onError: useCallback((axiosError: AxiosError) => { + if (axiosError?.response?.status === 400) { + toast.error(axiosError?.response.data.error); + return Promise.resolve(); + } + return Promise.reject(axiosError); + }, []), + }); + const { execute: downloadDocumentFilePageImage, loading: downloadDocumentFilePageImageLoading } = useApiRequestWrapper< ( @@ -289,6 +338,11 @@ export const useDocumentProvider = () => { downloadWrappedDocumentFileLatest, downloadWrappedDocumentFileLatestResponse, downloadWrappedDocumentFileLatestLoading, + streamDocumentFile, + streamDocumentFileLoading, + streamDocumentFileLatest, + streamDocumentFileLatestResponse, + streamDocumentFileLatestLoading, retrieveDocumentTypeMetadata, retrieveDocumentTypeMetadataLoading, getDocumentTypes, diff --git a/source/frontend/src/hooks/pims-api/useApiDocuments.ts b/source/frontend/src/hooks/pims-api/useApiDocuments.ts index 18d89166ef..43038a2330 100644 --- a/source/frontend/src/hooks/pims-api/useApiDocuments.ts +++ b/source/frontend/src/hooks/pims-api/useApiDocuments.ts @@ -1,3 +1,4 @@ +import { File } from 'buffer'; import React from 'react'; import { ApiGen_CodeTypes_DocumentRelationType } from '@/models/api/generated/ApiGen_CodeTypes_DocumentRelationType'; @@ -75,6 +76,12 @@ export const useApiDocuments = () => { `/documents/storage/${mayanDocumentId}/download-wrapped`, ), + streamDocumentFileApiCall: (mayanDocumentId: number, mayanFileId: number) => + api.get(`/documents/storage/${mayanDocumentId}/stream/${mayanFileId}`), + + streamDocumentFileLatestApiCall: (mayanDocumentId: number) => + api.get(`/documents/storage/${mayanDocumentId}/stream`), + uploadDocumentRelationshipApiCall: ( relationshipType: ApiGen_CodeTypes_DocumentRelationType, parentId: string, diff --git a/source/frontend/src/hooks/util/useApiRequestWrapper.ts b/source/frontend/src/hooks/util/useApiRequestWrapper.ts index 3f558ac343..64f5354cfd 100644 --- a/source/frontend/src/hooks/util/useApiRequestWrapper.ts +++ b/source/frontend/src/hooks/util/useApiRequestWrapper.ts @@ -89,7 +89,7 @@ export const useApiRequestWrapper = < setStatus(response?.status); setResponse(response?.data); onSuccess && onSuccess(response?.data); - return response?.data; + return rawResponse ? response : response?.data; } catch (e) { if (!axios.isAxiosError(e) && throwError) { throw e;