Compare commits

...

7 Commits

Author SHA1 Message Date
8f0019592e fix(proxy): honor forwarded headers from Caddy
All checks were successful
Caddy Manager CI build / docker (push) Successful in 1m14s
Bump the application version to 1.2.2 and configure forwarded headers so
OpenAPI generates HTTPS URLs when TLS is terminated by Caddy.
2026-07-26 20:40:35 +07:00
4c271241d7 ci: publish only the web application container
All checks were successful
Caddy Manager CI build / docker (push) Successful in 2m45s
Disable container support for tests and bump the application version to
1.2.1.
2026-07-26 20:02:13 +07:00
9f7cb79bad feat(api): Add authenticated Caddy management API
All checks were successful
Caddy Manager CI build / docker (push) Successful in 4m24s
2026-07-26 18:34:02 +07:00
a94abca127 build(deps): declare Alpine runtime identifier
All checks were successful
Caddy Manager CI build / docker (push) Successful in 3m25s
2026-07-26 16:20:04 +07:00
96f0112449 ci(build): target .NET 10 Alpine with low-memory tuning
Some checks failed
Caddy Manager CI build / docker (push) Failing after 32s
2026-07-26 16:15:00 +07:00
7380680230 feat: upgrade application to .NET 10
All checks were successful
Caddy Manager CI build / docker (push) Successful in 6m37s
Preserve configuration defaults with the .NET 10 binder and update
dependencies, Docker builds, CI, and test tooling.
2026-07-26 15:55:11 +07:00
b5cbad3664 refactor(caddy): centralize reverse proxy editor dialogs
All checks were successful
Caddy Manager CI build / docker (push) Successful in 5m28s
2026-07-26 13:29:46 +07:00
26 changed files with 825 additions and 552 deletions

View File

@@ -22,15 +22,15 @@ jobs:
config-inline: | config-inline: |
[registry."${{ vars.DOCKER_GITEA_DOMAIN }}"] [registry."${{ vars.DOCKER_GITEA_DOMAIN }}"]
http = true http = true
insecure = true insecure = true
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-dotnet@v3 - uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '9' # SDK Version to use. dotnet-version: '10' # SDK Version to use.
dotnet-quality: 'ga' dotnet-quality: 'ga'
cache: true cache: true
cache-dependency-path: '**/packages.lock.json' cache-dependency-path: '**/packages.lock.json'
- name: Restore dependencies - name: Restore dependencies
run: dotnet restore --locked-mode run: dotnet restore --locked-mode
- name: Application metadata - name: Application metadata
id: metadata id: metadata
@@ -50,10 +50,3 @@ jobs:
--configuration Release --os linux --arch x64 \ --configuration Release --os linux --arch x64 \
/t:PublishContainer -p ContainerRegistry=${{ vars.DOCKER_GITEA_DOMAIN }} \ /t:PublishContainer -p ContainerRegistry=${{ vars.DOCKER_GITEA_DOMAIN }} \
-p ContainerRepository=ebolo/caddy-manager -p:ContainerImageTags='"${{ steps.metadata.outputs.APP_VERSION }};latest"' -p ContainerRepository=ebolo/caddy-manager -p:ContainerImageTags='"${{ steps.metadata.outputs.APP_VERSION }};latest"'
- name: Deploy to Komodo
uses: fjogeleit/http-request-action@v1
if: success()
with:
url: '${{ vars.WINDMILL_DOMAIN }}/komodo/pull-stack/${{ secrets.KOMODO_STACK_ID }}'
method: 'PUT'
customHeaders: '{"Auth-Key": "${{ secrets.WINDMILL_KEY }}"}'

View File

@@ -25,12 +25,14 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-dotnet@v3 - uses: actions/setup-dotnet@v3
with: with:
dotnet-version: '9' # SDK Version to use. dotnet-version: '10' # SDK Version to use.
dotnet-quality: 'ga' dotnet-quality: 'ga'
cache: true cache: true
cache-dependency-path: '**/packages.lock.json' cache-dependency-path: '**/packages.lock.json'
- name: Restore dependencies - name: Restore dependencies
run: dotnet restore --locked-mode # Scoped to the web app so the test project is never restored or built here; it pulls in
# Contracts and Services through its project references. Tests run locally, not in this job.
run: dotnet restore CaddyManager/CaddyManager.csproj --locked-mode
- name: Application metadata - name: Application metadata
id: metadata id: metadata
run: | run: |
@@ -41,7 +43,7 @@ jobs:
sed -i "s/public static readonly string CommitHash = \"\[DEVELOPMENT\]\";/public static readonly string CommitHash = \"${{ steps.metadata.outputs.COMMIT_HASH }}\";/" CaddyManager/Configurations/Application/ApplicationInfo.cs sed -i "s/public static readonly string CommitHash = \"\[DEVELOPMENT\]\";/public static readonly string CommitHash = \"${{ steps.metadata.outputs.COMMIT_HASH }}\";/" CaddyManager/Configurations/Application/ApplicationInfo.cs
- name: Publish container - name: Publish container
run: | run: |
dotnet publish \ dotnet publish CaddyManager/CaddyManager.csproj \
--configuration Release --os linux --arch x64 \ --configuration Release --os linux-musl --arch x64 \
/t:PublishContainer -p ContainerRegistry=ghcr.io \ /t:PublishContainer -p ContainerRegistry=ghcr.io \
-p ContainerRepository=${{ github.repository }} -p:ContainerImageTags='"${{ steps.metadata.outputs.APP_VERSION }};latest"' -p ContainerRepository=${{ github.repository }} -p:ContainerImageTags='"${{ steps.metadata.outputs.APP_VERSION }};latest"'

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

View File

@@ -67,6 +67,11 @@ public class CaddyService(
}; };
} }
if (request.FileName != CaddyGlobalConfigName && IsInvalidFileName(request.FileName))
{
return Failure("The configuration file name contains invalid characters");
}
var filePath = Path.Combine(Configurations.ConfigDir, var filePath = Path.Combine(Configurations.ConfigDir,
request.FileName == CaddyGlobalConfigName ? CaddyGlobalConfigName : $"{request.FileName}.caddy"); request.FileName == CaddyGlobalConfigName ? CaddyGlobalConfigName : $"{request.FileName}.caddy");
// if in the new mode, we would have to check if the file already exists // if in the new mode, we would have to check if the file already exists
@@ -198,6 +203,12 @@ public class CaddyService(
foreach (var configurationName in configurationNames) foreach (var configurationName in configurationNames)
{ {
if (configurationName != CaddyGlobalConfigName && IsInvalidFileName(configurationName))
{
failed.Add(configurationName);
continue;
}
var filePath = Path.Combine(Configurations.ConfigDir, var filePath = Path.Combine(Configurations.ConfigDir,
configurationName == CaddyGlobalConfigName ? CaddyGlobalConfigName : $"{configurationName}.caddy"); configurationName == CaddyGlobalConfigName ? CaddyGlobalConfigName : $"{configurationName}.caddy");

View File

@@ -5,19 +5,19 @@
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="NetCore.AutoRegisterDi" Version="2.2.1" /> <PackageReference Include="NetCore.AutoRegisterDi" Version="2.2.1" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.10" />
<PackageReference Include="Docker.DotNet" Version="3.125.15" /> <PackageReference Include="Docker.DotNet" Version="3.125.15" />
<PackageReference Include="Humanizer" Version="3.0.0-beta.96" /> <PackageReference Include="Humanizer" Version="3.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -19,6 +19,18 @@ public class ConfigurationsService(IConfiguration configuration) : IConfiguratio
else if (section.EndsWith("Configuration")) else if (section.EndsWith("Configuration"))
section = section[..^"Configuration".Length]; section = section[..^"Configuration".Length];
return configuration.GetSection(section).Get<T>() ?? Activator.CreateInstance<T>(); var result = configuration.GetSection(section).Get<T>();
var defaults = Activator.CreateInstance<T>();
if (result is null) return defaults;
// The .NET 10 binder writes nulls over property initialisers, so put the defaults back
foreach (var property in typeof(T).GetProperties())
{
if (property is { CanRead: true, CanWrite: true } && property.GetValue(result) is null)
property.SetValue(result, property.GetValue(defaults));
}
return result;
} }
} }

View File

@@ -1,15 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>
<!--
The test SDK makes this project an executable, so a solution wide
`dotnet publish /t:PublishContainer` containerizes it alongside the web app, pushes both
to the same repository and tag, and lets the last one to finish win. That shipped an
image whose entrypoint was the test assembly, which exits 0 without output, so the
container looked like it started and stopped for no reason.
The pipelines publish the web app project explicitly; this keeps a solution wide publish
from ever producing an image from the tests again.
-->
<EnableSdkContainerSupport>false</EnableSdkContainerSupport>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4"> <PackageReference Include="coverlet.collector" Version="10.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
@@ -21,17 +31,17 @@
</None> </None>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.2" /> <PackageReference Include="AwesomeAssertions" Version="9.5.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.10" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -41,7 +51,7 @@
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />
<Using Include="FluentAssertions" /> <Using Include="AwesomeAssertions" />
<Using Include="Moq" /> <Using Include="Moq" />
</ItemGroup> </ItemGroup>

View File

@@ -66,7 +66,7 @@ Coverage reports exclude:
## Test Frameworks and Libraries ## Test Frameworks and Libraries
- **xUnit**: Primary testing framework - **xUnit**: Primary testing framework
- **FluentAssertions**: For readable assertions - **AwesomeAssertions**: For readable assertions
- **Moq**: For mocking dependencies - **Moq**: For mocking dependencies
- **Coverlet**: For code coverage analysis - **Coverlet**: For code coverage analysis
@@ -122,7 +122,7 @@ Common test data is provided through the `TestHelper` class:
2. **Single Responsibility**: Each test should verify one specific behavior 2. **Single Responsibility**: Each test should verify one specific behavior
3. **Independence**: Tests should not depend on each other and should be able to run in any order 3. **Independence**: Tests should not depend on each other and should be able to run in any order
4. **Cleanup**: Use `IDisposable` or cleanup methods to remove temporary resources 4. **Cleanup**: Use `IDisposable` or cleanup methods to remove temporary resources
5. **Readable Assertions**: Use FluentAssertions for more readable test assertions 5. **Readable Assertions**: Use AwesomeAssertions for more readable test assertions
6. **Mock Verification**: Verify that mocked methods are called as expected when relevant 6. **Mock Verification**: Verify that mocked methods are called as expected when relevant
## Continuous Integration ## Continuous Integration

View File

@@ -436,6 +436,37 @@ public class CaddyServiceTests : IDisposable
File.Exists(filePath).Should().BeTrue(); File.Exists(filePath).Should().BeTrue();
} }
/// <summary>
/// Tests that the Caddy service refuses to save configurations whose file name escapes the configuration directory.
/// Setup: Provides save requests with traversal segments and directory separators in the file name.
/// Expectation: The service should reject the request and write nothing outside the configuration directory, so an untrusted caller such as the HTTP API cannot write arbitrary files on the host.
/// </summary>
[Theory]
[InlineData("../escape")]
[InlineData("../../etc/escape")]
[InlineData("sub/escape")]
[InlineData("sub\\escape")]
public void SaveCaddyConfiguration_WithFileNameEscapingConfigDir_ReturnsFailureAndWritesNothing(string fileName)
{
// Arrange
var request = new CaddySaveConfigurationRequest
{
FileName = fileName,
Content = TestHelper.SampleCaddyfiles.SimpleReverseProxy,
IsNew = true
};
var escapedPath = Path.GetFullPath(Path.Combine(_tempConfigDir, $"{fileName}.caddy"));
// Act
var result = _service.SaveCaddyConfiguration(request);
// Assert
result.Success.Should().BeFalse();
result.Message.Should().Be("The configuration file name contains invalid characters");
File.Exists(escapedPath).Should().BeFalse();
Directory.GetFiles(_tempConfigDir).Should().BeEmpty();
}
#endregion #endregion
#region SaveCaddyGlobalConfiguration Tests #region SaveCaddyGlobalConfiguration Tests
@@ -755,6 +786,37 @@ public class CaddyServiceTests : IDisposable
result.DeletedConfigurations.Should().BeEmpty(); result.DeletedConfigurations.Should().BeEmpty();
} }
/// <summary>
/// Tests that the Caddy service refuses to delete configurations whose file name escapes the configuration directory.
/// Setup: Creates a file outside the configuration directory and asks the service to delete it through a traversal file name.
/// Expectation: The service should report the name as failed and leave the outside file untouched, so an untrusted caller such as the HTTP API cannot delete arbitrary files on the host.
/// </summary>
[Fact]
public void DeleteCaddyConfigurations_WithFileNameEscapingConfigDir_ReportsFailureAndDeletesNothing()
{
// Arrange
var outsideDir = TestHelper.CreateTempDirectory();
try
{
var outsidePath = Path.Combine(outsideDir, "victim.caddy");
File.WriteAllText(outsidePath, "content");
var traversalName = Path.Combine("..", Path.GetFileName(outsideDir), "victim");
// Act
var result = _service.DeleteCaddyConfigurations([traversalName]);
// Assert
result.Success.Should().BeFalse();
result.Message.Should().Contain(traversalName);
result.DeletedConfigurations.Should().BeEmpty();
File.Exists(outsidePath).Should().BeTrue();
}
finally
{
TestHelper.CleanupDirectory(outsideDir);
}
}
#endregion #endregion
#region GetCaddyConfigurationInfo Tests #region GetCaddyConfigurationInfo Tests

View File

@@ -15,7 +15,7 @@
<Exclude>[*]Microsoft.*</Exclude> <Exclude>[*]Microsoft.*</Exclude>
<Exclude>[*]System.*</Exclude> <Exclude>[*]System.*</Exclude>
<Exclude>[*]Moq.*</Exclude> <Exclude>[*]Moq.*</Exclude>
<Exclude>[*]FluentAssertions.*</Exclude> <Exclude>[*]AwesomeAssertions.*</Exclude>
<Exclude>[*]xunit.*</Exclude> <Exclude>[*]xunit.*</Exclude>
<ExcludeByAttribute>Obsolete,GeneratedCodeAttribute,CompilerGeneratedAttribute</ExcludeByAttribute> <ExcludeByAttribute>Obsolete,GeneratedCodeAttribute,CompilerGeneratedAttribute</ExcludeByAttribute>
<SingleHit>false</SingleHit> <SingleHit>false</SingleHit>

View File

@@ -0,0 +1,252 @@
using System.Security.Cryptography;
using System.Text;
using CaddyManager.Contracts.Caddy;
using CaddyManager.Contracts.Docker;
using CaddyManager.Contracts.Models.Caddy;
namespace CaddyManager.Api;
/// <summary>
/// HTTP surface over the same services the Blazor UI uses, so scripts can manage reverse proxy
/// configurations and reload Caddy without a browser
/// </summary>
public static class CaddyApi
{
/// <summary>
/// Header carrying the shared API key
/// </summary>
public const string ApiKeyHeader = "X-Api-Key";
private const string ReverseProxiesTag = "Reverse proxies";
private const string CaddyTag = "Caddy";
/// <summary>
/// Maps every /api endpoint behind the shared key check
/// </summary>
public static void MapCaddyApi(this WebApplication app)
{
// Read once at startup; the key is a single value and does not warrant a configuration class
var apiKey = app.Configuration["Api:Key"];
var api = app.MapGroup("/api")
// Antiforgery only validates form content types, but the API is JSON only and should
// never start failing because a caller switched to form encoding
.DisableAntiforgery()
.AddEndpointFilter(async (context, next) =>
{
if (string.IsNullOrWhiteSpace(apiKey))
{
// Fail closed: this API writes reverse proxy configuration and can restart Caddy
return Results.Problem("API disabled: Api:Key is not configured",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
var provided = context.HttpContext.Request.Headers[ApiKeyHeader].ToString();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(provided), Encoding.UTF8.GetBytes(apiKey)))
{
return Results.Problem($"Invalid or missing {ApiKeyHeader} header",
statusCode: StatusCodes.Status401Unauthorized);
}
return await next(context);
});
MapConfigurationEndpoints(api);
MapCaddyEndpoints(api);
}
private static void MapConfigurationEndpoints(RouteGroupBuilder api)
{
var group = api.MapGroup("/configurations").WithTags(ReverseProxiesTag);
group.MapGet("", (ICaddyService caddyService) => caddyService.GetExistingCaddyConfigurations())
.WithSummary("List reverse proxy configurations")
.WithDescription("Returns every *.caddy file in the configuration directory, parsed for hostnames, upstream target, ports and tags. The global Caddyfile is not included.");
group.MapGet("/{name}", IResult (string name, ICaddyService caddyService) =>
{
if (!Exists(caddyService, name))
{
return Results.NotFound(Failure($"The configuration {name} does not exist"));
}
return Results.Ok(new ConfigurationResponse(
caddyService.GetCaddyConfigurationInfo(name),
caddyService.GetCaddyConfigurationContent(name)));
})
.WithSummary("Get a reverse proxy configuration")
.Produces<ConfigurationResponse>()
.Produces<CaddyOperationResponse>(StatusCodes.Status404NotFound);
group.MapPost("", IResult (CreateConfigurationRequest request, ICaddyService caddyService) =>
{
var response = caddyService.SaveCaddyConfiguration(new CaddySaveConfigurationRequest
{
IsNew = true,
FileName = request.FileName,
Content = request.Content,
});
return response.Success
? Results.Created($"/api/configurations/{request.FileName}", response)
: ToResult(response);
})
.WithSummary("Create a reverse proxy configuration")
.WithDescription("Fails if a configuration with the same file name already exists; use PUT to overwrite one.")
.Produces<CaddyOperationResponse>(StatusCodes.Status201Created)
.Produces<CaddyOperationResponse>(StatusCodes.Status400BadRequest)
.Produces<CaddyOperationResponse>(StatusCodes.Status409Conflict);
group.MapPut("/{name}", IResult (string name, SaveContentRequest request, ICaddyService caddyService) =>
{
if (!Exists(caddyService, name))
{
return Results.NotFound(Failure($"The configuration {name} does not exist"));
}
return ToResult(caddyService.SaveCaddyConfiguration(new CaddySaveConfigurationRequest
{
IsNew = false,
FileName = name,
Content = request.Content,
}));
})
.WithSummary("Replace the content of a reverse proxy configuration")
.Produces<CaddyOperationResponse>()
.Produces<CaddyOperationResponse>(StatusCodes.Status400BadRequest)
.Produces<CaddyOperationResponse>(StatusCodes.Status404NotFound);
group.MapPost("/{name}/rename",
IResult (string name, RenameConfigurationRequest request, ICaddyService caddyService) =>
ToResult(caddyService.RenameCaddyConfiguration(name, request.NewFileName)))
.WithSummary("Rename a reverse proxy configuration")
.WithDescription("Renames the file only. An import of the old name in the global Caddyfile is left untouched and has to be updated separately.")
.Produces<CaddyOperationResponse>()
.Produces<CaddyOperationResponse>(StatusCodes.Status400BadRequest)
.Produces<CaddyOperationResponse>(StatusCodes.Status404NotFound)
.Produces<CaddyOperationResponse>(StatusCodes.Status409Conflict);
group.MapDelete("/{name}", IResult (string name, ICaddyService caddyService) =>
{
var response = caddyService.DeleteCaddyConfigurations([name]);
return response.Success
? Results.Ok(response)
: Results.NotFound(response);
})
.WithSummary("Delete a reverse proxy configuration")
.Produces<CaddyDeleteOperationResponse>()
.Produces<CaddyDeleteOperationResponse>(StatusCodes.Status404NotFound);
}
private static void MapCaddyEndpoints(RouteGroupBuilder api)
{
// Empty prefix so the tag stays scoped to these endpoints instead of leaking onto the whole /api group
var group = api.MapGroup("").WithTags(CaddyTag);
group.MapGet("/caddyfile",
(ICaddyService caddyService) => new ContentResponse(caddyService.GetCaddyGlobalConfigurationContent()))
.WithSummary("Get the global Caddyfile")
.WithDescription("The global Caddyfile is the entry point Caddy loads; it usually imports the individual *.caddy configurations.");
group.MapPut("/caddyfile",
IResult (SaveContentRequest request, ICaddyService caddyService) =>
ToResult(caddyService.SaveCaddyGlobalConfiguration(request.Content)))
.WithSummary("Replace the global Caddyfile")
.Produces<CaddyOperationResponse>()
.Produces<CaddyOperationResponse>(StatusCodes.Status400BadRequest);
group.MapPost("/caddy/reload", async Task<IResult> (IDockerService dockerService) =>
{
var response = await dockerService.ReloadCaddyContainerAsync();
return response.Success
? Results.Ok(response)
// The reload itself failed (bad config, container down); the request was fine
: Results.Json(response, statusCode: StatusCodes.Status502BadGateway);
})
.WithSummary("Reload the Caddy configuration")
.WithDescription("Runs `caddy reload` inside the Caddy container, which applies configuration changes without dropping connections.")
.Produces<CaddyReloadResponse>()
.Produces<CaddyReloadResponse>(StatusCodes.Status502BadGateway);
group.MapPost("/caddy/restart", async Task<IResult> (IDockerService dockerService) =>
{
try
{
await dockerService.RestartCaddyContainerAsync();
return Results.Accepted();
}
catch (Exception e)
{
// Restart reports problems by throwing rather than by a response object, the way
// reload does, so it needs the same try/catch the UI puts around it
return Results.Json(Failure(e.Message), statusCode: StatusCodes.Status502BadGateway);
}
})
.WithSummary("Restart the Caddy container")
.WithDescription("Returns 202 once the restart has been requested. A missing Caddy container is not reported as an error, matching the UI behaviour.")
.Produces(StatusCodes.Status202Accepted)
.Produces<CaddyOperationResponse>(StatusCodes.Status502BadGateway);
}
/// <summary>
/// The global Caddyfile is excluded from the listing, and reading a missing file yields an empty
/// string, so the listing is the only way to tell missing from empty
/// </summary>
private static bool Exists(ICaddyService caddyService, string name) =>
caddyService.GetExistingCaddyConfigurations().Any(configuration => configuration.FileName == name);
private static CaddyOperationResponse Failure(string message) => new()
{
Success = false,
Message = message,
};
/// <summary>
/// Turns a service response into a status code. The service reports failures as messages rather
/// than typed errors, so the known ones are matched here and anything else is treated as a fault
/// </summary>
private static IResult ToResult(CaddyOperationResponse response) => response.Success
? Results.Ok(response)
: Results.Json(response, statusCode: response.Message switch
{
"The configuration file already exists" => StatusCodes.Status409Conflict,
"The configuration file to rename does not exist" => StatusCodes.Status404NotFound,
"The global Caddyfile cannot be renamed" => StatusCodes.Status400BadRequest,
var message when message.StartsWith("The configuration file name") =>
StatusCodes.Status400BadRequest,
_ => StatusCodes.Status500InternalServerError,
});
}
/// <summary>
/// A reverse proxy configuration together with its raw Caddyfile content
/// </summary>
/// <param name="Info">Parsed summary of the configuration</param>
/// <param name="Content">Raw content of the .caddy file</param>
public record ConfigurationResponse(CaddyConfigurationInfo Info, string Content);
/// <summary>
/// Raw content of a configuration file
/// </summary>
/// <param name="Content">Raw Caddyfile content</param>
public record ContentResponse(string Content);
/// <summary>
/// Request to create a new reverse proxy configuration
/// </summary>
/// <param name="FileName">File name without the .caddy extension</param>
/// <param name="Content">Raw Caddyfile content</param>
public record CreateConfigurationRequest(string FileName, string Content);
/// <summary>
/// Request to replace the content of an existing configuration
/// </summary>
/// <param name="Content">Raw Caddyfile content</param>
public record SaveContentRequest(string Content);
/// <summary>
/// Request to rename a configuration file
/// </summary>
/// <param name="NewFileName">New file name without the .caddy extension</param>
public record RenameConfigurationRequest(string NewFileName);

View File

@@ -1,17 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<LangVersion>13</LangVersion> <LangVersion>14</LangVersion>
<ContainerRepository>caddy-manager</ContainerRepository> <ContainerRepository>caddy-manager</ContainerRepository>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<!-- To avoid error on lock file not found --> <!-- To avoid error on lock file not found -->
<DisableImplicitNuGetFallbackFolder>true</DisableImplicitNuGetFallbackFolder> <DisableImplicitNuGetFallbackFolder>true</DisableImplicitNuGetFallbackFolder>
<!--
Low-traffic single-user admin UI: trade GC throughput for a much smaller heap.
Server GC (the Web SDK default) reserves one heap per CPU core.
-->
<ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
<TieredPGO>false</TieredPGO>
<!-- No localized content, so skip loading ICU entirely (alpine ships no ICU anyway) -->
<InvariantGlobalization>true</InvariantGlobalization>
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:10.0-alpine</ContainerBaseImage>
<!--
The alpine base is musl. Declared here so a plain `dotnet restore` evaluates the
same RID the container publish uses, keeping packages.lock.json consistent for
CI's locked-mode restore (otherwise NU1004).
-->
<RuntimeIdentifiers>linux-musl-x64</RuntimeIdentifiers>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<!-- No MSBuild property exists for this one; lands in runtimeconfig.json for both build paths -->
<RuntimeHostConfigurationOption Include="System.GC.ConserveMemory" Value="9" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="..\.dockerignore"> <Content Include="..\.dockerignore">
<Link>.dockerignore</Link> <Link>.dockerignore</Link>
@@ -19,11 +40,15 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BlazorMonaco" Version="3.3.0" /> <PackageReference Include="BlazorMonaco" Version="3.5.0" />
<PackageReference Include="Docker.DotNet" Version="3.125.15" /> <PackageReference Include="Docker.DotNet" Version="3.125.15" />
<PackageReference Include="Humanizer" Version="3.0.0-beta.96" /> <PackageReference Include="Humanizer" Version="3.0.10" />
<PackageReference Include="MudBlazor" Version="8.0.0" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<!-- Overrides the 2.0.0 that Microsoft.AspNetCore.OpenApi pulls in, which has advisory GHSA-v5pm-xwqc-g5wc -->
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageReference Include="MudBlazor" Version="9.7.0" />
<PackageReference Include="NetCore.AutoRegisterDi" Version="2.2.1" /> <PackageReference Include="NetCore.AutoRegisterDi" Version="2.2.1" />
<PackageReference Include="Scalar.AspNetCore" Version="2.16.16" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -21,8 +21,8 @@ public partial class MainLayout
if (firstRender) if (firstRender)
{ {
// Get the system preference for dark mode // Get the system preference for dark mode
_isDarkMode = await _mudThemeProvider.GetSystemPreference(); _isDarkMode = await _mudThemeProvider.GetSystemDarkModeAsync();
await _mudThemeProvider.WatchSystemPreference(OnSystemPreferenceChanged); await _mudThemeProvider.WatchSystemDarkModeAsync(OnSystemPreferenceChanged);
_isInitialing = false; _isInitialing = false;
StateHasChanged(); StateHasChanged();
} }

View File

@@ -35,8 +35,8 @@
@bind-SelectedValues="_selectedCaddyConfigurations"> @bind-SelectedValues="_selectedCaddyConfigurations">
@foreach (var (index, caddyConfig) in _availableCaddyConfigurations.Index()) @foreach (var (index, caddyConfig) in _availableCaddyConfigurations.Index())
{ {
<CaddyReverseProxyItem ConfigurationInfo="@caddyConfig" OnCaddyRestartRequired="@ReloadCaddy" <CaddyReverseProxyItem ConfigurationInfo="@caddyConfig"
OnCaddyfileDuplicateRequested="@HandleDuplicateRequest" /> OnEditRequested="@(fileName => ShowCaddyfileEditorDialog(fileName))" />
@if (index < _availableCaddyConfigurations.Count - 1) @if (index < _availableCaddyConfigurations.Count - 1)
{ {

View File

@@ -63,7 +63,8 @@ public partial class CaddyReverseProxiesPage : ComponentBase
/// <returns></returns> /// <returns></returns>
private async Task ShowCaddyfileEditorDialog(string fileName, string initialContent = "") private async Task ShowCaddyfileEditorDialog(string fileName, string initialContent = "")
{ {
var dialog = await DialogService.ShowAsync<CaddyfileEditorComponent>("New configuration", var dialog = await DialogService.ShowAsync<CaddyfileEditorComponent>(
string.IsNullOrWhiteSpace(fileName) ? "New configuration" : "Caddy file",
options: new DialogOptions options: new DialogOptions
{ {
FullWidth = true, FullWidth = true,
@@ -71,7 +72,8 @@ public partial class CaddyReverseProxiesPage : ComponentBase
}, parameters: new DialogParameters<CaddyfileEditorComponent> }, parameters: new DialogParameters<CaddyfileEditorComponent>
{ {
{ p => p.FileName, fileName }, { p => p.FileName, fileName },
{ p => p.InitialContent, initialContent } { p => p.InitialContent, initialContent },
{ p => p.OnDuplicate, EventCallback.Factory.Create<string>(this, HandleDuplicateRequest) }
}); });
var result = await dialog.Result; var result = await dialog.Result;
@@ -81,6 +83,7 @@ public partial class CaddyReverseProxiesPage : ComponentBase
await ReloadCaddy(); await ReloadCaddy();
} }
// Always rebuild the list, the configuration may have been renamed or had its hostnames and ports changed
Refresh(); Refresh();
} }

View File

@@ -1,7 +1,5 @@
using CaddyManager.Contracts.Caddy;
using CaddyManager.Contracts.Models.Caddy; using CaddyManager.Contracts.Models.Caddy;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace CaddyManager.Components.Pages.Caddy.CaddyReverseProxies; namespace CaddyManager.Components.Pages.Caddy.CaddyReverseProxies;
@@ -12,52 +10,18 @@ namespace CaddyManager.Components.Pages.Caddy.CaddyReverseProxies;
public partial class CaddyReverseProxyItem : ComponentBase public partial class CaddyReverseProxyItem : ComponentBase
{ {
/// <summary> /// <summary>
/// Callback to refresh the Caddy reverse proxies on the main page /// Callback asking the main page to open the editor for this configuration. The page owns the dialog so that it
/// can refresh the whole list afterwards, which matters when the configuration is renamed or deleted.
/// </summary> /// </summary>
[Parameter] [Parameter]
public EventCallback OnCaddyRestartRequired { get; set; } public EventCallback<string> OnEditRequested { get; set; }
[Parameter]
public EventCallback<string> OnCaddyfileDuplicateRequested { get; set; }
[Parameter] [Parameter]
public CaddyConfigurationInfo ConfigurationInfo { get; set; } = null!; public CaddyConfigurationInfo ConfigurationInfo { get; set; } = null!;
/// <summary>
/// Dialog service for showing the Caddy file editor dialog
/// </summary>
[Inject]
private IDialogService DialogService { get; set; } = null!;
/// <summary> /// <summary>
/// Caddy service for ops on the Caddy configuration /// Request the Caddy file editor dialog for this configuration
/// </summary>
[Inject] private ICaddyService CaddyService { get; set; } = null!;
/// <summary>
/// Show the Caddy file editor dialog
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
private async Task Edit() private Task Edit() => OnEditRequested.InvokeAsync(ConfigurationInfo.FileName);
{ }
var dialog = await DialogService.ShowAsync<CaddyfileEditor.CaddyfileEditor>("Caddy file", options: new DialogOptions
{
FullWidth = true,
MaxWidth = MaxWidth.Medium,
}, parameters: new DialogParameters<CaddyfileEditor.CaddyfileEditor>
{
{ p => p.FileName, ConfigurationInfo.FileName },
{ p => p.OnDuplicate, EventCallback.Factory.Create(this, OnCaddyfileDuplicateRequested) }
});
var result = await dialog.Result;
ConfigurationInfo = CaddyService.GetCaddyConfigurationInfo(ConfigurationInfo.FileName);
await InvokeAsync(StateHasChanged);
if (result is { Data: bool, Canceled: false } && (bool)result.Data)
{
await OnCaddyRestartRequired.InvokeAsync();
}
}
}

View File

@@ -19,6 +19,6 @@
<MudButton OnClick="Duplicate">Duplicate</MudButton> <MudButton OnClick="Duplicate">Duplicate</MudButton>
} }
<MudButton Color="Color.Primary" OnClick="Submit">Save</MudButton> <MudButton Color="Color.Primary" OnClick="Submit">Save</MudButton>
<MudButton Color="Color.Secondary" OnClick="SaveAndRestart">Save & Restart</MudButton> <MudButton Color="Color.Secondary" OnClick="SaveAndReload">Save &amp; Reload</MudButton>
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>

View File

@@ -144,13 +144,13 @@ public partial class CaddyfileEditor : ComponentBase
} }
/// <summary> /// <summary>
/// Saves the Caddy configuration file and restarts the Caddy container /// Saves the Caddy configuration file and reloads the Caddy configuration
/// </summary> /// </summary>
private async Task SaveAndRestart() private async Task SaveAndReload()
{ {
if (await Save()) if (await Save())
{ {
// Indicate successful save and that a restart is required by the calling component // Indicate successful save and that a reload is required by the calling component
MudDialog.Close(DialogResult.Ok(true)); MudDialog.Close(DialogResult.Ok(true));
} }
} }

View File

@@ -8,7 +8,7 @@ public class ApplicationInfo
/// <summary> /// <summary>
/// The version of the application, to be defined and tagged /// The version of the application, to be defined and tagged
/// </summary> /// </summary>
public static readonly string Version = "1.1.0"; public static readonly string Version = "1.2.2";
/// <summary> /// <summary>
/// The commit hash of the application /// The commit hash of the application

View File

@@ -1,23 +1,20 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base
USER $APP_UID USER $APP_UID
WORKDIR /app WORKDIR /app
EXPOSE 8080 EXPOSE 8080
EXPOSE 8081 EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY ["CaddyManager/CaddyManager.csproj", "CaddyManager/"] COPY ["CaddyManager/CaddyManager.csproj", "CaddyManager/packages.lock.json", "CaddyManager/"]
COPY ["CaddyManager.Contracts/CaddyManager.Contracts.csproj", "CaddyManager.Contracts/"]
COPY ["CaddyManager.Services/CaddyManager.Services.csproj", "CaddyManager.Services/"]
RUN dotnet restore "CaddyManager/CaddyManager.csproj" RUN dotnet restore "CaddyManager/CaddyManager.csproj"
COPY . . COPY . .
WORKDIR "/src/CaddyManager" RUN dotnet publish "CaddyManager/CaddyManager.csproj" -c $BUILD_CONFIGURATION -o /app/publish --no-restore /p:UseAppHost=false
RUN dotnet build "CaddyManager.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "CaddyManager.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final FROM base AS final
WORKDIR /app WORKDIR /app
COPY --from=publish /app/publish . COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "CaddyManager.dll"] ENTRYPOINT ["dotnet", "CaddyManager.dll"]

View File

@@ -1,12 +1,16 @@
using CaddyManager.Api;
using CaddyManager.Components; using CaddyManager.Components;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.OpenApi;
using MudBlazor.Services; using MudBlazor.Services;
using NetCore.AutoRegisterDi; using NetCore.AutoRegisterDi;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Add services to the container. // Add services to the container.
builder.Services builder.Services
.AddMudServices()
.AddRazorComponents() .AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
@@ -15,7 +19,56 @@ builder.Services.RegisterAssemblyPublicNonGenericClasses(System.Reflection.Assem
.Where(t => t.Name.EndsWith("Service")) .Where(t => t.Name.EndsWith("Service"))
.AsPublicImplementedInterfaces(); .AsPublicImplementedInterfaces();
builder.Services.AddSignalR(e => { e.MaximumReceiveMessageSize = 102400000; }); builder.Services.AddSignalR(e =>
{
// Caddyfiles are kilobytes; this is generous headroom for Monaco editor round-trips
e.MaximumReceiveMessageSize = 512 * 1024;
e.StreamBufferCapacity = 5;
});
// Keep as little per-circuit state resident as practical for a single-user admin UI
builder.Services.Configure<CircuitOptions>(o =>
{
o.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(1);
o.DisconnectedCircuitMaxRetained = 5;
o.MaxBufferedUnacknowledgedRenderBatches = 3;
});
builder.Services.AddOpenApi(options =>
{
// Declare the shared key scheme so the docs page offers an auth box and marks every endpoint as secured
options.AddDocumentTransformer((document, _, _) =>
{
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes[CaddyApi.ApiKeyHeader] = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.ApiKey,
In = ParameterLocation.Header,
Name = CaddyApi.ApiKeyHeader,
Description = "Shared key configured through Api:Key (environment variable Api__Key)",
};
document.Security ??= [];
document.Security.Add(new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference(CaddyApi.ApiKeyHeader, document)] = [],
});
return Task.CompletedTask;
});
});
// Caddy terminates TLS and forwards plain HTTP, so without this the app thinks every request is
// http and the OpenAPI document advertises http:// server URLs, which the browser blocks as mixed
// content when the docs page itself was served over https
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto |
ForwardedHeaders.XForwardedHost;
// The proxy is another container on a Docker network, so its address is not known up front;
// this app is only ever meant to be reached through that proxy
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
builder.Services.AddMudServices(config => builder.Services.AddMudServices(config =>
{ {
@@ -26,6 +79,9 @@ builder.Services.AddMudServices(config =>
var app = builder.Build(); var app = builder.Build();
// Has to run before anything that reads the scheme, host or client address
app.UseForwardedHeaders();
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
@@ -43,4 +99,8 @@ app.MapStaticAssets();
app.MapRazorComponents<App>() app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode(); .AddInteractiveServerRenderMode();
app.MapCaddyApi();
app.MapOpenApi();
app.MapScalarApiReference();
app.Run(); app.Run();

View File

@@ -6,6 +6,9 @@
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Api": {
"Key": ""
},
"CaddyService": { "CaddyService": {
"ConfigDir": "/root/compose/caddy/config" "ConfigDir": "/root/compose/caddy/config"
}, },

View File

@@ -1,16 +1,12 @@
{ {
"version": 1, "version": 1,
"dependencies": { "dependencies": {
"net9.0": { "net10.0": {
"BlazorMonaco": { "BlazorMonaco": {
"type": "Direct", "type": "Direct",
"requested": "[3.3.0, )", "requested": "[3.5.0, )",
"resolved": "3.3.0", "resolved": "3.5.0",
"contentHash": "ywtUCZMfmaNBadhQbZEGPerdptHUcyVbPB2Ug1HTNzarJlMCJFSBRvQZefS383h/vDhFgHUrR7/pE6scIc6lJg==", "contentHash": "MfEzmLlhGEUCi4O65jnDipDUf0UZz3sO9tWupp09hMSvHWcYIP7NRG6r3PIfBUz78nfEkU+aW7Jyg71gd8QcPQ=="
"dependencies": {
"Microsoft.AspNetCore.Components": "9.0.0",
"Microsoft.AspNetCore.Components.Web": "9.0.0"
}
}, },
"Docker.DotNet": { "Docker.DotNet": {
"type": "Direct", "type": "Direct",
@@ -18,694 +14,525 @@
"resolved": "3.125.15", "resolved": "3.125.15",
"contentHash": "XN8FKxVv8Mjmwu104/Hl9lM61pLY675s70gzwSj8KR5pwblo8HfWLcCuinh9kYsqujBkMH4HVRCEcRuU6al4BQ==", "contentHash": "XN8FKxVv8Mjmwu104/Hl9lM61pLY675s70gzwSj8KR5pwblo8HfWLcCuinh9kYsqujBkMH4HVRCEcRuU6al4BQ==",
"dependencies": { "dependencies": {
"Newtonsoft.Json": "13.0.1", "Newtonsoft.Json": "13.0.1"
"System.Buffers": "4.5.1",
"System.Threading.Tasks.Extensions": "4.5.4"
} }
}, },
"Humanizer": { "Humanizer": {
"type": "Direct", "type": "Direct",
"requested": "[3.0.0-beta.96, )", "requested": "[3.0.10, )",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "T1X21b+0l3jYDH1DztroJIeXdCwlNGmNYDidru9TzcLahwceQ48UGMjOQeWSa1v8zncPvhJzLzVAYWmOZcOySA==", "contentHash": "13+6RiWdpVCzSn2SSEd1hAIr/UHScNdLCUhRgjvI60UcD5u3vXu3u1/Z+74OcHWum1itjiBTjYXgJKQna1jTgw==",
"dependencies": { "dependencies": {
"Humanizer.Core.af": "3.0.0-beta.96", "Humanizer.Core.af": "3.0.10",
"Humanizer.Core.ar": "3.0.0-beta.96", "Humanizer.Core.ar": "3.0.10",
"Humanizer.Core.az": "3.0.0-beta.96", "Humanizer.Core.az": "3.0.10",
"Humanizer.Core.bg": "3.0.0-beta.96", "Humanizer.Core.bg": "3.0.10",
"Humanizer.Core.bn-BD": "3.0.0-beta.96", "Humanizer.Core.bn": "3.0.10",
"Humanizer.Core.cs": "3.0.0-beta.96", "Humanizer.Core.ca": "3.0.10",
"Humanizer.Core.da": "3.0.0-beta.96", "Humanizer.Core.cs": "3.0.10",
"Humanizer.Core.de": "3.0.0-beta.96", "Humanizer.Core.da": "3.0.10",
"Humanizer.Core.el": "3.0.0-beta.96", "Humanizer.Core.de": "3.0.10",
"Humanizer.Core.es": "3.0.0-beta.96", "Humanizer.Core.el": "3.0.10",
"Humanizer.Core.fa": "3.0.0-beta.96", "Humanizer.Core.es": "3.0.10",
"Humanizer.Core.fi-FI": "3.0.0-beta.96", "Humanizer.Core.fa": "3.0.10",
"Humanizer.Core.fr": "3.0.0-beta.96", "Humanizer.Core.fi": "3.0.10",
"Humanizer.Core.fr-BE": "3.0.0-beta.96", "Humanizer.Core.fil": "3.0.10",
"Humanizer.Core.he": "3.0.0-beta.96", "Humanizer.Core.fr": "3.0.10",
"Humanizer.Core.hr": "3.0.0-beta.96", "Humanizer.Core.he": "3.0.10",
"Humanizer.Core.hu": "3.0.0-beta.96", "Humanizer.Core.hr": "3.0.10",
"Humanizer.Core.hy": "3.0.0-beta.96", "Humanizer.Core.hu": "3.0.10",
"Humanizer.Core.id": "3.0.0-beta.96", "Humanizer.Core.hy": "3.0.10",
"Humanizer.Core.is": "3.0.0-beta.96", "Humanizer.Core.id": "3.0.10",
"Humanizer.Core.it": "3.0.0-beta.96", "Humanizer.Core.is": "3.0.10",
"Humanizer.Core.ja": "3.0.0-beta.96", "Humanizer.Core.it": "3.0.10",
"Humanizer.Core.ko-KR": "3.0.0-beta.96", "Humanizer.Core.ja": "3.0.10",
"Humanizer.Core.ku": "3.0.0-beta.96", "Humanizer.Core.ko": "3.0.10",
"Humanizer.Core.lb": "3.0.0-beta.96", "Humanizer.Core.ku": "3.0.10",
"Humanizer.Core.lt": "3.0.0-beta.96", "Humanizer.Core.lb": "3.0.10",
"Humanizer.Core.lv": "3.0.0-beta.96", "Humanizer.Core.lt": "3.0.10",
"Humanizer.Core.ms-MY": "3.0.0-beta.96", "Humanizer.Core.lv": "3.0.10",
"Humanizer.Core.mt": "3.0.0-beta.96", "Humanizer.Core.ms": "3.0.10",
"Humanizer.Core.nb": "3.0.0-beta.96", "Humanizer.Core.mt": "3.0.10",
"Humanizer.Core.nb-NO": "3.0.0-beta.96", "Humanizer.Core.nb": "3.0.10",
"Humanizer.Core.nl": "3.0.0-beta.96", "Humanizer.Core.nl": "3.0.10",
"Humanizer.Core.pl": "3.0.0-beta.96", "Humanizer.Core.pl": "3.0.10",
"Humanizer.Core.pt": "3.0.0-beta.96", "Humanizer.Core.pt": "3.0.10",
"Humanizer.Core.ro": "3.0.0-beta.96", "Humanizer.Core.pt-BR": "3.0.10",
"Humanizer.Core.ru": "3.0.0-beta.96", "Humanizer.Core.ro": "3.0.10",
"Humanizer.Core.sk": "3.0.0-beta.96", "Humanizer.Core.ru": "3.0.10",
"Humanizer.Core.sl": "3.0.0-beta.96", "Humanizer.Core.sk": "3.0.10",
"Humanizer.Core.sr": "3.0.0-beta.96", "Humanizer.Core.sl": "3.0.10",
"Humanizer.Core.sr-Latn": "3.0.0-beta.96", "Humanizer.Core.sr": "3.0.10",
"Humanizer.Core.sv": "3.0.0-beta.96", "Humanizer.Core.sr-Latn": "3.0.10",
"Humanizer.Core.th-TH": "3.0.0-beta.96", "Humanizer.Core.sv": "3.0.10",
"Humanizer.Core.tr": "3.0.0-beta.96", "Humanizer.Core.th": "3.0.10",
"Humanizer.Core.uk": "3.0.0-beta.96", "Humanizer.Core.tr": "3.0.10",
"Humanizer.Core.uz-Cyrl-UZ": "3.0.0-beta.96", "Humanizer.Core.uk": "3.0.10",
"Humanizer.Core.uz-Latn-UZ": "3.0.0-beta.96", "Humanizer.Core.uz-Cyrl-UZ": "3.0.10",
"Humanizer.Core.vi": "3.0.0-beta.96", "Humanizer.Core.uz-Latn-UZ": "3.0.10",
"Humanizer.Core.zh-CN": "3.0.0-beta.96", "Humanizer.Core.vi": "3.0.10",
"Humanizer.Core.zh-Hans": "3.0.0-beta.96", "Humanizer.Core.zh-CN": "3.0.10",
"Humanizer.Core.zh-Hant": "3.0.0-beta.96" "Humanizer.Core.zh-Hans": "3.0.10",
"Humanizer.Core.zh-Hant": "3.0.10"
} }
}, },
"Microsoft.AspNetCore.App.Internal.Assets": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "8k0KoYKvNrSqP8E3oDJMBVKavlTEE6BacdLGtEDcxv9Hz0XxWFkD6JdLJsxXUlGeAfZ6YfvWmxKenl73qeCTSA=="
},
"Microsoft.AspNetCore.OpenApi": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "d4Atx9IHq7JgX0F/h7Db+m9zAUzC+cKdI9k+OWnnyQIOUQtfvjIEuhvbjPigVMkAmPUgCbJ8Yp6M9ghUqHtJSQ==",
"dependencies": {
"Microsoft.OpenApi": "2.0.0"
}
},
"Microsoft.OpenApi": {
"type": "Direct",
"requested": "[2.11.0, )",
"resolved": "2.11.0",
"contentHash": "/ignjfdeKT2SGLIR7QEv19KnI0rvoxRG/TYDOZdK9EsWLjKK9IK8i1Mo5NRm9PRV3i64DzlTqnIflWvoyfljLg=="
},
"MudBlazor": { "MudBlazor": {
"type": "Direct", "type": "Direct",
"requested": "[8.0.0, )", "requested": "[9.7.0, )",
"resolved": "8.0.0", "resolved": "9.7.0",
"contentHash": "itY10dugofdvLYnJ1VZSzUIiKVqNvnTz4eEMYlHUdrOfsk9xShj7k1EW5H6ssMmkbQzShlLBHlUt+VPFGoEJGw==", "contentHash": "uWOgNn9B556J/TL8KFoSqpA3YD2heqOpK5etcofwlj1RiEX1IDy0NmwYE9urlTUQ8zProEK5GqwTBnoObFPN3g=="
"dependencies": {
"Microsoft.AspNetCore.Components": "9.0.1",
"Microsoft.AspNetCore.Components.Web": "9.0.1",
"Microsoft.Extensions.Localization": "9.0.1"
}
}, },
"NetCore.AutoRegisterDi": { "NetCore.AutoRegisterDi": {
"type": "Direct", "type": "Direct",
"requested": "[2.2.1, )", "requested": "[2.2.1, )",
"resolved": "2.2.1", "resolved": "2.2.1",
"contentHash": "qRda/VP+Lxak/GCGfT3PqXE6VA+bCbf2wlExcUWwnkwY1d6cfWv4Fp5RRN6dChlFhI8tbmzlNutYwxlA8kBb1A==", "contentHash": "qRda/VP+Lxak/GCGfT3PqXE6VA+bCbf2wlExcUWwnkwY1d6cfWv4Fp5RRN6dChlFhI8tbmzlNutYwxlA8kBb1A=="
"dependencies": { },
"Microsoft.Extensions.DependencyInjection": "2.1.1" "Scalar.AspNetCore": {
} "type": "Direct",
"requested": "[2.16.16, )",
"resolved": "2.16.16",
"contentHash": "Ax0e0bIh+Upf92k1+pTBUom3e/kbpu20qsrDYmmS1NM721Eq2xF8c789x6IbiNrvW8WwySRzPv/icYYhb6idSg=="
}, },
"Humanizer.Core": { "Humanizer.Core": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "anMn7QrFRWthyt08MSDFdUKoOwMqNqDpI9AJ3YIgJZJq1qmJd9sjHiKgJM/Ov0WyLEE8pARzyxOkIbXX1pBd0w==" "contentHash": "yZIhtw8sYuvsONzQbZxWpR60tMWYHXoo0DL6nyOqSFiU5POjBTSEyWFpTQtJEZuy+oqiYTXKXY/Mjx7KnqIQFw=="
}, },
"Humanizer.Core.af": { "Humanizer.Core.af": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "8/9iF3PY2Dm5M87AyU02LoUZV634znBVQsuMQu+yjxtAgGYAksL0skikU1IblDEp2Hh7I2ejOa48Jb/MjIvSEw==", "contentHash": "jIpC73aOXfhqGEnpFKgO2AJ2Esb2t40c/cKqR+RgK1yg1bcBZBuhtjs+PrAIHf5Lp7Znd4teLjCGDS5ut8O1cw==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.ar": { "Humanizer.Core.ar": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "TfwuryVUAE8GLA3ZuTh2VE8gz+15kyBpA+UjMsrgVm9b8biySFegHRNvcdb2H1w/mHYKD6eQbEq/dXGELu1C1A==", "contentHash": "LdDeLE9nmlqbl14RinjII0v8APKZKUqJEClILvdLAoUEdv8UhgsHhDX2BHJKZv2aNfCnb19z9otE8Ffaa11Sow==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.az": { "Humanizer.Core.az": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "CuBTV+VWWkFUdXK0f11KASlx3zAsx701bG/sbhvWWEfEQsXwr8fqNl4S74iNE2TWFSP8e1nPPS4Lu23qpXfwkw==", "contentHash": "+tfsgP5PkTY5mpx7t0rpMVVNXJQMcRkFSsWtnPpntuC/z7I8GS68CBcsHItw5/qbTJxWRY/cLPe/Psxh/SjEkA==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.bg": { "Humanizer.Core.bg": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "AFZc1q0s9zFmxlaBYkZLlXWIAy6uxznU424g5RuNllAjuRKIjSZ7VSF/9yRC4PasVZf1hYiqL5M9oQLXz10yqA==", "contentHash": "xBQMDuLOjmj1U0h39tv7B39vmVZXBbRUcy3xkHBKHAyuRoxskDJ3F1LUOOw1/E41+dGQziAe2I0EVukwvQ/h0g==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.bn-BD": { "Humanizer.Core.bn": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "nkgVbmYNo9G8brbR0EmVxsN1nF2eqn+GSCMNY3+Nr2VnEo6RlESd8ZYz6+Q93RpelVq2jmsmXWgnp+TLa43eQA==", "contentHash": "U5Li886U/hPdhMxyN9P3eF5dlB15sT/w4F3NOioXsaTQxnJKdxUKglRHiGbakX+aSrsFGok1MoQwVcH7Sac1Lg==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.ca": {
"type": "Transitive",
"resolved": "3.0.10",
"contentHash": "D29T+OWcYG4rlOXKwAGpNNrwLgE44oE1JYqlNuNs3lVYmTDEgItyT+SfjG2TXuTQXkpQLzsxfRjqtA/APygjgw==",
"dependencies": {
"Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.cs": { "Humanizer.Core.cs": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "ERRbgPC2/Jpj0sSoKFS28dpluDwNteHtmaH2NHq6d9xTbNSNMnINKBR+osDhpFJboJA5EsSEj4ZZwnNDMSnrUw==", "contentHash": "kaG0//ULYkyB8Rk4g5PgC2o8jrmG2joc8lgtyDzCiFCaobr0jgUn393JAIB0JUygbngEIfA0PMK3fLbMXcBNLQ==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.da": { "Humanizer.Core.da": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "cf6/r5QJ74pQZVNGCr+P0jXJ7WtP8Wqvur053Rwc+Sfvuj+W3Y7A+Bkwnz1kreGhaH5ikz/7tirnKIkdA119cQ==", "contentHash": "/MCePoHsSaRo03I1NSRut5iUzhYRIKL+UncbtpN8AZ3Vl0TOu0Psi9/6QyjD4gLLx45puiXB2S0lUjCdYmlP7w==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.de": { "Humanizer.Core.de": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "XdinGY9a+vyZ9HOgqTzH+vs0oFSe8bncw+QlbT9N5dyx9ssTnmo94DhSi9+lAK6TONrv9gFJJdLKLgi2xtvdhw==", "contentHash": "UoJk/DmAOFZivuMulLoB52Pn5iFvSqurF7qQQAyUAz1/IUZjxkoeoiI5mMILEjVeGoQJqrylt6O+f8johCo0Bg==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.el": { "Humanizer.Core.el": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "wib7nnI+RS8WapMRUOXxV7tToWwfm6mjytsZ9xIRDlMk+kN1/YgBdKU+A/gPrKsYIBPzHbCtP8PgrdUm1YsnYA==", "contentHash": "5oIHs25LMm1IBrhcxOYYLTFHZQfBaqgKxm0jAkYmsxedpDZkv1k+BHs2D5Kg1672/ZhTs4jsLbqYNXkHm671vw==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.es": { "Humanizer.Core.es": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "OBQm6ONiwNgPT1FXYHAdEwgEh/F/C3zu99v/SQVxgUYOP0MxCIjt9heizUOAjkB7iuV7mwC8Ni0TNzDt/CXplQ==", "contentHash": "epxhWWQJ/I610mJGTfQPTocjp7/xCIH6tiREVIlHb/sA1kDAD1zHQcHI+HgWFzn/LzzbVg99y8HVFFHUvjnfXA==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.fa": { "Humanizer.Core.fa": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "f1mSmnmTZhEx10qHiYzX7te6cXlF55TwJDrA1bXnIwe5/pEXBYVp9xvQD76zOEOER57HRNaCwum6ZHNkwwquHw==", "contentHash": "9BEFCpbWoUdq2JnLbkohqBfpjjdeMqFhkhCNk3qYmutZwSxuZHrCozppY14SSWI6b6qD1Veb3rVyHGz9Fex5AA==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.fi-FI": { "Humanizer.Core.fi": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "M4rtABPYIbJZI41fNbIwY18ly3uwi04pelqG4Ox5PChmDo1KFYeTI+MjqCC3J16CDtV9IYq43JYmz2aS9KkzxQ==", "contentHash": "9Nbbxs1WZH2Li6Q28tZC06Y0mXwmIGnCEO/2nRzHvTqf07153frndezl5ads4uX8mNs+hMnUGUsr03IsFhI3jQ==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.fil": {
"type": "Transitive",
"resolved": "3.0.10",
"contentHash": "zpUvc+riMHZ2QGYETwnaDiT4Y4fHv2/Ctpi8Nm5SujNmOuxQyQMEAcalbUqWaLjzfTcysKqlU/NkiNP86kGlCA==",
"dependencies": {
"Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.fr": { "Humanizer.Core.fr": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "Zf6DKTXvKUQ4751UuWdpgLvF0sHoWSdU2nYLd0NRkjxkko+GA1VCo3z6jHJKRNFYrOPXYRfl6e1RqXEEnLmhAw==", "contentHash": "k+STDkiJVeO/nA5s0/GeBdwZI17D9KgqyYA4XGxfGxtFACwbFyVKQLWLzlIR2BsrT53EkyKLAzuFJ9FCT2CyfQ==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.fr-BE": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "6JpbLjoEkYzLTvN9qedAkFW+MCyi8u3zx2DFNur64uLkO5gIKMUUbGLfFT9DJiji9iTukryGejmFGC4gqd5HnA==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
} }
}, },
"Humanizer.Core.he": { "Humanizer.Core.he": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "2EkoQaHlsPYNOvfGwioQfa5PB5tYCuRI2sbVKDd71SujUUuFmvQo7Y1cqrXkB0aRw4IXRMV4kqVw7WC54vQjIw==", "contentHash": "4WIzAg5hocnbnzaTnueYW/BRhCrQoPg/0U6WiUC4BJKGJDuVYbqIa8iVcHqhr7vVKd5ngFbhSsXuCgkYqOMaXQ==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.hr": { "Humanizer.Core.hr": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "5eKjP6mxl5F7CmD4++u2aRfPkm7Ky1jdC322lOhORXIMY2QUG7nSk6wdP3n4gZX/35GI3ZbCs2SOA67rc8QGiw==", "contentHash": "USin9T/FsHjmxvaolEXgL6FOdw+1Juh7geqcjTvJRCdoo/U4Y/usoAiZRbFtDA//1od4yqSxEbmj+F16nKwbkQ==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.hu": { "Humanizer.Core.hu": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "Hfyn9yfC4SkQ2IDV2x74oQauosvbXJdg43bzA/W3uPP3gBBKeZHsGnYdZtlWoXcsH9bbBj6PEi2PQwrHI5Euzg==", "contentHash": "Umon7VBTVDs8QFDqNzGm6MBD+43UZJoN44htW/+iJUDF2gl8K8meRup27Dkl2zvewW3ynZic/xwP2hTDaPjIjw==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.hy": { "Humanizer.Core.hy": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "noClOyB3R9YfDQ3R8rMFLXTBlb9qwz6oEnDwgOojv+gUQ6uN+39DNEB3NiTAnL9hryC4ArnCbixOjCXghjIr4Q==", "contentHash": "PzdcqQMwyDYO81PYbzBf+gFMj+wzOYF3kz3mlJCcBX5mSmNRpLIwB2s6PC9OiNN/Y/ya1orHV/z0OY9j7NFc5Q==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.id": { "Humanizer.Core.id": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "6uxBc4LeJxm2UWGjkF4BbXYeaNmy6oM8OVaaDmcMtWWanJNpxQ0q7i0UssuLKeFEOhm2KnDVmTcHqkOS3p26wA==", "contentHash": "rMb9jvglGeeUxb6Z4EkXTg+et1Ri3DWZjlXs2WFle1Qpu39zWWF0l9rh9PQJb0fzeiZ8jOjiUhUI9lRnoAHHCQ==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.is": { "Humanizer.Core.is": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "DH6j66s1l2OX0rXM22uaFDN6bwh6Ss6LBckaUWUb4t1aEzDIgW09mzZoMG+3zx/LhRibmsur86rf/jN3+2weCQ==", "contentHash": "xnNgWY9azqXyclRYmy3D6kgnbdCcPFBAEN4yNdhu2IN10p7Y2T9YUw5R9jLWo5+NGM2uzbbyTxPGf4+9YYUc1w==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.it": { "Humanizer.Core.it": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "WzxjrUGtRWrB623K8M0wW7qmAdfLroind4yEvsPTRnDFa0prFHLFZxOWF9ypBu6yLXR3T2TdT4Z3r7jYsT+bLA==", "contentHash": "iCrKxOX6q/PZgEk3vYyU1fHC0jnq0hiq7nwWXiuV7uF5VypOxwVeao3vkE6MLoNKW7RQN/TbP06n1TOqyhTA7A==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.ja": { "Humanizer.Core.ja": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "6aHU1JB1gF0azIodYsPFJ5s/5fiDoDiCg5kTI7O+SoOyJrpjKJ8umaMN6r/VwhEUPW1yHxFsEkX/XHO+mXrvAA==", "contentHash": "UicfcXwMibwWN3jLesdk702IRQVndgBUqueFr3iXVEn2POue06pHInSIruxNaPTscaNdGKxXZiIiJrN7pCVO9Q==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.ko-KR": { "Humanizer.Core.ko": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "gcYmdBIZ+oBFRTnDZ3RMx4DIOoLcaTa5XPbmefl0GdDJeG7xsFxZQHeRh5sHXNIiMchSYjkPgCgfJTuiwHumIQ==", "contentHash": "Zr/GwEilhAcmeiDxVKqr79ZK/SEpHfhKzXD15JZ1NpsfYnIfNAN/9i0o535UaadaDDT6EIVpkoGIYAGuYQ0Z4g==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.ku": { "Humanizer.Core.ku": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "4tMhhNYFOEljlp+y5C/xMVpXga5rGUiYv1gEQJC82tRMhxm/q5elVMQEGtx/iJWZSNiCgj4VZ2Ur4Sqh186gJQ==", "contentHash": "j5Qm+CY+c8onfjO1O+TReCDg/epCYdfv5N5LNB+Ydcmn06gkR4HvbWJnk5UFLVHz8NMPmtCrv7CYwmPJ0+lJqw==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.lb": { "Humanizer.Core.lb": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "QAObL6iaSztyoYnA8yZlrGraXKXGcxPufRpDbn7S3+boTXY3tkdK4ytZH61AB6wJvuN2ZVqphOMYgCnttO8DUg==", "contentHash": "5lpkw49T6MQ7Gwa0k2JV4cw6aH5FdT1Ku1nfGeey/iScajHDV9mvdxjkxRZpFAhJ5W/5yCtMlHH992LpXFJkeA==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.lt": { "Humanizer.Core.lt": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "ePE7K9tngIOLwbKf6lQjtRhTTpiRUvy/DxnHAXI282RDA9DiUB7OE4Zr6NHJeIdrJA4JGat+HosJDPwDp7vV7A==", "contentHash": "1OO4XZ/74tt5nkxYHnpj+ti1w0HdeMGi+AHR59asxcJ3A/iTDZR4RNRIvoj1MdYRe0HdTUjtnGXfA7tXXOkiYg==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.lv": { "Humanizer.Core.lv": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "HL/3ilL1doE8MublQyMFB4ro/C6M7miAfHvtOXUtc7wsbHJPc9HnbIcBhnyxh3JmYgKbThWoZ6ssfmDeV73qbg==", "contentHash": "S4RN1q/saAQxlFlRRUnmi3Oz+mIXRqWtIRZJA3VrvMmBy12cXIdpeY0+d+HCA58WXpI2diOrWxHq9KPUDPunIw==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.ms-MY": { "Humanizer.Core.ms": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "CuUek2ZvjaR3l4zSsb0lhUujTvtExLkSgP1RlQiaux0RB9Kbh88odwLTDaPhWYcA7D0h0nCkBxSZNwxhP4kWdg==", "contentHash": "m0+BJa9vG0GeuAWhmdmMxdUUa1cfGjyHZQeJoeivqxJrrtdUUFekXH0xbpvI3ErC8qu5Ufpmfnr6QAX0WQfkIw==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.mt": { "Humanizer.Core.mt": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "ss4Uxs9mjzaBw5Fhy68ttAP3MeyxHD1Rbk+bLERBMWtwQibER1fcH4JYu/EuRJ7yDe4oObOjRqu3GzNrXGLbXg==", "contentHash": "8GfXTko/iweepZHcUcOANreClfvTeCDfSuiMxLtFbUZQOBegLZ6VnTVun2gVtv5OANdOk64qhqUKIVLq2rG0rw==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.nb": { "Humanizer.Core.nb": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "o579CpW97Oj8Xp4IgTzlwieH+QOV/gRbEXUKapaQdiK/bOCOMm9N1z+wNhbDYmY/Uym6K34PG7xpbqio0Nj9QQ==", "contentHash": "UcdV9TWXG2MFVEkqHy8oD2ZbuFhFPLY7ScCJKWBu7OsgInyuHZe72hHP/i52T0FAgiVbrB+Urd3uoWiUT7hP9Q==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.nb-NO": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "epxcGNzQMeyhGNEypcb3bFYePcoARkBHWHfKttOdNKI91uuSMt7lG9/Y7l8cGGVPfvOLu6brU6KZISQ+85TyaA==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
} }
}, },
"Humanizer.Core.nl": { "Humanizer.Core.nl": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "FmFCjnc53MH5tk6sRgOB/pSjFUmCiPBO2OcdV1szqYPJ0dGNkGw3pHUfE3P3gAHYuHL/GdYyhCi74/BiyK5C2g==", "contentHash": "b+Y1CFId1ucl33OvNITID0eEVesOKiw/dmrcDJ4Z/P9KEuMTeE8aKu5/iSjwtPVV+SagaMx9XzZe7kVYyRqhuQ==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.pl": { "Humanizer.Core.pl": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "JMUCLMrAEzRR9b+4BY4+b8D+ymQIUAp74b7+zWY/ZLOnwc8Yw01sFx51hbJCgohEVXAitOAyHjzvrhUoijG32Q==", "contentHash": "LJCt3+dGUQhopQ/1w/zzBNlUHtjkdrt57zS+llMQeFm+1u0e3R+lWJx4zP+hqZcpRA7armR38KghvRSz+bYBbg==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.pt": { "Humanizer.Core.pt": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "Xb9XhdAQKG9qkbR/SPO5oYPiUtDaueTwJPRnUoBBIbdDTzpQlSUVjJ/DFez+/X8d8GsANWjof3g/5YnOWZ16+g==", "contentHash": "+cggEy6W08xD8GU45/EaMfvNQMCd7K1G7hprSPty/9zIoM3OzaajdMA/Oxrb0mHIKpARxCNbQ29ltOtip3MTNA==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.pt-BR": {
"type": "Transitive",
"resolved": "3.0.10",
"contentHash": "kbhwn4G+STHUT9cLDdrP60+ZFla4lTg6def+FTnhpMS4JHf8ll0TL1kz9Nj5DTJFdqnvs8/eYfmVhnEegyBjBw==",
"dependencies": {
"Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.ro": { "Humanizer.Core.ro": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "SSaZzeQmrbmDh2/CHSUF3jbU2V0ilcVHPa32/i7g0TXeywzP35CIcZ7vfKlJcfLSnempMg1bdm703hfeUdoLTw==", "contentHash": "JwdY+SywNIDrFcWKyiZsgsSJkIleoAXAtCLYiLn+CNCk+135VwhchZHm5B9nNvpLjGVaP5Y0ouZQ8xTx1oeZag==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.ru": { "Humanizer.Core.ru": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "+uTlQsIs4blTP6f0mnQ7lnn8wyOLmZFMWHqqTh3+Ul960FLlo0UnxYScV1sgNLHudv/04IiLsuj3bGxWJKW0FQ==", "contentHash": "1vXr7pkV0zSZF6ENJUadsAagubrBAvTPHPgdgttHlMhUQ1Zz9ZWTJaQdmf2RCen4YywMmfsQQZiGqJl52LVT8Q==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.sk": { "Humanizer.Core.sk": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "dcSN1GYFUNTQFlLljGZLarLe1e258wtNK+k0Mj2sghN+ffvlK1aRhUvanmqAaURfkK5ume1Q4e0A4/ATMNNFsw==", "contentHash": "LD93nJw1zwXuC+LSw3+02Jk02bdAoENHzb/qrMoGBf70XeV2rBeoxSyJBVLHQ00RZfwNSy6TCTXQyagESVmN+Q==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.sl": { "Humanizer.Core.sl": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "H05Tl8RQs8RoJ+UWYPga087x4Be57EpRk6iYwo4absTlFyPeISv8whUqayJBvDpNGS94QHennrnfembrCnhJXg==", "contentHash": "utURJ8K/tnbA5KXlKcT8T+35MEVdZrXUvu2tH+T6M9xxJ1MhLGuFWnc687pcsLdpaLnGB7ZVwrAITlueAE+KGA==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.sr": { "Humanizer.Core.sr": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "FLH10qbsgL5NJJ/moB1iSnmO1wkVDloJYwq1CwIKp0PePb4bViO0z4nTXKCERWn3zl3WXeCJ5gOUyrvplr6bSQ==", "contentHash": "e2bVzUAjEpxjFdLVmXZL9EsdVojlV/8GMwZzwDS6OByfG+PP5JxemKylK6FoUK5X2IBPNg68LOvn72WgwaruIQ==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.sr-Latn": { "Humanizer.Core.sr-Latn": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "aTIigE/P5ahO5RO88igcz5V8Fo0MP8+Cu/NFVFXQJv+Yw39dQ1fOscLpVXenOg22sy2Tz2pi/JNfK42SDEiV6Q==", "contentHash": "VXPlm+eg5sraDhZ50WGiMZMvj4I2d+0VAy8cNaLLXNt+EgkUz646dRfg5HrQPPjtBbegj6o7gcg8Xl6z0ke+hA==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.sv": { "Humanizer.Core.sv": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "k3URfcuKrOfL64Eg5mE2uV8YotKnhObFsJaIiyGhRPyl2QrrPU6VXKM2DmLa6Cfa1RN3zHYLv8PzBo1g6jVBYA==", "contentHash": "COq4bbUKk7F1jfE2sC/ZmomoQqX0Herwe1iXZyDl5zyw21mUY9wL96S1qbRmRp15UmGzFJdqBPdnd58P7kVMyg==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.th-TH": { "Humanizer.Core.th": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "9vaBFcdcXvE4uXr4VLbhPQ/Hv7lKf4Cb1HzThrnWklTk6uzZkmYYRs/yat+apk7of0VAlY8rthsMiNIwwSWW+A==", "contentHash": "8A4lZCLE6bEPYdQKeGfPBHDgAsY30TWWdjoWA1+vAfLA9Ip1l2Ons+Ngw4npkcoI3qdCidU6hWOsFnPfJ8ZufQ==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.tr": { "Humanizer.Core.tr": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "d5xUVe/1NrmAeBTL3KamGtZJ/3ja7O8OOWV/ICmNeOEHPMg5wZTWnQ3DOOrJAdrnf5D6kNcngKxYwKZYoRS19g==", "contentHash": "aRAxq1VyuIVoFdx8TZyVsRoDk0yqe9NATjhGgWHPsdR8TVHnuiyXeEbiXf846870VteyLJL9tVy3jaj2rC45tw==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.uk": { "Humanizer.Core.uk": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "3i9xEIoqoWUtNKaihYqr9EfQw7ilWiDIaDwrXDrADldO1HHFAcc+F2vNEB1sZ81/F/1FBCthyODTFDBD1Ma4Kg==", "contentHash": "NPnGZ1jdoMDYGrsHt5aLlj7Ambp7i5ER/5BbtjBSegn3+PliMbd4Eftr1Ind4qvCU6rTP2a9lUqBoRd+hSVA+g==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.uz-Cyrl-UZ": { "Humanizer.Core.uz-Cyrl-UZ": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "LZK6cDgU0fQ0zURLYwk4qlodc0ZGcXkxV6zfHJbHXBsow12cJ2p7NhL495ihWLnDrHfD20+J1y9rTvC9vIWnFA==", "contentHash": "8NSyuiTlCfse1ki9QeO35IpZLcxoMlSMGvEY892aw9CNGaDMROQjFzf1iKJkWTyB9atrHHPuWlasBAFk2QkT9w==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.uz-Latn-UZ": { "Humanizer.Core.uz-Latn-UZ": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "AmoGiYjAjoi7htH6Vimd266rGRfyU0Jsa9BjALP7EAsWILjAzba3aMmChK33A6apFzC9fv2XT0amZsgIqBvR/Q==", "contentHash": "FaSETkwfUiDU/RHlD54xbBKEZXnFIUnTNTnA5ZYXgDOiMmX6cY7vyRDmI1YpQtO+QR1irSettpLFpHeysslnpA==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.vi": { "Humanizer.Core.vi": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "PZnFdIJF54ODyMazZDSe30NuRuVx4/c/3KBXzwFX3tR+yz+gJkYlYpTI8ozRLF2/mlcFr9iFAU4Pau6jJFevFw==", "contentHash": "Uz5gph//0eNP0Ta1tqdKDyghiUnZdW0aVW4MK/poD5hLEH1qLoDTWuTb8gxj3F8GvSFjgVmF9HZGQx5cO06N8Q==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.zh-CN": { "Humanizer.Core.zh-CN": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "GX8JvHC9/ghN8ZU/MadCnRrLB6kJXQEuJz3i0hS03VVrTZlHMRQfXEC3NhSktSJ9FCyYq+WZaw7jhmSaQ/S2pA==", "contentHash": "7veG4JdZ4OeYNlgci/6I/Xf9G/iCljBcwsbLVyGaqdia+2UMAp1iGu+MCs3zKOmEvcb4ipHGFkwT5d/2WfEevA==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.zh-Hans": { "Humanizer.Core.zh-Hans": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "S0nzvET+5M188jPN2cUiMguIszKZQ6tVyvAem3xBjvF0LjodAlAmZNlMjw5zaPMACuWMkOgvpKjuA8dhFa2Gqg==", "contentHash": "z8E2R0QL43NB6m8ntQaL1cONMjudoIJXvMQUFXG4OaKp5iyYqWs6FMC2B7x2rLxOynpsxoq5AQ/8BO0pMdfZrw==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Humanizer.Core.zh-Hant": { "Humanizer.Core.zh-Hant": {
"type": "Transitive", "type": "Transitive",
"resolved": "3.0.0-beta.96", "resolved": "3.0.10",
"contentHash": "nzWjsEb7JLHtBOuWuKBI1b0rm31C4Q8DSJHZy6UPhjO1HVra7GcMVeNyxXsljaVtdjR5kjLaPuhrr0yDNEUtYA==", "contentHash": "KI5Nv82v9diPBxcBAwIAvZQHjSrDy7He0atnzdZghS+QchHwlkPKhTyEzVSbOZdD42RxCX1ArMiL864A2iw34Q==",
"dependencies": { "dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]" "Humanizer.Core": "[3.0.10]"
} }
}, },
"Microsoft.AspNetCore.Authorization": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "WgLlLBlMczb2+QLNG6sM95OUZ0EBztz60k/N75tjIgpyu0SdpIfYytAmX/7JJAjRTZF0c/CrWaQV+SH9FuGsrA==",
"dependencies": {
"Microsoft.AspNetCore.Metadata": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Microsoft.Extensions.Options": "9.0.1"
}
},
"Microsoft.AspNetCore.Components": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "6pwfbQKNtvPkbF4tCGiAKGyt6BVpu58xAXz7u2YXcUKTNmNxrymbG1mEyMc0EPzVdnquDDqTyfXM3mC1EJycxQ==",
"dependencies": {
"Microsoft.AspNetCore.Authorization": "9.0.1",
"Microsoft.AspNetCore.Components.Analyzers": "9.0.1"
}
},
"Microsoft.AspNetCore.Components.Analyzers": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "I8Rs4LXT5UQxM5Nin2+Oj8aSY2heszSZ3EyTLgt3mxmfiRPrVO7D8NNSsf1voI2Gb0qFJceof/J5c9E+nfNuHw=="
},
"Microsoft.AspNetCore.Components.Forms": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "KyULVU32bLz74LWDwPEwNUEllTehzWJuM7YAsz80rMKEzvR0K8cRjRzO0fnN/nfydMeLRRlbI0xj8wnEAymLVw==",
"dependencies": {
"Microsoft.AspNetCore.Components": "9.0.1"
}
},
"Microsoft.AspNetCore.Components.Web": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "LI0vjYEd9MaDZPDQxPCn4gGYDkEC5U9rp1nWZo7rPozJxgTG2zU3WERujxTi2LeAC2ZzdXlOVCrUyPQ55LZV2A==",
"dependencies": {
"Microsoft.AspNetCore.Components": "9.0.1",
"Microsoft.AspNetCore.Components.Forms": "9.0.1",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.Primitives": "9.0.1",
"Microsoft.JSInterop": "9.0.1"
}
},
"Microsoft.AspNetCore.Metadata": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "EZnHifamF7IFEIyjAKMtJM3I/94OIe72i3P09v5oL0twmsmfQwal6Ni3m8lbB5mge3jWFhMozeW+rUdRSqnXRQ=="
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "YIMO9T3JL8MeEXgVozKt2v79hquo/EFtnY0vgxmLnUvk1Rei/halI7kOWZL2RBeV9FMGzgM9LZA8CVaNwFMaNA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
"Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "lqvd7W3FGKUO1+ZoUEMaZ5XDJeWvjpy2/M/ptCGz3tXLD4HWVaSzjufsAsjemasBEg+2SxXVtYVvGt5r2nKDlg==",
"dependencies": {
"Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "RiScL99DcyngY9zJA2ROrri7Br8tn5N4hP4YNvGdTN/bvg1A3dwvDOxHnNZ3Im7x2SJ5i4LkX1uPiR/MfSFBLQ==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0"
}
},
"Microsoft.Extensions.Configuration.EnvironmentVariables": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "v5R638eNMxksfXb7MFnkPwLPp+Ym4W/SIGNuoe8qFVVyvygQD5DdLusybmYSJEr9zc1UzWzim/ATKeIOVvOFDg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "9.0.0",
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0"
}
},
"Microsoft.Extensions.Configuration.FileExtensions": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "4EK93Jcd2lQG4GY6PAw8jGss0ZzFP0vPc1J85mES5fKNuDTqgFXHba9onBw2s18fs3I4vdo2AWyfD1mPAxWSQQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "9.0.0",
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
"Microsoft.Extensions.FileProviders.Abstractions": "9.0.0",
"Microsoft.Extensions.FileProviders.Physical": "9.0.0",
"Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Microsoft.Extensions.Configuration.Json": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "WiTK0LrnsqmedrbzwL7f4ZUo+/wByqy2eKab39I380i2rd8ImfCRMrtkqJVGDmfqlkP/YzhckVOwPc5MPrSNpg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "9.0.0",
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
"Microsoft.Extensions.Configuration.FileExtensions": "9.0.0",
"Microsoft.Extensions.FileProviders.Abstractions": "9.0.0"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "qZI42ASAe3hr2zMSA6UjM92pO1LeDq5DcwkgSowXXPY8I56M76pEKrnmsKKbxagAf39AJxkH2DY4sb72ixyOrg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "Tr74eP0oQ3AyC24ch17N8PuEkrPbD0JqIfENCYqmgKYNOmL8wQKzLJu3ObxTUDrjnn4rHoR1qKa37/eQyHmCDA=="
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "uK439QzYR0q2emLVtYzwyK3x+T5bTY4yWsd/k/ZUS9LR6Sflp8MIdhGXW8kQCd86dQD4tLqvcbLkku8qHY263Q==",
"dependencies": {
"Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Microsoft.Extensions.FileProviders.Physical": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "3+ZUSpOSmie+o8NnLIRqCxSh65XL/ExU7JYnFOg58awDRlY3lVpZ9A369jkoZL1rpsq7LDhEfkn2ghhGaY1y5Q==",
"dependencies": {
"Microsoft.Extensions.FileProviders.Abstractions": "9.0.0",
"Microsoft.Extensions.FileSystemGlobbing": "9.0.0",
"Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Microsoft.Extensions.FileSystemGlobbing": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "jGFKZiXs2HNseK3NK/rfwHNNovER71jSj4BD1a/649ml9+h6oEtYd0GSALZDNW8jZ2Rh+oAeadOa6sagYW1F2A=="
},
"Microsoft.Extensions.Localization": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "UgvX4Yb2T3tEsKT30ktZr0H7kTRPapCgEH0bdTwxiEGSdA39/hAQMvvb+vgHpqmevDU5+puyI9ujRkmmbF946w==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Localization.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Microsoft.Extensions.Options": "9.0.1"
}
},
"Microsoft.Extensions.Localization.Abstractions": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "CABog43lyaZQMjmlktuImCy6zmAzRBaXqN81uPaMQjlp//ISDVYItZPh6KWpWRF4MY/B67X5oDc3JTUpfdocZw=="
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "w2gUqXN/jNIuvqYwX3lbXagsizVNXYyt6LlF57+tMve4JYCEgCMMAjRce6uKcDASJgpMbErRT1PfHy2OhbkqEA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "nggoNKnWcsBIAaOWHA+53XZWrslC7aGeok+aR+epDPRy7HI7GwMnGZE8yEsL2Onw7kMOHVHwKcsDls1INkNUJQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Primitives": "9.0.1"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g=="
},
"Microsoft.JSInterop": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "/xBwIfb0YoC2Muv6EsHjxpqZw2aKv94+i0g0FWZvqvGv3DeAy+8wipAuECVvKYEs2EIclRD41bjajHLoD6mTtw=="
},
"Newtonsoft.Json": { "Newtonsoft.Json": {
"type": "Transitive", "type": "Transitive",
"resolved": "13.0.1", "resolved": "13.0.1",
"contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A=="
}, },
"System.Buffers": {
"type": "Transitive",
"resolved": "4.5.1",
"contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg=="
},
"System.Threading.Tasks.Extensions": {
"type": "Transitive",
"resolved": "4.5.4",
"contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg=="
},
"caddymanager.contracts": { "caddymanager.contracts": {
"type": "Project" "type": "Project"
}, },
@@ -714,14 +541,11 @@
"dependencies": { "dependencies": {
"CaddyManager.Contracts": "[1.0.0, )", "CaddyManager.Contracts": "[1.0.0, )",
"Docker.DotNet": "[3.125.15, )", "Docker.DotNet": "[3.125.15, )",
"Humanizer": "[3.0.0-beta.96, )", "Humanizer": "[3.0.10, )",
"Microsoft.Extensions.Configuration": "[9.0.0, )",
"Microsoft.Extensions.Configuration.Binder": "[9.0.0, )",
"Microsoft.Extensions.Configuration.EnvironmentVariables": "[9.0.0, )",
"Microsoft.Extensions.Configuration.Json": "[9.0.0, )",
"NetCore.AutoRegisterDi": "[2.2.1, )" "NetCore.AutoRegisterDi": "[2.2.1, )"
} }
} }
} },
"net10.0/linux-musl-x64": {}
} }
} }

View File

@@ -161,8 +161,14 @@ services:
# Path to the Caddyfile as seen from inside the caddy container. Must match the container side of the # Path to the Caddyfile as seen from inside the caddy container. Must match the container side of the
# caddy container's config volume. Defaults to /etc/caddy/Caddyfile, so this line is optional above. # caddy container's config volume. Defaults to /etc/caddy/Caddyfile, so this line is optional above.
DockerService__CaddyConfigPathInContainer: "/etc/caddy/Caddyfile" DockerService__CaddyConfigPathInContainer: "/etc/caddy/Caddyfile"
# Shared key for the HTTP API (see "HTTP API" below). Leave it out to keep the API closed.
Api__Key: "change-me"
# To have the access to the caddy config file # To have the access to the caddy config file
user: "1000:1000" user: "1000:1000"
# The .NET GC sizes its heap against the cgroup limit, so this both caps the worst
# case and makes the runtime self-tune downward. Raise it if you run many configs.
mem_limit: 256m
memswap_limit: 256m
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
@@ -186,6 +192,49 @@ Currently, the Caddy Manager is able to:
configuration first, so a broken file is reported back instead of taking the proxy down) configuration first, so a broken file is reported back instead of taking the proxy down)
- Restart caddy container on demand - Restart caddy container on demand
- Parse simple information from the caddy configurations - Parse simple information from the caddy configurations
- Do all of the above over HTTP, for scripts and other services (see below)
### HTTP API
The same operations are exposed as a JSON API, documented with OpenAPI:
- Interactive documentation: `/scalar`
- OpenAPI document: `/openapi/v1.json`
Every request needs the shared key in the `X-Api-Key` header. The key comes from `Api:Key`
(environment variable `Api__Key`). **While no key is configured the API is disabled and every
endpoint answers `503`** — nothing is exposed by accident.
| Method | Endpoint | Description |
| --- | --- | --- |
| `GET` | `/api/configurations` | List the reverse proxy configurations |
| `GET` | `/api/configurations/{name}` | Get one configuration with its raw content |
| `POST` | `/api/configurations` | Create a configuration (`{ "fileName": "...", "content": "..." }`) |
| `PUT` | `/api/configurations/{name}` | Replace a configuration's content (`{ "content": "..." }`) |
| `POST` | `/api/configurations/{name}/rename` | Rename a configuration (`{ "newFileName": "..." }`) |
| `DELETE` | `/api/configurations/{name}` | Delete a configuration |
| `GET` | `/api/caddyfile` | Get the global Caddyfile |
| `PUT` | `/api/caddyfile` | Replace the global Caddyfile (`{ "content": "..." }`) |
| `POST` | `/api/caddy/reload` | Graceful `caddy reload` |
| `POST` | `/api/caddy/restart` | Restart the Caddy container |
`{name}` is the file name without the `.caddy` extension, as shown in the UI.
```shell
curl -H "X-Api-Key: change-me" http://localhost:8080/api/configurations
curl -X POST http://localhost:8080/api/configurations \
-H "X-Api-Key: change-me" -H "Content-Type: application/json" \
-d '{"fileName":"example","content":"example.com {\n\treverse_proxy 10.0.0.2:8080\n}"}'
curl -X POST -H "X-Api-Key: change-me" http://localhost:8080/api/caddy/reload
```
Renaming moves the file only; if the global Caddyfile imports the old name, update that import
yourself (the UI warns about this too).
> Note: the app redirects HTTP to HTTPS, so a direct `curl http://...` against the container port
> gets a `307`. Add `-L`, call it over HTTPS, or go through your reverse proxy.
<p align="right">(<a href="#readme-top">back to top</a>)</p> <p align="right">(<a href="#readme-top">back to top</a>)</p>

View File

@@ -18,7 +18,13 @@
ASPNETCORE_ENVIRONMENT: "Production" ASPNETCORE_ENVIRONMENT: "Production"
CaddyService__ConfigDir: "/config" CaddyService__ConfigDir: "/config"
DockerService__CaddyContainerName: "caddy" DockerService__CaddyContainerName: "caddy"
# Shared key for the HTTP API. While unset the /api endpoints return 503 and stay closed.
# Api__Key: "change-me"
user: "1000:1000" user: "1000:1000"
# The .NET GC sizes its heap against the cgroup limit, so this both caps the
# worst case and makes the runtime self-tune downward.
mem_limit: 256m
memswap_limit: 256m
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:

View File

@@ -18,5 +18,5 @@ exec docker run --rm \
--volume "$repo_root":/src \ --volume "$repo_root":/src \
--workdir /src \ --workdir /src \
--volume caddymanager-nuget:/root/.nuget/packages \ --volume caddymanager-nuget:/root/.nuget/packages \
mcr.microsoft.com/dotnet/sdk:9.0 \ mcr.microsoft.com/dotnet/sdk:10.0 \
dotnet "$@" dotnet "$@"