Skip to content

Commit

Permalink
Check file extension for validation (#70)
Browse files Browse the repository at this point in the history
  • Loading branch information
domi-b authored Nov 16, 2023
2 parents 0ee35b9 + e151cca commit c7867af
Show file tree
Hide file tree
Showing 12 changed files with 194 additions and 23 deletions.
2 changes: 1 addition & 1 deletion src/GeoCop.Api/ContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public static void SeedOperate(this Context context)
.StrictMode(true)
.RuleFor(o => o.Id, f => 0)
.RuleFor(o => o.Name, f => f.Commerce.ProductName())
.RuleFor(o => o.FileTypes, f => new string[] { f.System.CommonFileExt(), f.System.CommonFileExt() }.Distinct().ToArray())
.RuleFor(o => o.FileTypes, f => new string[] { "." + f.System.CommonFileExt(), "." + f.System.CommonFileExt() }.Distinct().ToArray())
.RuleFor(o => o.SpatialExtent, f => f.Address.GetExtent())
.RuleFor(o => o.Organisations, f => f.PickRandom(context.Organisations.ToList(), 1).ToList())
.RuleFor(o => o.Deliveries, _ => new List<Delivery>());
Expand Down
14 changes: 14 additions & 0 deletions src/GeoCop.Api/Contracts/ValidationSettingsResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace GeoCop.Api.Contracts
{
/// <summary>
/// The validation settings response schema.
/// </summary>
public class ValidationSettingsResponse
{
/// <summary>
/// File extensions that are allowed for upload.
/// All entries start with a "." like ".txt", ".xml" and the collection can include ".*" (all files allowed).
/// </summary>
public ICollection<string> AllowedFileExtensions { get; set; } = new List<string>();
}
}
22 changes: 22 additions & 0 deletions src/GeoCop.Api/Controllers/UploadController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Asp.Versioning;
using GeoCop.Api.Contracts;
using GeoCop.Api.Validation;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
Expand Down Expand Up @@ -26,6 +27,20 @@ public UploadController(ILogger<UploadController> logger, IValidationService val
this.validationService = validationService;
}

/// <summary>
/// Returns the validation settings.
/// </summary>
/// <returns>Configuration settings for validations.</returns>
[HttpGet]
[SwaggerResponse(StatusCodes.Status200OK, "The specified settings for uploading files.", typeof(ValidationSettingsResponse), new[] { "application/json" })]
public async Task<IActionResult> GetValidationSettings()
{
return Ok(new ValidationSettingsResponse
{
AllowedFileExtensions = await validationService.GetSupportedFileExtensionsAsync(),
});
}

/// <summary>
/// Schedules a new job for the given <paramref name="file"/>.
/// </summary>
Expand Down Expand Up @@ -73,6 +88,13 @@ public async Task<IActionResult> UploadAsync(ApiVersion version, IFormFile file)
{
if (file == null) return Problem($"Form data <{nameof(file)}> cannot be empty.", statusCode: StatusCodes.Status400BadRequest);

var fileExtension = Path.GetExtension(file.FileName);
if (!await validationService.IsFileExtensionSupportedAsync(fileExtension))
{
logger.LogTrace("File extension <{FileExtension}> is not supported.", fileExtension);
return Problem($"File extension <{fileExtension}> is not supported.", statusCode: StatusCodes.Status400BadRequest);
}

var (validationJob, fileHandle) = validationService.CreateValidationJob(file.FileName);
using (fileHandle)
{
Expand Down
14 changes: 14 additions & 0 deletions src/GeoCop.Api/Validation/IValidationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,19 @@ public interface IValidationService
/// <param name="jobId">The id of the validation job.</param>
/// <returns>Status information for the validation job with the specified <paramref name="jobId"/>.</returns>
ValidationJobStatus? GetJobStatus(Guid jobId);

/// <summary>
/// Gets all file extensions that are supported for upload.
/// All entries start with a "." like ".txt", ".xml" and the collection can include ".*" (all files allowed).
/// </summary>
/// <returns>Supported file extensions.</returns>
Task<ICollection<string>> GetSupportedFileExtensionsAsync();

/// <summary>
/// Checks if the specified <paramref name="fileExtension"/> is supported for upload.
/// </summary>
/// <param name="fileExtension">Extension of the uploaded file starting with ".".</param>
/// <returns>True, if the <paramref name="fileExtension"/> is supported.</returns>
Task<bool> IsFileExtensionSupportedAsync(string fileExtension);
}
}
5 changes: 5 additions & 0 deletions src/GeoCop.Api/Validation/IValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ public interface IValidator
/// </summary>
string Name { get; }

/// <summary>
/// Gets the supported file extensions.
/// </summary>
Task<ICollection<string>> GetSupportedFileExtensionsAsync();

/// <summary>
/// Asynchronously validates the <paramref name="validationJob"/> specified.
/// Its file must be accessible by an <see cref="IFileProvider"/> when executing this function.
Expand Down
13 changes: 13 additions & 0 deletions src/GeoCop.Api/Validation/Interlis/IliCheckSettingsResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace GeoCop.Api.Validation.Interlis
{
/// <summary>
/// Result of a settings query of interlis-check-service at /api/v1/settings.
/// </summary>
public class IliCheckSettingsResponse
{
/// <summary>
/// The accepted file types.
/// </summary>
public string? AcceptedFileTypes { get; set; }
}
}
13 changes: 13 additions & 0 deletions src/GeoCop.Api/Validation/Interlis/InterlisValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ namespace GeoCop.Api.Validation.Interlis
public class InterlisValidator : IValidator
{
private const string UploadUrl = "/api/v1/upload";
private const string SettingsUrl = "/api/v1/settings";
private static readonly TimeSpan pollInterval = TimeSpan.FromSeconds(2);

private readonly ILogger<InterlisValidator> logger;
private readonly IFileProvider fileProvider;
private readonly HttpClient httpClient;
private readonly JsonSerializerOptions jsonSerializerOptions;
private ICollection<string>? supportedFileExtensions;

/// <inheritdoc/>
public string Name => "ilicheck";
Expand All @@ -33,6 +35,17 @@ public InterlisValidator(ILogger<InterlisValidator> logger, IFileProvider filePr
jsonSerializerOptions = jsonOptions.Value.JsonSerializerOptions;
}

/// <inheritdoc/>
public async Task<ICollection<string>> GetSupportedFileExtensionsAsync()
{
if (supportedFileExtensions != null) return supportedFileExtensions;

var response = await httpClient.GetAsync(SettingsUrl).ConfigureAwait(false);
var configResult = await ReadSuccessResponseJsonAsync<IliCheckSettingsResponse>(response, CancellationToken.None).ConfigureAwait(false);
supportedFileExtensions = configResult.AcceptedFileTypes?.Split(", ");
return supportedFileExtensions ?? Array.Empty<string>();
}

/// <inheritdoc/>
public async Task<ValidatorResult> ExecuteAsync(ValidationJob validationJob, CancellationToken cancellationToken)
{
Expand Down
63 changes: 61 additions & 2 deletions src/GeoCop.Api/Validation/ValidationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@ public class ValidationService : IValidationService
private readonly IFileProvider fileProvider;
private readonly IValidationRunner validationRunner;
private readonly IEnumerable<IValidator> validators;
private readonly Context context;

/// <summary>
/// Initializes a new instance of the <see cref="ValidationService"/> class.
/// </summary>
public ValidationService(IFileProvider fileProvider, IValidationRunner validationRunner, IEnumerable<IValidator> validators)
public ValidationService(IFileProvider fileProvider, IValidationRunner validationRunner, IEnumerable<IValidator> validators, Context context)
{
this.fileProvider = fileProvider;
this.validationRunner = validationRunner;
this.validators = validators;
this.context = context;
}

/// <inheritdoc/>
Expand All @@ -34,7 +36,18 @@ public ValidationService(IFileProvider fileProvider, IValidationRunner validatio
/// <inheritdoc/>
public async Task<ValidationJobStatus> StartValidationJobAsync(ValidationJob validationJob)
{
await validationRunner.EnqueueJobAsync(validationJob, validators);
var fileExtension = Path.GetExtension(validationJob.TempFileName);
var supportedValidators = new List<IValidator>();
foreach (var validator in validators)
{
var supportedExtensions = await validator.GetSupportedFileExtensionsAsync();
if (IsExtensionSupported(supportedExtensions, fileExtension))
{
supportedValidators.Add(validator);
}
}

await validationRunner.EnqueueJobAsync(validationJob, supportedValidators);
return GetJobStatus(validationJob.Id) ?? throw new InvalidOperationException("The validation job was not enqueued.");
}

Expand All @@ -49,5 +62,51 @@ public async Task<ValidationJobStatus> StartValidationJobAsync(ValidationJob val
{
return validationRunner.GetJobStatus(jobId);
}

/// <inheritdoc/>
public async Task<ICollection<string>> GetSupportedFileExtensionsAsync()
{
var mandateFileExtensions = GetFileExtensionsForDeliveryMandates();
var validatorFileExtensions = await GetFileExtensionsForValidatorsAsync();

return mandateFileExtensions
.Union(validatorFileExtensions)
.OrderBy(ext => ext)
.ToList();
}

/// <inheritdoc/>
public async Task<bool> IsFileExtensionSupportedAsync(string fileExtension)
{
var extensions = await GetSupportedFileExtensionsAsync();
return IsExtensionSupported(extensions, fileExtension);
}

private HashSet<string> GetFileExtensionsForDeliveryMandates()
{
return context.DeliveryMandates
.Select(mandate => mandate.FileTypes)
.AsEnumerable()
.SelectMany(ext => ext)
.Select(ext => ext.ToLowerInvariant())
.ToHashSet();
}

private async Task<HashSet<string>> GetFileExtensionsForValidatorsAsync()
{
var tasks = validators.Select(validator => validator.GetSupportedFileExtensionsAsync());

var validatorFileExtensions = await Task.WhenAll(tasks);

return validatorFileExtensions
.SelectMany(ext => ext)
.Select(ext => ext.ToLowerInvariant())
.ToHashSet();
}

private static bool IsExtensionSupported(ICollection<string> supportedExtensions, string fileExtension)
{
return supportedExtensions.Any(ext => ext == ".*" || string.Equals(ext, fileExtension, StringComparison.OrdinalIgnoreCase));
}
}
}
35 changes: 20 additions & 15 deletions src/GeoCop.Frontend/src/FileDropzone.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,13 @@ export const FileDropzone = ({
const [dropZoneText, setDropZoneText] = useState(dropZoneDefaultText);
const [dropZoneTextClass, setDropZoneTextClass] = useState("dropzone dropzone-text-disabled");

useEffect(
() =>
setDropZoneDefaultText(
`Datei (${acceptedFileTypes}) hier ablegen oder klicken um vom lokalen Dateisystem auszuwählen.`,
),
[acceptedFileTypes],
);
const acceptsAllFileTypes = acceptedFileTypes?.includes(".*") ?? false;
const acceptedFileTypesText = acceptedFileTypes?.join(", ") ?? "";

useEffect(() => {
const fileDescription = acceptsAllFileTypes ? "Datei" : `Datei (${acceptedFileTypesText})`;
setDropZoneDefaultText(`${fileDescription} hier ablegen oder klicken um vom lokalen Dateisystem auszuwählen.`);
}, [acceptsAllFileTypes, acceptedFileTypesText]);
useEffect(() => setDropZoneText(dropZoneDefaultText), [dropZoneDefaultText]);

const onDropAccepted = useCallback(
Expand Down Expand Up @@ -87,12 +87,13 @@ export const FileDropzone = ({
(fileRejections) => {
setDropZoneTextClass("dropzone dropzone-text-error");
const errorCode = fileRejections[0].errors[0].code;
const genericError =
"Bitte wähle eine Datei (max. 200MB)" +
(acceptsAllFileTypes ? "" : ` mit einer der folgenden Dateiendungen: ${acceptedFileTypesText}`);

switch (errorCode) {
case "file-invalid-type":
setDropZoneText(
`Der Dateityp wird nicht unterstützt. Bitte wähle eine Datei (max. 200MB) mit einer der folgenden Dateiendungen: ${acceptedFileTypes}`,
);
setDropZoneText(`Der Dateityp wird nicht unterstützt. ${genericError}`);
break;
case "too-many-files":
setDropZoneText("Es kann nur eine Datei aufs Mal geprüft werden.");
Expand All @@ -103,14 +104,13 @@ export const FileDropzone = ({
);
break;
default:
setDropZoneText(
`Bitte wähle eine Datei (max. 200MB) mit einer der folgenden Dateiendungen: ${acceptedFileTypes}`,
);
setDropZoneText(genericError);
break;
}
resetFileToCheck();
setFileAvailable(false);
},
[resetFileToCheck, acceptedFileTypes],
[resetFileToCheck, acceptsAllFileTypes, acceptedFileTypesText],
);

const removeFile = (e) => {
Expand All @@ -122,12 +122,17 @@ export const FileDropzone = ({
setDropZoneTextClass("dropzone dropzone-text-disabled");
};

const accept = acceptsAllFileTypes
? undefined
: {
"application/x-geocop-files": acceptedFileTypes ?? [],
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDropAccepted,
onDropRejected,
maxFiles: 1,
maxSize: 209715200,
accept: acceptedFileTypes,
accept,
});

return (
Expand Down
9 changes: 8 additions & 1 deletion src/GeoCop.Frontend/src/Home.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ export const Home = ({
const [log, setLog] = useState([]);
const [uploadLogsInterval, setUploadLogsInterval] = useState(0);
const [uploadLogsEnabled, setUploadLogsEnabled] = useState(false);
const [uploadSettings, setUploadSettings] = useState({});

useEffect(() => {
fetch("/api/v1/upload")
.then((res) => res.headers.get("content-type")?.includes("application/json") && res.json())
.then((settings) => setUploadSettings(settings));
}, []);

// Enable Upload logging
useEffect(() => {
Expand Down Expand Up @@ -131,7 +138,7 @@ export const Home = ({
validationRunning={validationRunning}
setCheckedNutzungsbestimmungen={setCheckedNutzungsbestimmungen}
showNutzungsbestimmungen={showNutzungsbestimmungen}
acceptedFileTypes={clientSettings?.acceptedFileTypes}
acceptedFileTypes={uploadSettings?.allowedFileExtensions}
fileToCheckRef={fileToCheckRef}
/>
</Container>
Expand Down
19 changes: 17 additions & 2 deletions tests/GeoCop.Api.Test/UploadControllerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ public async Task UploadAsync()
var validationJob = new ValidationJob(jobId, originalFileName, "TEMP.xtf");
using var fileHandle = new FileHandle(validationJob.TempFileName, Stream.Null);

validationServiceMock.Setup(x => x.CreateValidationJob(It.Is<string>(x => x == originalFileName))).Returns((validationJob, fileHandle));
validationServiceMock.Setup(x => x.IsFileExtensionSupportedAsync(".xtf")).Returns(Task.FromResult(true));
validationServiceMock.Setup(x => x.CreateValidationJob(originalFileName)).Returns((validationJob, fileHandle));
validationServiceMock
.Setup(x => x.StartValidationJobAsync(It.Is<ValidationJob>(x => x == validationJob)))
.Setup(x => x.StartValidationJobAsync(validationJob))
.Returns(Task.FromResult(new ValidationJobStatus(jobId)));

var response = await controller.UploadAsync(apiVersionMock.Object, formFileMock.Object) as CreatedResult;
Expand All @@ -74,5 +75,19 @@ public async Task UploadAsyncForNull()
Assert.AreEqual(StatusCodes.Status400BadRequest, response!.StatusCode);
Assert.AreEqual("Form data <file> cannot be empty.", ((ProblemDetails)response.Value!).Detail);
}

[TestMethod]
public async Task UploadInvalidFileExtension()
{
formFileMock.SetupGet(x => x.FileName).Returns("upload.exe");

validationServiceMock.Setup(x => x.IsFileExtensionSupportedAsync(".exe")).Returns(Task.FromResult(false));

var response = await controller.UploadAsync(apiVersionMock.Object, formFileMock.Object) as ObjectResult;

Assert.IsInstanceOfType(response, typeof(ObjectResult));
Assert.AreEqual(StatusCodes.Status400BadRequest, response!.StatusCode);
Assert.AreEqual("File extension <.exe> is not supported.", ((ProblemDetails)response.Value!).Detail);
}
}
}
8 changes: 6 additions & 2 deletions tests/GeoCop.Api.Test/Validation/ValidationServiceTest.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Moq;
using Microsoft.EntityFrameworkCore;
using Moq;

namespace GeoCop.Api.Validation
{
Expand All @@ -8,6 +9,7 @@ public class ValidationServiceTest
private Mock<IFileProvider> fileProviderMock;
private Mock<IValidationRunner> validationRunnerMock;
private Mock<IValidator> validatorMock;
private Mock<Context> contextMock;
private ValidationService validationService;

[TestInitialize]
Expand All @@ -16,11 +18,13 @@ public void Initialize()
fileProviderMock = new Mock<IFileProvider>(MockBehavior.Strict);
validationRunnerMock = new Mock<IValidationRunner>(MockBehavior.Strict);
validatorMock = new Mock<IValidator>(MockBehavior.Strict);
contextMock = new Mock<Context>(new DbContextOptions<Context>());

validationService = new ValidationService(
fileProviderMock.Object,
validationRunnerMock.Object,
new[] { validatorMock.Object });
new[] { validatorMock.Object },
contextMock.Object);
}

[TestCleanup]
Expand Down

0 comments on commit c7867af

Please sign in to comment.