Browse Source

Merge pull request #261 from Squidex/backup

Backup
pull/262/head
Sebastian Stehle 8 years ago
committed by GitHub
parent
commit
59866232cc
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 242
      src/Squidex.Domain.Apps.Backup/BackupGrain.cs
  2. 80
      src/Squidex.Domain.Apps.Backup/EventStreamWriter.cs
  3. 13
      src/Squidex.Domain.Apps.Backup/IBackupArchiveLocation.cs
  4. 24
      src/Squidex.Domain.Apps.Backup/IBackupGrain.cs
  5. 25
      src/Squidex.Domain.Apps.Backup/IBackupJob.cs
  6. 22
      src/Squidex.Domain.Apps.Backup/Squidex.Domain.Apps.Backup.csproj
  7. 18
      src/Squidex.Domain.Apps.Backup/State/BackupState.cs
  8. 31
      src/Squidex.Domain.Apps.Backup/State/BackupStateJob.cs
  9. 1
      src/Squidex.Domain.Apps.Core.Model/Apps/LanguagesConfig.cs
  10. 7
      src/Squidex.Domain.Apps.Core.Model/Rules/Rule.cs
  11. 2
      src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs
  12. 5
      src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs
  13. 12
      src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs
  14. 2
      src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs
  15. 286
      src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs
  16. 79
      src/Squidex.Domain.Apps.Entities/Backup/EventStreamWriter.cs
  17. 20
      src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveLocation.cs
  18. 24
      src/Squidex.Domain.Apps.Entities/Backup/IBackupGrain.cs
  19. 27
      src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs
  20. 18
      src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs
  21. 34
      src/Squidex.Domain.Apps.Entities/Backup/State/BackupStateJob.cs
  22. 44
      src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs
  23. 2
      src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs
  24. 22
      src/Squidex.Domain.Apps.Entities/Contents/IContentScheduleItem.cs
  25. 26
      src/Squidex.Domain.Apps.Entities/Contents/State/ContentStateScheduleItem.cs
  26. 2
      src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs
  27. 2
      src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs
  28. 33
      src/Squidex.Domain.Apps.Entities/SquidexDomainObjectGrain.cs
  29. 2
      src/Squidex.Domain.Apps.Entities/SquidexEntities.cs
  30. 32
      src/Squidex.Infrastructure.Azure/Assets/AzureBlobAssetStore.cs
  31. 34
      src/Squidex.Infrastructure.GoogleCloud/Assets/GoogleCloudAssetStore.cs
  32. 2
      src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStore.cs
  33. 40
      src/Squidex.Infrastructure/Assets/FolderAssetStore.cs
  34. 13
      src/Squidex.Infrastructure/Assets/IAssetStore.cs
  35. 4
      src/Squidex.Infrastructure/States/Store.cs
  36. 12
      src/Squidex.Infrastructure/States/StoreExtensions.cs
  37. 16
      src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs
  38. 54
      src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs
  39. 106
      src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs
  40. 45
      src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs
  41. 7
      src/Squidex/Config/Domain/EntitiesServices.cs
  42. 5
      src/Squidex/Config/Domain/StoreServices.cs
  43. 1
      src/Squidex/app/features/settings/declarations.ts
  44. 6
      src/Squidex/app/features/settings/module.ts
  45. 82
      src/Squidex/app/features/settings/pages/backups/backups-page.component.html
  46. 29
      src/Squidex/app/features/settings/pages/backups/backups-page.component.scss
  47. 83
      src/Squidex/app/features/settings/pages/backups/backups-page.component.ts
  48. 8
      src/Squidex/app/features/settings/settings-area.component.html
  49. 4
      src/Squidex/app/framework/angular/date-time.pipes.spec.ts
  50. 8
      src/Squidex/app/framework/utils/duration.spec.ts
  51. 14
      src/Squidex/app/framework/utils/duration.ts
  52. 1
      src/Squidex/app/shared/declarations-base.ts
  53. 2
      src/Squidex/app/shared/module.ts
  54. 101
      src/Squidex/app/shared/services/backups.service.spec.ts
  55. 80
      src/Squidex/app/shared/services/backups.service.ts
  56. 29
      src/Squidex/app/theme/_common.scss
  57. 15
      src/Squidex/app/theme/_mixins.scss
  58. 2
      src/Squidex/app/theme/icomoon/demo-files/demo.css
  59. 828
      src/Squidex/app/theme/icomoon/demo.html
  60. BIN
      src/Squidex/app/theme/icomoon/fonts/icomoon.eot
  61. 3
      src/Squidex/app/theme/icomoon/fonts/icomoon.svg
  62. BIN
      src/Squidex/app/theme/icomoon/fonts/icomoon.ttf
  63. BIN
      src/Squidex/app/theme/icomoon/fonts/icomoon.woff
  64. 1473
      src/Squidex/app/theme/icomoon/selection.json
  65. 136
      src/Squidex/app/theme/icomoon/style.css
  66. 2
      tests/RunCoverage.ps1
  67. 47
      tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs
  68. 6
      tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs
  69. 13
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs
  70. 224
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs
  71. 14
      tests/Squidex.Domain.Users.Tests/AssetUserPictureStoreTests.cs
  72. 28
      tests/Squidex.Infrastructure.Tests/Assets/AssetStoreTests.cs
  73. 8
      tools/Migrate_01/MigrationPath.cs
  74. 31
      tools/Migrate_01/Migrations/ConvertEventStore.cs
  75. 97
      tools/Migrate_01/Migrations/ConvertEventStoreAppId.cs

242
src/Squidex.Domain.Apps.Backup/BackupGrain.cs

@ -0,0 +1,242 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NodaTime;
using Orleans;
using Orleans.Concurrency;
using Squidex.Domain.Apps.Backup.State;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Backup
{
[Reentrant]
public sealed class BackupGrain : Grain, IBackupGrain
{
private const int MaxBackups = 10;
private readonly IClock clock;
private readonly IAssetStore assetStore;
private readonly IEventDataFormatter eventDataFormatter;
private readonly ISemanticLog log;
private readonly IEventStore eventStore;
private readonly IBackupArchiveLocation backupArchiveLocation;
private readonly IStore<Guid> store;
private CancellationTokenSource currentTask;
private BackupStateJob currentJob;
private Guid appId;
private BackupState state;
private IPersistence<BackupState> persistence;
public BackupGrain(
IAssetStore assetStore,
IBackupArchiveLocation backupArchiveLocation,
IClock clock,
IEventStore eventStore,
IEventDataFormatter eventDataFormatter,
ISemanticLog log,
IStore<Guid> store)
{
Guard.NotNull(assetStore, nameof(assetStore));
Guard.NotNull(backupArchiveLocation, nameof(backupArchiveLocation));
Guard.NotNull(clock, nameof(clock));
Guard.NotNull(eventStore, nameof(eventStore));
Guard.NotNull(eventDataFormatter, nameof(eventDataFormatter));
Guard.NotNull(store, nameof(store));
Guard.NotNull(log, nameof(log));
this.assetStore = assetStore;
this.backupArchiveLocation = backupArchiveLocation;
this.clock = clock;
this.eventStore = eventStore;
this.eventDataFormatter = eventDataFormatter;
this.store = store;
this.log = log;
}
public override Task OnActivateAsync()
{
return OnActivateAsync(this.GetPrimaryKey());
}
public async Task OnActivateAsync(Guid appId)
{
this.appId = appId;
persistence = store.WithSnapshots<BackupState, Guid>(GetType(), appId, s => state = s);
await ReadAsync();
await CleanupAsync();
}
private async Task ReadAsync()
{
await persistence.ReadAsync();
}
private async Task WriteAsync()
{
await persistence.WriteSnapshotAsync(state);
}
private async Task CleanupAsync()
{
var hasUpdated = false;
foreach (var job in state.Jobs)
{
if (!job.Stopped.HasValue)
{
await CleanupAsync(job);
job.Stopped = clock.GetCurrentInstant();
job.Failed = true;
hasUpdated = true;
}
}
if (hasUpdated)
{
await WriteAsync();
}
}
private async Task CleanupAsync(BackupStateJob job)
{
await backupArchiveLocation.DeleteArchiveAsync(job.Id);
}
public async Task StartNewAsync()
{
if (currentTask != null)
{
throw new DomainException("Another backup process is already running.");
}
if (state.Jobs.Count >= MaxBackups)
{
throw new DomainException($"You cannot have more than {MaxBackups} backups.");
}
var job = new BackupStateJob { Id = Guid.NewGuid(), Started = clock.GetCurrentInstant() };
currentTask = new CancellationTokenSource();
currentJob = job;
state.Jobs.Add(job);
await WriteAsync();
try
{
using (var stream = await backupArchiveLocation.OpenStreamAsync(job.Id))
{
using (var writer = new EventStreamWriter(stream))
{
await eventStore.QueryAsync(async @event =>
{
var eventData = @event.Data;
if (eventData.Type == nameof(AssetCreated) ||
eventData.Type == nameof(AssetUpdated))
{
var parsedEvent = eventDataFormatter.Parse(eventData);
var assetVersion = 0L;
var assetId = Guid.Empty;
if (parsedEvent.Payload is AssetCreated assetCreated)
{
assetId = assetCreated.AssetId;
assetVersion = assetCreated.FileVersion;
}
if (parsedEvent.Payload is AssetUpdated asetUpdated)
{
assetId = asetUpdated.AssetId;
assetVersion = asetUpdated.FileVersion;
}
await writer.WriteEventAsync(eventData, async attachmentStream =>
{
await assetStore.DownloadAsync(assetId.ToString(), assetVersion, null, attachmentStream);
});
}
else
{
await writer.WriteEventAsync(eventData);
}
}, "AppId", appId, null, currentTask.Token);
}
stream.Position = 0;
currentTask.Token.ThrowIfCancellationRequested();
await assetStore.UploadAsync(job.Id.ToString(), 0, null, stream);
currentTask.Token.ThrowIfCancellationRequested();
}
}
catch
{
job.Failed = true;
}
finally
{
job.Stopped = clock.GetCurrentInstant();
await WriteAsync();
currentTask = null;
currentJob = null;
}
}
public async Task DeleteAsync(Guid id)
{
var job = state.Jobs.FirstOrDefault(x => x.Id == id);
if (job == null)
{
throw new DomainObjectNotFoundException(id.ToString(), typeof(IBackupJob));
}
if (currentJob == job)
{
currentTask?.Cancel();
}
else
{
state.Jobs.Remove(job);
await WriteAsync();
await CleanupAsync(job);
}
}
public Task<J<List<IBackupJob>>> GetStateAsync()
{
return Task.FromResult(new J<List<IBackupJob>>(state.Jobs.OfType<IBackupJob>().ToList()));
}
private bool IsRunning()
{
return state.Jobs.Any(x => !x.Stopped.HasValue);
}
}
}

80
src/Squidex.Domain.Apps.Backup/EventStreamWriter.cs

@ -0,0 +1,80 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.IO.Compression;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Backup
{
public sealed class EventStreamWriter : DisposableObjectBase
{
private const int MaxItemsPerFolder = 1000;
private readonly StreamWriter streamWriter;
private readonly ZipArchive archive;
private int writtenEvents;
private int writtenAttachments;
public EventStreamWriter(Stream stream)
{
archive = new ZipArchive(stream, ZipArchiveMode.Update, true);
}
public async Task WriteEventAsync(EventData eventData, Func<Stream, Task> attachment = null)
{
var eventObject =
new JObject(
new JProperty("type", eventData.Type),
new JProperty("payload", eventData.Payload),
new JProperty("metadata", eventData.Metadata));
var eventFolder = writtenEvents / MaxItemsPerFolder;
var eventPath = $"events/{eventFolder}/{writtenEvents}.json";
var eventEntry = archive.GetEntry(eventPath) ?? archive.CreateEntry(eventPath);
using (var stream = eventEntry.Open())
{
using (var textWriter = new StreamWriter(stream))
{
using (var jsonWriter = new JsonTextWriter(textWriter))
{
await eventObject.WriteToAsync(jsonWriter);
}
}
}
writtenEvents++;
if (attachment != null)
{
var attachmentFolder = writtenAttachments / MaxItemsPerFolder;
var attachmentPath = $"attachments/{attachmentFolder}/{writtenEvents}.blob";
var attachmentEntry = archive.GetEntry(attachmentPath) ?? archive.CreateEntry(attachmentPath);
using (var stream = eventEntry.Open())
{
await attachment(stream);
}
writtenAttachments++;
}
}
protected override void DisposeObject(bool disposing)
{
if (disposing)
{
archive.Dispose();
}
}
}
}

13
src/Squidex.Domain.Apps.Backup/IBackupArchiveLocation.cs

@ -0,0 +1,13 @@
using System;
using System.IO;
using System.Threading.Tasks;
namespace Squidex.Domain.Apps.Backup
{
public interface IBackupArchiveLocation
{
Task<Stream> OpenStreamAsync(Guid backupId);
Task DeleteArchiveAsync(Guid backupId);
}
}

24
src/Squidex.Domain.Apps.Backup/IBackupGrain.cs

@ -0,0 +1,24 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Orleans;
using Squidex.Infrastructure.Orleans;
namespace Squidex.Domain.Apps.Backup
{
public interface IBackupGrain : IGrainWithGuidKey
{
Task StartNewAsync();
Task DeleteAsync(Guid id);
Task<J<List<IBackupJob>>> GetStateAsync();
}
}

25
src/Squidex.Domain.Apps.Backup/IBackupJob.cs

@ -0,0 +1,25 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using NodaTime;
namespace Squidex.Domain.Apps.Backup
{
public interface IBackupJob
{
Guid Id { get; }
Instant Started { get; }
Instant? Stopped { get; }
bool Failed { get; }
string DownloadPath { get; }
}
}

22
src/Squidex.Domain.Apps.Backup/Squidex.Domain.Apps.Backup.csproj

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Squidex.Domain.Apps.Entities\Squidex.Domain.Apps.Entities.csproj" />
<ProjectReference Include="..\Squidex.Domain.Apps.Events\Squidex.Domain.Apps.Events.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Orleans.OrleansCodeGenerator.Build" Version="2.0.0-rc2" />
<PackageReference Include="NodaTime" Version="2.2.4" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.0.2" />
<PackageReference Include="System.ValueTuple" Version="4.4.0" />
</ItemGroup>
<PropertyGroup>
<CodeAnalysisRuleSet>..\..\Squidex.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="..\..\stylecop.json" Link="stylecop.json" />
</ItemGroup>
</Project>

18
src/Squidex.Domain.Apps.Backup/State/BackupState.cs

@ -0,0 +1,18 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using Newtonsoft.Json;
namespace Squidex.Domain.Apps.Backup.State
{
public sealed class BackupState
{
[JsonProperty]
public List<BackupStateJob> Jobs { get; set; } = new List<BackupStateJob>();
}
}

31
src/Squidex.Domain.Apps.Backup/State/BackupStateJob.cs

@ -0,0 +1,31 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Newtonsoft.Json;
using NodaTime;
namespace Squidex.Domain.Apps.Backup.State
{
public sealed class BackupStateJob : IBackupJob
{
[JsonProperty]
public Guid Id { get; set; }
[JsonProperty]
public Instant Started { get; set; }
[JsonProperty]
public Instant? Stopped { get; set; }
[JsonProperty]
public string DownloadPath { get; set; }
[JsonProperty]
public bool Failed { get; set; }
}
}

1
src/Squidex.Domain.Apps.Core.Model/Apps/LanguagesConfig.cs

@ -17,7 +17,6 @@ namespace Squidex.Domain.Apps.Core.Apps
{
public sealed class LanguagesConfig : IFieldPartitioning
{
public static readonly LanguagesConfig Empty = new LanguagesConfig(ImmutableDictionary<Language, LanguageConfig>.Empty, null, false);
public static readonly LanguagesConfig English = Build(Language.EN);
private readonly ImmutableDictionary<Language, LanguageConfig> languages;

7
src/Squidex.Domain.Apps.Core.Model/Rules/Rule.cs

@ -38,7 +38,10 @@ namespace Squidex.Domain.Apps.Core.Rules
Guard.NotNull(action, nameof(action));
this.trigger = trigger;
this.trigger.Freeze();
this.action = action;
this.action.Freeze();
}
[Pure]
@ -69,6 +72,8 @@ namespace Squidex.Domain.Apps.Core.Rules
throw new ArgumentException("New trigger has another type.", nameof(newTrigger));
}
newTrigger.Freeze();
return Clone(clone =>
{
clone.trigger = newTrigger;
@ -85,6 +90,8 @@ namespace Squidex.Domain.Apps.Core.Rules
throw new ArgumentException("New action has another type.", nameof(newAction));
}
newAction.Freeze();
return Clone(clone =>
{
clone.action = newAction;

2
src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs

@ -25,7 +25,7 @@ using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps
{
public class AppGrain : DomainObjectGrain<AppState>, IAppGrain
public class AppGrain : SquidexDomainObjectGrain<AppState>, IAppGrain
{
private readonly InitialPatterns initialPatterns;
private readonly IAppProvider appProvider;

5
src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs

@ -9,7 +9,6 @@ using Newtonsoft.Json;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Dispatching;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
@ -19,8 +18,6 @@ namespace Squidex.Domain.Apps.Entities.Apps.State
public class AppState : DomainObjectState<AppState>,
IAppEntity
{
private static readonly LanguagesConfig English = LanguagesConfig.Build(Language.EN);
[JsonProperty]
public string Name { get; set; }
@ -37,7 +34,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.State
public AppContributors Contributors { get; set; } = AppContributors.Empty;
[JsonProperty]
public LanguagesConfig LanguagesConfig { get; set; } = English;
public LanguagesConfig LanguagesConfig { get; set; } = LanguagesConfig.English;
[JsonProperty]
public bool IsArchived { get; set; }

12
src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs

@ -41,18 +41,18 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
createAsset.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(createAsset.File.OpenRead());
await assetStore.UploadTemporaryAsync(context.ContextId.ToString(), createAsset.File.OpenRead());
await assetStore.UploadAsync(context.ContextId.ToString(), createAsset.File.OpenRead());
try
{
var result = await ExecuteCommandAsync(createAsset) as AssetSavedResult;
context.Complete(EntityCreatedResult.Create(createAsset.AssetId, result.Version));
await assetStore.CopyTemporaryAsync(context.ContextId.ToString(), createAsset.AssetId.ToString(), result.FileVersion, null);
await assetStore.CopyAsync(context.ContextId.ToString(), createAsset.AssetId.ToString(), result.FileVersion, null);
}
finally
{
await assetStore.DeleteTemporaryAsync(context.ContextId.ToString());
await assetStore.DeleteAsync(context.ContextId.ToString());
}
break;
@ -62,18 +62,18 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
updateAsset.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(updateAsset.File.OpenRead());
await assetStore.UploadTemporaryAsync(context.ContextId.ToString(), updateAsset.File.OpenRead());
await assetStore.UploadAsync(context.ContextId.ToString(), updateAsset.File.OpenRead());
try
{
var result = await ExecuteCommandAsync(updateAsset) as AssetSavedResult;
context.Complete(result);
await assetStore.CopyTemporaryAsync(context.ContextId.ToString(), updateAsset.AssetId.ToString(), result.FileVersion, null);
await assetStore.CopyAsync(context.ContextId.ToString(), updateAsset.AssetId.ToString(), result.FileVersion, null);
}
finally
{
await assetStore.DeleteTemporaryAsync(context.ContextId.ToString());
await assetStore.DeleteAsync(context.ContextId.ToString());
}
break;

2
src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs

@ -21,7 +21,7 @@ using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Assets
{
public class AssetGrain : DomainObjectGrain<AssetState>, IAssetGrain
public class AssetGrain : SquidexDomainObjectGrain<AssetState>, IAssetGrain
{
public AssetGrain(IStore<Guid> store)
: base(store)

286
src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs

@ -0,0 +1,286 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NodaTime;
using Orleans;
using Orleans.Concurrency;
using Squidex.Domain.Apps.Entities.Backup.State;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Backup
{
[Reentrant]
public sealed class BackupGrain : Grain, IBackupGrain
{
private const int MaxBackups = 10;
private static readonly Duration UpdateDuration = Duration.FromSeconds(1);
private readonly IClock clock;
private readonly IAssetStore assetStore;
private readonly IEventDataFormatter eventDataFormatter;
private readonly ISemanticLog log;
private readonly IEventStore eventStore;
private readonly IBackupArchiveLocation backupArchiveLocation;
private readonly IStore<Guid> store;
private CancellationTokenSource currentTask;
private BackupStateJob currentJob;
private Guid appId;
private BackupState state = new BackupState();
private IPersistence<BackupState> persistence;
public BackupGrain(
IAssetStore assetStore,
IBackupArchiveLocation backupArchiveLocation,
IClock clock,
IEventStore eventStore,
IEventDataFormatter eventDataFormatter,
ISemanticLog log,
IStore<Guid> store)
{
Guard.NotNull(assetStore, nameof(assetStore));
Guard.NotNull(backupArchiveLocation, nameof(backupArchiveLocation));
Guard.NotNull(clock, nameof(clock));
Guard.NotNull(eventStore, nameof(eventStore));
Guard.NotNull(eventDataFormatter, nameof(eventDataFormatter));
Guard.NotNull(store, nameof(store));
Guard.NotNull(log, nameof(log));
this.assetStore = assetStore;
this.backupArchiveLocation = backupArchiveLocation;
this.clock = clock;
this.eventStore = eventStore;
this.eventDataFormatter = eventDataFormatter;
this.store = store;
this.log = log;
}
public override Task OnActivateAsync()
{
return OnActivateAsync(this.GetPrimaryKey());
}
public async Task OnActivateAsync(Guid appId)
{
this.appId = appId;
persistence = store.WithSnapshots<BackupState, Guid>(GetType(), appId, s => state = s);
await ReadAsync();
await CleanupAsync();
}
private async Task ReadAsync()
{
await persistence.ReadAsync();
}
private async Task WriteAsync()
{
await persistence.WriteSnapshotAsync(state);
}
private async Task CleanupAsync()
{
foreach (var job in state.Jobs)
{
if (!job.Stopped.HasValue)
{
job.Stopped = clock.GetCurrentInstant();
await CleanupArchiveAsync(job);
await CleanupBackupAsync(job);
job.IsFailed = true;
await WriteAsync();
}
}
}
private async Task CleanupBackupAsync(BackupStateJob job)
{
try
{
await assetStore.DeleteAsync(job.Id.ToString(), 0, null);
}
catch (Exception ex)
{
log.LogError(ex, w => w
.WriteProperty("action", "deleteBackup")
.WriteProperty("status", "failed")
.WriteProperty("backupId", job.Id.ToString()));
}
}
private async Task CleanupArchiveAsync(BackupStateJob job)
{
try
{
await backupArchiveLocation.DeleteArchiveAsync(job.Id);
}
catch (Exception ex)
{
log.LogError(ex, w => w
.WriteProperty("action", "deleteArchive")
.WriteProperty("status", "failed")
.WriteProperty("backupId", job.Id.ToString()));
}
}
public async Task RunAsync()
{
if (currentTask != null)
{
throw new DomainException("Another backup process is already running.");
}
if (state.Jobs.Count >= MaxBackups)
{
throw new DomainException($"You cannot have more than {MaxBackups} backups.");
}
var job = new BackupStateJob { Id = Guid.NewGuid(), Started = clock.GetCurrentInstant() };
currentTask = new CancellationTokenSource();
currentJob = job;
var lastTimestamp = job.Started;
state.Jobs.Insert(0, job);
await WriteAsync();
try
{
using (var stream = await backupArchiveLocation.OpenStreamAsync(job.Id))
{
using (var writer = new EventStreamWriter(stream))
{
await eventStore.QueryAsync(async @event =>
{
var eventData = @event.Data;
if (eventData.Type == "AssetCreatedEvent" ||
eventData.Type == "AssetUpdatedEvent")
{
var parsedEvent = eventDataFormatter.Parse(eventData);
var assetVersion = 0L;
var assetId = Guid.Empty;
if (parsedEvent.Payload is AssetCreated assetCreated)
{
assetId = assetCreated.AssetId;
assetVersion = assetCreated.FileVersion;
}
if (parsedEvent.Payload is AssetUpdated asetUpdated)
{
assetId = asetUpdated.AssetId;
assetVersion = asetUpdated.FileVersion;
}
await writer.WriteEventAsync(eventData, async attachmentStream =>
{
await assetStore.DownloadAsync(assetId.ToString(), assetVersion, null, attachmentStream);
});
job.HandledAssets++;
}
else
{
await writer.WriteEventAsync(eventData);
}
job.HandledEvents++;
var now = clock.GetCurrentInstant();
if ((now - lastTimestamp) >= UpdateDuration)
{
lastTimestamp = now;
await WriteAsync();
}
}, SquidexHeaders.AppId, appId.ToString(), null, currentTask.Token);
}
stream.Position = 0;
currentTask.Token.ThrowIfCancellationRequested();
await assetStore.UploadAsync(job.Id.ToString(), 0, null, stream, currentTask.Token);
}
}
catch (Exception ex)
{
log.LogError(ex, w => w
.WriteProperty("action", "makeBackup")
.WriteProperty("status", "failed")
.WriteProperty("backupId", job.Id.ToString()));
job.IsFailed = true;
}
finally
{
await CleanupArchiveAsync(job);
job.Stopped = clock.GetCurrentInstant();
await WriteAsync();
currentTask = null;
currentJob = null;
}
}
public async Task DeleteAsync(Guid id)
{
var job = state.Jobs.FirstOrDefault(x => x.Id == id);
if (job == null)
{
throw new DomainObjectNotFoundException(id.ToString(), typeof(IBackupJob));
}
if (currentJob == job)
{
currentTask?.Cancel();
}
else
{
await CleanupArchiveAsync(job);
await CleanupBackupAsync(job);
state.Jobs.Remove(job);
await WriteAsync();
}
}
public Task<J<List<IBackupJob>>> GetStateAsync()
{
return Task.FromResult(new J<List<IBackupJob>>(state.Jobs.OfType<IBackupJob>().ToList()));
}
private bool IsRunning()
{
return state.Jobs.Any(x => !x.Stopped.HasValue);
}
}
}

79
src/Squidex.Domain.Apps.Entities/Backup/EventStreamWriter.cs

@ -0,0 +1,79 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.IO.Compression;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Entities.Backup
{
public sealed class EventStreamWriter : DisposableObjectBase
{
private const int MaxItemsPerFolder = 1000;
private readonly ZipArchive archive;
private int writtenEvents;
private int writtenAttachments;
public EventStreamWriter(Stream stream)
{
archive = new ZipArchive(stream, ZipArchiveMode.Update, true);
}
public async Task WriteEventAsync(EventData eventData, Func<Stream, Task> attachment = null)
{
var eventObject =
new JObject(
new JProperty("type", eventData.Type),
new JProperty("payload", eventData.Payload),
new JProperty("metadata", eventData.Metadata));
var eventFolder = writtenEvents / MaxItemsPerFolder;
var eventPath = $"events/{eventFolder}/{writtenEvents}.json";
var eventEntry = archive.GetEntry(eventPath) ?? archive.CreateEntry(eventPath);
using (var stream = eventEntry.Open())
{
using (var textWriter = new StreamWriter(stream))
{
using (var jsonWriter = new JsonTextWriter(textWriter))
{
await eventObject.WriteToAsync(jsonWriter);
}
}
}
writtenEvents++;
if (attachment != null)
{
var attachmentFolder = writtenAttachments / MaxItemsPerFolder;
var attachmentPath = $"attachments/{attachmentFolder}/{writtenEvents}.blob";
var attachmentEntry = archive.GetEntry(attachmentPath) ?? archive.CreateEntry(attachmentPath);
using (var stream = eventEntry.Open())
{
await attachment(stream);
}
writtenAttachments++;
}
}
protected override void DisposeObject(bool disposing)
{
if (disposing)
{
archive.Dispose();
}
}
}
}

20
src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveLocation.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading.Tasks;
namespace Squidex.Domain.Apps.Entities.Backup
{
public interface IBackupArchiveLocation
{
Task<Stream> OpenStreamAsync(Guid backupId);
Task DeleteArchiveAsync(Guid backupId);
}
}

24
src/Squidex.Domain.Apps.Entities/Backup/IBackupGrain.cs

@ -0,0 +1,24 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Orleans;
using Squidex.Infrastructure.Orleans;
namespace Squidex.Domain.Apps.Entities.Backup
{
public interface IBackupGrain : IGrainWithGuidKey
{
Task RunAsync();
Task DeleteAsync(Guid id);
Task<J<List<IBackupJob>>> GetStateAsync();
}
}

27
src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs

@ -0,0 +1,27 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using NodaTime;
namespace Squidex.Domain.Apps.Entities.Backup
{
public interface IBackupJob
{
Guid Id { get; }
Instant Started { get; }
Instant? Stopped { get; }
int HandledEvents { get; }
int HandledAssets { get; }
bool IsFailed { get; }
}
}

18
src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs

@ -0,0 +1,18 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using Newtonsoft.Json;
namespace Squidex.Domain.Apps.Entities.Backup.State
{
public sealed class BackupState
{
[JsonProperty]
public List<BackupStateJob> Jobs { get; } = new List<BackupStateJob>();
}
}

34
src/Squidex.Domain.Apps.Entities/Backup/State/BackupStateJob.cs

@ -0,0 +1,34 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Newtonsoft.Json;
using NodaTime;
namespace Squidex.Domain.Apps.Entities.Backup.State
{
public sealed class BackupStateJob : IBackupJob
{
[JsonProperty]
public Guid Id { get; set; }
[JsonProperty]
public Instant Started { get; set; }
[JsonProperty]
public Instant? Stopped { get; set; }
[JsonProperty]
public int HandledEvents { get; set; }
[JsonProperty]
public int HandledAssets { get; set; }
[JsonProperty]
public bool IsFailed { get; set; }
}
}

44
src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs

@ -0,0 +1,44 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading.Tasks;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Backup
{
public sealed class TempFolderBackupArchiveLocation : IBackupArchiveLocation
{
public Task<Stream> OpenStreamAsync(Guid backupId)
{
var tempFile = GetTempFile(backupId);
return Task.FromResult<Stream>(new FileStream(tempFile, FileMode.Create, FileAccess.ReadWrite));
}
public Task DeleteArchiveAsync(Guid backupId)
{
var tempFile = GetTempFile(backupId);
try
{
File.Delete(tempFile);
}
catch (IOException)
{
}
return TaskHelper.Done;
}
private static string GetTempFile(Guid backupId)
{
return Path.Combine(Path.GetTempPath(), backupId.ToString());
}
}
}

2
src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs

@ -24,7 +24,7 @@ using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Contents
{
public class ContentGrain : DomainObjectGrain<ContentState>, IContentGrain
public class ContentGrain : SquidexDomainObjectGrain<ContentState>, IContentGrain
{
private readonly IAppProvider appProvider;
private readonly IAssetRepository assetRepository;

22
src/Squidex.Domain.Apps.Entities/Contents/IContentScheduleItem.cs

@ -0,0 +1,22 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents
{
public interface IContentScheduleItem
{
Status ScheduledTo { get; }
Instant ScheduledAt { get; }
RefToken ScheduledBy { get; }
}
}

26
src/Squidex.Domain.Apps.Entities/Contents/State/ContentStateScheduleItem.cs

@ -0,0 +1,26 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Newtonsoft.Json;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.State
{
public sealed class ContentStateScheduleItem : IContentScheduleItem
{
[JsonProperty]
public Instant ScheduledAt { get; set; }
[JsonProperty]
public RefToken ScheduledBy { get; set; }
[JsonProperty]
public Status ScheduledTo { get; set; }
}
}

2
src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs

@ -21,7 +21,7 @@ using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Rules
{
public sealed class RuleGrain : DomainObjectGrain<RuleState>, IRuleGrain
public sealed class RuleGrain : SquidexDomainObjectGrain<RuleState>, IRuleGrain
{
private readonly IAppProvider appProvider;

2
src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs

@ -24,7 +24,7 @@ using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Schemas
{
public class SchemaGrain : DomainObjectGrain<SchemaState>, ISchemaGrain
public class SchemaGrain : SquidexDomainObjectGrain<SchemaState>, ISchemaGrain
{
private readonly IAppProvider appProvider;
private readonly FieldRegistry registry;

33
src/Squidex.Domain.Apps.Entities/SquidexDomainObjectGrain.cs

@ -0,0 +1,33 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities
{
public abstract class SquidexDomainObjectGrain<T> : DomainObjectGrain<T> where T : IDomainState, new()
{
protected SquidexDomainObjectGrain(IStore<Guid> store)
: base(store)
{
}
public override void RaiseEvent(Envelope<IEvent> @event)
{
if (@event.Payload is AppEvent appEvent)
{
@event.SetAppId(appEvent.AppId.Id);
}
base.RaiseEvent(@event);
}
}
}

2
src/Squidex.Domain.Apps.Entities/SquidexEntities.cs

@ -9,7 +9,7 @@ using System.Reflection;
namespace Squidex.Domain.Apps.Entities
{
public sealed class SquidexEntities
public static class SquidexEntities
{
public static readonly Assembly Assembly = typeof(SquidexEntities).Assembly;
}

32
src/Squidex.Infrastructure.Azure/Assets/AzureBlobAssetStore.cs

@ -7,6 +7,7 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Blob;
@ -56,7 +57,7 @@ namespace Squidex.Infrastructure.Assets
return new Uri(blobContainer.StorageUri.PrimaryUri, $"/{containerName}/{blobName}").ToString();
}
public async Task CopyTemporaryAsync(string name, string id, long version, string suffix)
public async Task CopyAsync(string name, string id, long version, string suffix, CancellationToken ct = default(CancellationToken))
{
var blobName = GetObjectName(id, version, suffix);
var blobRef = blobContainer.GetBlobReference(blobName);
@ -65,12 +66,14 @@ namespace Squidex.Infrastructure.Assets
try
{
await blobRef.StartCopyAsync(tempBlob.Uri);
await blobRef.StartCopyAsync(tempBlob.Uri, null, null, null, null, ct);
while (blobRef.CopyState.Status == CopyStatus.Pending)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(50);
await blobRef.FetchAttributesAsync();
await blobRef.FetchAttributesAsync(null, null, null, ct);
}
if (blobRef.CopyState.Status != CopyStatus.Success)
@ -84,14 +87,14 @@ namespace Squidex.Infrastructure.Assets
}
}
public async Task DownloadAsync(string id, long version, string suffix, Stream stream)
public async Task DownloadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken))
{
var blobName = GetObjectName(id, version, suffix);
var blobRef = blobContainer.GetBlockBlobReference(blobName);
try
{
await blobRef.DownloadToStreamAsync(stream);
await blobRef.DownloadToStreamAsync(stream, null, null, null, ct);
}
catch (StorageException ex) when (ex.RequestInformation.HttpStatusCode == 404)
{
@ -99,7 +102,7 @@ namespace Squidex.Infrastructure.Assets
}
}
public async Task UploadAsync(string id, long version, string suffix, Stream stream)
public async Task UploadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken))
{
var blobName = GetObjectName(id, version, suffix);
var blobRef = blobContainer.GetBlockBlobReference(blobName);
@ -107,22 +110,29 @@ namespace Squidex.Infrastructure.Assets
blobRef.Metadata[AssetVersion] = version.ToString();
blobRef.Metadata[AssetId] = id;
await blobRef.UploadFromStreamAsync(stream);
await blobRef.UploadFromStreamAsync(stream, null, null, null, ct);
await blobRef.SetMetadataAsync();
}
public async Task UploadTemporaryAsync(string name, Stream stream)
public Task UploadAsync(string name, Stream stream, CancellationToken ct = default(CancellationToken))
{
var tempBlob = blobContainer.GetBlockBlobReference(name);
await tempBlob.UploadFromStreamAsync(stream);
return tempBlob.UploadFromStreamAsync(stream, null, null, null, ct);
}
public async Task DeleteTemporaryAsync(string name)
public Task DeleteAsync(string name)
{
var tempBlob = blobContainer.GetBlockBlobReference(name);
await tempBlob.DeleteIfExistsAsync();
return tempBlob.DeleteIfExistsAsync();
}
public Task DeleteAsync(string id, long version, string suffix)
{
var tempBlob = blobContainer.GetBlockBlobReference(GetObjectName(id, version, suffix));
return tempBlob.DeleteIfExistsAsync();
}
private string GetObjectName(string id, long version, string suffix)

34
src/Squidex.Infrastructure.GoogleCloud/Assets/GoogleCloudAssetStore.cs

@ -9,6 +9,7 @@ using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Google;
using Google.Cloud.Storage.V1;
@ -48,25 +49,25 @@ namespace Squidex.Infrastructure.Assets
return $"https://storage.cloud.google.com/{bucketName}/{objectName}";
}
public Task UploadTemporaryAsync(string name, Stream stream)
public Task UploadAsync(string name, Stream stream, CancellationToken ct = default(CancellationToken))
{
return storageClient.UploadObjectAsync(bucketName, name, "application/octet-stream", stream);
return storageClient.UploadObjectAsync(bucketName, name, "application/octet-stream", stream, cancellationToken: ct);
}
public async Task UploadAsync(string id, long version, string suffix, Stream stream)
public async Task UploadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken))
{
var objectName = GetObjectName(id, version, suffix);
await storageClient.UploadObjectAsync(bucketName, objectName, "application/octet-stream", stream);
await storageClient.UploadObjectAsync(bucketName, objectName, "application/octet-stream", stream, cancellationToken: ct);
}
public async Task CopyTemporaryAsync(string name, string id, long version, string suffix)
public async Task CopyAsync(string name, string id, long version, string suffix, CancellationToken ct = default(CancellationToken))
{
var objectName = GetObjectName(id, version, suffix);
try
{
await storageClient.CopyObjectAsync(bucketName, name, bucketName, objectName);
await storageClient.CopyObjectAsync(bucketName, name, bucketName, objectName, cancellationToken: ct);
}
catch (GoogleApiException ex) when (ex.HttpStatusCode == HttpStatusCode.NotFound)
{
@ -74,13 +75,13 @@ namespace Squidex.Infrastructure.Assets
}
}
public async Task DownloadAsync(string id, long version, string suffix, Stream stream)
public async Task DownloadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken))
{
var objectName = GetObjectName(id, version, suffix);
try
{
await storageClient.DownloadObjectAsync(bucketName, objectName, stream);
await storageClient.DownloadObjectAsync(bucketName, objectName, stream, cancellationToken: ct);
}
catch (GoogleApiException ex) when (ex.HttpStatusCode == HttpStatusCode.NotFound)
{
@ -88,7 +89,7 @@ namespace Squidex.Infrastructure.Assets
}
}
public async Task DeleteTemporaryAsync(string name)
public async Task DeleteAsync(string name)
{
try
{
@ -103,6 +104,21 @@ namespace Squidex.Infrastructure.Assets
}
}
public async Task DeleteAsync(string id, long version, string suffix)
{
try
{
await storageClient.DeleteObjectAsync(bucketName, GetObjectName(id, version, suffix));
}
catch (GoogleApiException ex)
{
if (ex.HttpStatusCode != HttpStatusCode.NotFound)
{
throw;
}
}
}
private string GetObjectName(string id, long version, string suffix)
{
Guard.NotNullOrEmpty(id, nameof(id));

2
src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStore.cs

@ -32,7 +32,7 @@ namespace Squidex.Infrastructure.States
public async Task<(T Value, long Version)> ReadAsync(TKey key)
{
var existing =
await Collection.Find(x => Equals(x.Id, key))
await Collection.Find(x => x.Id.Equals(key))
.FirstOrDefaultAsync();
if (existing != null)

40
src/Squidex.Infrastructure/Assets/FolderAssetStore.cs

@ -7,6 +7,7 @@
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Tasks;
@ -15,6 +16,7 @@ namespace Squidex.Infrastructure.Assets
{
public sealed class FolderAssetStore : IAssetStore, IInitializable
{
private const int BufferSize = 81920;
private readonly ISemanticLog log;
private readonly DirectoryInfo directory;
@ -57,27 +59,27 @@ namespace Squidex.Infrastructure.Assets
return file.FullName;
}
public async Task UploadTemporaryAsync(string name, Stream stream)
public async Task UploadAsync(string name, Stream stream, CancellationToken ct = default(CancellationToken))
{
var file = GetFile(name);
using (var fileStream = file.OpenWrite())
{
await stream.CopyToAsync(fileStream);
await stream.CopyToAsync(fileStream, BufferSize, ct);
}
}
public async Task UploadAsync(string id, long version, string suffix, Stream stream)
public async Task UploadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken))
{
var file = GetFile(id, version, suffix);
using (var fileStream = file.OpenWrite())
{
await stream.CopyToAsync(fileStream);
await stream.CopyToAsync(fileStream, BufferSize, ct);
}
}
public async Task DownloadAsync(string id, long version, string suffix, Stream stream)
public async Task DownloadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken))
{
var file = GetFile(id, version, suffix);
@ -85,7 +87,7 @@ namespace Squidex.Infrastructure.Assets
{
using (var fileStream = file.OpenRead())
{
await fileStream.CopyToAsync(stream);
await fileStream.CopyToAsync(stream, BufferSize, ct);
}
}
catch (FileNotFoundException ex)
@ -94,7 +96,7 @@ namespace Squidex.Infrastructure.Assets
}
}
public Task CopyTemporaryAsync(string name, string id, long version, string suffix)
public Task CopyAsync(string name, string id, long version, string suffix, CancellationToken ct = default(CancellationToken))
{
try
{
@ -110,20 +112,22 @@ namespace Squidex.Infrastructure.Assets
}
}
public Task DeleteTemporaryAsync(string name)
public Task DeleteAsync(string id, long version, string suffix)
{
try
{
var file = GetFile(name);
var file = GetFile(id, version, suffix);
file.Delete();
file.Delete();
return TaskHelper.Done;
}
catch (FileNotFoundException ex)
{
throw new AssetNotFoundException($"Asset {name} not found.", ex);
}
return TaskHelper.Done;
}
public Task DeleteAsync(string name)
{
var file = GetFile(name);
file.Delete();
return TaskHelper.Done;
}
private FileInfo GetFile(string id, long version, string suffix)

13
src/Squidex.Infrastructure/Assets/IAssetStore.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Squidex.Infrastructure.Assets
@ -14,14 +15,16 @@ namespace Squidex.Infrastructure.Assets
{
string GenerateSourceUrl(string id, long version, string suffix);
Task CopyTemporaryAsync(string name, string id, long version, string suffix);
Task CopyAsync(string name, string id, long version, string suffix, CancellationToken ct = default(CancellationToken));
Task DownloadAsync(string id, long version, string suffix, Stream stream);
Task DownloadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken));
Task UploadTemporaryAsync(string name, Stream stream);
Task UploadAsync(string name, Stream stream, CancellationToken ct = default(CancellationToken));
Task UploadAsync(string id, long version, string suffix, Stream stream);
Task UploadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken));
Task DeleteTemporaryAsync(string name);
Task DeleteAsync(string name);
Task DeleteAsync(string id, long version, string suffix);
}
}

4
src/Squidex.Infrastructure/States/Store.cs

@ -32,12 +32,12 @@ namespace Squidex.Infrastructure.States
public IPersistence<TState> WithSnapshots<TState>(Type owner, TKey key, Func<TState, Task> applySnapshot)
{
return CreatePersistence<TState>(owner, key, PersistenceMode.Snapshots, applySnapshot, null);
return CreatePersistence(owner, key, PersistenceMode.Snapshots, applySnapshot, null);
}
public IPersistence<TState> WithSnapshotsAndEventSourcing<TState>(Type owner, TKey key, Func<TState, Task> applySnapshot, Func<Envelope<IEvent>, Task> applyEvent)
{
return CreatePersistence<TState>(owner, key, PersistenceMode.SnapshotsAndEventSourcing, applySnapshot, applyEvent);
return CreatePersistence(owner, key, PersistenceMode.SnapshotsAndEventSourcing, applySnapshot, applyEvent);
}
public IPersistence WithEventSourcing(Type owner, TKey key, Func<Envelope<IEvent>, Task> applyEvent)

12
src/Squidex.Infrastructure/States/StoreExtensions.cs

@ -21,12 +21,12 @@ namespace Squidex.Infrastructure.States
public static IPersistence<TState> WithSnapshots<TOwner, TState, TKey>(this IStore<TKey> store, TKey key, Func<TState, Task> applySnapshot)
{
return store.WithSnapshots<TState>(typeof(TOwner), key, applySnapshot);
return store.WithSnapshots(typeof(TOwner), key, applySnapshot);
}
public static IPersistence<TState> WithSnapshotsAndEventSourcing<TOwner, TState, TKey>(this IStore<TKey> store, TKey key, Func<TState, Task> applySnapshot, Func<Envelope<IEvent>, Task> applyEvent)
{
return store.WithSnapshotsAndEventSourcing<TState>(typeof(TOwner), key, applySnapshot, applyEvent);
return store.WithSnapshotsAndEventSourcing(typeof(TOwner), key, applySnapshot, applyEvent);
}
public static IPersistence WithEventSourcing<TKey>(this IStore<TKey> store, Type owner, TKey key, Action<Envelope<IEvent>> applyEvent)
@ -36,12 +36,12 @@ namespace Squidex.Infrastructure.States
public static IPersistence<TState> WithSnapshots<TState, TKey>(this IStore<TKey> store, Type owner, TKey key, Action<TState> applySnapshot)
{
return store.WithSnapshots<TState>(owner, key, applySnapshot.ToAsync());
return store.WithSnapshots(owner, key, applySnapshot.ToAsync());
}
public static IPersistence<TState> WithSnapshotsAndEventSourcing<TState, TKey>(this IStore<TKey> store, Type owner, TKey key, Action<TState> applySnapshot, Action<Envelope<IEvent>> applyEvent)
{
return store.WithSnapshotsAndEventSourcing<TState>(owner, key, applySnapshot.ToAsync(), applyEvent.ToAsync());
return store.WithSnapshotsAndEventSourcing(owner, key, applySnapshot.ToAsync(), applyEvent.ToAsync());
}
public static IPersistence WithEventSourcing<TOwner, TKey>(this IStore<TKey> store, TKey key, Action<Envelope<IEvent>> applyEvent)
@ -51,12 +51,12 @@ namespace Squidex.Infrastructure.States
public static IPersistence<TState> WithSnapshots<TOwner, TState, TKey>(this IStore<TKey> store, TKey key, Action<TState> applySnapshot)
{
return store.WithSnapshots<TState>(typeof(TOwner), key, applySnapshot.ToAsync());
return store.WithSnapshots(typeof(TOwner), key, applySnapshot.ToAsync());
}
public static IPersistence<TState> WithSnapshotsAndEventSourcing<TOwner, TState, TKey>(this IStore<TKey> store, TKey key, Action<TState> applySnapshot, Action<Envelope<IEvent>> applyEvent)
{
return store.WithSnapshotsAndEventSourcing<TState>(typeof(TOwner), key, applySnapshot.ToAsync(), applyEvent.ToAsync());
return store.WithSnapshotsAndEventSourcing(typeof(TOwner), key, applySnapshot.ToAsync(), applyEvent.ToAsync());
}
}
}

16
src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs

@ -27,18 +27,18 @@ namespace Squidex.Areas.Api.Controllers.Assets
[SwaggerTag(nameof(Assets))]
public sealed class AssetContentController : ApiController
{
private readonly IAssetStore assetStorage;
private readonly IAssetStore assetStore;
private readonly IAssetRepository assetRepository;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator;
public AssetContentController(
ICommandBus commandBus,
IAssetStore assetStorage,
IAssetStore assetStore,
IAssetRepository assetRepository,
IAssetThumbnailGenerator assetThumbnailGenerator)
: base(commandBus)
{
this.assetStorage = assetStorage;
this.assetStore = assetStore;
this.assetRepository = assetRepository;
this.assetThumbnailGenerator = assetThumbnailGenerator;
}
@ -53,7 +53,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// <param name="height">The target height of the asset, if it is an image.</param>
/// <param name="mode">The resize mode when the width and height is defined.</param>
/// <returns>
/// 200 => Asset found and content or (resize) image returned.
/// 200 => Asset found and content or (resized) image returned.
/// 404 => Asset or app not found.
/// </returns>
[HttpGet]
@ -79,7 +79,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
try
{
await assetStorage.DownloadAsync(assetId, entity.FileVersion, assetSuffix, bodyStream);
await assetStore.DownloadAsync(assetId, entity.FileVersion, assetSuffix, bodyStream);
}
catch (AssetNotFoundException)
{
@ -87,13 +87,13 @@ namespace Squidex.Areas.Api.Controllers.Assets
{
using (var destinationStream = GetTempStream())
{
await assetStorage.DownloadAsync(assetId, entity.FileVersion, null, sourceStream);
await assetStore.DownloadAsync(assetId, entity.FileVersion, null, sourceStream);
sourceStream.Position = 0;
await assetThumbnailGenerator.CreateThumbnailAsync(sourceStream, destinationStream, width, height, mode);
destinationStream.Position = 0;
await assetStorage.UploadAsync(assetId, entity.FileVersion, assetSuffix, destinationStream);
await assetStore.UploadAsync(assetId, entity.FileVersion, assetSuffix, destinationStream);
destinationStream.Position = 0;
await destinationStream.CopyToAsync(bodyStream);
@ -103,7 +103,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
}
else
{
await assetStorage.DownloadAsync(assetId, entity.FileVersion, null, bodyStream);
await assetStore.DownloadAsync(assetId, entity.FileVersion, null, bodyStream);
}
});
}

54
src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs

@ -0,0 +1,54 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Microsoft.AspNetCore.Mvc;
using NSwag.Annotations;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands;
using Squidex.Pipeline;
namespace Squidex.Areas.Api.Controllers.Backups
{
/// <summary>
/// Manages backups for app.
/// </summary>
[ApiExceptionFilter]
[AppApi]
[SwaggerTag(nameof(Backups))]
public class BackupContentController : ApiController
{
private readonly IAssetStore assetStore;
public BackupContentController(ICommandBus commandBus, IAssetStore assetStore)
: base(commandBus)
{
this.assetStore = assetStore;
}
/// <summary>
/// Get the backup content.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The id of the asset.</param>
/// <returns>
/// 200 => Backup found and content returned.
/// 404 => Backup or app not found.
/// </returns>
[HttpGet]
[Route("apps/{app}/backups/{id}")]
[ProducesResponseType(200)]
[ApiCosts(0)]
public IActionResult GetBackupContent(string app, Guid id)
{
return new FileCallbackResult("application/zip", "Backup.zip", bodyStream =>
{
return assetStore.DownloadAsync(id.ToString(), 0, null, bodyStream);
});
}
}
}

106
src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs

@ -0,0 +1,106 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using NSwag.Annotations;
using Orleans;
using Squidex.Areas.Api.Controllers.Backups.Models;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Tasks;
using Squidex.Pipeline;
namespace Squidex.Areas.Api.Controllers.Backups
{
/// <summary>
/// Manages backups for app.
/// </summary>
[ApiAuthorize]
[ApiExceptionFilter]
[AppApi]
[MustBeAppOwner]
[SwaggerTag(nameof(Backups))]
public class BackupsController : ApiController
{
private readonly IGrainFactory grainFactory;
public BackupsController(ICommandBus commandBus, IGrainFactory grainFactory)
: base(commandBus)
{
this.grainFactory = grainFactory;
}
/// <summary>
/// Get all backup jobs.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <returns>
/// 200 => Backups returned.
/// 404 => App not found.
/// </returns>
[HttpGet]
[Route("apps/{app}/backups/")]
[ProducesResponseType(typeof(List<BackupJobDto>), 200)]
[ApiCosts(0)]
public async Task<IActionResult> GetJobs(string app)
{
var backupGrain = grainFactory.GetGrain<IBackupGrain>(App.Id);
var jobs = await backupGrain.GetStateAsync();
return Ok(jobs.Value.Select(x => SimpleMapper.Map(x, new BackupJobDto())).ToList());
}
/// <summary>
/// Start a new backup.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <returns>
/// 204 => Backup started.
/// 404 => App not found.
/// </returns>
[HttpPost]
[Route("apps/{app}/backups/")]
[ProducesResponseType(typeof(List<BackupJobDto>), 200)]
[ApiCosts(0)]
public IActionResult PostBackup(string app)
{
var backupGrain = grainFactory.GetGrain<IBackupGrain>(App.Id);
backupGrain.RunAsync().Forget();
return NoContent();
}
/// <summary>
/// Delete a backup.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The id of the backup to delete.</param>
/// <returns>
/// 204 => Backup started.
/// 404 => Backup or app not found.
/// </returns>
[HttpDelete]
[Route("apps/{app}/backups/{id}")]
[ProducesResponseType(typeof(List<BackupJobDto>), 200)]
[ApiCosts(0)]
public async Task<IActionResult> DeleteBackup(string app, Guid id)
{
var backupGrain = grainFactory.GetGrain<IBackupGrain>(App.Id);
await backupGrain.DeleteAsync(id);
return NoContent();
}
}
}

45
src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs

@ -0,0 +1,45 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using NodaTime;
namespace Squidex.Areas.Api.Controllers.Backups.Models
{
public sealed class BackupJobDto
{
/// <summary>
/// The id of the backup job.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// The time when the job has been started.
/// </summary>
public Instant Started { get; set; }
/// <summary>
/// The time when the job has been stopped.
/// </summary>
public Instant? Stopped { get; set; }
/// <summary>
/// The number of handled events.
/// </summary>
public int HandledEvents { get; set; }
/// <summary>
/// The number of handled assets.
/// </summary>
public int HandledAssets { get; set; }
/// <summary>
/// Indicates if the job has failed.
/// </summary>
public bool IsFailed { get; set; }
}
}

7
src/Squidex/Config/Domain/EntitiesServices.cs

@ -19,6 +19,7 @@ using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Apps.Templates;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Contents.Edm;
@ -50,6 +51,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<CachingGraphQLService>()
.As<IGraphQLService>();
services.AddSingletonAs<TempFolderBackupArchiveLocation>()
.As<IBackupArchiveLocation>();
services.AddSingletonAs<AppProvider>()
.As<IAppProvider>();
@ -157,6 +161,9 @@ namespace Squidex.Config.Domain
services.AddTransientAs<ConvertEventStore>()
.As<IMigration>();
services.AddTransientAs<ConvertEventStoreAppId>()
.As<IMigration>();
services.AddTransientAs<AddPatterns>()
.As<IMigration>();

5
src/Squidex/Config/Domain/StoreServices.cs

@ -18,6 +18,7 @@ using Squidex.Domain.Apps.Entities.Apps.Repositories;
using Squidex.Domain.Apps.Entities.Apps.State;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Backup.State;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.Contents.State;
using Squidex.Domain.Apps.Entities.History;
@ -69,6 +70,10 @@ namespace Squidex.Config.Domain
.As<IMigrationStatus>()
.As<IInitializable>();
services.AddSingletonAs(c => new MongoSnapshotStore<BackupState, Guid>(mongoDatabase, c.GetRequiredService<JsonSerializer>()))
.As<ISnapshotStore<BackupState, Guid>>()
.As<IInitializable>();
services.AddSingletonAs(c => new MongoSnapshotStore<EventConsumerState, string>(mongoDatabase, c.GetRequiredService<JsonSerializer>()))
.As<ISnapshotStore<EventConsumerState, string>>()
.As<IInitializable>();

1
src/Squidex/app/features/settings/declarations.ts

@ -5,6 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
export * from './pages/backups/backups-page.component';
export * from './pages/clients/client.component';
export * from './pages/clients/clients-page.component';
export * from './pages/contributors/contributors-page.component';

6
src/Squidex/app/features/settings/module.ts

@ -17,6 +17,7 @@ import {
} from 'shared';
import {
BackupsPageComponent,
ClientComponent,
ClientsPageComponent,
ContributorsPageComponent,
@ -45,6 +46,10 @@ const routes: Routes = [
path: 'more',
component: MorePageComponent
},
{
path: 'backups',
component: BackupsPageComponent
},
{
path: 'clients',
component: ClientsPageComponent,
@ -137,6 +142,7 @@ const routes: Routes = [
RouterModule.forChild(routes)
],
declarations: [
BackupsPageComponent,
ClientComponent,
ClientsPageComponent,
ContributorsPageComponent,

82
src/Squidex/app/features/settings/pages/backups/backups-page.component.html

@ -0,0 +1,82 @@
<sqx-title message="{app} | Backups | Settings" parameter1="app" [value1]="ctx.appName"></sqx-title>
<sqx-panel desiredWidth="50rem">
<div class="panel-header">
<div class="panel-title-row">
<div class="float-right">
<button class="btn btn-success" [disabled]="backups.length === 10" (click)="startBackup()">
Start Backup
</button>
</div>
<h3 class="panel-title">Backups</h3>
</div>
<a class="panel-close" sqxParentLink>
<i class="icon-close"></i>
</a>
</div>
<div class="panel-main">
<div class="panel-content">
<div class="panel-alert panel-alert-danger" *ngIf="backups.length >= 10">
Your have reached the maximum number of backups: 10.
</div>
<div class="table-items-row" *ngFor="let backup of backups; trackBy: trackBy">
<div class="row no-gutter">
<div class="col col-auto">
<div *ngIf="!backup.stopped" class="backup-status backup-status-pending spin">
<i class="icon-hour-glass"></i>
</div>
<div *ngIf="backup.stopped && backup.isFailed" class="backup-status backup-status-failed">
<i class="icon-exclamation"></i>
</div>
<div *ngIf="backup.stopped && !backup.isFailed" class="backup-status backup-status-success">
<i class="icon-checkmark"></i>
</div>
</div>
<div class="col col-auto">
<div>
Started:
</div>
<div>
Duration:
</div>
</div>
<div class="col col-auto">
<div>
{{backup.started.toISOString()}}
</div>
<div *ngIf="backup.stopped">
{{getDuration(backup) | sqxDuration}}
</div>
</div>
<div class="col">
<div>
<span title="Archived events">
Events: <strong class="backup-progress">{{backup.handledEvents | sqxKNumber}}</strong>
</span>,
<span title="Archived assets">
Assets: <strong class="backup-progress">{{backup.handledAssets | sqxKNumber}}</strong>
</span>
</div>
<div *ngIf="backup.stopped && !backup.isFailed">
Download:
<a href="{{getDownloadUrl(backup)}}" target="_blank">
Ready
</a>
</div>
</div>
<div class="col col-auto">
<button type="button" class="btn btn-link btn-danger" (sqxConfirmClick)="deleteBackup(backup)" confirmTitle="Delete backup"
confirmText="Do you really want to delete the backup?">
<i class="icon-bin2"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</sqx-panel>

29
src/Squidex/app/features/settings/pages/backups/backups-page.component.scss

@ -0,0 +1,29 @@
@import '_vars';
@import '_mixins';
$cicle-size: 2.8rem;
.backup-status {
& {
@include circle($cicle-size);
line-height: $cicle-size + .1rem;
text-align: center;
font-size: .4 * $cicle-size;
font-weight: normal;
background: $color-border;
color: $color-dark-foreground;
vertical-align: middle;
}
&-pending {
color: inherit;
}
&-failed {
background: $color-theme-error;
}
&-success {
background: $color-theme-green;
}
}

83
src/Squidex/app/features/settings/pages/backups/backups-page.component.ts

@ -0,0 +1,83 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import {
ApiUrlConfig,
AppContext,
BackupDto,
BackupsService,
Duration,
ImmutableArray
} from 'shared';
@Component({
selector: 'sqx-backups-page',
styleUrls: ['./backups-page.component.scss'],
templateUrl: './backups-page.component.html',
providers: [
AppContext
]
})
export class BackupsPageComponent implements OnInit, OnDestroy {
private loadSubscription: Subscription;
public backups = ImmutableArray.empty<BackupDto>();
constructor(
public readonly ctx: AppContext,
private readonly apiUrl: ApiUrlConfig,
private readonly backupsService: BackupsService
) {
}
public ngOnDestroy() {
this.loadSubscription.unsubscribe();
}
public ngOnInit() {
this.loadSubscription =
Observable.timer(0, 2000)
.switchMap(t => this.backupsService.getBackups(this.ctx.appName))
.subscribe(dtos => {
this.backups = ImmutableArray.of(dtos);
});
}
public startBackup() {
this.backupsService.postBackup(this.ctx.appName)
.subscribe(() => {
this.ctx.notifyInfo('Backup started, it can take several minutes to complete.');
}, error => {
this.ctx.notifyError(error);
});
}
public deleteBackup(backup: BackupDto) {
this.backupsService.deleteBackup(this.ctx.appName, backup.id)
.subscribe(() => {
this.ctx.notifyInfo('Backup is about to be deleted.');
}, error => {
this.ctx.notifyError(error);
});
}
public getDownloadUrl(backup: BackupDto) {
return this.apiUrl.buildUrl(`api/apps/${this.ctx.appName}/backups/${backup.id}`);
}
public getDuration(backup: BackupDto) {
return Duration.create(backup.started, backup.stopped!);
}
public trackBy(index: number, item: BackupDto) {
return item.id;
}
}

8
src/Squidex/app/features/settings/settings-area.component.html

@ -14,6 +14,12 @@
<div class="panel-main">
<div class="panel-content">
<ul class="nav nav-panel nav-dark flex-column">
<li class="nav-item" *ngIf="ctx.app.permission === 'Owner'">
<a class="nav-link" routerLink="backups" routerLinkActive="active">
Backups
<i class="icon-angle-right"></i>
</a>
</li>
<li class="nav-item">
<a class="nav-link" routerLink="clients" routerLinkActive="active">
Clients
@ -40,7 +46,7 @@
</li>
<li class="nav-item" *ngIf="ctx.app.permission === 'Owner'">
<a class="nav-link" routerLink="plans" routerLinkActive="active">
Update Plan
Subscription
<i class="icon-angle-right"></i>
</a>
</li>

4
src/Squidex/app/framework/angular/date-time.pipes.spec.ts

@ -23,12 +23,12 @@ const dateTime = DateTime.parse('2013-10-03T12:13:14.125', DateTime.iso8601());
describe('DurationPipe', () => {
it('should format to standard duration string', () => {
const duration = Duration.create(dateTime, dateTime.addMinutes(10).addDays(13));
const duration = Duration.create(dateTime, dateTime.addMinutes(10).addDays(13).addSeconds(10));
const pipe = new DurationPipe();
const actual = pipe.transform(duration);
const expected = '312:10h';
const expected = '312:10:10';
expect(actual).toBe(expected);
});

8
src/Squidex/app/framework/utils/duration.spec.ts

@ -28,24 +28,24 @@ describe('Duration', () => {
it('should print to string correctly', () => {
const s = DateTime.today();
const d = s.addHours(1).addMinutes(30).addSeconds(60);
const d = s.addHours(12).addMinutes(30).addSeconds(60);
const duration = Duration.create(s, d);
const actual = duration.toString();
const expected = '1:31h';
const expected = '12:31:00';
expect(actual).toBe(expected);
});
it('should print to string correctly for one digit minutes', () => {
const s = DateTime.today();
const d = s.addHours(1).addMinutes(1).addSeconds(60);
const d = s.addHours(1).addMinutes(2).addSeconds(5);
const duration = Duration.create(s, d);
const actual = duration.toString();
const expected = '1:02h';
const expected = '01:02:05';
expect(actual).toBe(expected);
});

14
src/Squidex/app/framework/utils/duration.ts

@ -25,12 +25,24 @@ export class Duration {
public toString(): string {
const duration = moment.duration(this.value);
let hoursString = Math.floor(duration.asHours()).toString();
if (hoursString.length === 1) {
hoursString = `0${hoursString}`;
}
let minutesString = duration.minutes().toString();
if (minutesString.length === 1) {
minutesString = `0${minutesString}`;
}
return Math.floor(duration.asHours()) + ':' + minutesString + 'h';
let secondsString = duration.seconds().toString();
if (secondsString.length === 1) {
secondsString = `0${secondsString}`;
}
return `${hoursString}:${minutesString}:${secondsString}`;
}
}

1
src/Squidex/app/shared/declarations-base.ts

@ -25,6 +25,7 @@ export * from './services/apps-store.service';
export * from './services/apps.service';
export * from './services/assets.service';
export * from './services/auth.service';
export * from './services/backups.service';
export * from './services/contents.service';
export * from './services/event-consumers.service';
export * from './services/graphql.service';

2
src/Squidex/app/shared/module.ts

@ -26,6 +26,7 @@ import {
AssetUrlPipe,
AuthInterceptor,
AuthService,
BackupsService,
ContentsService,
EventConsumersService,
FileIconPipe,
@ -127,6 +128,7 @@ export class SqxSharedModule {
AppsStoreService,
AssetsService,
AuthService,
BackupsService,
ContentsService,
EventConsumersService,
GraphQlService,

101
src/Squidex/app/shared/services/backups.service.spec.ts

@ -0,0 +1,101 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing';
import {
AnalyticsService,
ApiUrlConfig,
BackupDto,
BackupsService,
DateTime
} from './../';
describe('BackupsService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
HttpClientTestingModule
],
providers: [
BackupsService,
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') },
{ provide: AnalyticsService, useValue: new AnalyticsService() }
]
});
});
afterEach(inject([HttpTestingController], (httpMock: HttpTestingController) => {
httpMock.verify();
}));
it('should make get request to get backups',
inject([BackupsService, HttpTestingController], (backupsService: BackupsService, httpMock: HttpTestingController) => {
let backups: BackupDto[] | null = null;
backupsService.getBackups('my-app').subscribe(result => {
backups = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/backups');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush([
{
id: '1',
started: '2017-02-03',
stopped: '2017-02-04',
handledEvents: 13,
handledAssets: 17,
isFailed: false
},
{
id: '2',
started: '2018-02-03',
stopped: null,
handledEvents: 23,
handledAssets: 27,
isFailed: true
}
]);
expect(backups).toEqual([
new BackupDto('1', DateTime.parseISO_UTC('2017-02-03'), DateTime.parseISO_UTC('2017-02-04'), 13, 17, false),
new BackupDto('2', DateTime.parseISO_UTC('2018-02-03'), null, 23, 27, true)
]);
}));
it('should make post request to start backup',
inject([BackupsService, HttpTestingController], (backupsService: BackupsService, httpMock: HttpTestingController) => {
backupsService.postBackup('my-app').subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/backups');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({});
}));
it('should make delete request to remove language',
inject([BackupsService, HttpTestingController], (backupsService: BackupsService, httpMock: HttpTestingController) => {
backupsService.deleteBackup('my-app', '1').subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/backups/1');
expect(req.request.method).toEqual('DELETE');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({});
}));
});

80
src/Squidex/app/shared/services/backups.service.ts

@ -0,0 +1,80 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import 'framework/angular/http-extensions';
import {
AnalyticsService,
ApiUrlConfig,
DateTime
} from 'framework';
export class BackupDto {
constructor(
public readonly id: string,
public readonly started: DateTime,
public readonly stopped: DateTime | null,
public readonly handledEvents: number,
public readonly handledAssets: number,
public readonly isFailed: boolean
) {
}
}
@Injectable()
export class BackupsService {
constructor(
private readonly http: HttpClient,
private readonly apiUrl: ApiUrlConfig,
private readonly analytics: AnalyticsService
) {
}
public getBackups(appName: string): Observable<BackupDto[]> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/backups`);
return this.http.get(url)
.map(response => {
const items: any[] = <any>response;
return items.map(item => {
return new BackupDto(
item.id,
DateTime.parseISO_UTC(item.started),
item.stopped ? DateTime.parseISO_UTC(item.stopped) : null,
item.handledEvents,
item.handledAssets,
item.isFailed);
});
})
.pretifyError('Failed to load backups.');
}
public postBackup(appName: string): Observable<any> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/backups`);
return this.http.post(url, {})
.do(() => {
this.analytics.trackEvent('Backup', 'Started', appName);
})
.pretifyError('Failed to start backup.');
}
public deleteBackup(appName: string, id: string): Observable<any> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/backups/${id}`);
return this.http.delete(url)
.do(() => {
this.analytics.trackEvent('Backup', 'Deleted', appName);
})
.pretifyError('Failed to delete backup.');
}
}

29
src/Squidex/app/theme/_common.scss

@ -158,3 +158,32 @@ body {
background: $color-dark-foreground;
}
}
//
// Animations
//
.spin {
@include animation(spin 3s infinite linear);
}
@include keyframes (spin) {
20% {
@include rotate(0deg);
}
30% {
@include rotate(180deg);
}
70% {
@include rotate(180deg);
}
80% {
@include rotate(360deg);
}
100% {
@include rotate(360deg);
}
}

15
src/Squidex/app/theme/_mixins.scss

@ -293,6 +293,21 @@
border-radius: $radius $radius 0 0;
}
@mixin keyframes($name) {
@-webkit-keyframes #{$name} {
@content;
}
@-moz-keyframes #{$name} {
@content;
}
@keyframes #{$name} {
@content;
}
@-ms-keyframes #{$name} {
@content;
}
}
@mixin animation($animation...) {
-webkit-animation: $animation;
-moz-animation: $animation;

2
src/Squidex/app/theme/icomoon/demo-files/demo.css

@ -147,7 +147,7 @@ p {
font-size: 16px;
}
.fs1 {
font-size: 32px;
font-size: 28px;
}
.fs2 {
font-size: 32px;

828
src/Squidex/app/theme/icomoon/demo.html

File diff suppressed because it is too large

BIN
src/Squidex/app/theme/icomoon/fonts/icomoon.eot

Binary file not shown.

3
src/Squidex/app/theme/icomoon/fonts/icomoon.svg

@ -90,6 +90,9 @@
<glyph unicode="&#xe950;" glyph-name="clock" d="M658.744 210.744l-210.744 210.746v282.51h128v-229.49l173.256-173.254zM512 960c-282.77 0-512-229.23-512-512s229.23-512 512-512 512 229.23 512 512-229.23 512-512 512zM512 64c-212.078 0-384 171.922-384 384s171.922 384 384 384c212.078 0 384-171.922 384-384s-171.922-384-384-384z" />
<glyph unicode="&#xe951;" glyph-name="circle" d="M1024 448c0-282.77-229.23-512-512-512s-512 229.23-512 512c0 282.77 229.23 512 512 512s512-229.23 512-512z" />
<glyph unicode="&#xe952;" glyph-name="action-ElasticSearch" horiz-adv-x="1071" d="M491.055 313.184h-338.784c-12.301 39.564-19.283 81.787-19.283 125.673s6.649 86.109 19.283 125.673h557.548c69.153 0 125.008-56.519 125.008-126.005 0-69.153-55.855-125.34-125.008-125.34h-218.764zM475.096 271.293h-306.868c32.582-74.473 86.442-137.974 153.6-182.192v0c66.161-43.553 145.288-69.153 230.4-69.153 145.288 0 273.288 74.14 348.426 186.514-38.566 39.896-92.758 64.831-152.603 64.831h-272.956zM748.052 606.421c59.844 0 114.036 24.935 152.603 64.831-75.138 112.374-203.138 186.514-348.758 186.514-85.112 0-164.239-25.6-230.4-69.153v0c-67.158-44.551-121.018-107.719-153.6-182.192h580.156z" />
<glyph unicode="&#xe953;" glyph-name="spinner" d="M192 448c0 12.18 0.704 24.196 2.030 36.022l-184.98 60.104c-5.916-31.14-9.050-63.264-9.050-96.126 0-147.23 62.166-279.922 161.654-373.324l114.284 157.296c-52.124 56.926-83.938 132.758-83.938 216.028zM832 448c0-83.268-31.812-159.102-83.938-216.028l114.284-157.296c99.488 93.402 161.654 226.094 161.654 373.324 0 32.862-3.132 64.986-9.048 96.126l-184.98-60.104c1.324-11.828 2.028-23.842 2.028-36.022zM576 761.592c91.934-18.662 169.544-76.742 214.45-155.826l184.978 60.102c-73.196 155.42-222.24 268.060-399.428 290.156v-194.432zM233.55 605.768c44.906 79.084 122.516 137.164 214.45 155.826v194.43c-177.188-22.096-326.23-134.736-399.426-290.154l184.976-60.102zM644.556 156.672c-40.39-18.408-85.272-28.672-132.556-28.672s-92.166 10.264-132.554 28.67l-114.292-157.31c73.206-40.366 157.336-63.36 246.846-63.36s173.64 22.994 246.848 63.36l-114.292 157.312z" />
<glyph unicode="&#xe954;" glyph-name="hour-glass" d="M728.992 448c137.754 87.334 231.008 255.208 231.008 448 0 21.676-1.192 43.034-3.478 64h-889.042c-2.29-20.968-3.48-42.326-3.48-64 0-192.792 93.254-360.666 231.006-448-137.752-87.334-231.006-255.208-231.006-448 0-21.676 1.19-43.034 3.478-64h889.042c2.288 20.966 3.478 42.324 3.478 64 0.002 192.792-93.252 360.666-231.006 448zM160 0c0 186.912 80.162 345.414 224 397.708v100.586c-143.838 52.29-224 210.792-224 397.706v0h704c0-186.914-80.162-345.416-224-397.706v-100.586c143.838-52.294 224-210.796 224-397.708h-704zM619.626 290.406c-71.654 40.644-75.608 93.368-75.626 125.366v64.228c0 31.994 3.804 84.914 75.744 125.664 38.504 22.364 71.808 56.348 97.048 98.336h-409.582c25.266-42.032 58.612-76.042 97.166-98.406 71.654-40.644 75.606-93.366 75.626-125.366v-64.228c0-31.992-3.804-84.914-75.744-125.664-72.622-42.18-126.738-125.684-143.090-226.336h501.67c-16.364 100.708-70.53 184.248-143.212 226.406z" />
<glyph unicode="&#xe955;" glyph-name="exclamation" horiz-adv-x="366" d="M292.571 237.714v-128c0-20-16.571-36.571-36.571-36.571h-146.286c-20 0-36.571 16.571-36.571 36.571v128c0 20 16.571 36.571 36.571 36.571h146.286c20 0 36.571-16.571 36.571-36.571zM309.714 841.143l-16-438.857c-0.571-20-17.714-36.571-37.714-36.571h-146.286c-20 0-37.143 16.571-37.714 36.571l-16 438.857c-0.571 20 15.429 36.571 35.429 36.571h182.857c20 0 36-16.571 35.429-36.571z" />
<glyph unicode="&#xe9ca;" glyph-name="earth" d="M512 960c-282.77 0-512-229.23-512-512s229.23-512 512-512 512 229.23 512 512-229.23 512-512 512zM512-0.002c-62.958 0-122.872 13.012-177.23 36.452l233.148 262.29c5.206 5.858 8.082 13.422 8.082 21.26v96c0 17.674-14.326 32-32 32-112.99 0-232.204 117.462-233.374 118.626-6 6.002-14.14 9.374-22.626 9.374h-128c-17.672 0-32-14.328-32-32v-192c0-12.122 6.848-23.202 17.69-28.622l110.31-55.156v-187.886c-116.052 80.956-192 215.432-192 367.664 0 68.714 15.49 133.806 43.138 192h116.862c8.488 0 16.626 3.372 22.628 9.372l128 128c6 6.002 9.372 14.14 9.372 22.628v77.412c40.562 12.074 83.518 18.588 128 18.588 70.406 0 137.004-16.26 196.282-45.2-4.144-3.502-8.176-7.164-12.046-11.036-36.266-36.264-56.236-84.478-56.236-135.764s19.97-99.5 56.236-135.764c36.434-36.432 85.218-56.264 135.634-56.26 3.166 0 6.342 0.080 9.518 0.236 13.814-51.802 38.752-186.656-8.404-372.334-0.444-1.744-0.696-3.488-0.842-5.224-81.324-83.080-194.7-134.656-320.142-134.656z" />
<glyph unicode="&#xf00a;" glyph-name="grid" d="M292.571 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM292.571 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM292.571 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857z" />
<glyph unicode="&#xf0c9;" glyph-name="list" horiz-adv-x="878" d="M877.714 182.857v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571zM877.714 475.428v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571zM877.714 768v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571z" />

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 75 KiB

BIN
src/Squidex/app/theme/icomoon/fonts/icomoon.ttf

Binary file not shown.

BIN
src/Squidex/app/theme/icomoon/fonts/icomoon.woff

Binary file not shown.

1473
src/Squidex/app/theme/icomoon/selection.json

File diff suppressed because it is too large

136
src/Squidex/app/theme/icomoon/style.css

@ -1,10 +1,10 @@
@font-face {
font-family: 'icomoon';
src: url('fonts/icomoon.eot?asl9vj');
src: url('fonts/icomoon.eot?asl9vj#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?asl9vj') format('truetype'),
url('fonts/icomoon.woff?asl9vj') format('woff'),
url('fonts/icomoon.svg?asl9vj#icomoon') format('svg');
src: url('fonts/icomoon.eot?y97utm');
src: url('fonts/icomoon.eot?y97utm#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?y97utm') format('truetype'),
url('fonts/icomoon.woff?y97utm') format('woff'),
url('fonts/icomoon.svg?y97utm#icomoon') format('svg');
font-weight: normal;
font-style: normal;
}
@ -24,6 +24,9 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-exclamation:before {
content: "\e955";
}
.icon-action-ElasticSearch:before {
content: "\e952";
}
@ -102,6 +105,87 @@
.icon-action-Webhook:before {
content: "\e947";
}
.icon-hour-glass:before {
content: "\e954";
}
.icon-spinner:before {
content: "\e953";
}
.icon-clock:before {
content: "\e950";
}
.icon-bin2:before {
content: "\e902";
}
.icon-earth:before {
content: "\e9ca";
}
.icon-elapsed:before {
content: "\e943";
}
.icon-google:before {
content: "\e93b";
}
.icon-lock:before {
content: "\e934";
}
.icon-microsoft:before {
content: "\e940";
}
.icon-action-AzureQueue:before {
content: "\e940";
}
.icon-pause:before {
content: "\e92f";
}
.icon-play:before {
content: "\e930";
}
.icon-reset:before {
content: "\e92e";
}
.icon-settings2:before {
content: "\e92d";
}
.icon-timeout:before {
content: "\e944";
}
.icon-unlocked:before {
content: "\e933";
}
.icon-caret-up:before {
content: "\e92b";
}
.icon-contents:before {
content: "\e946";
}
.icon-trigger-ContentChanged:before {
content: "\e946";
}
.icon-control-Date:before {
content: "\e936";
}
.icon-control-DateTime:before {
content: "\e937";
}
.icon-control-Markdown:before {
content: "\e938";
}
.icon-grid:before {
content: "\f00a";
}
.icon-list:before {
content: "\f0c9";
}
.icon-user-o:before {
content: "\e932";
}
.icon-rules:before {
content: "\e947";
}
.icon-action-Webhook:before {
content: "\e947";
}
.icon-circle:before {
content: "\e951";
}
@ -262,48 +346,6 @@
.icon-user:before {
content: "\e928";
}
.icon-clock:before {
content: "\e950";
}
.icon-bin2:before {
content: "\e902";
}
.icon-earth:before {
content: "\e9ca";
}
.icon-elapsed:before {
content: "\e943";
}
.icon-google:before {
content: "\e93b";
}
.icon-lock:before {
content: "\e934";
}
.icon-microsoft:before {
content: "\e940";
}
.icon-action-AzureQueue:before {
content: "\e940";
}
.icon-pause:before {
content: "\e92f";
}
.icon-play:before {
content: "\e930";
}
.icon-reset:before {
content: "\e92e";
}
.icon-settings2:before {
content: "\e92d";
}
.icon-timeout:before {
content: "\e944";
}
.icon-unlocked:before {
content: "\e933";
}
.icon-browser:before {
content: "\e935";
}

2
tests/RunCoverage.ps1

@ -47,7 +47,7 @@ if ($all -Or $appsEntities) {
-register:user `
-target:"C:\Program Files\dotnet\dotnet.exe" `
-targetargs:"test $folderWorking\Squidex.Domain.Apps.Entities.Tests\Squidex.Domain.Apps.Entities.Tests.csproj" `
-filter:"+[Squidex.Domain.Apps.Entities*]*" `
-filter:"+[Squidex.Domain.Apps.Entities*]* -[Squidex.Domain.Apps.Entities*]*CodeGen*" `
-skipautoprops `
-output:"$folderWorking\$folderReports\Entities.xml" `
-oldStyle

47
tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs

@ -37,6 +37,36 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
Assert.True(result is JObject);
}
[Fact]
public void Should_create_route_data()
{
var appId = Guid.NewGuid();
var @event = new ContentCreated
{
AppId = new NamedId<Guid>(appId, "my-app")
};
var result = sut.ToRouteData(AsEnvelope(@event));
Assert.True(result is JObject);
}
[Fact]
public void Should_create_route_data_from_event()
{
var appId = Guid.NewGuid();
var @event = new ContentCreated
{
AppId = new NamedId<Guid>(appId, "my-app")
};
var result = sut.ToRouteData(AsEnvelope(@event), "MyEventName");
Assert.Equal("MyEventName", result["type"]);
}
[Fact]
public void Should_replace_app_information_from_event()
{
@ -165,6 +195,23 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
Assert.Equal("Berlin", result);
}
[Fact]
public void Should_return_plain_value_when_found_from_update_event()
{
var @event = new ContentUpdated
{
Data =
new NamedContentData()
.AddField("city",
new ContentFieldData()
.AddValue("iv", "Berlin"))
};
var result = sut.FormatString("$CONTENT_DATA.city.iv", AsEnvelope(@event));
Assert.Equal("Berlin", result);
}
[Fact]
public void Should_return_undefined_when_null()
{

6
tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs

@ -77,7 +77,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
}
[Fact]
public void Should_not_create_trigger_if_no_trigger_handler_registered()
public void Should_not_create_job_if_no_trigger_handler_registered()
{
var ruleConfig = new Rule(new InvalidTrigger(), new WebhookAction());
var ruleEnvelope = Envelope.Create(new ContentCreated());
@ -88,7 +88,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
}
[Fact]
public void Should_not_create_trigger_if_no_action_handler_registered()
public void Should_not_create_job_if_no_action_handler_registered()
{
var ruleConfig = new Rule(new ContentChangedTrigger(), new InvalidAction());
var ruleEnvelope = Envelope.Create(new ContentCreated());
@ -101,7 +101,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Fact]
public void Should_not_create_if_not_triggered()
{
var ruleConfig = new Rule(new ContentChangedTrigger(), new InvalidAction());
var ruleConfig = new Rule(new ContentChangedTrigger(), new WebhookAction());
var ruleEnvelope = Envelope.Create(new ContentCreated());
A.CallTo(() => ruleTriggerHandler.Triggers(A<Envelope<AppEvent>>.Ignored, ruleConfig.Trigger))

13
tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs

@ -7,6 +7,7 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using FakeItEasy;
using Orleans;
@ -89,21 +90,21 @@ namespace Squidex.Domain.Apps.Entities.Assets
private void SetupStore(long version, Guid commitId)
{
A.CallTo(() => assetStore.UploadTemporaryAsync(commitId.ToString(), stream))
A.CallTo(() => assetStore.UploadAsync(commitId.ToString(), stream, CancellationToken.None))
.Returns(TaskHelper.Done);
A.CallTo(() => assetStore.CopyTemporaryAsync(commitId.ToString(), assetId.ToString(), version, null))
A.CallTo(() => assetStore.CopyAsync(commitId.ToString(), assetId.ToString(), version, null, CancellationToken.None))
.Returns(TaskHelper.Done);
A.CallTo(() => assetStore.DeleteTemporaryAsync(commitId.ToString()))
A.CallTo(() => assetStore.DeleteAsync(commitId.ToString()))
.Returns(TaskHelper.Done);
}
private void AssertAssetHasBeenUploaded(long version, Guid commitId)
{
A.CallTo(() => assetStore.UploadTemporaryAsync(commitId.ToString(), stream))
A.CallTo(() => assetStore.UploadAsync(commitId.ToString(), stream, CancellationToken.None))
.MustHaveHappened();
A.CallTo(() => assetStore.CopyTemporaryAsync(commitId.ToString(), assetId.ToString(), version, null))
A.CallTo(() => assetStore.CopyAsync(commitId.ToString(), assetId.ToString(), version, null, CancellationToken.None))
.MustHaveHappened();
A.CallTo(() => assetStore.DeleteTemporaryAsync(commitId.ToString()))
A.CallTo(() => assetStore.DeleteAsync(commitId.ToString()))
.MustHaveHappened();
}

224
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs

@ -0,0 +1,224 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using FakeItEasy;
using Microsoft.OData.UriParser;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents.Edm;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents
{
public class ContentQueryServiceTests
{
private readonly IContentRepository contentRepository = A.Fake<IContentRepository>();
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>();
private readonly ISchemaEntity schema = A.Fake<ISchemaEntity>();
private readonly IContentEntity content = A.Fake<IContentEntity>();
private readonly IAppEntity app = A.Fake<IAppEntity>();
private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly Guid appId = Guid.NewGuid();
private readonly Guid schemaId = Guid.NewGuid();
private readonly Guid contentId = Guid.NewGuid();
private readonly string appName = "my-app";
private readonly NamedContentData contentData = new NamedContentData();
private readonly NamedContentData contentTransformed = new NamedContentData();
private readonly ClaimsPrincipal user;
private readonly ClaimsIdentity identity = new ClaimsIdentity();
private readonly EdmModelBuilder modelBuilder = A.Fake<EdmModelBuilder>();
private readonly ContentQueryService sut;
public ContentQueryServiceTests()
{
user = new ClaimsPrincipal(identity);
A.CallTo(() => app.Id).Returns(appId);
A.CallTo(() => app.Name).Returns(appName);
A.CallTo(() => content.Id).Returns(contentId);
A.CallTo(() => content.Data).Returns(contentData);
A.CallTo(() => content.Status).Returns(Status.Published);
sut = new ContentQueryService(contentRepository, appProvider, scriptEngine, modelBuilder);
}
[Fact]
public async Task Should_return_schema_from_id_if_string_is_guid()
{
A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false))
.Returns(schema);
var result = await sut.FindSchemaAsync(app, schemaId.ToString());
Assert.Equal(schema, result);
}
[Fact]
public async Task Should_return_schema_from_name_if_string_not_guid()
{
A.CallTo(() => appProvider.GetSchemaAsync(appId, "my-schema"))
.Returns(schema);
var result = await sut.FindSchemaAsync(app, "my-schema");
Assert.Equal(schema, result);
}
[Fact]
public async Task Should_throw_if_schema_not_found()
{
A.CallTo(() => appProvider.GetSchemaAsync(appId, "my-schema"))
.Returns((ISchemaEntity)null);
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.FindSchemaAsync(app, "my-schema"));
}
[Fact]
public async Task Should_return_content_from_repository_and_transform()
{
A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false))
.Returns(schema);
A.CallTo(() => contentRepository.FindContentAsync(app, schema, contentId))
.Returns(content);
A.CallTo(() => schema.ScriptQuery)
.Returns("<script-query>");
A.CallTo(() => scriptEngine.Transform(A<ScriptContext>.That.Matches(x => x.User == user && x.ContentId == contentId && ReferenceEquals(x.Data, contentData)), "<query-script>"))
.Returns(contentTransformed);
var result = await sut.FindContentAsync(app, schemaId.ToString(), user, contentId);
Assert.Equal(schema, result.Schema);
Assert.Equal(contentTransformed, result.Content.Data);
Assert.Equal(content.Id, result.Content.Id);
}
[Fact]
public async Task Should_throw_if_content_to_find_does_not_exist()
{
A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false))
.Returns(schema);
A.CallTo(() => contentRepository.FindContentAsync(app, schema, contentId))
.Returns((IContentEntity)null);
await Assert.ThrowsAsync<DomainObjectNotFoundException>(async () => await sut.FindContentAsync(app, schemaId.ToString(), user, contentId));
}
[Fact]
public async Task Should_return_contents_with_ids_from_repository_and_transform()
{
await TestManyIdRequest(true, false, new HashSet<Guid> { Guid.NewGuid() }, Status.Draft, Status.Published);
}
[Fact]
public async Task Should_return_non_archived_contents_from_repository_and_transform()
{
await TestManyRequest(true, false, Status.Draft, Status.Published);
}
[Fact]
public async Task Should_return_archived_contents_from_repository_and_transform()
{
await TestManyRequest(true, true, Status.Archived);
}
[Fact]
public async Task Should_return_draft_contents_from_repository_and_transform()
{
await TestManyRequest(false, false, Status.Published);
}
[Fact]
public async Task Should_return_draft_contents_from_repository_and_transform_when_requesting_archive_as_non_frontend()
{
await TestManyRequest(false, true, Status.Published);
}
private async Task TestManyRequest(bool isFrontend, bool archive, params Status[] status)
{
SetupClaims(isFrontend);
SetupFakeWithOdataQuery(status);
SetupFakeWithScripting();
var result = await sut.QueryAsync(app, schemaId.ToString(), user, archive, string.Empty);
Assert.Equal(schema, result.Schema);
Assert.Equal(contentData, result.Contents[0].Data);
Assert.Equal(content.Id, result.Contents[0].Id);
Assert.Equal(123, result.Contents.Total);
}
private async Task TestManyIdRequest(bool isFrontend, bool archive, HashSet<Guid> ids, params Status[] status)
{
SetupClaims(isFrontend);
SetupFakeWithIdQuery(status, ids);
SetupFakeWithScripting();
var result = await sut.QueryAsync(app, schemaId.ToString(), user, archive, ids);
Assert.Equal(schema, result.Schema);
Assert.Equal(contentData, result.Contents[0].Data);
Assert.Equal(content.Id, result.Contents[0].Id);
Assert.Equal(123, result.Contents.Total);
}
private void SetupClaims(bool isFrontend)
{
if (isFrontend)
{
identity.AddClaim(new Claim(OpenIdClaims.ClientId, "squidex-frontend"));
}
}
private void SetupFakeWithIdQuery(Status[] status, HashSet<Guid> ids)
{
A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false))
.Returns(schema);
A.CallTo(() => contentRepository.QueryAsync(app, schema, A<Status[]>.That.IsSameSequenceAs(status), ids))
.Returns(ResultList.Create(Enumerable.Repeat(content, 1), 123));
}
private void SetupFakeWithOdataQuery(Status[] status)
{
A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false))
.Returns(schema);
A.CallTo(() => contentRepository.QueryAsync(app, schema, A<Status[]>.That.IsSameSequenceAs(status), A<ODataUriParser>.Ignored))
.Returns(ResultList.Create(Enumerable.Repeat(content, 1), 123));
}
private void SetupFakeWithScripting()
{
A.CallTo(() => schema.ScriptQuery)
.Returns("<script-query>");
A.CallTo(() => scriptEngine.Transform(A<ScriptContext>.That.Matches(x => x.User == user && x.ContentId == contentId && ReferenceEquals(x.Data, contentData)), "<query-script>"))
.Returns(contentTransformed);
}
}
}

14
tests/Squidex.Domain.Users.Tests/AssetUserPictureStoreTests.cs

@ -7,8 +7,10 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using FakeItEasy;
using FakeItEasy.Core;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Tasks;
using Xunit;
@ -33,20 +35,22 @@ namespace Squidex.Domain.Users
{
var stream = new MemoryStream();
A.CallTo(() => assetStore.UploadAsync(userId, 0, "picture", stream))
A.CallTo(() => assetStore.UploadAsync(userId, 0, "picture", stream, CancellationToken.None))
.Returns(TaskHelper.Done);
await sut.UploadAsync(userId, stream);
A.CallTo(() => assetStore.UploadAsync(userId, 0, "picture", stream)).MustHaveHappened();
A.CallTo(() => assetStore.UploadAsync(userId, 0, "picture", stream, CancellationToken.None)).MustHaveHappened();
}
[Fact]
public async Task Should_invoke_asset_store_to_download_picture()
{
A.CallTo(() => assetStore.DownloadAsync(userId, 0, "picture", A<Stream>.Ignored))
.Invokes(async (string id, long version, string suffix, Stream stream) =>
A.CallTo(() => assetStore.DownloadAsync(userId, 0, "picture", A<Stream>.Ignored, CancellationToken.None))
.Invokes(async (IFakeObjectCall call) =>
{
var stream = call.GetArgument<Stream>(3);
await stream.WriteAsync(new byte[] { 1, 2, 3, 4 }, 0, 4);
});
@ -55,7 +59,7 @@ namespace Squidex.Domain.Users
Assert.Equal(0, result.Position);
Assert.Equal(4, result.Length);
A.CallTo(() => assetStore.DownloadAsync(userId, 0, "picture", A<Stream>.Ignored)).MustHaveHappened();
A.CallTo(() => assetStore.DownloadAsync(userId, 0, "picture", A<Stream>.Ignored, CancellationToken.None)).MustHaveHappened();
}
}
}

28
tests/Squidex.Infrastructure.Tests/Assets/AssetStoreTests.cs

@ -43,7 +43,7 @@ namespace Squidex.Infrastructure.Assets
{
((IInitializable)Sut).Initialize();
return Assert.ThrowsAsync<AssetNotFoundException>(() => Sut.CopyTemporaryAsync(Id(), Id(), 1, null));
return Assert.ThrowsAsync<AssetNotFoundException>(() => Sut.CopyAsync(Id(), Id(), 1, null));
}
[Fact]
@ -73,8 +73,8 @@ namespace Squidex.Infrastructure.Assets
var assetId = Id();
var assetData = new MemoryStream(new byte[] { 0x1, 0x2, 0x3, 0x4 });
await Sut.UploadTemporaryAsync(tempId, assetData);
await Sut.CopyTemporaryAsync(tempId, assetId, 1, "suffix");
await Sut.UploadAsync(tempId, assetData);
await Sut.CopyAsync(tempId, assetId, 1, "suffix");
var readData = new MemoryStream();
@ -84,7 +84,7 @@ namespace Squidex.Infrastructure.Assets
}
[Fact]
public async Task Should_ignore_when_deleting_twice()
public async Task Should_ignore_when_deleting_twice_by_name()
{
((IInitializable)Sut).Initialize();
@ -92,9 +92,23 @@ namespace Squidex.Infrastructure.Assets
var assetData = new MemoryStream(new byte[] { 0x1, 0x2, 0x3, 0x4 });
await Sut.UploadTemporaryAsync(tempId, assetData);
await Sut.DeleteTemporaryAsync(tempId);
await Sut.DeleteTemporaryAsync(tempId);
await Sut.UploadAsync(tempId, assetData);
await Sut.DeleteAsync(tempId);
await Sut.DeleteAsync(tempId);
}
[Fact]
public async Task Should_ignore_when_deleting_twice_by_id()
{
((IInitializable)Sut).Initialize();
var tempId = Id();
var assetData = new MemoryStream(new byte[] { 0x1, 0x2, 0x3, 0x4 });
await Sut.UploadAsync(tempId, 0, null, assetData);
await Sut.DeleteAsync(tempId, 0, null);
await Sut.DeleteAsync(tempId, 0, null);
}
private static string Id()

8
tools/Migrate_01/MigrationPath.cs

@ -15,7 +15,7 @@ namespace Migrate_01
{
public sealed class MigrationPath : IMigrationPath
{
private const int CurrentVersion = 6;
private const int CurrentVersion = 7;
private readonly IServiceProvider serviceProvider;
public MigrationPath(IServiceProvider serviceProvider)
@ -38,6 +38,12 @@ namespace Migrate_01
migrations.Add(serviceProvider.GetRequiredService<ConvertEventStore>());
}
// Version 7: Introduces AppId for backups.
else if (version < 7)
{
migrations.Add(serviceProvider.GetRequiredService<ConvertEventStoreAppId>());
}
// Version 5: Fixes the broken command architecture and requires a rebuild of all snapshots.
if (version < 5)
{

31
tools/Migrate_01/Migrations/ConvertEventStore.cs

@ -5,13 +5,11 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Driver;
using Newtonsoft.Json.Linq;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Migrations;
using Squidex.Infrastructure.MongoDb;
@ -35,24 +33,37 @@ namespace Migrate_01.Migrations
var filter = Builders<BsonDocument>.Filter;
var writesBatches = new List<WriteModel<BsonDocument>>();
async Task WriteAsync(WriteModel<BsonDocument> model, bool force)
{
if (model != null)
{
writesBatches.Add(model);
}
if (writesBatches.Count == 1000 || (force && writesBatches.Count > 0))
{
await collection.BulkWriteAsync(writesBatches);
writesBatches.Clear();
}
}
await collection.Find(new BsonDocument()).ForEachAsync(async commit =>
{
foreach (BsonDocument @event in commit["Events"].AsBsonArray)
{
var meta = JObject.Parse(@event["Metadata"].AsString);
var data = JObject.Parse(@event["Payload"].AsString);
if (data.TryGetValue("appId", out var appId))
{
meta[SquidexHeaders.AppId] = NamedId<Guid>.Parse(appId.ToString(), Guid.TryParse).Id;
}
@event.Remove("EventId");
@event["Metadata"] = meta.ToBson();
}
await collection.ReplaceOneAsync(filter.Eq("_id", commit["_id"].AsString), commit);
await WriteAsync(new ReplaceOneModel<BsonDocument>(filter.Eq("_id", commit["_id"].AsString), commit), false);
});
await WriteAsync(null, true);
}
}
}

97
tools/Migrate_01/Migrations/ConvertEventStoreAppId.cs

@ -0,0 +1,97 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Driver;
using Newtonsoft.Json.Linq;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Migrations;
namespace Migrate_01.Migrations
{
public sealed class ConvertEventStoreAppId : IMigration
{
private readonly IEventStore eventStore;
public ConvertEventStoreAppId(IEventStore eventStore)
{
this.eventStore = eventStore;
}
public async Task UpdateAsync()
{
if (eventStore is MongoEventStore mongoEventStore)
{
var collection = mongoEventStore.RawCollection;
var filterer = Builders<BsonDocument>.Filter;
var updater = Builders<BsonDocument>.Update;
var writesBatches = new List<WriteModel<BsonDocument>>();
async Task WriteAsync(WriteModel<BsonDocument> model, bool force)
{
if (model != null)
{
writesBatches.Add(model);
}
if (writesBatches.Count == 1000 || (force && writesBatches.Count > 0))
{
await collection.BulkWriteAsync(writesBatches);
writesBatches.Clear();
}
}
await collection.Find(new BsonDocument()).ForEachAsync(async commit =>
{
UpdateDefinition<BsonDocument> update = null;
var index = 0;
foreach (BsonDocument @event in commit["Events"].AsBsonArray)
{
var data = JObject.Parse(@event["Payload"].AsString);
if (data.TryGetValue("appId", out var appIdValue))
{
var appId = NamedId<Guid>.Parse(appIdValue.ToString(), Guid.TryParse).Id.ToString();
var eventUpdate = updater.Set($"Events.{index}.Metadata.{SquidexHeaders.AppId}", appId);
if (update != null)
{
update = updater.Combine(update, eventUpdate);
}
else
{
update = eventUpdate;
}
}
index++;
}
if (update != null)
{
var write = new UpdateOneModel<BsonDocument>(filterer.Eq("_id", commit["_id"].AsString), update);
await WriteAsync(write, false);
}
});
await WriteAsync(null, true);
}
}
}
}
Loading…
Cancel
Save