Browse Source

Merge branch 'dev' into issue-24918-bg-workers

pull/25066/head
maliming 2 weeks ago
parent
commit
f2e8af629f
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 1
      .gitignore
  2. 18
      Directory.Packages.props
  3. 152
      docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/POST.md
  4. 91
      docs/en/Community-Articles/2026-03-10-Tutorial-Validator/article.md
  5. 185
      docs/en/Community-Articles/2026-03-12-OpenIddict-private-key-jwt/POST.md
  6. BIN
      docs/en/Community-Articles/2026-03-12-OpenIddict-private-key-jwt/cover.png
  7. BIN
      docs/en/Community-Articles/2026-03-12-OpenIddict-private-key-jwt/create-edit-ui.png
  8. 151
      docs/en/Community-Articles/2026-03-17-OpenAI-Compatible-Endpoints/POST.md
  9. BIN
      docs/en/Community-Articles/2026-03-17-OpenAI-Compatible-Endpoints/cover-image.png
  10. BIN
      docs/en/Community-Articles/2026-03-17-OpenAI-Compatible-Endpoints/openai-compatible-endpoints-demo.gif
  11. 167
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/POST.md
  12. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/cover.png
  13. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/exist-user-accept.png
  14. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/invite-admin-user-to-join-tenant-modal.png
  15. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/invite-admin-user-to-join-tenant.png
  16. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/invite-user.png
  17. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/new-user-accept.png
  18. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/new-user-join-strategy-create-tenant.png
  19. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/new-user-join-strategy-inform.png
  20. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/switch-tenant.png
  21. BIN
      docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/tenant-selection.png
  22. 94
      docs/en/cli/index.md
  23. 8
      docs/en/docs-nav.json
  24. 4
      docs/en/framework/api-development/auto-controllers.md
  25. 4
      docs/en/framework/infrastructure/background-jobs/tickerq.md
  26. 8
      docs/en/framework/infrastructure/background-workers/tickerq.md
  27. 113
      docs/en/framework/infrastructure/entity-cache.md
  28. 20
      docs/en/framework/ui/maui/index.md
  29. BIN
      docs/en/images/adb-command-prompt.png
  30. 79
      docs/en/modules/ai-management/index.md
  31. 289
      docs/en/modules/operation-rate-limiting.md
  32. 14
      docs/en/package-version-changes.md
  33. 2
      framework/Volo.Abp.slnx
  34. 5
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/AbpBlazorClientHttpMessageHandler.cs
  35. 11
      framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Form/AbpInputTagHelperService.cs
  36. 59
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Validation/AbpValidationActionFilter.cs
  37. 4
      framework/src/Volo.Abp.BackgroundJobs.RabbitMQ/Volo/Abp/BackgroundJobs/RabbitMQ/JobQueue.cs
  38. 18
      framework/src/Volo.Abp.BackgroundJobs.TickerQ/Volo/Abp/BackgroundJobs/TickerQ/AbpBackgroundJobsTickerQModule.cs
  39. 2
      framework/src/Volo.Abp.BackgroundJobs.TickerQ/Volo/Abp/BackgroundJobs/TickerQ/AbpBackgroundJobsTimeTickerConfiguration.cs
  40. 2
      framework/src/Volo.Abp.BackgroundWorkers.TickerQ/Volo/Abp/BackgroundWorkers/TickerQ/AbpBackgroundWorkersCronTickerConfiguration.cs
  41. 4
      framework/src/Volo.Abp.BackgroundWorkers.TickerQ/Volo/Abp/BackgroundWorkers/TickerQ/AbpTickerQBackgroundWorkerManager.cs
  42. 1
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/AbpCliCoreModule.cs
  43. 173
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/GenerateJwksCommand.cs
  44. 88
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheBase.cs
  45. 30
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheServiceCollectionExtensions.cs
  46. 10
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapper.cs
  47. 1
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapperContext.cs
  48. 1
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithoutCacheItem.cs
  49. 38
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/IEntityCache.cs
  50. 3
      framework/src/Volo.Abp.OperationRateLimiting/FodyWeavers.xml
  51. 32
      framework/src/Volo.Abp.OperationRateLimiting/Volo.Abp.OperationRateLimiting.csproj
  52. 14
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs
  53. 42
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingModule.cs
  54. 20
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs
  55. 8
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingResource.cs
  56. 14
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/IOperationRateLimitingChecker.cs
  57. 277
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs
  58. 38
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingCheckerExtensions.cs
  59. 33
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingContext.cs
  60. 24
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingResult.cs
  61. 20
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingRuleResult.cs
  62. 48
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Exceptions/AbpOperationRateLimitingException.cs
  63. 68
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/DefaultOperationRateLimitingFormatter.cs
  64. 8
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/IOperationRateLimitingFormatter.cs
  65. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ar.json
  66. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/cs.json
  67. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/de.json
  68. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/el.json
  69. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en-GB.json
  70. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en.json
  71. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/es.json
  72. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fa.json
  73. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fi.json
  74. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fr.json
  75. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hi.json
  76. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hr.json
  77. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hu.json
  78. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/is.json
  79. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/it.json
  80. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/nl.json
  81. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pl-PL.json
  82. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pt-BR.json
  83. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ro-RO.json
  84. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ru.json
  85. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sk.json
  86. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sl.json
  87. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sv.json
  88. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/tr.json
  89. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/vi.json
  90. 18
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/zh-Hans.json
  91. 34
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/DefaultOperationRateLimitingPolicyProvider.cs
  92. 11
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/IOperationRateLimitingPolicyProvider.cs
  93. 12
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPartitionType.cs
  94. 15
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicy.cs
  95. 102
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicyBuilder.cs
  96. 157
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleBuilder.cs
  97. 17
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleDefinition.cs
  98. 147
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs
  99. 12
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/IOperationRateLimitingRule.cs
  100. 155
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/DistributedCacheOperationRateLimitingStore.cs

1
.gitignore

@ -270,6 +270,7 @@ modules/blogging/app/Volo.BloggingTestApp/Logs/*.*
modules/blogging/app/Volo.BloggingTestApp/wwwroot/files/*.*
modules/docs/app/VoloDocs.Web/Logs/*.*
modules/setting-management/app/Volo.Abp.SettingManagement.DemoApp/Logs/*.*
modules/openiddict/app/OpenIddict.Demo.Server/wwwroot/libs/**
templates/module/app/MyCompanyName.MyProjectName.DemoApp/Logs/*.*
templates/module/aspnet-core/host/MyCompanyName.MyProjectName.Blazor.Server.Host/Logs/logs.txt
templates/mvc/src/MyCompanyName.MyProjectName.Web/Logs/*.*

18
Directory.Packages.props

@ -117,12 +117,12 @@
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
<PackageVersion Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.14.0" />
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.14.0" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
<PackageVersion Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.16.0" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.16.0" />
<PackageVersion Include="Minio" Version="6.0.5" />
<PackageVersion Include="MongoDB.Driver" Version="3.7.0" />
<PackageVersion Include="MongoDB.Driver" Version="3.7.1" />
<PackageVersion Include="NEST" Version="7.17.5" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="Nito.AsyncEx.Context" Version="5.1.2" />
@ -183,10 +183,10 @@
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<PackageVersion Include="TencentCloudSDK.Sms" Version="3.0.1273" />
<PackageVersion Include="TimeZoneConverter" Version="7.2.0" />
<PackageVersion Include="TickerQ" Version="10.1.1" />
<PackageVersion Include="TickerQ.Dashboard" Version="10.1.1" />
<PackageVersion Include="TickerQ.Utilities" Version="10.1.1" />
<PackageVersion Include="TickerQ.EntityFrameworkCore" Version="10.1.1" />
<PackageVersion Include="TickerQ" Version="10.2.0" />
<PackageVersion Include="TickerQ.Dashboard" Version="10.2.0" />
<PackageVersion Include="TickerQ.Utilities" Version="10.2.0" />
<PackageVersion Include="TickerQ.EntityFrameworkCore" Version="10.2.0" />
<PackageVersion Include="Unidecode.NET" Version="2.1.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.extensibility.execution" Version="2.9.3" />

152
docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/POST.md

@ -1,4 +1,4 @@
# Operation Rate Limiting in ABP Framework
# Operation Rate Limiting in ABP
Almost every user-facing system eventually runs into the same problem: **some operations cannot be allowed to run without limits**.
@ -24,15 +24,9 @@ Real-world requirements tend to look like this:
The pattern is clear: the identity being throttled is a **business identity** — a user, a phone number, a resource ID — not an IP address. And the action being throttled is a **business operation**, not an HTTP request.
ABP Framework's **Operation Rate Limiting** module is built for exactly this. It lets you enforce limits directly in your application or domain layer, with full awareness of who is doing what.
ABP's **Operation Rate Limiting** module is built for exactly this. It lets you enforce limits directly in your application or domain layer, with full awareness of who is doing what.
Add the package to your project:
```bash
abp add-package Volo.Abp.OperationRateLimiting
```
> Operation Rate Limiting is available starting from **ABP Framework 10.3**. See the [pull request](https://github.com/abpframework/abp/pull/25024) for details.
This module is used by the Account (Pro) modules internally and comes pre-installed in the latest startup templates. You must have an [ABP Team or a higher license](https://abp.io/pricing) to use this module.
## Defining a Policy
@ -66,7 +60,7 @@ public class SmsAppService : ApplicationService
_rateLimitChecker = rateLimitChecker;
}
public async Task SendCodeAsync(string phoneNumber)
public virtual async Task SendCodeAsync(string phoneNumber)
{
await _rateLimitChecker.CheckAsync("SendSmsCode", phoneNumber);
@ -77,6 +71,74 @@ public class SmsAppService : ApplicationService
`CheckAsync` checks the current usage against the limit and throws `AbpOperationRateLimitingException` (HTTP 429) if the limit is already exceeded. If the check passes, it then increments the counter and proceeds. ABP's exception pipeline catches this automatically and returns a standard error response. Put `CheckAsync` first — the rate limit check is the gate, and everything else only runs if it passes.
## Declarative Usage with `[OperationRateLimiting]`
The explicit `CheckAsync` approach is useful when you need fine-grained control — for example, when you want to check the limit conditionally, or when the parameter value comes from somewhere other than a method argument. But for the common case where you simply want to enforce a policy on every invocation of a specific method, there's a cleaner way: the `[OperationRateLimiting]` attribute.
```csharp
public class SmsAppService : ApplicationService
{
[OperationRateLimiting("SendSmsCode")]
public virtual async Task SendCodeAsync([RateLimitingParameter] string phoneNumber)
{
// Rate limit is enforced automatically — no manual CheckAsync needed.
await _smsSender.SendAsync(phoneNumber, GenerateCode());
}
}
```
The attribute works on both **Application Service methods** (via ABP's interceptor) and **MVC Controller actions** (via an action filter). No manual injection of `IOperationRateLimitingChecker` required.
### Providing the Partition Key
When using the attribute, the partition key is resolved from the method's parameters automatically:
- Mark a parameter with `[RateLimitingParameter]` to use its `ToString()` value as the key — this is the most common case when the key is a single primitive like a phone number or email.
- Have your input DTO implement `IHasOperationRateLimitingParameter` and provide a `GetPartitionParameter()` method — useful when the key is a property buried inside a complex input object.
```csharp
public class SendSmsCodeInput : IHasOperationRateLimitingParameter
{
public string PhoneNumber { get; set; }
public string Language { get; set; }
public string? GetPartitionParameter() => PhoneNumber;
}
[OperationRateLimiting("SendSmsCode")]
public virtual async Task SendCodeAsync(SendSmsCodeInput input)
{
// input.GetPartitionParameter() = input.PhoneNumber is used as the partition key.
}
```
If neither is provided, `Parameter` is `null` — which is perfectly valid for policies that use `PartitionByCurrentUser`, `PartitionByClientIp`, or similar partition types that don't rely on an explicit value.
```csharp
// Policy uses PartitionByCurrentUser — no partition key needed.
[OperationRateLimiting("GenerateReport")]
public virtual async Task<ReportDto> GenerateMonthlyReportAsync()
{
// Rate limit is checked per current user, automatically.
}
```
> The resolution order is: `[RateLimitingParameter]` first, then `IHasOperationRateLimitingParameter`, then `null`. If the method has parameters but none is resolved, a warning is logged to help you catch the misconfiguration early.
You can also place `[OperationRateLimiting]` on the class itself to apply the policy to all public methods:
```csharp
[OperationRateLimiting("MyServiceLimit")]
public class MyAppService : ApplicationService
{
public virtual async Task MethodAAsync([RateLimitingParameter] string key) { ... }
public virtual async Task MethodBAsync([RateLimitingParameter] string key) { ... }
}
```
A method-level attribute always takes precedence over the class-level one.
## Choosing a Partition Type
The partition type controls **how counters are isolated from each other** — it's the most important decision when setting up a policy, because it determines *what dimension you're counting across*.
@ -87,7 +149,7 @@ Getting this wrong can make your rate limiting completely ineffective. Using `Pa
- **`PartitionByCurrentUser`** — uses the authenticated user's ID, with no value to pass. Perfect for "each user gets N per day" scenarios where user identity is all you need.
- **`PartitionByClientIp`** — uses the client's IP address. Don't rely on this alone — it's too easy to rotate. Use it as a secondary layer alongside another partition type, as in the login example below.
- **`PartitionByEmail`** and **`PartitionByPhoneNumber`** — designed for pre-authentication flows where the user isn't logged in yet. They prefer the `Parameter` value you explicitly pass, and fall back to the current user's email or phone number if none is provided.
- **`PartitionBy`** — a custom async delegate that can produce any partition key you need. When the built-in options don't fit, you're free to implement whatever logic makes sense: look up a resource's owner in the database, derive a key from the user's subscription tier, partition by tenant — anything that returns a string.
- **`PartitionBy`** — a named custom resolver that can produce any partition key you need. Register a resolver function under a unique name via `options.AddPartitionKeyResolver("MyResolver", ctx => ...)`, then reference it by name: `.PartitionBy("MyResolver")`. You can also register and reference in one step: `.PartitionBy("MyResolver", ctx => ...)`. When the built-in options don't fit, you're free to implement whatever logic makes sense: look up a resource's owner in the database, derive a key from the user's subscription tier, partition by tenant — anything that returns a string. Because the resolver is stored by name (not as an anonymous delegate), it can be serialized and managed from a UI or database.
> The rule of thumb: partition by the identity of whoever's behavior you're trying to limit.
@ -114,6 +176,70 @@ The two counters are completely independent. If `alice` fails 5 times, her accou
When multiple rules are present, the module uses a two-phase approach: it checks all rules first, and only increments counters if every rule passes. This prevents a rule from consuming quota on a request that would have been rejected by another rule anyway.
## Customizing Policies from Reusable Modules
ABP modules (including your own) can ship with built-in rate limiting policies. For example, an Account module might define a `"Account.SendPasswordResetCode"` policy with conservative defaults that make sense for most applications. When you need different rules in your specific application, you have two options.
**Complete replacement with `AddPolicy`:** call `AddPolicy` with the same name and the second registration wins, replacing all rules from the module:
```csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.AddPolicy("Account.SendPasswordResetCode", policy =>
{
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 3)
.PartitionByEmail());
});
});
```
**Partial modification with `ConfigurePolicy`:** when you only want to tweak part of a policy — change the error code, add a secondary rule, or tighten the window — use `ConfigurePolicy`. The builder starts pre-populated with the module's existing rules, so you only express what changes.
For example, keep the module's default rules but assign your own localized error code:
```csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.ConfigurePolicy("Account.SendPasswordResetCode", policy =>
{
policy.WithErrorCode("MyApp:PasswordResetLimit");
});
});
```
Or add a secondary IP-based rule on top of what the module already defined, without touching it:
```csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.ConfigurePolicy("Account.SendPasswordResetCode", policy =>
{
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20)
.PartitionByClientIp());
});
});
```
If you want a clean slate, call `ClearRules()` first and then define entirely new rules — this gives you the same result as `AddPolicy` but makes the intent explicit:
```csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.ConfigurePolicy("Account.SendPasswordResetCode", policy =>
{
policy.ClearRules()
.WithFixedWindow(TimeSpan.FromMinutes(10), maxCount: 5)
.PartitionByEmail();
});
});
```
`ConfigurePolicy` throws if the policy name doesn't exist — which catches typos at startup rather than silently doing nothing.
The general rule: use `AddPolicy` for full replacements, `ConfigurePolicy` for surgical modifications.
## Beyond Just Checking
Not every scenario calls for throwing an exception. `IOperationRateLimitingChecker` provides three additional methods for more nuanced control.
@ -179,10 +305,10 @@ public override void ConfigureServices(ServiceConfigurationContext context)
## Summary
ABP's Operation Rate Limiting fills the gap that ASP.NET Core's HTTP middleware can't: rate limiting with real awareness of *who* is doing *what*. Define a named policy, pick a time window, a max count, and a partition type. Call `CheckAsync` wherever you need it. Counter storage, distributed locking, and exception handling are all taken care of.
ABP's Operation Rate Limiting fills the gap that ASP.NET Core's HTTP middleware can't: rate limiting with real awareness of *who* is doing *what*. Define a named policy, pick a time window, a max count, and a partition type. Then either call `CheckAsync` explicitly, or just add `[OperationRateLimiting]` to your method and let the framework handle the rest. Counter storage, distributed locking, and exception handling are all taken care of.
## References
- [Operation Rate Limiting](https://abp.io/docs/latest/framework/infrastructure/operation-rate-limiting)
- [Operation Rate Limiting (Pro)](https://abp.io/docs/latest/modules/operation-rate-limiting)
- [ASP.NET Core Rate Limiting Middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit)
- [Exception Handling](https://abp.io/docs/latest/framework/fundamentals/exception-handling)

91
docs/en/Community-Articles/2026-03-10-Tutorial-Validator/article.md

@ -1,49 +1,56 @@
# How We Built TutorialValidator to Automatically Validate Documentation Tutorials
# Automatically Validate Your Documentation: How We Built a Tutorial Validator
Writing a tutorial is hard. Keeping it correct over time is even harder.
Writing a tutorial is difficult. Keeping technical documentation accurate over time is even harder.
If you maintain developer documentation, you probably know the problem: a tutorial that worked a few months ago can silently break after a framework update, dependency change, or a small missing line in a code snippet.
New developers follow the guide, encounter an error, and quickly lose trust in the documentation.
To solve this problem, we built the tutorial validator — an open-source AI-powered tutorial validator that automatically verifies whether a software tutorial actually works from start to finish.
Instead of manually reviewing documentation, the tutorial validator behaves like a real developer following your guide step by step.
It reads instructions, runs commands, writes files, executes the application, and verifies expected results.
We initially created it to automatically validate ABP Framework tutorials, then released it as an open-source tool so anyone can use it to test their own documentation.
If you maintain technical documentation, you probably know this pain: a tutorial that worked three months ago can quietly break after a framework update, a package change, or a small missing line in a code snippet. New developers follow the steps, hit an error, and lose trust in the docs.
That exact problem is why we built **TutorialValidator**.
![the tutorial validator Orchestrator](docs/images/image.png)
TutorialValidator is an open-source, AI-powered tool that checks whether a software tutorial actually works from start to finish. You give it a tutorial URL, and it behaves like a real developer following the guide: it reads each step, executes commands, writes files, runs the app, and verifies expected results.
We first created it to validate ABP Framework tutorials internally, then shared it publicly so anyone can use it with their own tutorials.
## The Problem: Broken Tutorials in Technical Documentation
![TutorialValidator Orchestrator](docs/images/image.png)
Many documentation issues are difficult to catch during normal reviews.
Common problems include:
## What Problem Does It Solve?
- A command assumes a file already exists
Most documentation issues are not obvious during review:
- A code snippet misses a namespace or import
- A command assumes a file that has not been created yet
- A code sample misses a namespace or import
- A step relies on hidden context that is never explained
- An endpoint is expected to respond, but does not
- A tutorial step relies on hidden context
Traditional proofreading catches wording problems. TutorialValidator targets **execution problems**.
- An endpoint is expected to respond but fails
It turns tutorials into something testable.
- A dependency version changed and breaks the project
## How It Works (Simple View)
TutorialValidator runs in three phases:
Traditional proofreading tools only check grammar or wording.
**The tutorial validator focuses on execution correctness.**
It treats tutorials like testable workflows, ensuring that every step works exactly as written.
## How the Tutorial Validator Works?
the tutorial validator validates tutorials using a three-stage pipeline:
1. **Analyst**: Scrapes tutorial pages and converts instructions into a structured test plan
2. **Executor**: Follows the plan step by step in a clean environment
3. **Reporter**: Produces a clear result summary and optional notifications
![TutorialValidator Analyst](docs/images/image-1.png)
![the tutorial validator Analyst](docs/images/image-1.png)
It identifies commands, code edits, HTTP requests, and expected outcomes.
The key idea is simple: if a developer would need to do it, the validator does it too.
That includes running terminal commands, editing files, checking HTTP responses, and validating build outcomes.
![TutorialValidator Executor](docs/images/image-2.png)
![the tutorial validator Executor](docs/images/image-2.png)
## Why This Approach Is Useful
## Why Automated Tutorial Validation Matters?
TutorialValidator is designed for practical documentation quality, not just technical experimentation.
The tutorial validator is designed for practical documentation quality, not just technical experimentation.
- **Catches real-world breakages early** before readers report them
- **Creates repeatable validation** instead of one-off manual checks
@ -54,7 +61,7 @@ For example, `junior` and `mid` personas are great for spotting unclear document
## Built for ABP, Open for Everyone
Even though TutorialValidator was born from ABP documentation needs, it is not limited to ABP content.
Although TutorialValidator was originally built to validate **ABP Framework tutorials**, it works with **any publicly accessible software tutorial**.
It supports validating any publicly accessible software tutorial and can run in:
@ -63,28 +70,44 @@ It supports validating any publicly accessible software tutorial and can run in:
It also supports multiple AI providers, including OpenAI, Azure OpenAI, and OpenAI-compatible endpoints.
## Open Source and Extensible
## Open Source and Easily Extensible
The tutorial validator is designed with a modular architecture.
The project consists of multiple focused components:
- **Core** – shared models and contracts
- **Analyst** – tutorial scraping and step extraction
- **Executor** – step-by-step execution engine
- **Orchestrator** – workflow coordination
- **Reporter** – notifications and result summaries
TutorialValidator is structured as multiple focused projects:
This architecture makes it easy to extend the validator with:
- Core models and shared contracts
- Analyst for scraping and plan extraction
- Executor for step-by-step validation
- Orchestrator for end-to-end workflow
- Reporter for Email/Discord notifications
- new step types
- additional AI providers
- custom reporting integrations
This architecture keeps the project easy to understand and extend. Teams can add new step types, plugins, or reporting channels based on their own workflow.
## Final Thoughts
Documentation is part of the product experience. When tutorials fail, trust fails.
Documentation is a critical part of the product experience.
When tutorials break, developer trust breaks too.
TutorialValidator helps teams move from:
> We believe this tutorial works 🙄
to
TutorialValidator helps teams move from “we think this tutorial works” to “we verified it works.”
> We verified this tutorial works ✅
If your team maintains **technical tutorials, developer guides, or framework documentation**, automated tutorial validation can provide a powerful safety net.
Documentation is part of the product experience. When tutorials fail, trust fails.
If your team maintains technical tutorials, this project can give you a practical safety net and a repeatable quality process.
---
Repository: https://github.com/AbpFramework/TutorialValidator
You can find the source-code of the tutorial validator at this repo 👉 https://github.com/abpframework/tutorial-validator
If you try it in your own docs pipeline, we would love to hear your feedback and ideas.
We would love to hear your feedback, ideas and waiting PRs to improve this application.

185
docs/en/Community-Articles/2026-03-12-OpenIddict-private-key-jwt/POST.md

@ -0,0 +1,185 @@
# Secure Client Authentication with private_key_jwt in ABP 10.3
If you've built a confidential client with ABP's OpenIddict module, you know the drill: create an application in the management UI, set a `client_id`, generate a `client_secret`, and paste that secret into your client's `appsettings.json` or environment variables. It works. It's familiar. And for a lot of projects, it's perfectly fine.
But `client_secret` is a **shared secret** — and shared secrets carry an uncomfortable truth: the same value exists in two places at once. The authorization server stores a hash of it in the database, and your client stores the raw value in configuration. That means two potential leak points. Worse, the secret has no inherent identity. Anyone who obtains the string can impersonate your client and the server has no way to tell the difference.
For many teams, this tradeoff is acceptable. But certain scenarios make it hard to ignore:
- **Microservice-to-microservice calls**: A backend mesh of a dozen services, each with its own `client_secret` scattered across deployment configs and CI/CD pipelines. Rotating them across environments without missing one becomes a coordination problem.
- **Multi-tenant SaaS platforms**: Every tenant's client application deserves truly isolated credentials. With shared secrets, the database holds hashed copies for all tenants — a breach of that table is a breach of everyone's credentials.
- **Financial-grade API (FAPI) compliance**: Standards like [FAPI 2.0](https://openid.net/specs/fapi-2_0-security-profile.html) explicitly require asymmetric client authentication. `client_secret` doesn't make the cut.
- **Zero-trust architectures**: In a zero-trust model, identity must be cryptographically provable, not based on a string that can be copied and pasted.
The underlying problem is that a shared secret is just a password. It can be stolen, replicated, and used without leaving a trace. The fix has existed in cryptography for decades: **asymmetric keys**.
With asymmetric key authentication, the client generates a key pair. The public key is registered with the authorization server. The private key never leaves the client. Each time the client needs a token, it signs a short-lived JWT — called a _client assertion_ — with the private key. The server verifies the signature using the registered public key. There is no secret on the server side that could be used to forge a request, because the private key is never transmitted or stored remotely.
This is exactly what the **`private_key_jwt`** client authentication method, defined in [OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication), provides. ABP's OpenIddict module now supports it end-to-end: you register a **JSON Web Key Set (JWKS)** containing your public key through the application management UI (ABP Commercial), and your client authenticates using the corresponding private key. The key generation tooling (`abp generate-jwks`) ships as part of the open-source ABP CLI.
> This feature is available starting from **ABP Framework 10.3**.
## How It Works
The flow is straightforward:
1. The client holds an RSA key pair — **private key** (kept locally) and **public key** (registered on the authorization server as a JWKS).
2. On each token request, the client uses the private key to sign a JWT with a short expiry and a unique `jti` claim.
3. The authorization server verifies the signature against the registered public key and issues a token if it checks out.
The private key never leaves the client. Even if someone obtains the authorization server's database, there's nothing there that can be used to generate a valid client assertion.
## Generating a Key Pair
ABP CLI includes a `generate-jwks` command that creates an RSA key pair in the right formats:
```bash
abp generate-jwks
```
This produces two files in the current directory:
- `jwks.json` — the public key in JWKS format, to be uploaded to the server
- `jwks-private.pem` — the private key in PKCS#8 PEM format, to be kept on the client
You can customize the output directory, key size, and signing algorithm:
```bash
abp generate-jwks --alg RS512 --key-size 4096 -o ./keys -f myapp
```
> Supported algorithms: `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`. The default is `RS256` with a 2048-bit key.
The command also prints the contents of `jwks.json` to the console so you can copy it directly.
## Registering the JWKS in the Management UI
Open **OpenIddict → Applications** in the ABP admin panel and create or edit a confidential application (Client Type: `Confidential`).
In the **Client authentication method** section, you'll find the new **JSON Web Key Set** field.
![](./create-edit-ui.png)
Paste the contents of `jwks.json` into the **JSON Web Key Set** field:
```json
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "6444...",
"alg": "RS256",
"n": "tx...",
"e": "AQAB"
}
]
}
```
Save the application. It's now configured for `private_key_jwt` authentication. You can set either `client_secret` or a JWKS, or both — ABP enforces that a confidential application always has at least one credential.
## Requesting a Token with the Private Key
On the client side, each token request requires building a _client assertion_ JWT signed with the private key. Here's a complete `client_credentials` example:
```csharp
// Discover the authorization server endpoints (including the issuer URI).
var client = new HttpClient();
var configuration = await client.GetDiscoveryDocumentAsync("https://your-auth-server/");
// Load the private key generated by `abp generate-jwks`.
using var rsaKey = RSA.Create();
rsaKey.ImportFromPem(await File.ReadAllTextAsync("jwks-private.pem"));
// Read the kid from jwks.json so it stays in sync with the server-registered public key.
string? signingKid = null;
if (File.Exists("jwks.json"))
{
using var jwksDoc = JsonDocument.Parse(await File.ReadAllTextAsync("jwks.json"));
if (jwksDoc.RootElement.TryGetProperty("keys", out var keysElem) &&
keysElem.GetArrayLength() > 0 &&
keysElem[0].TryGetProperty("kid", out var kidElem))
{
signingKid = kidElem.GetString();
}
}
var signingKey = new RsaSecurityKey(rsaKey) { KeyId = signingKid };
var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256);
// Build the client assertion JWT.
var now = DateTime.UtcNow;
var jwtHandler = new JsonWebTokenHandler();
var clientAssertionToken = jwtHandler.CreateToken(new SecurityTokenDescriptor
{
// OpenIddict requires typ = "client-authentication+jwt" for client assertion JWTs.
TokenType = "client-authentication+jwt",
Issuer = "MyClientId",
// aud must equal the authorization server's issuer URI from the discovery document,
// not the token endpoint URL.
Audience = configuration.Issuer,
Subject = new ClaimsIdentity(new[]
{
new Claim(JwtRegisteredClaimNames.Sub, "MyClientId"),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}),
IssuedAt = now,
NotBefore = now,
Expires = now.AddMinutes(5),
SigningCredentials = signingCredentials,
});
// Request a token using the client_credentials flow.
var tokenResponse = await client.RequestClientCredentialsTokenAsync(
new ClientCredentialsTokenRequest
{
Address = configuration.TokenEndpoint,
ClientId = "MyClientId",
ClientCredentialStyle = ClientCredentialStyle.PostBody,
ClientAssertion = new ClientAssertion
{
Type = OidcConstants.ClientAssertionTypes.JwtBearer,
Value = clientAssertionToken,
},
Scope = "MyAPI",
});
```
A few things worth paying attention to:
- **`TokenType`** must be `"client-authentication+jwt"`. OpenIddict rejects client assertion JWTs that don't carry this header.
- **`Audience`** must match the authorization server's issuer URI exactly — use `configuration.Issuer` from the discovery document, not the token endpoint URL.
- **`Jti`** must be unique per request to prevent replay attacks.
- Keep **`Expires`** short (five minutes or less). A client assertion is a one-time proof of identity, not a long-lived credential.
This example uses [IdentityModel](https://github.com/IdentityModel/IdentityModel) for the token request helpers and [Microsoft.IdentityModel.JsonWebTokens](https://www.nuget.org/packages/Microsoft.IdentityModel.JsonWebTokens) for JWT creation.
## Key Rotation Without Downtime
One of the practical advantages of JWKS is that it can hold multiple public keys simultaneously. This makes **zero-downtime key rotation** straightforward:
1. Run `abp generate-jwks` to produce a new key pair.
2. Append the new public key to the `keys` array in your existing `jwks.json` and update the JWKS in the management UI.
3. Switch the client to sign assertions with the new private key.
4. Once the transition is complete, remove the old public key from the JWKS.
During the transition window, both the old and new public keys are registered on the server, so any in-flight requests signed with either key will still validate correctly.
## Summary
To use `private_key_jwt` authentication in an ABP Pro application:
1. Run `abp generate-jwks` to generate an RSA key pair.
2. Paste the `jwks.json` contents into the **JSON Web Key Set** field in the OpenIddict application management UI.
3. On the client side, sign a short-lived _client assertion_ JWT with the private key — making sure to set the correct `typ`, `aud` (from the discovery document), and a unique `jti` — then use it to request a token.
ABP handles public key storage and validation automatically. OpenIddict handles the signature verification on the token endpoint. As a developer, you only need to keep the private key file secure — there's no shared secret to synchronize between client and server.
## References
- [OpenID Connect Core — Client Authentication](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication)
- [RFC 7523 — JWT Profile for Client Authentication](https://datatracker.ietf.org/doc/html/rfc7523)
- [ABP OpenIddict Module Documentation](https://abp.io/docs/latest/modules/openiddict)
- [ABP CLI Documentation](https://abp.io/docs/latest/cli)
- [OpenIddict Documentation](https://documentation.openiddict.com/)

BIN
docs/en/Community-Articles/2026-03-12-OpenIddict-private-key-jwt/cover.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
docs/en/Community-Articles/2026-03-12-OpenIddict-private-key-jwt/create-edit-ui.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

151
docs/en/Community-Articles/2026-03-17-OpenAI-Compatible-Endpoints/POST.md

@ -0,0 +1,151 @@
# One Endpoint, Many AI Clients: Turning ABP Workspaces into OpenAI-Compatible Models
ABP's AI Management module already makes it easy to define and manage AI workspaces (provider, model, API key/base URL, system prompt, permissions, MCP tools, RAG settings, and more). With **ABP v10.2**, there is a major addition: you can now expose those workspaces through **OpenAI-compatible endpoints** under `/v1`.
That changes the integration story in a practical way. Instead of wiring every external tool directly to a provider, you can point those tools to ABP and keep runtime decisions centralized in one place.
In this post, we will walk through a practical setup with **AnythingLLM** and show why this pattern is useful in real projects.
Before we get into the details, here's a quick look at the full flow in action:
## See It in Action: AnythingLLM + ABP
The demo below shows the full flow: connecting an OpenAI-compatible client to ABP, selecting a workspace-backed model, and sending a successful chat request through `/v1`.
![ABP AI Management OpenAI-compatible endpoints demo](./openai-compatible-endpoints-demo.gif)
## Why This Is a Big Deal
Many teams end up with AI configuration spread across multiple clients and services. Updating providers, rotating keys, or changing model behavior can become operationally messy.
With ABP in front of your AI traffic:
- Clients keep speaking the familiar OpenAI contract.
- ABP resolves the requested `model` to a workspace.
- The workspace decides which provider/model settings are actually used.
This gives you a clean split: standardized client integration outside, governed AI configuration inside.
## Key Concept: Workspace = Model
OpenAI-compatible clients send a `model` value.
In ABP AI Management, that `model` maps to a **workspace name**.
**For example:**
- Workspace name: `SupportAgent`
- Client request model: `SupportAgent`
When the client calls `/v1/chat/completions` with `"model": "SupportAgent"`, ABP routes the request to that workspace and applies that workspace's provider (OpenAI, Ollama etc.) and model configuration.
This is the main mental model to keep in mind while integrating any OpenAI-compatible tool with ABP.
## Endpoints Exposed by ABP v10.2
The AI Management module exposes OpenAI-compatible REST endpoints at `/v1`.
| Endpoint | Method | Description |
| ---------------------------- | ------ | ---------------------------------------------- |
| `/v1/chat/completions` | POST | Chat completions (streaming and non-streaming) |
| `/v1/completions` | POST | Legacy text completions |
| `/v1/models` | GET | List available models (workspaces) |
| `/v1/models/{modelId}` | GET | Get a single model (workspace) |
| `/v1/embeddings` | POST | Generate embeddings |
| `/v1/files` | GET | List files |
| `/v1/files` | POST | Upload a file |
| `/v1/files/{fileId}` | GET | Get file metadata |
| `/v1/files/{fileId}` | DELETE | Delete a file |
| `/v1/files/{fileId}/content` | GET | Download file content |
All endpoints require `Authorization: Bearer <token>`.
## Quick Setup with AnythingLLM
Before configuration, ensure:
1. AI Management is installed and running in your ABP app.
2. At least one workspace is created and **active**.
3. You have a valid Bearer token for your ABP application.
### 1) Get an access token
Use any valid token accepted by your app. In a demo-style setup, token retrieval can look like this:
```bash
curl -X POST http://localhost:44337/connect/token \
-d "grant_type=password&username=admin&password=1q2w3E*&client_id=DemoApp_API&client_secret=1q2w3e*&scope=DemoApp"
```
Use the returned `access_token` as the API key value in your OpenAI-compatible client.
### 2) Configure AnythingLLM as Generic OpenAI
In **AnythingLLM -> Settings -> LLM Preference**, select **Generic OpenAI** and set:
| Setting | Value |
| -------------------- | --------------------------- |
| Base URL | `http://localhost:44337/v1` |
| API Key | `<access_token>` |
| Chat Model Selection | Select an active workspace |
In most OpenAI-compatible UIs, the app adds `Bearer` automatically, so the API key field should contain only the raw token string.
### 3) Optional: configure embeddings
If you want RAG flows through ABP, go to **Settings -> Embedding Preference** and use the same Base URL/API key values.
Then select a workspace that has embedder settings configured.
## Validate the Flow
### List models (workspaces)
```bash
curl http://localhost:44337/v1/models \
-H "Authorization: Bearer <your-token>"
```
### Chat completion
```bash
curl -X POST http://localhost:44337/v1/chat/completions \
-H "Authorization: Bearer <your-token>" \
-H "Content-Type: application/json" \
-d '{
"model": "MyWorkspace",
"messages": [
{ "role": "user", "content": "Hello from ABP OpenAI-compatible endpoint!" }
]
}'
```
### Optional SDK check (Python)
```python
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:44337/v1",
api_key="<your-token>"
)
response = client.chat.completions.create(
model="MyWorkspace",
messages=[{"role": "user", "content": "Hello!"}]
)
print(response.choices[0].message.content)
```
## Where This Fits in Real Projects
This approach is a strong fit when you want to:
- Keep ABP as the central control plane for AI workspaces.
- Let client tools integrate through a standard OpenAI contract.
- Switch providers or model settings without rewriting client-side integration.
If your team uses multiple AI clients, this pattern keeps integration simple while preserving control where it matters.
## Learn More
- [ABP AI Management Documentation](https://abp.io/docs/10.2/modules/ai-management)

BIN
docs/en/Community-Articles/2026-03-17-OpenAI-Compatible-Endpoints/cover-image.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

BIN
docs/en/Community-Articles/2026-03-17-OpenAI-Compatible-Endpoints/openai-compatible-endpoints-demo.gif

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 MiB

167
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/POST.md

@ -0,0 +1,167 @@
# Shared User Accounts in ABP Multi-Tenancy
Multi-tenancy is built on **isolation** — isolated data, isolated permissions, isolated users. ABP's default behavior has always followed this assumption: one user belongs to exactly one tenant. Clean, simple, no ambiguity. For most SaaS applications, that's exactly what you want. (The new `TenantUserSharingStrategy` enum formally names this default behavior `Isolated`.)
But isolation is **the system's** concern, not **the user's**. In practice, people's work doesn't always line up neatly with tenant boundaries.
Think about a financial consultant who works with three different companies — each one a tenant in your system. Under the Isolated model, she needs three separate accounts, three passwords. Forgot which password goes with which company? Good luck. Worse, the system sees three unrelated people — there's nothing linking those accounts to the same human being.
This comes up more often than you'd think:
- In a **corporate group**, an IT admin manages multiple subsidiaries, each running as its own tenant. Every day means logging out, logging back in with different credentials, over and over
- A **SaaS platform's ops team** needs to hop into different customer tenants to debug issues. Each time they create a throwaway account, then delete it — or just share one account and lose all audit trail
- Some users resort to email aliases (`alice+company1@example.com`) to work around uniqueness constraints — that's not a solution, that's a hack
The common thread here: the user's **identity** is global, but their **working context** is per-tenant. The problem isn't a technical limitation — it's that the Isolated assumption ("one user, one tenant") simply doesn't hold in these scenarios.
What's needed is not "one account per tenant" but "one account, multiple tenants."
ABP's **Shared User Accounts** (`TenantUserSharingStrategy.Shared`) does exactly this. It makes user identity global and turns tenants into workspaces that a user can join and switch between — similar to how one person can belong to multiple workspaces in Slack.
> This is a **commercial** feature, available starting from **ABP 10.2**, provided by the Account.Pro and Identity.Pro modules.
## Enabling the Shared Strategy
A single configuration is all it takes:
```csharp
Configure<AbpMultiTenancyOptions>(options =>
{
options.IsEnabled = true;
options.UserSharingStrategy = TenantUserSharingStrategy.Shared;
});
```
The most important behavior change after switching to Shared: **username and email uniqueness become global** instead of per-tenant. This follows naturally — if the same account needs to be recognized across tenants, its identifiers must be unique across the entire system.
Security-related settings (2FA, account lockout, password policies, captcha, etc.) are also managed at the **Host** level. This makes sense too: if user identity is global, the security rules around it should be global as well.
## One Account, Multiple Tenants
With the Shared strategy enabled, the day-to-day user experience changes fundamentally.
When a user is associated with only one tenant, the system recognizes it automatically and signs them in directly — the user doesn't even notice that tenants exist. When the user belongs to multiple tenants, the login flow presents a tenant selection screen after credentials are verified:
![tenant-selection](./tenant-selection.png)
After signing into a tenant, a tenant switcher appears in the user menu — click it anytime to jump to another tenant without signing out. ABP re-issues the authentication ticket (with the new `TenantId` in the claims) on each switch, so the permission system is fully independent per tenant.
![switch-tenant](./switch-tenant.png)
Users can also leave a tenant. Leaving doesn't delete the association record — it marks it as inactive. This preserves foreign key relationships with other entities. If the user is invited back later, the association is simply reactivated instead of recreated.
Back to our earlier scenario: the financial consultant now has one account, one password. She picks which company to work in at login, switches between them during the day. The system knows it's the same person, and the audit log can trace her actions across every tenant.
## Invitations
Users don't just appear in a tenant — someone has to invite them. This is the core operation from the administrator's perspective.
A tenant admin opens the invitation dialog, enters one or more email addresses (batch invitations are supported), and can pre-assign roles — so the user gets the right permissions the moment they join, no extra setup needed:
![invite-user](./invite-user.png)
The invited person receives an email with a link. What happens next depends on whether they already have an account.
If they **already have an account**, they see a confirmation page and can join the tenant with a single click:
![exist-user-accept](./exist-user-accept.png)
If they **don't have an account yet**, the link takes them to a registration form. Once they register, they're automatically added to the tenant:
![new-user-accept](./new-user-accept.png)
Admins can also manage pending invitations at any time — resend emails or revoke invitations.
> The invitation feature is also available under the Isolated strategy, but invited users can only join a single tenant.
## Setting Up a New Tenant
There's a notable shift in how new tenants are bootstrapped.
Under the Isolated model, creating a tenant typically seeds an `admin` user automatically. With Shared, this no longer happens — because users are global, and it doesn't make sense to create one out of thin air for a specific tenant.
Instead, you create the tenant first, then invite someone in and grant them the admin role.
![invite-admin-user-to-join-tenant](./invite-admin-user-to-join-tenant.png)
![invite-admin-user-to-join-tenant-modal](./invite-admin-user-to-join-tenant-modal.png)
This is a natural fit — the admin is just a global user who happens to hold the admin role in this particular tenant.
## Where Do Newly Registered Users Go?
Under the Shared strategy, self-registration runs into an interesting problem: the system doesn't know which tenant the user wants to join. Without being signed in, tenant context is usually determined by subdomain or a tenant switcher on the login page — but for a brand-new user, those signals might not exist at all.
So ABP's approach is: **don't establish any tenant association at registration time**. A newly registered user doesn't belong to any tenant, and doesn't belong to the Host either — this is an entirely new state. ABP still lets these users sign in, change their password, and manage their account, but they can't access any permission-protected features within a tenant.
`AbpIdentityPendingTenantUserOptions.Strategy` controls what happens in this "pending" state.
**CreateTenant** — automatically creates a tenant for the new user. This fits the "sign up and get your own workspace" pattern, like how Slack or Notion handles registration: you register, the system spins up a workspace for you.
```csharp
Configure<AbpIdentityPendingTenantUserOptions>(options =>
{
options.Strategy = AbpIdentityPendingTenantUserStrategy.CreateTenant;
});
```
![new-user-join-strategy-create-tenant](./new-user-join-strategy-create-tenant.png)
**Inform** (the default) — shows a message telling the user to contact an administrator to join a tenant. This is the right choice for invite-only platforms where users must be brought in by an existing tenant admin.
```csharp
Configure<AbpIdentityPendingTenantUserOptions>(options =>
{
options.Strategy = AbpIdentityPendingTenantUserStrategy.Inform;
});
```
![new-user-join-strategy-inform](./new-user-join-strategy-inform.png)
There's also a **Redirect** strategy that sends the user to a custom URL for more complex flows.
> See the [official documentation](https://abp.io/docs/latest/modules/account/shared-user-accounts) for full configuration details.
## Database Considerations
The Shared strategy introduces some mechanisms and constraints at the database level that are worth understanding.
### Global Uniqueness: Enforced in Code, Not by Database Indexes
Username and email uniqueness checks must span all tenants. ABP disables the tenant filter (`TenantFilter.Disable()`) during validation and searches globally for conflicts.
A notable design choice here: **global uniqueness is enforced at the application level, not through database unique indexes**. The reason is practical — in a database-per-tenant setup, users live in separate physical databases, so a cross-database unique index simply isn't possible. Even in a shared database, soft-delete complicates unique indexes (you'd need a composite index on "username + deletion time"). So ABP handles this in application code instead.
To keep things safe under concurrency — say two tenant admins invite the same email address at the same time — ABP uses a **distributed lock** to serialize uniqueness validation. This means your production environment needs a distributed lock provider configured (such as Redis).
The uniqueness check goes beyond just "no duplicate usernames." ABP also checks for **cross-field conflicts**: a user's username can't match another user's email, and vice versa. This prevents identity confusion in edge cases.
### Tenants with Separate Databases
If some of your tenants use their own database (database-per-tenant), the Shared strategy requires extra attention.
The login flow and tenant selection happen on the **Host side**. This means the Host database's `AbpUsers` table must contain records for all users — even those originally created in a tenant's separate database. ABP's approach is replication: it saves the primary user record in the Host context and creates a copy in the tenant context. In a shared-database setup, both records live in the same table; in a database-per-tenant setup, they live in different physical databases. Updates and deletes are kept in sync automatically.
If your application uses social login or passkeys, the `AbpUserLogins` and `AbpUserPasskeys` tables also need to be synced in the Host database.
### Migrating from the Isolated Strategy
If you're moving an existing multi-tenant application from Isolated to Shared, ABP automatically runs a global uniqueness check when you switch the strategy and reports any conflicts.
The most common conflict: the same email address registered as separate users in different tenants. You'll need to resolve these first — merge the accounts or change one side's email — before the Shared strategy can be enabled.
## Summary
ABP's Shared User Accounts addresses a real-world need in multi-tenant systems: one person working across multiple tenants.
- One configuration switch to `TenantUserSharingStrategy.Shared`
- User experience: pick a tenant at login, switch between tenants anytime, one password for everything
- Admin experience: invite users by email, pre-assign roles on invitation
- Database notes: configure a distributed lock provider for production; tenants with separate databases need user records replicated in the Host database
ABP takes care of global uniqueness validation, tenant association management, and login flow adaptation under the hood.
## References
- [Shared User Accounts](https://abp.io/docs/latest/modules/account/shared-user-accounts)
- [ABP Multi-Tenancy](https://abp.io/docs/latest/framework/architecture/multi-tenancy)

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/cover.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/exist-user-accept.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/invite-admin-user-to-join-tenant-modal.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/invite-admin-user-to-join-tenant.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/invite-user.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/new-user-accept.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/new-user-join-strategy-create-tenant.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/new-user-join-strategy-inform.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/switch-tenant.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
docs/en/Community-Articles/2026-03-17-Shared-User-Accounts-in-ABP/tenant-selection.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

94
docs/en/cli/index.md

@ -75,6 +75,7 @@ Here is the list of all available commands before explaining their details:
* **[`install-old-cli`](../cli#install-old-cli)**: Installs old ABP CLI.
* **[`mcp-studio`](../cli#mcp-studio)**: Starts ABP Studio MCP bridge for AI tools (requires ABP Studio running).
* **[`generate-razor-page`](../cli#generate-razor-page)**: Generates a page class that you can use it in the ASP NET Core pipeline to return an HTML page.
* **[`generate-jwks`](../cli#generate-jwks)**: Generates an RSA key pair (JWKS public key + PEM private key) for OpenIddict `private_key_jwt` client authentication.
### help
@ -1127,6 +1128,99 @@ app.Use(async (httpContext, next) =>
* ```--version``` or ```-v```: Specifies the version for ABP CLI to be installed.
### generate-jwks
Generates an RSA key pair for use with OpenIddict `private_key_jwt` client authentication.
The command produces two files:
| File | Description |
|---|---|
| `<prefix>.json` | JWKS (JSON Web Key Set) containing the **public key**. Paste this into the **JSON Web Key Set** field of your OpenIddict application in the ABP management UI. |
| `<prefix>-private.pem` | PKCS#8 PEM **private key**. Store this securely in your client application and use it to sign JWT client assertions. |
> **Security notice:** Never commit the private key file to source control. Add it to `.gitignore`. Only the JWKS (public key) needs to be shared with the authorization server.
Usage:
```bash
abp generate-jwks [options]
```
#### Options
* `--output` or `-o`: Output directory. Defaults to the current directory.
* `--key-size` or `-s`: RSA key size in bits. Supported values: `2048` (default), `4096`.
* `--alg`: Signing algorithm. Supported values: `RS256` (default), `RS384`, `RS512`, `PS256`, `PS384`, `PS512`.
* `--kid`: Custom Key ID. Auto-generated if not specified.
* `--file` or `-f`: Output file name prefix. Defaults to `jwks`. Generates `<prefix>.json` and `<prefix>-private.pem`.
#### Examples
```bash
# Generate with defaults (2048-bit RS256, current directory)
abp generate-jwks
# Generate with RS512 and 4096-bit key
abp generate-jwks --alg RS512 --key-size 4096
# Output to a specific directory with a custom file prefix
abp generate-jwks -o ./keys -f myapp
```
#### Workflow
1. Run `abp generate-jwks` to generate the key pair.
2. Open the ABP OpenIddict application management UI, select your **Confidential** application, choose **JWKS (private_key_jwt)** as the authentication method, and paste the contents of `jwks.json` into the **JSON Web Key Set** field.
3. In your client application, load the private key from the PEM file and sign JWT client assertions:
```csharp
// Load private key from PEM file
using var rsa = RSA.Create();
rsa.ImportFromPem(await File.ReadAllTextAsync("jwks-private.pem"));
// The kid must match the "kid" field in the JWKS registered on the server
var signingKey = new RsaSecurityKey(rsa) { KeyId = "<kid-from-jwks.json>" };
var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256);
var now = DateTime.UtcNow;
var jwtHandler = new JsonWebTokenHandler();
var clientAssertion = jwtHandler.CreateToken(new SecurityTokenDescriptor
{
// OpenIddict requires typ = "client-authentication+jwt"
TokenType = "client-authentication+jwt",
// iss and sub must both equal the client_id
Issuer = "<your-client-id>",
Audience = "<authorization-server-issuer-uri>",
Subject = new ClaimsIdentity(new[]
{
new Claim(JwtRegisteredClaimNames.Sub, "<your-client-id>"),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}),
IssuedAt = now,
NotBefore = now,
Expires = now.AddMinutes(5),
SigningCredentials = signingCredentials,
});
// Use the assertion in the token request
var tokenResponse = await httpClient.RequestClientCredentialsTokenAsync(
new ClientCredentialsTokenRequest
{
Address = "<token-endpoint>",
ClientId = "<your-client-id>",
ClientCredentialStyle = ClientCredentialStyle.PostBody,
ClientAssertion = new ClientAssertion
{
Type = OidcConstants.ClientAssertionTypes.JwtBearer,
Value = clientAssertion,
},
Scope = "<requested-scopes>",
});
```
## See Also
* [Examples for the new command](./new-command-samples.md)

8
docs/en/docs-nav.json

@ -807,10 +807,6 @@
"text": "Object to Object Mapping",
"path": "framework/infrastructure/object-to-object-mapping.md"
},
{
"text": "Operation Rate Limiting",
"path": "framework/infrastructure/operation-rate-limiting.md"
},
{
"text": "Settings",
"path": "framework/infrastructure/settings.md"
@ -2577,6 +2573,10 @@
"text": "Language Management (Pro)",
"path": "modules/language-management.md"
},
{
"text": "Operation Rate Limiting (Pro)",
"path": "modules/operation-rate-limiting.md"
},
{
"text": "OpenIddict",
"isLazyExpandable": true,

4
docs/en/framework/api-development/auto-controllers.md

@ -70,7 +70,7 @@ Route is calculated based on some conventions:
* Continues with a **route path**. Default value is '**/app**' and can be configured as like below:
````csharp
Configure<AbpAspNetCoreMvcOptions>(options =>
PreConfigure<AbpAspNetCoreMvcOptions>(options =>
{
options.ConventionalControllers
.Create(typeof(BookStoreApplicationModule).Assembly, opts =>
@ -149,7 +149,7 @@ public class PersonAppService : ApplicationService
You can further filter classes to become an API controller by providing the `TypePredicate` option:
````csharp
services.Configure<AbpAspNetCoreMvcOptions>(options =>
PreConfigure<AbpAspNetCoreMvcOptions>(options =>
{
options.ConventionalControllers
.Create(typeof(BookStoreApplicationModule).Assembly, opts =>

4
docs/en/framework/infrastructure/background-jobs/tickerq.md

@ -95,13 +95,13 @@ public class CleanupJobs
public override Task OnPreApplicationInitializationAsync(ApplicationInitializationContext context)
{
var abpTickerQFunctionProvider = context.ServiceProvider.GetRequiredService<AbpTickerQFunctionProvider>();
abpTickerQFunctionProvider.Functions.TryAdd(nameof(CleanupJobs), (string.Empty, TickerTaskPriority.Normal, new TickerFunctionDelegate(async (cancellationToken, serviceProvider, tickerFunctionContext) =>
abpTickerQFunctionProvider.AddFunction(nameof(CleanupJobs), async (cancellationToken, serviceProvider, tickerFunctionContext) =>
{
var service = new CleanupJobs(); // Or get it from the serviceProvider
var request = await TickerRequestProvider.GetRequestAsync<string>(tickerFunctionContext, cancellationToken);
var genericContext = new TickerFunctionContext<string>(tickerFunctionContext, request);
await service.CleanupLogsAsync(genericContext, cancellationToken);
})));
}, TickerTaskPriority.Normal);
abpTickerQFunctionProvider.RequestTypes.TryAdd(nameof(CleanupJobs), (typeof(string).FullName, typeof(string)));
return Task.CompletedTask;
}

8
docs/en/framework/infrastructure/background-workers/tickerq.md

@ -83,13 +83,13 @@ public class CleanupJobs
public override Task OnPreApplicationInitializationAsync(ApplicationInitializationContext context)
{
var abpTickerQFunctionProvider = context.ServiceProvider.GetRequiredService<AbpTickerQFunctionProvider>();
abpTickerQFunctionProvider.Functions.TryAdd(nameof(CleanupJobs), (string.Empty, TickerTaskPriority.Normal, new TickerFunctionDelegate(async (cancellationToken, serviceProvider, tickerFunctionContext) =>
abpTickerQFunctionProvider.AddFunction(nameof(CleanupJobs), async (cancellationToken, serviceProvider, tickerFunctionContext) =>
{
var service = new CleanupJobs(); // Or get it from the serviceProvider
var request = await TickerRequestProvider.GetRequestAsync<string>(tickerFunctionContext, cancellationToken);
var genericContext = new TickerFunctionContext<string>(tickerFunctionContext, request);
await service.CleanupLogsAsync(genericContext, cancellationToken);
})));
}, TickerTaskPriority.Normal);
abpTickerQFunctionProvider.RequestTypes.TryAdd(nameof(CleanupJobs), (typeof(string).FullName, typeof(string)));
return Task.CompletedTask;
}
@ -112,11 +112,11 @@ await cronTickerManager.AddAsync(new CronTickerEntity
You can specify a cron expression instead of using `ICronTickerManager<CronTickerEntity>` to add a worker:
```csharp
abpTickerQFunctionProvider.Functions.TryAdd(nameof(CleanupJobs), (string.Empty, TickerTaskPriority.Normal, new TickerFunctionDelegate(async (cancellationToken, serviceProvider, tickerFunctionContext) =>
abpTickerQFunctionProvider.AddFunction(nameof(CleanupJobs), async (cancellationToken, serviceProvider, tickerFunctionContext) =>
{
var service = new CleanupJobs();
var request = await TickerRequestProvider.GetRequestAsync<string>(tickerFunctionContext, cancellationToken);
var genericContext = new TickerFunctionContext<string>(tickerFunctionContext, request);
await service.CleanupLogsAsync(genericContext, cancellationToken);
})));
}, TickerTaskPriority.Normal);
```

113
docs/en/framework/infrastructure/entity-cache.md

@ -26,7 +26,7 @@ public class Product : AggregateRoot<Guid>
public string Name { get; set; }
public string Description { get; set; }
public float Price { get; set; }
public decimal Price { get; set; }
public int StockCount { get; set; }
}
```
@ -72,7 +72,7 @@ public class ProductDto : EntityDto<Guid>
{
public string Name { get; set; }
public string Description { get; set; }
public float Price { get; set; }
public decimal Price { get; set; }
public int StockCount { get; set; }
}
```
@ -147,6 +147,115 @@ context.Services.AddEntityCache<Product, ProductDto, Guid>(
* Entity classes should be serializable/deserializable to/from JSON to be cached (because it's serialized to JSON when saving in the [Distributed Cache](../fundamentals/caching.md)). If your entity class is not serializable, you can consider using a cache-item/DTO class instead, as explained before.
* Entity Caching System is designed as **read-only**. You should use the standard [repository](../architecture/domain-driven-design/repositories.md) methods to manipulate the entity if you need to. If you need to manipulate (update) the entity, do not get it from the entity cache. Instead, read it from the repository, change it and update using the repository.
## Getting Multiple Entities
In addition to the single-entity methods `FindAsync` and `GetAsync`, the `IEntityCache` service provides batch retrieval methods for retrieving multiple entities at once.
### List-Based Batch Retrieval
`FindManyAsync` and `GetManyAsync` return results as a list, preserving the order of the given IDs (including duplicates):
```csharp
public class ProductAppService : ApplicationService, IProductAppService
{
private readonly IEntityCache<ProductDto, Guid> _productCache;
public ProductAppService(IEntityCache<ProductDto, Guid> productCache)
{
_productCache = productCache;
}
public async Task<List<ProductDto>> GetManyAsync(List<Guid> ids)
{
return await _productCache.GetManyAsync(ids);
}
public async Task<List<ProductDto?>> FindManyAsync(List<Guid> ids)
{
return await _productCache.FindManyAsync(ids);
}
}
```
* `GetManyAsync` throws `EntityNotFoundException` if any entity is not found for the given IDs.
* `FindManyAsync` returns a list where each entry corresponds to the given ID in the same order; an entry will be `null` if the entity was not found.
### Dictionary-Based Batch Retrieval
`FindManyAsDictionaryAsync` and `GetManyAsDictionaryAsync` return results as a dictionary keyed by entity ID, which is convenient when you need fast lookup by ID:
```csharp
public async Task<Dictionary<Guid, ProductDto?>> FindManyAsDictionaryAsync(List<Guid> ids)
{
return await _productCache.FindManyAsDictionaryAsync(ids);
}
public async Task<Dictionary<Guid, ProductDto>> GetManyAsDictionaryAsync(List<Guid> ids)
{
return await _productCache.GetManyAsDictionaryAsync(ids);
}
```
* `GetManyAsDictionaryAsync` throws `EntityNotFoundException` if any entity is not found for the given IDs.
* `FindManyAsDictionaryAsync` returns a dictionary where the value is `null` if the entity was not found for the corresponding key.
All batch methods internally use `IDistributedCache.GetOrAddManyAsync` to batch-fetch only the cache-missed entities from the database, making them more efficient than calling `FindAsync` or `GetAsync` in a loop.
## Custom Object Mapping
When you need full control over how an entity is mapped to a cache item, you can derive from `EntityCacheWithObjectMapper` and override the `MapToValue` method:
First, define the cache item class:
```csharp
public class ProductCacheDto
{
public Guid Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
```
Then, derive from `EntityCacheWithObjectMapper` and override `MapToValue`:
```csharp
public class ProductEntityCache :
EntityCacheWithObjectMapper<Product, ProductCacheDto, Guid>
{
public ProductEntityCache(
IReadOnlyRepository<Product, Guid> repository,
IDistributedCache<EntityCacheItemWrapper<ProductCacheDto>, Guid> cache,
IUnitOfWorkManager unitOfWorkManager,
IObjectMapper objectMapper)
: base(repository, cache, unitOfWorkManager, objectMapper)
{
}
protected override ProductCacheDto MapToValue(Product entity)
{
// Custom mapping logic here
return new ProductCacheDto
{
Id = entity.Id,
Name = entity.Name.ToUpperInvariant(),
Price = entity.Price
};
}
}
```
Register your custom cache class in the `ConfigureServices` method of your [module class](../architecture/modularity/basics.md):
```csharp
context.Services.ReplaceEntityCache<ProductEntityCache, Product, ProductCacheDto, Guid>(
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
```
> If no prior `AddEntityCache` registration exists for the same cache item type, `ReplaceEntityCache` will simply add the service instead of throwing an error.
## See Also
* [Distributed caching](../fundamentals/caching.md)

20
docs/en/framework/ui/maui/index.md

@ -42,15 +42,29 @@ You can examine the [Users Page](#users-page) or any other pre-defined page to s
### Android
If you get the following error when connecting to the emulator or a physical phone, you need to set up port mapping.
If you get the following error when connecting to the emulator or a physical phone, you need to set up port mapping using the `adb` tool:
```
Cannot connect to the backend on localhost.
```
Open a command line terminal and run the `adb reverse` command to expose a port on your Android device to a port on your computer. For example:
**How to get and use `adb` tool:**
`adb reverse tcp:44305 tcp:44305`
- **Option 1: Install `adb` globally**
Download and install the [Android SDK Platform-Tools](https://developer.android.com/tools/releases/platform-tools) to get the [`adb`](https://developer.android.com/tools/adb) command-line tool.
- **Option 2: Use Visual Studio’s built-in `adb` command prompt**
If you are using Visual Studio, you can access the `adb` command prompt directly from the IDE:
![Android Adb Command Prompt](../../../images/adb-command-prompt.png)
> For more information on setting up your environment for Android development and debugging, refer to the [Microsoft MAUI Android device setup guide](https://learn.microsoft.com/en-us/dotnet/maui/android/device/setup).
**Port mapping command:**
Once `adb` is available, run the following command in your terminal (or Visual Studio's `adb` command prompt) to map the backend port to your Android device:
```bash
adb reverse tcp:44305 tcp:44305
```
> Replace `44305` with the port number your backend application is running on.
>

BIN
docs/en/images/adb-command-prompt.png

Binary file not shown.

79
docs/en/modules/ai-management/index.md

@ -44,6 +44,16 @@ abp add-package Volo.AIManagement.OpenAI
abp add-package Volo.AIManagement.Ollama
```
> [!IMPORTANT]
> If you use Ollama, make sure the Ollama server is installed and running, and that the models referenced by your workspace are already available locally. Before configuring an Ollama workspace, pull the chat model and any embedding model you plan to use. For example:
>
> ```bash
> ollama pull llama3.2
> ollama pull nomic-embed-text
> ```
>
> Replace the model names with the exact models you configure in the workspace. `nomic-embed-text` is an embedding-only model and can't be used as a chat model.
> [!TIP]
> You can install multiple provider packages to support different AI providers simultaneously in your workspaces.
@ -308,6 +318,14 @@ RAG requires an **embedder** and a **vector store** to be configured on the work
* **Embedder**: Converts documents and queries into vector embeddings. You can use any provider that supports embedding generation (e.g., OpenAI `text-embedding-3-small`, Ollama `nomic-embed-text`).
* **Vector Store**: Stores and retrieves vector embeddings. Supported providers: **MongoDb**, **Pgvector**, and **Qdrant**.
> [!IMPORTANT]
> If the workspace uses Ollama for chat or embeddings, the configured model names must exist in the local Ollama instance first. For example, if you configure `ModelName = "llama3.2"` and `EmbedderModelName = "nomic-embed-text"`, pull both models before using the workspace:
>
> ```bash
> ollama pull llama3.2
> ollama pull nomic-embed-text
> ```
### Configuring RAG on a Workspace
To enable RAG for a workspace, configure the embedder and vector store settings in the workspace edit page.
@ -432,6 +450,67 @@ The options class also provides helper methods:
> [!NOTE]
> Adding new file extensions also requires a matching content extractor to be registered for document processing. The built-in extractors support `.txt`, `.md`, and `.pdf` files.
#### Hosting-Level Upload Limits
`WorkspaceDataSourceOptions.MaxFileSize` controls the module-level validation, but your hosting stack may reject large uploads before the request reaches AI Management. If you increase `MaxFileSize`, make sure the underlying server and proxy limits are also updated.
Typical limits to review:
* **ASP.NET Core form/multipart limit** (`FormOptions.MultipartBodyLengthLimit`)
* **Kestrel request body limit** (`KestrelServerLimits.MaxRequestBodySize`)
* **IIS request filtering limit** (`maxAllowedContentLength`)
* **Reverse proxy limits** such as **Nginx** (`client_max_body_size`)
Example ASP.NET Core configuration:
```csharp
using Microsoft.AspNetCore.Http.Features;
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<WorkspaceDataSourceOptions>(options =>
{
options.MaxFileSize = 50 * 1024 * 1024;
});
Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 50 * 1024 * 1024;
});
}
```
```csharp
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 50 * 1024 * 1024;
});
```
Example IIS configuration in `web.config`:
```xml
<configuration>
<system.webServer>
<security>
<requestFiltering>
<requestLimits maxAllowedContentLength="52428800" />
</requestFiltering>
</security>
</system.webServer>
</configuration>
```
Example Nginx configuration:
```nginx
server {
client_max_body_size 50M;
}
```
If you are hosting behind another proxy or gateway (for example Apache, YARP, Azure App Gateway, Cloudflare, or Kubernetes ingress), ensure its request-body limit is also greater than or equal to the configured `MaxFileSize`.
## Permissions
The AI Management module defines the following permissions:

289
docs/en/framework/infrastructure/operation-rate-limiting.md → docs/en/modules/operation-rate-limiting.md

@ -1,11 +1,13 @@
````json
//[doc-seo]
{
"Description": "Learn how to use the Operation Rate Limiting module in ABP Framework to control the frequency of specific operations like SMS sending, login attempts, and resource-intensive tasks."
"Description": "Learn how to use the Operation Rate Limiting module (Pro) in ABP to control the frequency of specific operations like SMS sending, login attempts, and resource-intensive tasks."
}
````
# Operation Rate Limiting
# Operation Rate Limiting Module (Pro)
> You must have an [ABP Team or a higher license](https://abp.io/pricing) to use this module.
ABP provides an operation rate limiting system that allows you to control the frequency of specific operations in your application. You may need operation rate limiting for several reasons:
@ -15,15 +17,9 @@ ABP provides an operation rate limiting system that allows you to control the fr
> This is not for [ASP.NET Core's built-in rate limiting middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) which works at the HTTP request pipeline level. This module works at the **application/domain code level** and is called explicitly from your services. See the [Combining with ASP.NET Core Rate Limiting](#combining-with-aspnet-core-rate-limiting) section for a comparison.
## Installation
You can open a command-line terminal and type the following command to install the [Volo.Abp.OperationRateLimiting](https://www.nuget.org/packages/Volo.Abp.OperationRateLimiting) package into your project:
## How to Install
````bash
abp add-package Volo.Abp.OperationRateLimiting
````
> If you haven't done it yet, you first need to install the [ABP CLI](../../../cli).
This module is used by the [Account (Pro)](account-pro.md) module internally and comes pre-installed in the latest [startup templates](../solution-templates). So, no need to manually install it.
## Quick Start
@ -31,7 +27,7 @@ This section shows the basic usage of the operation rate limiting system with a
### Defining a Policy
First, define a rate limiting policy in the `ConfigureServices` method of your [module class](../../architecture/modularity/basics.md):
First, define a rate limiting policy in the `ConfigureServices` method of your [module class](../framework/architecture/modularity/basics.md):
````csharp
Configure<AbpOperationRateLimitingOptions>(options =>
@ -62,7 +58,7 @@ public class SmsAppService : ApplicationService
_rateLimitChecker = rateLimitChecker;
}
public async Task SendCodeAsync(string phoneNumber)
public virtual async Task SendCodeAsync(string phoneNumber)
{
await _rateLimitChecker.CheckAsync("SendSmsCode", phoneNumber);
@ -78,9 +74,120 @@ public class SmsAppService : ApplicationService
That's the basic usage. The following sections explain each concept in detail.
## Declarative Usage (Attribute)
Instead of injecting `IOperationRateLimitingChecker` manually, you can use the `[OperationRateLimiting]` attribute to enforce a policy declaratively on Application Service methods or MVC Controller actions.
> **Application Services** are handled by the ABP interceptor (built into the Domain layer).
> **MVC Controllers** are handled by `AbpOperationRateLimitingActionFilter`, which is automatically registered when you reference the `Volo.Abp.OperationRateLimiting.AspNetCore` package.
### Applying to an Application Service
````csharp
public class SmsAppService : ApplicationService
{
[OperationRateLimiting("SendSmsCode")]
public virtual async Task SendCodeAsync([RateLimitingParameter] string phoneNumber)
{
// Rate limit is checked automatically before this line executes.
await _smsSender.SendAsync(phoneNumber, GenerateCode());
}
}
````
### Applying to an MVC Controller
````csharp
[Route("api/account")]
public class AccountController : AbpController
{
[HttpPost("send-sms-code")]
[OperationRateLimiting("SendSmsCode")]
public async Task<IActionResult> SendSmsCodeAsync([RateLimitingParameter] string phoneNumber)
{
// Rate limit is checked automatically before this line executes.
await _smsSender.SendAsync(phoneNumber, GenerateCode());
return Ok();
}
}
````
### Resolving the Parameter Value
The `[OperationRateLimiting]` attribute resolves `OperationRateLimitingContext.Parameter` automatically using the following priority order:
1. **`[RateLimitingParameter]`** — a method parameter marked with this attribute. Its `ToString()` value is used as the partition key.
2. **`IHasOperationRateLimitingParameter`** — a method parameter whose type implements this interface. The value returned by `GetPartitionParameter()` is used as the partition key.
3. **`null`** — no parameter is resolved; suitable for policies that use `PartitionByCurrentUser`, `PartitionByClientIp`, etc.
#### Using `[RateLimitingParameter]`
Mark a single parameter to use its value as the partition key:
````csharp
[OperationRateLimiting("SendSmsCode")]
public virtual async Task SendCodeAsync([RateLimitingParameter] string phoneNumber)
{
// partition key = phoneNumber
}
````
#### Using `IHasOperationRateLimitingParameter`
Implement the interface on an input DTO when the partition key is a property of the DTO:
````csharp
public class SendSmsCodeInput : IHasOperationRateLimitingParameter
{
public string PhoneNumber { get; set; }
public string Language { get; set; }
public string? GetPartitionParameter() => PhoneNumber;
}
````
````csharp
[OperationRateLimiting("SendSmsCode")]
public virtual async Task SendCodeAsync(SendSmsCodeInput input)
{
// partition key = input.GetPartitionParameter() = input.PhoneNumber
}
````
#### No Partition Parameter
If no parameter is marked and no DTO implements the interface, the policy is checked without a `Parameter` value. This is appropriate for policies that use `PartitionByCurrentUser`, `PartitionByClientIp`, or `PartitionByCurrentTenant`:
````csharp
// Policy uses PartitionByCurrentUser — no explicit parameter needed.
[OperationRateLimiting("GenerateReport")]
public virtual async Task<ReportDto> GenerateMonthlyReportAsync()
{
// Rate limit is checked per current user automatically.
}
````
> If the method has parameters but none is resolved, a **warning log** is emitted to help you catch misconfigured usages early.
### Applying to a Class
You can also place `[OperationRateLimiting]` on the class to apply it to **all public methods** of that class:
````csharp
[OperationRateLimiting("MyServiceLimit")]
public class MyAppService : ApplicationService
{
public virtual async Task MethodAAsync([RateLimitingParameter] string key) { ... }
public virtual async Task MethodBAsync([RateLimitingParameter] string key) { ... }
}
````
> A method-level attribute takes precedence over the class-level attribute.
## Defining Policies
Policies are defined using `AbpOperationRateLimitingOptions` in the `ConfigureServices` method of your [module class](../../architecture/modularity/basics.md). Each policy has a unique name, one or more rules, and a partition strategy.
Policies are defined using `AbpOperationRateLimitingOptions` in the `ConfigureServices` method of your [module class](../framework/architecture/modularity/basics.md). Each policy has a unique name, one or more rules, and a partition strategy.
### Single-Rule Policies
@ -115,6 +222,78 @@ options.AddPolicy("Login", policy =>
> When multiple rules are present, the module uses a **two-phase check**: it first verifies all rules without incrementing counters, then increments only if all rules pass. This prevents wasted quota when one rule would block the request.
### Overriding an Existing Policy
If a reusable module (e.g., ABP's Account module) defines a policy with default rules, you have two ways to customize it in your own module's `ConfigureServices`.
**Option 1 — Full replacement with `AddPolicy`:**
Call `AddPolicy` with the same name. The last registration wins and completely replaces all rules:
````csharp
// In your application module — runs after the Account module
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.AddPolicy("Account.SendPasswordResetCode", policy =>
{
// Replaces all rules defined by the Account module for this policy
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 3)
.PartitionByEmail());
});
});
````
> `AddPolicy` stores policies in a dictionary keyed by name, so calling it again with the same name fully replaces the previous policy and all its rules.
**Option 2 — Partial modification with `ConfigurePolicy`:**
Use `ConfigurePolicy` to modify an existing policy without replacing it entirely. The builder is pre-populated with the existing rules, so you only need to express what changes:
````csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
// Only override the error code, keeping the module's original rules
options.ConfigurePolicy("Account.SendPasswordResetCode", policy =>
{
policy.WithErrorCode("MyApp:SmsCodeLimit");
});
});
````
You can also add a rule on top of the existing ones:
````csharp
options.ConfigurePolicy("Account.SendPasswordResetCode", policy =>
{
// Keep the module's per-email rule and add a per-IP rule on top
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20)
.PartitionByClientIp());
});
````
Or clear all inherited rules first and define entirely new ones using `ClearRules()`:
````csharp
options.ConfigurePolicy("Account.SendPasswordResetCode", policy =>
{
policy.ClearRules()
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 3)
.PartitionByEmail();
});
````
`ConfigurePolicy` returns `AbpOperationRateLimitingOptions`, so you can chain multiple calls:
````csharp
options
.ConfigurePolicy("Account.SendPasswordResetCode", p => p.WithErrorCode("MyApp:SmsLimit"))
.ConfigurePolicy("Account.Login", p => p.WithErrorCode("MyApp:LoginLimit"));
````
> `ConfigurePolicy` throws `AbpException` if the policy name is not found. Use `AddPolicy` first (in the module that owns the policy), then `ConfigurePolicy` in downstream modules to customize it.
### Custom Error Code
By default, the exception uses the error code `Volo.Abp.OperationRateLimiting:010001`. You can override it per policy:
@ -195,14 +374,59 @@ Works the same way as `PartitionByEmail`: resolves from `context.Parameter` firs
### Custom Partition (PartitionBy)
You can provide a custom async function to generate the partition key. The async signature allows you to perform database queries or other I/O operations:
You can register a named custom resolver to generate the partition key. The resolver is an async function, so you can perform database queries or other I/O operations. Because the resolver is stored by name (not as an anonymous delegate), it can be serialized and managed from a UI or database.
**Step 1 — Register the resolver by name:**
````csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.AddPartitionKeyResolver("ByDevice", ctx =>
Task.FromResult($"{ctx.Parameter}:{ctx.ExtraProperties["DeviceId"]}"));
});
````
**Step 2 — Reference it in a policy:**
````csharp
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100)
.PartitionBy("ByDevice");
````
You can also register and reference in one step (inline):
````csharp
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100)
.PartitionBy(ctx => Task.FromResult(
$"{ctx.Parameter}:{ctx.ExtraProperties["DeviceId"]}"));
.PartitionBy("ByDevice", ctx =>
Task.FromResult($"{ctx.Parameter}:{ctx.ExtraProperties["DeviceId"]}"));
````
> If you call `PartitionBy("name")` with a resolver name that hasn't been registered, an exception is thrown at configuration time (not at runtime), so typos are caught early.
To replace an existing resolver (e.g., in a downstream module), use `ReplacePartitionKeyResolver`:
````csharp
options.ReplacePartitionKeyResolver("ByDevice", ctx =>
Task.FromResult($"v2:{ctx.Parameter}:{ctx.ExtraProperties["DeviceId"]}"));
````
### Named Rules (WithName)
By default, a rule's store key is derived from its `Duration`, `MaxCount`, and `PartitionType`. This means that if you change a rule's parameters (e.g., increase `maxCount` from 5 to 10), the counter resets because the key changes.
To keep a stable key across parameter changes, give the rule a name:
````csharp
policy.AddRule(rule => rule
.WithName("HourlyLimit")
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100)
.PartitionByCurrentUser());
````
When a name is set, it is used as the store key instead of the content-based descriptor. This is particularly useful when rules are managed from a database or UI — changing the `maxCount` or `duration` will not reset existing counters.
> Rule names must be unique within a policy. Duplicate names cause an exception at build time.
## Multi-Tenancy
By default, partition keys do not include tenant information — for partition types like `PartitionByParameter`, `PartitionByCurrentUser`, `PartitionByClientIp`, etc., counters are shared across tenants unless you call `WithMultiTenancy()`. Note that `PartitionByCurrentTenant()` is inherently per-tenant since the partition key is the tenant ID itself, and `PartitionByClientIp()` is typically kept global since the same IP should share a counter regardless of tenant.
@ -434,7 +658,7 @@ This module and ASP.NET Core's built-in [rate limiting middleware](https://learn
|---|---|---|
| **Level** | HTTP request pipeline | Application/domain code |
| **Scope** | All incoming requests | Specific business operations |
| **Usage** | Middleware (automatic) | Explicit `CheckAsync` calls |
| **Usage** | Middleware (automatic) | `[OperationRateLimiting]` attribute or explicit `CheckAsync` calls |
| **Typical use** | API throttling, DDoS protection | Business logic limits (SMS, reports) |
A common pattern is to use ASP.NET Core middleware for broad API protection and this module for fine-grained business operation limits.
@ -467,7 +691,7 @@ public class MyCustomStore : IOperationRateLimitingStore, ITransientDependency
}
````
ABP's [dependency injection](../../fundamentals/dependency-injection.md) system will automatically use your implementation since it replaces the default one.
ABP's [dependency injection](../framework/fundamentals/dependency-injection.md) system will automatically use your implementation since it replaces the default one.
### Custom Rule
@ -485,8 +709,33 @@ Replace `IOperationRateLimitingFormatter` to customize how time durations are di
Replace `IOperationRateLimitingPolicyProvider` to load policies from a database or external configuration source instead of the in-memory options.
When loading pre-built policies from an external source, use the `AddPolicy` overload that accepts an `OperationRateLimitingPolicy` object directly (bypassing the builder):
````csharp
options.AddPolicy(new OperationRateLimitingPolicy
{
Name = "DynamicPolicy",
Rules =
[
new OperationRateLimitingRuleDefinition
{
Name = "HourlyLimit",
Duration = TimeSpan.FromHours(1),
MaxCount = 100,
PartitionType = OperationRateLimitingPartitionType.CurrentUser
}
]
});
````
To remove a policy (e.g., when it is deleted from the database), use `RemovePolicy`:
````csharp
options.RemovePolicy("DynamicPolicy");
````
## See Also
* [ASP.NET Core Rate Limiting Middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit)
* [Distributed Caching](../fundamentals/caching.md)
* [Exception Handling](../fundamentals/exception-handling.md)
* [Distributed Caching](../framework/fundamentals/caching.md)
* [Exception Handling](../framework/fundamentals/exception-handling.md)

14
docs/en/package-version-changes.md

@ -1,5 +1,19 @@
# Package Version Changes
## 10.3.0-rc.1
| Package | Old Version | New Version | PR |
|---------|-------------|-------------|-----|
| Microsoft.IdentityModel.JsonWebTokens | 8.14.0 | 8.16.0 | #25068 |
| Microsoft.IdentityModel.Protocols.OpenIdConnect | 8.14.0 | 8.16.0 | #25068 |
| Microsoft.IdentityModel.Tokens | 8.14.0 | 8.16.0 | #25068 |
| MongoDB.Driver | 3.7.0 | 3.7.1 | #25114 |
| System.IdentityModel.Tokens.Jwt | 8.14.0 | 8.16.0 | #25068 |
| TickerQ | 10.1.1 | 10.2.0 | #25091 |
| TickerQ.Dashboard | 10.1.1 | 10.2.0 | #25091 |
| TickerQ.EntityFrameworkCore | 10.1.1 | 10.2.0 | #25091 |
| TickerQ.Utilities | 10.1.1 | 10.2.0 | #25091 |
## 10.3.0-preview
| Package | Old Version | New Version | PR |

2
framework/Volo.Abp.slnx

@ -169,7 +169,6 @@
<Project Path="src/Volo.Abp.TickerQ/Volo.Abp.TickerQ.csproj" />
<Project Path="src/Volo.Abp.BackgroundJobs.TickerQ/Volo.Abp.BackgroundJobs.TickerQ.csproj" />
<Project Path="src/Volo.Abp.BackgroundWorkers.TickerQ/Volo.Abp.BackgroundWorkers.TickerQ.csproj" />
<Project Path="src/Volo.Abp.OperationRateLimiting/Volo.Abp.OperationRateLimiting.csproj" />
</Folder>
<Folder Name="/test/">
<Project Path="test/AbpTestBase/AbpTestBase.csproj" />
@ -257,6 +256,5 @@
<Project Path="test/Volo.Abp.Uow.Tests/Volo.Abp.Uow.Tests.csproj" />
<Project Path="test/Volo.Abp.Validation.Tests/Volo.Abp.Validation.Tests.csproj" />
<Project Path="test/Volo.Abp.VirtualFileSystem.Tests/Volo.Abp.VirtualFileSystem.Tests.csproj" />
<Project Path="test/Volo.Abp.OperationRateLimiting.Tests/Volo.Abp.OperationRateLimiting.Tests.csproj" />
</Folder>
</Solution>

5
framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/AbpBlazorClientHttpMessageHandler.cs

@ -53,7 +53,10 @@ public class AbpBlazorClientHttpMessageHandler : DelegatingHandler, ITransientDe
options.Type = UiPageProgressType.Info;
});
request.SetBrowserRequestStreamingEnabled(true);
if (request.RequestUri?.Scheme == Uri.UriSchemeHttps)
{
request.SetBrowserRequestStreamingEnabled(true);
}
await SetLanguageAsync(request, cancellationToken);
await SetAntiForgeryTokenAsync(request);
await SetTimeZoneAsync(request);

11
framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Form/AbpInputTagHelperService.cs

@ -54,7 +54,7 @@ public class AbpInputTagHelperService : AbpTagHelperService<AbpInputTagHelper>
output.TagMode = TagMode.StartTagAndEndTag;
output.TagName = "div";
LeaveOnlyGroupAttributes(context, output);
if (!IsOutputHidden(output))
if (!IsInputHidden(context))
{
if (TagHelper.FloatingLabel && !isCheckBox)
{
@ -86,6 +86,7 @@ public class AbpInputTagHelperService : AbpTagHelperService<AbpInputTagHelper>
protected virtual async Task<(string, bool)> GetFormInputGroupAsHtmlAsync(TagHelperContext context, TagHelperOutput output)
{
var (inputTag, isCheckBox) = await GetInputTagHelperOutputAsync(context, output);
context.Items[nameof(IsOutputHidden)] = IsOutputHidden(inputTag);
var inputHtml = inputTag.Render(_encoder);
var label = await GetLabelAsHtmlAsync(context, output, inputTag, isCheckBox);
@ -124,7 +125,8 @@ public class AbpInputTagHelperService : AbpTagHelperService<AbpInputTagHelper>
protected virtual string SurroundInnerHtmlAndGet(TagHelperContext context, TagHelperOutput output, string innerHtml, bool isCheckbox)
{
var mb = TagHelper.AddMarginBottomClass ? (isCheckbox ? "mb-2" : "mb-3") : string.Empty;
var isHidden = IsInputHidden(context);
var mb = !isHidden && TagHelper.AddMarginBottomClass ? (isCheckbox ? "mb-2" : "mb-3") : string.Empty;
return "<div class=\"" + (isCheckbox ? $"custom-checkbox custom-control {mb} form-check" : $"{mb}") + "\">" +
Environment.NewLine + innerHtml + Environment.NewLine +
"</div>";
@ -516,6 +518,11 @@ public class AbpInputTagHelperService : AbpTagHelperService<AbpInputTagHelper>
return inputTag.Attributes.Any(a => a.Name.ToLowerInvariant() == "type" && a.Value.ToString()!.ToLowerInvariant() == "hidden");
}
protected virtual bool IsInputHidden(TagHelperContext context)
{
return context.Items.TryGetValue(nameof(IsOutputHidden), out var val) && val is true;
}
protected virtual string GetIdAttributeValue(TagHelperOutput inputTag)
{
var idAttr = inputTag.Attributes.FirstOrDefault(a => a.Name == "id");

59
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Validation/AbpValidationActionFilter.cs

@ -1,4 +1,5 @@
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
@ -39,27 +40,55 @@ public class AbpValidationActionFilter : IAsyncActionFilter, IAbpFilter, ITransi
return;
}
if (context.ActionDescriptor.GetMethodInfo().DeclaringType != context.Controller.GetType())
var effectiveMethod = GetEffectiveMethodInfo(context);
if (effectiveMethod != null)
{
var baseMethod = context.ActionDescriptor.GetMethodInfo();
var overrideMethod = context.Controller.GetType().GetMethods().FirstOrDefault(x =>
x.DeclaringType == context.Controller.GetType() &&
x.Name == baseMethod.Name &&
x.ReturnType == baseMethod.ReturnType &&
x.GetParameters().Select(p => p.ToString()).SequenceEqual(baseMethod.GetParameters().Select(p => p.ToString())));
if (overrideMethod != null)
if (ReflectionHelper.GetSingleAttributeOfMemberOrDeclaringTypeOrDefault<DisableValidationAttribute>(effectiveMethod) != null)
{
if (ReflectionHelper.GetSingleAttributeOfMemberOrDeclaringTypeOrDefault<DisableValidationAttribute>(overrideMethod) != null)
{
await next();
return;
}
await next();
return;
}
}
context.GetRequiredService<IModelStateValidator>().Validate(context.ModelState);
if (context.Controller is IValidationEnabled)
{
await ValidateActionArgumentsAsync(context, effectiveMethod);
}
await next();
}
protected virtual MethodInfo? GetEffectiveMethodInfo(ActionExecutingContext context)
{
var baseMethod = context.ActionDescriptor.GetMethodInfo();
if (baseMethod.DeclaringType == context.Controller.GetType())
{
return null;
}
return context.Controller.GetType().GetMethods().FirstOrDefault(x =>
x.DeclaringType == context.Controller.GetType() &&
x.Name == baseMethod.Name &&
x.ReturnType == baseMethod.ReturnType &&
x.GetParameters().Select(p => p.ToString()).SequenceEqual(baseMethod.GetParameters().Select(p => p.ToString())));
}
protected virtual async Task ValidateActionArgumentsAsync(ActionExecutingContext context, MethodInfo? effectiveMethod = null)
{
var methodInfo = effectiveMethod ?? context.ActionDescriptor.GetMethodInfo();
var parameterValues = methodInfo.GetParameters()
.Select(p => context.ActionArguments.TryGetValue(p.Name!, out var value) ? value : null)
.ToArray();
await context.GetRequiredService<IMethodInvocationValidator>().ValidateAsync(
new MethodInvocationValidationContext(
context.Controller,
methodInfo,
parameterValues
)
);
}
}

4
framework/src/Volo.Abp.BackgroundJobs.RabbitMQ/Volo/Abp/BackgroundJobs/RabbitMQ/JobQueue.cs

@ -176,10 +176,10 @@ public class JobQueue<TArgs> : IJobQueue<TArgs>
CorrelationId = CorrelationIdProvider.Get()
};
if (delay.HasValue)
if (delay.HasValue && delay.Value > TimeSpan.Zero)
{
routingKey = QueueConfiguration.DelayedQueueName;
basicProperties.Expiration = delay.Value.TotalMilliseconds.ToString(CultureInfo.InvariantCulture);
basicProperties.Expiration = ((long)Math.Ceiling(delay.Value.TotalMilliseconds)).ToString(CultureInfo.InvariantCulture);
}
if (ChannelAccessor != null)

18
framework/src/Volo.Abp.BackgroundJobs.TickerQ/Volo/Abp/BackgroundJobs/TickerQ/AbpBackgroundJobsTickerQModule.cs

@ -23,26 +23,14 @@ public class AbpBackgroundJobsTickerQModule : AbpModule
{
var abpBackgroundJobOptions = context.ServiceProvider.GetRequiredService<IOptions<AbpBackgroundJobOptions>>();
var abpBackgroundJobsTickerQOptions = context.ServiceProvider.GetRequiredService<IOptions<AbpBackgroundJobsTickerQOptions>>();
var tickerFunctionDelegates = new Dictionary<string, (string, TickerTaskPriority, TickerFunctionDelegate)>();
var requestTypes = new Dictionary<string, (string, Type)>();
var abpTickerQFunctionProvider = context.ServiceProvider.GetRequiredService<AbpTickerQFunctionProvider>();
foreach (var jobConfiguration in abpBackgroundJobOptions.Value.GetJobs())
{
var genericMethod = GetTickerFunctionDelegateMethod.MakeGenericMethod(jobConfiguration.ArgsType);
var tickerFunctionDelegate = (TickerFunctionDelegate)genericMethod.Invoke(null, [jobConfiguration.ArgsType])!;
var config = abpBackgroundJobsTickerQOptions.Value.GetConfigurationOrNull(jobConfiguration.JobType);
tickerFunctionDelegates.TryAdd(jobConfiguration.JobName, (string.Empty, config?.Priority ?? TickerTaskPriority.Normal, tickerFunctionDelegate));
requestTypes.TryAdd(jobConfiguration.JobName, (jobConfiguration.ArgsType.FullName, jobConfiguration.ArgsType)!);
}
var abpTickerQFunctionProvider = context.ServiceProvider.GetRequiredService<AbpTickerQFunctionProvider>();
foreach (var functionDelegate in tickerFunctionDelegates)
{
abpTickerQFunctionProvider.Functions.TryAdd(functionDelegate.Key, functionDelegate.Value);
}
foreach (var requestType in requestTypes)
{
abpTickerQFunctionProvider.RequestTypes.TryAdd(requestType.Key, requestType.Value);
abpTickerQFunctionProvider.AddFunction(jobConfiguration.JobName, tickerFunctionDelegate, config?.Priority ?? TickerTaskPriority.Normal, config?.MaxConcurrency ?? 0);
abpTickerQFunctionProvider.RequestTypes.TryAdd(jobConfiguration.JobName, (jobConfiguration.ArgsType.FullName, jobConfiguration.ArgsType)!);
}
}

2
framework/src/Volo.Abp.BackgroundJobs.TickerQ/Volo/Abp/BackgroundJobs/TickerQ/AbpBackgroundJobsTimeTickerConfiguration.cs

@ -10,5 +10,7 @@ public class AbpBackgroundJobsTimeTickerConfiguration
public TickerTaskPriority? Priority { get; set; }
public int? MaxConcurrency { get; set; }
public RunCondition? RunCondition { get; set; }
}

2
framework/src/Volo.Abp.BackgroundWorkers.TickerQ/Volo/Abp/BackgroundWorkers/TickerQ/AbpBackgroundWorkersCronTickerConfiguration.cs

@ -9,4 +9,6 @@ public class AbpBackgroundWorkersCronTickerConfiguration
public int[]? RetryIntervals { get; set; }
public TickerTaskPriority? Priority { get; set; }
public int? MaxConcurrency { get; set; }
}

4
framework/src/Volo.Abp.BackgroundWorkers.TickerQ/Volo/Abp/BackgroundWorkers/TickerQ/AbpTickerQBackgroundWorkerManager.cs

@ -62,11 +62,11 @@ public class AbpTickerQBackgroundWorkerManager : BackgroundWorkerManager, ISingl
var name = BackgroundWorkerNameAttribute.GetNameOrNull(worker.GetType()) ?? worker.GetType().FullName;
var config = Options.GetConfigurationOrNull(ProxyHelper.GetUnProxiedType(worker));
AbpTickerQFunctionProvider.Functions.TryAdd(name!, (string.Empty, config?.Priority ?? TickerTaskPriority.LongRunning, async (tickerQCancellationToken, serviceProvider, tickerFunctionContext) =>
AbpTickerQFunctionProvider.AddFunction(name!, async (tickerQCancellationToken, serviceProvider, tickerFunctionContext) =>
{
var workerInvoker = new AbpTickerQPeriodicBackgroundWorkerInvoker(worker, serviceProvider);
await workerInvoker.DoWorkAsync(tickerFunctionContext, tickerQCancellationToken);
}));
}, config?.Priority ?? TickerTaskPriority.LongRunning, config?.MaxConcurrency ?? 0);
AbpTickerQBackgroundWorkersProvider.BackgroundWorkers.Add(name!, new AbpTickerQCronBackgroundWorker
{

1
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/AbpCliCoreModule.cs

@ -80,6 +80,7 @@ public class AbpCliCoreModule : AbpModule
options.Commands[RecreateInitialMigrationCommand.Name] = typeof(RecreateInitialMigrationCommand);
options.Commands[GenerateRazorPage.Name] = typeof(GenerateRazorPage);
options.Commands[McpCommand.Name] = typeof(McpCommand);
options.Commands[GenerateJwksCommand.Name] = typeof(GenerateJwksCommand);
options.DisabledModulesToAddToSolution.Add("Volo.Abp.LeptonXTheme.Pro");
options.DisabledModulesToAddToSolution.Add("Volo.Abp.LeptonXTheme.Lite");

173
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/GenerateJwksCommand.cs

@ -0,0 +1,173 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Volo.Abp.Cli.Args;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.Cli.Commands;
public class GenerateJwksCommand : IConsoleCommand, ITransientDependency
{
public const string Name = "generate-jwks";
public ILogger<GenerateJwksCommand> Logger { get; set; }
public GenerateJwksCommand()
{
Logger = NullLogger<GenerateJwksCommand>.Instance;
}
public Task ExecuteAsync(CommandLineArgs commandLineArgs)
{
var outputDir = commandLineArgs.Options.GetOrNull("output", "o")
?? Directory.GetCurrentDirectory();
var keySizeStr = commandLineArgs.Options.GetOrNull("key-size", "s") ?? "2048";
var alg = commandLineArgs.Options.GetOrNull("alg") ?? "RS256";
var kid = commandLineArgs.Options.GetOrNull("kid") ?? Guid.NewGuid().ToString("N");
var filePrefix = commandLineArgs.Options.GetOrNull("file", "f") ?? "jwks";
if (!int.TryParse(keySizeStr, out var keySize) || (keySize != 2048 && keySize != 4096))
{
Logger.LogError("Invalid key size '{0}'. Supported values: 2048, 4096.", keySizeStr);
return Task.CompletedTask;
}
if (!IsValidAlgorithm(alg))
{
Logger.LogError("Invalid algorithm '{0}'. Supported values: RS256, RS384, RS512, PS256, PS384, PS512.", alg);
return Task.CompletedTask;
}
if (!Directory.Exists(outputDir))
{
Directory.CreateDirectory(outputDir);
}
Logger.LogInformation("Generating RSA {0}-bit key pair (algorithm: {1})...", keySize, alg);
using var rsa = RSA.Create();
rsa.KeySize = keySize;
var jwksJson = BuildJwksJson(rsa, alg, kid);
var privateKeyPem = ExportPrivateKeyPem(rsa);
var jwksFilePath = Path.Combine(outputDir, $"{filePrefix}.json");
var privateKeyFilePath = Path.Combine(outputDir, $"{filePrefix}-private.pem");
File.WriteAllText(jwksFilePath, jwksJson, Encoding.UTF8);
File.WriteAllText(privateKeyFilePath, privateKeyPem, Encoding.UTF8);
Logger.LogInformation("");
Logger.LogInformation("Generated files:");
Logger.LogInformation(" JWKS (public key) : {0}", jwksFilePath);
Logger.LogInformation(" Private key (PEM) : {0}", privateKeyFilePath);
Logger.LogInformation("");
Logger.LogInformation("JWKS content (paste this into the ABP OpenIddict application's 'JSON Web Key Set' field):");
Logger.LogInformation("");
Logger.LogInformation("{0}", jwksJson);
Logger.LogInformation("");
Logger.LogInformation("IMPORTANT: Keep the private key file safe. Never share it or commit it to source control.");
Logger.LogInformation(" The JWKS file contains only the public key and is safe to share.");
return Task.CompletedTask;
}
private static string BuildJwksJson(RSA rsa, string alg, string kid)
{
var parameters = rsa.ExportParameters(false);
var n = Base64UrlEncode(parameters.Modulus);
var e = Base64UrlEncode(parameters.Exponent);
using var stream = new System.IO.MemoryStream();
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
writer.WriteStartObject();
writer.WriteStartArray("keys");
writer.WriteStartObject();
writer.WriteString("kty", "RSA");
writer.WriteString("use", "sig");
writer.WriteString("kid", kid);
writer.WriteString("alg", alg);
writer.WriteString("n", n);
writer.WriteString("e", e);
writer.WriteEndObject();
writer.WriteEndArray();
writer.WriteEndObject();
writer.Flush();
return Encoding.UTF8.GetString(stream.ToArray());
}
private static string ExportPrivateKeyPem(RSA rsa)
{
#if NET5_0_OR_GREATER
return rsa.ExportPkcs8PrivateKeyPem();
#elif NETSTANDARD2_0
// RSA.ExportPkcs8PrivateKey() was introduced in .NET Standard 2.1.
// The ABP CLI always runs on .NET 5+, so this path is never reached at runtime.
throw new PlatformNotSupportedException("Private key export requires .NET Standard 2.1 or later.");
#else
var privateKeyBytes = rsa.ExportPkcs8PrivateKey();
var base64 = Convert.ToBase64String(privateKeyBytes, Base64FormattingOptions.InsertLineBreaks);
return $"-----BEGIN PRIVATE KEY-----\n{base64}\n-----END PRIVATE KEY-----";
#endif
}
private static string Base64UrlEncode(byte[] input)
{
return Convert.ToBase64String(input)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static bool IsValidAlgorithm(string alg)
{
return alg == "RS256" || alg == "RS384" || alg == "RS512" ||
alg == "PS256" || alg == "PS384" || alg == "PS512";
}
public string GetUsageInfo()
{
var sb = new StringBuilder();
sb.AppendLine("");
sb.AppendLine("Usage:");
sb.AppendLine(" abp generate-jwks [options]");
sb.AppendLine("");
sb.AppendLine("Options:");
sb.AppendLine(" -o|--output <dir> Output directory (default: current directory)");
sb.AppendLine(" -s|--key-size <size> RSA key size: 2048 or 4096 (default: 2048)");
sb.AppendLine(" --alg <alg> Algorithm: RS256, RS384, RS512, PS256, PS384, PS512 (default: RS256)");
sb.AppendLine(" --kid <id> Key ID (kid) - auto-generated if not specified");
sb.AppendLine(" -f|--file <prefix> Output file name prefix (default: jwks)");
sb.AppendLine(" Generates: <prefix>.json (JWKS) and <prefix>-private.pem (private key)");
sb.AppendLine("");
sb.AppendLine("Examples:");
sb.AppendLine(" abp generate-jwks");
sb.AppendLine(" abp generate-jwks --alg RS512 --key-size 4096");
sb.AppendLine(" abp generate-jwks -o ./keys -f myapp");
sb.AppendLine("");
sb.AppendLine("Description:");
sb.AppendLine(" Generates an RSA key pair for use with OpenIddict private_key_jwt client authentication.");
sb.AppendLine(" The JWKS file (public key) should be pasted into the ABP OpenIddict application's");
sb.AppendLine(" 'JSON Web Key Set' field in the management UI.");
sb.AppendLine(" The private key PEM file should be kept secure and used by the client application");
sb.AppendLine(" to sign JWT assertions when authenticating to the token endpoint.");
sb.AppendLine("");
sb.AppendLine("See the documentation for more info: https://abp.io/docs/latest/cli");
return sb.ToString();
}
public static string GetShortDescription()
{
return "Generates an RSA key pair (JWKS + private key) for OpenIddict private_key_jwt authentication.";
}
}

88
framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheBase.cs

@ -1,4 +1,6 @@
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Volo.Abp.Caching;
using Volo.Abp.Data;
using Volo.Abp.Domain.Entities.Events;
@ -14,6 +16,7 @@ public abstract class EntityCacheBase<TEntity, TEntityCacheItem, TKey> :
ILocalEventHandler<EntityChangedEventData<TEntity>>
where TEntity : Entity<TKey>
where TEntityCacheItem : class
where TKey : notnull
{
protected IReadOnlyRepository<TEntity, TKey> Repository { get; }
protected IDistributedCache<EntityCacheItemWrapper<TEntityCacheItem>, TKey> Cache { get; }
@ -44,6 +47,20 @@ public abstract class EntityCacheBase<TEntity, TEntityCacheItem, TKey> :
}))?.Value;
}
public virtual async Task<List<TEntityCacheItem?>> FindManyAsync(IEnumerable<TKey> ids)
{
var idArray = ids.ToArray();
var cacheItemDict = await GetCacheItemDictionaryAsync(idArray.Distinct().ToArray());
return idArray
.Select(id => cacheItemDict.TryGetValue(id, out var item) ? item : null)
.ToList();
}
public virtual async Task<Dictionary<TKey, TEntityCacheItem?>> FindManyAsDictionaryAsync(IEnumerable<TKey> ids)
{
return await GetCacheItemDictionaryAsync(ids.Distinct().ToArray());
}
public virtual async Task<TEntityCacheItem> GetAsync(TKey id)
{
return (await Cache.GetOrAddAsync(
@ -59,6 +76,75 @@ public abstract class EntityCacheBase<TEntity, TEntityCacheItem, TKey> :
}))!.Value!;
}
public virtual async Task<List<TEntityCacheItem>> GetManyAsync(IEnumerable<TKey> ids)
{
var idArray = ids.ToArray();
var cacheItemDict = await GetCacheItemDictionaryAsync(idArray.Distinct().ToArray());
return idArray
.Select(id =>
{
if (!cacheItemDict.TryGetValue(id, out var item) || item == null)
{
throw new EntityNotFoundException<TEntity>(id);
}
return item;
})
.ToList();
}
public virtual async Task<Dictionary<TKey, TEntityCacheItem>> GetManyAsDictionaryAsync(IEnumerable<TKey> ids)
{
var distinctIds = ids.Distinct().ToArray();
var cacheItemDict = await GetCacheItemDictionaryAsync(distinctIds);
var result = new Dictionary<TKey, TEntityCacheItem>();
foreach (var id in distinctIds)
{
if (!cacheItemDict.TryGetValue(id, out var item) || item == null)
{
throw new EntityNotFoundException<TEntity>(id);
}
result[id] = item;
}
return result;
}
protected virtual async Task<Dictionary<TKey, TEntityCacheItem?>> GetCacheItemDictionaryAsync(TKey[] distinctIds)
{
var cacheItems = await GetOrAddManyCacheItemsAsync(distinctIds);
return cacheItems.ToDictionary(x => x.Key, x => x.Value?.Value);
}
protected virtual async Task<KeyValuePair<TKey, EntityCacheItemWrapper<TEntityCacheItem>?>[]> GetOrAddManyCacheItemsAsync(TKey[] ids)
{
return await Cache.GetOrAddManyAsync(
ids,
async missingKeys =>
{
if (HasObjectExtensionInfo())
{
Repository.EnableTracking();
}
var missingKeyArray = missingKeys.ToArray();
var entities = await Repository.GetListAsync(
x => missingKeyArray.Contains(x.Id),
includeDetails: true
);
var entityDict = entities.ToDictionary(e => e.Id);
return missingKeyArray
.Select(key =>
{
entityDict.TryGetValue(key, out var entity);
return new KeyValuePair<TKey, EntityCacheItemWrapper<TEntityCacheItem>>(
key,
MapToCacheItem(entity)!
);
})
.ToList();
});
}
protected virtual bool HasObjectExtensionInfo()
{
return typeof(IHasExtraProperties).IsAssignableFrom(typeof(TEntity)) &&

30
framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheServiceCollectionExtensions.cs

@ -14,6 +14,7 @@ public static class EntityCacheServiceCollectionExtensions
this IServiceCollection services,
DistributedCacheEntryOptions? cacheOptions = null)
where TEntity : Entity<TKey>
where TKey : notnull
{
services.TryAddTransient<IEntityCache<TEntity, TKey>, EntityCacheWithoutCacheItem<TEntity, TKey>>();
services.TryAddTransient<EntityCacheWithoutCacheItem<TEntity, TKey>>();
@ -36,6 +37,7 @@ public static class EntityCacheServiceCollectionExtensions
DistributedCacheEntryOptions? cacheOptions = null)
where TEntity : Entity<TKey>
where TEntityCacheItem : class
where TKey : notnull
{
services.TryAddTransient<IEntityCache<TEntityCacheItem, TKey>, EntityCacheWithObjectMapper<TEntity, TEntityCacheItem, TKey>>();
services.TryAddTransient<EntityCacheWithObjectMapper<TEntity, TEntityCacheItem, TKey>>();
@ -53,6 +55,7 @@ public static class EntityCacheServiceCollectionExtensions
DistributedCacheEntryOptions? cacheOptions = null)
where TEntity : Entity<TKey>
where TEntityCacheItem : class
where TKey : notnull
{
services.TryAddTransient<IEntityCache<TEntityCacheItem, TKey>, EntityCacheWithObjectMapperContext<TObjectMapperContext, TEntity, TEntityCacheItem, TKey>>();
services.TryAddTransient<EntityCacheWithObjectMapperContext<TObjectMapperContext, TEntity, TEntityCacheItem, TKey>>();
@ -65,6 +68,33 @@ public static class EntityCacheServiceCollectionExtensions
return services;
}
public static IServiceCollection ReplaceEntityCache<TEntityCache, TEntity, TEntityCacheItem, TKey>(
this IServiceCollection services,
DistributedCacheEntryOptions? cacheOptions = null)
where TEntityCache : EntityCacheBase<TEntity, TEntityCacheItem, TKey>
where TEntity : Entity<TKey>
where TEntityCacheItem : class
where TKey : notnull
{
services.Replace(ServiceDescriptor.Transient<IEntityCache<TEntityCacheItem, TKey>, TEntityCache>());
services.TryAddTransient<TEntityCache>();
services.Configure<AbpDistributedCacheOptions>(options =>
{
options.ConfigureCache<EntityCacheItemWrapper<TEntityCacheItem>>(cacheOptions ?? GetDefaultCacheOptions());
});
if (typeof(TEntity) == typeof(TEntityCacheItem))
{
services.Configure<AbpSystemTextJsonSerializerModifiersOptions>(options =>
{
options.Modifiers.Add(new AbpIncludeNonPublicPropertiesModifiers<TEntity, TKey>().CreateModifyAction(x => x.Id));
});
}
return services;
}
private static DistributedCacheEntryOptions GetDefaultCacheOptions()
{
return new DistributedCacheEntryOptions {

10
framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapper.cs

@ -10,6 +10,7 @@ public class EntityCacheWithObjectMapper<TEntity, TEntityCacheItem, TKey> :
EntityCacheBase<TEntity, TEntityCacheItem, TKey>
where TEntity : Entity<TKey>
where TEntityCacheItem : class
where TKey : notnull
{
protected IObjectMapper ObjectMapper { get; }
@ -30,11 +31,16 @@ public class EntityCacheWithObjectMapper<TEntity, TEntityCacheItem, TKey> :
return null;
}
return new EntityCacheItemWrapper<TEntityCacheItem>(MapToValue(entity));
}
protected virtual TEntityCacheItem MapToValue(TEntity entity)
{
if (typeof(TEntity) == typeof(TEntityCacheItem))
{
return new EntityCacheItemWrapper<TEntityCacheItem>(entity.As<TEntityCacheItem>());
return entity.As<TEntityCacheItem>();
}
return new EntityCacheItemWrapper<TEntityCacheItem>(ObjectMapper.Map<TEntity, TEntityCacheItem>(entity));
return ObjectMapper.Map<TEntity, TEntityCacheItem>(entity);
}
}

1
framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapperContext.cs

@ -9,6 +9,7 @@ public class EntityCacheWithObjectMapperContext<TObjectMapperContext, TEntity, T
EntityCacheWithObjectMapper<TEntity, TEntityCacheItem, TKey>
where TEntity : Entity<TKey>
where TEntityCacheItem : class
where TKey : notnull
{
public EntityCacheWithObjectMapperContext(
IReadOnlyRepository<TEntity, TKey> repository,

1
framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithoutCacheItem.cs

@ -7,6 +7,7 @@ namespace Volo.Abp.Domain.Entities.Caching;
public class EntityCacheWithoutCacheItem<TEntity, TKey> :
EntityCacheBase<TEntity, TEntity, TKey>
where TEntity : Entity<TKey>
where TKey : notnull
{
public EntityCacheWithoutCacheItem(
IReadOnlyRepository<TEntity, TKey> repository,

38
framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/IEntityCache.cs

@ -1,21 +1,49 @@
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Threading.Tasks;
using JetBrains.Annotations;
namespace Volo.Abp.Domain.Entities.Caching;
public interface IEntityCache<TEntityCacheItem, in TKey>
public interface IEntityCache<TEntityCacheItem, TKey>
where TEntityCacheItem : class
where TKey : notnull
{
/// <summary>
/// Gets the entity with given <paramref name="id"/>,
/// or returns null if the entity was not found.
/// </summary>
Task<TEntityCacheItem?> FindAsync(TKey id);
/// <summary>
/// Gets multiple entities with the given <paramref name="ids"/>.
/// Returns a list where each entry corresponds to the given id in the same order.
/// An entry will be null if the entity was not found for the corresponding id.
/// </summary>
Task<List<TEntityCacheItem?>> FindManyAsync(IEnumerable<TKey> ids);
/// <summary>
/// Gets multiple entities with the given <paramref name="ids"/> as a dictionary keyed by id.
/// An entry will be null if the entity was not found for the corresponding id.
/// </summary>
Task<Dictionary<TKey, TEntityCacheItem?>> FindManyAsDictionaryAsync(IEnumerable<TKey> ids);
/// <summary>
/// Gets the entity with given <paramref name="id"/>,
/// or throws <see cref="EntityNotFoundException"/> if the entity was not found.
/// </summary>
[ItemNotNull]
[ItemNotNull]
Task<TEntityCacheItem> GetAsync(TKey id);
}
/// <summary>
/// Gets multiple entities with the given <paramref name="ids"/>.
/// Returns a list where each entry corresponds to the given id in the same order.
/// Throws <see cref="EntityNotFoundException"/> if any entity was not found.
/// </summary>
Task<List<TEntityCacheItem>> GetManyAsync(IEnumerable<TKey> ids);
/// <summary>
/// Gets multiple entities with the given <paramref name="ids"/> as a dictionary keyed by id.
/// Throws <see cref="EntityNotFoundException"/> if any entity was not found.
/// </summary>
Task<Dictionary<TKey, TEntityCacheItem>> GetManyAsDictionaryAsync(IEnumerable<TKey> ids);
}

3
framework/src/Volo.Abp.OperationRateLimiting/FodyWeavers.xml

@ -1,3 +0,0 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait ContinueOnCapturedContext="false" />
</Weavers>

32
framework/src/Volo.Abp.OperationRateLimiting/Volo.Abp.OperationRateLimiting.csproj

@ -1,32 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\configureawait.props" />
<Import Project="..\..\..\common.props" />
<PropertyGroup>
<TargetFrameworks>netstandard2.0;netstandard2.1;net8.0;net9.0;net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<WarningsAsErrors>Nullable</WarningsAsErrors>
<AssemblyName>Volo.Abp.OperationRateLimiting</AssemblyName>
<PackageId>Volo.Abp.OperationRateLimiting</PackageId>
<AssetTargetFallback>$(AssetTargetFallback);portable-net45+win8+wp8+wpa81;</AssetTargetFallback>
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
<RootNamespace />
</PropertyGroup>
<ItemGroup>
<None Remove="Volo\Abp\OperationRateLimiting\Localization\*.json" />
<EmbeddedResource Include="Volo\Abp\OperationRateLimiting\Localization\*.json" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Volo.Abp.AspNetCore.Abstractions\Volo.Abp.AspNetCore.Abstractions.csproj" />
<ProjectReference Include="..\Volo.Abp.Caching\Volo.Abp.Caching.csproj" />
<ProjectReference Include="..\Volo.Abp.DistributedLocking.Abstractions\Volo.Abp.DistributedLocking.Abstractions.csproj" />
<ProjectReference Include="..\Volo.Abp.Localization\Volo.Abp.Localization.csproj" />
<ProjectReference Include="..\Volo.Abp.Security\Volo.Abp.Security.csproj" />
</ItemGroup>
</Project>

14
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs

@ -1,14 +0,0 @@
namespace Volo.Abp.OperationRateLimiting;
public static class AbpOperationRateLimitingErrorCodes
{
/// <summary>
/// Default error code for rate limit exceeded (with a retry-after window).
/// </summary>
public const string ExceedLimit = "Volo.Abp.OperationRateLimiting:010001";
/// <summary>
/// Error code for ban policy (maxCount: 0) where requests are permanently denied.
/// </summary>
public const string ExceedLimitPermanently = "Volo.Abp.OperationRateLimiting:010002";
}

42
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingModule.cs

@ -1,42 +0,0 @@
using Volo.Abp.AspNetCore;
using Volo.Abp.Caching;
using Volo.Abp.DistributedLocking;
using Volo.Abp.Localization;
using Volo.Abp.Localization.ExceptionHandling;
using Volo.Abp.Modularity;
using Volo.Abp.Security;
using Volo.Abp.VirtualFileSystem;
namespace Volo.Abp.OperationRateLimiting;
[DependsOn(
typeof(AbpCachingModule),
typeof(AbpLocalizationModule),
typeof(AbpSecurityModule),
typeof(AbpAspNetCoreAbstractionsModule),
typeof(AbpDistributedLockingAbstractionsModule)
)]
public class AbpOperationRateLimitingModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<AbpVirtualFileSystemOptions>(options =>
{
options.FileSets.AddEmbedded<AbpOperationRateLimitingModule>();
});
Configure<AbpLocalizationOptions>(options =>
{
options.Resources
.Add<AbpOperationRateLimitingResource>("en")
.AddVirtualJson("/Volo/Abp/OperationRateLimiting/Localization");
});
Configure<AbpExceptionLocalizationOptions>(options =>
{
options.MapCodeNamespace(
"Volo.Abp.OperationRateLimiting",
typeof(AbpOperationRateLimitingResource));
});
}
}

20
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs

@ -1,20 +0,0 @@
using System;
using System.Collections.Generic;
namespace Volo.Abp.OperationRateLimiting;
public class AbpOperationRateLimitingOptions
{
public bool IsEnabled { get; set; } = true;
public TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(5);
public Dictionary<string, OperationRateLimitingPolicy> Policies { get; } = new();
public void AddPolicy(string name, Action<OperationRateLimitingPolicyBuilder> configure)
{
var builder = new OperationRateLimitingPolicyBuilder(name);
configure(builder);
Policies[name] = builder.Build();
}
}

8
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingResource.cs

@ -1,8 +0,0 @@
using Volo.Abp.Localization;
namespace Volo.Abp.OperationRateLimiting;
[LocalizationResourceName("AbpOperationRateLimiting")]
public class AbpOperationRateLimitingResource
{
}

14
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/IOperationRateLimitingChecker.cs

@ -1,14 +0,0 @@
using System.Threading.Tasks;
namespace Volo.Abp.OperationRateLimiting;
public interface IOperationRateLimitingChecker
{
Task CheckAsync(string policyName, OperationRateLimitingContext? context = null);
Task<bool> IsAllowedAsync(string policyName, OperationRateLimitingContext? context = null);
Task<OperationRateLimitingResult> GetStatusAsync(string policyName, OperationRateLimitingContext? context = null);
Task ResetAsync(string policyName, OperationRateLimitingContext? context = null);
}

277
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs

@ -1,277 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Volo.Abp.AspNetCore.WebClientInfo;
using Volo.Abp.DependencyInjection;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Users;
namespace Volo.Abp.OperationRateLimiting;
public class OperationRateLimitingChecker : IOperationRateLimitingChecker, ITransientDependency
{
protected AbpOperationRateLimitingOptions Options { get; }
protected IOperationRateLimitingPolicyProvider PolicyProvider { get; }
protected IServiceProvider ServiceProvider { get; }
protected IOperationRateLimitingStore Store { get; }
protected ICurrentUser CurrentUser { get; }
protected ICurrentTenant CurrentTenant { get; }
protected IWebClientInfoProvider WebClientInfoProvider { get; }
public OperationRateLimitingChecker(
IOptions<AbpOperationRateLimitingOptions> options,
IOperationRateLimitingPolicyProvider policyProvider,
IServiceProvider serviceProvider,
IOperationRateLimitingStore store,
ICurrentUser currentUser,
ICurrentTenant currentTenant,
IWebClientInfoProvider webClientInfoProvider)
{
Options = options.Value;
PolicyProvider = policyProvider;
ServiceProvider = serviceProvider;
Store = store;
CurrentUser = currentUser;
CurrentTenant = currentTenant;
WebClientInfoProvider = webClientInfoProvider;
}
public virtual async Task CheckAsync(string policyName, OperationRateLimitingContext? context = null)
{
if (!Options.IsEnabled)
{
return;
}
context = EnsureContext(context);
var policy = await PolicyProvider.GetAsync(policyName);
var rules = CreateRules(policy);
// Phase 1: Check ALL rules without incrementing to get complete status.
// Do not exit early: a later rule may have a larger RetryAfter that the caller needs to know about.
var checkResults = new List<OperationRateLimitingRuleResult>();
foreach (var rule in rules)
{
checkResults.Add(await rule.CheckAsync(context));
}
if (checkResults.Any(r => !r.IsAllowed))
{
// Throw without incrementing any counter; RetryAfter is the max across all blocking rules.
var aggregatedResult = AggregateResults(checkResults, policy);
ThrowRateLimitException(policy, aggregatedResult, context);
}
// Phase 2: All rules passed in Phase 1 - now increment counters.
// Guard against concurrent races where another request consumed the last quota
// between Phase 1 and Phase 2.
// Once any rule fails during increment, stop incrementing subsequent rules
// to minimize wasted quota. Remaining rules use read-only check instead.
var incrementResults = new List<OperationRateLimitingRuleResult>();
var phase2Failed = false;
foreach (var rule in rules)
{
if (phase2Failed)
{
incrementResults.Add(await rule.CheckAsync(context));
}
else
{
var result = await rule.AcquireAsync(context);
incrementResults.Add(result);
if (!result.IsAllowed)
{
phase2Failed = true;
}
}
}
if (phase2Failed)
{
var aggregatedResult = AggregateResults(incrementResults, policy);
ThrowRateLimitException(policy, aggregatedResult, context);
}
}
public virtual async Task<bool> IsAllowedAsync(string policyName, OperationRateLimitingContext? context = null)
{
if (!Options.IsEnabled)
{
return true;
}
context = EnsureContext(context);
var policy = await PolicyProvider.GetAsync(policyName);
var rules = CreateRules(policy);
foreach (var rule in rules)
{
var result = await rule.CheckAsync(context);
if (!result.IsAllowed)
{
return false;
}
}
return true;
}
public virtual async Task<OperationRateLimitingResult> GetStatusAsync(string policyName, OperationRateLimitingContext? context = null)
{
if (!Options.IsEnabled)
{
return new OperationRateLimitingResult
{
IsAllowed = true,
RemainingCount = int.MaxValue,
MaxCount = int.MaxValue,
CurrentCount = 0
};
}
context = EnsureContext(context);
var policy = await PolicyProvider.GetAsync(policyName);
var rules = CreateRules(policy);
var ruleResults = new List<OperationRateLimitingRuleResult>();
foreach (var rule in rules)
{
ruleResults.Add(await rule.CheckAsync(context));
}
return AggregateResults(ruleResults, policy);
}
public virtual async Task ResetAsync(string policyName, OperationRateLimitingContext? context = null)
{
if (!Options.IsEnabled)
{
return;
}
context = EnsureContext(context);
var policy = await PolicyProvider.GetAsync(policyName);
var rules = CreateRules(policy);
foreach (var rule in rules)
{
await rule.ResetAsync(context);
}
}
protected virtual OperationRateLimitingContext EnsureContext(OperationRateLimitingContext? context)
{
context ??= new OperationRateLimitingContext();
context.ServiceProvider = ServiceProvider;
return context;
}
protected virtual List<IOperationRateLimitingRule> CreateRules(OperationRateLimitingPolicy policy)
{
var rules = new List<IOperationRateLimitingRule>();
foreach (var ruleDefinition in policy.Rules)
{
rules.Add(new FixedWindowOperationRateLimitingRule(
policy.Name,
ruleDefinition,
Store,
CurrentUser,
CurrentTenant,
WebClientInfoProvider));
}
foreach (var customRuleType in policy.CustomRuleTypes)
{
rules.Add((IOperationRateLimitingRule)ServiceProvider.GetRequiredService(customRuleType));
}
return rules;
}
protected virtual OperationRateLimitingResult AggregateResults(
List<OperationRateLimitingRuleResult> ruleResults,
OperationRateLimitingPolicy policy)
{
var isAllowed = ruleResults.All(r => r.IsAllowed);
var mostRestrictive = ruleResults
.OrderBy(r => r.RemainingCount)
.ThenByDescending(r => r.RetryAfter ?? TimeSpan.Zero)
.First();
return new OperationRateLimitingResult
{
IsAllowed = isAllowed,
RemainingCount = mostRestrictive.RemainingCount,
MaxCount = mostRestrictive.MaxCount,
CurrentCount = mostRestrictive.CurrentCount,
RetryAfter = ruleResults.Any(r => !r.IsAllowed && r.RetryAfter.HasValue)
? ruleResults
.Where(r => !r.IsAllowed && r.RetryAfter.HasValue)
.Select(r => r.RetryAfter!.Value)
.Max()
: null,
WindowDuration = mostRestrictive.WindowDuration,
RuleResults = ruleResults
};
}
protected virtual void ThrowRateLimitException(
OperationRateLimitingPolicy policy,
OperationRateLimitingResult result,
OperationRateLimitingContext context)
{
var formatter = context.ServiceProvider.GetRequiredService<IOperationRateLimitingFormatter>();
var exception = new AbpOperationRateLimitingException(
policy.Name,
result,
policy.ErrorCode);
if (result.RetryAfter.HasValue)
{
exception.SetRetryAfterFormatted(formatter.Format(result.RetryAfter.Value));
}
if (result.WindowDuration > TimeSpan.Zero)
{
exception.SetWindowDescriptionFormatted(formatter.Format(result.WindowDuration));
}
if (result.RuleResults != null)
{
var ruleDetails = new List<Dictionary<string, object>>();
foreach (var ruleResult in result.RuleResults)
{
ruleDetails.Add(new Dictionary<string, object>
{
["RuleName"] = ruleResult.RuleName,
["IsAllowed"] = ruleResult.IsAllowed,
["MaxCount"] = ruleResult.MaxCount,
["RemainingCount"] = ruleResult.RemainingCount,
["CurrentCount"] = ruleResult.CurrentCount,
["WindowDurationSeconds"] = (int)ruleResult.WindowDuration.TotalSeconds,
["WindowDescription"] = ruleResult.WindowDuration > TimeSpan.Zero
? formatter.Format(ruleResult.WindowDuration)
: string.Empty,
["RetryAfterSeconds"] = (int)(ruleResult.RetryAfter?.TotalSeconds ?? 0),
["RetryAfter"] = ruleResult.RetryAfter.HasValue
? formatter.Format(ruleResult.RetryAfter.Value)
: string.Empty
});
}
exception.WithData("RuleDetails", ruleDetails);
}
foreach (var kvp in context.ExtraProperties)
{
exception.WithData(kvp.Key, kvp.Value!);
}
throw exception;
}
}

38
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingCheckerExtensions.cs

@ -1,38 +0,0 @@
using System.Threading.Tasks;
namespace Volo.Abp.OperationRateLimiting;
public static class OperationRateLimitingCheckerExtensions
{
public static Task CheckAsync(
this IOperationRateLimitingChecker checker,
string policyName,
string parameter)
{
return checker.CheckAsync(policyName, new OperationRateLimitingContext { Parameter = parameter });
}
public static Task<bool> IsAllowedAsync(
this IOperationRateLimitingChecker checker,
string policyName,
string parameter)
{
return checker.IsAllowedAsync(policyName, new OperationRateLimitingContext { Parameter = parameter });
}
public static Task<OperationRateLimitingResult> GetStatusAsync(
this IOperationRateLimitingChecker checker,
string policyName,
string parameter)
{
return checker.GetStatusAsync(policyName, new OperationRateLimitingContext { Parameter = parameter });
}
public static Task ResetAsync(
this IOperationRateLimitingChecker checker,
string policyName,
string parameter)
{
return checker.ResetAsync(policyName, new OperationRateLimitingContext { Parameter = parameter });
}
}

33
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingContext.cs

@ -1,33 +0,0 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.DependencyInjection;
namespace Volo.Abp.OperationRateLimiting;
public class OperationRateLimitingContext
{
/// <summary>
/// Optional parameter passed by the caller.
/// Used as the partition key by PartitionByParameter() (required),
/// and as a fallback by PartitionByEmail() and PartitionByPhoneNumber().
/// Can be email, phone number, user id, resource id, or any string.
/// </summary>
public string? Parameter { get; set; }
/// <summary>
/// Additional properties that can be read by custom <see cref="IOperationRateLimitingRule"/> implementations
/// and are forwarded to the exception's Data dictionary when the rate limit is exceeded.
/// </summary>
public Dictionary<string, object?> ExtraProperties { get; set; } = new();
/// <summary>
/// The service provider for resolving services.
/// Set automatically by the checker.
/// </summary>
public IServiceProvider ServiceProvider { get; set; } = default!;
public T GetRequiredService<T>() where T : notnull
=> ServiceProvider.GetRequiredService<T>();
public T? GetService<T>() => ServiceProvider.GetService<T>();
}

24
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingResult.cs

@ -1,24 +0,0 @@
using System;
using System.Collections.Generic;
namespace Volo.Abp.OperationRateLimiting;
public class OperationRateLimitingResult
{
public bool IsAllowed { get; set; }
public int RemainingCount { get; set; }
public int MaxCount { get; set; }
public int CurrentCount { get; set; }
public TimeSpan? RetryAfter { get; set; }
public TimeSpan WindowDuration { get; set; }
/// <summary>
/// Detailed results per rule (for composite policies).
/// </summary>
public List<OperationRateLimitingRuleResult>? RuleResults { get; set; }
}

20
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingRuleResult.cs

@ -1,20 +0,0 @@
using System;
namespace Volo.Abp.OperationRateLimiting;
public class OperationRateLimitingRuleResult
{
public string RuleName { get; set; } = default!;
public bool IsAllowed { get; set; }
public int CurrentCount { get; set; }
public int RemainingCount { get; set; }
public int MaxCount { get; set; }
public TimeSpan? RetryAfter { get; set; }
public TimeSpan WindowDuration { get; set; }
}

48
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Exceptions/AbpOperationRateLimitingException.cs

@ -1,48 +0,0 @@
using System;
using Volo.Abp.ExceptionHandling;
namespace Volo.Abp.OperationRateLimiting;
public class AbpOperationRateLimitingException : BusinessException, IHasHttpStatusCode
{
public string PolicyName { get; }
public OperationRateLimitingResult Result { get; }
public int HttpStatusCode => 429;
public AbpOperationRateLimitingException(
string policyName,
OperationRateLimitingResult result,
string? errorCode = null)
: base(code: errorCode ?? ResolveDefaultErrorCode(result))
{
PolicyName = policyName;
Result = result;
WithData("PolicyName", policyName);
WithData("MaxCount", result.MaxCount);
WithData("CurrentCount", result.CurrentCount);
WithData("RemainingCount", result.RemainingCount);
WithData("RetryAfterSeconds", (int)(result.RetryAfter?.TotalSeconds ?? 0));
WithData("RetryAfterMinutes", (int)(result.RetryAfter?.TotalMinutes ?? 0));
WithData("WindowDurationSeconds", (int)result.WindowDuration.TotalSeconds);
}
internal void SetRetryAfterFormatted(string formattedRetryAfter)
{
WithData("RetryAfter", formattedRetryAfter);
}
internal void SetWindowDescriptionFormatted(string formattedWindowDescription)
{
WithData("WindowDescription", formattedWindowDescription);
}
private static string ResolveDefaultErrorCode(OperationRateLimitingResult result)
{
return result.RetryAfter.HasValue
? AbpOperationRateLimitingErrorCodes.ExceedLimit
: AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently;
}
}

68
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/DefaultOperationRateLimitingFormatter.cs

@ -1,68 +0,0 @@
using System;
using Microsoft.Extensions.Localization;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.OperationRateLimiting;
public class DefaultOperationRateLimitingFormatter
: IOperationRateLimitingFormatter, ITransientDependency
{
protected IStringLocalizer<AbpOperationRateLimitingResource> Localizer { get; }
public DefaultOperationRateLimitingFormatter(
IStringLocalizer<AbpOperationRateLimitingResource> localizer)
{
Localizer = localizer;
}
public virtual string Format(TimeSpan duration)
{
if (duration.TotalDays >= 365)
{
var years = (int)(duration.TotalDays / 365);
var remainingDays = (int)(duration.TotalDays % 365);
var months = remainingDays / 30;
return months > 0
? Localizer["RetryAfter:YearsAndMonths", years, months]
: Localizer["RetryAfter:Years", years];
}
if (duration.TotalDays >= 30)
{
var months = (int)(duration.TotalDays / 30);
var remainingDays = (int)(duration.TotalDays % 30);
return remainingDays > 0
? Localizer["RetryAfter:MonthsAndDays", months, remainingDays]
: Localizer["RetryAfter:Months", months];
}
if (duration.TotalDays >= 1)
{
var days = (int)duration.TotalDays;
var hours = duration.Hours;
return hours > 0
? Localizer["RetryAfter:DaysAndHours", days, hours]
: Localizer["RetryAfter:Days", days];
}
if (duration.TotalHours >= 1)
{
var hours = (int)duration.TotalHours;
var minutes = duration.Minutes;
return minutes > 0
? Localizer["RetryAfter:HoursAndMinutes", hours, minutes]
: Localizer["RetryAfter:Hours", hours];
}
if (duration.TotalMinutes >= 1)
{
var minutes = (int)duration.TotalMinutes;
var seconds = duration.Seconds;
return seconds > 0
? Localizer["RetryAfter:MinutesAndSeconds", minutes, seconds]
: Localizer["RetryAfter:Minutes", minutes];
}
return Localizer["RetryAfter:Seconds", (int)duration.TotalSeconds];
}
}

8
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/IOperationRateLimitingFormatter.cs

@ -1,8 +0,0 @@
using System;
namespace Volo.Abp.OperationRateLimiting;
public interface IOperationRateLimitingFormatter
{
string Format(TimeSpan duration);
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ar.json

@ -1,18 +0,0 @@
{
"culture": "ar",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "تم تجاوز حد معدل العملية. يمكنك المحاولة مرة أخرى بعد {RetryAfter}.",
"RetryAfter:Years": "{0} سنة/سنوات",
"RetryAfter:YearsAndMonths": "{0} سنة/سنوات و {1} شهر/أشهر",
"RetryAfter:Months": "{0} شهر/أشهر",
"RetryAfter:MonthsAndDays": "{0} شهر/أشهر و {1} يوم/أيام",
"RetryAfter:Days": "{0} يوم/أيام",
"RetryAfter:DaysAndHours": "{0} يوم/أيام و {1} ساعة/ساعات",
"RetryAfter:Hours": "{0} ساعة/ساعات",
"RetryAfter:HoursAndMinutes": "{0} ساعة/ساعات و {1} دقيقة/دقائق",
"RetryAfter:Minutes": "{0} دقيقة/دقائق",
"RetryAfter:MinutesAndSeconds": "{0} دقيقة/دقائق و {1} ثانية/ثوان",
"RetryAfter:Seconds": "{0} ثانية/ثوان",
"Volo.Abp.OperationRateLimiting:010002": "تم تجاوز حد معدل العملية. هذا الطلب مرفوض بشكل دائم."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/cs.json

@ -1,18 +0,0 @@
{
"culture": "cs",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Překročen limit rychlosti operace. Můžete to zkusit znovu za {RetryAfter}.",
"RetryAfter:Years": "{0} rok(y/let)",
"RetryAfter:YearsAndMonths": "{0} rok(y/let) a {1} měsíc(e/ů)",
"RetryAfter:Months": "{0} měsíc(e/ů)",
"RetryAfter:MonthsAndDays": "{0} měsíc(e/ů) a {1} den/dny/dní",
"RetryAfter:Days": "{0} den/dny/dní",
"RetryAfter:DaysAndHours": "{0} den/dny/dní a {1} hodina/hodiny/hodin",
"RetryAfter:Hours": "{0} hodina/hodiny/hodin",
"RetryAfter:HoursAndMinutes": "{0} hodina/hodiny/hodin a {1} minuta/minuty/minut",
"RetryAfter:Minutes": "{0} minuta/minuty/minut",
"RetryAfter:MinutesAndSeconds": "{0} minuta/minuty/minut a {1} sekunda/sekundy/sekund",
"RetryAfter:Seconds": "{0} sekunda/sekundy/sekund",
"Volo.Abp.OperationRateLimiting:010002": "Byl překročen limit četnosti operace. Tento požadavek je trvale zamítnut."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/de.json

@ -1,18 +0,0 @@
{
"culture": "de",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Betriebsratenlimit überschritten. Sie können es nach {RetryAfter} erneut versuchen.",
"RetryAfter:Years": "{0} Jahr(e)",
"RetryAfter:YearsAndMonths": "{0} Jahr(e) und {1} Monat(e)",
"RetryAfter:Months": "{0} Monat(e)",
"RetryAfter:MonthsAndDays": "{0} Monat(e) und {1} Tag(e)",
"RetryAfter:Days": "{0} Tag(e)",
"RetryAfter:DaysAndHours": "{0} Tag(e) und {1} Stunde(n)",
"RetryAfter:Hours": "{0} Stunde(n)",
"RetryAfter:HoursAndMinutes": "{0} Stunde(n) und {1} Minute(n)",
"RetryAfter:Minutes": "{0} Minute(n)",
"RetryAfter:MinutesAndSeconds": "{0} Minute(n) und {1} Sekunde(n)",
"RetryAfter:Seconds": "{0} Sekunde(n)",
"Volo.Abp.OperationRateLimiting:010002": "Das Vorgangshäufigkeitslimit wurde überschritten. Diese Anfrage wird dauerhaft abgelehnt."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/el.json

@ -1,18 +0,0 @@
{
"culture": "el",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Υπέρβαση ορίου ρυθμού λειτουργίας. Μπορείτε να δοκιμάσετε ξανά μετά από {RetryAfter}.",
"RetryAfter:Years": "{0} έτος/η",
"RetryAfter:YearsAndMonths": "{0} έτος/η και {1} μήνας/ες",
"RetryAfter:Months": "{0} μήνας/ες",
"RetryAfter:MonthsAndDays": "{0} μήνας/ες και {1} ημέρα/ες",
"RetryAfter:Days": "{0} ημέρα/ες",
"RetryAfter:DaysAndHours": "{0} ημέρα/ες και {1} ώρα/ες",
"RetryAfter:Hours": "{0} ώρα/ες",
"RetryAfter:HoursAndMinutes": "{0} ώρα/ες και {1} λεπτό/ά",
"RetryAfter:Minutes": "{0} λεπτό/ά",
"RetryAfter:MinutesAndSeconds": "{0} λεπτό/ά και {1} δευτερόλεπτο/α",
"RetryAfter:Seconds": "{0} δευτερόλεπτο/α",
"Volo.Abp.OperationRateLimiting:010002": "Υπερβλήθηκε το όριο συχνότητας λειτουργίας. Αυτό το αίτημα απορρίπτεται μόνιμα."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en-GB.json

@ -1,18 +0,0 @@
{
"culture": "en-GB",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Operation rate limit exceeded. You can try again after {RetryAfter}.",
"RetryAfter:Years": "{0} year(s)",
"RetryAfter:YearsAndMonths": "{0} year(s) and {1} month(s)",
"RetryAfter:Months": "{0} month(s)",
"RetryAfter:MonthsAndDays": "{0} month(s) and {1} day(s)",
"RetryAfter:Days": "{0} day(s)",
"RetryAfter:DaysAndHours": "{0} day(s) and {1} hour(s)",
"RetryAfter:Hours": "{0} hour(s)",
"RetryAfter:HoursAndMinutes": "{0} hour(s) and {1} minute(s)",
"RetryAfter:Minutes": "{0} minute(s)",
"RetryAfter:MinutesAndSeconds": "{0} minute(s) and {1} second(s)",
"RetryAfter:Seconds": "{0} second(s)",
"Volo.Abp.OperationRateLimiting:010002": "Operation rate limit exceeded. This request is permanently denied."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en.json

@ -1,18 +0,0 @@
{
"culture": "en",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Operation rate limit exceeded. You can try again after {RetryAfter}.",
"RetryAfter:Years": "{0} year(s)",
"RetryAfter:YearsAndMonths": "{0} year(s) and {1} month(s)",
"RetryAfter:Months": "{0} month(s)",
"RetryAfter:MonthsAndDays": "{0} month(s) and {1} day(s)",
"RetryAfter:Days": "{0} day(s)",
"RetryAfter:DaysAndHours": "{0} day(s) and {1} hour(s)",
"RetryAfter:Hours": "{0} hour(s)",
"RetryAfter:HoursAndMinutes": "{0} hour(s) and {1} minute(s)",
"RetryAfter:Minutes": "{0} minute(s)",
"RetryAfter:MinutesAndSeconds": "{0} minute(s) and {1} second(s)",
"RetryAfter:Seconds": "{0} second(s)",
"Volo.Abp.OperationRateLimiting:010002": "Operation rate limit exceeded. This request is permanently denied."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/es.json

@ -1,18 +0,0 @@
{
"culture": "es",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Se ha excedido el límite de tasa de operación. Puede intentarlo de nuevo después de {RetryAfter}.",
"RetryAfter:Years": "{0} año(s)",
"RetryAfter:YearsAndMonths": "{0} año(s) y {1} mes(es)",
"RetryAfter:Months": "{0} mes(es)",
"RetryAfter:MonthsAndDays": "{0} mes(es) y {1} día(s)",
"RetryAfter:Days": "{0} día(s)",
"RetryAfter:DaysAndHours": "{0} día(s) y {1} hora(s)",
"RetryAfter:Hours": "{0} hora(s)",
"RetryAfter:HoursAndMinutes": "{0} hora(s) y {1} minuto(s)",
"RetryAfter:Minutes": "{0} minuto(s)",
"RetryAfter:MinutesAndSeconds": "{0} minuto(s) y {1} segundo(s)",
"RetryAfter:Seconds": "{0} segundo(s)",
"Volo.Abp.OperationRateLimiting:010002": "Se superó el límite de frecuencia de operación. Esta solicitud está permanentemente denegada."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fa.json

@ -1,18 +0,0 @@
{
"culture": "fa",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "محدودیت نرخ عملیات فراتر رفته است. می‌توانید بعد از {RetryAfter} دوباره تلاش کنید.",
"RetryAfter:Years": "{0} سال",
"RetryAfter:YearsAndMonths": "{0} سال و {1} ماه",
"RetryAfter:Months": "{0} ماه",
"RetryAfter:MonthsAndDays": "{0} ماه و {1} روز",
"RetryAfter:Days": "{0} روز",
"RetryAfter:DaysAndHours": "{0} روز و {1} ساعت",
"RetryAfter:Hours": "{0} ساعت",
"RetryAfter:HoursAndMinutes": "{0} ساعت و {1} دقیقه",
"RetryAfter:Minutes": "{0} دقیقه",
"RetryAfter:MinutesAndSeconds": "{0} دقیقه و {1} ثانیه",
"RetryAfter:Seconds": "{0} ثانیه",
"Volo.Abp.OperationRateLimiting:010002": "محدودیت نرخ عملیات از حد مجاز فراتر رفت. این درخواست به طور دائمی رد شده است."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fi.json

@ -1,18 +0,0 @@
{
"culture": "fi",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Toiminnon nopeusraja ylitetty. Voit yrittää uudelleen {RetryAfter} kuluttua.",
"RetryAfter:Years": "{0} vuosi/vuotta",
"RetryAfter:YearsAndMonths": "{0} vuosi/vuotta ja {1} kuukausi/kuukautta",
"RetryAfter:Months": "{0} kuukausi/kuukautta",
"RetryAfter:MonthsAndDays": "{0} kuukausi/kuukautta ja {1} päivä/päivää",
"RetryAfter:Days": "{0} päivä/päivää",
"RetryAfter:DaysAndHours": "{0} päivä/päivää ja {1} tunti/tuntia",
"RetryAfter:Hours": "{0} tunti/tuntia",
"RetryAfter:HoursAndMinutes": "{0} tunti/tuntia ja {1} minuutti/minuuttia",
"RetryAfter:Minutes": "{0} minuutti/minuuttia",
"RetryAfter:MinutesAndSeconds": "{0} minuutti/minuuttia ja {1} sekunti/sekuntia",
"RetryAfter:Seconds": "{0} sekunti/sekuntia",
"Volo.Abp.OperationRateLimiting:010002": "Toiminnan nopeusraja ylitettiin. Tämä pyyntö on pysyvästi hylätty."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fr.json

@ -1,18 +0,0 @@
{
"culture": "fr",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Limite de taux d'opération dépassée. Vous pouvez réessayer après {RetryAfter}.",
"RetryAfter:Years": "{0} an(s)",
"RetryAfter:YearsAndMonths": "{0} an(s) et {1} mois",
"RetryAfter:Months": "{0} mois",
"RetryAfter:MonthsAndDays": "{0} mois et {1} jour(s)",
"RetryAfter:Days": "{0} jour(s)",
"RetryAfter:DaysAndHours": "{0} jour(s) et {1} heure(s)",
"RetryAfter:Hours": "{0} heure(s)",
"RetryAfter:HoursAndMinutes": "{0} heure(s) et {1} minute(s)",
"RetryAfter:Minutes": "{0} minute(s)",
"RetryAfter:MinutesAndSeconds": "{0} minute(s) et {1} seconde(s)",
"RetryAfter:Seconds": "{0} seconde(s)",
"Volo.Abp.OperationRateLimiting:010002": "La limite de fréquence d'opération a été dépassée. Cette demande est définitivement refusée."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hi.json

@ -1,18 +0,0 @@
{
"culture": "hi",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "ऑपरेशन दर सीमा पार हो गई। आप {RetryAfter} के बाद पुनः प्रयास कर सकते हैं।",
"RetryAfter:Years": "{0} वर्ष",
"RetryAfter:YearsAndMonths": "{0} वर्ष और {1} महीना/महीने",
"RetryAfter:Months": "{0} महीना/महीने",
"RetryAfter:MonthsAndDays": "{0} महीना/महीने और {1} दिन",
"RetryAfter:Days": "{0} दिन",
"RetryAfter:DaysAndHours": "{0} दिन और {1} घंटा/घंटे",
"RetryAfter:Hours": "{0} घंटा/घंटे",
"RetryAfter:HoursAndMinutes": "{0} घंटा/घंटे और {1} मिनट",
"RetryAfter:Minutes": "{0} मिनट",
"RetryAfter:MinutesAndSeconds": "{0} मिनट और {1} सेकंड",
"RetryAfter:Seconds": "{0} सेकंड",
"Volo.Abp.OperationRateLimiting:010002": "ऑपरेशन दर सीमा पार हो गई। यह अनुरोध स्थायी रूप से अस्वीकृत है।"
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hr.json

@ -1,18 +0,0 @@
{
"culture": "hr",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Prekoračeno ograničenje brzine operacije. Možete pokušati ponovo nakon {RetryAfter}.",
"RetryAfter:Years": "{0} godina/e",
"RetryAfter:YearsAndMonths": "{0} godina/e i {1} mjesec/i",
"RetryAfter:Months": "{0} mjesec/i",
"RetryAfter:MonthsAndDays": "{0} mjesec/i i {1} dan/a",
"RetryAfter:Days": "{0} dan/a",
"RetryAfter:DaysAndHours": "{0} dan/a i {1} sat/i",
"RetryAfter:Hours": "{0} sat/i",
"RetryAfter:HoursAndMinutes": "{0} sat/i i {1} minuta/e",
"RetryAfter:Minutes": "{0} minuta/e",
"RetryAfter:MinutesAndSeconds": "{0} minuta/e i {1} sekunda/e",
"RetryAfter:Seconds": "{0} sekunda/e",
"Volo.Abp.OperationRateLimiting:010002": "Prekoračeno je ograničenje brzine operacije. Ovaj zahtjev je trajno odbijen."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hu.json

@ -1,18 +0,0 @@
{
"culture": "hu",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "A műveleti sebességkorlát túllépve. Újra próbálkozhat {RetryAfter} múlva.",
"RetryAfter:Years": "{0} év",
"RetryAfter:YearsAndMonths": "{0} év és {1} hónap",
"RetryAfter:Months": "{0} hónap",
"RetryAfter:MonthsAndDays": "{0} hónap és {1} nap",
"RetryAfter:Days": "{0} nap",
"RetryAfter:DaysAndHours": "{0} nap és {1} óra",
"RetryAfter:Hours": "{0} óra",
"RetryAfter:HoursAndMinutes": "{0} óra és {1} perc",
"RetryAfter:Minutes": "{0} perc",
"RetryAfter:MinutesAndSeconds": "{0} perc és {1} másodperc",
"RetryAfter:Seconds": "{0} másodperc",
"Volo.Abp.OperationRateLimiting:010002": "A műveleti ráta korlátja túllépve. Ez a kérés véglegesen elutasítva."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/is.json

@ -1,18 +0,0 @@
{
"culture": "is",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Aðgerðarhraðatakmörk náð. Þú getur reynt aftur eftir {RetryAfter}.",
"RetryAfter:Years": "{0} ár",
"RetryAfter:YearsAndMonths": "{0} ár og {1} mánuð(ir)",
"RetryAfter:Months": "{0} mánuð(ur/ir)",
"RetryAfter:MonthsAndDays": "{0} mánuð(ur/ir) og {1} dag(ur/ar)",
"RetryAfter:Days": "{0} dag(ur/ar)",
"RetryAfter:DaysAndHours": "{0} dag(ur/ar) og {1} klukkustund(ir)",
"RetryAfter:Hours": "{0} klukkustund(ir)",
"RetryAfter:HoursAndMinutes": "{0} klukkustund(ir) og {1} mínúta/úr",
"RetryAfter:Minutes": "{0} mínúta/úr",
"RetryAfter:MinutesAndSeconds": "{0} mínúta/úr og {1} sekúnda/úr",
"RetryAfter:Seconds": "{0} sekúnda/úr",
"Volo.Abp.OperationRateLimiting:010002": "Farið var yfir takmörk á rekstrartíðni. Þessari beiðni er varanlega hafnað."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/it.json

@ -1,18 +0,0 @@
{
"culture": "it",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Limite di frequenza operazione superato. Puoi riprovare dopo {RetryAfter}.",
"RetryAfter:Years": "{0} anno/i",
"RetryAfter:YearsAndMonths": "{0} anno/i e {1} mese/i",
"RetryAfter:Months": "{0} mese/i",
"RetryAfter:MonthsAndDays": "{0} mese/i e {1} giorno/i",
"RetryAfter:Days": "{0} giorno/i",
"RetryAfter:DaysAndHours": "{0} giorno/i e {1} ora/e",
"RetryAfter:Hours": "{0} ora/e",
"RetryAfter:HoursAndMinutes": "{0} ora/e e {1} minuto/i",
"RetryAfter:Minutes": "{0} minuto/i",
"RetryAfter:MinutesAndSeconds": "{0} minuto/i e {1} secondo/i",
"RetryAfter:Seconds": "{0} secondo/i",
"Volo.Abp.OperationRateLimiting:010002": "Limite di frequenza operazione superato. Questa richiesta è permanentemente negata."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/nl.json

@ -1,18 +0,0 @@
{
"culture": "nl",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Bewerkingssnelheidslimiet overschreden. U kunt het opnieuw proberen na {RetryAfter}.",
"RetryAfter:Years": "{0} jaar",
"RetryAfter:YearsAndMonths": "{0} jaar en {1} maand(en)",
"RetryAfter:Months": "{0} maand(en)",
"RetryAfter:MonthsAndDays": "{0} maand(en) en {1} dag(en)",
"RetryAfter:Days": "{0} dag(en)",
"RetryAfter:DaysAndHours": "{0} dag(en) en {1} uur",
"RetryAfter:Hours": "{0} uur",
"RetryAfter:HoursAndMinutes": "{0} uur en {1} minuut/minuten",
"RetryAfter:Minutes": "{0} minuut/minuten",
"RetryAfter:MinutesAndSeconds": "{0} minuut/minuten en {1} seconde(n)",
"RetryAfter:Seconds": "{0} seconde(n)",
"Volo.Abp.OperationRateLimiting:010002": "Het bewerkingsfrequentielimiet is overschreden. Dit verzoek wordt permanent geweigerd."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pl-PL.json

@ -1,18 +0,0 @@
{
"culture": "pl-PL",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Przekroczono limit częstotliwości operacji. Możesz spróbować ponownie po {RetryAfter}.",
"RetryAfter:Years": "{0} rok/lat",
"RetryAfter:YearsAndMonths": "{0} rok/lat i {1} miesiąc/miesięcy",
"RetryAfter:Months": "{0} miesiąc/miesięcy",
"RetryAfter:MonthsAndDays": "{0} miesiąc/miesięcy i {1} dzień/dni",
"RetryAfter:Days": "{0} dzień/dni",
"RetryAfter:DaysAndHours": "{0} dzień/dni i {1} godzina/godzin",
"RetryAfter:Hours": "{0} godzina/godzin",
"RetryAfter:HoursAndMinutes": "{0} godzina/godzin i {1} minuta/minut",
"RetryAfter:Minutes": "{0} minuta/minut",
"RetryAfter:MinutesAndSeconds": "{0} minuta/minut i {1} sekunda/sekund",
"RetryAfter:Seconds": "{0} sekunda/sekund",
"Volo.Abp.OperationRateLimiting:010002": "Przekroczono limit częstotliwości operacji. To żądanie jest trwale odrzucone."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pt-BR.json

@ -1,18 +0,0 @@
{
"culture": "pt-BR",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Limite de taxa de operação excedido. Você pode tentar novamente após {RetryAfter}.",
"RetryAfter:Years": "{0} ano(s)",
"RetryAfter:YearsAndMonths": "{0} ano(s) e {1} mês/meses",
"RetryAfter:Months": "{0} mês/meses",
"RetryAfter:MonthsAndDays": "{0} mês/meses e {1} dia(s)",
"RetryAfter:Days": "{0} dia(s)",
"RetryAfter:DaysAndHours": "{0} dia(s) e {1} hora(s)",
"RetryAfter:Hours": "{0} hora(s)",
"RetryAfter:HoursAndMinutes": "{0} hora(s) e {1} minuto(s)",
"RetryAfter:Minutes": "{0} minuto(s)",
"RetryAfter:MinutesAndSeconds": "{0} minuto(s) e {1} segundo(s)",
"RetryAfter:Seconds": "{0} segundo(s)",
"Volo.Abp.OperationRateLimiting:010002": "Limite de taxa de operação excedido. Esta solicitação está permanentemente negada."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ro-RO.json

@ -1,18 +0,0 @@
{
"culture": "ro-RO",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Limita ratei de operare a fost depășită. Puteți încerca din nou după {RetryAfter}.",
"RetryAfter:Years": "{0} an/ani",
"RetryAfter:YearsAndMonths": "{0} an/ani și {1} lună/luni",
"RetryAfter:Months": "{0} lună/luni",
"RetryAfter:MonthsAndDays": "{0} lună/luni și {1} zi/zile",
"RetryAfter:Days": "{0} zi/zile",
"RetryAfter:DaysAndHours": "{0} zi/zile și {1} oră/ore",
"RetryAfter:Hours": "{0} oră/ore",
"RetryAfter:HoursAndMinutes": "{0} oră/ore și {1} minut(e)",
"RetryAfter:Minutes": "{0} minut(e)",
"RetryAfter:MinutesAndSeconds": "{0} minut(e) și {1} secundă/secunde",
"RetryAfter:Seconds": "{0} secundă/secunde",
"Volo.Abp.OperationRateLimiting:010002": "Limita de rată a operației a fost depășită. Această solicitare este permanent refuzată."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ru.json

@ -1,18 +0,0 @@
{
"culture": "ru",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Превышен лимит частоты операций. Вы можете повторить попытку через {RetryAfter}.",
"RetryAfter:Years": "{0} год/лет",
"RetryAfter:YearsAndMonths": "{0} год/лет и {1} месяц/месяцев",
"RetryAfter:Months": "{0} месяц/месяцев",
"RetryAfter:MonthsAndDays": "{0} месяц/месяцев и {1} день/дней",
"RetryAfter:Days": "{0} день/дней",
"RetryAfter:DaysAndHours": "{0} день/дней и {1} час/часов",
"RetryAfter:Hours": "{0} час/часов",
"RetryAfter:HoursAndMinutes": "{0} час/часов и {1} минута/минут",
"RetryAfter:Minutes": "{0} минута/минут",
"RetryAfter:MinutesAndSeconds": "{0} минута/минут и {1} секунда/секунд",
"RetryAfter:Seconds": "{0} секунда/секунд",
"Volo.Abp.OperationRateLimiting:010002": "Превышен лимит частоты операций. Этот запрос постоянно отклонён."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sk.json

@ -1,18 +0,0 @@
{
"culture": "sk",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Prekročený limit rýchlosti operácie. Môžete to skúsiť znova po {RetryAfter}.",
"RetryAfter:Years": "{0} rok/rokov",
"RetryAfter:YearsAndMonths": "{0} rok/rokov a {1} mesiac/mesiacov",
"RetryAfter:Months": "{0} mesiac/mesiacov",
"RetryAfter:MonthsAndDays": "{0} mesiac/mesiacov a {1} deň/dní",
"RetryAfter:Days": "{0} deň/dní",
"RetryAfter:DaysAndHours": "{0} deň/dní a {1} hodina/hodín",
"RetryAfter:Hours": "{0} hodina/hodín",
"RetryAfter:HoursAndMinutes": "{0} hodina/hodín a {1} minúta/minút",
"RetryAfter:Minutes": "{0} minúta/minút",
"RetryAfter:MinutesAndSeconds": "{0} minúta/minút a {1} sekunda/sekúnd",
"RetryAfter:Seconds": "{0} sekunda/sekúnd",
"Volo.Abp.OperationRateLimiting:010002": "Bol prekročený limit frekvencie operácie. Táto požiadavka je trvalo zamietnutá."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sl.json

@ -1,18 +0,0 @@
{
"culture": "sl",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Presežena omejitev hitrosti operacije. Poskusite lahko znova čez {RetryAfter}.",
"RetryAfter:Years": "{0} leto/let",
"RetryAfter:YearsAndMonths": "{0} leto/let in {1} mesec/mesecev",
"RetryAfter:Months": "{0} mesec/mesecev",
"RetryAfter:MonthsAndDays": "{0} mesec/mesecev in {1} dan/dni",
"RetryAfter:Days": "{0} dan/dni",
"RetryAfter:DaysAndHours": "{0} dan/dni in {1} ura/ur",
"RetryAfter:Hours": "{0} ura/ur",
"RetryAfter:HoursAndMinutes": "{0} ura/ur in {1} minuta/minut",
"RetryAfter:Minutes": "{0} minuta/minut",
"RetryAfter:MinutesAndSeconds": "{0} minuta/minut in {1} sekunda/sekund",
"RetryAfter:Seconds": "{0} sekunda/sekund",
"Volo.Abp.OperationRateLimiting:010002": "Prekoračena je omejitev hitrosti operacije. Ta zahteva je trajno zavrnjena."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sv.json

@ -1,18 +0,0 @@
{
"culture": "sv",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Hastighetsgränsen för operationen har överskridits. Du kan försöka igen efter {RetryAfter}.",
"RetryAfter:Years": "{0} år",
"RetryAfter:YearsAndMonths": "{0} år och {1} månad(er)",
"RetryAfter:Months": "{0} månad(er)",
"RetryAfter:MonthsAndDays": "{0} månad(er) och {1} dag(ar)",
"RetryAfter:Days": "{0} dag(ar)",
"RetryAfter:DaysAndHours": "{0} dag(ar) och {1} timme/timmar",
"RetryAfter:Hours": "{0} timme/timmar",
"RetryAfter:HoursAndMinutes": "{0} timme/timmar och {1} minut(er)",
"RetryAfter:Minutes": "{0} minut(er)",
"RetryAfter:MinutesAndSeconds": "{0} minut(er) och {1} sekund(er)",
"RetryAfter:Seconds": "{0} sekund(er)",
"Volo.Abp.OperationRateLimiting:010002": "Hastighetsgränsen för operationen har överskridits. Denna förfrågan är permanent nekad."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/tr.json

@ -1,18 +0,0 @@
{
"culture": "tr",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "İşlem hız sınırı aşıldı. {RetryAfter} sonra tekrar deneyebilirsiniz.",
"RetryAfter:Years": "{0} yıl",
"RetryAfter:YearsAndMonths": "{0} yıl ve {1} ay",
"RetryAfter:Months": "{0} ay",
"RetryAfter:MonthsAndDays": "{0} ay ve {1} gün",
"RetryAfter:Days": "{0} gün",
"RetryAfter:DaysAndHours": "{0} gün ve {1} saat",
"RetryAfter:Hours": "{0} saat",
"RetryAfter:HoursAndMinutes": "{0} saat ve {1} dakika",
"RetryAfter:Minutes": "{0} dakika",
"RetryAfter:MinutesAndSeconds": "{0} dakika ve {1} saniye",
"RetryAfter:Seconds": "{0} saniye",
"Volo.Abp.OperationRateLimiting:010002": "İşlem hızı sınırı aşıldı. Bu istek kalıcı olarak reddedildi."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/vi.json

@ -1,18 +0,0 @@
{
"culture": "vi",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "Đã vượt quá giới hạn tốc độ thao tác. Bạn có thể thử lại sau {RetryAfter}.",
"RetryAfter:Years": "{0} năm",
"RetryAfter:YearsAndMonths": "{0} năm và {1} tháng",
"RetryAfter:Months": "{0} tháng",
"RetryAfter:MonthsAndDays": "{0} tháng và {1} ngày",
"RetryAfter:Days": "{0} ngày",
"RetryAfter:DaysAndHours": "{0} ngày và {1} giờ",
"RetryAfter:Hours": "{0} giờ",
"RetryAfter:HoursAndMinutes": "{0} giờ và {1} phút",
"RetryAfter:Minutes": "{0} phút",
"RetryAfter:MinutesAndSeconds": "{0} phút và {1} giây",
"RetryAfter:Seconds": "{0} giây",
"Volo.Abp.OperationRateLimiting:010002": "Vượt quá giới hạn tần suất thao tác. Yêu cầu này bị từ chối vĩnh viễn."
}
}

18
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/zh-Hans.json

@ -1,18 +0,0 @@
{
"culture": "zh-Hans",
"texts": {
"Volo.Abp.OperationRateLimiting:010001": "操作频率超出限制。请在 {RetryAfter} 后重试。",
"RetryAfter:Years": "{0} 年",
"RetryAfter:YearsAndMonths": "{0} 年 {1} 个月",
"RetryAfter:Months": "{0} 个月",
"RetryAfter:MonthsAndDays": "{0} 个月 {1} 天",
"RetryAfter:Days": "{0} 天",
"RetryAfter:DaysAndHours": "{0} 天 {1} 小时",
"RetryAfter:Hours": "{0} 小时",
"RetryAfter:HoursAndMinutes": "{0} 小时 {1} 分钟",
"RetryAfter:Minutes": "{0} 分钟",
"RetryAfter:MinutesAndSeconds": "{0} 分钟 {1} 秒",
"RetryAfter:Seconds": "{0} 秒",
"Volo.Abp.OperationRateLimiting:010002": "操作频率超出限制。此请求已被永久拒绝。"
}
}

34
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/DefaultOperationRateLimitingPolicyProvider.cs

@ -1,34 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.OperationRateLimiting;
public class DefaultOperationRateLimitingPolicyProvider : IOperationRateLimitingPolicyProvider, ITransientDependency
{
protected AbpOperationRateLimitingOptions Options { get; }
public DefaultOperationRateLimitingPolicyProvider(IOptions<AbpOperationRateLimitingOptions> options)
{
Options = options.Value;
}
public virtual Task<OperationRateLimitingPolicy> GetAsync(string policyName)
{
if (!Options.Policies.TryGetValue(policyName, out var policy))
{
throw new AbpException(
$"Operation rate limit policy '{policyName}' was not found. " +
$"Make sure to configure it using AbpOperationRateLimitingOptions.AddPolicy().");
}
return Task.FromResult(policy);
}
public virtual Task<List<OperationRateLimitingPolicy>> GetListAsync()
{
return Task.FromResult(Options.Policies.Values.ToList());
}
}

11
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/IOperationRateLimitingPolicyProvider.cs

@ -1,11 +0,0 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Volo.Abp.OperationRateLimiting;
public interface IOperationRateLimitingPolicyProvider
{
Task<OperationRateLimitingPolicy> GetAsync(string policyName);
Task<List<OperationRateLimitingPolicy>> GetListAsync();
}

12
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPartitionType.cs

@ -1,12 +0,0 @@
namespace Volo.Abp.OperationRateLimiting;
public enum OperationRateLimitingPartitionType
{
Parameter,
CurrentUser,
CurrentTenant,
ClientIp,
Email,
PhoneNumber,
Custom
}

15
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicy.cs

@ -1,15 +0,0 @@
using System;
using System.Collections.Generic;
namespace Volo.Abp.OperationRateLimiting;
public class OperationRateLimitingPolicy
{
public string Name { get; set; } = default!;
public string? ErrorCode { get; set; }
public List<OperationRateLimitingRuleDefinition> Rules { get; set; } = new();
public List<Type> CustomRuleTypes { get; set; } = new();
}

102
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicyBuilder.cs

@ -1,102 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Volo.Abp.OperationRateLimiting;
public class OperationRateLimitingPolicyBuilder
{
private readonly string _name;
private string? _errorCode;
private readonly List<OperationRateLimitingRuleDefinition> _rules = new();
private readonly List<Type> _customRuleTypes = new();
public OperationRateLimitingPolicyBuilder(string name)
{
_name = Check.NotNullOrWhiteSpace(name, nameof(name));
}
/// <summary>
/// Add a built-in rule. Multiple rules are AND-combined.
/// </summary>
public OperationRateLimitingPolicyBuilder AddRule(
Action<OperationRateLimitingRuleBuilder> configure)
{
var builder = new OperationRateLimitingRuleBuilder(this);
configure(builder);
if (!builder.IsCommitted)
{
_rules.Add(builder.Build());
}
return this;
}
/// <summary>
/// Add a custom rule type (resolved from DI).
/// </summary>
public OperationRateLimitingPolicyBuilder AddRule<TRule>()
where TRule : class, IOperationRateLimitingRule
{
_customRuleTypes.Add(typeof(TRule));
return this;
}
/// <summary>
/// Shortcut: single-rule policy with fixed window.
/// Returns the rule builder for partition configuration.
/// </summary>
public OperationRateLimitingRuleBuilder WithFixedWindow(
TimeSpan duration, int maxCount)
{
var builder = new OperationRateLimitingRuleBuilder(this);
builder.WithFixedWindow(duration, maxCount);
return builder;
}
/// <summary>
/// Set a custom ErrorCode for this policy's exception.
/// </summary>
public OperationRateLimitingPolicyBuilder WithErrorCode(string errorCode)
{
_errorCode = Check.NotNullOrWhiteSpace(errorCode, nameof(errorCode));
return this;
}
internal void AddRuleDefinition(OperationRateLimitingRuleDefinition definition)
{
_rules.Add(definition);
}
internal OperationRateLimitingPolicy Build()
{
if (_rules.Count == 0 && _customRuleTypes.Count == 0)
{
throw new AbpException(
$"Operation rate limit policy '{_name}' has no rules. " +
"Call AddRule() or WithFixedWindow(...).PartitionBy*() to add at least one rule.");
}
var duplicate = _rules
.Where(r => r.PartitionType != OperationRateLimitingPartitionType.Custom)
.GroupBy(r => (r.Duration, r.MaxCount, r.PartitionType, r.IsMultiTenant))
.FirstOrDefault(g => g.Count() > 1);
if (duplicate != null)
{
var (duration, maxCount, partitionType, isMultiTenant) = duplicate.Key;
throw new AbpException(
$"Operation rate limit policy '{_name}' has duplicate rules with the same " +
$"Duration ({duration}), MaxCount ({maxCount}), PartitionType ({partitionType}), " +
$"and IsMultiTenant ({isMultiTenant}). " +
"Each rule in a policy must have a unique combination of these properties.");
}
return new OperationRateLimitingPolicy
{
Name = _name,
ErrorCode = _errorCode,
Rules = new List<OperationRateLimitingRuleDefinition>(_rules),
CustomRuleTypes = new List<Type>(_customRuleTypes)
};
}
}

157
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleBuilder.cs

@ -1,157 +0,0 @@
using System;
using System.Threading.Tasks;
namespace Volo.Abp.OperationRateLimiting;
public class OperationRateLimitingRuleBuilder
{
private readonly OperationRateLimitingPolicyBuilder _policyBuilder;
private TimeSpan _duration;
private int _maxCount;
private OperationRateLimitingPartitionType? _partitionType;
private Func<OperationRateLimitingContext, Task<string>>? _customPartitionKeyResolver;
private bool _isMultiTenant;
internal bool IsCommitted { get; private set; }
internal OperationRateLimitingRuleBuilder(OperationRateLimitingPolicyBuilder policyBuilder)
{
_policyBuilder = policyBuilder;
}
public OperationRateLimitingRuleBuilder WithFixedWindow(
TimeSpan duration, int maxCount)
{
_duration = duration;
_maxCount = maxCount;
return this;
}
public OperationRateLimitingRuleBuilder WithMultiTenancy()
{
_isMultiTenant = true;
return this;
}
/// <summary>
/// Use context.Parameter as partition key.
/// </summary>
public OperationRateLimitingPolicyBuilder PartitionByParameter()
{
_partitionType = OperationRateLimitingPartitionType.Parameter;
CommitToPolicyBuilder();
return _policyBuilder;
}
/// <summary>
/// Partition by the current authenticated user (ICurrentUser.Id).
/// Use PartitionByParameter() if you need to specify the user ID explicitly.
/// </summary>
public OperationRateLimitingPolicyBuilder PartitionByCurrentUser()
{
_partitionType = OperationRateLimitingPartitionType.CurrentUser;
CommitToPolicyBuilder();
return _policyBuilder;
}
/// <summary>
/// Partition by the current tenant (ICurrentTenant.Id). Uses "host" when no tenant is active.
/// </summary>
public OperationRateLimitingPolicyBuilder PartitionByCurrentTenant()
{
_partitionType = OperationRateLimitingPartitionType.CurrentTenant;
CommitToPolicyBuilder();
return _policyBuilder;
}
/// <summary>
/// Partition by the client IP address (IWebClientInfoProvider.ClientIpAddress).
/// Use PartitionByParameter() if you need to specify the IP explicitly.
/// </summary>
public OperationRateLimitingPolicyBuilder PartitionByClientIp()
{
_partitionType = OperationRateLimitingPartitionType.ClientIp;
CommitToPolicyBuilder();
return _policyBuilder;
}
/// <summary>
/// Partition by email address.
/// Resolves from context.Parameter, falls back to ICurrentUser.Email.
/// </summary>
public OperationRateLimitingPolicyBuilder PartitionByEmail()
{
_partitionType = OperationRateLimitingPartitionType.Email;
CommitToPolicyBuilder();
return _policyBuilder;
}
/// <summary>
/// Partition by phone number.
/// Resolves from context.Parameter, falls back to ICurrentUser.PhoneNumber.
/// </summary>
public OperationRateLimitingPolicyBuilder PartitionByPhoneNumber()
{
_partitionType = OperationRateLimitingPartitionType.PhoneNumber;
CommitToPolicyBuilder();
return _policyBuilder;
}
/// <summary>
/// Custom async partition key resolver from context.
/// </summary>
public OperationRateLimitingPolicyBuilder PartitionBy(
Func<OperationRateLimitingContext, Task<string>> keyResolver)
{
_partitionType = OperationRateLimitingPartitionType.Custom;
_customPartitionKeyResolver = Check.NotNull(keyResolver, nameof(keyResolver));
CommitToPolicyBuilder();
return _policyBuilder;
}
protected virtual void CommitToPolicyBuilder()
{
_policyBuilder.AddRuleDefinition(Build());
IsCommitted = true;
}
internal OperationRateLimitingRuleDefinition Build()
{
if (_duration <= TimeSpan.Zero)
{
throw new AbpException(
"Operation rate limit rule requires a positive duration. " +
"Call WithFixedWindow(duration, maxCount) before building the rule.");
}
if (_maxCount < 0)
{
throw new AbpException(
"Operation rate limit rule requires maxCount >= 0. " +
"Use maxCount: 0 to completely deny all requests (ban policy).");
}
if (!_partitionType.HasValue)
{
throw new AbpException(
"Operation rate limit rule requires a partition type. " +
"Call PartitionByParameter(), PartitionByCurrentUser(), PartitionByClientIp(), or another PartitionBy*() method.");
}
if (_partitionType == OperationRateLimitingPartitionType.Custom && _customPartitionKeyResolver == null)
{
throw new AbpException(
"Custom partition type requires a key resolver. " +
"Call PartitionBy(keyResolver) instead of setting partition type directly.");
}
return new OperationRateLimitingRuleDefinition
{
Duration = _duration,
MaxCount = _maxCount,
PartitionType = _partitionType.Value,
CustomPartitionKeyResolver = _customPartitionKeyResolver,
IsMultiTenant = _isMultiTenant
};
}
}

17
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleDefinition.cs

@ -1,17 +0,0 @@
using System;
using System.Threading.Tasks;
namespace Volo.Abp.OperationRateLimiting;
public class OperationRateLimitingRuleDefinition
{
public TimeSpan Duration { get; set; }
public int MaxCount { get; set; }
public OperationRateLimitingPartitionType PartitionType { get; set; }
public Func<OperationRateLimitingContext, Task<string>>? CustomPartitionKeyResolver { get; set; }
public bool IsMultiTenant { get; set; }
}

147
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs

@ -1,147 +0,0 @@
using System.Threading.Tasks;
using Volo.Abp.AspNetCore.WebClientInfo;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Users;
namespace Volo.Abp.OperationRateLimiting;
public class FixedWindowOperationRateLimitingRule : IOperationRateLimitingRule
{
private const string HostTenantKey = "host";
protected string PolicyName { get; }
protected OperationRateLimitingRuleDefinition Definition { get; }
protected IOperationRateLimitingStore Store { get; }
protected ICurrentUser CurrentUser { get; }
protected ICurrentTenant CurrentTenant { get; }
protected IWebClientInfoProvider WebClientInfoProvider { get; }
public FixedWindowOperationRateLimitingRule(
string policyName,
OperationRateLimitingRuleDefinition definition,
IOperationRateLimitingStore store,
ICurrentUser currentUser,
ICurrentTenant currentTenant,
IWebClientInfoProvider webClientInfoProvider)
{
PolicyName = policyName;
Definition = definition;
Store = store;
CurrentUser = currentUser;
CurrentTenant = currentTenant;
WebClientInfoProvider = webClientInfoProvider;
}
public virtual async Task<OperationRateLimitingRuleResult> AcquireAsync(
OperationRateLimitingContext context)
{
var partitionKey = await ResolvePartitionKeyAsync(context);
var storeKey = BuildStoreKey(partitionKey);
var storeResult = await Store.IncrementAsync(storeKey, Definition.Duration, Definition.MaxCount);
return ToRuleResult(storeResult);
}
public virtual async Task<OperationRateLimitingRuleResult> CheckAsync(
OperationRateLimitingContext context)
{
var partitionKey = await ResolvePartitionKeyAsync(context);
var storeKey = BuildStoreKey(partitionKey);
var storeResult = await Store.GetAsync(storeKey, Definition.Duration, Definition.MaxCount);
return ToRuleResult(storeResult);
}
public virtual async Task ResetAsync(OperationRateLimitingContext context)
{
var partitionKey = await ResolvePartitionKeyAsync(context);
var storeKey = BuildStoreKey(partitionKey);
await Store.ResetAsync(storeKey);
}
protected virtual async Task<string> ResolvePartitionKeyAsync(OperationRateLimitingContext context)
{
return Definition.PartitionType switch
{
OperationRateLimitingPartitionType.Parameter =>
context.Parameter ?? throw new AbpException(
$"OperationRateLimitingContext.Parameter is required for policy '{PolicyName}' (PartitionByParameter)."),
OperationRateLimitingPartitionType.CurrentUser =>
CurrentUser.Id?.ToString()
?? throw new AbpException(
$"Current user is not authenticated. Policy '{PolicyName}' requires PartitionByCurrentUser. " +
"Use PartitionByParameter() if you need to specify the user ID explicitly."),
OperationRateLimitingPartitionType.CurrentTenant =>
CurrentTenant.Id?.ToString()
?? HostTenantKey,
OperationRateLimitingPartitionType.ClientIp =>
WebClientInfoProvider.ClientIpAddress
?? throw new AbpException(
$"Client IP address could not be determined. Policy '{PolicyName}' requires PartitionByClientIp. " +
"Ensure IWebClientInfoProvider is properly configured or use PartitionByParameter() to pass the IP explicitly."),
OperationRateLimitingPartitionType.Email =>
context.Parameter
?? CurrentUser.Email
?? throw new AbpException(
$"Email is required for policy '{PolicyName}' (PartitionByEmail). Provide it via context.Parameter or ensure the user has an email."),
OperationRateLimitingPartitionType.PhoneNumber =>
context.Parameter
?? CurrentUser.PhoneNumber
?? throw new AbpException(
$"Phone number is required for policy '{PolicyName}' (PartitionByPhoneNumber). Provide it via context.Parameter or ensure the user has a phone number."),
OperationRateLimitingPartitionType.Custom =>
await ResolveCustomPartitionKeyAsync(context),
_ => throw new AbpException($"Unknown partition type: {Definition.PartitionType}")
};
}
protected virtual async Task<string> ResolveCustomPartitionKeyAsync(OperationRateLimitingContext context)
{
var key = await Definition.CustomPartitionKeyResolver!(context);
if (string.IsNullOrEmpty(key))
{
throw new AbpException(
$"Custom partition key resolver returned null or empty for policy '{PolicyName}'. " +
"The resolver must return a non-empty string.");
}
return key;
}
protected virtual string BuildStoreKey(string partitionKey)
{
// Stable rule descriptor based on content so reordering rules does not change the key.
// Changing Duration or MaxCount intentionally resets counters for that rule.
var ruleKey = $"{(long)Definition.Duration.TotalSeconds}_{Definition.MaxCount}_{(int)Definition.PartitionType}";
// Tenant isolation is opt-in via WithMultiTenancy() on the rule builder.
// When not set, the key is global (shared across all tenants).
if (!Definition.IsMultiTenant)
{
return $"orl:{PolicyName}:{ruleKey}:{partitionKey}";
}
var tenantId = CurrentTenant.Id.HasValue ? CurrentTenant.Id.Value.ToString() : HostTenantKey;
return $"orl:t:{tenantId}:{PolicyName}:{ruleKey}:{partitionKey}";
}
protected virtual OperationRateLimitingRuleResult ToRuleResult(OperationRateLimitingStoreResult storeResult)
{
return new OperationRateLimitingRuleResult
{
RuleName = $"{PolicyName}:Rule[{(long)Definition.Duration.TotalSeconds}s,{Definition.MaxCount},{Definition.PartitionType}]",
IsAllowed = storeResult.IsAllowed,
CurrentCount = storeResult.CurrentCount,
RemainingCount = storeResult.MaxCount - storeResult.CurrentCount,
MaxCount = storeResult.MaxCount,
RetryAfter = storeResult.RetryAfter,
WindowDuration = Definition.Duration
};
}
}

12
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/IOperationRateLimitingRule.cs

@ -1,12 +0,0 @@
using System.Threading.Tasks;
namespace Volo.Abp.OperationRateLimiting;
public interface IOperationRateLimitingRule
{
Task<OperationRateLimitingRuleResult> AcquireAsync(OperationRateLimitingContext context);
Task<OperationRateLimitingRuleResult> CheckAsync(OperationRateLimitingContext context);
Task ResetAsync(OperationRateLimitingContext context);
}

155
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/DistributedCacheOperationRateLimitingStore.cs

@ -1,155 +0,0 @@
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using Volo.Abp.Caching;
using Volo.Abp.DependencyInjection;
using Volo.Abp.DistributedLocking;
using Volo.Abp.Timing;
namespace Volo.Abp.OperationRateLimiting;
public class DistributedCacheOperationRateLimitingStore : IOperationRateLimitingStore, ITransientDependency
{
protected IDistributedCache<OperationRateLimitingCacheItem> Cache { get; }
protected IClock Clock { get; }
protected IAbpDistributedLock DistributedLock { get; }
protected AbpOperationRateLimitingOptions Options { get; }
public DistributedCacheOperationRateLimitingStore(
IDistributedCache<OperationRateLimitingCacheItem> cache,
IClock clock,
IAbpDistributedLock distributedLock,
IOptions<AbpOperationRateLimitingOptions> options)
{
Cache = cache;
Clock = clock;
DistributedLock = distributedLock;
Options = options.Value;
}
public virtual async Task<OperationRateLimitingStoreResult> IncrementAsync(
string key, TimeSpan duration, int maxCount)
{
if (maxCount <= 0)
{
return new OperationRateLimitingStoreResult
{
IsAllowed = false,
CurrentCount = 0,
MaxCount = maxCount,
RetryAfter = null
};
}
await using (var handle = await DistributedLock.TryAcquireAsync(
$"OperationRateLimiting:{key}", Options.LockTimeout))
{
if (handle == null)
{
throw new AbpException(
"Could not acquire distributed lock for operation rate limit. " +
"This is an infrastructure issue, not a rate limit violation.");
}
var cacheItem = await Cache.GetAsync(key);
var now = new DateTimeOffset(Clock.Now.ToUniversalTime());
if (cacheItem == null || now >= cacheItem.WindowStart.Add(duration))
{
cacheItem = new OperationRateLimitingCacheItem { Count = 1, WindowStart = now };
await Cache.SetAsync(key, cacheItem,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = duration
});
return new OperationRateLimitingStoreResult
{
IsAllowed = true,
CurrentCount = 1,
MaxCount = maxCount
};
}
if (cacheItem.Count >= maxCount)
{
var retryAfter = cacheItem.WindowStart.Add(duration) - now;
return new OperationRateLimitingStoreResult
{
IsAllowed = false,
CurrentCount = cacheItem.Count,
MaxCount = maxCount,
RetryAfter = retryAfter
};
}
cacheItem.Count++;
var expiration = cacheItem.WindowStart.Add(duration) - now;
await Cache.SetAsync(key, cacheItem,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = expiration > TimeSpan.Zero ? expiration : duration
});
return new OperationRateLimitingStoreResult
{
IsAllowed = true,
CurrentCount = cacheItem.Count,
MaxCount = maxCount
};
}
}
public virtual async Task<OperationRateLimitingStoreResult> GetAsync(
string key, TimeSpan duration, int maxCount)
{
if (maxCount <= 0)
{
return new OperationRateLimitingStoreResult
{
IsAllowed = false,
CurrentCount = 0,
MaxCount = maxCount,
RetryAfter = null
};
}
var cacheItem = await Cache.GetAsync(key);
var now = new DateTimeOffset(Clock.Now.ToUniversalTime());
if (cacheItem == null || now >= cacheItem.WindowStart.Add(duration))
{
return new OperationRateLimitingStoreResult
{
IsAllowed = true,
CurrentCount = 0,
MaxCount = maxCount
};
}
if (cacheItem.Count >= maxCount)
{
var retryAfter = cacheItem.WindowStart.Add(duration) - now;
return new OperationRateLimitingStoreResult
{
IsAllowed = false,
CurrentCount = cacheItem.Count,
MaxCount = maxCount,
RetryAfter = retryAfter
};
}
return new OperationRateLimitingStoreResult
{
IsAllowed = true,
CurrentCount = cacheItem.Count,
MaxCount = maxCount
};
}
public virtual async Task ResetAsync(string key)
{
await Cache.RemoveAsync(key);
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save