mirror of https://github.com/Squidex/squidex.git
committed by
GitHub
159 changed files with 3905 additions and 732 deletions
@ -0,0 +1,190 @@ |
|||||
|
// ==========================================================================
|
||||
|
// 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 Newtonsoft.Json.Linq; |
||||
|
using Orleans; |
||||
|
using Squidex.Domain.Apps.Entities.Apps.Indexes; |
||||
|
using Squidex.Domain.Apps.Entities.Apps.State; |
||||
|
using Squidex.Domain.Apps.Entities.Backup; |
||||
|
using Squidex.Domain.Apps.Events; |
||||
|
using Squidex.Domain.Apps.Events.Apps; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.EventSourcing; |
||||
|
using Squidex.Infrastructure.Orleans; |
||||
|
using Squidex.Infrastructure.States; |
||||
|
using Squidex.Shared.Users; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Apps |
||||
|
{ |
||||
|
public sealed class BackupApps : BackupHandlerWithStore |
||||
|
{ |
||||
|
private const string UsersFile = "Users.json"; |
||||
|
private readonly IGrainFactory grainFactory; |
||||
|
private readonly IUserResolver userResolver; |
||||
|
private readonly HashSet<string> activeUsers = new HashSet<string>(); |
||||
|
private Dictionary<string, string> usersWithEmail = new Dictionary<string, string>(); |
||||
|
private Dictionary<string, RefToken> userMapping = new Dictionary<string, RefToken>(); |
||||
|
private bool isReserved; |
||||
|
private bool isActorAssigned; |
||||
|
private AppCreated appCreated; |
||||
|
|
||||
|
public override string Name { get; } = "Apps"; |
||||
|
|
||||
|
public BackupApps(IStore<Guid> store, IGrainFactory grainFactory, IUserResolver userResolver) |
||||
|
: base(store) |
||||
|
{ |
||||
|
Guard.NotNull(grainFactory, nameof(grainFactory)); |
||||
|
Guard.NotNull(userResolver, nameof(userResolver)); |
||||
|
|
||||
|
this.grainFactory = grainFactory; |
||||
|
|
||||
|
this.userResolver = userResolver; |
||||
|
} |
||||
|
|
||||
|
public override async Task BackupEventAsync(Envelope<IEvent> @event, Guid appId, BackupWriter writer) |
||||
|
{ |
||||
|
if (@event.Payload is AppContributorAssigned appContributorAssigned) |
||||
|
{ |
||||
|
var userId = appContributorAssigned.ContributorId; |
||||
|
|
||||
|
if (!usersWithEmail.ContainsKey(userId)) |
||||
|
{ |
||||
|
var user = await userResolver.FindByIdOrEmailAsync(userId); |
||||
|
|
||||
|
if (user != null) |
||||
|
{ |
||||
|
usersWithEmail.Add(userId, user.Email); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public override Task BackupAsync(Guid appId, BackupWriter writer) |
||||
|
{ |
||||
|
return WriterUsersAsync(writer); |
||||
|
} |
||||
|
|
||||
|
public async override Task RestoreEventAsync(Envelope<IEvent> @event, Guid appId, BackupReader reader, RefToken actor) |
||||
|
{ |
||||
|
switch (@event.Payload) |
||||
|
{ |
||||
|
case AppCreated appCreated: |
||||
|
{ |
||||
|
this.appCreated = appCreated; |
||||
|
|
||||
|
await ResolveUsersAsync(reader, actor); |
||||
|
await ReserveAppAsync(); |
||||
|
|
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
case AppContributorAssigned contributorAssigned: |
||||
|
{ |
||||
|
if (isActorAssigned) |
||||
|
{ |
||||
|
contributorAssigned.ContributorId = MapUser(contributorAssigned.ContributorId, actor).Identifier; |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
isActorAssigned = true; |
||||
|
|
||||
|
contributorAssigned.ContributorId = actor.Identifier; |
||||
|
} |
||||
|
|
||||
|
activeUsers.Add(contributorAssigned.ContributorId); |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
case AppContributorRemoved contributorRemoved: |
||||
|
{ |
||||
|
contributorRemoved.ContributorId = MapUser(contributorRemoved.ContributorId, actor).Identifier; |
||||
|
|
||||
|
activeUsers.Remove(contributorRemoved.ContributorId); |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (@event.Payload is SquidexEvent squidexEvent) |
||||
|
{ |
||||
|
squidexEvent.Actor = MapUser(squidexEvent.Actor.Identifier, actor); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task ReserveAppAsync() |
||||
|
{ |
||||
|
var index = grainFactory.GetGrain<IAppsByNameIndex>(SingleGrain.Id); |
||||
|
|
||||
|
if (!(isReserved = await index.ReserveAppAsync(appCreated.AppId.Id, appCreated.AppId.Name))) |
||||
|
{ |
||||
|
throw new BackupRestoreException("The app id or name is not available."); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private RefToken MapUser(string userId, RefToken fallback) |
||||
|
{ |
||||
|
return userMapping.GetOrAdd(userId, fallback); |
||||
|
} |
||||
|
|
||||
|
private async Task ResolveUsersAsync(BackupReader reader, RefToken actor) |
||||
|
{ |
||||
|
await ReadUsersAsync(reader); |
||||
|
|
||||
|
foreach (var kvp in usersWithEmail) |
||||
|
{ |
||||
|
var user = await userResolver.FindByIdOrEmailAsync(kvp.Value); |
||||
|
|
||||
|
if (user != null) |
||||
|
{ |
||||
|
userMapping[kvp.Key] = new RefToken(RefTokenType.Subject, user.Id); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
userMapping[kvp.Key] = actor; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task ReadUsersAsync(BackupReader reader) |
||||
|
{ |
||||
|
var json = await reader.ReadJsonAttachmentAsync(UsersFile); |
||||
|
|
||||
|
usersWithEmail = json.ToObject<Dictionary<string, string>>(); |
||||
|
} |
||||
|
|
||||
|
private Task WriterUsersAsync(BackupWriter writer) |
||||
|
{ |
||||
|
var json = JObject.FromObject(usersWithEmail); |
||||
|
|
||||
|
return writer.WriteJsonAsync(UsersFile, json); |
||||
|
} |
||||
|
|
||||
|
public override async Task CompleteRestoreAsync(Guid appId, BackupReader reader) |
||||
|
{ |
||||
|
await RebuildAsync<AppState, AppGrain>(appId, (e, s) => s.Apply(e)); |
||||
|
|
||||
|
await grainFactory.GetGrain<IAppsByNameIndex>(SingleGrain.Id).AddAppAsync(appCreated.AppId.Id, appCreated.AppId.Name); |
||||
|
|
||||
|
foreach (var user in activeUsers) |
||||
|
{ |
||||
|
await grainFactory.GetGrain<IAppsByUserIndex>(user).AddAppAsync(appCreated.AppId.Id); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public override async Task CleanupRestoreAsync(Guid appId) |
||||
|
{ |
||||
|
if (isReserved) |
||||
|
{ |
||||
|
var index = grainFactory.GetGrain<IAppsByNameIndex>(SingleGrain.Id); |
||||
|
|
||||
|
await index.ReserveAppAsync(appCreated.AppId.Id, appCreated.AppId.Name); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,132 @@ |
|||||
|
// ==========================================================================
|
||||
|
// 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 Newtonsoft.Json.Linq; |
||||
|
using Squidex.Domain.Apps.Entities.Assets.Repositories; |
||||
|
using Squidex.Domain.Apps.Entities.Assets.State; |
||||
|
using Squidex.Domain.Apps.Entities.Backup; |
||||
|
using Squidex.Domain.Apps.Entities.Tags; |
||||
|
using Squidex.Domain.Apps.Events.Assets; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.Assets; |
||||
|
using Squidex.Infrastructure.EventSourcing; |
||||
|
using Squidex.Infrastructure.States; |
||||
|
using Squidex.Infrastructure.Tasks; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Assets |
||||
|
{ |
||||
|
public sealed class BackupAssets : BackupHandlerWithStore |
||||
|
{ |
||||
|
private const string TagsFile = "AssetTags.json"; |
||||
|
private readonly HashSet<Guid> assetIds = new HashSet<Guid>(); |
||||
|
private readonly IAssetStore assetStore; |
||||
|
private readonly IAssetRepository assetRepository; |
||||
|
private readonly ITagService tagService; |
||||
|
|
||||
|
public override string Name { get; } = "Assets"; |
||||
|
|
||||
|
public BackupAssets(IStore<Guid> store, |
||||
|
IAssetStore assetStore, |
||||
|
IAssetRepository assetRepository, |
||||
|
ITagService tagService) |
||||
|
: base(store) |
||||
|
{ |
||||
|
Guard.NotNull(assetStore, nameof(assetStore)); |
||||
|
Guard.NotNull(assetRepository, nameof(assetRepository)); |
||||
|
Guard.NotNull(tagService, nameof(tagService)); |
||||
|
|
||||
|
this.assetStore = assetStore; |
||||
|
this.assetRepository = assetRepository; |
||||
|
this.tagService = tagService; |
||||
|
} |
||||
|
|
||||
|
public override Task BackupAsync(Guid appId, BackupWriter writer) |
||||
|
{ |
||||
|
return BackupTagsAsync(appId, writer); |
||||
|
} |
||||
|
|
||||
|
public override Task BackupEventAsync(Envelope<IEvent> @event, Guid appId, BackupWriter writer) |
||||
|
{ |
||||
|
switch (@event.Payload) |
||||
|
{ |
||||
|
case AssetCreated assetCreated: |
||||
|
return WriteAssetAsync(assetCreated.AssetId, assetCreated.FileVersion, writer); |
||||
|
case AssetUpdated assetUpdated: |
||||
|
return WriteAssetAsync(assetUpdated.AssetId, assetUpdated.FileVersion, writer); |
||||
|
} |
||||
|
|
||||
|
return TaskHelper.Done; |
||||
|
} |
||||
|
|
||||
|
public override Task RestoreEventAsync(Envelope<IEvent> @event, Guid appId, BackupReader reader, RefToken actor) |
||||
|
{ |
||||
|
switch (@event.Payload) |
||||
|
{ |
||||
|
case AssetCreated assetCreated: |
||||
|
return ReadAssetAsync(assetCreated.AssetId, assetCreated.FileVersion, reader); |
||||
|
case AssetUpdated assetUpdated: |
||||
|
return ReadAssetAsync(assetUpdated.AssetId, assetUpdated.FileVersion, reader); |
||||
|
} |
||||
|
|
||||
|
return TaskHelper.Done; |
||||
|
} |
||||
|
|
||||
|
public override async Task RestoreAsync(Guid appId, BackupReader reader) |
||||
|
{ |
||||
|
await RestoreTagsAsync(appId, reader); |
||||
|
|
||||
|
await RebuildManyAsync(assetIds, id => RebuildAsync<AssetState, AssetGrain>(id, (e, s) => s.Apply(e))); |
||||
|
} |
||||
|
|
||||
|
private async Task RestoreTagsAsync(Guid appId, BackupReader reader) |
||||
|
{ |
||||
|
var tags = await reader.ReadJsonAttachmentAsync(TagsFile); |
||||
|
|
||||
|
await tagService.RebuildTagsAsync(appId, TagGroups.Assets, tags.ToObject<TagSet>()); |
||||
|
} |
||||
|
|
||||
|
private async Task BackupTagsAsync(Guid appId, BackupWriter writer) |
||||
|
{ |
||||
|
var tags = await tagService.GetExportableTagsAsync(appId, TagGroups.Assets); |
||||
|
|
||||
|
await writer.WriteJsonAsync(TagsFile, JObject.FromObject(tags)); |
||||
|
} |
||||
|
|
||||
|
private Task WriteAssetAsync(Guid assetId, long fileVersion, BackupWriter writer) |
||||
|
{ |
||||
|
return writer.WriteBlobAsync(GetName(assetId, fileVersion), stream => |
||||
|
{ |
||||
|
return assetStore.DownloadAsync(assetId.ToString(), fileVersion, null, stream); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private Task ReadAssetAsync(Guid assetId, long fileVersion, BackupReader reader) |
||||
|
{ |
||||
|
assetIds.Add(assetId); |
||||
|
|
||||
|
return reader.ReadBlobAsync(GetName(reader.OldGuid(assetId), fileVersion), async stream => |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
await assetStore.UploadAsync(assetId.ToString(), fileVersion, null, stream); |
||||
|
} |
||||
|
catch (AssetAlreadyExistsException) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private static string GetName(Guid assetId, long fileVersion) |
||||
|
{ |
||||
|
return $"{assetId}_{fileVersion}.asset"; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,55 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Threading.Tasks; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.EventSourcing; |
||||
|
using Squidex.Infrastructure.Tasks; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup |
||||
|
{ |
||||
|
public abstract class BackupHandler |
||||
|
{ |
||||
|
public abstract string Name { get; } |
||||
|
|
||||
|
public virtual Task RestoreEventAsync(Envelope<IEvent> @event, Guid appId, BackupReader reader, RefToken actor) |
||||
|
{ |
||||
|
return TaskHelper.Done; |
||||
|
} |
||||
|
|
||||
|
public virtual Task BackupEventAsync(Envelope<IEvent> @event, Guid appId, BackupWriter writer) |
||||
|
{ |
||||
|
return TaskHelper.Done; |
||||
|
} |
||||
|
|
||||
|
public virtual Task RestoreAsync(Guid appId, BackupReader reader) |
||||
|
{ |
||||
|
return TaskHelper.Done; |
||||
|
} |
||||
|
|
||||
|
public virtual Task BackupAsync(Guid appId, BackupWriter writer) |
||||
|
{ |
||||
|
return TaskHelper.Done; |
||||
|
} |
||||
|
|
||||
|
public virtual Task CleanupRestoreAsync(Guid appId) |
||||
|
{ |
||||
|
return TaskHelper.Done; |
||||
|
} |
||||
|
|
||||
|
public virtual Task CompleteRestoreAsync(Guid appId, BackupReader reader) |
||||
|
{ |
||||
|
return TaskHelper.Done; |
||||
|
} |
||||
|
|
||||
|
public virtual Task CompleteBackupAsync(Guid appId, BackupWriter writer) |
||||
|
{ |
||||
|
return TaskHelper.Done; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,60 @@ |
|||||
|
// ==========================================================================
|
||||
|
// 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 Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.Commands; |
||||
|
using Squidex.Infrastructure.EventSourcing; |
||||
|
using Squidex.Infrastructure.States; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup |
||||
|
{ |
||||
|
public abstract class BackupHandlerWithStore : BackupHandler |
||||
|
{ |
||||
|
private readonly IStore<Guid> store; |
||||
|
|
||||
|
protected BackupHandlerWithStore(IStore<Guid> store) |
||||
|
{ |
||||
|
Guard.NotNull(store, nameof(store)); |
||||
|
|
||||
|
this.store = store; |
||||
|
} |
||||
|
|
||||
|
protected Task RemoveSnapshotAsync<TState>(Guid id) |
||||
|
{ |
||||
|
return store.RemoveSnapshotAsync<Guid, TState>(id); |
||||
|
} |
||||
|
|
||||
|
protected async Task RebuildManyAsync(IEnumerable<Guid> ids, Func<Guid, Task> action) |
||||
|
{ |
||||
|
foreach (var id in ids) |
||||
|
{ |
||||
|
await action(id); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected async Task RebuildAsync<TState, TGrain>(Guid key, Func<Envelope<IEvent>, TState, TState> func) where TState : IDomainState, new() |
||||
|
{ |
||||
|
var state = new TState |
||||
|
{ |
||||
|
Version = EtagVersion.Empty |
||||
|
}; |
||||
|
|
||||
|
var persistence = store.WithSnapshotsAndEventSourcing<TState, Guid>(typeof(TGrain), key, s => state = s, e => |
||||
|
{ |
||||
|
state = func(e, state); |
||||
|
|
||||
|
state.Version++; |
||||
|
}); |
||||
|
|
||||
|
await persistence.ReadAsync(); |
||||
|
await persistence.WriteSnapshotAsync(state); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,149 @@ |
|||||
|
// ==========================================================================
|
||||
|
// 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.Domain.Apps.Entities.Backup.Archive; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.EventSourcing; |
||||
|
using Squidex.Infrastructure.States; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup |
||||
|
{ |
||||
|
public sealed class BackupReader : DisposableObjectBase |
||||
|
{ |
||||
|
private static readonly JsonSerializer Serializer = new JsonSerializer(); |
||||
|
private readonly GuidMapper guidMapper = new GuidMapper(); |
||||
|
private readonly ZipArchive archive; |
||||
|
private int readEvents; |
||||
|
private int readAttachments; |
||||
|
|
||||
|
public int ReadEvents |
||||
|
{ |
||||
|
get { return readEvents; } |
||||
|
} |
||||
|
|
||||
|
public int ReadAttachments |
||||
|
{ |
||||
|
get { return readAttachments; } |
||||
|
} |
||||
|
|
||||
|
public BackupReader(Stream stream) |
||||
|
{ |
||||
|
archive = new ZipArchive(stream, ZipArchiveMode.Read, false); |
||||
|
} |
||||
|
|
||||
|
protected override void DisposeObject(bool disposing) |
||||
|
{ |
||||
|
if (disposing) |
||||
|
{ |
||||
|
archive.Dispose(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public Guid OldGuid(Guid newId) |
||||
|
{ |
||||
|
return guidMapper.OldGuid(newId); |
||||
|
} |
||||
|
|
||||
|
public async Task<JToken> ReadJsonAttachmentAsync(string name) |
||||
|
{ |
||||
|
Guard.NotNullOrEmpty(name, nameof(name)); |
||||
|
|
||||
|
var attachmentEntry = archive.GetEntry(ArchiveHelper.GetAttachmentPath(name)); |
||||
|
|
||||
|
if (attachmentEntry == null) |
||||
|
{ |
||||
|
throw new FileNotFoundException("Cannot find attachment.", name); |
||||
|
} |
||||
|
|
||||
|
JToken result; |
||||
|
|
||||
|
using (var stream = attachmentEntry.Open()) |
||||
|
{ |
||||
|
using (var textReader = new StreamReader(stream)) |
||||
|
{ |
||||
|
using (var jsonReader = new JsonTextReader(textReader)) |
||||
|
{ |
||||
|
result = await JToken.ReadFromAsync(jsonReader); |
||||
|
|
||||
|
guidMapper.NewGuids(result); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
readAttachments++; |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
public async Task ReadBlobAsync(string name, Func<Stream, Task> handler) |
||||
|
{ |
||||
|
Guard.NotNullOrEmpty(name, nameof(name)); |
||||
|
Guard.NotNull(handler, nameof(handler)); |
||||
|
|
||||
|
var attachmentEntry = archive.GetEntry(ArchiveHelper.GetAttachmentPath(name)); |
||||
|
|
||||
|
if (attachmentEntry == null) |
||||
|
{ |
||||
|
throw new FileNotFoundException("Cannot find attachment.", name); |
||||
|
} |
||||
|
|
||||
|
using (var stream = attachmentEntry.Open()) |
||||
|
{ |
||||
|
await handler(stream); |
||||
|
} |
||||
|
|
||||
|
readAttachments++; |
||||
|
} |
||||
|
|
||||
|
public async Task ReadEventsAsync(IStreamNameResolver streamNameResolver, Func<StoredEvent, Task> handler) |
||||
|
{ |
||||
|
Guard.NotNull(handler, nameof(handler)); |
||||
|
Guard.NotNull(streamNameResolver, nameof(streamNameResolver)); |
||||
|
|
||||
|
while (true) |
||||
|
{ |
||||
|
var eventEntry = archive.GetEntry(ArchiveHelper.GetEventPath(readEvents)); |
||||
|
|
||||
|
if (eventEntry == null) |
||||
|
{ |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
using (var stream = eventEntry.Open()) |
||||
|
{ |
||||
|
using (var textReader = new StreamReader(stream)) |
||||
|
{ |
||||
|
using (var jsonReader = new JsonTextReader(textReader)) |
||||
|
{ |
||||
|
var storedEvent = Serializer.Deserialize<StoredEvent>(jsonReader); |
||||
|
|
||||
|
storedEvent.Data.Payload = guidMapper.NewGuids(storedEvent.Data.Payload); |
||||
|
storedEvent.Data.Metadata = guidMapper.NewGuids(storedEvent.Data.Metadata); |
||||
|
|
||||
|
var streamName = streamNameResolver.WithNewId(storedEvent.StreamName, guidMapper.NewGuidString); |
||||
|
|
||||
|
storedEvent = new StoredEvent(streamName, |
||||
|
storedEvent.EventPosition, |
||||
|
storedEvent.EventStreamNumber, |
||||
|
storedEvent.Data); |
||||
|
|
||||
|
await handler(storedEvent); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
readEvents++; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,31 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Runtime.Serialization; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup |
||||
|
{ |
||||
|
[Serializable] |
||||
|
public class BackupRestoreException : Exception |
||||
|
{ |
||||
|
public BackupRestoreException(string message) |
||||
|
: base(message) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
public BackupRestoreException(string message, Exception inner) |
||||
|
: base(message, inner) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
protected BackupRestoreException(SerializationInfo info, StreamingContext context) |
||||
|
: base(info, context) |
||||
|
{ |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,105 @@ |
|||||
|
// ==========================================================================
|
||||
|
// 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.Domain.Apps.Entities.Backup.Archive; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.EventSourcing; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup |
||||
|
{ |
||||
|
public sealed class BackupWriter : DisposableObjectBase |
||||
|
{ |
||||
|
private static readonly JsonSerializer Serializer = new JsonSerializer(); |
||||
|
private readonly ZipArchive archive; |
||||
|
private int writtenEvents; |
||||
|
private int writtenAttachments; |
||||
|
|
||||
|
public int WrittenEvents |
||||
|
{ |
||||
|
get { return writtenEvents; } |
||||
|
} |
||||
|
|
||||
|
public int WrittenAttachments |
||||
|
{ |
||||
|
get { return writtenAttachments; } |
||||
|
} |
||||
|
|
||||
|
public BackupWriter(Stream stream, bool keepOpen = false) |
||||
|
{ |
||||
|
archive = new ZipArchive(stream, ZipArchiveMode.Create, keepOpen); |
||||
|
} |
||||
|
|
||||
|
protected override void DisposeObject(bool disposing) |
||||
|
{ |
||||
|
if (disposing) |
||||
|
{ |
||||
|
archive.Dispose(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task WriteJsonAsync(string name, JToken value) |
||||
|
{ |
||||
|
Guard.NotNullOrEmpty(name, nameof(name)); |
||||
|
|
||||
|
var attachmentEntry = archive.CreateEntry(ArchiveHelper.GetAttachmentPath(name)); |
||||
|
|
||||
|
using (var stream = attachmentEntry.Open()) |
||||
|
{ |
||||
|
using (var textWriter = new StreamWriter(stream)) |
||||
|
{ |
||||
|
using (var jsonWriter = new JsonTextWriter(textWriter)) |
||||
|
{ |
||||
|
await value.WriteToAsync(jsonWriter); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
writtenAttachments++; |
||||
|
} |
||||
|
|
||||
|
public async Task WriteBlobAsync(string name, Func<Stream, Task> handler) |
||||
|
{ |
||||
|
Guard.NotNullOrEmpty(name, nameof(name)); |
||||
|
Guard.NotNull(handler, nameof(handler)); |
||||
|
|
||||
|
var attachmentEntry = archive.CreateEntry(ArchiveHelper.GetAttachmentPath(name)); |
||||
|
|
||||
|
using (var stream = attachmentEntry.Open()) |
||||
|
{ |
||||
|
await handler(stream); |
||||
|
} |
||||
|
|
||||
|
writtenAttachments++; |
||||
|
} |
||||
|
|
||||
|
public void WriteEvent(StoredEvent storedEvent) |
||||
|
{ |
||||
|
Guard.NotNull(storedEvent, nameof(storedEvent)); |
||||
|
|
||||
|
var eventEntry = archive.CreateEntry(ArchiveHelper.GetEventPath(writtenEvents)); |
||||
|
|
||||
|
using (var stream = eventEntry.Open()) |
||||
|
{ |
||||
|
using (var textWriter = new StreamWriter(stream)) |
||||
|
{ |
||||
|
using (var jsonWriter = new JsonTextWriter(textWriter)) |
||||
|
{ |
||||
|
Serializer.Serialize(jsonWriter, storedEvent); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
writtenEvents++; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1,79 +0,0 @@ |
|||||
// ==========================================================================
|
|
||||
// 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 = attachmentEntry.Open()) |
|
||||
{ |
|
||||
await attachment(stream); |
|
||||
} |
|
||||
|
|
||||
writtenAttachments++; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
protected override void DisposeObject(bool disposing) |
|
||||
{ |
|
||||
if (disposing) |
|
||||
{ |
|
||||
archive.Dispose(); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,170 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using Newtonsoft.Json.Linq; |
||||
|
using Squidex.Infrastructure; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup |
||||
|
{ |
||||
|
public sealed class GuidMapper |
||||
|
{ |
||||
|
private static readonly int GuidLength = Guid.Empty.ToString().Length; |
||||
|
private readonly List<(JObject Source, string NewKey, string OldKey)> mappings = new List<(JObject Source, string NewKey, string OldKey)>(); |
||||
|
private readonly Dictionary<Guid, Guid> oldToNewGuid = new Dictionary<Guid, Guid>(); |
||||
|
private readonly Dictionary<Guid, Guid> newToOldGuid = new Dictionary<Guid, Guid>(); |
||||
|
|
||||
|
public Guid NewGuid(Guid oldGuid) |
||||
|
{ |
||||
|
return oldToNewGuid.GetOrDefault(oldGuid); |
||||
|
} |
||||
|
|
||||
|
public Guid OldGuid(Guid newGuid) |
||||
|
{ |
||||
|
return newToOldGuid.GetOrDefault(newGuid); |
||||
|
} |
||||
|
|
||||
|
public string NewGuidString(string key) |
||||
|
{ |
||||
|
if (Guid.TryParse(key, out var guid)) |
||||
|
{ |
||||
|
return GenerateNewGuid(guid).ToString(); |
||||
|
} |
||||
|
|
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
public JToken NewGuids(JToken jToken) |
||||
|
{ |
||||
|
var result = NewGuidsCore(jToken); |
||||
|
|
||||
|
if (mappings.Count > 0) |
||||
|
{ |
||||
|
foreach (var mapping in mappings) |
||||
|
{ |
||||
|
if (mapping.Source.TryGetValue(mapping.OldKey, out var value)) |
||||
|
{ |
||||
|
mapping.Source.Remove(mapping.OldKey); |
||||
|
mapping.Source[mapping.NewKey] = value; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
mappings.Clear(); |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
private JToken NewGuidsCore(JToken jToken) |
||||
|
{ |
||||
|
switch (jToken.Type) |
||||
|
{ |
||||
|
case JTokenType.String: |
||||
|
if (TryConvertString(jToken.ToString(), out var result)) |
||||
|
{ |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
break; |
||||
|
case JTokenType.Guid: |
||||
|
return GenerateNewGuid((Guid)jToken); |
||||
|
case JTokenType.Object: |
||||
|
NewGuidsCore((JObject)jToken); |
||||
|
break; |
||||
|
case JTokenType.Array: |
||||
|
NewGuidsCore((JArray)jToken); |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
return jToken; |
||||
|
} |
||||
|
|
||||
|
private void NewGuidsCore(JArray jArray) |
||||
|
{ |
||||
|
for (var i = 0; i < jArray.Count; i++) |
||||
|
{ |
||||
|
jArray[i] = NewGuidsCore(jArray[i]); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void NewGuidsCore(JObject jObject) |
||||
|
{ |
||||
|
foreach (var jProperty in jObject.Properties()) |
||||
|
{ |
||||
|
var newValue = NewGuidsCore(jProperty.Value); |
||||
|
|
||||
|
if (!ReferenceEquals(newValue, jProperty.Value)) |
||||
|
{ |
||||
|
jProperty.Value = newValue; |
||||
|
} |
||||
|
|
||||
|
if (TryConvertString(jProperty.Name, out var newKey)) |
||||
|
{ |
||||
|
mappings.Add((jObject, newKey, jProperty.Name)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private bool TryConvertString(string value, out string result) |
||||
|
{ |
||||
|
return TryGenerateNewGuidString(value, out result) || TryGenerateNewNamedId(value, out result); |
||||
|
} |
||||
|
|
||||
|
private bool TryGenerateNewGuidString(string value, out string result) |
||||
|
{ |
||||
|
result = null; |
||||
|
|
||||
|
if (value.Length == GuidLength) |
||||
|
{ |
||||
|
if (Guid.TryParse(value, out var guid)) |
||||
|
{ |
||||
|
var newGuid = GenerateNewGuid(guid); |
||||
|
|
||||
|
result = newGuid.ToString(); |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
private bool TryGenerateNewNamedId(string value, out string result) |
||||
|
{ |
||||
|
result = null; |
||||
|
|
||||
|
if (value.Length > GuidLength && value[GuidLength] == ',') |
||||
|
{ |
||||
|
if (Guid.TryParse(value.Substring(0, GuidLength), out var guid)) |
||||
|
{ |
||||
|
var newGuid = GenerateNewGuid(guid); |
||||
|
|
||||
|
result = newGuid + value.Substring(GuidLength); |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
private Guid GenerateNewGuid(Guid oldGuid) |
||||
|
{ |
||||
|
return oldToNewGuid.GetOrAdd(oldGuid, GuidGenerator); |
||||
|
} |
||||
|
|
||||
|
private Guid GuidGenerator(Guid oldGuid) |
||||
|
{ |
||||
|
var newGuid = Guid.NewGuid(); |
||||
|
|
||||
|
newToOldGuid[newGuid] = oldGuid; |
||||
|
|
||||
|
return newGuid; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,50 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup.Archive |
||||
|
{ |
||||
|
public static class ArchiveHelper |
||||
|
{ |
||||
|
private const int MaxAttachmentFolders = 1000; |
||||
|
private const int MaxEventsPerFolder = 1000; |
||||
|
|
||||
|
public static string GetAttachmentPath(string name) |
||||
|
{ |
||||
|
name = name.ToLowerInvariant(); |
||||
|
|
||||
|
var attachmentFolder = SimpleHash(name) % MaxAttachmentFolders; |
||||
|
var attachmentPath = $"attachments/{attachmentFolder}/{name}"; |
||||
|
|
||||
|
return attachmentPath; |
||||
|
} |
||||
|
|
||||
|
public static string GetEventPath(int index) |
||||
|
{ |
||||
|
var eventFolder = index / MaxEventsPerFolder; |
||||
|
var eventPath = $"events/{eventFolder}/{index}.json"; |
||||
|
|
||||
|
return eventPath; |
||||
|
} |
||||
|
|
||||
|
private static int SimpleHash(string value) |
||||
|
{ |
||||
|
var hash = 17; |
||||
|
|
||||
|
foreach (char c in value) |
||||
|
{ |
||||
|
unchecked |
||||
|
{ |
||||
|
hash = (hash * 23) + c.GetHashCode(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return Math.Abs(hash); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,66 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.IO; |
||||
|
using System.Net.Http; |
||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup.Helpers |
||||
|
{ |
||||
|
public static class Downloader |
||||
|
{ |
||||
|
public static async Task DownloadAsync(this IBackupArchiveLocation backupArchiveLocation, Uri url, Guid id) |
||||
|
{ |
||||
|
HttpResponseMessage response = null; |
||||
|
try |
||||
|
{ |
||||
|
using (var client = new HttpClient()) |
||||
|
{ |
||||
|
response = await client.GetAsync(url); |
||||
|
response.EnsureSuccessStatusCode(); |
||||
|
|
||||
|
using (var sourceStream = await response.Content.ReadAsStreamAsync()) |
||||
|
{ |
||||
|
using (var targetStream = await backupArchiveLocation.OpenStreamAsync(id)) |
||||
|
{ |
||||
|
await sourceStream.CopyToAsync(targetStream); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
catch (HttpRequestException ex) |
||||
|
{ |
||||
|
throw new BackupRestoreException($"Cannot download the archive. Got status code: {response?.StatusCode}.", ex); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public static async Task<BackupReader> OpenArchiveAsync(this IBackupArchiveLocation backupArchiveLocation, Guid id) |
||||
|
{ |
||||
|
Stream stream = null; |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
stream = await backupArchiveLocation.OpenStreamAsync(id); |
||||
|
|
||||
|
return new BackupReader(stream); |
||||
|
} |
||||
|
catch (IOException) |
||||
|
{ |
||||
|
stream?.Dispose(); |
||||
|
|
||||
|
throw new BackupRestoreException("The backup archive is correupt and cannot be opened."); |
||||
|
} |
||||
|
catch (Exception) |
||||
|
{ |
||||
|
stream?.Dispose(); |
||||
|
|
||||
|
throw; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,62 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Threading.Tasks; |
||||
|
using Squidex.Infrastructure.Assets; |
||||
|
using Squidex.Infrastructure.Log; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup.Helpers |
||||
|
{ |
||||
|
public static class Safe |
||||
|
{ |
||||
|
public static async Task DeleteAsync(IBackupArchiveLocation backupArchiveLocation, Guid id, ISemanticLog log) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
await backupArchiveLocation.DeleteArchiveAsync(id); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
log.LogError(ex, w => w |
||||
|
.WriteProperty("action", "deleteArchive") |
||||
|
.WriteProperty("status", "failed") |
||||
|
.WriteProperty("operationId", id.ToString())); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public static async Task DeleteAsync(IAssetStore assetStore, Guid id, ISemanticLog log) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
await assetStore.DeleteAsync(id.ToString(), 0, null); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
log.LogError(ex, w => w |
||||
|
.WriteProperty("action", "deleteBackup") |
||||
|
.WriteProperty("status", "failed") |
||||
|
.WriteProperty("operationId", id.ToString())); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public static async Task CleanupRestoreAsync(BackupHandler handler, Guid appId, Guid id, ISemanticLog log) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
await handler.CleanupRestoreAsync(appId); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
log.LogError(ex, w => w |
||||
|
.WriteProperty("action", "cleanupRestore") |
||||
|
.WriteProperty("status", "failed") |
||||
|
.WriteProperty("operationId", id.ToString())); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Threading.Tasks; |
||||
|
using Orleans; |
||||
|
using Squidex.Infrastructure.Orleans; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup |
||||
|
{ |
||||
|
public interface IRestoreGrain : IGrainWithStringKey |
||||
|
{ |
||||
|
Task RestoreAsync(Uri url, string newAppName = null); |
||||
|
|
||||
|
Task<J<IRestoreJob>> GetJobAsync(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,26 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using NodaTime; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup |
||||
|
{ |
||||
|
public interface IRestoreJob |
||||
|
{ |
||||
|
Uri Url { get; } |
||||
|
|
||||
|
Instant Started { get; } |
||||
|
|
||||
|
Instant? Stopped { get; } |
||||
|
|
||||
|
List<string> Log { get; } |
||||
|
|
||||
|
JobStatus Status { get; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,17 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup |
||||
|
{ |
||||
|
public enum JobStatus |
||||
|
{ |
||||
|
Created, |
||||
|
Started, |
||||
|
Completed, |
||||
|
Failed |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,335 @@ |
|||||
|
// ==========================================================================
|
||||
|
// 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 NodaTime; |
||||
|
using Orleans; |
||||
|
using Squidex.Domain.Apps.Entities.Backup.Helpers; |
||||
|
using Squidex.Domain.Apps.Entities.Backup.State; |
||||
|
using Squidex.Domain.Apps.Events; |
||||
|
using Squidex.Domain.Apps.Events.Apps; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.Assets; |
||||
|
using Squidex.Infrastructure.EventSourcing; |
||||
|
using Squidex.Infrastructure.Log; |
||||
|
using Squidex.Infrastructure.Orleans; |
||||
|
using Squidex.Infrastructure.States; |
||||
|
using Squidex.Infrastructure.Tasks; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup |
||||
|
{ |
||||
|
public sealed class RestoreGrain : GrainOfString, IRestoreGrain |
||||
|
{ |
||||
|
private readonly IAssetStore assetStore; |
||||
|
private readonly IBackupArchiveLocation backupArchiveLocation; |
||||
|
private readonly IClock clock; |
||||
|
private readonly IEnumerable<BackupHandler> handlers; |
||||
|
private readonly IEventStore eventStore; |
||||
|
private readonly IEventDataFormatter eventDataFormatter; |
||||
|
private readonly IGrainFactory grainFactory; |
||||
|
private readonly ISemanticLog log; |
||||
|
private readonly IStreamNameResolver streamNameResolver; |
||||
|
private readonly IStore<string> store; |
||||
|
private RefToken actor; |
||||
|
private RestoreState state = new RestoreState(); |
||||
|
private IPersistence<RestoreState> persistence; |
||||
|
|
||||
|
private RestoreStateJob CurrentJob |
||||
|
{ |
||||
|
get { return state.Job; } |
||||
|
} |
||||
|
|
||||
|
public RestoreGrain( |
||||
|
IAssetStore assetStore, |
||||
|
IBackupArchiveLocation backupArchiveLocation, |
||||
|
IClock clock, |
||||
|
IEventStore eventStore, |
||||
|
IEventDataFormatter eventDataFormatter, |
||||
|
IGrainFactory grainFactory, |
||||
|
IEnumerable<BackupHandler> handlers, |
||||
|
ISemanticLog log, |
||||
|
IStreamNameResolver streamNameResolver, |
||||
|
IStore<string> 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(grainFactory, nameof(grainFactory)); |
||||
|
Guard.NotNull(handlers, nameof(handlers)); |
||||
|
Guard.NotNull(store, nameof(store)); |
||||
|
Guard.NotNull(streamNameResolver, nameof(streamNameResolver)); |
||||
|
Guard.NotNull(log, nameof(log)); |
||||
|
|
||||
|
this.assetStore = assetStore; |
||||
|
this.backupArchiveLocation = backupArchiveLocation; |
||||
|
this.clock = clock; |
||||
|
this.eventStore = eventStore; |
||||
|
this.eventDataFormatter = eventDataFormatter; |
||||
|
this.grainFactory = grainFactory; |
||||
|
this.handlers = handlers; |
||||
|
this.store = store; |
||||
|
this.streamNameResolver = streamNameResolver; |
||||
|
this.log = log; |
||||
|
} |
||||
|
|
||||
|
public override async Task OnActivateAsync(string key) |
||||
|
{ |
||||
|
actor = new RefToken(RefTokenType.Subject, key); |
||||
|
|
||||
|
persistence = store.WithSnapshots<RestoreState, string>(GetType(), key, s => state = s); |
||||
|
|
||||
|
await ReadAsync(); |
||||
|
|
||||
|
RecoverAfterRestart(); |
||||
|
} |
||||
|
|
||||
|
private void RecoverAfterRestart() |
||||
|
{ |
||||
|
RecoverAfterRestartAsync().Forget(); |
||||
|
} |
||||
|
|
||||
|
private async Task RecoverAfterRestartAsync() |
||||
|
{ |
||||
|
if (CurrentJob?.Status == JobStatus.Started) |
||||
|
{ |
||||
|
Log("Failed due application restart"); |
||||
|
|
||||
|
CurrentJob.Status = JobStatus.Failed; |
||||
|
|
||||
|
await CleanupAsync(); |
||||
|
await WriteAsync(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public Task RestoreAsync(Uri url, string newAppName) |
||||
|
{ |
||||
|
Guard.NotNull(url, nameof(url)); |
||||
|
|
||||
|
if (newAppName != null) |
||||
|
{ |
||||
|
Guard.ValidSlug(newAppName, nameof(newAppName)); |
||||
|
} |
||||
|
|
||||
|
if (CurrentJob?.Status == JobStatus.Started) |
||||
|
{ |
||||
|
throw new DomainException("A restore operation is already running."); |
||||
|
} |
||||
|
|
||||
|
state.Job = new RestoreStateJob |
||||
|
{ |
||||
|
Id = Guid.NewGuid(), |
||||
|
NewAppName = newAppName, |
||||
|
Started = clock.GetCurrentInstant(), |
||||
|
Status = JobStatus.Started, |
||||
|
Url = url |
||||
|
}; |
||||
|
|
||||
|
Process(); |
||||
|
|
||||
|
return TaskHelper.Done; |
||||
|
} |
||||
|
|
||||
|
private void Process() |
||||
|
{ |
||||
|
ProcessAsync().Forget(); |
||||
|
} |
||||
|
|
||||
|
private async Task ProcessAsync() |
||||
|
{ |
||||
|
using (Profiler.StartSession()) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
Log("Started. The restore process has the following steps:"); |
||||
|
Log(" * Download backup"); |
||||
|
Log(" * Restore events and attachments."); |
||||
|
Log(" * Restore all objects like app, schemas and contents"); |
||||
|
Log(" * Complete the restore operation for all objects"); |
||||
|
|
||||
|
log.LogInformation(w => w |
||||
|
.WriteProperty("action", "restore") |
||||
|
.WriteProperty("status", "started") |
||||
|
.WriteProperty("operationId", CurrentJob.Id.ToString()) |
||||
|
.WriteProperty("url", CurrentJob.Url.ToString())); |
||||
|
|
||||
|
using (Profiler.Trace("Download")) |
||||
|
{ |
||||
|
await DownloadAsync(); |
||||
|
} |
||||
|
|
||||
|
using (var reader = await backupArchiveLocation.OpenArchiveAsync(CurrentJob.Id)) |
||||
|
{ |
||||
|
using (Profiler.Trace("ReadEvents")) |
||||
|
{ |
||||
|
await ReadEventsAsync(reader); |
||||
|
} |
||||
|
|
||||
|
foreach (var handler in handlers) |
||||
|
{ |
||||
|
using (Profiler.TraceMethod(handler.GetType(), nameof(BackupHandler.RestoreAsync))) |
||||
|
{ |
||||
|
await handler.RestoreAsync(CurrentJob.AppId, reader); |
||||
|
} |
||||
|
|
||||
|
Log($"Restored {handler.Name}"); |
||||
|
} |
||||
|
|
||||
|
foreach (var handler in handlers) |
||||
|
{ |
||||
|
using (Profiler.TraceMethod(handler.GetType(), nameof(BackupHandler.CompleteRestoreAsync))) |
||||
|
{ |
||||
|
await handler.CompleteRestoreAsync(CurrentJob.AppId, reader); |
||||
|
} |
||||
|
|
||||
|
Log($"Completed {handler.Name}"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
CurrentJob.Status = JobStatus.Completed; |
||||
|
|
||||
|
Log("Completed, Yeah!"); |
||||
|
|
||||
|
log.LogInformation(w => |
||||
|
{ |
||||
|
w.WriteProperty("action", "restore"); |
||||
|
w.WriteProperty("status", "completed"); |
||||
|
w.WriteProperty("operationId", CurrentJob.Id.ToString()); |
||||
|
w.WriteProperty("url", CurrentJob.Url.ToString()); |
||||
|
|
||||
|
Profiler.Session?.Write(w); |
||||
|
}); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
if (ex is BackupRestoreException backupException) |
||||
|
{ |
||||
|
Log(backupException.Message); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
Log("Failed with internal error"); |
||||
|
} |
||||
|
|
||||
|
await CleanupAsync(ex); |
||||
|
|
||||
|
CurrentJob.Status = JobStatus.Failed; |
||||
|
|
||||
|
log.LogError(ex, w => |
||||
|
{ |
||||
|
w.WriteProperty("action", "retore"); |
||||
|
w.WriteProperty("status", "failed"); |
||||
|
w.WriteProperty("operationId", CurrentJob.Id.ToString()); |
||||
|
w.WriteProperty("url", CurrentJob.Url.ToString()); |
||||
|
|
||||
|
Profiler.Session?.Write(w); |
||||
|
}); |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
CurrentJob.Stopped = clock.GetCurrentInstant(); |
||||
|
|
||||
|
await WriteAsync(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task CleanupAsync(Exception exception = null) |
||||
|
{ |
||||
|
await Safe.DeleteAsync(backupArchiveLocation, CurrentJob.Id, log); |
||||
|
|
||||
|
if (CurrentJob.AppId != Guid.Empty) |
||||
|
{ |
||||
|
foreach (var handler in handlers) |
||||
|
{ |
||||
|
await Safe.CleanupRestoreAsync(handler, CurrentJob.AppId, CurrentJob.Id, log); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task DownloadAsync() |
||||
|
{ |
||||
|
Log("Downloading Backup"); |
||||
|
|
||||
|
await backupArchiveLocation.DownloadAsync(CurrentJob.Url, CurrentJob.Id); |
||||
|
|
||||
|
Log("Downloaded Backup"); |
||||
|
} |
||||
|
|
||||
|
private async Task ReadEventsAsync(BackupReader reader) |
||||
|
{ |
||||
|
await reader.ReadEventsAsync(streamNameResolver, async (storedEvent) => |
||||
|
{ |
||||
|
var @event = eventDataFormatter.Parse(storedEvent.Data); |
||||
|
|
||||
|
if (@event.Payload is SquidexEvent squidexEvent) |
||||
|
{ |
||||
|
squidexEvent.Actor = actor; |
||||
|
} |
||||
|
|
||||
|
if (@event.Payload is AppCreated appCreated) |
||||
|
{ |
||||
|
CurrentJob.AppId = appCreated.AppId.Id; |
||||
|
|
||||
|
if (!string.IsNullOrWhiteSpace(CurrentJob.NewAppName)) |
||||
|
{ |
||||
|
appCreated.Name = CurrentJob.NewAppName; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (@event.Payload is AppEvent appEvent && !string.IsNullOrWhiteSpace(CurrentJob.NewAppName)) |
||||
|
{ |
||||
|
appEvent.AppId = new NamedId<Guid>(appEvent.AppId.Id, CurrentJob.NewAppName); |
||||
|
} |
||||
|
|
||||
|
foreach (var handler in handlers) |
||||
|
{ |
||||
|
await handler.RestoreEventAsync(@event, CurrentJob.AppId, reader, actor); |
||||
|
} |
||||
|
|
||||
|
var eventData = eventDataFormatter.ToEventData(@event, @event.Headers.CommitId()); |
||||
|
var eventCommit = new List<EventData> { eventData }; |
||||
|
|
||||
|
await eventStore.AppendAsync(Guid.NewGuid(), storedEvent.StreamName, eventCommit); |
||||
|
|
||||
|
Log($"Read {reader.ReadEvents} events and {reader.ReadAttachments} attachments.", true); |
||||
|
}); |
||||
|
|
||||
|
Log("Reading events completed."); |
||||
|
} |
||||
|
|
||||
|
private void Log(string message, bool replace = false) |
||||
|
{ |
||||
|
if (replace && CurrentJob.Log.Count > 0) |
||||
|
{ |
||||
|
CurrentJob.Log[CurrentJob.Log.Count - 1] = $"{clock.GetCurrentInstant()}: {message}"; |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
CurrentJob.Log.Add($"{clock.GetCurrentInstant()}: {message}"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task ReadAsync() |
||||
|
{ |
||||
|
await persistence.ReadAsync(); |
||||
|
} |
||||
|
|
||||
|
private async Task WriteAsync() |
||||
|
{ |
||||
|
await persistence.WriteSnapshotAsync(state); |
||||
|
} |
||||
|
|
||||
|
public Task<J<IRestoreJob>> GetJobAsync() |
||||
|
{ |
||||
|
return Task.FromResult<J<IRestoreJob>>(CurrentJob); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,17 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Newtonsoft.Json; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup.State |
||||
|
{ |
||||
|
public class RestoreState |
||||
|
{ |
||||
|
[JsonProperty] |
||||
|
public RestoreStateJob Job { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,44 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using Newtonsoft.Json; |
||||
|
using NodaTime; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Backup.State |
||||
|
{ |
||||
|
public sealed class RestoreStateJob : IRestoreJob |
||||
|
{ |
||||
|
[JsonProperty] |
||||
|
public string AppName { get; set; } |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public Guid Id { get; set; } |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public Guid AppId { get; set; } |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public Uri Url { get; set; } |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public string NewAppName { get; set; } |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public Instant Started { get; set; } |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public Instant? Stopped { get; set; } |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public List<string> Log { get; set; } = new List<string>(); |
||||
|
|
||||
|
[JsonProperty] |
||||
|
public JobStatus Status { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,54 @@ |
|||||
|
// ==========================================================================
|
||||
|
// 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 Squidex.Domain.Apps.Entities.Backup; |
||||
|
using Squidex.Domain.Apps.Entities.Contents.Repositories; |
||||
|
using Squidex.Domain.Apps.Entities.Contents.State; |
||||
|
using Squidex.Domain.Apps.Events.Contents; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.EventSourcing; |
||||
|
using Squidex.Infrastructure.States; |
||||
|
using Squidex.Infrastructure.Tasks; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents |
||||
|
{ |
||||
|
public sealed class BackupContents : BackupHandlerWithStore |
||||
|
{ |
||||
|
private readonly HashSet<Guid> contentIds = new HashSet<Guid>(); |
||||
|
private readonly IContentRepository contentRepository; |
||||
|
|
||||
|
public override string Name { get; } = "Contents"; |
||||
|
|
||||
|
public BackupContents(IStore<Guid> store, IContentRepository contentRepository) |
||||
|
: base(store) |
||||
|
{ |
||||
|
Guard.NotNull(contentRepository, nameof(contentRepository)); |
||||
|
|
||||
|
this.contentRepository = contentRepository; |
||||
|
} |
||||
|
|
||||
|
public override Task RestoreEventAsync(Envelope<IEvent> @event, Guid appId, BackupReader reader, RefToken actor) |
||||
|
{ |
||||
|
switch (@event.Payload) |
||||
|
{ |
||||
|
case ContentCreated contentCreated: |
||||
|
contentIds.Add(contentCreated.ContentId); |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
return TaskHelper.Done; |
||||
|
} |
||||
|
|
||||
|
public override Task RestoreAsync(Guid appId, BackupReader reader) |
||||
|
{ |
||||
|
return RebuildManyAsync(contentIds, id => RebuildAsync<ContentState, ContentGrain>(id, (e, s) => s.Apply(e))); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,65 @@ |
|||||
|
// ==========================================================================
|
||||
|
// 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.Domain.Apps.Entities.Backup; |
||||
|
using Squidex.Domain.Apps.Entities.Rules.Indexes; |
||||
|
using Squidex.Domain.Apps.Entities.Rules.Repositories; |
||||
|
using Squidex.Domain.Apps.Entities.Rules.State; |
||||
|
using Squidex.Domain.Apps.Events.Rules; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.EventSourcing; |
||||
|
using Squidex.Infrastructure.States; |
||||
|
using Squidex.Infrastructure.Tasks; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Rules |
||||
|
{ |
||||
|
public sealed class BackupRules : BackupHandlerWithStore |
||||
|
{ |
||||
|
private readonly HashSet<Guid> ruleIds = new HashSet<Guid>(); |
||||
|
private readonly IGrainFactory grainFactory; |
||||
|
private readonly IRuleEventRepository ruleEventRepository; |
||||
|
|
||||
|
public override string Name { get; } = "Rules"; |
||||
|
|
||||
|
public BackupRules(IStore<Guid> store, IGrainFactory grainFactory, IRuleEventRepository ruleEventRepository) |
||||
|
: base(store) |
||||
|
{ |
||||
|
Guard.NotNull(grainFactory, nameof(grainFactory)); |
||||
|
Guard.NotNull(ruleEventRepository, nameof(ruleEventRepository)); |
||||
|
|
||||
|
this.grainFactory = grainFactory; |
||||
|
|
||||
|
this.ruleEventRepository = ruleEventRepository; |
||||
|
} |
||||
|
|
||||
|
public override Task RestoreEventAsync(Envelope<IEvent> @event, Guid appId, BackupReader reader, RefToken actor) |
||||
|
{ |
||||
|
switch (@event.Payload) |
||||
|
{ |
||||
|
case RuleCreated ruleCreated: |
||||
|
ruleIds.Add(ruleCreated.RuleId); |
||||
|
break; |
||||
|
case RuleDeleted ruleDeleted: |
||||
|
ruleIds.Remove(ruleDeleted.RuleId); |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
return TaskHelper.Done; |
||||
|
} |
||||
|
|
||||
|
public async override Task RestoreAsync(Guid appId, BackupReader reader) |
||||
|
{ |
||||
|
await RebuildManyAsync(ruleIds, id => RebuildAsync<RuleState, RuleGrain>(id, (e, s) => s.Apply(e))); |
||||
|
|
||||
|
await grainFactory.GetGrain<IRulesByAppIndex>(appId).RebuildAsync(ruleIds); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,65 @@ |
|||||
|
// ==========================================================================
|
||||
|
// 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 Orleans; |
||||
|
using Squidex.Domain.Apps.Core.Schemas; |
||||
|
using Squidex.Domain.Apps.Entities.Backup; |
||||
|
using Squidex.Domain.Apps.Entities.Schemas.Indexes; |
||||
|
using Squidex.Domain.Apps.Entities.Schemas.State; |
||||
|
using Squidex.Domain.Apps.Events.Schemas; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.EventSourcing; |
||||
|
using Squidex.Infrastructure.States; |
||||
|
using Squidex.Infrastructure.Tasks; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Schemas |
||||
|
{ |
||||
|
public sealed class BackupSchemas : BackupHandlerWithStore |
||||
|
{ |
||||
|
private readonly HashSet<NamedId<Guid>> schemaIds = new HashSet<NamedId<Guid>>(); |
||||
|
private readonly Dictionary<string, Guid> schemasByName = new Dictionary<string, Guid>(); |
||||
|
private readonly FieldRegistry fieldRegistry; |
||||
|
private readonly IGrainFactory grainFactory; |
||||
|
|
||||
|
public override string Name { get; } = "Schemas"; |
||||
|
|
||||
|
public BackupSchemas(IStore<Guid> store, FieldRegistry fieldRegistry, IGrainFactory grainFactory) |
||||
|
: base(store) |
||||
|
{ |
||||
|
Guard.NotNull(fieldRegistry, nameof(fieldRegistry)); |
||||
|
Guard.NotNull(grainFactory, nameof(grainFactory)); |
||||
|
|
||||
|
this.fieldRegistry = fieldRegistry; |
||||
|
|
||||
|
this.grainFactory = grainFactory; |
||||
|
} |
||||
|
|
||||
|
public override Task RestoreEventAsync(Envelope<IEvent> @event, Guid appId, BackupReader reader, RefToken actor) |
||||
|
{ |
||||
|
switch (@event.Payload) |
||||
|
{ |
||||
|
case SchemaCreated schemaCreated: |
||||
|
schemaIds.Add(schemaCreated.SchemaId); |
||||
|
schemasByName[schemaCreated.SchemaId.Name] = schemaCreated.SchemaId.Id; |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
return TaskHelper.Done; |
||||
|
} |
||||
|
|
||||
|
public async override Task RestoreAsync(Guid appId, BackupReader reader) |
||||
|
{ |
||||
|
await RebuildManyAsync(schemaIds.Select(x => x.Id), id => RebuildAsync<SchemaState, SchemaGrain>(id, (e, s) => s.Apply(e, fieldRegistry))); |
||||
|
|
||||
|
await grainFactory.GetGrain<ISchemasByAppIndex>(appId).RebuildAsync(schemasByName); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Tags |
||||
|
{ |
||||
|
public sealed class Tag |
||||
|
{ |
||||
|
public string Name { get; set; } |
||||
|
|
||||
|
public int Count { get; set; } = 1; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Collections.Generic; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Tags |
||||
|
{ |
||||
|
public sealed class TagSet : Dictionary<string, Tag> |
||||
|
{ |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,38 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Runtime.Serialization; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.Assets |
||||
|
{ |
||||
|
[Serializable] |
||||
|
public class AssetAlreadyExistsException : Exception |
||||
|
{ |
||||
|
public AssetAlreadyExistsException(string fileName) |
||||
|
: base(FormatMessage(fileName)) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
public AssetAlreadyExistsException(string fileName, Exception inner) |
||||
|
: base(FormatMessage(fileName), inner) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
protected AssetAlreadyExistsException(SerializationInfo info, StreamingContext context) |
||||
|
: base(info, context) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
private static string FormatMessage(string fileName) |
||||
|
{ |
||||
|
Guard.NotNullOrEmpty(fileName, nameof(fileName)); |
||||
|
|
||||
|
return $"An asset with name '{fileName}' already not exists."; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
namespace Squidex.Infrastructure |
||||
|
{ |
||||
|
public static class RefTokenType |
||||
|
{ |
||||
|
public const string Subject = "subject"; |
||||
|
|
||||
|
public const string Client = "client"; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,51 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.ComponentModel.DataAnnotations; |
||||
|
using NodaTime; |
||||
|
using Squidex.Domain.Apps.Entities.Backup; |
||||
|
using Squidex.Infrastructure.Reflection; |
||||
|
|
||||
|
namespace Squidex.Areas.Api.Controllers.Backups.Models |
||||
|
{ |
||||
|
public sealed class RestoreJobDto |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// The uri to load from.
|
||||
|
/// </summary>
|
||||
|
[Required] |
||||
|
public Uri Url { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// The status log.
|
||||
|
/// </summary>
|
||||
|
[Required] |
||||
|
public List<string> Log { 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 status of the operation.
|
||||
|
/// </summary>
|
||||
|
public JobStatus Status { get; set; } |
||||
|
|
||||
|
public static RestoreJobDto FromJob(IRestoreJob job) |
||||
|
{ |
||||
|
return SimpleMapper.Map(job, new RestoreJobDto()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,28 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.ComponentModel.DataAnnotations; |
||||
|
|
||||
|
namespace Squidex.Areas.Api.Controllers.Backups.Models |
||||
|
{ |
||||
|
public sealed class RestoreRequest |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// The name of the app.
|
||||
|
/// </summary>
|
||||
|
[Required] |
||||
|
[RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")]
|
||||
|
public string Name { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// The url to the restore file.
|
||||
|
/// </summary>
|
||||
|
[Required] |
||||
|
public Uri Url { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,71 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
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.Security; |
||||
|
using Squidex.Pipeline; |
||||
|
|
||||
|
namespace Squidex.Areas.Api.Controllers.Backups |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Restores backups.
|
||||
|
/// </summary>
|
||||
|
[ApiAuthorize] |
||||
|
[ApiExceptionFilter] |
||||
|
[ApiModelValidation(true)] |
||||
|
[MustBeAdministrator] |
||||
|
[SwaggerIgnore] |
||||
|
public class RestoreController : ApiController |
||||
|
{ |
||||
|
private readonly IGrainFactory grainFactory; |
||||
|
|
||||
|
public RestoreController(ICommandBus commandBus, IGrainFactory grainFactory) |
||||
|
: base(commandBus) |
||||
|
{ |
||||
|
this.grainFactory = grainFactory; |
||||
|
} |
||||
|
|
||||
|
[HttpGet] |
||||
|
[Route("apps/restore/")] |
||||
|
[ApiCosts(0)] |
||||
|
public async Task<IActionResult> GetJob() |
||||
|
{ |
||||
|
var restoreGrain = grainFactory.GetGrain<IRestoreGrain>(User.OpenIdSubject()); |
||||
|
|
||||
|
var job = await restoreGrain.GetJobAsync(); |
||||
|
|
||||
|
if (job.Value == null) |
||||
|
{ |
||||
|
return NotFound(); |
||||
|
} |
||||
|
|
||||
|
var jobs = await restoreGrain.GetJobAsync(); |
||||
|
|
||||
|
var response = RestoreJobDto.FromJob(job.Value); |
||||
|
|
||||
|
return Ok(response); |
||||
|
} |
||||
|
|
||||
|
[HttpPost] |
||||
|
[Route("apps/restore/")] |
||||
|
[ApiCosts(0)] |
||||
|
public async Task<IActionResult> PostRestore([FromBody] RestoreRequest request) |
||||
|
{ |
||||
|
var restoreGrain = grainFactory.GetGrain<IRestoreGrain>(User.OpenIdSubject()); |
||||
|
|
||||
|
await restoreGrain.RestoreAsync(request.Url, request.Name); |
||||
|
|
||||
|
return NoContent(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,68 @@ |
|||||
|
<sqx-title message="Restore Backup"></sqx-title> |
||||
|
|
||||
|
<sqx-panel theme="light" desiredWidth="70rem"> |
||||
|
<ng-container title> |
||||
|
Restore Backup |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container content> |
||||
|
<ng-container *ngIf="restoreJob; let job"> |
||||
|
<div class="card section"> |
||||
|
<div class="card-header"> |
||||
|
<div class="row no-gutters"> |
||||
|
<div class="col col-auto pr-2"> |
||||
|
<div *ngIf="job.status === 'Started'" class="restore-status restore-status-pending spin"> |
||||
|
<i class="icon-hour-glass"></i> |
||||
|
</div> |
||||
|
<div *ngIf="job.status === 'Failed'" class="restore-status restore-status-failed"> |
||||
|
<i class="icon-exclamation"></i> |
||||
|
</div> |
||||
|
<div *ngIf="job.status === 'Completed'" class="restore-status restore-status-success"> |
||||
|
<i class="icon-checkmark"></i> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="col"> |
||||
|
<h3>Last Restore Operation</h3> |
||||
|
</div> |
||||
|
|
||||
|
<div class="col text-right restore-url"> |
||||
|
{{job.url}} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="card-body"> |
||||
|
<div *ngFor="let row of job.log"> |
||||
|
{{row}} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="card-footer text-muted"> |
||||
|
<div class="row"> |
||||
|
<div class="col"> |
||||
|
Started: {{job.started | sqxISODate}} |
||||
|
</div> |
||||
|
<div class="col text-right" *ngIf="job.stopped"> |
||||
|
Stopped: {{job.stopped | sqxISODate}} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</ng-container> |
||||
|
|
||||
|
<div class="table-items-row"> |
||||
|
<form [formGroup]="restoreForm.form" (submit)="restore()"> |
||||
|
<div class="row no-gutters"> |
||||
|
<div class="col"> |
||||
|
<input class="form-control" name="url" formControlName="url" placeholder="Url to backup" /> |
||||
|
</div> |
||||
|
<div class="col pl-1"> |
||||
|
<input class="form-control" name="name" formControlName="name" placeholder="Optional app name" /> |
||||
|
</div> |
||||
|
<div class="col col-auto pl-1"> |
||||
|
<button type="submit" class="btn btn-success" [disabled]="restoreForm.hasNoUrl | async">Restore Backup</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</form> |
||||
|
</div> |
||||
|
</ng-container> |
||||
|
</sqx-panel> |
||||
@ -0,0 +1,68 @@ |
|||||
|
@import '_vars'; |
||||
|
@import '_mixins'; |
||||
|
|
||||
|
$circle-size: 2rem; |
||||
|
|
||||
|
h3 { |
||||
|
margin: 0; |
||||
|
} |
||||
|
|
||||
|
.section { |
||||
|
margin-bottom: .8rem; |
||||
|
} |
||||
|
|
||||
|
.container { |
||||
|
padding-top: 2rem; |
||||
|
} |
||||
|
|
||||
|
.card { |
||||
|
&-header { |
||||
|
h3 { |
||||
|
line-height: $circle-size; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
&-footer { |
||||
|
font-size: .9rem; |
||||
|
} |
||||
|
|
||||
|
&-body { |
||||
|
font-family: monospace; |
||||
|
background: $color-border; |
||||
|
max-height: 400px; |
||||
|
min-height: 300px; |
||||
|
overflow-y: scroll; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.restore { |
||||
|
&-status { |
||||
|
& { |
||||
|
@include circle($circle-size); |
||||
|
line-height: $circle-size + .1rem; |
||||
|
text-align: center; |
||||
|
font-size: .6 * $circle-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; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
&-url { |
||||
|
@include truncate; |
||||
|
line-height: 30px; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,68 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { Component, OnDestroy, OnInit } from '@angular/core'; |
||||
|
import { FormBuilder } from '@angular/forms'; |
||||
|
import { Subscription, timer } from 'rxjs'; |
||||
|
import { switchMap } from 'rxjs/operators'; |
||||
|
|
||||
|
import { |
||||
|
AuthService, |
||||
|
BackupsService, |
||||
|
DialogService, |
||||
|
RestoreDto, |
||||
|
RestoreForm |
||||
|
} from '@app/shared'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'sqx-restore-page', |
||||
|
styleUrls: ['./restore-page.component.scss'], |
||||
|
templateUrl: './restore-page.component.html' |
||||
|
}) |
||||
|
export class RestorePageComponent implements OnDestroy, OnInit { |
||||
|
private timerSubscription: Subscription; |
||||
|
|
||||
|
public restoreJob: RestoreDto | null; |
||||
|
public restoreForm = new RestoreForm(this.formBuilder); |
||||
|
|
||||
|
constructor( |
||||
|
public readonly authState: AuthService, |
||||
|
private readonly backupsService: BackupsService, |
||||
|
private readonly dialogs: DialogService, |
||||
|
private readonly formBuilder: FormBuilder |
||||
|
) { |
||||
|
} |
||||
|
|
||||
|
public ngOnDestroy() { |
||||
|
this.timerSubscription.unsubscribe(); |
||||
|
} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.timerSubscription = |
||||
|
timer(0, 2000).pipe(switchMap(() => this.backupsService.getRestore())) |
||||
|
.subscribe(dto => { |
||||
|
if (dto !== null) { |
||||
|
this.restoreJob = dto; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public restore() { |
||||
|
const value = this.restoreForm.submit(); |
||||
|
|
||||
|
if (value) { |
||||
|
this.restoreForm.submitCompleted({}); |
||||
|
|
||||
|
this.backupsService.postRestore(value) |
||||
|
.subscribe(() => { |
||||
|
this.dialogs.notifyInfo('Restore started, it can take several minutes to complete.'); |
||||
|
}, error => { |
||||
|
this.dialogs.notifyError(error); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue