Skip to content

Commit 34799a4

Browse files
websocket POC demo - not production ready.
1 parent ac86660 commit 34799a4

File tree

14 files changed

+333
-6
lines changed

14 files changed

+333
-6
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using System;
2+
using System.Security.Claims;
3+
using System.Threading.Tasks;
4+
using Microsoft.AspNetCore.Authorization;
5+
using Microsoft.AspNetCore.Mvc;
6+
using Scv.Api.Models;
7+
using Scv.Api.Services;
8+
9+
namespace Scv.Api.Controllers;
10+
11+
[ApiController]
12+
[Route("api/notifications")]
13+
[Authorize]
14+
public class NotificationsController(INotificationPublisher publisher) : ControllerBase
15+
{
16+
private readonly INotificationPublisher _publisher = publisher;
17+
18+
[HttpPost("demo")]
19+
public async Task<IActionResult> SendDemo()
20+
{
21+
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
22+
if (string.IsNullOrWhiteSpace(userId))
23+
{
24+
return Unauthorized();
25+
}
26+
27+
var serverId = Environment.MachineName;
28+
Response.Headers["X-Server-Id"] = serverId;
29+
30+
var notification = new NotificationDto(
31+
Type: "demo",
32+
Message: $"Hello from the server! server id: ({serverId})",
33+
Timestamp: DateTimeOffset.UtcNow,
34+
Payload: new { source = "demo-endpoint" }
35+
);
36+
37+
await _publisher.NotifyUserAsync(userId, notification);
38+
39+
return Ok();
40+
}
41+
}

api/Hubs/NotificationsHub.cs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using System.Security.Claims;
2+
using System;
3+
using System.Linq;
4+
using System.Threading.Tasks;
5+
using Microsoft.AspNetCore.Authorization;
6+
using Microsoft.AspNetCore.SignalR;
7+
using Microsoft.Extensions.Configuration;
8+
using Microsoft.Extensions.DependencyInjection;
9+
using Microsoft.Extensions.Logging;
10+
11+
namespace Scv.Api.Hubs;
12+
13+
[Authorize]
14+
public class NotificationsHub : Hub
15+
{
16+
public override Task OnConnectedAsync()
17+
{
18+
var httpContext = Context.GetHttpContext();
19+
var config = httpContext?.RequestServices.GetService<IConfiguration>();
20+
var logger = httpContext?.RequestServices.GetService<ILogger<NotificationsHub>>();
21+
var allowedOrigin = config?.GetValue<string>("CORS_DOMAIN");
22+
var disableOriginCheck = config?.GetValue<bool>("DISABLE_SIGNALR_ORIGIN_CHECK") ?? false;
23+
var origin = httpContext?.Request.Headers["Origin"].ToString();
24+
25+
logger?.LogInformation(
26+
"SignalR connect attempt. Origin={Origin}, CORS_DOMAIN={CorsDomain}",
27+
origin,
28+
allowedOrigin);
29+
30+
if (disableOriginCheck)
31+
{
32+
logger?.LogWarning("SignalR origin check disabled via DISABLE_SIGNALR_ORIGIN_CHECK.");
33+
}
34+
else if (!string.IsNullOrWhiteSpace(allowedOrigin))
35+
{
36+
var allowedOrigins = allowedOrigin
37+
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
38+
.Select(value => value.Trim().Trim('"', '\''))
39+
.Where(value => !string.IsNullOrWhiteSpace(value))
40+
.ToArray();
41+
42+
logger?.LogInformation(
43+
"SignalR allowed origins resolved to {AllowedOrigins}",
44+
string.Join(";", allowedOrigins));
45+
46+
if (allowedOrigins.Length > 0 &&
47+
(string.IsNullOrWhiteSpace(origin) ||
48+
!allowedOrigins.Any(value => string.Equals(origin, value, StringComparison.OrdinalIgnoreCase))))
49+
{
50+
logger?.LogWarning(
51+
"SignalR connection aborted due to origin mismatch. Origin={Origin}",
52+
origin);
53+
Context.Abort();
54+
return Task.CompletedTask;
55+
}
56+
}
57+
58+
var userId = Context.User?.FindFirstValue(ClaimTypes.NameIdentifier);
59+
if (string.IsNullOrWhiteSpace(userId))
60+
{
61+
if (logger != null)
62+
{
63+
var claims = Context.User?.Claims
64+
.Select(claim => $"{claim.Type}={claim.Value}")
65+
.ToArray() ?? Array.Empty<string>();
66+
logger.LogDebug(
67+
"SignalR user claims: {Claims}",
68+
string.Join(";", claims));
69+
}
70+
logger?.LogWarning("SignalR connection aborted due to missing user id claim.");
71+
Context.Abort();
72+
return Task.CompletedTask;
73+
}
74+
75+
logger?.LogInformation("SignalR connection accepted for user {UserId}.", userId);
76+
77+
return base.OnConnectedAsync();
78+
}
79+
}

api/Models/NotificationDto.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using System;
2+
3+
namespace Scv.Api.Models;
4+
5+
public record NotificationDto(
6+
string Type,
7+
string Message,
8+
DateTimeOffset Timestamp,
9+
object? Payload = null
10+
);

api/Properties/launchSettings.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
"launchBrowser": true,
2424
"launchUrl": "api/values",
2525
"environmentVariables": {
26-
"CORS_DOMAIN": "http://localhost:8080",
2726
"ASPNETCORE_ENVIRONMENT": "Development"
2827
},
2928
"applicationUrl": "http://localhost:5000"
@@ -37,4 +36,4 @@
3736
}
3837
}
3938
}
40-
}
39+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using System.Threading.Tasks;
2+
using Microsoft.AspNetCore.SignalR;
3+
using Scv.Api.Hubs;
4+
using Scv.Api.Models;
5+
6+
namespace Scv.Api.Services;
7+
8+
public interface INotificationPublisher
9+
{
10+
Task NotifyUserAsync(string userId, NotificationDto notification);
11+
Task NotifyAllAsync(NotificationDto notification);
12+
}
13+
14+
public class NotificationPublisher(IHubContext<NotificationsHub> hubContext) : INotificationPublisher
15+
{
16+
private readonly IHubContext<NotificationsHub> _hubContext = hubContext;
17+
18+
public Task NotifyUserAsync(string userId, NotificationDto notification)
19+
{
20+
return _hubContext.Clients.User(userId)
21+
.SendAsync("notificationReceived", notification);
22+
}
23+
24+
public Task NotifyAllAsync(NotificationDto notification)
25+
{
26+
return _hubContext.Clients.All
27+
.SendAsync("notificationReceived", notification);
28+
}
29+
}

api/SignalR/UserIdProvider.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using System.Security.Claims;
2+
using Microsoft.AspNetCore.SignalR;
3+
4+
namespace Scv.Api.SignalR;
5+
6+
public class UserIdProvider : IUserIdProvider
7+
{
8+
public string? GetUserId(HubConnectionContext connection)
9+
{
10+
return connection.User?.FindFirstValue(ClaimTypes.NameIdentifier);
11+
}
12+
}

api/Startup.cs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using Microsoft.AspNetCore.Hosting;
1212
using Microsoft.AspNetCore.Http;
1313
using Microsoft.AspNetCore.Routing;
14+
using Microsoft.AspNetCore.SignalR;
1415
using Microsoft.EntityFrameworkCore;
1516
using Microsoft.Extensions.Configuration;
1617
using Microsoft.Extensions.DependencyInjection;
@@ -30,8 +31,12 @@
3031
using Scv.Api.Infrastructure.Middleware;
3132
using Scv.Api.Repositories;
3233
using Scv.Api.Infrastructure.Options;
34+
using Scv.Api.Hubs;
35+
using Scv.Api.Services;
36+
using Scv.Api.SignalR;
3337
using Scv.Api.Services.EF;
3438
using Scv.Db.Models;
39+
using System.Linq;
3540

3641
namespace Scv.Api
3742
{
@@ -103,15 +108,29 @@ public void ConfigureServices(IServiceCollection services)
103108
services.AddHangfire(Configuration);
104109
services.AddGraphService(Configuration);
105110

111+
services.AddSignalR(options =>
112+
{
113+
options.MaximumReceiveMessageSize = 64 * 1024;
114+
});
115+
services.AddSingleton<IUserIdProvider, UserIdProvider>();
116+
services.AddScoped<INotificationPublisher, NotificationPublisher>();
117+
106118
#region Cors
107119

108-
string corsDomain = Configuration.GetValue<string>("CORS_DOMAIN");
120+
var corsDomain = Configuration.GetValue<string>("CORS_DOMAIN");
121+
var origins = corsDomain?
122+
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
123+
.Select(o => o.Trim().Trim('"', '\''))
124+
.ToArray() ?? Array.Empty<string>();
109125

110126
services.AddCors(options =>
111127
{
112128
options.AddDefaultPolicy(builder =>
113129
{
114-
builder.WithOrigins(corsDomain);
130+
builder.WithOrigins(origins)
131+
.AllowAnyHeader()
132+
.AllowAnyMethod()
133+
.AllowCredentials();
115134
});
116135
});
117136

@@ -259,6 +278,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
259278
app.UseEndpoints(endpoints =>
260279
{
261280
endpoints.MapControllers();
281+
endpoints.MapHub<NotificationsHub>("/hubs/notifications");
262282
});
263283
}
264284
}

docker/api/Dockerfile.dev

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0
22

33
ENV ASPNETCORE_ENVIRONMENT='Production'
44
ENV ASPNETCORE_URLS='http://+:5000'
5-
ENV CORS_DOMAIN='http://localhost:8080'
65
ENV DOTNET_STARTUP_PROJECT='./api/api.csproj'
76
ENV DOTNET_USE_POLLING_FILE_WATCHER 1
87

docker/docker-compose.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ services:
2424
- NPM_CONFIG_LOGLEVEL=notice
2525
- VITE_PORT=1339
2626
- CHOKIDAR_USEPOLLING=true
27+
- VITE_WS_PROXY_INSECURE=${VITE_WS_PROXY_INSECURE}
28+
- VITE_WS_PROXY_ORIGIN=${VITE_WS_PROXY_ORIGIN}
29+
- DEBUG=http-proxy*
2730
ports:
2831
- '8080:1339'
2932
volumes:
@@ -124,6 +127,7 @@ services:
124127
- UserServicesClient__Password=${UserServicesClientPassword}
125128
- UserServicesClient__Url=${UserServicesClientUrl}
126129
- UserServicesClient__Username=${UserServicesClientUsername}
130+
- CORS_DOMAIN=${CORS_DOMAIN}
127131

128132
ports:
129133
- 5000:5000

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@fullcalendar/daygrid": "^6.1.20",
2828
"@fullcalendar/vue3": "^6.1.20",
2929
"@nutrient-sdk/viewer": "^1.11.0",
30+
"@microsoft/signalr": "^8.0.7",
3031
"@types/luxon": "^3.7.1",
3132
"@types/underscore": "^1.13.0",
3233
"@vitejs/plugin-basic-ssl": "^2.1.4",

0 commit comments

Comments
 (0)