diff --git a/src/SIL.XForge.Scripture/Controllers/ParatextController.cs b/src/SIL.XForge.Scripture/Controllers/ParatextController.cs index 07bf7fd138..3012d83594 100644 --- a/src/SIL.XForge.Scripture/Controllers/ParatextController.cs +++ b/src/SIL.XForge.Scripture/Controllers/ParatextController.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Security; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -24,24 +26,69 @@ namespace SIL.XForge.Scripture.Controllers; public class ParatextController : ControllerBase { private readonly IExceptionHandler _exceptionHandler; - private readonly IRepository _userSecrets; + private readonly IMachineProjectService _machineProjectService; private readonly IParatextService _paratextService; private readonly IUserAccessor _userAccessor; + private readonly IRepository _userSecrets; public ParatextController( - IRepository userSecrets, + IExceptionHandler exceptionHandler, + IMachineProjectService machineProjectService, IParatextService paratextService, IUserAccessor userAccessor, - IExceptionHandler exceptionHandler + IRepository userSecrets ) { _userSecrets = userSecrets; + _machineProjectService = machineProjectService; _paratextService = paratextService; _userAccessor = userAccessor; _exceptionHandler = exceptionHandler; _exceptionHandler.RecordUserIdForException(_userAccessor.UserId); } + /// + /// Download a project as a Paratext zip file. + /// + /// The Scripture Forge project identifier. + /// The cancellation token. + /// The zip data for the project, if present in Scripture Forge. + /// The zip file was successfully downloaded. + /// The user is not a system administrator or serval administrator. + /// The project does not exist, is a resource, or could not be found on disk. + [HttpGet("projects/{projectId}/download")] + [ProducesResponseType(typeof(FileStreamResult), 200)] + public async Task DownloadProjectAsync(string projectId, CancellationToken cancellationToken) + { + // Only a system administrator or serval administrator can download a project + if ( + !( + _userAccessor.SystemRoles.Contains(SystemRole.ServalAdmin) + || _userAccessor.SystemRoles.Contains(SystemRole.SystemAdmin) + ) + ) + { + return Forbid(); + } + + string fileName; + MemoryStream outputStream = new MemoryStream(); + try + { + fileName = await _machineProjectService.GetProjectZipAsync(projectId, outputStream, cancellationToken); + } + catch (DataNotFoundException e) + { + return NotFound(e.Message); + } + + // Reset the stream to the start + outputStream.Seek(0, SeekOrigin.Begin); + + // Return the zip file stream + return File(outputStream, "application/zip", fileName); + } + /// /// Retrieves the Paratext projects the user has access to. /// diff --git a/src/SIL.XForge.Scripture/Services/IMachineProjectService.cs b/src/SIL.XForge.Scripture/Services/IMachineProjectService.cs index ae1ee64d4c..cca7cf0548 100644 --- a/src/SIL.XForge.Scripture/Services/IMachineProjectService.cs +++ b/src/SIL.XForge.Scripture/Services/IMachineProjectService.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Threading; using System.Threading.Tasks; using Serval.Client; @@ -25,6 +26,7 @@ Task BuildProjectForBackgroundJobAsync( bool preTranslate, CancellationToken cancellationToken ); + Task GetProjectZipAsync(string sfProjectId, Stream outputStream, CancellationToken cancellationToken); Task GetTranslationEngineTypeAsync(bool preTranslate); Task RemoveProjectAsync( string curUserId, diff --git a/src/SIL.XForge.Scripture/Services/MachineProjectService.cs b/src/SIL.XForge.Scripture/Services/MachineProjectService.cs index fb8c60360a..5a4ef2f0c5 100644 --- a/src/SIL.XForge.Scripture/Services/MachineProjectService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineProjectService.cs @@ -378,6 +378,58 @@ await projectSecrets.UpdateAsync( } } + /// + /// Gets the project as a zip file, writing it to . + /// + /// The Scripture Forge project identifier. + /// The output stream. + /// The cancellation token. + /// The name of the zip file, e.g. ABC.zip. + /// The project does not exist, is a resource, or could not be found on disk. + public async Task GetProjectZipAsync( + string sfProjectId, + Stream outputStream, + CancellationToken cancellationToken + ) + { + // Load the project from the realtime service + Attempt attempt = await realtimeService.TryGetSnapshotAsync(sfProjectId); + if (!attempt.TryResult(out SFProject project)) + { + throw new DataNotFoundException("The project does not exist."); + } + + // Ensure that the project is not a resource + if (paratextService.IsResource(project.ParatextId)) + { + throw new DataNotFoundException("You cannot download a resource."); + } + + // Get the path to the Paratext directory + string path = Path.Combine(siteOptions.Value.SiteDir, "sync", project.ParatextId, "target"); + + // Ensure that the path exists + if (!fileSystemService.DirectoryExists(path)) + { + throw new DataNotFoundException($"The following directory could not be found: {path}"); + } + + // Create the zip file from the directory in memory + using var archive = new ZipArchive(outputStream, ZipArchiveMode.Create, true); + foreach (string filePath in fileSystemService.EnumerateFiles(path)) + { + await using Stream fileStream = fileSystemService.OpenFile(filePath, FileMode.Open); + ZipArchiveEntry entry = archive.CreateEntry(Path.GetFileName(filePath)); + await using Stream entryStream = entry.Open(); + await fileStream.CopyToAsync(entryStream, cancellationToken); + } + + // Strip invalid characters from the file name + string fileName = Path.GetInvalidFileNameChars() + .Aggregate(project.ShortName, (current, c) => current.Replace(c.ToString(), string.Empty)); + return $"{fileName}.zip"; + } + /// /// Gets the translation engine type string for Serval. /// diff --git a/test/SIL.XForge.Scripture.Tests/Controllers/ParatextControllerTests.cs b/test/SIL.XForge.Scripture.Tests/Controllers/ParatextControllerTests.cs index e96392f4d9..f4c8a3f8a7 100644 --- a/test/SIL.XForge.Scripture.Tests/Controllers/ParatextControllerTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Controllers/ParatextControllerTests.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using NSubstitute; @@ -24,6 +26,73 @@ public class ParatextControllerTests private static readonly DateTime Timestamp = DateTime.UtcNow; + [Test] + public async Task DownloadProjectAsync_Forbidden() + { + // Set up test environment + var env = new TestEnvironment(); + env.UserAccessor.SystemRoles.Returns([SystemRole.User]); + + // SUT + ActionResult actual = await env.Controller.DownloadProjectAsync(Project01, CancellationToken.None); + + Assert.IsInstanceOf(actual); + } + + [Test] + public async Task DownloadProjectAsync_NotFound() + { + // Set up test environment + var env = new TestEnvironment(); + const string message = "Not Found"; + env.MachineProjectService.GetProjectZipAsync(Project01, Arg.Any(), CancellationToken.None) + .Throws(new DataNotFoundException(message)); + + // SUT + ActionResult actual = await env.Controller.DownloadProjectAsync(Project01, CancellationToken.None); + + Assert.IsInstanceOf(actual); + Assert.AreEqual(message, (actual as NotFoundObjectResult)?.Value); + } + + [Test] + public async Task DownloadProjectAsync_Success() + { + // Set up test environment + var env = new TestEnvironment(); + + // SUT + ActionResult actual = await env.Controller.DownloadProjectAsync(Project01, CancellationToken.None); + + // Check the file metadata + Assert.IsInstanceOf(actual); + var fileStreamResult = (FileStreamResult)actual; + Assert.AreEqual("application/zip", fileStreamResult.ContentType); + Assert.AreEqual("P01.zip", fileStreamResult.FileDownloadName); + + // Check the file stream + Stream stream = fileStreamResult.FileStream; + stream.Seek(0, SeekOrigin.Begin); + byte[] bytes = new byte[4]; + Memory buffer = new Memory(bytes); + int length = await stream.ReadAsync(buffer, CancellationToken.None); + Assert.AreEqual(4, length); + Assert.AreEqual(env.ZipHeader, bytes); + } + + [Test] + public async Task DownloadProjectAsync_SystemAdmin() + { + // Set up test environment + var env = new TestEnvironment(); + env.UserAccessor.SystemRoles.Returns([SystemRole.SystemAdmin]); + + // SUT + ActionResult actual = await env.Controller.DownloadProjectAsync(Project01, CancellationToken.None); + + Assert.IsInstanceOf(actual); + } + [Test] public async Task GetRevisionHistoryAsync_Forbidden() { @@ -181,17 +250,32 @@ private class TestEnvironment Version = 1, }; + public readonly byte[] ZipHeader = [80, 75, 05, 06]; + public TestEnvironment() { IExceptionHandler exceptionHandler = Substitute.For(); UserAccessor = Substitute.For(); UserAccessor.UserId.Returns(User01); + UserAccessor.SystemRoles.Returns([SystemRole.ServalAdmin]); MemoryRepository userSecrets = new MemoryRepository( new[] { new UserSecret { Id = User01 } } ); + MachineProjectService = Substitute.For(); + MachineProjectService + .GetProjectZipAsync(Project01, Arg.Any(), CancellationToken.None) + .Returns(async args => + { + // Write the zip header, and return the file name + Stream stream = args.ArgAt(1); + var buffer = new ReadOnlyMemory(ZipHeader); + await stream.WriteAsync(buffer, CancellationToken.None); + return "P01.zip"; + }); + ParatextService = Substitute.For(); ParatextService .GetRevisionHistoryAsync(Arg.Any(), Project01, Book, Chapter) @@ -200,10 +284,17 @@ public TestEnvironment() .GetSnapshotAsync(Arg.Any(), Project01, Book, Chapter, Timestamp) .Returns(Task.FromResult(TestSnapshot)); - Controller = new ParatextController(userSecrets, ParatextService, UserAccessor, exceptionHandler); + Controller = new ParatextController( + exceptionHandler, + MachineProjectService, + ParatextService, + UserAccessor, + userSecrets + ); } public ParatextController Controller { get; } + public IMachineProjectService MachineProjectService { get; } public IParatextService ParatextService { get; } public IUserAccessor UserAccessor { get; } diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs index 1676559417..3202edf435 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.IO.Compression; using System.Linq; using System.Text; using System.Threading; @@ -1020,6 +1021,64 @@ public async Task GetTranslationEngineTypeAsync_Smt() Assert.AreEqual(MachineProjectService.SmtTransfer, actual); } + [Test] + public async Task GetProjectZipAsync_Success() + { + var env = new TestEnvironment(); + MemoryStream outputStream = new MemoryStream(); + + // SUT + string actual = await env.Service.GetProjectZipAsync(Project01, outputStream, CancellationToken.None); + Assert.AreEqual("P01.zip", actual); + + // Validate the zip file + outputStream.Seek(0, SeekOrigin.Begin); + using var archive = new ZipArchive(outputStream, ZipArchiveMode.Read); + Assert.AreEqual(1, archive.Entries.Count); + Assert.AreEqual("file", archive.Entries[0].FullName); + } + + [Test] + public void GetProjectZipAsync_ThrowsExceptionWhenProjectDirectoryMissing() + { + // Set up test environment + var env = new TestEnvironment(); + env.FileSystemService.DirectoryExists(Arg.Any()).Returns(false); + MemoryStream outputStream = new MemoryStream(); + + // SUT + Assert.ThrowsAsync( + () => env.Service.GetProjectZipAsync(Project01, outputStream, CancellationToken.None) + ); + } + + [Test] + public void GetProjectZipAsync_ThrowsExceptionWhenProjectDocumentMissing() + { + // Set up test environment + var env = new TestEnvironment(); + MemoryStream outputStream = new MemoryStream(); + + // SUT + Assert.ThrowsAsync( + () => env.Service.GetProjectZipAsync("invalid_project_id", outputStream, CancellationToken.None) + ); + } + + [Test] + public void GetProjectZipAsync_ThrowsExceptionWhenProjectIsAResource() + { + // Set up test environment + var env = new TestEnvironment(); + env.ParatextService.IsResource(Arg.Any()).Returns(true); + MemoryStream outputStream = new MemoryStream(); + + // SUT + Assert.ThrowsAsync( + () => env.Service.GetProjectZipAsync(Project01, outputStream, CancellationToken.None) + ); + } + [Test] public void RemoveProjectAsync_ThrowsExceptionWhenProjectSecretMissing() {