Headless CMS and Content Managment Hub
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

266 lines
8.8 KiB

// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using NodaTime;
using Squidex.Domain.Apps.Core.Comments;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Reflection;
using Squidex.Shared.Users;
using YDotNet.Document.Cells;
using YDotNet.Document.Types.Events;
using YDotNet.Extensions;
using YDotNet.Server;
namespace Squidex.Domain.Apps.Entities.Collaboration;
public sealed partial class CommentCollaborationHandler : IDocumentCallback, ICollaborationService
{
private static readonly Regex MentionRegex = BuildMentionRegex();
private readonly IJsonSerializer jsonSerializer;
private readonly IEventStore eventStore;
private readonly IEventFormatter eventFormatter;
private readonly IUserResolver userResolver;
private readonly IClock clock;
private readonly ILogger<CommentCollaborationHandler> log;
private IDocumentManager? currentManager;
public Task LastTask { get; private set; }
public CommentCollaborationHandler(
IJsonSerializer jsonSerializer,
IEventStore eventStore,
IEventFormatter eventFormatter,
IUserResolver userResolver,
IClock clock,
ILogger<CommentCollaborationHandler> log)
{
this.jsonSerializer = jsonSerializer;
this.eventStore = eventStore;
this.eventFormatter = eventFormatter;
this.userResolver = userResolver;
this.clock = clock;
this.log = log;
}
public ValueTask OnInitializedAsync(IDocumentManager manager)
{
currentManager = manager;
return default;
}
public Task NotifyAsync(string userId, string text, RefToken actor, Uri? url, bool skipHandlers,
CancellationToken ct = default)
{
return CommentAsync(UserDocument(userId), text, actor, url, skipHandlers, ct);
}
public Task CommentAsync(NamedId<DomainId> appId, DomainId resourceId, string text, RefToken actor, Uri? url, bool skipHandlers,
CancellationToken ct = default)
{
return CommentAsync(ResourceDocument(appId, resourceId), text, actor, url, skipHandlers, ct);
}
private async Task CommentAsync(string documentName, string text, RefToken actor, Uri? url, bool skipHandlers,
CancellationToken ct)
{
if (currentManager == null)
{
return;
}
var notificationsContext = new DocumentContext(documentName, 0);
// Use the update method to ensure that only one thread has access to the doc.
await currentManager.UpdateDocAsync(notificationsContext, doc =>
{
var stream = doc.Array("stream");
using (var transaction = doc.WriteTransaction())
{
var commentValue = new Comment(clock.GetCurrentInstant(), actor, text, url, skipHandlers);
var commentJson = jsonSerializer.Serialize(commentValue);
stream.InsertRange(transaction, stream.Length, InputFactory.FromJson(commentJson));
}
}, ct);
}
public ValueTask OnDocumentLoadedAsync(DocumentLoadEvent @event)
{
if (!IsResourceDocument(@event.Context.DocumentName, out var appId, out var resourceId))
{
return default;
}
var stream = @event.Document.Array("stream");
stream.ObserveDeep(changes =>
{
var newComments =
changes
.Where(x => x.Tag == EventBranchTag.Array)
.Select(x => x.ArrayEvent)
.SelectMany(x => x.Delta).Where(x => x.Tag == EventChangeTag.Add)
.SelectMany(x => x.Values).Where(x => x.Tag == OutputTag.JsonObject)
.ToArray();
if (newComments.Length == 0)
{
// Just store the last task for tests.
LastTask = Task.CompletedTask;
return;
}
LastTask = Task.Run(async () =>
{
try
{
// Run in an extra task to prevent deadlocks with the outer transaction.
await HandleAsync(@event, appId, resourceId, newComments);
}
catch (Exception ex)
{
// We are in an extra task, so the exception would be probably swallowed.
log.LogError(ex, "Failed to handle yjs event.");
throw;
}
});
});
return default;
}
private async Task HandleAsync(DocumentLoadEvent @event, NamedId<DomainId> appId, DomainId resourceId, Output[] newComments)
{
var comments = new List<Comment>();
// Use the update method to ensure that only one thread has access to the doc.
await @event.Source.UpdateDocAsync(@event.Context, (doc) =>
{
using (var transaction = @event.Document.ReadTransaction())
{
foreach (var output in newComments)
{
// Just use the json string for debuggability.
var json = output.ToJson(transaction);
var comment = jsonSerializer.Deserialize<Comment>(json);
if (!comment.SkipHandlers)
{
comments.Add(comment);
}
}
}
});
var streamName = $"comments-{DomainId.Combine(appId, resourceId)}";
foreach (var comment in comments)
{
var commentEvent = await CreateEventAsync(comment, appId, resourceId);
var eventBody = Envelope.Create<IEvent>(commentEvent);
var eventData = eventFormatter.ToEventData(eventBody, Guid.NewGuid());
await eventStore.AppendAsync(Guid.NewGuid(), streamName, EtagVersion.Any, new List<EventData> { eventData });
foreach (var mentionedUser in commentEvent.Mentions.OrEmpty())
{
await NotifyAsync(mentionedUser, comment.Text, RefToken.User(mentionedUser), comment.Url, true);
}
}
}
private async Task<CommentCreated> CreateEventAsync(Comment comment, NamedId<DomainId> appId, DomainId commentsId)
{
var @event = new CommentCreated
{
Actor = comment.User,
CommentId = DomainId.NewGuid(),
CommentsId = commentsId,
AppId = appId,
};
SimpleMapper.Map(comment, @event);
await MentionUsersAsync(@event);
return @event;
}
private async Task MentionUsersAsync(CommentCreated comment)
{
if (string.IsNullOrWhiteSpace(comment.Text))
{
return;
}
var emails = MentionRegex.Matches(comment.Text).Select(x => x.Value[1..]).ToArray();
if (emails.Length == 0)
{
return;
}
var mentions = new List<string>();
foreach (var email in emails)
{
var user = await userResolver.FindByIdOrEmailAsync(email);
if (user != null)
{
mentions.Add(user.Id);
}
}
if (mentions.Count > 0)
{
comment.Mentions = mentions.ToArray();
}
}
public string UserDocument(string userId)
{
return $"users/{userId}";
}
public string ResourceDocument(NamedId<DomainId> appId, DomainId resourceId)
{
return $"apps/{appId}/{resourceId}";
}
private static bool IsResourceDocument(string name, out NamedId<DomainId> appId, out DomainId resourceId)
{
resourceId = default;
if (!name.StartsWith("apps", StringComparison.Ordinal))
{
appId = default!;
return false;
}
var parts = name.Split('/');
if (parts.Length < 3 || !NamedId<DomainId>.TryParse(parts[1], DomainId.TryParse, out appId!))
{
appId = default!;
return false;
}
resourceId = DomainId.Create(string.Join('/', parts.Skip(2)));
return true;
}
[GeneratedRegex(@"@(?=.{1,64}@)[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*", RegexOptions.Compiled | RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 100)]
private static partial Regex BuildMentionRegex();
}