Browse Source

Plan enforcement.

pull/65/head
Sebastian Stehle 9 years ago
parent
commit
fb59474bc3
  1. 4
      src/Squidex.Read.MongoDb/Apps/MongoAppEntity.cs
  2. 2
      src/Squidex.Read/Apps/IAppEntity.cs
  3. 21
      src/Squidex.Read/Apps/Services/IAppLimitsPlan.cs
  4. 21
      src/Squidex.Read/Apps/Services/IAppLimitsProvider.cs
  5. 26
      src/Squidex.Read/Apps/Services/Implementations/ConfigAppLimitsPlan.cs
  6. 56
      src/Squidex.Read/Apps/Services/Implementations/ConfigAppLimitsProvider.cs
  7. 24
      src/Squidex.Write/Apps/AppCommandHandler.cs
  8. 5
      src/Squidex.Write/Apps/AppContributors.cs
  9. 10
      src/Squidex.Write/Apps/AppDomainObject.cs
  10. 13
      src/Squidex/Config/Domain/ReadModule.cs
  11. 17
      src/Squidex/Config/MyUsageOptions.cs
  12. 19
      src/Squidex/Controllers/Api/Apps/AppClientsController.cs
  13. 21
      src/Squidex/Controllers/Api/Apps/AppContributorsController.cs
  14. 21
      src/Squidex/Controllers/Api/Apps/AppLanguagesController.cs
  15. 26
      src/Squidex/Controllers/Api/Apps/Models/ContributorsDto.cs
  16. 24
      src/Squidex/Controllers/Api/Assets/AssetsController.cs
  17. 16
      src/Squidex/Controllers/Api/History/HistoryController.cs
  18. 5
      src/Squidex/Controllers/Api/Statistics/Models/CurrentCallsDto.cs
  19. 5
      src/Squidex/Controllers/Api/Statistics/Models/CurrentStorageDto.cs
  20. 17
      src/Squidex/Controllers/Api/Statistics/UsagesController.cs
  21. 23
      src/Squidex/Pipeline/AppApiFilter.cs
  22. 2
      src/Squidex/Pipeline/MustBeAppDeveloperAttribute.cs
  23. 2
      src/Squidex/Pipeline/MustBeAppEditorAttribute.cs
  24. 2
      src/Squidex/Pipeline/MustBeAppOwnerAttribute.cs
  25. 3
      src/Squidex/Startup.cs
  26. 10
      src/Squidex/app/features/dashboard/pages/dashboard-page.component.html
  27. 44
      src/Squidex/app/features/dashboard/pages/dashboard-page.component.ts
  28. 6
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html
  29. 9
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.scss
  30. 12
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts
  31. 34
      src/Squidex/app/shared/services/app-contributors.service.spec.ts
  32. 24
      src/Squidex/app/shared/services/app-contributors.service.ts
  33. 8
      src/Squidex/app/shared/services/usages.service.spec.ts
  34. 26
      src/Squidex/app/shared/services/usages.service.ts
  35. 18
      tests/Squidex.Infrastructure.Tests/CQRS/Events/CompoundEventConsumerTests.cs
  36. 14
      tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs
  37. 102
      tests/Squidex.Read.Tests/Apps/ConfigAppLimitsProviderTests.cs
  38. 26
      tests/Squidex.Write.Tests/Apps/AppCommandHandlerTests.cs
  39. 8
      tests/Squidex.Write.Tests/Assets/AssetCommandHandlerTests.cs

4
src/Squidex.Read.MongoDb/Apps/MongoAppEntity.cs

@ -32,6 +32,10 @@ namespace Squidex.Read.MongoDb.Apps
[BsonElement]
public long Version { get; set; }
[BsonIgnoreIfDefault]
[BsonElement]
public int PlanId { get; set; }
[BsonRequired]
[BsonElement]
public string MasterLanguage { get; set; }

2
src/Squidex.Read/Apps/IAppEntity.cs

@ -15,6 +15,8 @@ namespace Squidex.Read.Apps
{
string Name { get; }
int PlanId { get; }
LanguagesConfig LanguagesConfig { get; }
IReadOnlyCollection<IAppClientEntity> Clients { get; }

21
src/Squidex.Read/Apps/Services/IAppLimitsPlan.cs

@ -0,0 +1,21 @@
// ==========================================================================
// IAppLimitsPlan.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
namespace Squidex.Read.Apps.Services
{
public interface IAppLimitsPlan
{
string Name { get; }
long MaxApiCalls { get; }
long MaxAssetSize { get; }
int MaxContributors { get; }
}
}

21
src/Squidex.Read/Apps/Services/IAppLimitsProvider.cs

@ -0,0 +1,21 @@
// ==========================================================================
// IAppLimitsProvider.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System.Collections.Generic;
namespace Squidex.Read.Apps.Services
{
public interface IAppLimitsProvider
{
IEnumerable<IAppLimitsPlan> GetAvailablePlans();
IAppLimitsPlan GetPlanForApp(IAppEntity entity);
IAppLimitsPlan GetPlan(int planId);
}
}

26
src/Squidex.Read/Apps/Services/Implementations/ConfigAppLimitsPlan.cs

@ -0,0 +1,26 @@
// ==========================================================================
// ConfigAppLimitsPlan.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
namespace Squidex.Read.Apps.Services.Implementations
{
public sealed class ConfigAppLimitsPlan : IAppLimitsPlan
{
public string Name { get; set; }
public long MaxApiCalls { get; set; }
public long MaxAssetSize { get; set; }
public int MaxContributors { get; set; }
public ConfigAppLimitsPlan Clone()
{
return (ConfigAppLimitsPlan)MemberwiseClone();
}
}
}

56
src/Squidex.Read/Apps/Services/Implementations/ConfigAppLimitsProvider.cs

@ -0,0 +1,56 @@
// ==========================================================================
// ConfigAppLimitsProvider.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using Squidex.Infrastructure;
namespace Squidex.Read.Apps.Services.Implementations
{
public sealed class ConfigAppLimitsProvider : IAppLimitsProvider
{
private static readonly ConfigAppLimitsPlan Infinite = new ConfigAppLimitsPlan
{
Name = "Infinite",
MaxApiCalls = -1,
MaxAssetSize = -1,
MaxContributors = -1
};
private readonly List<ConfigAppLimitsPlan> config;
public ConfigAppLimitsProvider(IEnumerable<ConfigAppLimitsPlan> config)
{
Guard.NotNull(config, nameof(config));
this.config = config.Select(c => c.Clone()).OrderBy(x => x.MaxApiCalls).ToList();
}
public IEnumerable<IAppLimitsPlan> GetAvailablePlans()
{
return config;
}
public IAppLimitsPlan GetPlanForApp(IAppEntity app)
{
Guard.NotNull(app, nameof(app));
return GetPlan(app.PlanId);
}
public IAppLimitsPlan GetPlan(int planId)
{
if (planId >= 0 && planId < config.Count)
{
return config[planId];
}
return config.FirstOrDefault() ?? Infinite;
}
}
}

24
src/Squidex.Write/Apps/AppCommandHandler.cs

@ -12,21 +12,26 @@ using Squidex.Infrastructure.CQRS.Commands;
using Squidex.Infrastructure.Dispatching;
using Squidex.Infrastructure.Tasks;
using Squidex.Read.Apps.Repositories;
using Squidex.Read.Apps.Services;
using Squidex.Read.Users.Repositories;
using Squidex.Write.Apps.Commands;
// ReSharper disable InvertIf
namespace Squidex.Write.Apps
{
public class AppCommandHandler : ICommandHandler
{
private readonly IAggregateHandler handler;
private readonly IAppRepository appRepository;
private readonly IAppLimitsProvider appLimitsProvider;
private readonly IUserRepository userRepository;
private readonly ClientKeyGenerator keyGenerator;
public AppCommandHandler(
IAggregateHandler handler,
IAppRepository appRepository,
IAppLimitsProvider appLimitsProvider,
IUserRepository userRepository,
ClientKeyGenerator keyGenerator)
{
@ -34,11 +39,13 @@ namespace Squidex.Write.Apps
Guard.NotNull(keyGenerator, nameof(keyGenerator));
Guard.NotNull(appRepository, nameof(appRepository));
Guard.NotNull(userRepository, nameof(userRepository));
Guard.NotNull(appLimitsProvider, nameof(appLimitsProvider));
this.handler = handler;
this.keyGenerator = keyGenerator;
this.appRepository = appRepository;
this.userRepository = userRepository;
this.appRepository = appRepository;
this.appLimitsProvider = appLimitsProvider;
}
protected async Task On(CreateApp command, CommandContext context)
@ -71,7 +78,20 @@ namespace Squidex.Write.Apps
throw new ValidationException("Cannot assign contributor to app", error);
}
await handler.UpdateAsync<AppDomainObject>(context, a => a.AssignContributor(command));
await handler.UpdateAsync<AppDomainObject>(context, a =>
{
var oldContributors = a.ContributorCount;
var maxContributors = appLimitsProvider.GetPlan(a.PlanId).MaxContributors;
a.AssignContributor(command);
if (a.ContributorCount > oldContributors && a.ContributorCount > maxContributors)
{
var error = new ValidationError("You have reached your max number of contributors");
throw new ValidationException("Cannot assign contributor to app", error);
}
});
}
protected Task On(AttachClient command, CommandContext context)

5
src/Squidex.Write/Apps/AppContributors.cs

@ -20,6 +20,11 @@ namespace Squidex.Write.Apps
{
private readonly Dictionary<string, PermissionLevel> contributors = new Dictionary<string, PermissionLevel>();
public int Count
{
get { return contributors.Count; }
}
public void Assign(string contributorId, PermissionLevel permission)
{
string Message() => "Cannot assign contributor";

10
src/Squidex.Write/Apps/AppDomainObject.cs

@ -36,6 +36,16 @@ namespace Squidex.Write.Apps
get { return name; }
}
public int PlanId
{
get { return 0; }
}
public int ContributorCount
{
get { return contributors.Count; }
}
public IReadOnlyDictionary<string, AppClient> Clients
{
get { return clients.Clients; }

13
src/Squidex/Config/Domain/ReadModule.cs

@ -6,8 +6,11 @@
// All rights reserved.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using Autofac;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using Squidex.Read.Apps;
using Squidex.Read.Apps.Services;
using Squidex.Read.Apps.Services.Implementations;
@ -33,11 +36,21 @@ namespace Squidex.Config.Domain
protected override void Load(ContainerBuilder builder)
{
builder.Register(c => c.Resolve<IOptions<MyUsageOptions>>().Value?.Plans ?? Enumerable.Empty<ConfigAppLimitsPlan>())
.As<IEnumerable<ConfigAppLimitsPlan>>()
.AsSelf()
.SingleInstance();
builder.RegisterType<CachingAppProvider>()
.As<IAppProvider>()
.AsSelf()
.SingleInstance();
builder.RegisterType<ConfigAppLimitsProvider>()
.As<IAppLimitsProvider>()
.AsSelf()
.SingleInstance();
builder.RegisterType<CachingSchemaProvider>()
.As<ISchemaProvider>()
.AsSelf()

17
src/Squidex/Config/MyUsageOptions.cs

@ -0,0 +1,17 @@
// ==========================================================================
// MyUsageOptions.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using Squidex.Read.Apps.Services.Implementations;
namespace Squidex.Config
{
public class MyUsageOptions
{
public ConfigAppLimitsPlan[] Plans { get; set; }
}
}

19
src/Squidex/Controllers/Api/Apps/AppClientsController.cs

@ -15,7 +15,6 @@ using Squidex.Controllers.Api.Apps.Models;
using Squidex.Infrastructure.CQRS.Commands;
using Squidex.Infrastructure.Reflection;
using Squidex.Pipeline;
using Squidex.Read.Apps.Services;
using Squidex.Write.Apps;
using Squidex.Write.Apps.Commands;
@ -30,12 +29,9 @@ namespace Squidex.Controllers.Api.Apps
[SwaggerTag("Apps")]
public class AppClientsController : ControllerBase
{
private readonly IAppProvider appProvider;
public AppClientsController(ICommandBus commandBus, IAppProvider appProvider)
public AppClientsController(ICommandBus commandBus)
: base(commandBus)
{
this.appProvider = appProvider;
}
/// <summary>
@ -53,18 +49,11 @@ namespace Squidex.Controllers.Api.Apps
[Route("apps/{app}/clients/")]
[ProducesResponseType(typeof(ClientDto[]), 200)]
[ApiCosts(1)]
public async Task<IActionResult> GetClients(string app)
public IActionResult GetClients(string app)
{
var entity = await appProvider.FindAppByNameAsync(app);
if (entity == null)
{
return NotFound();
}
var response = entity.Clients.Select(x => SimpleMapper.Map(x, new ClientDto())).ToList();
var response = App.Clients.Select(x => SimpleMapper.Map(x, new ClientDto())).ToList();
Response.Headers["ETag"] = new StringValues(entity.Version.ToString());
Response.Headers["ETag"] = new StringValues(App.Version.ToString());
return Ok(response);
}

21
src/Squidex/Controllers/Api/Apps/AppContributorsController.cs

@ -29,12 +29,12 @@ namespace Squidex.Controllers.Api.Apps
[SwaggerTag("Apps")]
public class AppContributorsController : ControllerBase
{
private readonly IAppProvider appProvider;
private readonly IAppLimitsProvider appLimitsProvider;
public AppContributorsController(ICommandBus commandBus, IAppProvider appProvider)
public AppContributorsController(ICommandBus commandBus, IAppLimitsProvider appLimitsProvider)
: base(commandBus)
{
this.appProvider = appProvider;
this.appLimitsProvider = appLimitsProvider;
}
/// <summary>
@ -47,20 +47,15 @@ namespace Squidex.Controllers.Api.Apps
/// </returns>
[HttpGet]
[Route("apps/{app}/contributors/")]
[ProducesResponseType(typeof(ContributorDto[]), 200)]
[ProducesResponseType(typeof(ContributorsDto), 200)]
[ApiCosts(1)]
public async Task<IActionResult> GetContributors(string app)
public IActionResult GetContributors(string app)
{
var entity = await appProvider.FindAppByNameAsync(app);
var contributors = App.Contributors.Select(x => SimpleMapper.Map(x, new ContributorDto())).ToArray();
if (entity == null)
{
return NotFound();
}
var response = new ContributorsDto { Contributors = contributors, MaxContributors = appLimitsProvider.GetPlanForApp(App).MaxContributors };
var response = entity.Contributors.Select(x => SimpleMapper.Map(x, new ContributorDto())).ToList();
Response.Headers["ETag"] = new StringValues(entity.Version.ToString());
Response.Headers["ETag"] = new StringValues(App.Version.ToString());
return Ok(response);
}

21
src/Squidex/Controllers/Api/Apps/AppLanguagesController.cs

@ -19,7 +19,6 @@ using Squidex.Controllers.Api.Apps.Models;
using Squidex.Core;
using Squidex.Infrastructure;
using Squidex.Pipeline;
using Squidex.Read.Apps.Services;
using Squidex.Write.Apps.Commands;
namespace Squidex.Controllers.Api.Apps
@ -32,12 +31,9 @@ namespace Squidex.Controllers.Api.Apps
[SwaggerTag("Apps")]
public class AppLanguagesController : ControllerBase
{
private readonly IAppProvider appProvider;
public AppLanguagesController(ICommandBus commandBus, IAppProvider appProvider)
public AppLanguagesController(ICommandBus commandBus)
: base(commandBus)
{
this.appProvider = appProvider;
}
/// <summary>
@ -52,25 +48,18 @@ namespace Squidex.Controllers.Api.Apps
[HttpGet]
[Route("apps/{app}/languages/")]
[ProducesResponseType(typeof(LanguageDto[]), 200)]
public async Task<IActionResult> GetLanguages(string app)
public IActionResult GetLanguages(string app)
{
var entity = await appProvider.FindAppByNameAsync(app);
if (entity == null)
{
return NotFound();
}
var model = entity.LanguagesConfig.OfType<LanguageConfig>().Select(x =>
var model = App.LanguagesConfig.OfType<LanguageConfig>().Select(x =>
SimpleMapper.Map(x.Language,
new AppLanguageDto
{
IsMaster = x == entity.LanguagesConfig.Master,
IsMaster = x == App.LanguagesConfig.Master,
IsOptional = x.IsOptional,
Fallback = x.Fallback.ToList()
})).OrderByDescending(x => x.IsMaster).ThenBy(x => x.Iso2Code).ToList();
Response.Headers["ETag"] = new StringValues(entity.Version.ToString());
Response.Headers["ETag"] = new StringValues(App.Version.ToString());
return Ok(model);
}

26
src/Squidex/Controllers/Api/Apps/Models/ContributorsDto.cs

@ -0,0 +1,26 @@
// ==========================================================================
// ContributorsDto.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
namespace Squidex.Controllers.Api.Apps.Models
{
public class ContributorsDto
{
/// <summary>
/// The contributors.
/// </summary>
[Required]
public ContributorDto[] Contributors { get; set; }
/// <summary>
/// The maximum number of allowed contributors.
/// </summary>
public int MaxContributors { get; set; }
}
}

24
src/Squidex/Controllers/Api/Assets/AssetsController.cs

@ -21,6 +21,7 @@ using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.CQRS.Commands;
using Squidex.Infrastructure.Reflection;
using Squidex.Pipeline;
using Squidex.Read.Apps.Services;
using Squidex.Read.Assets.Repositories;
using Squidex.Write.Assets.Commands;
@ -36,16 +37,22 @@ namespace Squidex.Controllers.Api.Assets
public class AssetsController : ControllerBase
{
private readonly IAssetRepository assetRepository;
private readonly IAssetStatsRepository assetStatsRepository;
private readonly IAppLimitsProvider appLimitProvider;
private readonly AssetConfig assetsConfig;
public AssetsController(
ICommandBus commandBus,
IAssetRepository assetRepository,
IAssetStatsRepository assetStatsRepository,
IAppLimitsProvider appLimitProvider,
IOptions<AssetConfig> assetsConfig)
: base(commandBus)
{
this.assetsConfig = assetsConfig.Value;
this.assetRepository = assetRepository;
this.assetStatsRepository = assetStatsRepository;
this.appLimitProvider = appLimitProvider;
}
/// <summary>
@ -149,7 +156,7 @@ namespace Squidex.Controllers.Api.Assets
[ProducesResponseType(typeof(ErrorDto), 400)]
public async Task<IActionResult> PostAsset(string app, List<IFormFile> file)
{
var assetFile = GetAssetFile(file);
var assetFile = await CheckAssetFileAsync(file);
var command = new CreateAsset { File = assetFile };
var context = await CommandBus.PublishAsync(command);
@ -178,7 +185,7 @@ namespace Squidex.Controllers.Api.Assets
[ApiCosts(1)]
public async Task<IActionResult> PutAssetContent(string app, Guid id, List<IFormFile> file)
{
var assetFile = GetAssetFile(file);
var assetFile = await CheckAssetFileAsync(file);
var command = new UpdateAsset { File = assetFile, AssetId = id };
var context = await CommandBus.PublishAsync(command);
@ -232,7 +239,7 @@ namespace Squidex.Controllers.Api.Assets
return NoContent();
}
private AssetFile GetAssetFile(IReadOnlyList<IFormFile> file)
private async Task<AssetFile> CheckAssetFileAsync(IReadOnlyList<IFormFile> file)
{
if (file.Count != 1)
{
@ -250,6 +257,17 @@ namespace Squidex.Controllers.Api.Assets
throw new ValidationException("Cannot create asset.", error);
}
var plan = appLimitProvider.GetPlanForApp(App);
var currentSize = await assetStatsRepository.GetTotalSizeAsync(App.Id);
if (plan.MaxAssetSize > 0 && plan.MaxAssetSize < currentSize + formFile.Length)
{
var error = new ValidationError("You have reached your max asset size.");
throw new ValidationException("Cannot create asset.", error);
}
var assetFile = new AssetFile(formFile.FileName, formFile.ContentType, formFile.Length, formFile.OpenReadStream);
return assetFile;

16
src/Squidex/Controllers/Api/History/HistoryController.cs

@ -29,14 +29,11 @@ namespace Squidex.Controllers.Api.History
[SwaggerTag("History")]
public class HistoryController : ControllerBase
{
private readonly IAppProvider appProvider;
private readonly IHistoryEventRepository historyEventRepository;
public HistoryController(ICommandBus commandBus, IAppProvider appProvider, IHistoryEventRepository historyEventRepository)
public HistoryController(ICommandBus commandBus, IHistoryEventRepository historyEventRepository)
: base(commandBus)
{
this.appProvider = appProvider;
this.historyEventRepository = historyEventRepository;
}
@ -55,16 +52,9 @@ namespace Squidex.Controllers.Api.History
[ApiCosts(0.1)]
public async Task<IActionResult> GetHistory(string app, string channel)
{
var entity = await appProvider.FindAppByNameAsync(app);
if (entity == null)
{
return NotFound();
}
var schemas = await historyEventRepository.QueryByChannelAsync(entity.Id, channel, 100);
var entities = await historyEventRepository.QueryByChannelAsync(App.Id, channel, 100);
var response = schemas.Select(x => SimpleMapper.Map(x, new HistoryEventDto())).ToList();
var response = entities.Select(x => SimpleMapper.Map(x, new HistoryEventDto())).ToList();
return Ok(response);
}

5
src/Squidex/Controllers/Api/Statistics/Models/CurrentCallsDto.cs

@ -14,5 +14,10 @@ namespace Squidex.Controllers.Api.Statistics.Models
/// The number of calls.
/// </summary>
public long Count { get; set; }
/// <summary>
/// The number of maximum allowed calls.
/// </summary>
public long MaxAllowed { get; set; }
}
}

5
src/Squidex/Controllers/Api/Statistics/Models/CurrentStorageDto.cs

@ -14,5 +14,10 @@ namespace Squidex.Controllers.Api.Statistics.Models
/// The size in bytes.
/// </summary>
public long Size { get; set; }
/// <summary>
/// The maximum allowed asset size.
/// </summary>
public long MaxAllowed { get; set; }
}
}

17
src/Squidex/Controllers/Api/Statistics/UsagesController.cs

@ -15,6 +15,7 @@ using Squidex.Controllers.Api.Statistics.Models;
using Squidex.Infrastructure.CQRS.Commands;
using Squidex.Infrastructure.UsageTracking;
using Squidex.Pipeline;
using Squidex.Read.Apps.Services;
using Squidex.Read.Assets.Repositories;
namespace Squidex.Controllers.Api.Statistics
@ -28,13 +29,19 @@ namespace Squidex.Controllers.Api.Statistics
public class UsagesController : ControllerBase
{
private readonly IUsageTracker usageTracker;
private readonly IAppLimitsProvider appLimitProvider;
private readonly IAssetStatsRepository assetStatsRepository;
public UsagesController(ICommandBus commandBus, IUsageTracker usageTracker, IAssetStatsRepository assetStatsRepository)
public UsagesController(
ICommandBus commandBus,
IUsageTracker usageTracker,
IAppLimitsProvider appLimitProvider,
IAssetStatsRepository assetStatsRepository)
: base(commandBus)
{
this.usageTracker = usageTracker;
this.appLimitProvider = appLimitProvider;
this.assetStatsRepository = assetStatsRepository;
}
@ -55,7 +62,9 @@ namespace Squidex.Controllers.Api.Statistics
{
var count = await usageTracker.GetMonthlyCalls(App.Id.ToString(), DateTime.Today);
return Ok(new CurrentCallsDto { Count = count });
var plan = appLimitProvider.GetPlanForApp(App);
return Ok(new CurrentCallsDto { Count = count, MaxAllowed = plan.MaxApiCalls });
}
/// <summary>
@ -110,7 +119,9 @@ namespace Squidex.Controllers.Api.Statistics
{
var size = await assetStatsRepository.GetTotalSizeAsync(App.Id);
return Ok(new CurrentStorageDto { Size = size });
var plan = appLimitProvider.GetPlanForApp(App);
return Ok(new CurrentStorageDto { Size = size, MaxAllowed = plan.MaxAssetSize });
}
/// <summary>

23
src/Squidex/Pipeline/AppApiFilter.cs

@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Mvc.Filters;
using Squidex.Core.Apps;
using Squidex.Core.Identity;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.UsageTracking;
using Squidex.Read.Apps;
using Squidex.Read.Apps.Services;
@ -25,10 +26,15 @@ namespace Squidex.Pipeline
public sealed class AppApiFilter : IAsyncAuthorizationFilter
{
private readonly IAppProvider appProvider;
private readonly IAppLimitsProvider appLimitProvider;
private readonly IUsageTracker usageTracker;
public AppApiFilter(IAppProvider appProvider)
public AppApiFilter(IAppProvider appProvider, IAppLimitsProvider appLimitProvider, IUsageTracker usageTracker)
{
this.appProvider = appProvider;
this.appLimitProvider = appLimitProvider;
this.usageTracker = usageTracker;
}
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
@ -56,6 +62,16 @@ namespace Squidex.Pipeline
context.Result = new NotFoundResult();
return;
}
var plan = appLimitProvider.GetPlanForApp(app);
var usage = await usageTracker.GetMonthlyCalls(app.Id.ToString(), DateTime.Today);
if (plan.MaxApiCalls >= 0 && (usage * 1.1) > plan.MaxApiCalls)
{
context.Result = new StatusCodeResult(429);
return;
}
var defaultIdentity = context.HttpContext.User.Identities.First();
@ -63,12 +79,15 @@ namespace Squidex.Pipeline
{
case PermissionLevel.Owner:
defaultIdentity.AddClaim(new Claim(defaultIdentity.RoleClaimType, SquidexRoles.AppOwner));
defaultIdentity.AddClaim(new Claim(defaultIdentity.RoleClaimType, SquidexRoles.AppDeveloper));
defaultIdentity.AddClaim(new Claim(defaultIdentity.RoleClaimType, SquidexRoles.AppEditor));
break;
case PermissionLevel.Editor:
defaultIdentity.AddClaim(new Claim(defaultIdentity.RoleClaimType, SquidexRoles.AppDeveloper));
defaultIdentity.AddClaim(new Claim(defaultIdentity.RoleClaimType, SquidexRoles.AppEditor));
break;
case PermissionLevel.Developer:
defaultIdentity.AddClaim(new Claim(defaultIdentity.RoleClaimType, SquidexRoles.AppDeveloper));
defaultIdentity.AddClaim(new Claim(defaultIdentity.RoleClaimType, SquidexRoles.AppEditor));
break;
}

2
src/Squidex/Pipeline/MustBeAppDeveloperAttribute.cs

@ -15,7 +15,7 @@ namespace Squidex.Pipeline
{
public MustBeAppDeveloperAttribute()
{
Roles = $"{SquidexRoles.AppOwner},{SquidexRoles.AppDeveloper}";
Roles = SquidexRoles.AppDeveloper;
}
}
}

2
src/Squidex/Pipeline/MustBeAppEditorAttribute.cs

@ -15,7 +15,7 @@ namespace Squidex.Pipeline
{
public MustBeAppEditorAttribute()
{
Roles = $"{SquidexRoles.AppOwner},{SquidexRoles.AppDeveloper},{SquidexRoles.AppEditor}";
Roles = SquidexRoles.AppEditor;
}
}
}

2
src/Squidex/Pipeline/MustBeAppOwnerAttribute.cs

@ -15,7 +15,7 @@ namespace Squidex.Pipeline
{
public MustBeAppOwnerAttribute()
{
Roles = $"{SquidexRoles.AppOwner}";
Roles = SquidexRoles.AppOwner;
}
}
}

3
src/Squidex/Startup.cs

@ -24,6 +24,7 @@ using Squidex.Config.Swagger;
using Squidex.Config.Web;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Log.Adapter;
using Squidex.Read.Apps.Services.Implementations;
// ReSharper disable ConvertClosureToMethodGroup
// ReSharper disable AccessToModifiedClosure
@ -75,6 +76,8 @@ namespace Squidex
Configuration.GetSection("urls"));
services.Configure<MyIdentityOptions>(
Configuration.GetSection("identity"));
services.Configure<MyUsageOptions>(
Configuration.GetSection("usage"));
var builder = new ContainerBuilder();
builder.Populate(services);

10
src/Squidex/app/features/dashboard/pages/dashboard-page.component.html

@ -70,9 +70,10 @@
<div class="card card">
<div class="card-block">
<div class="aggregation" *ngIf="currentCalls">
<div class="aggregation" *ngIf="callsCurrent">
<div class="aggregation-label">API calls this month</div>
<div class="aggregation-value">{{currentCalls}}</div>
<div class="aggregation-value">{{callsCurrent}}</div>
<div class="aggregation-label" *ngIf="callsMax">Monthly limit: {{callsMax}}</div>
</div>
</div>
</div>
@ -85,9 +86,10 @@
<div class="card card">
<div class="card-block">
<div class="aggregation" *ngIf="currentStorage">
<div class="aggregation" *ngIf="assetsCurrent">
<div class="aggregation-label">Asset size today</div>
<div class="aggregation-value">{{currentStorage}}</div>
<div class="aggregation-value">{{assetsCurrent}}</div>
<div class="aggregation-label" *ngIf="callsMax">Total limit: {{assetsMax}}</div>
</div>
</div>
</div>

44
src/Squidex/app/features/dashboard/pages/dashboard-page.component.ts

@ -51,8 +51,11 @@ export class DashboardPageComponent extends AppComponentBase implements OnInit,
maintainAspectRatio: false
};
public currentStorage: string | null = null;
public currentCalls: string | null = null;
public assetsCurrent: string | null = null;
public assetsMax: string | null = null;
public callsCurrent: string | null = null;
public callsMax: string | null = null;
constructor(apps: AppsStoreService, notifications: NotificationService,
private readonly auth: AuthService,
@ -69,26 +72,15 @@ export class DashboardPageComponent extends AppComponentBase implements OnInit,
this.appName()
.switchMap(app => this.usagesService.getTodayStorage(app))
.subscribe(dto => {
this.currentStorage = FileHelper.fileSize(dto.size);
this.assetsCurrent = FileHelper.fileSize(dto.size);
this.assetsMax = FileHelper.fileSize(dto.maxAllowed);
});
this.appName()
.switchMap(app => this.usagesService.getMonthCalls(app))
.subscribe(dto => {
let count = dto.count;
if (count > 1000) {
count = count / 1000;
if (count < 10) {
count = Math.round(count * 10) / 10;
} else {
count = Math.round(count);
}
this.currentCalls = count + 'k';
} else {
this.currentCalls = count.toString();
}
this.callsCurrent = formatCalls(dto.count);
this.callsMax = formatCalls(dto.maxAllowed);
});
this.appName()
@ -170,6 +162,24 @@ export class DashboardPageComponent extends AppComponentBase implements OnInit,
}
}
function formatCalls(count: number): string {
if (count > 1000) {
count = count / 1000;
if (count < 10) {
count = Math.round(count * 10) / 10;
} else {
count = Math.round(count);
}
return count + 'k';
} else if (count < 0) {
return undefined;
} else {
return count.toString();
}
}
function createLabels(dtos: { date: DateTime }[]): string[] {
const labels: string[] = [];

6
src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html

@ -13,6 +13,10 @@
<div class="panel-main">
<div class="panel-content panel-content-scroll">
<div class="contributors-limit" *ngIf="maxContributors > 0">
Your plan allows up to {{maxContributors}} contributors.
</div>
<table class="table table-items table-fixed">
<colgroup>
<col style="width: 70px" />
@ -56,7 +60,7 @@
<sqx-autocomplete [source]="usersDataSource" formControlName="user" [inputName]="'contributor'" placeholder="Find existing user"></sqx-autocomplete>
</div>
<button type="submit" class="btn btn-success" [disabled]="!addContributorForm.valid">Add Contributor</button>
<button type="submit" class="btn btn-success" [disabled]="!canAddContributor">Add Contributor</button>
</form>
</div>
</div>

9
src/Squidex/app/features/settings/pages/contributors/contributors-page.component.scss

@ -11,4 +11,13 @@
font-style: italic;
font-size: .8rem;
}
}
.contributors-limit {
margin: -$panel-padding;
margin-bottom: $panel-padding;
color: $color-dark-foreground;
background: $color-theme-green-dark;
border: 0;
padding: .5 * $panel-padding $panel-padding;
}

12
src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts

@ -64,6 +64,8 @@ export class ContributorsPageComponent extends AppComponentBase implements OnIni
public currentUserId: string;
public maxContributors = -1;
public usersDataSource: UsersDataSource;
public usersPermissions = [
'Owner',
@ -71,6 +73,10 @@ export class ContributorsPageComponent extends AppComponentBase implements OnIni
'Editor'
];
public get canAddContributor() {
return this.addContributorForm.valid && (this.maxContributors < -1 || this.appContributors.length < this.maxContributors);
}
public addContributorForm: FormGroup =
this.formBuilder.group({
user: [null,
@ -99,8 +105,10 @@ export class ContributorsPageComponent extends AppComponentBase implements OnIni
public load() {
this.appNameOnce()
.switchMap(app => this.appContributorsService.getContributors(app, this.version).retry(2))
.subscribe(dtos => {
this.updateContributors(ImmutableArray.of(dtos));
.subscribe(dto => {
this.updateContributors(ImmutableArray.of(dto.contributors));
this.maxContributors = dto.maxContributors;
}, error => {
this.notifyError(error);
});

34
src/Squidex/app/shared/services/app-contributors.service.spec.ts

@ -12,6 +12,7 @@ import { IMock, Mock, Times } from 'typemoq';
import {
ApiUrlConfig,
AppContributorDto,
AppContributorsDto,
AppContributorsService,
AuthService,
Version
@ -32,32 +33,35 @@ describe('AppContributorsService', () => {
.returns(() => Observable.of(
new Response(
new ResponseOptions({
body: [
{
contributorId: '123',
permission: 'Owner'
},
{
contributorId: '456',
permission: 'Owner'
}
]
body: {
contributors: [
{
contributorId: '123',
permission: 'Owner'
},
{
contributorId: '456',
permission: 'Owner'
}
],
maxContributors: 100
}
})
)
))
.verifiable(Times.once());
let contributors: AppContributorDto[] | null = null;
let contributors: AppContributorsDto | null = null;
appContributorsService.getContributors('my-app', version).subscribe(result => {
contributors = result;
}).unsubscribe();
expect(contributors).toEqual(
[
new AppContributorDto('123', 'Owner'),
new AppContributorDto('456', 'Owner')
]);
new AppContributorsDto([
new AppContributorDto('123', 'Owner'),
new AppContributorDto('456', 'Owner')
], 100));
authService.verifyAll();
});

24
src/Squidex/app/shared/services/app-contributors.service.ts

@ -21,6 +21,14 @@ export class AppContributorDto {
}
}
export class AppContributorsDto {
constructor(
public readonly contributors: AppContributorDto[],
public readonly maxContributors: number
) {
}
}
@Injectable()
export class AppContributorsService {
constructor(
@ -29,19 +37,21 @@ export class AppContributorsService {
) {
}
public getContributors(appName: string, version?: Version): Observable<AppContributorDto[]> {
public getContributors(appName: string, version?: Version): Observable<AppContributorsDto> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/contributors`);
return this.authService.authGet(url, version)
.map(response => response.json())
.map(response => {
const items: any[] = response;
const items: any[] = response.contributors;
return items.map(item => {
return new AppContributorDto(
item.contributorId,
item.permission);
});
return new AppContributorsDto(
items.map(item => {
return new AppContributorDto(
item.contributorId,
item.permission);
}),
response.maxContributors);
})
.catchError('Failed to load contributors. Please reload.');
}

8
src/Squidex/app/shared/services/usages.service.spec.ts

@ -71,7 +71,7 @@ describe('UsagesService', () => {
.returns(() => Observable.of(
new Response(
new ResponseOptions({
body: { count: 130 }
body: { count: 130, maxAllowed: 150 }
})
)
))
@ -83,7 +83,7 @@ describe('UsagesService', () => {
usages = result;
}).unsubscribe();
expect(usages).toEqual(new CurrentCallsDto(130));
expect(usages).toEqual(new CurrentCallsDto(130, 150));
authService.verifyAll();
});
@ -130,7 +130,7 @@ describe('UsagesService', () => {
.returns(() => Observable.of(
new Response(
new ResponseOptions({
body: { size: 130 }
body: { size: 130, maxAllowed: 150 }
})
)
))
@ -142,7 +142,7 @@ describe('UsagesService', () => {
usages = result;
}).unsubscribe();
expect(usages).toEqual(new CurrentStorageDto(130));
expect(usages).toEqual(new CurrentStorageDto(130, 150));
authService.verifyAll();
});

26
src/Squidex/app/shared/services/usages.service.ts

@ -33,14 +33,16 @@ export class StorageUsageDto {
export class CurrentStorageDto {
constructor(
public readonly size: number
public readonly size: number,
public readonly maxAllowed: number
) {
}
}
export class CurrentCallsDto {
constructor(
public readonly count: number
public readonly count: number,
public readonly maxAllowed: number
) {
}
}
@ -58,10 +60,19 @@ export class UsagesService {
return this.authService.authGet(url)
.map(response => response.json())
.map(response => new CurrentCallsDto(response.count))
.map(response => new CurrentCallsDto(response.count, response.maxAllowed))
.catchError('Failed to load monthly api calls. Please reload.');
}
public getTodayStorage(app: string): Observable<CurrentStorageDto> {
const url = this.apiUrl.buildUrl(`api/apps/${app}/usages/storage/today`);
return this.authService.authGet(url)
.map(response => response.json())
.map(response => new CurrentStorageDto(response.size, response.maxAllowed))
.catchError('Failed to load todays storage size. Please reload.');
}
public getCallsUsages(app: string, fromDate: DateTime, toDate: DateTime): Observable<CallsUsageDto[]> {
const url = this.apiUrl.buildUrl(`api/apps/${app}/usages/calls/${fromDate.toStringFormat('YYYY-MM-DD')}/${toDate.toStringFormat('YYYY-MM-DD')}`);
@ -80,15 +91,6 @@ export class UsagesService {
.catchError('Failed to load calls usage. Please reload.');
}
public getTodayStorage(app: string): Observable<CurrentStorageDto> {
const url = this.apiUrl.buildUrl(`api/apps/${app}/usages/storage/today`);
return this.authService.authGet(url)
.map(response => response.json())
.map(response => new CurrentStorageDto(response.size))
.catchError('Failed to load todays storage size. Please reload.');
}
public getStorageUsages(app: string, fromDate: DateTime, toDate: DateTime): Observable<StorageUsageDto[]> {
const url = this.apiUrl.buildUrl(`api/apps/${app}/usages/storage/${fromDate.toStringFormat('YYYY-MM-DD')}/${toDate.toStringFormat('YYYY-MM-DD')}`);

18
tests/Squidex.Infrastructure.Tests/CQRS/Events/CompoundEventConsumerTests.cs

@ -57,8 +57,13 @@ namespace Squidex.Infrastructure.CQRS.Events
[Fact]
public async Task Should_clear_all_consumers()
{
consumer1.Setup(x => x.ClearAsync()).Returns(TaskHelper.Done).Verifiable();
consumer2.Setup(x => x.ClearAsync()).Returns(TaskHelper.Done).Verifiable();
consumer1.Setup(x => x.ClearAsync()).
Returns(TaskHelper.Done)
.Verifiable();
consumer2.Setup(x => x.ClearAsync())
.Returns(TaskHelper.Done)
.Verifiable();
var sut = new CompoundEventConsumer("consumer-name", consumer1.Object, consumer2.Object);
@ -73,8 +78,13 @@ namespace Squidex.Infrastructure.CQRS.Events
{
var @event = Envelope.Create(new MyEvent());
consumer1.Setup(x => x.On(@event)).Returns(TaskHelper.Done).Verifiable();
consumer2.Setup(x => x.On(@event)).Returns(TaskHelper.Done).Verifiable();
consumer1.Setup(x => x.On(@event))
.Returns(TaskHelper.Done)
.Verifiable();
consumer2.Setup(x => x.On(@event))
.Returns(TaskHelper.Done)
.Verifiable();
var sut = new CompoundEventConsumer("consumer-name", consumer1.Object, consumer2.Object);

14
tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs

@ -121,9 +121,17 @@ namespace Squidex.Infrastructure.UsageTracking
{
var today = DateTime.Today;
usageStore.Setup(x => x.TrackUsagesAsync(today, "key1", 1.0, 1000)).Returns(TaskHelper.Done).Verifiable();
usageStore.Setup(x => x.TrackUsagesAsync(today, "key2", 1.5, 5000)).Returns(TaskHelper.Done).Verifiable();
usageStore.Setup(x => x.TrackUsagesAsync(today, "key3", 0.9, 15000)).Returns(TaskHelper.Done).Verifiable();
usageStore.Setup(x => x.TrackUsagesAsync(today, "key1", 1.0, 1000))
.Returns(TaskHelper.Done)
.Verifiable();
usageStore.Setup(x => x.TrackUsagesAsync(today, "key2", 1.5, 5000))
.Returns(TaskHelper.Done)
.Verifiable();
usageStore.Setup(x => x.TrackUsagesAsync(today, "key3", 0.9, 15000))
.Returns(TaskHelper.Done)
.Verifiable();
await sut.TrackAsync("key1", 1, 1000);

102
tests/Squidex.Read.Tests/Apps/ConfigAppLimitsProviderTests.cs

@ -0,0 +1,102 @@
// ==========================================================================
// ConfigAppLimitsProviderTests.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System.Linq;
using FluentAssertions;
using Moq;
using Squidex.Read.Apps.Services.Implementations;
using Xunit;
namespace Squidex.Read.Apps
{
public class ConfigAppLimitsProviderTests
{
private static readonly ConfigAppLimitsPlan[] Plans =
{
new ConfigAppLimitsPlan
{
Name = "Basic",
MaxApiCalls = 150000,
MaxAssetSize = 1024 * 1024 * 2,
MaxContributors = 5
},
new ConfigAppLimitsPlan
{
Name = "Free",
MaxApiCalls = 50000,
MaxAssetSize = 1024 * 1024 * 10,
MaxContributors = 2
}
};
[Fact]
public void Should_return_plans()
{
var sut = new ConfigAppLimitsProvider(Plans);
Plans.OrderBy(x => x.MaxApiCalls).ShouldBeEquivalentTo(sut.GetAvailablePlans());
}
[Fact]
public void Should_return_infinite_if_nothing_configured()
{
var sut = new ConfigAppLimitsProvider(Enumerable.Empty<ConfigAppLimitsPlan>());
var plan = sut.GetPlanForApp(CreateApp(0));
plan.ShouldBeEquivalentTo(new ConfigAppLimitsPlan
{
Name = "Infinite",
MaxApiCalls = -1,
MaxAssetSize = -1,
MaxContributors = -1
});
}
[Fact]
public void Should_return_fitting_app_plan()
{
var sut = new ConfigAppLimitsProvider(Plans);
var plan = sut.GetPlanForApp(CreateApp(1));
plan.ShouldBeEquivalentTo(new ConfigAppLimitsPlan
{
Name = "Basic",
MaxApiCalls = 150000,
MaxAssetSize = 1024 * 1024 * 2,
MaxContributors = 5
});
}
[Fact]
public void Should_smallest_plan_if_none_fits()
{
var sut = new ConfigAppLimitsProvider(Plans);
var plan = sut.GetPlanForApp(CreateApp(4));
plan.ShouldBeEquivalentTo(new ConfigAppLimitsPlan
{
Name = "Free",
MaxApiCalls = 50000,
MaxAssetSize = 1024 * 1024 * 10,
MaxContributors = 2
});
}
private static IAppEntity CreateApp(int plan)
{
var app = new Mock<IAppEntity>();
app.Setup(x => x.PlanId).Returns(plan);
return app.Object;
}
}
}

26
tests/Squidex.Write.Tests/Apps/AppCommandHandlerTests.cs

@ -14,6 +14,8 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.CQRS.Commands;
using Squidex.Read.Apps;
using Squidex.Read.Apps.Repositories;
using Squidex.Read.Apps.Services;
using Squidex.Read.Apps.Services.Implementations;
using Squidex.Read.Users;
using Squidex.Read.Users.Repositories;
using Squidex.Write.Apps.Commands;
@ -29,6 +31,7 @@ namespace Squidex.Write.Apps
{
private readonly Mock<ClientKeyGenerator> keyGenerator = new Mock<ClientKeyGenerator>();
private readonly Mock<IAppRepository> appRepository = new Mock<IAppRepository>();
private readonly Mock<IAppLimitsProvider> appLimitsProvider = new Mock<IAppLimitsProvider>();
private readonly Mock<IUserRepository> userRepository = new Mock<IUserRepository>();
private readonly AppCommandHandler sut;
private readonly AppDomainObject app;
@ -39,9 +42,11 @@ namespace Squidex.Write.Apps
public AppCommandHandlerTests()
{
appLimitsProvider.Setup(x => x.GetPlan(0)).Returns(new ConfigAppLimitsPlan { MaxContributors = 2 });
app = new AppDomainObject(AppId, -1);
sut = new AppCommandHandler(Handler, appRepository.Object, userRepository.Object, keyGenerator.Object);
sut = new AppCommandHandler(Handler, appRepository.Object, appLimitsProvider.Object, userRepository.Object, keyGenerator.Object);
}
[Fact]
@ -93,12 +98,29 @@ namespace Squidex.Write.Apps
}, false);
}
[Fact]
public async Task AssignContributor_throw_if_reached_max_contributor_size()
{
CreateApp()
.AssignContributor(CreateCommand(new AssignContributor { ContributorId = "1" }))
.AssignContributor(CreateCommand(new AssignContributor { ContributorId = "2" }));
var context = CreateContextForCommand(new AssignContributor { ContributorId = contributorId });
userRepository.Setup(x => x.FindUserByIdAsync(It.IsAny<string>())).Returns(Task.FromResult(new Mock<IUserEntity>().Object));
await TestUpdate(app, async _ =>
{
await Assert.ThrowsAsync<ValidationException>(() => sut.HandleAsync(context));
}, false);
}
[Fact]
public async Task AssignContributor_should_throw_if_null_user_not_found()
{
CreateApp();
var context = CreateContextForCommand(new AssignContributor { ContributorId = null });
var context = CreateContextForCommand(new AssignContributor { ContributorId = contributorId });
userRepository.Setup(x => x.FindUserByIdAsync(contributorId)).Returns(Task.FromResult<IUserEntity>(null));

8
tests/Squidex.Write.Tests/Assets/AssetCommandHandlerTests.cs

@ -112,12 +112,16 @@ namespace Squidex.Write.Assets
private void SetupImageInfo()
{
assetThumbnailGenerator.Setup(x => x.GetImageInfoAsync(stream)).Returns(Task.FromResult(image)).Verifiable();
assetThumbnailGenerator
.Setup(x => x.GetImageInfoAsync(stream)).Returns(Task.FromResult(image))
.Verifiable();
}
private void SetupStore(long version)
{
assetStore.Setup(x => x.UploadAsync(assetId, version, null, stream)).Returns(TaskHelper.Done).Verifiable();
assetStore
.Setup(x => x.UploadAsync(assetId, version, null, stream)).Returns(TaskHelper.Done)
.Verifiable();
}
}
}

Loading…
Cancel
Save