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