Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 8 additions & 15 deletions src/SIL.XForge.Scripture/Services/DraftNotificationHub.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using SIL.XForge.Realtime;
using SIL.XForge.Scripture.Models;

namespace SIL.XForge.Scripture.Services;

[Authorize]
public class DraftNotificationHub : Hub<IDraftNotifier>, IDraftNotifier
public class DraftNotificationHub(IRealtimeService realtimeService)
: NotificationHubBase<IDraftNotifier>(realtimeService),
IDraftNotifier
{
/// <summary>
/// Notifies subscribers to a project of draft application progress.
Expand All @@ -23,16 +23,9 @@ public class DraftNotificationHub : Hub<IDraftNotifier>, IDraftNotifier
/// is sufficient for all other users to subscribe to, although they will not receive all draft
/// progress notifications, only the final success message.
/// </remarks>
public async Task NotifyDraftApplyProgress(string projectId, DraftApplyState draftApplyState) =>
public async Task NotifyDraftApplyProgress(string projectId, DraftApplyState draftApplyState)
{
await EnsurePermissionAsync(projectId);
await Clients.Group(projectId).NotifyDraftApplyProgress(projectId, draftApplyState);

/// <summary>
/// Subscribe to notifications for a project.
///
/// This is called from the frontend via <c>project-notification.service.ts</c>.
/// </summary>
/// <param name="projectId">The Scripture Forge project identifier.</param>
/// <returns>The asynchronous task.</returns>
public async Task SubscribeToProject(string projectId) =>
await Groups.AddToGroupAsync(Context.ConnectionId, projectId);
}
}
33 changes: 16 additions & 17 deletions src/SIL.XForge.Scripture/Services/NotificationHub.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using SIL.XForge.Realtime;
using SIL.XForge.Scripture.Models;

namespace SIL.XForge.Scripture.Services;

[Authorize]
public class NotificationHub : Hub<INotifier>, INotifier
public class NotificationHub(IRealtimeService realtimeService)
: NotificationHubBase<INotifier>(realtimeService),
INotifier
{
/// <summary>
/// Notifies subscribers to a project of draft build progress.
Expand All @@ -18,8 +18,11 @@ public class NotificationHub : Hub<INotifier>, INotifier
/// This will currently be emitted on the TranslationBuildStarted and TranslationBuildFinished webhooks,
/// and when the draft pre-translations have been retrieved.
/// </remarks>
public async Task NotifyBuildProgress(string projectId, ServalBuildState buildState) =>
public async Task NotifyBuildProgress(string projectId, ServalBuildState buildState)
{
await EnsurePermissionAsync(projectId);
await Clients.Group(projectId).NotifyBuildProgress(projectId, buildState);
}

/// <summary>
/// Notifies subscribers to a project of draft application progress.
Expand All @@ -31,8 +34,11 @@ public async Task NotifyBuildProgress(string projectId, ServalBuildState buildSt
/// This differs from the implementation in <see cref="DraftNotificationHub"/> in that this version
/// does not have stateful reconnection, and so there is no guarantee that the message is received.
/// </remarks>
public async Task NotifyDraftApplyProgress(string projectId, DraftApplyState draftApplyState) =>
public async Task NotifyDraftApplyProgress(string projectId, DraftApplyState draftApplyState)
{
await EnsurePermissionAsync(projectId);
await Clients.Group(projectId).NotifyDraftApplyProgress(projectId, draftApplyState);
}

/// <summary>
/// Notifies subscribers to a project of sync progress.
Expand All @@ -42,16 +48,9 @@ public async Task NotifyDraftApplyProgress(string projectId, DraftApplyState dra
/// The progress state, including a string value (Paratext only - not used in SF), or percentage value.
/// </param>
/// <returns>The asynchronous task.</returns>
public async Task NotifySyncProgress(string projectId, ProgressState progressState) =>
public async Task NotifySyncProgress(string projectId, ProgressState progressState)
{
await EnsurePermissionAsync(projectId);
await Clients.Group(projectId).NotifySyncProgress(projectId, progressState);

/// <summary>
/// Subscribe to notifications for a project.
///
/// This is called from the frontend via <c>project-notification.service.ts</c>.
/// </summary>
/// <param name="projectId">The Scripture Forge project identifier.</param>
/// <returns>The asynchronous task.</returns>
public async Task SubscribeToProject(string projectId) =>
await Groups.AddToGroupAsync(Context.ConnectionId, projectId);
}
}
63 changes: 63 additions & 0 deletions src/SIL.XForge.Scripture/Services/NotificationHubBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using SIL.XForge.Realtime;
using SIL.XForge.Scripture.Models;
using SIL.XForge.Services;
using SIL.XForge.Utils;

namespace SIL.XForge.Scripture.Services;

/// <summary>
/// Base class for SignalR notification hubs, providing shared project subscription and
/// permission checking to ensure only authorized users with Paratext roles receive notifications.
/// </summary>
[Authorize]
public abstract class NotificationHubBase<T>(IRealtimeService realtimeService) : Hub<T>
where T : class
{
/// <summary>
/// Subscribe to notifications for a project.
///
/// This is called from the frontend via <c>project-notification.service.ts</c>.
/// </summary>
/// <param name="projectId">The Scripture Forge project identifier.</param>
/// <returns>The asynchronous task.</returns>
public async Task SubscribeToProject(string projectId)
{
await EnsurePermissionAsync(projectId);
await Groups.AddToGroupAsync(Context.ConnectionId, projectId);
}

/// <summary>
/// Ensures that the user has permission to access the project for SignalR notifications.
/// </summary>
/// <param name="projectId">The Scripture Forge project identifier.</param>
/// <exception cref="DataNotFoundException">The project does not exist.</exception>
/// <exception cref="ForbiddenException">
/// The user does not have permission to access the project.
/// </exception>
protected async Task EnsurePermissionAsync(string projectId)
{
// Load the project from the realtime service
Attempt<SFProject> attempt = await realtimeService.TryGetSnapshotAsync<SFProject>(projectId);
if (!attempt.TryResult(out SFProject project))
{
throw new DataNotFoundException("The project does not exist.");
}

// Retrieve the user identifier
string userId = Context.GetHttpContext()?.User.FindFirst(XFClaimTypes.UserId)?.Value;
if (string.IsNullOrWhiteSpace(userId))
{
throw new UnauthorizedAccessException();
}

// Ensure the user is on the project, and has a Paratext role
if (!project.UserRoles.TryGetValue(userId, out string role) || !SFProjectRole.IsParatextRole(role))
{
throw new ForbiddenException();
}
}
}
Loading