diff --git a/src/GeoCop.Api/ContextExtensions.cs b/src/GeoCop.Api/ContextExtensions.cs index ad890b76..c06b3ea7 100644 --- a/src/GeoCop.Api/ContextExtensions.cs +++ b/src/GeoCop.Api/ContextExtensions.cs @@ -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()); diff --git a/src/GeoCop.Api/Contracts/ValidationSettingsResponse.cs b/src/GeoCop.Api/Contracts/ValidationSettingsResponse.cs new file mode 100644 index 00000000..07741000 --- /dev/null +++ b/src/GeoCop.Api/Contracts/ValidationSettingsResponse.cs @@ -0,0 +1,14 @@ +namespace GeoCop.Api.Contracts +{ + /// + /// The validation settings response schema. + /// + public class ValidationSettingsResponse + { + /// + /// File extensions that are allowed for upload. + /// All entries start with a "." like ".txt", ".xml" and the collection can include ".*" (all files allowed). + /// + public ICollection AllowedFileExtensions { get; set; } = new List(); + } +} diff --git a/src/GeoCop.Api/Controllers/UploadController.cs b/src/GeoCop.Api/Controllers/UploadController.cs index 20f48b91..5ec9cf27 100644 --- a/src/GeoCop.Api/Controllers/UploadController.cs +++ b/src/GeoCop.Api/Controllers/UploadController.cs @@ -1,4 +1,5 @@ using Asp.Versioning; +using GeoCop.Api.Contracts; using GeoCop.Api.Validation; using Microsoft.AspNetCore.Mvc; using Swashbuckle.AspNetCore.Annotations; @@ -26,6 +27,20 @@ public UploadController(ILogger logger, IValidationService val this.validationService = validationService; } + /// + /// Returns the validation settings. + /// + /// Configuration settings for validations. + [HttpGet] + [SwaggerResponse(StatusCodes.Status200OK, "The specified settings for uploading files.", typeof(ValidationSettingsResponse), new[] { "application/json" })] + public async Task GetValidationSettings() + { + return Ok(new ValidationSettingsResponse + { + AllowedFileExtensions = await validationService.GetSupportedFileExtensionsAsync(), + }); + } + /// /// Schedules a new job for the given . /// @@ -73,6 +88,13 @@ public async Task 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) { diff --git a/src/GeoCop.Api/Validation/IValidationService.cs b/src/GeoCop.Api/Validation/IValidationService.cs index e81826c7..9a36d21a 100644 --- a/src/GeoCop.Api/Validation/IValidationService.cs +++ b/src/GeoCop.Api/Validation/IValidationService.cs @@ -32,5 +32,19 @@ public interface IValidationService /// The id of the validation job. /// Status information for the validation job with the specified . ValidationJobStatus? GetJobStatus(Guid jobId); + + /// + /// 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). + /// + /// Supported file extensions. + Task> GetSupportedFileExtensionsAsync(); + + /// + /// Checks if the specified is supported for upload. + /// + /// Extension of the uploaded file starting with ".". + /// True, if the is supported. + Task IsFileExtensionSupportedAsync(string fileExtension); } } diff --git a/src/GeoCop.Api/Validation/IValidator.cs b/src/GeoCop.Api/Validation/IValidator.cs index 6d8a417d..0e8438ef 100644 --- a/src/GeoCop.Api/Validation/IValidator.cs +++ b/src/GeoCop.Api/Validation/IValidator.cs @@ -10,6 +10,11 @@ public interface IValidator /// string Name { get; } + /// + /// Gets the supported file extensions. + /// + Task> GetSupportedFileExtensionsAsync(); + /// /// Asynchronously validates the specified. /// Its file must be accessible by an when executing this function. diff --git a/src/GeoCop.Api/Validation/Interlis/IliCheckSettingsResponse.cs b/src/GeoCop.Api/Validation/Interlis/IliCheckSettingsResponse.cs new file mode 100644 index 00000000..f0d4d12f --- /dev/null +++ b/src/GeoCop.Api/Validation/Interlis/IliCheckSettingsResponse.cs @@ -0,0 +1,13 @@ +namespace GeoCop.Api.Validation.Interlis +{ + /// + /// Result of a settings query of interlis-check-service at /api/v1/settings. + /// + public class IliCheckSettingsResponse + { + /// + /// The accepted file types. + /// + public string? AcceptedFileTypes { get; set; } + } +} diff --git a/src/GeoCop.Api/Validation/Interlis/InterlisValidator.cs b/src/GeoCop.Api/Validation/Interlis/InterlisValidator.cs index a62bb24f..f359ea66 100644 --- a/src/GeoCop.Api/Validation/Interlis/InterlisValidator.cs +++ b/src/GeoCop.Api/Validation/Interlis/InterlisValidator.cs @@ -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 logger; private readonly IFileProvider fileProvider; private readonly HttpClient httpClient; private readonly JsonSerializerOptions jsonSerializerOptions; + private ICollection? supportedFileExtensions; /// public string Name => "ilicheck"; @@ -33,6 +35,17 @@ public InterlisValidator(ILogger logger, IFileProvider filePr jsonSerializerOptions = jsonOptions.Value.JsonSerializerOptions; } + /// + public async Task> GetSupportedFileExtensionsAsync() + { + if (supportedFileExtensions != null) return supportedFileExtensions; + + var response = await httpClient.GetAsync(SettingsUrl).ConfigureAwait(false); + var configResult = await ReadSuccessResponseJsonAsync(response, CancellationToken.None).ConfigureAwait(false); + supportedFileExtensions = configResult.AcceptedFileTypes?.Split(", "); + return supportedFileExtensions ?? Array.Empty(); + } + /// public async Task ExecuteAsync(ValidationJob validationJob, CancellationToken cancellationToken) { diff --git a/src/GeoCop.Api/Validation/ValidationService.cs b/src/GeoCop.Api/Validation/ValidationService.cs index 07e9cd06..25b65e86 100644 --- a/src/GeoCop.Api/Validation/ValidationService.cs +++ b/src/GeoCop.Api/Validation/ValidationService.cs @@ -8,15 +8,17 @@ public class ValidationService : IValidationService private readonly IFileProvider fileProvider; private readonly IValidationRunner validationRunner; private readonly IEnumerable validators; + private readonly Context context; /// /// Initializes a new instance of the class. /// - public ValidationService(IFileProvider fileProvider, IValidationRunner validationRunner, IEnumerable validators) + public ValidationService(IFileProvider fileProvider, IValidationRunner validationRunner, IEnumerable validators, Context context) { this.fileProvider = fileProvider; this.validationRunner = validationRunner; this.validators = validators; + this.context = context; } /// @@ -34,7 +36,18 @@ public ValidationService(IFileProvider fileProvider, IValidationRunner validatio /// public async Task StartValidationJobAsync(ValidationJob validationJob) { - await validationRunner.EnqueueJobAsync(validationJob, validators); + var fileExtension = Path.GetExtension(validationJob.TempFileName); + var supportedValidators = new List(); + 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."); } @@ -49,5 +62,51 @@ public async Task StartValidationJobAsync(ValidationJob val { return validationRunner.GetJobStatus(jobId); } + + /// + public async Task> GetSupportedFileExtensionsAsync() + { + var mandateFileExtensions = GetFileExtensionsForDeliveryMandates(); + var validatorFileExtensions = await GetFileExtensionsForValidatorsAsync(); + + return mandateFileExtensions + .Union(validatorFileExtensions) + .OrderBy(ext => ext) + .ToList(); + } + + /// + public async Task IsFileExtensionSupportedAsync(string fileExtension) + { + var extensions = await GetSupportedFileExtensionsAsync(); + return IsExtensionSupported(extensions, fileExtension); + } + + private HashSet GetFileExtensionsForDeliveryMandates() + { + return context.DeliveryMandates + .Select(mandate => mandate.FileTypes) + .AsEnumerable() + .SelectMany(ext => ext) + .Select(ext => ext.ToLowerInvariant()) + .ToHashSet(); + } + + private async Task> 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 supportedExtensions, string fileExtension) + { + return supportedExtensions.Any(ext => ext == ".*" || string.Equals(ext, fileExtension, StringComparison.OrdinalIgnoreCase)); + } } } diff --git a/src/GeoCop.Frontend/src/FileDropzone.jsx b/src/GeoCop.Frontend/src/FileDropzone.jsx index f0a32777..d90c5a29 100644 --- a/src/GeoCop.Frontend/src/FileDropzone.jsx +++ b/src/GeoCop.Frontend/src/FileDropzone.jsx @@ -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( @@ -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."); @@ -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) => { @@ -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 ( diff --git a/src/GeoCop.Frontend/src/Home.jsx b/src/GeoCop.Frontend/src/Home.jsx index 44b76eef..02114951 100644 --- a/src/GeoCop.Frontend/src/Home.jsx +++ b/src/GeoCop.Frontend/src/Home.jsx @@ -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(() => { @@ -131,7 +138,7 @@ export const Home = ({ validationRunning={validationRunning} setCheckedNutzungsbestimmungen={setCheckedNutzungsbestimmungen} showNutzungsbestimmungen={showNutzungsbestimmungen} - acceptedFileTypes={clientSettings?.acceptedFileTypes} + acceptedFileTypes={uploadSettings?.allowedFileExtensions} fileToCheckRef={fileToCheckRef} /> diff --git a/tests/GeoCop.Api.Test/UploadControllerTest.cs b/tests/GeoCop.Api.Test/UploadControllerTest.cs index 7d8b93cc..39eaaa2d 100644 --- a/tests/GeoCop.Api.Test/UploadControllerTest.cs +++ b/tests/GeoCop.Api.Test/UploadControllerTest.cs @@ -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(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(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; @@ -74,5 +75,19 @@ public async Task UploadAsyncForNull() Assert.AreEqual(StatusCodes.Status400BadRequest, response!.StatusCode); Assert.AreEqual("Form data 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); + } } } diff --git a/tests/GeoCop.Api.Test/Validation/ValidationServiceTest.cs b/tests/GeoCop.Api.Test/Validation/ValidationServiceTest.cs index 110641d2..1d6eeb93 100644 --- a/tests/GeoCop.Api.Test/Validation/ValidationServiceTest.cs +++ b/tests/GeoCop.Api.Test/Validation/ValidationServiceTest.cs @@ -1,4 +1,5 @@ -using Moq; +using Microsoft.EntityFrameworkCore; +using Moq; namespace GeoCop.Api.Validation { @@ -8,6 +9,7 @@ public class ValidationServiceTest private Mock fileProviderMock; private Mock validationRunnerMock; private Mock validatorMock; + private Mock contextMock; private ValidationService validationService; [TestInitialize] @@ -16,11 +18,13 @@ public void Initialize() fileProviderMock = new Mock(MockBehavior.Strict); validationRunnerMock = new Mock(MockBehavior.Strict); validatorMock = new Mock(MockBehavior.Strict); + contextMock = new Mock(new DbContextOptions()); validationService = new ValidationService( fileProviderMock.Object, validationRunnerMock.Object, - new[] { validatorMock.Object }); + new[] { validatorMock.Object }, + contextMock.Object); } [TestCleanup]