Skip to content

Commit

Permalink
SF-2574 Create endpoint to download a project as a zip
Browse files Browse the repository at this point in the history
  • Loading branch information
pmachapman committed Mar 17, 2024
1 parent 4ce3934 commit 2472fc1
Show file tree
Hide file tree
Showing 5 changed files with 255 additions and 4 deletions.
53 changes: 50 additions & 3 deletions src/SIL.XForge.Scripture/Controllers/ParatextController.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -24,24 +26,69 @@ namespace SIL.XForge.Scripture.Controllers;
public class ParatextController : ControllerBase
{
private readonly IExceptionHandler _exceptionHandler;
private readonly IRepository<UserSecret> _userSecrets;
private readonly IMachineProjectService _machineProjectService;
private readonly IParatextService _paratextService;
private readonly IUserAccessor _userAccessor;
private readonly IRepository<UserSecret> _userSecrets;

public ParatextController(
IRepository<UserSecret> userSecrets,
IExceptionHandler exceptionHandler,
IMachineProjectService machineProjectService,
IParatextService paratextService,
IUserAccessor userAccessor,
IExceptionHandler exceptionHandler
IRepository<UserSecret> userSecrets
)
{
_userSecrets = userSecrets;
_machineProjectService = machineProjectService;
_paratextService = paratextService;
_userAccessor = userAccessor;
_exceptionHandler = exceptionHandler;
_exceptionHandler.RecordUserIdForException(_userAccessor.UserId);
}

/// <summary>
/// Download a project as a Paratext zip file.
/// </summary>
/// <param name="projectId">The Scripture Forge project identifier.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The zip data for the project, if present in Scripture Forge.</returns>
/// <response code="200">The zip file was successfully downloaded.</response>
/// <response code="403">The user is not a system administrator or serval administrator.</response>
/// <response code="404">The project does not exist, is a resource, or could not be found on disk.</response>
[HttpGet("projects/{projectId}/download")]
[ProducesResponseType(typeof(FileStreamResult), 200)]
public async Task<ActionResult> 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);
}

/// <summary>
/// Retrieves the Paratext projects the user has access to.
/// </summary>
Expand Down
2 changes: 2 additions & 0 deletions src/SIL.XForge.Scripture/Services/IMachineProjectService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Serval.Client;
Expand Down Expand Up @@ -25,6 +26,7 @@ Task BuildProjectForBackgroundJobAsync(
bool preTranslate,
CancellationToken cancellationToken
);
Task<string> GetProjectZipAsync(string sfProjectId, Stream outputStream, CancellationToken cancellationToken);
Task<string> GetTranslationEngineTypeAsync(bool preTranslate);
Task RemoveProjectAsync(
string curUserId,
Expand Down
52 changes: 52 additions & 0 deletions src/SIL.XForge.Scripture/Services/MachineProjectService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,58 @@ await projectSecrets.UpdateAsync(
}
}

/// <summary>
/// Gets the project as a zip file, writing it to <paramref name="outputStream"/>.
/// </summary>
/// <param name="sfProjectId">The Scripture Forge project identifier.</param>
/// <param name="outputStream">The output stream.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The name of the zip file, e.g. <c>ABC.zip</c>.</returns>
/// <exception cref="DataNotFoundException">The project does not exist, is a resource, or could not be found on disk.</exception>
public async Task<string> GetProjectZipAsync(
string sfProjectId,
Stream outputStream,
CancellationToken cancellationToken
)
{
// Load the project from the realtime service
Attempt<SFProject> attempt = await realtimeService.TryGetSnapshotAsync<SFProject>(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";
}

/// <summary>
/// Gets the translation engine type string for Serval.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<ForbidResult>(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<Stream>(), CancellationToken.None)
.Throws(new DataNotFoundException(message));

// SUT
ActionResult actual = await env.Controller.DownloadProjectAsync(Project01, CancellationToken.None);

Assert.IsInstanceOf<NotFoundObjectResult>(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<FileStreamResult>(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<byte> buffer = new Memory<byte>(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<FileStreamResult>(actual);
}

[Test]
public async Task GetRevisionHistoryAsync_Forbidden()
{
Expand Down Expand Up @@ -181,17 +250,32 @@ private class TestEnvironment
Version = 1,
};

public readonly byte[] ZipHeader = [80, 75, 05, 06];

public TestEnvironment()
{
IExceptionHandler exceptionHandler = Substitute.For<IExceptionHandler>();

UserAccessor = Substitute.For<IUserAccessor>();
UserAccessor.UserId.Returns(User01);
UserAccessor.SystemRoles.Returns([SystemRole.ServalAdmin]);

MemoryRepository<UserSecret> userSecrets = new MemoryRepository<UserSecret>(
new[] { new UserSecret { Id = User01 } }
);

MachineProjectService = Substitute.For<IMachineProjectService>();
MachineProjectService
.GetProjectZipAsync(Project01, Arg.Any<Stream>(), CancellationToken.None)
.Returns(async args =>
{
// Write the zip header, and return the file name
Stream stream = args.ArgAt<Stream>(1);
var buffer = new ReadOnlyMemory<byte>(ZipHeader);
await stream.WriteAsync(buffer, CancellationToken.None);
return "P01.zip";
});

ParatextService = Substitute.For<IParatextService>();
ParatextService
.GetRevisionHistoryAsync(Arg.Any<UserSecret>(), Project01, Book, Chapter)
Expand All @@ -200,10 +284,17 @@ public TestEnvironment()
.GetSnapshotAsync(Arg.Any<UserSecret>(), 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; }

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<string>()).Returns(false);
MemoryStream outputStream = new MemoryStream();

// SUT
Assert.ThrowsAsync<DataNotFoundException>(
() => 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<DataNotFoundException>(
() => 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<string>()).Returns(true);
MemoryStream outputStream = new MemoryStream();

// SUT
Assert.ThrowsAsync<DataNotFoundException>(
() => env.Service.GetProjectZipAsync(Project01, outputStream, CancellationToken.None)
);
}

[Test]
public void RemoveProjectAsync_ThrowsExceptionWhenProjectSecretMissing()
{
Expand Down

0 comments on commit 2472fc1

Please sign in to comment.