diff --git a/Directory.Packages.props b/Directory.Packages.props index e06af86a673..44f7093142e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -110,4 +110,8 @@ + + + + diff --git a/OrchardCore.slnx b/OrchardCore.slnx index 8ed8126a7e8..de1a7ea611c 100644 --- a/OrchardCore.slnx +++ b/OrchardCore.slnx @@ -65,6 +65,7 @@ + @@ -116,6 +117,7 @@ + diff --git a/mkdocs.yml b/mkdocs.yml index 456ab77a348..073938aa3d8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -209,6 +209,7 @@ nav: - Media: - Media: reference/modules/Media/README.md - Media Slugify: reference/modules/Media.Slugify/README.md + - Antivirus: reference/modules/Antivirus/README.md - Media Amazon S3: reference/modules/Media.AmazonS3/README.md - Media Azure: reference/modules/Media.Azure/README.md - ReCaptcha: reference/modules/ReCaptcha/README.md @@ -252,6 +253,7 @@ nav: - Audit Trail: reference/modules/AuditTrail/README.md - Auto Setup: reference/modules/AutoSetup/README.md - Features: reference/modules/Features/README.md + - File Upload Security: reference/core/file-upload-security.md - Contents: reference/modules/Contents/README.md - Configuration: reference/modules/Configuration/README.md - Cors: reference/modules/Cors/README.md diff --git a/src/OrchardCore.AspireHost/ClamAV.cs b/src/OrchardCore.AspireHost/ClamAV.cs new file mode 100644 index 00000000000..f8f94131051 --- /dev/null +++ b/src/OrchardCore.AspireHost/ClamAV.cs @@ -0,0 +1,53 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; + +namespace OrchardCore.AspireHost; + +public sealed class ClamAVResource : ContainerResource, IResourceWithConnectionString +{ + internal const string PrimaryEndpointName = "tcp"; + + private EndpointReference _primaryEndpoint; + + public ClamAVResource(string name) + : base(name) + { + } + + public EndpointReference PrimaryEndpoint + => _primaryEndpoint ??= new EndpointReference(this, PrimaryEndpointName); + + public ReferenceExpression ConnectionStringExpression + => ReferenceExpression.Create( + $"tcp://{PrimaryEndpoint.Property(EndpointProperty.Host)}:{PrimaryEndpoint.Property(EndpointProperty.Port)}"); +} + +internal static class ClamAVResourceBuilderExtensions +{ + public static IResourceBuilder AddClamAV( + this IDistributedApplicationBuilder builder, + string name, + int? port = null) + { + var resource = new ClamAVResource(name); + + return builder.AddResource(resource) + .WithImage("clamav/clamav") + .WithImageRegistry("docker.io") + .WithImageTag("latest") + .WithEnvironment("CLAMAV_NO_FRESHCLAMD", "true") + .WithEndpoint(port: port, name: ClamAVResource.PrimaryEndpointName, targetPort: 3310); + } + + public static IResourceBuilder WithDataVolume( + this IResourceBuilder builder, + string name, + bool isReadOnly = false) => + builder.WithVolume(name, "/var/lib/clamav", isReadOnly); + + public static IResourceBuilder WithDataBindMount( + this IResourceBuilder builder, + string source, + bool isReadOnly = false) => + builder.WithBindMount(source, "/var/lib/clamav", isReadOnly); +} diff --git a/src/OrchardCore.AspireHost/OrchardCore.AspireHost.csproj b/src/OrchardCore.AspireHost/OrchardCore.AspireHost.csproj new file mode 100644 index 00000000000..0e2144694d9 --- /dev/null +++ b/src/OrchardCore.AspireHost/OrchardCore.AspireHost.csproj @@ -0,0 +1,22 @@ + + + + $(DefaultTargetFramework) + Exe + true + false + 53f65258-c4bc-45d0-ab79-c8309ff77904 + + + + + + + + + + + diff --git a/src/OrchardCore.AspireHost/Program.cs b/src/OrchardCore.AspireHost/Program.cs new file mode 100644 index 00000000000..2789ab5058d --- /dev/null +++ b/src/OrchardCore.AspireHost/Program.cs @@ -0,0 +1,20 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using OrchardCore.AspireHost; + +var builder = DistributedApplication.CreateBuilder(args); + +var clamAv = builder.AddClamAV("antivirus") + .WithDataVolume("clamavdb"); + +builder.AddProject("OrchardCoreCms") + .WithExternalHttpEndpoints() + .WithReference(clamAv) + .WithEnvironment("OrchardCore__Antivirus_ClamAV__Host", clamAv.Resource.PrimaryEndpoint.Property(EndpointProperty.Host)) + .WithEnvironment("OrchardCore__Antivirus_ClamAV__Port", clamAv.Resource.PrimaryEndpoint.Property(EndpointProperty.Port)) + .WithEnvironment("OrchardCore__Antivirus_ClamAV__ConnectTimeoutSeconds", "5") + .WithEnvironment("OrchardCore__Antivirus_ClamAV__TransferTimeoutSeconds", "30"); + +var app = builder.Build(); + +await app.RunAsync(); diff --git a/src/OrchardCore.AspireHost/Properties/launchSettings.json b/src/OrchardCore.AspireHost/Properties/launchSettings.json new file mode 100644 index 00000000000..bf2f5b9ee03 --- /dev/null +++ b/src/OrchardCore.AspireHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:17262;http://localhost:15209", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21196", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22006" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:15209", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19032", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20030" + } + } + } +} diff --git a/src/OrchardCore.AspireHost/appsettings.Development.json b/src/OrchardCore.AspireHost/appsettings.Development.json new file mode 100644 index 00000000000..31c092aa450 --- /dev/null +++ b/src/OrchardCore.AspireHost/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/OrchardCore.AspireHost/appsettings.json b/src/OrchardCore.AspireHost/appsettings.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/src/OrchardCore.AspireHost/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/OrchardCore.AspireHost/aspire.config.json b/src/OrchardCore.AspireHost/aspire.config.json new file mode 100644 index 00000000000..da58ac9e4c9 --- /dev/null +++ b/src/OrchardCore.AspireHost/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "OrchardCore.AspireHost.csproj" + } +} diff --git a/src/OrchardCore.Cms.Web/OrchardCore.Cms.Web.csproj b/src/OrchardCore.Cms.Web/OrchardCore.Cms.Web.csproj index fc449c0160b..9a6cd034486 100644 --- a/src/OrchardCore.Cms.Web/OrchardCore.Cms.Web.csproj +++ b/src/OrchardCore.Cms.Web/OrchardCore.Cms.Web.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAVOptions.cs b/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAVOptions.cs new file mode 100644 index 00000000000..fdd3e9d7686 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAVOptions.cs @@ -0,0 +1,14 @@ +namespace OrchardCore.Antivirus.ClamAV; + +public sealed class ClamAvOptions +{ + public const string ConfigSection = "Antivirus_ClamAV"; + + public string Host { get; set; } + + public int Port { get; set; } = 3310; + + public int ConnectTimeoutSeconds { get; set; } = 5; + + public int TransferTimeoutSeconds { get; set; } = 30; +} diff --git a/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAvConnection.cs b/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAvConnection.cs new file mode 100644 index 00000000000..0c5e24926a6 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAvConnection.cs @@ -0,0 +1,132 @@ +using System.Net.Sockets; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace OrchardCore.Antivirus.ClamAV; + +public sealed class ClamAvConnection : IDisposable +{ + private const int BufferSize = 81920; + private static readonly byte[] _scanCommand = "nINSTREAM\n"u8.ToArray(); + + private readonly ClamAvOptions _options; + private readonly ILogger _logger; + private readonly SemaphoreSlim _lock = new(1, 1); + + private TcpClient _client; + private NetworkStream _networkStream; + + public ClamAvConnection(ClamAvOptions options, ILogger logger) + { + _options = options; + _logger = logger; + } + + public async Task ScanAsync(Stream stream, CancellationToken cancellationToken) + { + await _lock.WaitAsync(cancellationToken); + + try + { + try + { + await EnsureConnectedAsync(cancellationToken); + + using var transferCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + transferCancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(_options.TransferTimeoutSeconds)); + + await _networkStream.WriteAsync(_scanCommand, transferCancellationTokenSource.Token); + + var buffer = new byte[BufferSize]; + int bytesRead; + + while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), transferCancellationTokenSource.Token)) > 0) + { + var prefix = BitConverter.GetBytes(System.Net.IPAddress.HostToNetworkOrder(bytesRead)); + + await _networkStream.WriteAsync(prefix, transferCancellationTokenSource.Token); + await _networkStream.WriteAsync(buffer.AsMemory(0, bytesRead), transferCancellationTokenSource.Token); + } + + await _networkStream.WriteAsync(new byte[sizeof(int)], transferCancellationTokenSource.Token); + await _networkStream.FlushAsync(transferCancellationTokenSource.Token); + + return await ReadResponseAsync(_networkStream, transferCancellationTokenSource.Token); + } + catch (Exception exception) when ( + exception is IOException or + SocketException or + OperationCanceledException or + ObjectDisposedException) + { + ResetConnection(); + throw; + } + } + finally + { + _lock.Release(); + } + } + + public void Dispose() + { + ResetConnection(); + _lock.Dispose(); + } + + private async Task EnsureConnectedAsync(CancellationToken cancellationToken) + { + if (_client?.Connected == true && _networkStream is not null) + { + return; + } + + ResetConnection(); + + var client = new TcpClient(); + using var connectCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + connectCancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds)); + + await client.ConnectAsync(_options.Host, _options.Port, connectCancellationTokenSource.Token); + + client.SendTimeout = (int)TimeSpan.FromSeconds(_options.TransferTimeoutSeconds).TotalMilliseconds; + client.ReceiveTimeout = (int)TimeSpan.FromSeconds(_options.TransferTimeoutSeconds).TotalMilliseconds; + + _client = client; + _networkStream = client.GetStream(); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Created a shared ClamAV TCP connection for '{Host}:{Port}'.", _options.Host, _options.Port); + } + } + + private void ResetConnection() + { + _networkStream?.Dispose(); + _networkStream = null; + _client?.Dispose(); + _client = null; + } + + private static async Task ReadResponseAsync(NetworkStream stream, CancellationToken cancellationToken) + { + using var responseStream = new MemoryStream(); + var buffer = new byte[1]; + + while (true) + { + var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, 1), cancellationToken); + + if (bytesRead == 0 || buffer[0] == '\n') + { + break; + } + + responseStream.WriteByte(buffer[0]); + } + + return Encoding.ASCII.GetString(responseStream.ToArray()); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAvConnectionFactory.cs b/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAvConnectionFactory.cs new file mode 100644 index 00000000000..3505b687318 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAvConnectionFactory.cs @@ -0,0 +1,64 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace OrchardCore.Antivirus.ClamAV; + +public sealed class ClamAvConnectionFactory : IDisposable +{ + private static readonly ConcurrentDictionary> _connections = new(); + private static volatile int _registered; + private static volatile int _refCount; + + private readonly IHostApplicationLifetime _lifetime; + private readonly ILoggerFactory _loggerFactory; + + public ClamAvConnectionFactory( + IHostApplicationLifetime lifetime, + ILoggerFactory loggerFactory) + { + Interlocked.Increment(ref _refCount); + + _lifetime = lifetime; + _loggerFactory = loggerFactory; + + if (Interlocked.CompareExchange(ref _registered, 1, 0) == 0) + { + _lifetime.ApplicationStopped.Register(Release); + } + } + + public ClamAvConnection Create(ClamAvOptions options) + { + var key = $"{options.Host}:{options.Port}:{options.ConnectTimeoutSeconds}:{options.TransferTimeoutSeconds}"; + + return _connections.GetOrAdd(key, _ => new Lazy(() => + new ClamAvConnection(options, _loggerFactory.CreateLogger()))).Value; + } + + public void Dispose() + { + if (Interlocked.Decrement(ref _refCount) == 0 && _lifetime.ApplicationStopped.IsCancellationRequested) + { + Release(); + } + } + + internal static void Release() + { + if (Interlocked.CompareExchange(ref _refCount, 0, 0) == 0) + { + var connections = _connections.Values.ToArray(); + + _connections.Clear(); + + foreach (var connection in connections) + { + if (connection.IsValueCreated) + { + connection.Value.Dispose(); + } + } + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAvFileEventHandler.cs b/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAvFileEventHandler.cs new file mode 100644 index 00000000000..a59bf11504b --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAvFileEventHandler.cs @@ -0,0 +1,179 @@ +using System.Net.Sockets; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.FileStorage; +using OrchardCore.Infrastructure; + +namespace OrchardCore.Antivirus.ClamAV; + +public sealed class ClamAvFileEventHandler : IFileEventHandler +{ + private readonly ClamAvOptions _options; + private readonly ClamAvConnectionFactory _connectionFactory; + private readonly ILogger _logger; + + public ClamAvFileEventHandler( + IOptions options, + ClamAvConnectionFactory connectionFactory, + ILogger logger) + { + _options = options.Value; + _connectionFactory = connectionFactory; + _logger = logger; + } + + public async Task CreatingAsync(FileCreatingContext context, Stream stream, CancellationToken cancellationToken = default) + { + ValidateOptions(); + + var scanStream = stream; + + if (scanStream.CanSeek) + { + scanStream.Position = 0; + } + else + { + scanStream = await CreateSeekableStreamAsync(scanStream, cancellationToken); + } + + try + { + var response = await _connectionFactory.Create(_options).ScanAsync(scanStream, cancellationToken); + + if (TryCreateFailureResult(context, scanStream, response) is { } failureResult) + { + return failureResult; + } + + scanStream.Position = 0; + + return FileCreatingResult.Success(scanStream); + } + catch (OperationCanceledException exception) + { + if (scanStream != stream) + { + await scanStream.DisposeAsync(); + } + + _logger.LogError(exception, "ClamAV timed out while scanning '{FileName}'.", context.FileName); + + throw new AntivirusScanningException($"The ClamAV antivirus scanner timed out while scanning '{context.FileName}'.", exception); + } + catch (SocketException exception) + { + if (scanStream != stream) + { + await scanStream.DisposeAsync(); + } + + _logger.LogError(exception, "ClamAV could not be reached while scanning '{FileName}'.", context.FileName); + + throw new AntivirusScanningException($"The ClamAV antivirus scanner could not be reached while scanning '{context.FileName}'.", exception); + } + catch (IOException exception) + { + if (scanStream != stream) + { + await scanStream.DisposeAsync(); + } + + _logger.LogError(exception, "ClamAV failed while scanning '{FileName}'.", context.FileName); + + throw new AntivirusScanningException($"The ClamAV antivirus scanner failed while scanning '{context.FileName}'.", exception); + } + catch + { + if (scanStream != stream) + { + await scanStream.DisposeAsync(); + } + + throw; + } + } + + public Task CreatedAsync(IFileStoreEntry fileInfo, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + private static async Task CreateSeekableStreamAsync(Stream stream, CancellationToken cancellationToken) + { + var tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + var tempStream = new FileStream( + tempFilePath, + FileMode.CreateNew, + FileAccess.ReadWrite, + FileShare.None, + 81920, + FileOptions.Asynchronous | FileOptions.DeleteOnClose); + + try + { + await stream.CopyToAsync(tempStream, cancellationToken); + tempStream.Position = 0; + + return tempStream; + } + catch + { + await tempStream.DisposeAsync(); + throw; + } + } + + private static FileCreatingResult TryCreateFailureResult(FileCreatingContext context, Stream stream, string response) + { + if (string.Equals(response, "stream: OK", StringComparison.Ordinal)) + { + return null; + } + + if (response.EndsWith(" FOUND", StringComparison.Ordinal)) + { + var signature = response; + var separatorIndex = signature.IndexOf(": ", StringComparison.Ordinal); + + if (separatorIndex >= 0) + { + signature = signature[(separatorIndex + 2)..]; + } + + signature = signature[..^" FOUND".Length]; + + stream.Position = 0; + + return FileCreatingResult.Failed(stream, new ResultError + { + Message = new LocalizedString(nameof(ClamAvFileEventHandler), $"The uploaded file '{context.FileName}' was rejected because ClamAV detected '{signature}'."), + }); + } + + throw new AntivirusScanningException( + $"The ClamAV antivirus scanner returned an unexpected response while scanning '{context.FileName}': {response}"); + } + + private void ValidateOptions() + { + if (string.IsNullOrWhiteSpace(_options.Host)) + { + throw new AntivirusScanningException("The ClamAV antivirus scanner is enabled but the host setting is missing."); + } + + if (_options.Port is < 1 or > 65535) + { + throw new AntivirusScanningException("The ClamAV antivirus scanner is enabled but the port setting is invalid."); + } + + if (_options.ConnectTimeoutSeconds <= 0) + { + throw new AntivirusScanningException("The ClamAV antivirus scanner is enabled but the connection timeout must be greater than zero."); + } + + if (_options.TransferTimeoutSeconds <= 0) + { + throw new AntivirusScanningException("The ClamAV antivirus scanner is enabled but the transfer timeout must be greater than zero."); + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Antivirus/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Antivirus/Manifest.cs new file mode 100644 index 00000000000..76b0a84570f --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Antivirus/Manifest.cs @@ -0,0 +1,15 @@ +using OrchardCore.Modules.Manifest; + +[assembly: Module( + Name = "ClamAV Antivirus Scanner", + Author = ManifestConstants.OrchardCoreTeam, + Website = ManifestConstants.OrchardCoreWebsite, + Version = ManifestConstants.OrchardCoreVersion +)] + +[assembly: Feature( + Id = "OrchardCore.Antivirus.ClamAV", + Name = "ClamAV Antivirus Scanner", + Description = "Scans files with ClamAV before Orchard Core stores them.", + Category = "Security" +)] diff --git a/src/OrchardCore.Modules/OrchardCore.Antivirus/OrchardCore.Antivirus.csproj b/src/OrchardCore.Modules/OrchardCore.Antivirus/OrchardCore.Antivirus.csproj new file mode 100644 index 00000000000..df290d1277a --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Antivirus/OrchardCore.Antivirus.csproj @@ -0,0 +1,21 @@ + + + + true + OrchardCore Antivirus ClamAV + $(OCCMSDescription) + + Provides a ClamAV-backed antivirus scanner for Orchard Core uploads. + $(PackageTags) OrchardCoreCMS ContentManagement Security + + + + + + + + + + + + diff --git a/src/OrchardCore.Modules/OrchardCore.Antivirus/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Antivirus/Startup.cs new file mode 100644 index 00000000000..2becf57d075 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Antivirus/Startup.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OrchardCore.Antivirus.ClamAV; +using OrchardCore.Environment.Shell.Configuration; +using OrchardCore.FileStorage; +using OrchardCore.Modules; + +namespace OrchardCore.Antivirus; + +public sealed class Startup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + } +} + +[Feature("OrchardCore.Antivirus.ClamAV")] +public sealed class ClamAVStartup : StartupBase +{ + private readonly IShellConfiguration _configuration; + + public ClamAVStartup(IShellConfiguration configuration) + { + _configuration = configuration; + } + + public override void ConfigureServices(IServiceCollection services) + { + services.Configure(_configuration.GetSection(ClamAvOptions.ConfigSection)); + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/ImportRemoteInstanceController.cs b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/ImportRemoteInstanceController.cs index e9f1e35401e..db05a20ff89 100644 --- a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/ImportRemoteInstanceController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/ImportRemoteInstanceController.cs @@ -11,6 +11,7 @@ using OrchardCore.Deployment.Remote.ViewModels; using OrchardCore.Deployment.Services; using OrchardCore.DisplayManagement.Notify; +using OrchardCore.FileStorage; using OrchardCore.Recipes.Models; namespace OrchardCore.Deployment.Remote.Controllers; @@ -22,6 +23,7 @@ public sealed class ImportRemoteInstanceController : Controller private readonly INotifier _notifier; private readonly ILogger _logger; private readonly IDataProtector _dataProtector; + private readonly FileCreationService _fileCreationService; internal readonly IHtmlLocalizer H; internal readonly IStringLocalizer S; @@ -30,12 +32,14 @@ public ImportRemoteInstanceController( IDataProtectionProvider dataProtectionProvider, RemoteClientService remoteClientService, IDeploymentManager deploymentManager, + FileCreationService fileCreationService, INotifier notifier, IHtmlLocalizer htmlLocalizer, IStringLocalizer stringLocalizer, ILogger logger) { _deploymentManager = deploymentManager; + _fileCreationService = fileCreationService; _notifier = notifier; _logger = logger; _remoteClientService = remoteClientService; @@ -76,9 +80,20 @@ public async Task Import(ImportViewModel model) try { - using (var fs = System.IO.File.Create(tempArchiveName)) + await using var uploadedStream = model.Content.OpenReadStream(); + await using var fileCreatingResult = await _fileCreationService.CreateAsync( + new FileCreatingContext(model.Content.FileName, model.Content.Length, model.Content.ContentType), + uploadedStream, + HttpContext.RequestAborted); + + if (!fileCreatingResult.Succeeded) + { + return StatusCode((int)HttpStatusCode.BadRequest, fileCreatingResult.ErrorMessage ?? $"The uploaded file '{model.Content.FileName}' was rejected."); + } + + await using (var fs = System.IO.File.Create(tempArchiveName)) { - await model.Content.CopyToAsync(fs); + await fileCreatingResult.Stream.CopyToAsync(fs, HttpContext.RequestAborted); } ZipFile.ExtractToDirectory(tempArchiveName, tempArchiveFolder); diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/OrchardCore.Deployment.Remote.csproj b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/OrchardCore.Deployment.Remote.csproj index 8c1a77018ce..ac939193b4f 100644 --- a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/OrchardCore.Deployment.Remote.csproj +++ b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/OrchardCore.Deployment.Remote.csproj @@ -19,6 +19,7 @@ + diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Startup.cs index b4eb3c0152d..5eb35ca29de 100644 --- a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Startup.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using OrchardCore.Deployment.Remote; using OrchardCore.Deployment.Remote.Services; +using OrchardCore.FileStorage; using OrchardCore.Modules; using OrchardCore.Navigation; using OrchardCore.Security.Permissions; @@ -12,6 +14,7 @@ public sealed class Startup : StartupBase public override void ConfigureServices(IServiceCollection services) { services.AddHttpClient(); + services.TryAddSingleton(); services.AddNavigationProvider(); services.AddScoped(); diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment/Controllers/ImportController.cs b/src/OrchardCore.Modules/OrchardCore.Deployment/Controllers/ImportController.cs index 14c4560fa5f..314a8909c47 100644 --- a/src/OrchardCore.Modules/OrchardCore.Deployment/Controllers/ImportController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Deployment/Controllers/ImportController.cs @@ -11,6 +11,7 @@ using OrchardCore.Deployment.Services; using OrchardCore.Deployment.ViewModels; using OrchardCore.DisplayManagement.Notify; +using OrchardCore.FileStorage; using OrchardCore.Mvc.Utilities; using OrchardCore.Recipes.Models; @@ -23,6 +24,7 @@ public sealed class ImportController : Controller private readonly IAuthorizationService _authorizationService; private readonly INotifier _notifier; private readonly ILogger _logger; + private readonly FileCreationService _fileCreationService; internal readonly IHtmlLocalizer H; internal readonly IStringLocalizer S; @@ -30,6 +32,7 @@ public sealed class ImportController : Controller public ImportController( IDeploymentManager deploymentManager, IAuthorizationService authorizationService, + FileCreationService fileCreationService, INotifier notifier, ILogger logger, IHtmlLocalizer htmlLocalizer, @@ -38,6 +41,7 @@ IStringLocalizer stringLocalizer { _deploymentManager = deploymentManager; _authorizationService = authorizationService; + _fileCreationService = fileCreationService; _notifier = notifier; _logger = logger; H = htmlLocalizer; @@ -69,9 +73,22 @@ public async Task Import(IFormFile importedPackage) try { - using (var stream = new FileStream(tempArchiveName, FileMode.Create)) + await using var uploadedStream = importedPackage.OpenReadStream(); + await using var fileCreatingResult = await _fileCreationService.CreateAsync( + new FileCreatingContext(importedPackage.FileName, importedPackage.Length, importedPackage.ContentType), + uploadedStream, + HttpContext.RequestAborted); + + if (!fileCreatingResult.Succeeded) + { + await _notifier.ErrorAsync(H[fileCreatingResult.ErrorMessage ?? $"The uploaded file '{importedPackage.FileName}' was rejected."]); + + return RedirectToAction(nameof(Index)); + } + + await using (var stream = new FileStream(tempArchiveName, FileMode.Create)) { - await importedPackage.CopyToAsync(stream); + await fileCreatingResult.Stream.CopyToAsync(stream, HttpContext.RequestAborted); } if (importedPackage.FileName.EndsWith(".zip")) diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment/OrchardCore.Deployment.csproj b/src/OrchardCore.Modules/OrchardCore.Deployment/OrchardCore.Deployment.csproj index 6fcc560bfc0..7baacaaf38b 100644 --- a/src/OrchardCore.Modules/OrchardCore.Deployment/OrchardCore.Deployment.csproj +++ b/src/OrchardCore.Modules/OrchardCore.Deployment/OrchardCore.Deployment.csproj @@ -20,6 +20,7 @@ + diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Deployment/Startup.cs index 7911c0cb69c..29c0dbf1ee6 100644 --- a/src/OrchardCore.Modules/OrchardCore.Deployment/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Deployment/Startup.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using OrchardCore.Data; using OrchardCore.Data.Migration; using OrchardCore.Deployment.Core; @@ -9,6 +10,7 @@ using OrchardCore.Deployment.Recipes; using OrchardCore.Deployment.Steps; using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.FileStorage; using OrchardCore.Modules; using OrchardCore.Navigation; using OrchardCore.Recipes; @@ -20,6 +22,7 @@ public sealed class Startup : StartupBase { public override void ConfigureServices(IServiceCollection services) { + services.TryAddSingleton(); services.AddDeploymentServices(); services.AddNavigationProvider(); diff --git a/src/OrchardCore.Modules/OrchardCore.Media.AmazonS3/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Media.AmazonS3/Startup.cs index 57bc1aa610e..9e9fa81adac 100644 --- a/src/OrchardCore.Modules/OrchardCore.Media.AmazonS3/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Media.AmazonS3/Startup.cs @@ -103,6 +103,7 @@ public override void ConfigureServices(IServiceCollection services) var mediaOptions = serviceProvider.GetRequiredService>().Value; var mediaEventHandlers = serviceProvider.GetServices(); var mediaCreatingEventHandlers = serviceProvider.GetServices(); + var fileCreationService = serviceProvider.GetRequiredService(); var clock = serviceProvider.GetRequiredService(); var logger = serviceProvider.GetRequiredService>(); var amazonS3Client = serviceProvider.GetService(); @@ -126,6 +127,7 @@ public override void ConfigureServices(IServiceCollection services) mediaOptions.CdnBaseUrl, mediaEventHandlers, mediaCreatingEventHandlers, + fileCreationService, logger); })); diff --git a/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs index 903d45f22f3..26b209dc41a 100644 --- a/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs @@ -95,6 +95,7 @@ public override void ConfigureServices(IServiceCollection services) var contentTypeProvider = serviceProvider.GetRequiredService(); var mediaEventHandlers = serviceProvider.GetServices(); var mediaCreatingEventHandlers = serviceProvider.GetServices(); + var fileCreationService = serviceProvider.GetRequiredService(); var logger = serviceProvider.GetRequiredService>(); var fileStore = new BlobFileStore(blobStorageOptions, clock, contentTypeProvider); @@ -109,7 +110,7 @@ public override void ConfigureServices(IServiceCollection services) mediaUrlBase = fileStore.Combine(originalPathBase.Value, mediaUrlBase); } - return new DefaultMediaFileStore(fileStore, mediaUrlBase, mediaOptions.CdnBaseUrl, mediaEventHandlers, mediaCreatingEventHandlers, logger); + return new DefaultMediaFileStore(fileStore, mediaUrlBase, mediaOptions.CdnBaseUrl, mediaEventHandlers, mediaCreatingEventHandlers, fileCreationService, logger); })); services.AddSingleton(); diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Media/Startup.cs index bc51125e5f4..77ca434566a 100644 --- a/src/OrchardCore.Modules/OrchardCore.Media/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Media/Startup.cs @@ -87,6 +87,7 @@ public override void ConfigureServices(IServiceCollection services) services.AddResourceConfiguration(); services.AddTransient, MediaOptionsConfiguration>(); + services.TryAddSingleton(); services.AddSingleton(serviceProvider => { @@ -115,6 +116,7 @@ public override void ConfigureServices(IServiceCollection services) var mediaOptions = serviceProvider.GetRequiredService>().Value; var mediaEventHandlers = serviceProvider.GetServices(); var mediaCreatingEventHandlers = serviceProvider.GetServices(); + var fileCreationService = serviceProvider.GetRequiredService(); var fileSystemStoreLogger = serviceProvider.GetRequiredService>(); var defaultMediaFileStoreLogger = serviceProvider.GetRequiredService>(); @@ -132,7 +134,7 @@ public override void ConfigureServices(IServiceCollection services) mediaUrlBase = fileStore.Combine(originalPathBase.Value, mediaUrlBase); } - return new DefaultMediaFileStore(fileStore, mediaUrlBase, mediaOptions.CdnBaseUrl, mediaEventHandlers, mediaCreatingEventHandlers, defaultMediaFileStoreLogger); + return new DefaultMediaFileStore(fileStore, mediaUrlBase, mediaOptions.CdnBaseUrl, mediaEventHandlers, mediaCreatingEventHandlers, fileCreationService, defaultMediaFileStoreLogger); }); services.AddPermissionProvider(); diff --git a/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj b/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj index 4b7a20af08a..f3c19af713e 100644 --- a/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj +++ b/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj @@ -41,6 +41,7 @@ + diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/AntivirusScanningException.cs b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/AntivirusScanningException.cs new file mode 100644 index 00000000000..c48e43168d6 --- /dev/null +++ b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/AntivirusScanningException.cs @@ -0,0 +1,14 @@ +namespace OrchardCore.FileStorage; + +public class AntivirusScanningException : FileStoreException +{ + public AntivirusScanningException(string message) + : base(message) + { + } + + public AntivirusScanningException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/FileCreatingContext.cs b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/FileCreatingContext.cs new file mode 100644 index 00000000000..a2f073c5f33 --- /dev/null +++ b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/FileCreatingContext.cs @@ -0,0 +1,19 @@ +namespace OrchardCore.FileStorage; + +public sealed class FileCreatingContext +{ + public FileCreatingContext(string path, long? length = null, string contentType = null) + { + Path = path; + Length = length; + ContentType = contentType; + } + + public string Path { get; set; } + + public string FileName => System.IO.Path.GetFileName(Path); + + public long? Length { get; set; } + + public string ContentType { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/FileCreatingResult.cs b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/FileCreatingResult.cs new file mode 100644 index 00000000000..98c28dbb7e7 --- /dev/null +++ b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/FileCreatingResult.cs @@ -0,0 +1,55 @@ +using OrchardCore.Infrastructure; + +namespace OrchardCore.FileStorage; + +/// +/// Represents the outcome of processing a file before it is stored. +/// +public sealed class FileCreatingResult : Result, IAsyncDisposable +{ + private readonly bool _ownsStream; + + private FileCreatingResult(Stream stream, bool ownsStream) + : base(true) + { + Stream = stream; + _ownsStream = ownsStream; + } + + private FileCreatingResult(Stream stream, bool ownsStream, IEnumerable errors) + : base(errors) + { + Stream = stream; + _ownsStream = ownsStream; + } + + /// + /// Gets the stream that should continue through the upload pipeline. + /// + public Stream Stream { get; } + + public string ErrorMessage + => Errors + .Select(error => error.Message?.Value) + .FirstOrDefault(message => !string.IsNullOrWhiteSpace(message)); + + public static FileCreatingResult Success(Stream stream) + => new(stream, false); + + public static FileCreatingResult Failed(Stream stream = null, params ResultError[] errors) + => new(stream, false, errors); + + internal static FileCreatingResult Create(Stream stream, bool ownsStream, IEnumerable errors) + => new(stream, ownsStream, errors); + + internal static FileCreatingResult Create(Stream stream, bool ownsStream) + => new(stream, ownsStream); + + public async ValueTask DisposeAsync() + { + if (_ownsStream && Stream is not null) + { + await Stream.DisposeAsync(); + } + } +} diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/FileCreationService.cs b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/FileCreationService.cs new file mode 100644 index 00000000000..e1ada6d0e38 --- /dev/null +++ b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/FileCreationService.cs @@ -0,0 +1,85 @@ +namespace OrchardCore.FileStorage; + +/// +/// Coordinates instances for user-uploaded files. +/// +public sealed class FileCreationService +{ + private readonly IEnumerable _handlers; + + public FileCreationService(IEnumerable handlers) + { + _handlers = handlers; + } + + /// + /// Runs the pre-create pipeline and returns the stream that should be stored. + /// The caller owns the original and should dispose the returned + /// to clean up any replacement stream created by handlers. + /// + public async Task CreateAsync( + FileCreatingContext context, + Stream stream, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(stream); + + var currentStream = stream; + + try + { + foreach (var handler in _handlers) + { + var creatingStream = currentStream; + var result = await handler.CreatingAsync(context, creatingStream, cancellationToken); + + if (result is null) + { + throw new InvalidOperationException($"{handler.GetType().Name} returned a null {nameof(FileCreatingResult)}."); + } + + currentStream = result.Stream ?? creatingStream; + + if (creatingStream != currentStream && creatingStream != stream) + { + await creatingStream.DisposeAsync(); + } + + if (!result.Succeeded) + { + return FileCreatingResult.Create(currentStream, currentStream != stream, result.Errors.ToList()); + } + } + + return FileCreatingResult.Create(currentStream, currentStream != stream); + } + catch + { + if (currentStream != stream) + { + await currentStream.DisposeAsync(); + } + + throw; + } + } + + /// + /// Runs the post-create pipeline after the file was stored successfully. + /// + public async Task CreatedAsync(IFileStoreEntry fileInfo, CancellationToken cancellationToken = default) + { + if (!_handlers.Any()) + { + return; + } + + ArgumentNullException.ThrowIfNull(fileInfo); + + foreach (var handler in _handlers) + { + await handler.CreatedAsync(fileInfo, cancellationToken); + } + } +} diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileEventHandler.cs b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileEventHandler.cs new file mode 100644 index 00000000000..78c4be8ed52 --- /dev/null +++ b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileEventHandler.cs @@ -0,0 +1,8 @@ +namespace OrchardCore.FileStorage; + +public interface IFileEventHandler +{ + Task CreatingAsync(FileCreatingContext context, Stream stream, CancellationToken cancellationToken = default); + + Task CreatedAsync(IFileStoreEntry fileInfo, CancellationToken cancellationToken = default); +} diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/OrchardCore.FileStorage.Abstractions.csproj b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/OrchardCore.FileStorage.Abstractions.csproj index 5a8ad8eae76..90b9bbc7aa0 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/OrchardCore.FileStorage.Abstractions.csproj +++ b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/OrchardCore.FileStorage.Abstractions.csproj @@ -15,4 +15,8 @@ + + + + diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Result.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Result.cs index 837ab69caed..3cf44afc2eb 100644 --- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Result.cs +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Result.cs @@ -7,22 +7,26 @@ namespace OrchardCore.Infrastructure; /// public class Result { + private static readonly ResultError[] _emptyErrors = []; private static readonly Result _success = new Result { Succeeded = true }; - private readonly List _errors = []; + private IEnumerable _errors = _emptyErrors; - private Result() + protected Result() { } + protected Result(bool succeeded) + { + Succeeded = succeeded; + } + /// /// Initializes a new instance of the class with the specified success status and errors. /// - /// Indicates whether the operation succeeded. /// The errors that occurred during the operation. - protected Result(bool succeeded, List errors) + protected Result(IEnumerable errors) { - Succeeded = succeeded; - _errors = errors; + _errors = errors ?? _emptyErrors; } /// @@ -48,7 +52,7 @@ protected Result(bool succeeded, List errors) /// The value returned by the operation. /// A successful result instance with the specified value. public static Result Success(TValue value) - => new Result(value, true, []); + => new Result(value); /// /// Returns a failed result instance with the specified errors. @@ -57,12 +61,7 @@ public static Result Success(TValue value) /// A failed result instance with the specified errors. public static Result Failed(params IEnumerable errors) { - var result = new Result(); - - if (errors is not null) - { - result._errors.AddRange(errors); - } + var result = new Result(errors); return result; } @@ -91,5 +90,5 @@ public static Result Failed(LocalizedString error) => Failed(new ResultError /// The errors that occurred during the operation. /// A failed result instance with the specified error message. public static Result Failed(params ResultError[] errors) - => new Result(default, false, errors.ToList()); + => new Result(default, errors); } diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/ResultOfT.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/ResultOfT.cs index ef521ec0a38..19262b67bec 100644 --- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/ResultOfT.cs +++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/ResultOfT.cs @@ -15,8 +15,16 @@ public class Result : Result /// Initializes a new instance of the class. /// /// The value returned by the operation. - /// Indicates whether the operation succeeded. /// The errors that occurred during the operation. - protected internal Result(TValue value, bool succeeded, List errors) - : base(succeeded, errors) => Value = value; + protected internal Result(TValue value, IEnumerable errors) + : base(errors) + { + Value = value; + } + + protected internal Result(TValue value) + : base(true) + { + Value = value; + } } diff --git a/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs b/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs index 48b01b34ded..f607320b0be 100644 --- a/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs +++ b/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs @@ -17,6 +17,7 @@ public class DefaultMediaFileStore : IMediaFileStore private readonly string _cdnBaseUrl; private readonly IEnumerable _mediaEventHandlers; private readonly IEnumerable _mediaCreatingEventHandlers; + private readonly FileCreationService _fileCreationService; private readonly ILogger _logger; private bool _requestBasePathValidated; @@ -27,6 +28,7 @@ public DefaultMediaFileStore( string cdnBaseUrl, IEnumerable mediaEventHandlers, IEnumerable mediaCreatingEventHandlers, + FileCreationService fileCreationService, ILogger logger) { _fileStore = fileStore; @@ -39,6 +41,7 @@ public DefaultMediaFileStore( _mediaEventHandlers = mediaEventHandlers; _mediaCreatingEventHandlers = mediaCreatingEventHandlers; + _fileCreationService = fileCreationService; _logger = logger; } @@ -192,9 +195,7 @@ public virtual async Task CreateFileFromStreamAsync(string path, Stream } } - await ValidateAvailableStorageAsync(outputStream.Length); - - return await _fileStore.CreateFileFromStreamAsync(context.Path, outputStream, overwrite); + return await CreateFileAsync(new FileCreatingContext(context.Path), outputStream, overwrite); } finally { @@ -204,9 +205,7 @@ public virtual async Task CreateFileFromStreamAsync(string path, Stream } else { - await ValidateAvailableStorageAsync(inputStream.Length); - - return await _fileStore.CreateFileFromStreamAsync(path, inputStream, overwrite); + return await CreateFileAsync(new FileCreatingContext(path), inputStream, overwrite); } } @@ -262,4 +261,23 @@ private async Task ValidateAvailableStorageAsync(long requiredStorageSpace) $"a file that fits the available space, or delete some unnecessary files."); } } + + private async Task CreateFileAsync(FileCreatingContext fileCreatingContext, Stream stream, bool overwrite) + { + await using var fileCreatingResult = await _fileCreationService.CreateAsync(fileCreatingContext, stream); + + if (!fileCreatingResult.Succeeded) + { + throw new FileStoreException(fileCreatingResult.ErrorMessage ?? $"The file '{fileCreatingContext.FileName}' was rejected."); + } + + await ValidateAvailableStorageAsync(fileCreatingResult.Stream.Length); + + var createdPath = await _fileStore.CreateFileFromStreamAsync(fileCreatingContext.Path, fileCreatingResult.Stream, overwrite); + var fileInfo = await _fileStore.GetFileInfoAsync(createdPath); + + await _fileCreationService.CreatedAsync(fileInfo); + + return createdPath; + } } diff --git a/src/docs/reference/README.md b/src/docs/reference/README.md index bead5c3a123..bdfecaa43a0 100644 --- a/src/docs/reference/README.md +++ b/src/docs/reference/README.md @@ -70,6 +70,7 @@ Here's a categorized overview of all built-in Orchard Core features at a glance. - Media: - [Media](modules/Media/README.md) - [Media Slugify](modules/Media.Slugify/README.md) + - [Antivirus](modules/Antivirus/README.md) - [Media Amazon S3](modules/Media.AmazonS3/README.md) - [Media Azure](modules/Media.Azure/README.md) - [XML-RPC](modules/XmlRpc/README.md) @@ -91,6 +92,7 @@ Here's a categorized overview of all built-in Orchard Core features at a glance. ### Extensibility +- [File Upload Security](core/file-upload-security.md) - [Auto Setup](modules/AutoSetup/README.md) - [GraphQL](modules/Apis.GraphQL/README.md) - [GraphQL queries](modules/Apis.GraphQL.Abstractions/README.md) diff --git a/src/docs/reference/core/file-upload-security.md b/src/docs/reference/core/file-upload-security.md new file mode 100644 index 00000000000..80a86f7acee --- /dev/null +++ b/src/docs/reference/core/file-upload-security.md @@ -0,0 +1,111 @@ +# File Upload Security + +When your feature accepts a user-uploaded file and stores it outside Orchard Core's built-in media flow, use `FileCreationService` to run the shared pre-storage security pipeline. + +This ensures every `IFileEventHandler` can inspect or replace the stream before the file is written permanently. If any handler returns a failed `FileCreatingResult`, the upload must be aborted and the file must not be stored. + +## When to use `FileCreationService` + +Use `FileCreationService` in custom controllers, admin endpoints, APIs, recipe importers, or background flows that: + +- accept a file from a user; +- write that file to disk, cloud storage, or another permanent store; and +- do **not** already go through `IMediaFileStore.CreateFileFromStreamAsync()`. + +!!! note + `DefaultMediaFileStore` already uses `FileCreationService` internally. If your code uploads through `IMediaFileStore`, do not call the service a second time. + +## Core types + +- `FileCreationService`: runs `CreatingAsync()` before storage and `CreatedAsync()` after storage succeeds. +- `FileCreatingContext`: describes the file being processed. +- `FileCreatingResult`: returns the stream that should continue through the pipeline and whether creation should proceed. +- `IFileEventHandler`: participates in the upload pipeline. + +## Ownership and cleanup + +- The original upload stream stays owned by the caller. +- If a handler replaces the stream, `FileCreationService` disposes the superseded intermediate stream. +- The `FileCreatingResult` returned from `CreateAsync()` owns the final replacement stream when one was created, so callers should use `await using` and keep that result alive for as long as they need the processed stream. +- If `CreateAsync()` returns a failed result, dispose that result the same way and abort the upload without storing the file. +- After the file has been written permanently, disposing `FileCreatingResult` cleans up any temporary replacement stream used during the upload pipeline. + +## Using `FileCreationService` + +Call `CreateAsync()` before writing the file. If the returned result did not succeed, abort the request. Only call `CreatedAsync()` after the file was stored successfully. + +```csharp +using Microsoft.AspNetCore.Http; +using OrchardCore.FileStorage; + +public sealed class CustomUploadService +{ + private readonly FileCreationService _fileCreationService; + private readonly IFileStore _fileStore; + + public CustomUploadService( + FileCreationService fileCreationService, + IFileStore fileStore) + { + _fileCreationService = fileCreationService; + _fileStore = fileStore; + } + + public async Task UploadAsync(IFormFile file, CancellationToken cancellationToken) + { + await using var uploadedStream = file.OpenReadStream(); + await using var fileCreatingResult = await _fileCreationService.CreateAsync( + new FileCreatingContext(file.FileName, file.Length, file.ContentType), + uploadedStream, + cancellationToken); + + if (!fileCreatingResult.Succeeded) + { + throw new FileStoreException(fileCreatingResult.ErrorMessage ?? $"The uploaded file '{file.FileName}' was rejected before it could be stored."); + } + + // Use the processed stream while the result is still in scope. + var path = await _fileStore.CreateFileFromStreamAsync(file.FileName, fileCreatingResult.Stream); + var fileInfo = await _fileStore.GetFileInfoAsync(path); + + await _fileCreationService.CreatedAsync(fileInfo, cancellationToken); + + return path; + } +} +``` + +## Implementing a handler + +Return `FileCreatingResult.Failed(...)` from `IFileEventHandler.CreatingAsync()` to stop the upload before it is stored. This is where a module would perform checks such as antivirus scanning, content inspection, or file-type validation. + +```csharp +using Microsoft.Extensions.Localization; +using OrchardCore.FileStorage; +using OrchardCore.Infrastructure; + +namespace MyModule.Services; + +public sealed class RejectExecutableFileEventHandler : IFileEventHandler +{ + public Task CreatingAsync(FileCreatingContext context, Stream stream, CancellationToken cancellationToken = default) + { + if (string.Equals(Path.GetExtension(context.FileName), ".exe", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(FileCreatingResult.Failed(stream, new ResultError + { + Message = new LocalizedString(nameof(RejectExecutableFileEventHandler), "Executable files are not allowed."), + })); + } + + return Task.FromResult(FileCreatingResult.Success(stream)); + } + + public Task CreatedAsync(IFileStoreEntry fileInfo, CancellationToken cancellationToken = default) + => Task.CompletedTask; +} +``` + +## Security guidance + +Always run `FileCreationService` before any permanent write. Do not save the uploaded file first and scan it afterward, since a failed scan must abort the upload before the file is persisted. diff --git a/src/docs/reference/modules/Antivirus/README.md b/src/docs/reference/modules/Antivirus/README.md new file mode 100644 index 00000000000..1a5056fffa1 --- /dev/null +++ b/src/docs/reference/modules/Antivirus/README.md @@ -0,0 +1,59 @@ +# Antivirus (`OrchardCore.Antivirus`) + +The Antivirus module provides features that inspect uploaded files before Orchard Core stores or imports them permanently. + +## ClamAV feature (`OrchardCore.Antivirus.ClamAV`) + +The ClamAV feature scans files with a `clamd` service before Orchard Core stores or imports them. + +When the feature is enabled, uploads fail closed: + +- Malware detections are rejected before storage. +- Scanner connectivity, timeout, or protocol failures also reject the upload. +- The ClamAV connection is reused per configuration to avoid creating a new TCP client for every scan. + +The scanner is wired through Orchard Core's file event handling abstractions, so uploads can be validated before storage without coupling media and deployment flows to a scanner-specific interface. ClamAV participates in that flow as an `IFileEventHandler`, and `FileCreationService` aborts the upload when ClamAV returns a failed `FileCreatingResult`. + +### Configuration + +Configure the ClamAV connection in application configuration. The settings key remains `OrchardCore_Antivirus_ClamAV` for compatibility: + +```json +{ + "OrchardCore": { + "OrchardCore_Antivirus_ClamAV": { + "Host": "localhost", + "Port": 3310, + "ConnectTimeoutSeconds": 5, + "TransferTimeoutSeconds": 30 + } + } +} +``` + +The same settings can be provided with environment variables: + +```text +OrchardCore__Antivirus_ClamAV__Host=localhost +OrchardCore__Antivirus_ClamAV__Port=3310 +OrchardCore__Antivirus_ClamAV__ConnectTimeoutSeconds=5 +OrchardCore__Antivirus_ClamAV__TransferTimeoutSeconds=30 +``` + +### Usage + +1. Configure the ClamAV settings. +2. Enable the `ClamAV Antivirus Scanner` feature (`OrchardCore.Antivirus.ClamAV`). +3. Ensure a reachable `clamd` instance is running. + +If the feature is enabled without a valid ClamAV connection, uploads are rejected until the scanner can verify them. + +This feature integrates with the shared file upload security pipeline through `IFileEventHandler`, so uploads can be rejected before Orchard Core stores them permanently. + +See [File Upload Security](../../core/file-upload-security.md) for the canonical guidance on invoking `FileCreationService` in custom upload flows and aborting rejected files before they are stored. + +### Notes + +- The currently audited upload surfaces covered by this change are media uploads plus deployment package imports, both local and remote. +- Media uploads are scanned before storage because they flow through `DefaultMediaFileStore`. +- Deployment package zip/json uploads are scanned before Orchard Core writes the uploaded file to a temporary archive location for import. diff --git a/src/docs/releases/3.0.0.md b/src/docs/releases/3.0.0.md index df6e0c01f51..2cc968662d5 100644 --- a/src/docs/releases/3.0.0.md +++ b/src/docs/releases/3.0.0.md @@ -87,6 +87,19 @@ The email event handler callbacks on `IEmailServiceEvents`, including overrides This is a source and binary breaking change for custom email services, providers, and event handlers. Update your implementations and overrides to include the new `cancellationToken` parameter. +### File Upload Security + +Orchard Core now provides a file upload event pipeline for securing user-uploaded files before they are stored: + +- `IFileEventHandler` +- `FileCreatingContext` +- `FileCreatingResult` +- `FileCreationService` + +Use `FileCreationService.CreateAsync()` anywhere your code accepts a user-uploaded file and stores it directly. If the returned `FileCreatingResult.Succeeded` value is `false`, you must abort the upload and avoid persisting the file. Only call `FileCreationService.CreatedAsync()` after the file was stored successfully. + +The ClamAV integration now uses this event pipeline through the `OrchardCore.Antivirus.ClamAV` feature. + ### Admin Theme — Removal of `TheAdminThemeOptions` and CSS Helper Extensions The `TheAdminThemeOptions` class and the `CssOrchardHelperExtensions` (from `OrchardCore.DisplayManagement`) have been removed. Admin editor views now use static CSS classes prefixed with `ocat-` (Orchard Core Admin Theme) instead of C# helper methods that injected CSS classes at runtime. diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media/AssetUrlShortcodeTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media/AssetUrlShortcodeTests.cs index c497dca55f1..b03d98f31e4 100644 --- a/test/OrchardCore.Tests/Modules/OrchardCore.Media/AssetUrlShortcodeTests.cs +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media/AssetUrlShortcodeTests.cs @@ -35,6 +35,7 @@ public async Task ShouldProcess(string cdnBaseUrl, string text, string expected) cdnBaseUrl, [], [], + new FileCreationService([]), Mock.Of>()); var defaultHttpContext = new DefaultHttpContext(); diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media/ClamAVFileEventHandlerTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media/ClamAVFileEventHandlerTests.cs new file mode 100644 index 00000000000..26089194653 --- /dev/null +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media/ClamAVFileEventHandlerTests.cs @@ -0,0 +1,301 @@ +using System.Buffers.Binary; +using System.Net.Sockets; +using OrchardCore.Antivirus.ClamAV; +using OrchardCore.FileStorage; + +namespace OrchardCore.Tests.Modules.OrchardCore.Media; + +public class ClamAvFileEventHandlerTests +{ + [Fact] + public async Task CreatingAsync_ReturnsSeekableStream_WhenClamAvReturnsOk() + { + await using var server = await FakeClamAvServer.StartAsync("stream: OK\n"); + using var factory = CreateFactory(); + var handler = CreateHandler(server.Port, factory); + + var inputStream = new MemoryStream("hello world"u8.ToArray()); + var result = await handler.CreatingAsync(new FileCreatingContext("folder/test.txt"), inputStream, TestContext.Current.CancellationToken); + var request = (await server.Completion).Single(); + + Assert.True(result.Succeeded); + Assert.Same(inputStream, result.Stream); + Assert.Equal("nINSTREAM\n", request.Command); + Assert.Equal("hello world"u8.ToArray(), request.Payload); + } + + [Fact] + public async Task CreatingAsync_BuffersNonSeekableStreams() + { + await using var server = await FakeClamAvServer.StartAsync("stream: OK\n"); + using var factory = CreateFactory(); + var handler = CreateHandler(server.Port, factory); + await using var baseStream = new MemoryStream("hello world"u8.ToArray()); + await using var inputStream = new NonSeekableReadStream(baseStream); + + var result = await handler.CreatingAsync(new FileCreatingContext("folder/test.txt"), inputStream); + + Assert.True(result.Succeeded); + Assert.NotSame(inputStream, result.Stream); + Assert.True(result.Stream.CanSeek); + + using var copy = new MemoryStream(); + await result.Stream.CopyToAsync(copy); + Assert.Equal("hello world"u8.ToArray(), copy.ToArray()); + } + + [Fact] + public async Task CreatingAsync_ReturnsFailedResult_WhenClamAvFindsMalware() + { + await using var server = await FakeClamAvServer.StartAsync("stream: Eicar-Test-Signature FOUND\n"); + using var factory = CreateFactory(); + var handler = CreateHandler(server.Port, factory); + + var result = await handler.CreatingAsync(new FileCreatingContext("folder/test.txt"), new MemoryStream("hello world"u8.ToArray())); + + Assert.False(result.Succeeded); + Assert.Equal("The uploaded file 'test.txt' was rejected because ClamAV detected 'Eicar-Test-Signature'.", result.ErrorMessage); + } + + [Fact] + public async Task CreatingAsync_Throws_WhenClamAvReturnsError() + { + await using var server = await FakeClamAvServer.StartAsync("INSTREAM size limit exceeded. ERROR\n"); + using var factory = CreateFactory(); + var handler = CreateHandler(server.Port, factory); + + var exception = await Assert.ThrowsAsync(() => + handler.CreatingAsync(new FileCreatingContext("folder/test.txt"), new MemoryStream("hello world"u8.ToArray()))); + + Assert.Equal("The ClamAV antivirus scanner returned an unexpected response while scanning 'test.txt': INSTREAM size limit exceeded. ERROR", exception.Message); + } + + [Fact] + public async Task CreatingAsync_Throws_WhenHostIsMissing() + { + using var factory = CreateFactory(); + var handler = new ClamAvFileEventHandler( + Options.Create(new ClamAvOptions + { + Host = "", + }), + factory, + NullLogger.Instance); + + var exception = await Assert.ThrowsAsync(() => + handler.CreatingAsync(new FileCreatingContext("folder/test.txt"), new MemoryStream("hello world"u8.ToArray()))); + + Assert.Equal("The ClamAV antivirus scanner is enabled but the host setting is missing.", exception.Message); + } + + [Fact] + public async Task CreatingAsync_ReusesTheSameTcpConnection() + { + await using var server = await FakeClamAvServer.StartAsync("stream: OK\n", "stream: OK\n"); + using var factory = CreateFactory(); + var handler = CreateHandler(server.Port, factory); + + var firstResult = await handler.CreatingAsync(new FileCreatingContext("folder/first.txt"), new MemoryStream("first"u8.ToArray())); + var secondResult = await handler.CreatingAsync(new FileCreatingContext("folder/second.txt"), new MemoryStream("second"u8.ToArray())); + var requests = await server.Completion; + + Assert.True(firstResult.Succeeded); + Assert.True(secondResult.Succeeded); + Assert.Equal(1, server.ConnectionCount); + Assert.Equal("first"u8.ToArray(), requests[0].Payload); + Assert.Equal("second"u8.ToArray(), requests[1].Payload); + } + + private static ClamAvFileEventHandler CreateHandler(int port, ClamAvConnectionFactory factory) + => new( + Options.Create(new ClamAvOptions + { + Host = IPAddress.Loopback.ToString(), + Port = port, + ConnectTimeoutSeconds = 5, + TransferTimeoutSeconds = 5, + }), + factory, + NullLogger.Instance); + + private static ClamAvConnectionFactory CreateFactory() + => new(Mock.Of(l => l.ApplicationStopped == CancellationToken.None), NullLoggerFactory.Instance); + + private sealed class FakeClamAvServer : IAsyncDisposable + { + private readonly TcpListener _listener; + + private FakeClamAvServer(TcpListener listener, Task completion, Func connectionCount) + { + _listener = listener; + Completion = completion; + _connectionCount = connectionCount; + } + + private readonly Func _connectionCount; + + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + + public int ConnectionCount => _connectionCount(); + + public Task Completion { get; } + + public static Task StartAsync(params string[] responses) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + + var connectionCount = 0; + var completion = Task.Run(async () => + { + var requests = new List(); + + using var client = await listener.AcceptTcpClientAsync(); + Interlocked.Increment(ref connectionCount); + + await using var networkStream = client.GetStream(); + + foreach (var response in responses) + { + var command = await ReadLineAsync(networkStream); + var payload = await ReadPayloadAsync(networkStream); + var responseBytes = Encoding.ASCII.GetBytes(response); + + requests.Add(new ClamAvRequest(command, payload)); + + await networkStream.WriteAsync(responseBytes); + await networkStream.FlushAsync(); + } + + return requests.ToArray(); + }); + + return Task.FromResult(new FakeClamAvServer(listener, completion, () => connectionCount)); + } + + public ValueTask DisposeAsync() + { + _listener.Stop(); + + return ValueTask.CompletedTask; + } + + private static async Task ReadLineAsync(NetworkStream stream) + { + using var commandStream = new MemoryStream(); + var buffer = new byte[1]; + + while (true) + { + var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, 1)); + + if (bytesRead == 0) + { + break; + } + + commandStream.WriteByte(buffer[0]); + + if (buffer[0] == '\n') + { + break; + } + } + + return Encoding.ASCII.GetString(commandStream.ToArray()); + } + + private static async Task ReadPayloadAsync(Stream stream) + { + using var payloadStream = new MemoryStream(); + var lengthBuffer = new byte[sizeof(int)]; + + while (true) + { + await ReadExactlyAsync(stream, lengthBuffer); + + var chunkLength = BinaryPrimitives.ReadInt32BigEndian(lengthBuffer); + + if (chunkLength == 0) + { + break; + } + + var chunk = new byte[chunkLength]; + await ReadExactlyAsync(stream, chunk); + await payloadStream.WriteAsync(chunk); + } + + return payloadStream.ToArray(); + } + + private static async Task ReadExactlyAsync(Stream stream, byte[] buffer) + { + var offset = 0; + + while (offset < buffer.Length) + { + var bytesRead = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset)); + + if (bytesRead == 0) + { + throw new EndOfStreamException(); + } + + offset += bytesRead; + } + } + } + + private sealed record ClamAvRequest(string Command, byte[] Payload); + + private sealed class NonSeekableReadStream : Stream + { + private readonly Stream _innerStream; + + public NonSeekableReadStream(Stream innerStream) + { + _innerStream = innerStream; + } + + public override bool CanRead => _innerStream.CanRead; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => _innerStream.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => _innerStream.Read(buffer, offset, count); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override async ValueTask DisposeAsync() + { + await _innerStream.DisposeAsync(); + await base.DisposeAsync(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _innerStream.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media/DefaultMediaFileStoreTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media/DefaultMediaFileStoreTests.cs index f1315b6e6e5..f992e7ac42e 100644 --- a/test/OrchardCore.Tests/Modules/OrchardCore.Media/DefaultMediaFileStoreTests.cs +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media/DefaultMediaFileStoreTests.cs @@ -1,4 +1,6 @@ +using Microsoft.Extensions.Localization; using OrchardCore.FileStorage; +using OrchardCore.Infrastructure; using OrchardCore.Media.Core; using OrchardCore.Media.Events; @@ -21,6 +23,7 @@ public void MapPathToPublicUrl_ReturnsUrlEncodedPath(string path, string expecte "", [], [], + CreateFileCreationService(), Mock.Of>()); var result = store.MapPathToPublicUrl(path); @@ -39,6 +42,7 @@ public void MapPathToPublicUrl_WithCdnBaseUrl_ReturnsUrlEncodedPath(string cdnBa cdnBaseUrl, [], [], + CreateFileCreationService(), Mock.Of>()); var result = store.MapPathToPublicUrl(path); @@ -57,12 +61,17 @@ public async Task CreateFileFromStreamAsync_NoHandlers_CallsFileStoreDirectly() .Setup(x => x.CreateFileFromStreamAsync("test.txt", inputStream, false)) .ReturnsAsync("result"); + fileStoreMock + .Setup(x => x.GetFileInfoAsync("result")) + .ReturnsAsync(Mock.Of()); + var store = new DefaultMediaFileStore( fileStoreMock.Object, "", "", new List(), new List(), + CreateFileCreationService(), loggerMock.Object); // Act @@ -95,6 +104,7 @@ public async Task CreateFileFromStreamAsync_HandlerReturnsInputStream_PassesInpu "", new List(), new[] { handlerMock.Object }, + CreateFileCreationService(), loggerMock.Object); // Act @@ -129,12 +139,17 @@ public async Task CreateFileFromStreamAsync_HandlersCreateNewStreams_PassesCorre .Setup(x => x.CreateFileFromStreamAsync("test.txt", stream2, false)) .ReturnsAsync("result"); + fileStoreMock + .Setup(x => x.GetFileInfoAsync("result")) + .ReturnsAsync(Mock.Of()); + var store = new DefaultMediaFileStore( fileStoreMock.Object, "", "", new List(), new[] { handler1.Object, handler2.Object }, + CreateFileCreationService(), loggerMock.Object); // Act @@ -175,6 +190,7 @@ public async Task CreateFileFromStreamAsync_ValidateAvailableStorageAsync() "", [handler.Object], [], + CreateFileCreationService(), loggerMock.Object); // Act @@ -188,4 +204,112 @@ public async Task CreateFileFromStreamAsync_ValidateAvailableStorageAsync() "uploading a file that fits the available space, or delete some unnecessary files."; Assert.Equal(expectedMessage, exception.Message); } + + [Fact] + public async Task CreateFileFromStreamAsync_FileEventHandlerCanReplaceStreamBeforeSaving() + { + var fileStoreMock = new Mock(); + var loggerMock = new Mock>(); + var inputStream = new MemoryStream("ignored"u8.ToArray()); + var replacementStream = new MemoryStream("hello world"u8.ToArray()); + var handlerMock = new Mock(); + + handlerMock + .Setup(x => x.CreatingAsync(It.IsAny(), inputStream, It.IsAny())) + .ReturnsAsync(FileCreatingResult.Success(replacementStream)); + + fileStoreMock + .Setup(x => x.CreateFileFromStreamAsync("test.txt", It.IsAny(), false)) + .ReturnsAsync("result") + .Callback((_, stream, _) => + { + using var copy = new MemoryStream(); + stream.CopyTo(copy); + Assert.Equal("hello world"u8.ToArray(), copy.ToArray()); + }); + + fileStoreMock + .Setup(x => x.GetFileInfoAsync("result")) + .ReturnsAsync(Mock.Of()); + + var store = new DefaultMediaFileStore( + fileStoreMock.Object, + "", + "", + [], + [], + CreateFileCreationService(handlerMock.Object), + loggerMock.Object); + + await store.CreateFileFromStreamAsync("test.txt", inputStream); + } + + [Fact] + public async Task CreateFileFromStreamAsync_FileEventHandlerCanRejectFile() + { + var fileStoreMock = new Mock(MockBehavior.Strict); + var loggerMock = new Mock>(); + var inputStream = new MemoryStream("hello world"u8.ToArray()); + var handlerMock = new Mock(); + + handlerMock + .Setup(x => x.CreatingAsync(It.IsAny(), inputStream, It.IsAny())) + .ReturnsAsync(FileCreatingResult.Failed(stream: null, + new ResultError + { + Message = new LocalizedString("Rejected", "The uploaded file was rejected by the anti-virus scanner."), + })); + + var store = new DefaultMediaFileStore( + fileStoreMock.Object, + "", + "", + [], + [], + CreateFileCreationService(handlerMock.Object), + loggerMock.Object); + + var exception = await Assert.ThrowsAsync(() => + store.CreateFileFromStreamAsync("test.txt", inputStream)); + + Assert.Equal("The uploaded file was rejected by the anti-virus scanner.", exception.Message); + } + + [Fact] + public async Task CreateFileFromStreamAsync_FileEventHandlersRunCreatedAfterTheFileIsStored() + { + var fileStoreMock = new Mock(); + var loggerMock = new Mock>(); + var inputStream = new MemoryStream("hello world"u8.ToArray()); + var fileInfoMock = new Mock(); + var handlerMock = new Mock(); + + handlerMock + .Setup(x => x.CreatingAsync(It.IsAny(), inputStream, It.IsAny())) + .ReturnsAsync(FileCreatingResult.Success(inputStream)); + + fileStoreMock + .Setup(x => x.CreateFileFromStreamAsync("test.txt", inputStream, false)) + .ReturnsAsync("result"); + + fileStoreMock + .Setup(x => x.GetFileInfoAsync("result")) + .ReturnsAsync(fileInfoMock.Object); + + var store = new DefaultMediaFileStore( + fileStoreMock.Object, + "", + "", + [], + [], + CreateFileCreationService(handlerMock.Object), + loggerMock.Object); + + await store.CreateFileFromStreamAsync("test.txt", inputStream); + + handlerMock.Verify(x => x.CreatedAsync(fileInfoMock.Object, It.IsAny()), Times.Once); + } + + private static FileCreationService CreateFileCreationService(params IFileEventHandler[] handlers) + => new(handlers); } diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media/FileCreationServiceTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media/FileCreationServiceTests.cs new file mode 100644 index 00000000000..5502af85310 --- /dev/null +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media/FileCreationServiceTests.cs @@ -0,0 +1,81 @@ +using OrchardCore.FileStorage; +using OrchardCore.Infrastructure; + +namespace OrchardCore.Tests.Modules.OrchardCore.Media; + +public class FileCreationServiceTests +{ + [Fact] + public async Task CreateAsync_DisposesReplacementStream_WhenLaterHandlerThrows() + { + var originalStream = new TrackingStream(); + var replacementStream = new TrackingStream(); + + var firstHandler = new Mock(); + firstHandler + .Setup(x => x.CreatingAsync(It.IsAny(), originalStream, It.IsAny())) + .ReturnsAsync(FileCreatingResult.Success(replacementStream)); + + var secondHandler = new Mock(); + secondHandler + .Setup(x => x.CreatingAsync(It.IsAny(), replacementStream, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Handler failure.")); + + var service = new FileCreationService([firstHandler.Object, secondHandler.Object]); + + await Assert.ThrowsAsync(() => + service.CreateAsync(new FileCreatingContext("file.txt"), originalStream)); + + Assert.True(replacementStream.IsDisposed); + Assert.False(originalStream.IsDisposed); + } + + [Fact] + public async Task CreateAsync_ReturnedFailedResult_DisposesReplacementStream() + { + var originalStream = new TrackingStream(); + var replacementStream = new TrackingStream(); + + var firstHandler = new Mock(); + firstHandler + .Setup(x => x.CreatingAsync(It.IsAny(), originalStream, It.IsAny())) + .ReturnsAsync(FileCreatingResult.Success(replacementStream)); + + var secondHandler = new Mock(); + secondHandler + .Setup(x => x.CreatingAsync(It.IsAny(), replacementStream, It.IsAny())) + .ReturnsAsync(FileCreatingResult.Failed(replacementStream, new ResultError + { + Message = new LocalizedString("Rejected", "The file was rejected."), + })); + + var service = new FileCreationService([firstHandler.Object, secondHandler.Object]); + + await using (var result = await service.CreateAsync(new FileCreatingContext("file.txt"), originalStream, TestContext.Current.CancellationToken)) + { + Assert.False(result.Succeeded); + Assert.Same(replacementStream, result.Stream); + Assert.False(replacementStream.IsDisposed); + } + + Assert.True(replacementStream.IsDisposed); + Assert.False(originalStream.IsDisposed); + } + + private sealed class TrackingStream : MemoryStream + { + public bool IsDisposed { get; private set; } + + protected override void Dispose(bool disposing) + { + IsDisposed = true; + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + IsDisposed = true; + await base.DisposeAsync(); + } + } +} diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media/ImageShortcodeTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media/ImageShortcodeTests.cs index 46817307e8e..09d7488a639 100644 --- a/test/OrchardCore.Tests/Modules/OrchardCore.Media/ImageShortcodeTests.cs +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media/ImageShortcodeTests.cs @@ -46,6 +46,7 @@ public async Task ShouldProcess(string cdnBaseUrl, string text, string expected) cdnBaseUrl, [], [], + new FileCreationService([]), Mock.Of>()); var fileVersionProvider = Mock.Of(); diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media/MediaOrchardHelperExtensionsTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media/MediaOrchardHelperExtensionsTests.cs index 030eff3781a..7c774cfce9f 100644 --- a/test/OrchardCore.Tests/Modules/OrchardCore.Media/MediaOrchardHelperExtensionsTests.cs +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media/MediaOrchardHelperExtensionsTests.cs @@ -43,6 +43,7 @@ private static TestOrchardHelper CreateOrchardHelper() "", [], [], + new FileCreationService([]), Mock.Of>()); var mediaProfileServiceMock = new Mock(); diff --git a/test/OrchardCore.Tests/OrchardCore.Tests.csproj b/test/OrchardCore.Tests/OrchardCore.Tests.csproj index 125dac70836..d1dc44fcfc2 100644 --- a/test/OrchardCore.Tests/OrchardCore.Tests.csproj +++ b/test/OrchardCore.Tests/OrchardCore.Tests.csproj @@ -52,9 +52,11 @@ + +