mirror of https://github.com/Squidex/squidex.git
Browse Source
* Fix JSON. * Simplify json. * Custom OIDC server. * Fix tests * Delete team and more tests.pull/1092/head
committed by
GitHub
162 changed files with 3468 additions and 946 deletions
@ -0,0 +1,23 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Core.Teams; |
|||
|
|||
public sealed record AuthScheme |
|||
{ |
|||
public string Domain { get; init; } |
|||
|
|||
public string DisplayName { get; init; } |
|||
|
|||
public string ClientId { get; init; } |
|||
|
|||
public string ClientSecret { get; init; } |
|||
|
|||
public string Authority { get; init; } |
|||
|
|||
public string? SignoutRedirectUrl { get; init; } |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Teams.Commands; |
|||
|
|||
public sealed class DeleteTeam : TeamCommand |
|||
{ |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Core.Teams; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Teams.Commands; |
|||
|
|||
public sealed class UpsertAuth : TeamCommand |
|||
{ |
|||
public AuthScheme? Scheme { get; set; } |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Core.Teams; |
|||
|
|||
namespace Squidex.Domain.Apps.Events.Teams; |
|||
|
|||
public sealed class TeamAuthChanged : TeamEvent |
|||
{ |
|||
public AuthScheme? Scheme { get; set; } |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Events.Teams; |
|||
|
|||
public sealed class TeamDeleted : TeamEvent |
|||
{ |
|||
} |
|||
@ -0,0 +1,62 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Core.Teams; |
|||
using Squidex.Infrastructure.Reflection; |
|||
using Squidex.Infrastructure.Validation; |
|||
using Squidex.Web; |
|||
|
|||
namespace Squidex.Areas.Api.Controllers.Teams.Models; |
|||
|
|||
[OpenApiRequest] |
|||
public sealed class AuthSchemeDto |
|||
{ |
|||
/// <summary>
|
|||
/// The domain name of your user accounts.
|
|||
/// </summary>
|
|||
[LocalizedRequired] |
|||
public string Domain { get; init; } |
|||
|
|||
/// <summary>
|
|||
/// The display name for buttons.
|
|||
/// </summary>
|
|||
[LocalizedRequired] |
|||
public string DisplayName { get; init; } |
|||
|
|||
/// <summary>
|
|||
/// The client ID.
|
|||
/// </summary>
|
|||
[LocalizedRequired] |
|||
public string ClientId { get; init; } |
|||
|
|||
/// <summary>
|
|||
/// The client secret.
|
|||
/// </summary>
|
|||
[LocalizedRequired] |
|||
public string ClientSecret { get; init; } |
|||
|
|||
/// <summary>
|
|||
/// The authority URL.
|
|||
/// </summary>
|
|||
[LocalizedRequired] |
|||
public string Authority { get; init; } |
|||
|
|||
/// <summary>
|
|||
/// The URL to redirect after a signout.
|
|||
/// </summary>
|
|||
public string? SignoutRedirectUrl { get; init; } |
|||
|
|||
public AuthScheme ToDomain() |
|||
{ |
|||
return SimpleMapper.Map(this, new AuthScheme()); |
|||
} |
|||
|
|||
public static AuthSchemeDto FromDomain(AuthScheme source) |
|||
{ |
|||
return SimpleMapper.Map(source, new AuthSchemeDto()); |
|||
} |
|||
} |
|||
@ -0,0 +1,46 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Core.Teams; |
|||
using Squidex.Web; |
|||
|
|||
namespace Squidex.Areas.Api.Controllers.Teams.Models; |
|||
|
|||
public class AuthSchemeResponseDto : Resource |
|||
{ |
|||
/// <summary>
|
|||
/// The auth scheme if configured.
|
|||
/// </summary>
|
|||
public AuthSchemeDto? Scheme { get; set; } |
|||
|
|||
public static AuthSchemeResponseDto FromDomain(Team team, Resources resources) |
|||
{ |
|||
var result = new AuthSchemeResponseDto(); |
|||
|
|||
if (team.AuthScheme != null) |
|||
{ |
|||
result.Scheme = AuthSchemeDto.FromDomain(team.AuthScheme); |
|||
} |
|||
|
|||
return result.CreateLinks(resources); |
|||
} |
|||
|
|||
private AuthSchemeResponseDto CreateLinks(Resources resources) |
|||
{ |
|||
var values = new { team = resources.Team }; |
|||
|
|||
AddSelfLink(resources.Url<TeamsController>(x => nameof(x.GetTeamAuth), values)); |
|||
|
|||
if (resources.CanChangeTeamAuth) |
|||
{ |
|||
AddPutLink("update", |
|||
resources.Url<TeamsController>(x => nameof(x.PutTeamAuth), values)); |
|||
} |
|||
|
|||
return this; |
|||
} |
|||
} |
|||
@ -0,0 +1,39 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Core.Teams; |
|||
using Squidex.Domain.Apps.Entities.Teams.Commands; |
|||
|
|||
namespace Squidex.Areas.Api.Controllers.Teams.Models; |
|||
|
|||
public class AuthSchemeValueDto |
|||
{ |
|||
/// <summary>
|
|||
/// The auth scheme if configured.
|
|||
/// </summary>
|
|||
public AuthSchemeDto? Scheme { get; set; } |
|||
|
|||
public UpsertAuth ToCommand() |
|||
{ |
|||
return new UpsertAuth |
|||
{ |
|||
Scheme = Scheme?.ToDomain() |
|||
}; |
|||
} |
|||
|
|||
public static AuthSchemeValueDto FromDomain(Team source) |
|||
{ |
|||
var result = new AuthSchemeValueDto(); |
|||
|
|||
if (source.AuthScheme != null) |
|||
{ |
|||
result.Scheme = AuthSchemeDto.FromDomain(source.AuthScheme); |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
} |
|||
@ -0,0 +1,20 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Text.Encodings.Web; |
|||
using Microsoft.AspNetCore.Authentication.OpenIdConnect; |
|||
using Microsoft.Extensions.Options; |
|||
|
|||
namespace Squidex.Areas.IdentityServer.Config; |
|||
|
|||
public sealed class DynamicOpenIdConnectHandler : OpenIdConnectHandler |
|||
{ |
|||
public DynamicOpenIdConnectHandler(IOptionsMonitor<DynamicOpenIdConnectOptions> options, ILoggerFactory logger, HtmlEncoder htmlEncoder, UrlEncoder encoder) |
|||
: base(options, logger, htmlEncoder, encoder) |
|||
{ |
|||
} |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.AspNetCore.Authentication.OpenIdConnect; |
|||
|
|||
namespace Squidex.Areas.IdentityServer.Config; |
|||
|
|||
public sealed class DynamicOpenIdConnectOptions : OpenIdConnectOptions |
|||
{ |
|||
} |
|||
@ -0,0 +1,236 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.AspNetCore.Authentication; |
|||
using Microsoft.AspNetCore.Authentication.OpenIdConnect; |
|||
using Microsoft.Extensions.Caching.Distributed; |
|||
using Microsoft.Extensions.Options; |
|||
using Squidex.Config; |
|||
using Squidex.Config.Authentication; |
|||
using Squidex.Domain.Apps.Core.Teams; |
|||
using Squidex.Domain.Apps.Entities; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Json; |
|||
|
|||
#pragma warning disable RECS0082 // Parameter has the same name as a member and hides it
|
|||
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
|
|||
|
|||
namespace Squidex.Areas.IdentityServer.Config; |
|||
|
|||
public sealed class DynamicSchemeProvider : AuthenticationSchemeProvider, IOptionsMonitor<DynamicOpenIdConnectOptions> |
|||
{ |
|||
private static readonly string[] UrlPrefixes = ["signin-", "signout-callback-", "signout-"]; |
|||
|
|||
private readonly IAppProvider appProvider; |
|||
private readonly IHttpContextAccessor httpContextAccessor; |
|||
private readonly IDistributedCache dynamicCache; |
|||
private readonly IJsonSerializer jsonSerializer; |
|||
private readonly OpenIdConnectPostConfigureOptions configure; |
|||
|
|||
public DynamicOpenIdConnectOptions CurrentValue => null!; |
|||
|
|||
private sealed record SchemeResult(AuthenticationScheme Scheme, DynamicOpenIdConnectOptions Options); |
|||
|
|||
public DynamicSchemeProvider( |
|||
IAppProvider appProvider, |
|||
IHttpContextAccessor httpContextAccessor, |
|||
IDistributedCache dynamicCache, |
|||
IJsonSerializer jsonSerializer, |
|||
OpenIdConnectPostConfigureOptions configure, |
|||
IOptions<AuthenticationOptions> options) |
|||
: base(options) |
|||
{ |
|||
this.appProvider = appProvider; |
|||
this.httpContextAccessor = httpContextAccessor; |
|||
this.dynamicCache = dynamicCache; |
|||
this.jsonSerializer = jsonSerializer; |
|||
this.configure = configure; |
|||
} |
|||
|
|||
public async Task<string> AddTemporarySchemeAsync(AuthScheme scheme, |
|||
CancellationToken ct = default) |
|||
{ |
|||
var id = Guid.NewGuid().ToString(); |
|||
|
|||
var serialized = jsonSerializer.SerializeToBytes(scheme); |
|||
|
|||
var options = new DistributedCacheEntryOptions |
|||
{ |
|||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) |
|||
}; |
|||
|
|||
await dynamicCache.SetAsync(CacheKey(id), serialized, options, ct); |
|||
return id; |
|||
} |
|||
|
|||
public async Task<AuthenticationScheme?> GetSchemaByEmailAddressAsync(string email) |
|||
{ |
|||
if (string.IsNullOrWhiteSpace(email)) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var parts = email.Split('@'); |
|||
|
|||
if (parts.Length != 2) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var team = await appProvider.GetTeamByAuthDomainAsync(parts[1], default); |
|||
|
|||
if (team?.AuthScheme != null) |
|||
{ |
|||
return CreateScheme(team.Id.ToString(), team.AuthScheme).Scheme; |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
public override async Task<AuthenticationScheme?> GetSchemeAsync(string name) |
|||
{ |
|||
var result = await GetSchemeCoreAsync(name, default); |
|||
|
|||
if (result != null) |
|||
{ |
|||
return result.Scheme; |
|||
} |
|||
|
|||
return await base.GetSchemeAsync(name); |
|||
} |
|||
|
|||
public override async Task<IEnumerable<AuthenticationScheme>> GetRequestHandlerSchemesAsync() |
|||
{ |
|||
var result = (await base.GetRequestHandlerSchemesAsync()).ToList(); |
|||
|
|||
if (httpContextAccessor.HttpContext == null) |
|||
{ |
|||
return result; |
|||
} |
|||
|
|||
var path = httpContextAccessor.HttpContext.Request.Path.Value; |
|||
|
|||
if (string.IsNullOrWhiteSpace(path)) |
|||
{ |
|||
return result; |
|||
} |
|||
|
|||
var lastSegment = path.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? string.Empty; |
|||
|
|||
foreach (var prefix in UrlPrefixes) |
|||
{ |
|||
if (lastSegment.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
var name = lastSegment[prefix.Length..]; |
|||
|
|||
var scheme = await GetSchemeCoreAsync(name, httpContextAccessor.HttpContext.RequestAborted); |
|||
|
|||
if (scheme != null) |
|||
{ |
|||
result.Add(scheme.Scheme); |
|||
} |
|||
} |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
|
|||
public DynamicOpenIdConnectOptions Get(string? name) |
|||
{ |
|||
if (string.IsNullOrWhiteSpace(name)) |
|||
{ |
|||
return new DynamicOpenIdConnectOptions(); |
|||
} |
|||
|
|||
var scheme = GetSchemeCoreAsync(name, default).Result; |
|||
|
|||
return scheme?.Options ?? new DynamicOpenIdConnectOptions(); |
|||
} |
|||
|
|||
private async Task<SchemeResult?> GetSchemeCoreAsync(string name, |
|||
CancellationToken ct) |
|||
{ |
|||
if (!Guid.TryParse(name, out _)) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var cacheKey = ("DYNAMIC_SCHEME", name); |
|||
|
|||
if (httpContextAccessor.HttpContext?.Items.TryGetValue(cacheKey, out var cached) == true) |
|||
{ |
|||
return cached as SchemeResult; |
|||
} |
|||
|
|||
var scheme = |
|||
await GetSchemeByTeamAsync(name, ct) ?? |
|||
await GetSchemeByTempNameAsync(name, ct); |
|||
|
|||
var result = |
|||
scheme != null ? |
|||
CreateScheme(name, scheme) : |
|||
null; |
|||
|
|||
if (httpContextAccessor.HttpContext != null) |
|||
{ |
|||
httpContextAccessor.HttpContext.Items[cacheKey] = result; |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
|
|||
private async Task<AuthScheme?> GetSchemeByTeamAsync(string name, |
|||
CancellationToken ct) |
|||
{ |
|||
var app = await appProvider.GetTeamAsync(DomainId.Create(name), ct); |
|||
|
|||
return app?.AuthScheme; |
|||
} |
|||
|
|||
private async Task<AuthScheme?> GetSchemeByTempNameAsync(string name, |
|||
CancellationToken ct) |
|||
{ |
|||
var value = await dynamicCache.GetAsync(CacheKey(name), ct); |
|||
|
|||
return value != null ? jsonSerializer.Deserialize<AuthScheme>(new MemoryStream(value)) : null; |
|||
} |
|||
|
|||
private SchemeResult CreateScheme(string name, AuthScheme config) |
|||
{ |
|||
var scheme = new AuthenticationScheme(name, config.DisplayName, typeof(DynamicOpenIdConnectHandler)); |
|||
|
|||
var options = new DynamicOpenIdConnectOptions |
|||
{ |
|||
Events = new OidcHandler(new MyIdentityOptions |
|||
{ |
|||
OidcOnSignoutRedirectUrl = config.SignoutRedirectUrl |
|||
}), |
|||
Authority = config.Authority, |
|||
CallbackPath = new PathString($"/signin-{name}"), |
|||
ClientId = config.ClientId, |
|||
ClientSecret = config.ClientSecret, |
|||
RemoteSignOutPath = new PathString($"/signout-{name}"), |
|||
RequireHttpsMetadata = false, |
|||
ResponseType = "code", |
|||
SignedOutRedirectUri = new PathString($"/signout-callback-{name}") |
|||
}; |
|||
|
|||
configure.PostConfigure(name, options); |
|||
|
|||
return new SchemeResult(scheme, options); |
|||
} |
|||
|
|||
public IDisposable? OnChange(Action<DynamicOpenIdConnectOptions, string?> listener) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
private static string CacheKey(string id) |
|||
{ |
|||
return $"AUTH_SCHEMES_{id}"; |
|||
} |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure.Validation; |
|||
|
|||
namespace Squidex.Areas.IdentityServer.Controllers.Account; |
|||
|
|||
public sealed class LoginDynamicModel |
|||
{ |
|||
[LocalizedRequired] |
|||
public string DynamicEmail { get; set; } |
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.AspNetCore.Mvc; |
|||
using Squidex.Areas.IdentityServer.Config; |
|||
using Squidex.Domain.Apps.Core.Teams; |
|||
|
|||
namespace Squidex.Areas.IdentityServer.Controllers.Test; |
|||
|
|||
public sealed class TestController : IdentityServerController |
|||
{ |
|||
private readonly DynamicSchemeProvider schemes; |
|||
|
|||
public TestController(DynamicSchemeProvider schemes) |
|||
{ |
|||
this.schemes = schemes; |
|||
} |
|||
|
|||
[Route("test/")] |
|||
public async Task<IActionResult> Test( |
|||
[FromQuery] AuthScheme scheme) |
|||
{ |
|||
var id = await schemes.AddTemporarySchemeAsync(scheme, default); |
|||
|
|||
var challengeRedirectUrl = Url.Action(nameof(Success)); |
|||
var challengeProperties = SignInManager.ConfigureExternalAuthenticationProperties(id, challengeRedirectUrl); |
|||
|
|||
return Challenge(challengeProperties, id); |
|||
} |
|||
|
|||
[Route("test/success/")] |
|||
public IActionResult Success() |
|||
{ |
|||
return View(); |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
@model Squidex.Areas.IdentityServer.Controllers.Error.ErrorVM |
|||
|
|||
@{ |
|||
ViewBag.Title = T.Get("login.test.title"); |
|||
} |
|||
|
|||
<img class="splash-image" src="squid.svg?title=Success&text=Login%20was%20successful&face=happy" /> |
|||
|
|||
<h1 class="splash-h1">@T.Get("login.test.headline")</h1> |
|||
|
|||
<p class="splash-text"> |
|||
<span>@T.Get("login.test.text")</span> |
|||
</p> |
|||
@ -0,0 +1,58 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.AspNetCore.Mvc.ModelBinding; |
|||
using Microsoft.AspNetCore.Mvc.Rendering; |
|||
using Microsoft.AspNetCore.Mvc.ViewFeatures; |
|||
using Microsoft.AspNetCore.Razor.TagHelpers; |
|||
|
|||
namespace Squidex.Areas.IdentityServer.Views; |
|||
|
|||
[HtmlTargetElement("div", Attributes = "error-for")] |
|||
public class ValidationPageHelper : TagHelper |
|||
{ |
|||
private readonly IHtmlHelper htmlHelper; |
|||
|
|||
[HtmlAttributeName("error-for")] |
|||
public ModelExpression For { get; set; } |
|||
|
|||
[ViewContext] |
|||
[HtmlAttributeNotBound] |
|||
public ViewContext ViewContext { get; set; } |
|||
|
|||
public ValidationPageHelper(IHtmlHelper htmlHelper) |
|||
{ |
|||
this.htmlHelper = htmlHelper; |
|||
} |
|||
|
|||
public override void Process(TagHelperContext context, TagHelperOutput output) |
|||
{ |
|||
output.Attributes.Clear(); |
|||
|
|||
if (htmlHelper is IViewContextAware viewContextAware) |
|||
{ |
|||
viewContextAware.Contextualize(ViewContext); |
|||
} |
|||
|
|||
if (ViewContext.ModelState[For.Name]?.ValidationState != ModelValidationState.Invalid) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var message = htmlHelper.ValidationMessage(For.Name); |
|||
|
|||
if (message == null) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
output.Content.AppendHtml("<span class=\"errors\">"); |
|||
output.Content.AppendHtml(message); |
|||
output.Content.AppendHtml("</span>"); |
|||
output.Attributes.Add("class", "errors-container"); |
|||
} |
|||
} |
|||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue