Compare commits

...
Author SHA1 Message Date
Andreas ZerbstandGitHub 9ab0abca99 E2E: Stop running version 14 on nightly test pipeline (#20558)
Stops 14 from running on our nightly test pipeline
2025-10-20 12:05:42 +02:00
Andy Butland 009a999e22 Merge branch 'release/14.3.4' into v14/dev 2025-04-08 07:15:06 +02:00
Andy ButlandandGitHub d3c1443b14 Merge commit from fork
* Prevent path traveral vulnerability with upload of temporary files.

* Used BadRequest instead of NotFound for invalid file name response.
2025-04-08 05:03:40 +02:00
Andy Butland 8a9db11422 Merge branch 'release/14.3.3' into v14/dev
# Conflicts:
#	Directory.Packages.props
#	version.json
2025-03-11 09:28:52 +01:00
Andy ButlandandGitHub d9fb6df16e Merge commit from fork
* Bumped version to 15.2.1.

# Conflicts:
#	version.json

* Tighten management API endpoint access rules.
2025-03-11 05:11:35 +01:00
Andy Butland 14ed3348bf bumped imagesharp to prevent CVE-2025-27598 (#18602)
# Conflicts:
#	Directory.Packages.props
2025-03-09 08:54:03 +01:00
Jacob Overgaard abc312c9b4 update backoffice submodule to 14.3.2 2025-01-21 08:44:10 +01:00
Ronald BarendseandNikolaj Geisle 65bb2801b0 Merge commit from fork
* Add TimedScope

* Use TimedScope in login endpoint

* Use seperate default duration and only calculate average of actual successful responses

* Only return detailed error responses if credentials are valid

* Cancel timed scope when credentials are valid

* Add UserDefaultFailedLoginDuration and UserMinimumFailedLoginDuration settings
2025-01-20 16:44:28 +01:00
Andy ButlandandGitHub a0a4af6a0c Merge commit from fork 2025-01-20 14:54:14 +01:00
Jacob Overgaard e8d6cded2b update backoffice submodule to 14.3.2 2025-01-20 14:42:17 +01:00
Bjarke BergandJacob Overgaard c65204a146 update backoffice submodule to 14.3.2 2025-01-20 14:34:03 +01:00
Andy ButlandandGitHub 86f3033334 Merge commit from fork 2025-01-20 14:14:28 +01:00
Andy Butland 0166727eee Bumped version to 14.3.2. 2025-01-07 10:13:02 +01:00
23 changed files with 402 additions and 65 deletions
+1 -1
View File
@@ -73,7 +73,7 @@
<PackageVersion Include="Serilog.Sinks.Async" Version="1.5.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageVersion Include="Serilog.Sinks.Map" Version="1.0.2" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.6" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.7" />
<PackageVersion Include="SixLabors.ImageSharp.Web" Version="3.1.3" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.9.0" />
</ItemGroup>
+6 -7
View File
@@ -3,13 +3,12 @@ name: Nightly_E2E_Test_$(TeamProject)_$(Build.DefinitionName)_$(SourceBranchName
pr: none
trigger: none
schedules:
- cron: '0 0 * * *'
displayName: Daily midnight build
branches:
include:
- v14/dev
- v15/dev
# schedules:
# - cron: '0 0 * * *'
# displayName: Daily midnight build
# branches:
# include:
# - v14/dev
variables:
nodeVersion: 20
@@ -1,4 +1,5 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.DataType;
@@ -7,10 +8,12 @@ using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.DataType;
[ApiVersion("1.0")]
[Authorize(Policy = AuthorizationPolicies.TreeAccessDataTypes)]
public class CopyDataTypeController : DataTypeControllerBase
{
private readonly IDataTypeService _dataTypeService;
@@ -1,4 +1,5 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
@@ -8,10 +9,12 @@ using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.DataType;
[ApiVersion("1.0")]
[Authorize(Policy = AuthorizationPolicies.TreeAccessDataTypes)]
public class CreateDataTypeController : DataTypeControllerBase
{
private readonly IDataTypeService _dataTypeService;
@@ -1,4 +1,5 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core;
@@ -6,10 +7,12 @@ using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.DataType;
[ApiVersion("1.0")]
[Authorize(Policy = AuthorizationPolicies.TreeAccessDataTypes)]
public class DeleteDataTypeController : DataTypeControllerBase
{
private readonly IDataTypeService _dataTypeService;
@@ -1,4 +1,5 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.DataType;
@@ -7,10 +8,12 @@ using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.DataType;
[ApiVersion("1.0")]
[Authorize(Policy = AuthorizationPolicies.TreeAccessDataTypes)]
public class MoveDataTypeController : DataTypeControllerBase
{
private readonly IDataTypeService _dataTypeService;
@@ -1,4 +1,5 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
@@ -8,10 +9,12 @@ using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.DataType;
[ApiVersion("1.0")]
[Authorize(Policy = AuthorizationPolicies.TreeAccessDataTypes)]
public class UpdateDataTypeController : DataTypeControllerBase
{
private readonly IDataTypeService _dataTypeService;
@@ -1,14 +1,17 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.DocumentType;
[ApiVersion("1.0")]
[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)]
public class ExportDocumentTypeController : DocumentTypeControllerBase
{
private readonly IContentTypeService _contentTypeService;
@@ -1,17 +1,19 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.DocumentType;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services.ImportExport;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.DocumentType;
[ApiVersion("1.0")]
[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)]
public class ImportExistingDocumentTypeController : DocumentTypeControllerBase
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
@@ -1,17 +1,19 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.DocumentType;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services.ImportExport;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.DocumentType;
[ApiVersion("1.0")]
[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)]
public class ImportNewDocumentTypeController : DocumentTypeControllerBase
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
@@ -1,14 +1,17 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.MediaType;
[ApiVersion("1.0")]
[Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)]
public class ExportMediaTypeController : MediaTypeControllerBase
{
private readonly IMediaTypeService _mediaTypeService;
@@ -1,17 +1,19 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.MediaType;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services.ImportExport;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.MediaType;
[ApiVersion("1.0")]
[Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)]
public class ImportExistingMediaTypeController : MediaTypeControllerBase
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
@@ -1,18 +1,19 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Controllers.DocumentType;
using Umbraco.Cms.Api.Management.ViewModels.MediaType;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services.ImportExport;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.MediaType;
[ApiVersion("1.0")]
[Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)]
public class ImportNewMediaTypeController : MediaTypeControllerBase
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
@@ -1,9 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Controllers.DocumentType;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Api.Management.ViewModels.MediaType;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
@@ -34,6 +34,8 @@ namespace Umbraco.Cms.Api.Management.Controllers.Security;
[ApiExplorerSettings(IgnoreApi = true)]
public class BackOfficeController : SecurityControllerBase
{
private static long? _loginDurationAverage;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IBackOfficeSignInManager _backOfficeSignInManager;
private readonly IBackOfficeUserManager _backOfficeUserManager;
@@ -72,45 +74,65 @@ public class BackOfficeController : SecurityControllerBase
[Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)]
public async Task<IActionResult> Login(CancellationToken cancellationToken, LoginRequestModel model)
{
IdentitySignInResult result = await _backOfficeSignInManager.PasswordSignInAsync(
model.Username, model.Password, true, true);
// Start a timed scope to ensure failed responses return is a consistent time
var loginDuration = Math.Max(_loginDurationAverage ?? _securitySettings.Value.UserDefaultFailedLoginDurationInMilliseconds, _securitySettings.Value.UserMinimumFailedLoginDurationInMilliseconds);
await using var timedScope = new TimedScope(loginDuration, cancellationToken);
if (result.IsNotAllowed)
IdentitySignInResult result = await _backOfficeSignInManager.PasswordSignInAsync(model.Username, model.Password, true, true);
if (result.Succeeded is false)
{
return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder()
.WithTitle("User is not allowed")
.WithDetail("The operation is not allowed on the user")
.Build());
}
if (result.IsLockedOut)
{
return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder()
.WithTitle("User is locked")
.WithDetail("The user is locked, and need to be unlocked before more login attempts can be executed.")
.Build());
}
if(result.RequiresTwoFactor)
{
string? twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(model.Username);
BackOfficeIdentityUser? attemptingUser = await _backOfficeUserManager.FindByNameAsync(model.Username);
IEnumerable<string> enabledProviders = (await _userTwoFactorLoginService.GetProviderNamesAsync(attemptingUser!.Key)).Result.Where(x=>x.IsEnabledOnUser).Select(x=>x.ProviderName);
return StatusCode(StatusCodes.Status402PaymentRequired, new RequiresTwoFactorResponseModel()
// TODO: The result should include the user and whether the credentials were valid to avoid these additional checks
BackOfficeIdentityUser? user = await _backOfficeUserManager.FindByNameAsync(model.Username.Trim()); // Align with UmbracoSignInManager and trim username!
if (user is not null &&
await _backOfficeUserManager.CheckPasswordAsync(user, model.Password))
{
TwoFactorLoginView = twofactorView,
EnabledTwoFactorProviderNames = enabledProviders
});
// The credentials were correct, so cancel timed scope and provide a more detailed failure response
await timedScope.CancelAsync();
if (result.IsNotAllowed)
{
return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder()
.WithTitle("User is not allowed")
.WithDetail("The operation is not allowed on the user")
.Build());
}
if (result.IsLockedOut)
{
return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder()
.WithTitle("User is locked")
.WithDetail("The user is locked, and need to be unlocked before more login attempts can be executed.")
.Build());
}
if (result.RequiresTwoFactor)
{
string? twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(model.Username);
IEnumerable<string> enabledProviders = (await _userTwoFactorLoginService.GetProviderNamesAsync(user.Key)).Result.Where(x => x.IsEnabledOnUser).Select(x => x.ProviderName);
return StatusCode(StatusCodes.Status402PaymentRequired, new RequiresTwoFactorResponseModel()
{
TwoFactorLoginView = twofactorView,
EnabledTwoFactorProviderNames = enabledProviders
});
}
}
return StatusCode(StatusCodes.Status401Unauthorized, new ProblemDetailsBuilder()
.WithTitle("Invalid credentials")
.WithDetail("The provided credentials are invalid. User has not been signed in.")
.Build());
}
if (result.Succeeded)
{
return Ok();
}
return StatusCode(StatusCodes.Status401Unauthorized, new ProblemDetailsBuilder()
.WithTitle("Invalid credentials")
.WithDetail("The provided credentials are invalid. User has not been signed in.")
.Build());
// Set initial or update average (successful) login duration
_loginDurationAverage = _loginDurationAverage is long average
? (average + (long)timedScope.Elapsed.TotalMilliseconds) / 2
: (long)timedScope.Elapsed.TotalMilliseconds;
// Cancel the timed scope (we don't want to unnecessarily wait on a successful response)
await timedScope.CancelAsync();
return Ok();
}
[AllowAnonymous]
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.Builders;
using Umbraco.Cms.Api.Management.Routing;
@@ -17,6 +17,9 @@ public abstract class TemporaryFileControllerBase : ManagementApiControllerBase
.WithTitle("File extension not allowed")
.WithDetail("The file extension is not allowed.")
.Build()),
TemporaryFileOperationStatus.InvalidFileName => BadRequest(problemDetailsBuilder
.WithTitle("The provided file name is not valid")
.Build()),
TemporaryFileOperationStatus.KeyAlreadyUsed => BadRequest(problemDetailsBuilder
.WithTitle("Key already used")
.WithDetail("The specified key is already used.")
@@ -4,7 +4,7 @@
<Description>Adds imaging support using ImageSharp/ImageSharp.Web version 2 to Umbraco CMS.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" VersionOverride="[2.1.9, 3)" />
<PackageReference Include="SixLabors.ImageSharp" VersionOverride="[2.1.10, 3)" />
<PackageReference Include="SixLabors.ImageSharp.Web" VersionOverride="[2.0.2, 3)" />
</ItemGroup>
@@ -2,6 +2,7 @@
// See LICENSE for more details.
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace Umbraco.Cms.Core.Configuration.Models;
@@ -27,6 +28,8 @@ public class SecuritySettings
internal const int StaticMemberDefaultLockoutTimeInMinutes = 30 * 24 * 60;
internal const int StaticUserDefaultLockoutTimeInMinutes = 30 * 24 * 60;
private const long StaticUserDefaultFailedLoginDurationInMilliseconds = 1000;
private const long StaticUserMinimumFailedLoginDurationInMilliseconds = 250;
internal const string StaticAuthorizeCallbackPathName = "/umbraco/oauth_complete";
internal const string StaticAuthorizeCallbackLogoutPathName = "/umbraco/logout";
internal const string StaticAuthorizeCallbackErrorPathName = "/umbraco/error";
@@ -117,6 +120,30 @@ public class SecuritySettings
[DefaultValue(StaticAllowConcurrentLogins)]
public bool AllowConcurrentLogins { get; set; } = StaticAllowConcurrentLogins;
/// <summary>
/// Gets or sets the default duration (in milliseconds) of failed login attempts.
/// </summary>
/// <value>
/// The default duration (in milliseconds) of failed login attempts.
/// </value>
/// <remarks>
/// The user login endpoint ensures that failed login attempts take at least as long as the average successful login.
/// However, if no successful logins have occurred, this value is used as the default duration.
/// </remarks>
[Range(0, long.MaxValue)]
[DefaultValue(StaticUserDefaultFailedLoginDurationInMilliseconds)]
public long UserDefaultFailedLoginDurationInMilliseconds { get; set; } = StaticUserDefaultFailedLoginDurationInMilliseconds;
/// <summary>
/// Gets or sets the minimum duration (in milliseconds) of failed login attempts.
/// </summary>
/// <value>
/// The minimum duration (in milliseconds) of failed login attempts.
/// </value>
[Range(0, long.MaxValue)]
[DefaultValue(StaticUserMinimumFailedLoginDurationInMilliseconds)]
public long UserMinimumFailedLoginDurationInMilliseconds { get; set; } = StaticUserMinimumFailedLoginDurationInMilliseconds;
/// <summary>
/// Gets or sets a value of the back-office host URI. Use this when running the back-office client and the Management API on different hosts. Leave empty when running both on the same host.
/// </summary>
@@ -6,5 +6,6 @@ public enum TemporaryFileOperationStatus
FileExtensionNotAllowed = 1,
KeyAlreadyUsed = 2,
NotFound = 3,
UploadBlocked
UploadBlocked = 4,
InvalidFileName = 5,
}
@@ -45,7 +45,6 @@ internal sealed class TemporaryFileService : ITemporaryFileService
return Attempt.FailWithStatus<TemporaryFileModel?, TemporaryFileOperationStatus>(TemporaryFileOperationStatus.KeyAlreadyUsed, null);
}
await using Stream dataStream = createModel.OpenReadStream();
dataStream.Seek(0, SeekOrigin.Begin);
if (_fileStreamSecurityValidator.IsConsideredSafe(dataStream) is false)
@@ -53,13 +52,12 @@ internal sealed class TemporaryFileService : ITemporaryFileService
return Attempt.FailWithStatus<TemporaryFileModel?, TemporaryFileOperationStatus>(TemporaryFileOperationStatus.UploadBlocked, null);
}
temporaryFileModel = new TemporaryFileModel
{
Key = createModel.Key,
FileName = createModel.FileName,
OpenReadStream = createModel.OpenReadStream,
AvailableUntil = DateTime.Now.Add(_runtimeSettings.TemporaryFileLifeTime)
AvailableUntil = DateTime.Now.Add(_runtimeSettings.TemporaryFileLifeTime),
};
await _temporaryFileRepository.SaveAsync(temporaryFileModel);
@@ -68,17 +66,29 @@ internal sealed class TemporaryFileService : ITemporaryFileService
}
private TemporaryFileOperationStatus Validate(TemporaryFileModelBase temporaryFileModel)
=> IsAllowedFileExtension(temporaryFileModel) == false
? TemporaryFileOperationStatus.FileExtensionNotAllowed
: TemporaryFileOperationStatus.Success;
private bool IsAllowedFileExtension(TemporaryFileModelBase temporaryFileModel)
{
var extension = Path.GetExtension(temporaryFileModel.FileName)[1..];
if (IsAllowedFileExtension(temporaryFileModel.FileName) == false)
{
return TemporaryFileOperationStatus.FileExtensionNotAllowed;
}
if (IsValidFileName(temporaryFileModel.FileName) == false)
{
return TemporaryFileOperationStatus.InvalidFileName;
}
return TemporaryFileOperationStatus.Success;
}
private bool IsAllowedFileExtension(string fileName)
{
var extension = Path.GetExtension(fileName)[1..];
return _contentSettings.IsFileAllowedForUpload(extension);
}
private static bool IsValidFileName(string fileName) =>
!string.IsNullOrEmpty(fileName) && fileName.IndexOfAny(Path.GetInvalidFileNameChars()) < 0;
public async Task<Attempt<TemporaryFileModel?, TemporaryFileOperationStatus>> DeleteAsync(Guid key)
{
TemporaryFileModel? model = await _temporaryFileRepository.GetAsync(key);
+162
View File
@@ -0,0 +1,162 @@
namespace Umbraco.Cms.Core;
/// <summary>
/// Makes a code block timed (take at least a certain amount of time). This class cannot be inherited.
/// </summary>
public sealed class TimedScope : IDisposable, IAsyncDisposable
{
private readonly TimeSpan _duration;
private readonly TimeProvider _timeProvider;
private readonly CancellationTokenSource _cancellationTokenSource;
private readonly long _startingTimestamp;
/// <summary>
/// Gets the elapsed time.
/// </summary>
/// <value>
/// The elapsed time.
/// </value>
public TimeSpan Elapsed
=> _timeProvider.GetElapsedTime(_startingTimestamp);
/// <summary>
/// Gets the remaining time.
/// </summary>
/// <value>
/// The remaining time.
/// </value>
public TimeSpan Remaining
=> TryGetRemaining(out TimeSpan remaining) ? remaining : TimeSpan.Zero;
/// <summary>
/// Initializes a new instance of the <see cref="TimedScope" /> class.
/// </summary>
/// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
public TimedScope(long millisecondsDuration)
: this(TimeSpan.FromMilliseconds(millisecondsDuration))
{ }
/// <summary>
/// Initializes a new instance of the <see cref="TimedScope" /> class.
/// </summary>
/// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public TimedScope(long millisecondsDuration, CancellationToken cancellationToken)
: this(TimeSpan.FromMilliseconds(millisecondsDuration), cancellationToken)
{ }
/// <summary>
/// Initializes a new instance of the <see cref="TimedScope" /> class.
/// </summary>
/// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
/// <param name="timeProvider">The time provider.</param>
public TimedScope(long millisecondsDuration, TimeProvider timeProvider)
: this(TimeSpan.FromMilliseconds(millisecondsDuration), timeProvider)
{ }
/// <summary>
/// Initializes a new instance of the <see cref="TimedScope" /> class.
/// </summary>
/// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
/// <param name="timeProvider">The time provider.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public TimedScope(long millisecondsDuration, TimeProvider timeProvider, CancellationToken cancellationToken)
: this(TimeSpan.FromMilliseconds(millisecondsDuration), timeProvider, cancellationToken)
{ }
/// <summary>
/// Initializes a new instance of the <see cref="TimedScope"/> class.
/// </summary>
/// <param name="duration">The duration the scope should at least take.</param>
public TimedScope(TimeSpan duration)
: this(duration, TimeProvider.System)
{ }
/// <summary>
/// Initializes a new instance of the <see cref="TimedScope" /> class.
/// </summary>
/// <param name="duration">The duration the scope should at least take.</param>
/// <param name="timeProvider">The time provider.</param>
public TimedScope(TimeSpan duration, TimeProvider timeProvider)
: this(duration, timeProvider, new CancellationTokenSource())
{ }
/// <summary>
/// Initializes a new instance of the <see cref="TimedScope" /> class.
/// </summary>
/// <param name="duration">The duration the scope should at least take.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public TimedScope(TimeSpan duration, CancellationToken cancellationToken)
: this(duration, TimeProvider.System, cancellationToken)
{ }
/// <summary>
/// Initializes a new instance of the <see cref="TimedScope" /> class.
/// </summary>
/// <param name="duration">The duration the scope should at least take.</param>
/// <param name="timeProvider">The time provider.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public TimedScope(TimeSpan duration, TimeProvider timeProvider, CancellationToken cancellationToken)
: this(duration, timeProvider, CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
{ }
private TimedScope(TimeSpan duration, TimeProvider timeProvider, CancellationTokenSource cancellationTokenSource)
{
_duration = duration;
_timeProvider = timeProvider;
_cancellationTokenSource = cancellationTokenSource;
_startingTimestamp = timeProvider.GetTimestamp();
}
/// <summary>
/// Cancels the timed scope.
/// </summary>
public void Cancel()
=> _cancellationTokenSource.Cancel();
/// <summary>
/// Cancels the timed scope asynchronously.
/// </summary>
public async Task CancelAsync()
=> await _cancellationTokenSource.CancelAsync().ConfigureAwait(false);
/// <summary>
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
/// </summary>
/// <remarks>
/// This will block using <see cref="Thread.Sleep(TimeSpan)" /> until the remaining time has elapsed, if not cancelled.
/// </remarks>
public void Dispose()
{
if (_cancellationTokenSource.IsCancellationRequested is false &&
TryGetRemaining(out TimeSpan remaining))
{
Thread.Sleep(remaining);
}
}
/// <summary>
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources asynchronously.
/// </summary>
/// <returns>
/// A task that represents the asynchronous dispose operation.
/// </returns>
/// <remarks>
/// This will delay using <see cref="Task.Delay(TimeSpan, TimeProvider, CancellationToken)" /> until the remaining time has elapsed, if not cancelled.
/// </remarks>
public async ValueTask DisposeAsync()
{
if (_cancellationTokenSource.IsCancellationRequested is false &&
TryGetRemaining(out TimeSpan remaining))
{
await Task.Delay(remaining, _timeProvider, _cancellationTokenSource.Token).ConfigureAwait(false);
}
}
private bool TryGetRemaining(out TimeSpan remaining)
{
remaining = _duration.Subtract(Elapsed);
return remaining > TimeSpan.Zero;
}
}
@@ -0,0 +1,86 @@
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models.TemporaryFile;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Tests.Common.Testing;
using Umbraco.Cms.Tests.Integration.Testing;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services;
[TestFixture]
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)]
public class TemporaryFileServiceTests : UmbracoIntegrationTest
{
private ITemporaryFileService TemporaryFileService => GetRequiredService<ITemporaryFileService>();
protected override void CustomTestSetup(IUmbracoBuilder builder) =>
builder.Services.Configure<ContentSettings>(config =>
config.AllowedUploadedFileExtensions = ["txt"]);
[Test]
public async Task Can_Create_Get_And_Delete_Temporary_File()
{
var key = Guid.NewGuid();
const string FileName = "test.txt";
const string FileContents = "test";
var model = new CreateTemporaryFileModel
{
FileName = FileName,
Key = key,
OpenReadStream = () =>
{
var stream = new MemoryStream();
var writer = new StreamWriter(stream);
writer.Write(FileContents);
writer.Flush();
stream.Position = 0;
return stream;
}
};
var createAttempt = await TemporaryFileService.CreateAsync(model);
Assert.IsTrue(createAttempt.Success);
TemporaryFileModel? fileModel = await TemporaryFileService.GetAsync(key);
Assert.IsNotNull(fileModel);
Assert.AreEqual(key, fileModel.Key);
Assert.AreEqual(FileName, fileModel.FileName);
using (var reader = new StreamReader(fileModel.OpenReadStream()))
{
string fileContents = reader.ReadToEnd();
Assert.AreEqual(FileContents, fileContents);
}
var deleteAttempt = await TemporaryFileService.DeleteAsync(key);
Assert.IsTrue(createAttempt.Success);
fileModel = await TemporaryFileService.GetAsync(key);
Assert.IsNull(fileModel);
}
[Test]
public async Task Cannot_Create_File_Outside_Of_Temporary_Files_Root()
{
var key = Guid.NewGuid();
const string FileName = "../test.txt";
var model = new CreateTemporaryFileModel
{
FileName = FileName,
Key = key,
OpenReadStream = () =>
{
var stream = new MemoryStream();
var writer = new StreamWriter(stream);
writer.Write(string.Empty);
writer.Flush();
stream.Position = 0;
return stream;
}
};
var createAttempt = await TemporaryFileService.CreateAsync(model);
Assert.IsFalse(createAttempt.Success);
Assert.AreEqual(TemporaryFileOperationStatus.InvalidFileName, createAttempt.Status);
}
}