diff --git a/backend/extensions/Squidex.Extensions/APM/Stackdriver/StackdriverExceptionHandler.cs b/backend/extensions/Squidex.Extensions/APM/Stackdriver/StackdriverExceptionHandler.cs index c62aa1021..bda1fe1eb 100644 --- a/backend/extensions/Squidex.Extensions/APM/Stackdriver/StackdriverExceptionHandler.cs +++ b/backend/extensions/Squidex.Extensions/APM/Stackdriver/StackdriverExceptionHandler.cs @@ -53,9 +53,16 @@ namespace Squidex.Extensions.APM.Stackdriver public void Append(IObjectWriter writer, SemanticLogLevel logLevel, Exception exception) { - if (exception != null && exception is not DomainException) + try { - logger.Log(exception, httpContextWrapper); + if (exception != null && exception is not DomainException) + { + logger.Log(exception, httpContextWrapper); + } + } + catch + { + return; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs index 9d42259fd..3aa220850 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs @@ -51,8 +51,8 @@ namespace Squidex.Domain.Apps.Entities.Backup IEventStore eventStore, IGrainState state, IServiceProvider serviceProvider, - IUserResolver userResolver, - ISemanticLog log) + ISemanticLog log, + IUserResolver userResolver) { this.backupArchiveLocation = backupArchiveLocation; this.backupArchiveStore = backupArchiveStore; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/GrainWatchingService.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/GrainWatchingService.cs new file mode 100644 index 000000000..6d6bcd017 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/GrainWatchingService.cs @@ -0,0 +1,36 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Orleans; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Comments +{ + public sealed class GrainWatchingService : IWatchingService + { + private readonly IGrainFactory grainFactory; + + public GrainWatchingService(IGrainFactory grainFactory) + { + this.grainFactory = grainFactory; + } + + public Task GetWatchingUsersAsync(DomainId appId, string resource, string userId) + { + Guard.NotNullOrEmpty(resource, nameof(resource)); + Guard.NotNullOrEmpty(userId, nameof(userId)); + + return GetGrain(appId).GetWatchingUsersAsync(resource, userId); + } + + private IWatchingGrain GetGrain(DomainId appId) + { + return grainFactory.GetGrain(appId.ToString()); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/IWatchingGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/IWatchingGrain.cs new file mode 100644 index 000000000..3b8841df2 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/IWatchingGrain.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Orleans; + +namespace Squidex.Domain.Apps.Entities.Comments +{ + public interface IWatchingGrain : IGrainWithStringKey + { + Task GetWatchingUsersAsync(string resource, string userId); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/IWatchingService.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/IWatchingService.cs new file mode 100644 index 000000000..9e7c158f5 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/IWatchingService.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.Comments +{ + public interface IWatchingService + { + Task GetWatchingUsersAsync(DomainId appId, string resource, string userId); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/WatchingGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/WatchingGrain.cs new file mode 100644 index 000000000..37a241da8 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/WatchingGrain.cs @@ -0,0 +1,88 @@ +// ========================================================================== +// 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 NodaTime; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Domain.Apps.Entities.Comments +{ + public sealed class WatchingGrain : GrainBase, IWatchingGrain + { + private static readonly Duration Timeout = Duration.FromMinutes(1); + private readonly Dictionary> users = new Dictionary>(); + private readonly IClock clock; + + public WatchingGrain(IClock clock) + { + this.clock = clock; + } + + public override Task OnActivateAsync() + { + var time = TimeSpan.FromSeconds(30); + + RegisterTimer(x => + { + Cleanup(); + + return Task.CompletedTask; + }, null, time, time); + + return Task.CompletedTask; + } + + public Task GetWatchingUsersAsync(string resource, string userId) + { + Guard.NotNullOrEmpty(resource, nameof(resource)); + Guard.NotNullOrEmpty(userId, nameof(userId)); + + var usersByResource = users.GetOrAddNew(resource); + + usersByResource[userId] = clock.GetCurrentInstant(); + + return Task.FromResult(usersByResource.Keys.ToArray()); + } + + public void Cleanup() + { + if (users.Count == 0) + { + return; + } + + var now = clock.GetCurrentInstant(); + + foreach (var (resource, usersByResource) in users.ToList()) + { + foreach (var (userId, lastSeen) in usersByResource.ToList()) + { + var timeSinceLastSeen = now - lastSeen; + + if (timeSinceLastSeen > Timeout) + { + usersByResource.Remove(userId); + } + } + + if (usersByResource.Count == 0) + { + users.Remove(resource); + } + } + + if (users.Count == 0) + { + TryDeactivateOnIdle(); + } + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs index 41249e4c5..f89224b5d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs @@ -26,6 +26,8 @@ namespace Squidex.Domain.Apps.Entities.Tags public TagsExport Tags { get; set; } = new TagsExport(); } + public TagsExport Tags => state.Value.Tags; + public TagGrain(IGrainState state) { this.state = state; @@ -53,28 +55,9 @@ namespace Squidex.Domain.Apps.Entities.Tags { if (!string.IsNullOrWhiteSpace(tag)) { - var tagName = tag.ToLowerInvariant(); - var tagId = string.Empty; - - var (key, value) = state.Value.Tags.FirstOrDefault(x => string.Equals(x.Value.Name, tagName, StringComparison.OrdinalIgnoreCase)); - - if (value != null) - { - tagId = key; - - if (ids == null || !ids.Contains(tagId)) - { - value.Count++; - } - } - else - { - tagId = DomainId.NewGuid().ToString(); + var name = tag.ToLowerInvariant(); - state.Value.Tags.Add(tagId, new Tag { Name = tagName }); - } - - result.Add(tagName, tagId); + result.Add(name, GetId(name, ids)); } } } @@ -85,13 +68,13 @@ namespace Squidex.Domain.Apps.Entities.Tags { if (!result.ContainsValue(id)) { - if (state.Value.Tags.TryGetValue(id, out var tagInfo)) + if (Tags.TryGetValue(id, out var tagInfo)) { tagInfo.Count--; if (tagInfo.Count <= 0) { - state.Value.Tags.Remove(id); + Tags.Remove(id); } } } @@ -103,13 +86,34 @@ namespace Squidex.Domain.Apps.Entities.Tags return result; } + private string GetId(string name, HashSet? ids) + { + var (id, value) = Tags.FirstOrDefault(x => string.Equals(x.Value.Name, name, StringComparison.OrdinalIgnoreCase)); + + if (value != null) + { + if (ids == null || !ids.Contains(id)) + { + value.Count++; + } + } + else + { + id = DomainId.NewGuid().ToString(); + + Tags.Add(id, new Tag { Name = name }); + } + + return id; + } + public Task> GetTagIdsAsync(HashSet names) { var result = new Dictionary(); foreach (var name in names) { - var id = state.Value.Tags.FirstOrDefault(x => string.Equals(x.Value.Name, name, StringComparison.OrdinalIgnoreCase)).Key; + var (id, _) = Tags.FirstOrDefault(x => string.Equals(x.Value.Name, name, StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrWhiteSpace(id)) { @@ -126,7 +130,7 @@ namespace Squidex.Domain.Apps.Entities.Tags foreach (var id in ids) { - if (state.Value.Tags.TryGetValue(id, out var tagInfo)) + if (Tags.TryGetValue(id, out var tagInfo)) { result[id] = tagInfo.Name; } @@ -137,14 +141,14 @@ namespace Squidex.Domain.Apps.Entities.Tags public Task GetTagsAsync() { - var tags = state.Value.Tags.Values.ToDictionary(x => x.Name, x => x.Count); + var tags = Tags.Values.ToDictionary(x => x.Name, x => x.Count); return Task.FromResult(new TagsSet(tags, state.Version)); } public Task GetExportableTagsAsync() { - return Task.FromResult(state.Value.Tags); + return Task.FromResult(Tags); } } } diff --git a/backend/src/Squidex.Web/Pipeline/ActionContextLogAppender.cs b/backend/src/Squidex.Web/Pipeline/ActionContextLogAppender.cs index 146d84b3f..506220290 100644 --- a/backend/src/Squidex.Web/Pipeline/ActionContextLogAppender.cs +++ b/backend/src/Squidex.Web/Pipeline/ActionContextLogAppender.cs @@ -27,36 +27,43 @@ namespace Squidex.Web.Pipeline public void Append(IObjectWriter writer, SemanticLogLevel logLevel, Exception? exception) { - var httpContext = httpContextAccessor.HttpContext; - - if (string.IsNullOrEmpty(httpContext?.Request?.Method)) + try { - return; - } - - var requestId = GetRequestId(httpContext); + var httpContext = httpContextAccessor.HttpContext; - var logContext = (requestId, context: httpContext, actionContextAccessor); + if (string.IsNullOrEmpty(httpContext?.Request?.Method)) + { + return; + } - writer.WriteObject("web", logContext, (ctx, w) => - { - w.WriteProperty("requestId", ctx.requestId); - w.WriteProperty("requestPath", ctx.context.Request.Path); - w.WriteProperty("requestMethod", ctx.context.Request.Method); + var requestId = GetRequestId(httpContext); - var actionContext = ctx.actionContextAccessor.ActionContext; + var logContext = (requestId, context: httpContext, actionContextAccessor); - if (actionContext != null) + writer.WriteObject("web", logContext, (ctx, w) => { - w.WriteObject("routeValues", actionContext.ActionDescriptor.RouteValues, (routeValues, r) => + w.WriteProperty("requestId", ctx.requestId); + w.WriteProperty("requestPath", ctx.context.Request.Path); + w.WriteProperty("requestMethod", ctx.context.Request.Method); + + var actionContext = ctx.actionContextAccessor.ActionContext; + + if (actionContext != null) { - foreach (var (key, value) in routeValues) + w.WriteObject("routeValues", actionContext.ActionDescriptor.RouteValues, (routeValues, r) => { - r.WriteProperty(key, value); - } - }); - } - }); + foreach (var (key, value) in routeValues) + { + r.WriteProperty(key, value); + } + }); + } + }); + } + catch + { + return; + } } private static string GetRequestId(HttpContext httpContext) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs index 17324fbe4..bf65e2919 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs @@ -83,6 +83,11 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// public bool CanAccessContent { get; set; } + /// + /// The role name of the user. + /// + public string? RoleName { get; set; } + /// /// The properties from the role. /// @@ -101,6 +106,15 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models { isContributor = true; + result.RoleName = roleName; + result.RoleProperties = role.Properties; + result.Permissions = permissions.ToIds(); + + permissions = role.Permissions; + } + else if (app.Clients.TryGetValue(userId, out var client) && app.Roles.TryGet(app.Name, client.Role, isFrontend, out role)) + { + result.RoleName = roleName; result.RoleProperties = role.Properties; result.Permissions = permissions.ToIds(); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs index a6e070fc1..b89e6c5a4 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs @@ -15,6 +15,8 @@ using Squidex.Domain.Apps.Entities.Comments; using Squidex.Domain.Apps.Entities.Comments.Commands; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Security; +using Squidex.Infrastructure.Translations; using Squidex.Shared; using Squidex.Web; @@ -27,11 +29,36 @@ namespace Squidex.Areas.Api.Controllers.Comments public sealed class CommentsController : ApiController { private readonly ICommentsLoader commentsLoader; + private readonly IWatchingService watchingService; - public CommentsController(ICommandBus commandBus, ICommentsLoader commentsLoader) + public CommentsController(ICommandBus commandBus, ICommentsLoader commentsLoader, + IWatchingService watchingService) : base(commandBus) { this.commentsLoader = commentsLoader; + + this.watchingService = watchingService; + } + + /// + /// Get all watching users.. + /// + /// The name of the app. + /// The path to the resource. + /// + /// 200 => Watching users returned. + /// 404 => App not found. + /// + [HttpGet] + [Route("apps/{app}/watching/{*resource}")] + [ProducesResponseType(typeof(string[]), StatusCodes.Status200OK)] + [ApiPermissionOrAnonymous] + [ApiCosts(0)] + public async Task GetWatchingUsers(string app, string? resource = null) + { + var result = await watchingService.GetWatchingUsersAsync(App.Id, resource ?? "all", UserId()); + + return Ok(result); } /// @@ -140,5 +167,17 @@ namespace Squidex.Areas.Api.Controllers.Comments return NoContent(); } + + private string UserId() + { + var subject = User.OpenIdSubject(); + + if (string.IsNullOrWhiteSpace(subject)) + { + throw new DomainForbiddenException(T.Get("common.httpOnlyAsUser")); + } + + return subject; + } } } diff --git a/backend/src/Squidex/Config/Domain/CommentsServices.cs b/backend/src/Squidex/Config/Domain/CommentsServices.cs index 2d3141a8c..883a4782d 100644 --- a/backend/src/Squidex/Config/Domain/CommentsServices.cs +++ b/backend/src/Squidex/Config/Domain/CommentsServices.cs @@ -1,4 +1,4 @@ -// ========================================================================== +// ========================================================================== // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex UG (haftungsbeschraenkt) @@ -14,8 +14,11 @@ namespace Squidex.Config.Domain { public static void AddSquidexComments(this IServiceCollection services) { + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); } } -} \ No newline at end of file +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/GrainWatchingServiceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/GrainWatchingServiceTests.cs new file mode 100644 index 000000000..2832235a0 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/GrainWatchingServiceTests.cs @@ -0,0 +1,46 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using FakeItEasy; +using Orleans; +using Squidex.Infrastructure; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Comments +{ + public class GrainWatchingServiceTests + { + private readonly IGrainFactory grainFactory = A.Fake(); + private readonly GrainWatchingService sut; + + public GrainWatchingServiceTests() + { + sut = new GrainWatchingService(grainFactory); + } + + [Fact] + public async Task Should_call_grain_if_retrieving_watching_users() + { + var appId = DomainId.NewGuid(); + var userResource = "resource1"; + var userIdentity = "user1"; + + var grain = A.Fake(); + + A.CallTo(() => grainFactory.GetGrain(appId.ToString(), null)) + .Returns(grain); + + A.CallTo(() => grain.GetWatchingUsersAsync(userResource, userIdentity)) + .Returns(Task.FromResult(new[] { "user1", "user2" })); + + var result = await sut.GetWatchingUsersAsync(appId, userResource, userIdentity); + + Assert.Equal(new[] { "user1", "user2" }, result); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/WatchingGrainTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/WatchingGrainTests.cs new file mode 100644 index 000000000..edca83109 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/WatchingGrainTests.cs @@ -0,0 +1,72 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using FakeItEasy; +using NodaTime; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Comments +{ + public class WatchingGrainTests + { + private readonly IClock clock = A.Fake(); + private readonly WatchingGrain sut; + private Instant now = SystemClock.Instance.GetCurrentInstant(); + + public WatchingGrainTests() + { + A.CallTo(() => clock.GetCurrentInstant()) + .ReturnsLazily(() => now); + + sut = new WatchingGrain(clock); + } + + [Fact] + public async Task Should_only_return_self_if_no_one_watching() + { + var watching = await sut.GetWatchingUsersAsync("resource1", "user1"); + + Assert.Equal(new[] { "user1" }, watching); + } + + [Fact] + public async Task Should_return_users_watching_on_same_resource() + { + await sut.GetWatchingUsersAsync("resource1", "user1"); + await sut.GetWatchingUsersAsync("resource2", "user2"); + + var watching1 = await sut.GetWatchingUsersAsync("resource1", "user3"); + var watching2 = await sut.GetWatchingUsersAsync("resource2", "user4"); + + Assert.Equal(new[] { "user1", "user3" }, watching1); + Assert.Equal(new[] { "user2", "user4" }, watching2); + } + + [Fact] + public async Task Should_cleanup_old_users() + { + await sut.GetWatchingUsersAsync("resource1", "user1"); + await sut.GetWatchingUsersAsync("resource2", "user2"); + + now = now.Plus(Duration.FromMinutes(2)); + + sut.Cleanup(); + + await sut.GetWatchingUsersAsync("resource1", "user3"); + await sut.GetWatchingUsersAsync("resource2", "user4"); + + sut.Cleanup(); + + var watching1 = await sut.GetWatchingUsersAsync("resource1", "user5"); + var watching2 = await sut.GetWatchingUsersAsync("resource2", "user6"); + + Assert.Equal(new[] { "user3", "user5" }, watching1); + Assert.Equal(new[] { "user4", "user6" }, watching2); + } + } +} diff --git a/frontend/app/features/content/pages/content/content-page.component.html b/frontend/app/features/content/pages/content/content-page.component.html index 9fc823501..3b7f6bf44 100644 --- a/frontend/app/features/content/pages/content/content-page.component.html +++ b/frontend/app/features/content/pages/content/content-page.component.html @@ -51,6 +51,8 @@