From 2ee47638b0de7d970fe5fb7e9ded64ab9101036e Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 25 Aug 2020 17:19:48 +0000 Subject: [PATCH] Feature/client contingent (#563) * T * Temporary * Temp. * Limits * Localization fix. * Test improvement. * Fixes. * Tests fixed * TS build fix. * Use client library. --- backend/i18n/frontend_en.json | 6 ++- backend/i18n/frontend_nl.json | 6 ++- backend/i18n/source/backend__ignore.json | 4 +- backend/i18n/source/frontend__ignore.json | 4 +- backend/i18n/source/frontend_en.json | 4 ++ .../Apps/AppClient.cs | 20 ++++++--- .../Apps/AppClients.cs | 6 +-- .../Contents/Workflow.cs | 2 +- .../Schemas/SchemaExtensions.cs | 6 +-- .../ContentSchemaBuilder.cs | 2 +- .../Apps/Commands/UpdateClient.cs | 4 ++ .../Apps/Guards/GuardAppClients.cs | 12 +++++- .../Apps/Plans/UsageGate.cs | 21 ++++++---- .../Apps/State/AppState.cs | 2 +- .../Assets/AssetUsageTracker.cs | 2 +- .../Contents/BulkUpdateCommandMiddleware.cs | 15 ++++--- .../Rules/UsageTracking/UsageTrackerGrain.cs | 2 +- .../Apps/AppClientUpdated.cs | 4 ++ .../EventSourcing/GetEventStore.cs | 2 +- .../StringExtensions.cs | 5 --- .../UsageTracking/ApiUsageTracker.cs | 8 ++-- .../UsageTracking/BackgroundUsageTracker.cs | 11 +++-- .../UsageTracking/CachingUsageTracker.cs | 12 +++--- .../UsageTracking/IApiUsageTracker.cs | 4 +- .../UsageTracking/IUsageTracker.cs | 4 +- .../Squidex.Web/Pipeline/ApiCostsFilter.cs | 5 ++- .../Pipeline/CleanupHostMiddleware.cs | 24 ++++------- .../Squidex.Web/Pipeline/UsageMiddleware.cs | 4 +- .../Api/Controllers/Apps/Models/ClientDto.cs | 15 +++++++ .../Apps/Models/UpdateClientDto.cs | 10 +++++ .../Authentication/IdentityServerServices.cs | 4 ++ backend/src/Squidex/Program.cs | 2 + .../Model/Apps/AppClientJsonTests.cs | 2 +- .../Model/Apps/AppClientsTests.cs | 30 ++++++++++---- .../Apps/Guards/GuardAppClientsTests.cs | 22 ++++++++++ .../Apps/Guards/GuardAppRolesTests.cs | 2 +- .../Apps/Plans/UsageGateTests.cs | 36 ++++++++++++---- .../Queries/ResolveReferencesTests.cs | 3 +- .../StringExtensionsTests.cs | 4 +- .../UsageTracking/ApiUsageTrackerTests.cs | 9 ++-- .../BackgroundUsageTrackerTests.cs | 30 ++++++++------ .../UsageTracking/CachingUsageTrackerTests.cs | 17 ++++---- .../Pipeline/ApiCostsFilterTests.cs | 8 ++-- .../Pipeline/AppResolverTests.cs | 4 +- .../Pipeline/CleanupHostMiddlewareTests.cs | 41 ++++--------------- frontend/app/app.module.ts | 2 +- .../pages/clients/client.component.html | 23 +++++++++-- .../pages/clients/client.component.ts | 20 +++++++-- .../editors/date-time-editor.component.html | 10 ++--- .../app/framework/utils/date-time.spec.ts | 4 ++ .../shared/services/clients.service.spec.ts | 4 +- .../app/shared/services/clients.service.ts | 6 +++ 52 files changed, 335 insertions(+), 174 deletions(-) diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index f97d27907..ba02bb434 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -134,6 +134,8 @@ "clients.addFailed": "Failed to add client. Please reload.", "clients.allowAnonymous": "Allow anonymous access.", "clients.allowAnonymousHint": "Allow access to the API without an access token to all resources that are configured via the role of this client. Do not give more than one client anonymous access.", + "clients.apiCallsLimit": "Max API Calls", + "clients.apiCallsLimitHint": "Limit the number of API calls this client can make per month to protect your API contingent for other clients that are more important.", "clients.clientIdValidationMessage": "Name can only contain letters, numbers, dashes and spaces.", "clients.clientNamePlaceholder": "Enter client name", "clients.connect": "Connect", @@ -209,10 +211,12 @@ "common.create": "Create", "common.created": "Created", "common.date": "Date", + "common.dateTimeEditor.local": "Local", "common.dateTimeEditor.now": "Now", - "common.dateTimeEditor.nowTooltip": "Use Now", + "common.dateTimeEditor.nowTooltip": "Use Now (UTC)", "common.dateTimeEditor.today": "Today", "common.dateTimeEditor.todayTooltip": "Use Today (UTC)", + "common.dateTimeEditor.utc": "UTC", "common.delete": "Delete", "common.description": "Description", "common.displayName": "Display Name", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index d0e9cc26c..424445254 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -134,6 +134,8 @@ "clients.addFailed": "Toevoegen van client is mislukt. Laad opnieuw.", "clients.allowAnonymous": "Sta anonieme toegang toe.", "clients.allowAnonymousHint": "Sta toegang tot de API toe zonder toegangstoken voor alle bronnen die zijn geconfigureerd via de rol van deze client. Geef niet meer dan één client anonieme toegang.", + "clients.apiCallsLimit": "Max API Calls", + "clients.apiCallsLimitHint": "Limit the number of API calls this client can make per month to protect your API contingent for other clients that are more important.", "clients.clientIdValidationMessage": "Naam mag alleen letters, cijfers, streepjes en spaties bevatten.", "clients.clientNamePlaceholder": "Voer de naam van de klant in", "clients.connect": "Verbinden", @@ -209,10 +211,12 @@ "common.create": "Maken", "common.created": "Gemaakt", "common.date": "Datum", + "common.dateTimeEditor.local": "Local", "common.dateTimeEditor.now": "Nu", - "common.dateTimeEditor.nowTooltip": "Nu gebruiken", + "common.dateTimeEditor.nowTooltip": "Nu gebruiken (UTC)", "common.dateTimeEditor.today": "Vandaag", "common.dateTimeEditor.todayTooltip": "Gebruik vandaag (UTC)", + "common.dateTimeEditor.utc": "UTC", "common.delete": "Verwijderen", "common.description": "Beschrijving", "common.displayName": "Weergavenaam", diff --git a/backend/i18n/source/backend__ignore.json b/backend/i18n/source/backend__ignore.json index a678d4af6..e9596e8ee 100644 --- a/backend/i18n/source/backend__ignore.json +++ b/backend/i18n/source/backend__ignore.json @@ -151,10 +151,10 @@ "/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchTextIndex.cs": [ "*" ], - "/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IndexManager_Impl.cs": [ + "/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IndexManager.cs": [ "*" ], - "/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IndexManager.cs": [ + "/Squidex.Domain.Apps.Entities/Contents/Text/Lucene/IndexManager_Impl.cs": [ "*" ], "/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailSender.cs": [ diff --git a/backend/i18n/source/frontend__ignore.json b/backend/i18n/source/frontend__ignore.json index 4635dc8c0..737bd389f 100644 --- a/backend/i18n/source/frontend__ignore.json +++ b/backend/i18n/source/frontend__ignore.json @@ -19,8 +19,8 @@ "#{{index + 1}}" ], "/features/content/shared/forms/field-editor.component.html": [ - "{{field.displayName}} {{displaySuffix}}", - "*" + "*", + "{{field.displayName}} {{displaySuffix}}" ], "/features/content/shared/references/references-editor.component.html": [ "·" diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index 2650bf8ab..ba02bb434 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -134,6 +134,8 @@ "clients.addFailed": "Failed to add client. Please reload.", "clients.allowAnonymous": "Allow anonymous access.", "clients.allowAnonymousHint": "Allow access to the API without an access token to all resources that are configured via the role of this client. Do not give more than one client anonymous access.", + "clients.apiCallsLimit": "Max API Calls", + "clients.apiCallsLimitHint": "Limit the number of API calls this client can make per month to protect your API contingent for other clients that are more important.", "clients.clientIdValidationMessage": "Name can only contain letters, numbers, dashes and spaces.", "clients.clientNamePlaceholder": "Enter client name", "clients.connect": "Connect", @@ -209,10 +211,12 @@ "common.create": "Create", "common.created": "Created", "common.date": "Date", + "common.dateTimeEditor.local": "Local", "common.dateTimeEditor.now": "Now", "common.dateTimeEditor.nowTooltip": "Use Now (UTC)", "common.dateTimeEditor.today": "Today", "common.dateTimeEditor.todayTooltip": "Use Today (UTC)", + "common.dateTimeEditor.utc": "UTC", "common.delete": "Delete", "common.description": "Description", "common.displayName": "Display Name", diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs index 58e607039..0d26b08c7 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs @@ -17,25 +17,35 @@ namespace Squidex.Domain.Apps.Core.Apps public string Secret { get; } - public bool AllowAnonymous { get; set; } + public long ApiCallsLimit { get; } - public AppClient(string name, string secret, string role, bool allowAnonymous) + public long ApiTrafficLimit { get; } + + public bool AllowAnonymous { get; } + + public AppClient(string name, string secret, string role, long apiCallsLimit = 0, long apiTrafficLimit = 0, bool allowAnonymous = false) : base(name) { Guard.NotNullOrEmpty(secret, nameof(secret)); Guard.NotNullOrEmpty(role, nameof(role)); + Guard.GreaterEquals(apiCallsLimit, 0, nameof(apiCallsLimit)); + Guard.GreaterEquals(apiTrafficLimit, 0, nameof(apiTrafficLimit)); + + Secret = secret; Role = role; - Secret = secret; + ApiCallsLimit = apiCallsLimit; + + ApiTrafficLimit = apiTrafficLimit; AllowAnonymous = allowAnonymous; } [Pure] - public AppClient Update(string? name, string? role, bool? allowAnonymous) + public AppClient Update(string? name, string? role, long? apiCallsLimit, long? apiTrafficLimit, bool? allowAnonymous) { - return new AppClient(name.Or(Name), Secret, role.Or(Role), allowAnonymous ?? AllowAnonymous); + return new AppClient(name.Or(Name), Secret, role.Or(Role), apiCallsLimit ?? ApiCallsLimit, apiTrafficLimit ?? ApiTrafficLimit, allowAnonymous ?? AllowAnonymous); } } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClients.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClients.cs index cb6e38679..77c32f985 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClients.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClients.cs @@ -53,11 +53,11 @@ namespace Squidex.Domain.Apps.Core.Apps throw new ArgumentException("Id already exists.", nameof(id)); } - return With(id, new AppClient(id, secret, Role.Editor, false)); + return Add(id, new AppClient(id, secret, Role.Editor)); } [Pure] - public AppClients Update(string id, string? name = null, string? role = null, bool? allowAnonymous = false) + public AppClients Update(string id, string? name = null, string? role = null, long? apiCallsLimit = null, long? apiTrafficLimit = null, bool? allowAnonymous = false) { Guard.NotNullOrEmpty(id, nameof(id)); @@ -66,7 +66,7 @@ namespace Squidex.Domain.Apps.Core.Apps return this; } - return With(id, client.Update(name, role, allowAnonymous)); + return With(id, client.Update(name, role, apiCallsLimit, apiTrafficLimit, allowAnonymous)); } } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs index 311467aad..73d44edba 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs @@ -36,7 +36,7 @@ namespace Squidex.Domain.Apps.Core.Contents IReadOnlyDictionary? steps, IReadOnlyList? schemaIds = null, string? name = null) - : base(name ?? DefaultName) + : base(name.Or(DefaultName)) { Initial = initial; diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs index 5df556719..97e720786 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs @@ -41,7 +41,7 @@ namespace Squidex.Domain.Apps.Core.Schemas public static string DisplayName(this IField field) { - return field.RawProperties.Label.WithFallback(field.TypeName()); + return field.RawProperties.Label.Or(field.TypeName()); } public static string TypeName(this Schema schema) @@ -51,12 +51,12 @@ namespace Squidex.Domain.Apps.Core.Schemas public static string DisplayName(this Schema schema) { - return schema.Properties.Label.WithFallback(schema.TypeName()); + return schema.Properties.Label.Or(schema.TypeName()); } public static string DisplayNameUnchanged(this Schema schema) { - return schema.Properties.Label.WithFallback(schema.Name); + return schema.Properties.Label.Or(schema.Name); } public static Guid SingleId(this ReferencesFieldProperties properties) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/ContentSchemaBuilder.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/ContentSchemaBuilder.cs index 0328df650..0acd3b306 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/ContentSchemaBuilder.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/ContentSchemaBuilder.cs @@ -18,7 +18,7 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema Guard.NotNull(schema, nameof(schema)); Guard.NotNull(dataSchema, nameof(dataSchema)); - var schemaName = schema.Properties.Label.WithFallback(schema.Name); + var schemaName = schema.Properties.Label.Or(schema.Name); var contentSchema = new JsonSchema { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs index 474db16b0..826c6e119 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs @@ -15,6 +15,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Commands public string? Role { get; set; } + public long? ApiCallsLimit { get; set; } + + public long? ApiTrafficLimit { get; set; } + public bool? AllowAnonymous { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs index d34cd27a2..ba43cdb14 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs @@ -62,7 +62,17 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards if (command.Role != null && !roles.Contains(command.Role)) { - e(Not.Valid("Role"), nameof(command.Role)); + e(Not.Valid(nameof(command.Role)), nameof(command.Role)); + } + + if (command.ApiCallsLimit != null && command.ApiCallsLimit < 0) + { + e(Not.GreaterEqualsThan(nameof(command.ApiCallsLimit), "0"), nameof(command.ApiCallsLimit)); + } + + if (command.ApiTrafficLimit != null && command.ApiTrafficLimit < 0) + { + e(Not.GreaterEqualsThan(nameof(command.ApiTrafficLimit), "0"), nameof(command.ApiTrafficLimit)); } }); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs index 3418bf0f6..25c9a56c0 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs @@ -38,19 +38,26 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans usageLimitNotifier = grainFactory.GetGrain(SingleGrain.Id); } - public virtual async Task IsBlockedAsync(IAppEntity app, DateTime today) + public virtual async Task IsBlockedAsync(IAppEntity app, string? clientId, DateTime today) { Guard.NotNull(app, nameof(app)); - var isLocked = false; + var appId = app.Id; + + var isBlocked = false; + + if (clientId != null && app.Clients.TryGetValue(clientId, out var client) && client.ApiCallsLimit > 0) + { + var usage = await apiUsageTracker.GetMonthCallsAsync(appId.ToString(), today, clientId); + + isBlocked = usage >= client.ApiCallsLimit; + } var (plan, _) = appPlansProvider.GetPlanForApp(app); if (plan.MaxApiCalls > 0 || plan.BlockingApiCalls > 0) { - var appId = app.Id; - - var usage = await apiUsageTracker.GetMonthCallsAsync(appId.ToString(), today); + var usage = await apiUsageTracker.GetMonthCallsAsync(appId.ToString(), today, null); if (IsAboutToBeLocked(today, plan.MaxApiCalls, usage) && !HasNotifiedBefore(app.Id)) { @@ -70,10 +77,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans TrackNotified(appId); } - isLocked = plan.BlockingApiCalls > 0 && usage > plan.BlockingApiCalls; + isBlocked = isBlocked || plan.BlockingApiCalls > 0 && usage > plan.BlockingApiCalls; } - return isLocked; + return isBlocked; } private bool HasNotifiedBefore(Guid appId) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs index 7c7d15332..0404b46c8 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs @@ -85,7 +85,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.State return UpdateClients(e, (ev, c) => c.Add(ev.Id, ev.Secret)); case AppClientUpdated e: - return UpdateClients(e, (ev, c) => c.Update(ev.Id, ev.Name, ev.Role, ev.AllowAnonymous)); + return UpdateClients(e, (ev, c) => c.Update(ev.Id, ev.Name, ev.Role, ev.ApiCallsLimit, ev.ApiTrafficLimit, ev.AllowAnonymous)); case AppClientRevoked e: return UpdateClients(e, (ev, c) => c.Revoke(ev.Id)); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs index 6a3a7680b..d721979ec 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs @@ -34,7 +34,7 @@ namespace Squidex.Domain.Apps.Entities.Assets { var key = GetKey(appId); - var counters = await usageTracker.GetAsync(key, SummaryDate, SummaryDate); + var counters = await usageTracker.GetAsync(key, SummaryDate, SummaryDate, null); return counters.GetInt64(CounterTotalSize); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateCommandMiddleware.cs index eaabaecb8..35376007e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateCommandMiddleware.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateCommandMiddleware.cs @@ -74,11 +74,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { var command = SimpleMapper.Map(bulkUpdates, new CreateContent { Data = job.Data }); - var content = serviceProvider.GetRequiredService(); - - content.Setup(command.ContentId); - - await content.ExecuteAsync(command); + await InsertAsync(command); result.ContentId = command.ContentId; } @@ -151,6 +147,15 @@ namespace Squidex.Domain.Apps.Entities.Contents } } + private async Task InsertAsync(CreateContent command) + { + var content = serviceProvider.GetRequiredService(); + + content.Setup(command.ContentId); + + await content.ExecuteAsync(command); + } + private async Task FindIdAsync(Context context, string schema, BulkUpdateJob job) { var id = job.Id; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs index dd63d0d45..7ef1f9b77 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs @@ -83,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking if (!target.Triggered.HasValue || target.Triggered < from) { - var costs = await usageTracker.GetMonthCallsAsync(target.AppId.Id.ToString(), today); + var costs = await usageTracker.GetMonthCallsAsync(target.AppId.Id.ToString(), today, null); var limit = target.Limits; diff --git a/backend/src/Squidex.Domain.Apps.Events/Apps/AppClientUpdated.cs b/backend/src/Squidex.Domain.Apps.Events/Apps/AppClientUpdated.cs index 519c02e26..d93393c1c 100644 --- a/backend/src/Squidex.Domain.Apps.Events/Apps/AppClientUpdated.cs +++ b/backend/src/Squidex.Domain.Apps.Events/Apps/AppClientUpdated.cs @@ -18,6 +18,10 @@ namespace Squidex.Domain.Apps.Events.Apps public string? Role { get; set; } + public long? ApiCallsLimit { get; set; } + + public long? ApiTrafficLimit { get; set; } + public bool? AllowAnonymous { get; set; } } } diff --git a/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs b/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs index 80503ad0a..107e0b5a3 100644 --- a/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs +++ b/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs @@ -35,7 +35,7 @@ namespace Squidex.Infrastructure.EventSourcing this.connection = connection; this.serializer = serializer; - this.prefix = prefix.Trim(' ', '-').WithFallback("squidex"); + this.prefix = prefix.Trim(' ', '-').Or("squidex"); projectionClient = new ProjectionClient(connection, prefix, projectionHost); } diff --git a/backend/src/Squidex.Infrastructure/StringExtensions.cs b/backend/src/Squidex.Infrastructure/StringExtensions.cs index b5c4fadca..34461cbb7 100644 --- a/backend/src/Squidex.Infrastructure/StringExtensions.cs +++ b/backend/src/Squidex.Infrastructure/StringExtensions.cs @@ -547,11 +547,6 @@ namespace Squidex.Infrastructure return !string.IsNullOrWhiteSpace(value) ? value.Trim() : fallback; } - public static string WithFallback(this string? value, string fallback) - { - return !string.IsNullOrWhiteSpace(value) ? value.Trim() : fallback; - } - public static string ToPascalCase(this string value) { if (value.Length == 0) diff --git a/backend/src/Squidex.Infrastructure/UsageTracking/ApiUsageTracker.cs b/backend/src/Squidex.Infrastructure/UsageTracking/ApiUsageTracker.cs index d475fa3c0..9a4334d7f 100644 --- a/backend/src/Squidex.Infrastructure/UsageTracking/ApiUsageTracker.cs +++ b/backend/src/Squidex.Infrastructure/UsageTracking/ApiUsageTracker.cs @@ -23,20 +23,20 @@ namespace Squidex.Infrastructure.UsageTracking this.usageTracker = usageTracker; } - public async Task GetMonthCallsAsync(string key, DateTime date) + public async Task GetMonthCallsAsync(string key, DateTime date, string? category) { var apiKey = GetKey(key); - var counters = await usageTracker.GetForMonthAsync(apiKey, date); + var counters = await usageTracker.GetForMonthAsync(apiKey, date, category); return counters.GetInt64(CounterTotalCalls); } - public async Task GetMonthBytesAsync(string key, DateTime date) + public async Task GetMonthBytesAsync(string key, DateTime date, string? category) { var apiKey = GetKey(key); - var counters = await usageTracker.GetForMonthAsync(apiKey, date); + var counters = await usageTracker.GetForMonthAsync(apiKey, date, category); return counters.GetInt64(CounterTotalBytes); } diff --git a/backend/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs b/backend/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs index a1ac083db..4a74c6279 100644 --- a/backend/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs +++ b/backend/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs @@ -141,15 +141,15 @@ namespace Squidex.Infrastructure.UsageTracking return result; } - public Task GetForMonthAsync(string key, DateTime date) + public Task GetForMonthAsync(string key, DateTime date, string? category) { var dateFrom = new DateTime(date.Year, date.Month, 1); var dateTo = dateFrom.AddMonths(1).AddDays(-1); - return GetAsync(key, dateFrom, dateTo); + return GetAsync(key, dateFrom, dateTo, category); } - public async Task GetAsync(string key, DateTime fromDate, DateTime toDate) + public async Task GetAsync(string key, DateTime fromDate, DateTime toDate, string? category) { Guard.NotNullOrEmpty(key, nameof(key)); @@ -157,6 +157,11 @@ namespace Squidex.Infrastructure.UsageTracking var queried = await usageRepository.QueryAsync(key, fromDate, toDate); + if (category != null) + { + queried = queried.Where(x => string.Equals(x.Category, category, StringComparison.OrdinalIgnoreCase)).ToList(); + } + var result = new Counters(); foreach (var usage in queried) diff --git a/backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs b/backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs index 882c37a90..830403891 100644 --- a/backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs +++ b/backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs @@ -40,31 +40,31 @@ namespace Squidex.Infrastructure.UsageTracking return inner.TrackAsync(date, key, category, counters); } - public Task GetForMonthAsync(string key, DateTime date) + public Task GetForMonthAsync(string key, DateTime date, string? category) { Guard.NotNull(key, nameof(key)); - var cacheKey = string.Join("$", "Usage", nameof(GetForMonthAsync), key, date); + var cacheKey = string.Join("$", "Usage", nameof(GetForMonthAsync), key, date, category); return Cache.GetOrCreateAsync(cacheKey, entry => { entry.AbsoluteExpirationRelativeToNow = CacheDuration; - return inner.GetForMonthAsync(key, date); + return inner.GetForMonthAsync(key, date, category); }); } - public Task GetAsync(string key, DateTime fromDate, DateTime toDate) + public Task GetAsync(string key, DateTime fromDate, DateTime toDate, string? category) { Guard.NotNull(key, nameof(key)); - var cacheKey = string.Join("$", "Usage", nameof(GetAsync), key, fromDate, toDate); + var cacheKey = string.Join("$", "Usage", nameof(GetAsync), key, fromDate, toDate, category); return Cache.GetOrCreateAsync(cacheKey, entry => { entry.AbsoluteExpirationRelativeToNow = CacheDuration; - return inner.GetAsync(key, fromDate, toDate); + return inner.GetAsync(key, fromDate, toDate, category); }); } } diff --git a/backend/src/Squidex.Infrastructure/UsageTracking/IApiUsageTracker.cs b/backend/src/Squidex.Infrastructure/UsageTracking/IApiUsageTracker.cs index 0d960eb1f..7a70a516e 100644 --- a/backend/src/Squidex.Infrastructure/UsageTracking/IApiUsageTracker.cs +++ b/backend/src/Squidex.Infrastructure/UsageTracking/IApiUsageTracker.cs @@ -15,9 +15,9 @@ namespace Squidex.Infrastructure.UsageTracking { Task TrackAsync(DateTime date, string key, string? category, double weight, long elapsedMs, long bytes); - Task GetMonthCallsAsync(string key, DateTime date); + Task GetMonthCallsAsync(string key, DateTime date, string? category); - Task GetMonthBytesAsync(string key, DateTime date); + Task GetMonthBytesAsync(string key, DateTime date, string? category); Task<(ApiStatsSummary, Dictionary> Details)> QueryAsync(string key, DateTime fromDate, DateTime toDate); } diff --git a/backend/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs b/backend/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs index 48d793128..bc498075e 100644 --- a/backend/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs +++ b/backend/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs @@ -15,9 +15,9 @@ namespace Squidex.Infrastructure.UsageTracking { Task TrackAsync(DateTime date, string key, string? category, Counters counters); - Task GetForMonthAsync(string key, DateTime date); + Task GetForMonthAsync(string key, DateTime date, string? category); - Task GetAsync(string key, DateTime fromDate, DateTime toDate); + Task GetAsync(string key, DateTime fromDate, DateTime toDate, string? category); Task>> QueryAsync(string key, DateTime fromDate, DateTime toDate); } diff --git a/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs b/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs index 0946cd3d5..152f7597f 100644 --- a/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs +++ b/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Mvc.Filters; using Squidex.Domain.Apps.Entities.Apps.Plans; using Squidex.Infrastructure; using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Security; namespace Squidex.Web.Pipeline { @@ -52,7 +53,9 @@ namespace Squidex.Web.Pipeline { using (Profiler.Trace("CheckUsage")) { - var isBlocked = await usageGate.IsBlockedAsync(app, DateTime.Today); + var (_, clientId) = context.HttpContext.User.GetClient(); + + var isBlocked = await usageGate.IsBlockedAsync(app, clientId, DateTime.Today); if (isBlocked) { diff --git a/backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs b/backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs index 69c3c43ad..76fb26dcd 100644 --- a/backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs +++ b/backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs @@ -5,40 +5,30 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; namespace Squidex.Web.Pipeline { public class CleanupHostMiddleware { private readonly RequestDelegate next; + private readonly HostString host; - public CleanupHostMiddleware(RequestDelegate next) + public CleanupHostMiddleware(RequestDelegate next, IOptions options) { this.next = next; + + host = new HostString(new Uri(options.Value.BaseUrl).Host); } public Task InvokeAsync(HttpContext context) { - var request = context.Request; - - if (request.Host.HasValue && (HasHttpsPort(request) || HasHttpPort(request))) - { - request.Host = new HostString(request.Host.Host); - } + context.Request.Host = host; return next(context); } - - private static bool HasHttpPort(HttpRequest request) - { - return request.Scheme == "http" && request.Host.Port == 80; - } - - private static bool HasHttpsPort(HttpRequest request) - { - return request.Scheme == "https" && request.Host.Port == 443; - } } } diff --git a/backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs b/backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs index 4202e8d17..a39bd1a6a 100644 --- a/backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs +++ b/backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs @@ -60,6 +60,8 @@ namespace Squidex.Web.Pipeline bytes += context.Request.ContentLength.Value; } + var (_, clientId) = context.User.GetClient(); + var request = default(RequestLog); request.Bytes = bytes; @@ -68,7 +70,7 @@ namespace Squidex.Web.Pipeline request.RequestMethod = context.Request.Method; request.RequestPath = context.Request.Path; request.Timestamp = clock.GetCurrentInstant(); - request.UserClientId = context.User.OpenIdClientId(); + request.UserClientId = clientId; request.UserId = context.User.OpenIdSubject(); await logStore.LogAsync(appId.Id, request); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs index 26cbff699..ea4618e2b 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs @@ -37,6 +37,21 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// public string? Role { get; set; } + /// + /// The number of allowed api calls per month for this client. + /// + public long ApiCallsLimit { get; set; } + + /// + /// The number of allowed api traffic bytes per month for this client. + /// + public long ApiTrafficLimit { get; set; } + + /// + /// True to allow anonymous access without an access token for this client. + /// + public bool AllowAnonymous { get; set; } + public static ClientDto FromClient(string id, AppClient client) { var result = SimpleMapper.Map(client, new ClientDto { Id = id }); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateClientDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateClientDto.cs index 9f7cd207f..cddec99cd 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateClientDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateClientDto.cs @@ -29,6 +29,16 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// public bool? AllowAnonymous { get; set; } + /// + /// The number of allowed api calls per month for this client. + /// + public long? ApiCallsLimit { get; set; } + + /// + /// The number of allowed api traffic bytes per month for this client. + /// + public long? ApiTrafficLimit { get; set; } + public UpdateClient ToCommand(string clientId) { return SimpleMapper.Map(this, new UpdateClient { Id = clientId }); diff --git a/backend/src/Squidex/Config/Authentication/IdentityServerServices.cs b/backend/src/Squidex/Config/Authentication/IdentityServerServices.cs index ed186189b..aa3284347 100644 --- a/backend/src/Squidex/Config/Authentication/IdentityServerServices.cs +++ b/backend/src/Squidex/Config/Authentication/IdentityServerServices.cs @@ -46,8 +46,12 @@ namespace Squidex.Config.Authentication } else { + var urlsOptions = config.GetSection("urls").Get(); + authBuilder.AddLocalApi(options => { + options.ClaimsIssuer = urlsOptions.BuildUrl("/identity-server", false); + options.ExpectedScope = apiScope; }); } diff --git a/backend/src/Squidex/Program.cs b/backend/src/Squidex/Program.cs index a2cb7d1ed..a1632056c 100644 --- a/backend/src/Squidex/Program.cs +++ b/backend/src/Squidex/Program.cs @@ -69,6 +69,8 @@ namespace Squidex serverOptions.ListenAnyIP( 5001, listenOptions => listenOptions.UseHttps("../../../dev/squidex-dev.pfx", "password")); + + serverOptions.ListenAnyIP(5000); } }); diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientJsonTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientJsonTests.cs index 82eed842f..fdef95913 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientJsonTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientJsonTests.cs @@ -29,7 +29,7 @@ namespace Squidex.Domain.Apps.Core.Model.Apps clients = clients.Update("3", name: "My Client 3"); clients = clients.Update("2", name: "My Client 2"); - clients = clients.Update("1", allowAnonymous: true); + clients = clients.Update("1", allowAnonymous: true, apiCallsLimit: 3); clients = clients.Revoke("4"); diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs index 252284ebb..021cb98cf 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs @@ -23,15 +23,15 @@ namespace Squidex.Domain.Apps.Core.Model.Apps { var clients_1 = clients_0.Add("2", "my-secret"); - clients_1["2"].Should().BeEquivalentTo(new AppClient("2", "my-secret", Role.Editor, false)); + clients_1["2"].Should().BeEquivalentTo(new AppClient("2", "my-secret", Role.Editor)); } [Fact] public void Should_assign_clients_with_permission() { - var clients_1 = clients_0.Add("2", new AppClient("my-name", "my-secret", Role.Reader, false)); + var clients_1 = clients_0.Add("2", new AppClient("my-name", "my-secret", Role.Reader)); - clients_1["2"].Should().BeEquivalentTo(new AppClient("my-name", "my-secret", Role.Reader, false)); + clients_1["2"].Should().BeEquivalentTo(new AppClient("my-name", "my-secret", Role.Reader)); } [Fact] @@ -47,7 +47,7 @@ namespace Squidex.Domain.Apps.Core.Model.Apps { var clients_1 = clients_0.Add("2", "my-secret"); - clients_1.Add("2", new AppClient("my-name", "my-secret", "my-role", false)); + clients_1.Add("2", new AppClient("my-name", "my-secret", "my-role")); } [Fact] @@ -55,7 +55,7 @@ namespace Squidex.Domain.Apps.Core.Model.Apps { var client_1 = clients_0.Update("1", role: Role.Reader); - client_1["1"].Should().BeEquivalentTo(new AppClient("1", "my-secret", Role.Reader, false)); + client_1["1"].Should().BeEquivalentTo(new AppClient("1", "my-secret", Role.Reader)); } [Fact] @@ -63,7 +63,7 @@ namespace Squidex.Domain.Apps.Core.Model.Apps { var client_1 = clients_0.Update("1", name: "New-Name"); - client_1["1"].Should().BeEquivalentTo(new AppClient("New-Name", "my-secret", Role.Editor, false)); + client_1["1"].Should().BeEquivalentTo(new AppClient("New-Name", "my-secret", Role.Editor)); } [Fact] @@ -71,7 +71,23 @@ namespace Squidex.Domain.Apps.Core.Model.Apps { var client_1 = clients_0.Update("1", allowAnonymous: true); - client_1["1"].Should().BeEquivalentTo(new AppClient("1", "my-secret", Role.Editor, true)); + client_1["1"].Should().BeEquivalentTo(new AppClient("1", "my-secret", Role.Editor, 0, 0, true)); + } + + [Fact] + public void Should_update_client_with_allow_api_calls_limit() + { + var client_1 = clients_0.Update("1", apiCallsLimit: 1000); + + client_1["1"].Should().BeEquivalentTo(new AppClient("1", "my-secret", Role.Editor, 1000, 0, false)); + } + + [Fact] + public void Should_update_client_with_allow_api_traffic_limit() + { + var client_1 = clients_0.Update("1", apiTrafficLimit: 1000); + + client_1["1"].Should().BeEquivalentTo(new AppClient("1", "my-secret", Role.Editor, 0, 1000, false)); } [Fact] diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppClientsTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppClientsTests.cs index 09afc62d1..5f376d735 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppClientsTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppClientsTests.cs @@ -107,6 +107,28 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards new ValidationError("Role is not a valid value.", "Role")); } + [Fact] + public void UpdateClient_should_throw_exception_if_api_calls_limit_is_less_than_zero() + { + var command = new UpdateClient { Id = "ios", ApiCallsLimit = -10 }; + + var clients_1 = clients_0.Add("ios", "secret"); + + ValidationAssert.Throws(() => GuardAppClients.CanUpdate(clients_1, command, roles), + new ValidationError("ApiCallsLimit must be greater or equal to 0.", "ApiCallsLimit")); + } + + [Fact] + public void UpdateClient_should_throw_exception_if_api_traffic_limit_is_less_than_zero() + { + var command = new UpdateClient { Id = "ios", ApiTrafficLimit = -10 }; + + var clients_1 = clients_0.Add("ios", "secret"); + + ValidationAssert.Throws(() => GuardAppClients.CanUpdate(clients_1, command, roles), + new ValidationError("ApiTrafficLimit must be greater or equal to 0.", "ApiTrafficLimit")); + } + [Fact] public void UpdateClient_should_not_throw_exception_if_client_has_same_name() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppRolesTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppRolesTests.cs index f110ebc19..09c039837 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppRolesTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppRolesTests.cs @@ -87,7 +87,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards var command = new DeleteRole { Name = roleName }; - ValidationAssert.Throws(() => GuardAppRoles.CanDelete(roles_1, command, contributors, clients.Add("1", new AppClient("client", "1", roleName, false))), + ValidationAssert.Throws(() => GuardAppRoles.CanDelete(roles_1, command, contributors, clients.Add("1", new AppClient("client", "1", roleName))), new ValidationError("Cannot remove a role when a client is assigned.")); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageGateTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageGateTests.cs index 34fc2c65f..89e723df0 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageGateTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageGateTests.cs @@ -9,6 +9,7 @@ using System; using System.Threading.Tasks; using FakeItEasy; using Orleans; +using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; using Squidex.Infrastructure.Orleans; @@ -27,6 +28,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans private readonly IUsageNotifierGrain usageNotifierGrain = A.Fake(); private readonly DateTime today = new DateTime(2020, 04, 10); private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); + private readonly string clientId = Guid.NewGuid().ToString(); private readonly UsageGate sut; private long apiCallsBlocking; private long apiCallsMax; @@ -51,20 +53,38 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans A.CallTo(() => appPlan.BlockingApiCalls) .ReturnsLazily(x => apiCallsBlocking); - A.CallTo(() => usageTracker.GetMonthCallsAsync(appId.Id.ToString(), today)) + A.CallTo(() => usageTracker.GetMonthCallsAsync(appId.Id.ToString(), today, A._)) .ReturnsLazily(x => Task.FromResult(apiCallsCurrent)); sut = new UsageGate(appPlansProvider, usageTracker, grainFactory); } [Fact] - public async Task Should_return_true_if_over_blocking_limt() + public async Task Should_return_true_if_over_client_limit() + { + A.CallTo(() => appEntity.Clients) + .Returns(AppClients.Empty.Add(clientId, new AppClient(clientId, clientId, Role.Developer, 100))); + + apiCallsCurrent = 1000; + apiCallsBlocking = 1600; + apiCallsMax = 1600; + + var isBlocked = await sut.IsBlockedAsync(appEntity, clientId, today); + + Assert.True(isBlocked); + + A.CallTo(() => usageNotifierGrain.NotifyAsync(A.Ignored)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_return_true_if_over_blocking_limit() { apiCallsCurrent = 1000; apiCallsBlocking = 600; apiCallsMax = 600; - var isBlocked = await sut.IsBlockedAsync(appEntity, today); + var isBlocked = await sut.IsBlockedAsync(appEntity, clientId, today); Assert.True(isBlocked); @@ -79,7 +99,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans apiCallsBlocking = 1600; apiCallsMax = 1600; - var isBlocked = await sut.IsBlockedAsync(appEntity, today); + var isBlocked = await sut.IsBlockedAsync(appEntity, clientId, today); Assert.False(isBlocked); @@ -94,7 +114,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans apiCallsBlocking = 5000; apiCallsMax = 3000; - var isBlocked = await sut.IsBlockedAsync(appEntity, today); + var isBlocked = await sut.IsBlockedAsync(appEntity, clientId, today); Assert.False(isBlocked); @@ -109,7 +129,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans apiCallsBlocking = 5000; apiCallsMax = 0; - var isBlocked = await sut.IsBlockedAsync(appEntity, today); + var isBlocked = await sut.IsBlockedAsync(appEntity, clientId, today); Assert.False(isBlocked); @@ -124,8 +144,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans apiCallsBlocking = 5000; apiCallsMax = 3000; - await sut.IsBlockedAsync(appEntity, today); - await sut.IsBlockedAsync(appEntity, today); + await sut.IsBlockedAsync(appEntity, clientId, today); + await sut.IsBlockedAsync(appEntity, clientId, today); A.CallTo(() => usageNotifierGrain.NotifyAsync(A.Ignored)) .MustHaveHappenedOnceExactly(); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs index 754a17d4f..2c7ff323c 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs @@ -13,6 +13,7 @@ using FakeItEasy; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Entities.Contents.Queries.Steps; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; @@ -22,7 +23,7 @@ using Xunit; namespace Squidex.Domain.Apps.Entities.Contents.Queries { - public class ResolveReferencesTests + public class ResolveReferencesTests : IClassFixture { private readonly IContentQueryService contentQuery = A.Fake(); private readonly IRequestCache requestCache = A.Fake(); diff --git a/backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs b/backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs index 0928dcafd..07d249e06 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs @@ -30,7 +30,7 @@ namespace Squidex.Infrastructure [InlineData(" ")] public void Should_provide_fallback_if_invalid(string value) { - Assert.Equal("fallback", value.WithFallback("fallback")); + Assert.Equal("fallback", value.Or("fallback")); } [Theory] @@ -130,7 +130,7 @@ namespace Squidex.Infrastructure { const string value = "value"; - Assert.Equal(value, value.WithFallback("fallback")); + Assert.Equal(value, value.Or("fallback")); } [Theory] diff --git a/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/ApiUsageTrackerTests.cs b/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/ApiUsageTrackerTests.cs index 7e33e731f..512d5b6a3 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/ApiUsageTrackerTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/ApiUsageTrackerTests.cs @@ -18,6 +18,7 @@ namespace Squidex.Infrastructure.UsageTracking { private readonly IUsageTracker usageTracker = A.Fake(); private readonly string key = Guid.NewGuid().ToString(); + private readonly string category = Guid.NewGuid().ToString(); private readonly DateTime date = DateTime.Today; private readonly ApiUsageTracker sut; @@ -55,10 +56,10 @@ namespace Squidex.Infrastructure.UsageTracking [ApiUsageTracker.CounterTotalCalls] = 4 }; - A.CallTo(() => usageTracker.GetForMonthAsync($"{key}_API", date)) + A.CallTo(() => usageTracker.GetForMonthAsync($"{key}_API", date, category)) .Returns(counters); - var result = await sut.GetMonthCallsAsync(key, date); + var result = await sut.GetMonthCallsAsync(key, date, category); Assert.Equal(4, result); } @@ -71,10 +72,10 @@ namespace Squidex.Infrastructure.UsageTracking [ApiUsageTracker.CounterTotalBytes] = 14 }; - A.CallTo(() => usageTracker.GetForMonthAsync($"{key}_API", date)) + A.CallTo(() => usageTracker.GetForMonthAsync($"{key}_API", date, category)) .Returns(counters); - var result = await sut.GetMonthBytesAsync(key, date); + var result = await sut.GetMonthBytesAsync(key, date, category); Assert.Equal(14, result); } diff --git a/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs b/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs index b515e4cae..7e0b482b8 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs @@ -49,7 +49,7 @@ namespace Squidex.Infrastructure.UsageTracking { sut.Dispose(); - await Assert.ThrowsAsync(() => sut.GetForMonthAsync(key, date)); + await Assert.ThrowsAsync(() => sut.GetForMonthAsync(key, date, null)); } [Fact] @@ -57,7 +57,7 @@ namespace Squidex.Infrastructure.UsageTracking { sut.Dispose(); - await Assert.ThrowsAsync(() => sut.GetAsync(key, date, date)); + await Assert.ThrowsAsync(() => sut.GetAsync(key, date, date, null)); } [Fact] @@ -70,17 +70,20 @@ namespace Squidex.Infrastructure.UsageTracking { new StoredUsage("category1", date.AddDays(1), Counters(a: 10, b: 15)), new StoredUsage("category1", date.AddDays(3), Counters(a: 13, b: 18)), - new StoredUsage("category1", date.AddDays(5), Counters(a: 15)), - new StoredUsage("category1", date.AddDays(7), Counters(b: 22)) + new StoredUsage("category2", date.AddDays(5), Counters(a: 15)), + new StoredUsage("category2", date.AddDays(7), Counters(b: 22)) }; A.CallTo(() => usageStore.QueryAsync(key, dateFrom, dateTo)) .Returns(originalData); - var result = await sut.GetForMonthAsync(key, date); + var result1 = await sut.GetForMonthAsync(key, date, null); + var result2 = await sut.GetForMonthAsync(key, date, "category2"); - Assert.Equal(38, result["A"]); - Assert.Equal(55, result["B"]); + Assert.Equal(38, result1["A"]); + Assert.Equal(55, result1["B"]); + + Assert.Equal(22, result2["B"]); } [Fact] @@ -93,17 +96,20 @@ namespace Squidex.Infrastructure.UsageTracking { new StoredUsage("category1", date.AddDays(1), Counters(a: 10, b: 15)), new StoredUsage("category1", date.AddDays(3), Counters(a: 13, b: 18)), - new StoredUsage("category1", date.AddDays(5), Counters(a: 15)), - new StoredUsage("category1", date.AddDays(7), Counters(b: 22)) + new StoredUsage("category2", date.AddDays(5), Counters(a: 15)), + new StoredUsage("category2", date.AddDays(7), Counters(b: 22)) }; A.CallTo(() => usageStore.QueryAsync(key, dateFrom, dateTo)) .Returns(originalData); - var result = await sut.GetAsync(key, dateFrom, dateTo); + var result1 = await sut.GetAsync(key, dateFrom, dateTo, null); + var result2 = await sut.GetAsync(key, dateFrom, dateTo, "category2"); + + Assert.Equal(38, result1["A"]); + Assert.Equal(55, result1["B"]); - Assert.Equal(38, result["A"]); - Assert.Equal(55, result["B"]); + Assert.Equal(22, result2["B"]); } [Fact] diff --git a/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/CachingUsageTrackerTests.cs b/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/CachingUsageTrackerTests.cs index 2488684a4..a407911b1 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/CachingUsageTrackerTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/UsageTracking/CachingUsageTrackerTests.cs @@ -18,6 +18,7 @@ namespace Squidex.Infrastructure.UsageTracking { private readonly MemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); private readonly string key = Guid.NewGuid().ToString(); + private readonly string category = Guid.NewGuid().ToString(); private readonly DateTime date = DateTime.Today; private readonly IUsageTracker inner = A.Fake(); private readonly IUsageTracker sut; @@ -55,16 +56,16 @@ namespace Squidex.Infrastructure.UsageTracking { var counters = new Counters(); - A.CallTo(() => inner.GetForMonthAsync(key, date)) + A.CallTo(() => inner.GetForMonthAsync(key, date, category)) .Returns(counters); - var result1 = await sut.GetForMonthAsync(key, date); - var result2 = await sut.GetForMonthAsync(key, date); + var result1 = await sut.GetForMonthAsync(key, date, category); + var result2 = await sut.GetForMonthAsync(key, date, category); Assert.Same(counters, result1); Assert.Same(counters, result2); - A.CallTo(() => inner.GetForMonthAsync(key, DateTime.Today)) + A.CallTo(() => inner.GetForMonthAsync(key, DateTime.Today, category)) .MustHaveHappenedOnceExactly(); } @@ -76,16 +77,16 @@ namespace Squidex.Infrastructure.UsageTracking var dateFrom = date; var dateTo = dateFrom.AddDays(10); - A.CallTo(() => inner.GetAsync(key, dateFrom, dateTo)) + A.CallTo(() => inner.GetAsync(key, dateFrom, dateTo, category)) .Returns(counters); - var result1 = await sut.GetAsync(key, dateFrom, dateTo); - var result2 = await sut.GetAsync(key, dateFrom, dateTo); + var result1 = await sut.GetAsync(key, dateFrom, dateTo, category); + var result2 = await sut.GetAsync(key, dateFrom, dateTo, category); Assert.Same(counters, result1); Assert.Same(counters, result2); - A.CallTo(() => inner.GetAsync(key, dateFrom, dateTo)) + A.CallTo(() => inner.GetAsync(key, dateFrom, dateTo, category)) .MustHaveHappenedOnceExactly(); } diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs index e0a5563cf..09899811b 100644 --- a/backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs +++ b/backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs @@ -55,7 +55,7 @@ namespace Squidex.Web.Pipeline SetupApp(); - A.CallTo(() => usageGate.IsBlockedAsync(appEntity, DateTime.Today)) + A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A._, DateTime.Today)) .Returns(true); await sut.OnActionExecutionAsync(actionContext, next); @@ -71,7 +71,7 @@ namespace Squidex.Web.Pipeline SetupApp(); - A.CallTo(() => usageGate.IsBlockedAsync(appEntity, DateTime.Today)) + A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A._, DateTime.Today)) .Returns(false); await sut.OnActionExecutionAsync(actionContext, next); @@ -90,7 +90,7 @@ namespace Squidex.Web.Pipeline Assert.True(isNextCalled); - A.CallTo(() => usageGate.IsBlockedAsync(appEntity, DateTime.Today)) + A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A._, DateTime.Today)) .MustNotHaveHappened(); } @@ -103,7 +103,7 @@ namespace Squidex.Web.Pipeline Assert.True(isNextCalled); - A.CallTo(() => usageGate.IsBlockedAsync(appEntity, DateTime.Today)) + A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A._, DateTime.Today)) .MustNotHaveHappened(); } diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/AppResolverTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/AppResolverTests.cs index b0c06d516..ec453dc68 100644 --- a/backend/tests/Squidex.Web.Tests/Pipeline/AppResolverTests.cs +++ b/backend/tests/Squidex.Web.Tests/Pipeline/AppResolverTests.cs @@ -227,7 +227,7 @@ namespace Squidex.Web.Pipeline return userIdentity; } - private static IAppEntity CreateApp(string name, string? appUser = null, string? appClient = null, bool? allowAnonymous = null) + private static IAppEntity CreateApp(string name, string? appUser = null, string? appClient = null, long? apiCallsLimit = null, bool? allowAnonymous = null) { var appEntity = A.Fake(); @@ -245,7 +245,7 @@ namespace Squidex.Web.Pipeline if (appClient != null) { A.CallTo(() => appEntity.Clients) - .Returns(AppClients.Empty.Add(appClient, "secret").Update(appClient, allowAnonymous: allowAnonymous)); + .Returns(AppClients.Empty.Add(appClient, "secret").Update(appClient, apiCallsLimit: apiCallsLimit, allowAnonymous: allowAnonymous)); } else { diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/CleanupHostMiddlewareTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/CleanupHostMiddlewareTests.cs index 6d314f856..491553753 100644 --- a/backend/tests/Squidex.Web.Tests/Pipeline/CleanupHostMiddlewareTests.cs +++ b/backend/tests/Squidex.Web.Tests/Pipeline/CleanupHostMiddlewareTests.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; using Xunit; #pragma warning disable RECS0092 // Convert field to readonly @@ -15,7 +16,7 @@ namespace Squidex.Web.Pipeline { public class CleanupHostMiddlewareTests { - private readonly CleanupHostMiddleware sut; + private readonly RequestDelegate next; private bool isNextCalled; public CleanupHostMiddlewareTests() @@ -27,48 +28,24 @@ namespace Squidex.Web.Pipeline return Task.CompletedTask; } - sut = new CleanupHostMiddleware(Next); + next = Next; } [Fact] - public async Task Should_cleanup_host_if_https_schema_contains_default_port() + public async Task Should_override_host_from_urls_options() { - var httpContext = new DefaultHttpContext(); - - httpContext.Request.Scheme = "https"; - httpContext.Request.Host = new HostString("host", 443); - - await sut.InvokeAsync(httpContext); - - Assert.Null(httpContext.Request.Host.Port); - Assert.True(isNextCalled); - } - - [Fact] - public async Task Should_cleanup_host_if_http_schema_contains_default_port() - { - var httpContext = new DefaultHttpContext(); - - httpContext.Request.Scheme = "http"; - httpContext.Request.Host = new HostString("host", 80); + var options = Options.Create(new UrlsOptions { BaseUrl = "https://cloud.squidex.io" }); - await sut.InvokeAsync(httpContext); + var sut = new CleanupHostMiddleware(next, options); - Assert.Null(httpContext.Request.Host.Port); - Assert.True(isNextCalled); - } - - [Fact] - public async Task Should_not_cleanup_host_if_http_schema_contains_other_port() - { var httpContext = new DefaultHttpContext(); - httpContext.Request.Scheme = "http"; - httpContext.Request.Host = new HostString("host", 8080); + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("host", 443); await sut.InvokeAsync(httpContext); - Assert.Equal(8080, httpContext.Request.Host.Port); + Assert.Equal("cloud.squidex.io", httpContext.Request.Host.Value); Assert.True(isNextCalled); } } diff --git a/frontend/app/app.module.ts b/frontend/app/app.module.ts index ef0ab43bf..56029d2f0 100644 --- a/frontend/app/app.module.ts +++ b/frontend/app/app.module.ts @@ -17,7 +17,7 @@ import { routing } from './app.routes'; import { ApiUrlConfig, CurrencyConfig, DateHelper, DecimalSeparatorConfig, LocalizerService, SqxFrameworkModule, SqxSharedModule, TitlesConfig, UIOptions } from './shared'; import { SqxShellModule } from './shell'; -DateHelper.setlocale(window['options'].more.culture); +DateHelper.setlocale(window['options']?.more?.culture); export function configApiUrl() { const baseElements = document.getElementsByTagName('base'); diff --git a/frontend/app/features/settings/pages/clients/client.component.html b/frontend/app/features/settings/pages/clients/client.component.html index fd3c8f0a2..4b3eecec9 100644 --- a/frontend/app/features/settings/pages/clients/client.component.html +++ b/frontend/app/features/settings/pages/clients/client.component.html @@ -51,7 +51,7 @@ {{ 'common.role' | sqxTranslate }}
-
@@ -60,7 +60,7 @@
- +
+
+ +
+ + + {{ 'clients.apiCallsLimitHint' | sqxTranslate }} +
+
+ +
+
+
- + \ No newline at end of file diff --git a/frontend/app/features/settings/pages/clients/client.component.ts b/frontend/app/features/settings/pages/clients/client.component.ts index ed9305513..3a69075c7 100644 --- a/frontend/app/features/settings/pages/clients/client.component.ts +++ b/frontend/app/features/settings/pages/clients/client.component.ts @@ -5,7 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { AppsState, ClientDto, ClientsState, DialogModel, RoleDto } from '@app/shared'; @Component({ @@ -14,13 +14,15 @@ import { AppsState, ClientDto, ClientsState, DialogModel, RoleDto } from '@app/s templateUrl: './client.component.html', changeDetection: ChangeDetectionStrategy.OnPush }) -export class ClientComponent { +export class ClientComponent implements OnChanges { @Input() public client: ClientDto; @Input() public clientRoles: ReadonlyArray; + public apiCallsLimit: number; + public connectDialog = new DialogModel(); constructor( @@ -29,18 +31,28 @@ export class ClientComponent { ) { } + public ngOnChanges(changes: SimpleChanges ) { + if (changes['client']) { + this.apiCallsLimit = this.client.apiCallsLimit; + } + } + public revoke() { this.clientsState.revoke(this.client); } - public update(role: string) { + public updateRole(role: string) { this.clientsState.update(this.client, { role }); } - public updateAccess(allowAnonymous: boolean) { + public updateAllowAnonymous(allowAnonymous: boolean) { this.clientsState.update(this.client, { allowAnonymous }); } + public updateApiCallsLimit() { + this.clientsState.update(this.client, { apiCallsLimit: this.client.apiCallsLimit }); + } + public rename(name: string) { this.clientsState.update(this.client, { name }); } diff --git a/frontend/app/framework/angular/forms/editors/date-time-editor.component.html b/frontend/app/framework/angular/forms/editors/date-time-editor.component.html index f83ce504f..2d0095a86 100644 --- a/frontend/app/framework/angular/forms/editors/date-time-editor.component.html +++ b/frontend/app/framework/angular/forms/editors/date-time-editor.component.html @@ -2,13 +2,11 @@
- -
diff --git a/frontend/app/framework/utils/date-time.spec.ts b/frontend/app/framework/utils/date-time.spec.ts index cc4e5c2b3..1f28522d1 100644 --- a/frontend/app/framework/utils/date-time.spec.ts +++ b/frontend/app/framework/utils/date-time.spec.ts @@ -13,6 +13,10 @@ describe('DateTime', () => { const today2 = DateTime.today(); const now = DateTime.now(); + beforeEach(() => { + DateHelper.setlocale(null); + }); + it('should parse from iso string', () => { const actual = DateTime.parseISO('2013-10-16T12:13:14.125', false); diff --git a/frontend/app/shared/services/clients.service.spec.ts b/frontend/app/shared/services/clients.service.spec.ts index 6ef1eac31..0f6be91d6 100644 --- a/frontend/app/shared/services/clients.service.spec.ts +++ b/frontend/app/shared/services/clients.service.spec.ts @@ -165,6 +165,8 @@ describe('ClientsService', () => { name: `Client ${id}`, role: `Role${id}`, secret: `secret${id}`, + apiCallsLimit: id * 512, + apiTrafficLimit: id * 5120, allowAnonymous: true, _links: { update: { method: 'PUT', href: `/clients/id${id}` } @@ -192,5 +194,5 @@ export function createClient(id: number) { update: { method: 'PUT', href: `/clients/id${id}` } }; - return new ClientDto(links, `id${id}`, `Client ${id}`, `secret${id}`, `Role${id}`, true); + return new ClientDto(links, `id${id}`, `Client ${id}`, `secret${id}`, `Role${id}`, id * 512, id * 5120, true); } \ No newline at end of file diff --git a/frontend/app/shared/services/clients.service.ts b/frontend/app/shared/services/clients.service.ts index a84a9fa68..d82e8807b 100644 --- a/frontend/app/shared/services/clients.service.ts +++ b/frontend/app/shared/services/clients.service.ts @@ -30,6 +30,8 @@ export class ClientDto { public readonly name: string, public readonly secret: string, public readonly role: string, + public readonly apiCallsLimit: number, + public readonly apiTrafficLimit: number, public readonly allowAnonymous: boolean ) { this._links = links; @@ -54,6 +56,8 @@ export interface CreateClientDto { export interface UpdateClientDto { readonly name?: string; readonly role?: string; + readonly apiCallsLimit?: number; + readonly apiTrafficLimit?: number; readonly allowAnonymous?: boolean; } @@ -147,6 +151,8 @@ function parseClients(response: any): ClientsPayload { item.name || item.id, item.secret, item.role, + item.apiCallsLimit, + item.apiTrafficLimit, item.allowAnonymous)); const _links = response._links;