diff --git a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazor.Middleware.csproj b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazor.Middleware.csproj index de9f347d..ca75beda 100644 --- a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazor.Middleware.csproj +++ b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazor.Middleware.csproj @@ -1,7 +1,15 @@ + + 11.0.0-rc.3.2 + + + + + + diff --git a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorApplicationBuilderExtensions.cs b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorApplicationBuilderExtensions.cs index 3a4615f2..0ea76438 100644 --- a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorApplicationBuilderExtensions.cs +++ b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorApplicationBuilderExtensions.cs @@ -1,49 +1,29 @@ -// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Website: https://www.blazor.zone or https://argozhang.github.io/ -using Microsoft.AspNetCore.Http; -using System.Text.Json; +using BootstrapBlazor.Components; namespace Microsoft.AspNetCore.Builder; /// -/// +/// BootstrapBlazor 服务端中间件扩展 +/// BootstrapBlazor server middleware extensions. /// public static class BootstrapBlazorApplicationBuilderExtensions { /// - /// + /// 使用 BootstrapBlazor 中间件 + /// Use BootstrapBlazor middleware /// - /// - /// + /// + /// 提供客户端 IP 地址查询(/ip.axd)。 + /// Provides client IP lookup (/ip.axd). + /// public static IApplicationBuilder UseBootstrapBlazor(this IApplicationBuilder builder) { - // 获得客户端 IP 地址 - builder.UseWhen(context => context.Request.Path.StartsWithSegments("/ip.axd"), app => app.Run(async context => - { - var ip = ""; - var headers = context.Request.Headers; - if (headers.TryGetValue("X-Forwarded-For", out var value)) - { - var ips = new List(); - foreach (var xf in value) - { - if (!string.IsNullOrEmpty(xf)) - { - ips.Add(xf); - } - } - ip = string.Join(";", ips); - } - else - { - ip = context.Connection.RemoteIpAddress.ToIPv4String(); - } + builder.MapWhen(context => context.Request.Path.StartsWithSegments("/ip.axd"), app => app.Run(IpRequestHandler.HandleAsync)); - context.Response.Headers.TryAdd("Content-Type", new Microsoft.Extensions.Primitives.StringValues("application/json; charset=utf-8")); - await context.Response.WriteAsync(JsonSerializer.Serialize(new { Id = context.TraceIdentifier, Ip = ip })); - })); return builder; } } diff --git a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHub.cs b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHub.cs new file mode 100644 index 00000000..cdfefda0 --- /dev/null +++ b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHub.cs @@ -0,0 +1,56 @@ +// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved. +// Licensed under the Apache License, Version 2.0. + +using Microsoft.AspNetCore.SignalR; +using System.Text.Json; + +namespace BootstrapBlazor.Components; + +/// +/// 消息分发和在线连接共用的 SignalR Hub +/// Shared SignalR Hub for dispatch and online connections. +/// 11.0.0 +/// +/// +/// 重连后需恢复订阅。宿主可对映射端点配置认证和授权。 +/// Subscriptions must be restored after reconnecting. Hosts can configure authentication and authorization on the mapped endpoint. +/// +public sealed class BootstrapBlazorHub(BootstrapBlazorHubOptions options, IServiceProvider services) : Hub +{ + /// + /// 尝试将当前连接加入消息类型分组,区分订阅拒绝与服务端执行失败 + /// Attempts to join an entry type group, distinguishing subscription rejection from server execution failures. + /// + /// 消息类型标识The entry type identifier. + /// 订阅成功时返回 true,类型未被允许时返回 false;加入分组失败时抛出异常True when subscribed, or false when the type is disallowed. Group membership failures throw an exception. + public Task Subscribe(string entryType) + => DispatchHandler.SubscribeAsync(Context, Groups, entryType, options); + + /// + /// 取消当前连接对指定消息类型的订阅 + /// Unsubscribes the current connection from the specified entry type. + /// + public Task Unsubscribe(string entryType) + => DispatchHandler.UnsubscribeAsync(Context, Groups, entryType); + + /// + /// 将消息分发给服务端本地订阅者和其他已订阅连接,发送端在调用成功后自行显示消息 + /// Dispatches a message locally and to other subscribed connections. The sender displays its own message after the invocation succeeds. + /// + public Task Dispatch(string entryType, JsonElement entry) + => DispatchHandler.DispatchAsync(Context, entryType, entry, options, services); + + /// + /// 上报客户端业务心跳,服务端禁用心跳时返回 false,传输层保活不能代替业务心跳 + /// Reports an application heartbeat, returning false when heartbeats are disabled. Transport keepalive messages do not replace application heartbeats. + /// + public Task Heartbeat(ClientInfo client) + => ConnectionHandler.ReportHeartbeatAsync(Context, client, services); + + /// + /// 查询在线连接快照,仅返回给调用方,不向消息订阅者广播;查询被禁用或调用方未获授权时返回 null + /// Queries a connection snapshot for the caller only, without broadcasting it to dispatch subscribers. Returns null when queries are disabled or the caller is not authorized. + /// + public Task GetConnectionSnapshot() + => ConnectionHandler.GetSnapshotAsync(Context, options, services); +} diff --git a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHubOptions.cs b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHubOptions.cs new file mode 100644 index 00000000..c14e267d --- /dev/null +++ b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHubOptions.cs @@ -0,0 +1,69 @@ +// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved. +// Licensed under the Apache License, Version 2.0. + +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.DependencyInjection; +using System.Text.Json; + +namespace BootstrapBlazor.Components; + +/// +/// 共享 SignalR Hub 配置,消息类型和在线连接快照查询需显式启用 +/// Shared SignalR Hub options. Dispatch types and connection snapshot queries require explicit opt-in. +/// 11.0.0 +/// +public sealed class BootstrapBlazorHubOptions +{ + private static readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web); + + internal Dictionary?> EntryTypes { get; } = new(StringComparer.Ordinal); + + /// + /// 获得/设置是否允许查询在线连接快照,快照包含客户端信息,默认关闭 + /// Gets or sets whether connection snapshot queries are enabled. Disabled by default because snapshots contain client information. + /// 11.0.0 + /// + public bool EnableConnectionSnapshots { get; set; } + + /// + /// 获得/设置独立于消息订阅的快照查询授权策略;未设置时,启用查询后所有获准连接 Hub 的客户端均可查询 + /// Gets or sets the optional snapshot authorization policy, independent of dispatch subscriptions. When omitted, enabling snapshots permits all clients authorized to connect to the Hub to query them. + /// 11.0.0 + /// + public string? ConnectionSnapshotAuthorizationPolicy { get; set; } + + /// + /// 允许客户端订阅指定消息类型,并可选择允许发布;不得公开包含凭据的内部 Webhook 载荷 + /// Allows clients to subscribe to a type and optionally publish it. Internal webhook payloads containing credentials must not be exposed. + /// + /// + /// + /// 是否允许客户端发布,默认 false + /// Whether clients are allowed to publish. Defaults to false. + /// + public void Allow(bool allowClientPublish = false) + { + EntryTypes[typeof(TEntry).FullName ?? typeof(TEntry).Name] = allowClientPublish ? PublishAsync : null; + } + + private static async Task PublishAsync(IServiceProvider services, JsonElement json, string connectionId, CancellationToken cancellationToken) + { + DispatchEntry? entry; + try + { + entry = json.Deserialize>(_serializerOptions); + } + catch (Exception ex) when (ex is JsonException or NotSupportedException) + { + throw new HubException("Invalid dispatch payload."); + } + + if (entry == null || json.ValueKind != JsonValueKind.Object) + { + throw new HubException("Invalid dispatch payload."); + } + + var service = (DefaultDispatchService)services.GetRequiredService>(); + await service.DispatchAsync(entry, connectionId, cancellationToken); + } +} diff --git a/src/middleware/BootstrapBlazor.Middleware/ConnectionHandler.cs b/src/middleware/BootstrapBlazor.Middleware/ConnectionHandler.cs new file mode 100644 index 00000000..520949a8 --- /dev/null +++ b/src/middleware/BootstrapBlazor.Middleware/ConnectionHandler.cs @@ -0,0 +1,75 @@ +// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved. +// Licensed under the Apache License, Version 2.0. + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using System.Security.Claims; + +namespace BootstrapBlazor.Components; + +/// +/// 在线连接心跳和快照查询处理类 +/// Handles online connection heartbeats and snapshot queries. +/// +static class ConnectionHandler +{ + public static async Task ReportHeartbeatAsync(HubCallerContext context, ClientInfo client, IServiceProvider services) + { + var bootstrapOptions = services.GetRequiredService>().Value; + if (!bootstrapOptions.ConnectionHubOptions.Enable) + { + return false; + } + + if (client == null || string.IsNullOrWhiteSpace(client.Id) || client.Id.Length > 128 || + !Enum.IsDefined(typeof(WebClientDeviceType), client.Device)) + { + throw new HubException("Invalid connection heartbeat."); + } + + var httpContext = context.GetHttpContext() ?? throw new HubException("The connection context is unavailable."); + client.Ip = httpContext.Connection.RemoteIpAddress.ToIPv4String(); + client.UserName = context.User?.Identity is { IsAuthenticated: true } identity ? identity.Name : null; + client.City = null; + + if (bootstrapOptions.ConnectionHubOptions.EnableIpLocator) + { + var factory = services.GetRequiredService(); + client.City = await factory.Create(bootstrapOptions.IpLocatorOptions.ProviderName).Locate(client.Ip); + } + + context.ConnectionAborted.ThrowIfCancellationRequested(); + await services.GetRequiredService().AddOrUpdateAsync(client, context.ConnectionAborted); + return true; + } + + public static async Task GetSnapshotAsync(HubCallerContext context, BootstrapBlazorHubOptions options, IServiceProvider services) + { + if (!options.EnableConnectionSnapshots) + { + // 未开启快照查询功能直接返回 null + return null; + } + + if (!string.IsNullOrEmpty(options.ConnectionSnapshotAuthorizationPolicy)) + { + // 如果设置了授权策略则进行授权检查 + var authorization = services.GetRequiredService(); + var result = await authorization.AuthorizeAsync(context.User ?? new ClaimsPrincipal(), null, options.ConnectionSnapshotAuthorizationPolicy); + if (!result.Succeeded) + { + return null; + } + } + + var service = services.GetRequiredService(); + return new ConnectionSnapshot + { + Count = service.Count, + Connections = service.Connections + }; + } +} diff --git a/src/middleware/BootstrapBlazor.Middleware/DefaultDispatchService.cs b/src/middleware/BootstrapBlazor.Middleware/DefaultDispatchService.cs new file mode 100644 index 00000000..eda6ef70 --- /dev/null +++ b/src/middleware/BootstrapBlazor.Middleware/DefaultDispatchService.cs @@ -0,0 +1,99 @@ +// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved. +// Licensed under the Apache License, Version 2.0. + +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace BootstrapBlazor.Components; + +/// +/// 服务端本地和 SignalR 消息分发服务 +/// Server-side local and SignalR message dispatch service. +/// +sealed class DefaultDispatchService( + IHubContext hub, + BootstrapBlazorHubOptions options, + ILogger> logger) : IDispatchService +{ + private static readonly string _entryType = typeof(TEntry).FullName ?? typeof(TEntry).Name; + private static readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web); + private readonly object _locker = new(); + private readonly List _subscriptions = []; + + public Task DispatchAsync(DispatchEntry entry, CancellationToken cancellationToken = default) + => DispatchAsync(entry, null, cancellationToken); + + internal async Task DispatchAsync(DispatchEntry entry, string? excludedConnectionId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (options.EntryTypes.ContainsKey(_entryType)) + { + var clients = excludedConnectionId == null + ? hub.Clients.Group(_entryType) + : hub.Clients.GroupExcept(_entryType, [excludedConnectionId]); + await clients.SendAsync("Receive", _entryType, JsonSerializer.SerializeToElement(entry, _serializerOptions), cancellationToken); + } + + Subscription[] subscriptions; + lock (_locker) + { + subscriptions = _subscriptions.ToArray(); + } + + foreach (var subscription in subscriptions) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + Task callbackTask; + lock (_locker) + { + if (!_subscriptions.Contains(subscription)) + { + continue; + } + + // Coordinate callback startup with unsubscription. + callbackTask = subscription.Callback(entry); + } + + await callbackTask; + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + logger.LogError(ex, "A dispatch subscriber failed for entry type {EntryType}.", _entryType); + } + } + } + + public Task SubscribeAsync(Func, Task> callback, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (_locker) + { + _subscriptions.Add(new Subscription(callback)); + } + return Task.CompletedTask; + } + + public Task UnSubscribeAsync(Func, Task> callback, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (_locker) + { + var index = _subscriptions.FindIndex(subscription => subscription.Callback == callback); + if (index >= 0) + { + _subscriptions.RemoveAt(index); + } + } + return Task.CompletedTask; + } + + private sealed class Subscription(Func, Task> callback) + { + public Func, Task> Callback { get; } = callback; + } +} diff --git a/src/middleware/BootstrapBlazor.Middleware/DispatchHandler.cs b/src/middleware/BootstrapBlazor.Middleware/DispatchHandler.cs new file mode 100644 index 00000000..df0b31cb --- /dev/null +++ b/src/middleware/BootstrapBlazor.Middleware/DispatchHandler.cs @@ -0,0 +1,42 @@ +// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved. +// Licensed under the Apache License, Version 2.0. + +using Microsoft.AspNetCore.SignalR; +using System.Text.Json; + +namespace BootstrapBlazor.Components; + +/// +/// 消息订阅和分发处理类 +/// Handles message subscriptions and dispatch. +/// +static class DispatchHandler +{ + /// + /// 尝试订阅消息类型,未允许时返回 false,加入分组失败时传播异常 + /// Attempts to subscribe to an entry type, returning false when disallowed and propagating group membership failures. + /// + public static async Task SubscribeAsync(HubCallerContext context, IGroupManager groups, string entryType, BootstrapBlazorHubOptions options) + { + if (!options.EntryTypes.ContainsKey(entryType)) + { + return false; + } + + await groups.AddToGroupAsync(context.ConnectionId, entryType, context.ConnectionAborted); + return true; + } + + public static Task UnsubscribeAsync(HubCallerContext context, IGroupManager groups, string entryType) + => groups.RemoveFromGroupAsync(context.ConnectionId, entryType, context.ConnectionAborted); + + public static Task DispatchAsync(HubCallerContext context, string entryType, JsonElement entry, BootstrapBlazorHubOptions options, IServiceProvider services) + { + if (!options.EntryTypes.TryGetValue(entryType, out var publish) || publish == null) + { + throw new HubException("Client publishing is not allowed for this type."); + } + + return publish(services, entry, context.ConnectionId, context.ConnectionAborted); + } +} diff --git a/src/middleware/BootstrapBlazor.Middleware/InternalIPAddressExtensions.cs b/src/middleware/BootstrapBlazor.Middleware/InternalIPAddressExtensions.cs index 57c8f42a..d4878ba0 100644 --- a/src/middleware/BootstrapBlazor.Middleware/InternalIPAddressExtensions.cs +++ b/src/middleware/BootstrapBlazor.Middleware/InternalIPAddressExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Website: https://www.blazor.zone or https://argozhang.github.io/ @@ -7,15 +7,15 @@ namespace Microsoft.AspNetCore.Builder; /// -/// IPAddress 内部操作扩展类 +/// IP 地址内部操作扩展类 +/// Internal IP address extensions. /// internal static class InternalIPAddressExtensions { /// - /// + /// 获得 IP 地址字符串,将 IPv4 映射地址转换为 IPv4 格式,空地址使用 IPv6 回环地址 + /// Gets an IP address string, converting IPv4-mapped addresses to IPv4 format and using the IPv6 loopback address when null. /// - /// - /// public static string ToIPv4String(this IPAddress? address) { var ipv4Address = (address ?? IPAddress.IPv6Loopback).ToString(); diff --git a/src/middleware/BootstrapBlazor.Middleware/IpRequestHandler.cs b/src/middleware/BootstrapBlazor.Middleware/IpRequestHandler.cs new file mode 100644 index 00000000..e60e9a7b --- /dev/null +++ b/src/middleware/BootstrapBlazor.Middleware/IpRequestHandler.cs @@ -0,0 +1,41 @@ +// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using System.Text.Json; + +namespace BootstrapBlazor.Components; + +/// +/// 客户端 IP 地址查询请求处理类 +/// Handles client IP address lookup requests. +/// +static class IpRequestHandler +{ + public static async Task HandleAsync(HttpContext context) + { + var ip = ""; + var headers = context.Request.Headers; + if (headers.TryGetValue("X-Forwarded-For", out var value)) + { + var ips = new List(); + foreach (var xf in value) + { + if (!string.IsNullOrEmpty(xf)) + { + ips.Add(xf); + } + } + ip = string.Join(";", ips); + } + else + { + ip = context.Connection.RemoteIpAddress.ToIPv4String(); + } + + context.Response.Headers.TryAdd("Content-Type", new Microsoft.Extensions.Primitives.StringValues("application/json; charset=utf-8")); + await context.Response.WriteAsync(JsonSerializer.Serialize(new { Id = context.TraceIdentifier, Ip = ip })); + } +} diff --git a/src/middleware/BootstrapBlazor.Middleware/ServiceCollectionExtensions.cs b/src/middleware/BootstrapBlazor.Middleware/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..5b17a014 --- /dev/null +++ b/src/middleware/BootstrapBlazor.Middleware/ServiceCollectionExtensions.cs @@ -0,0 +1,49 @@ +// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved. +// Licensed under the Apache License, Version 2.0. + +using BootstrapBlazor.Components; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// 服务端 SignalR 服务注册和端点映射扩展 +/// Server-side SignalR service registration and endpoint mapping extensions. +/// 11.0.0 +/// +public static class ServiceCollectionExtensions +{ + /// + /// 注册实时 SignalR 分发服务 + /// Registers the live SignalR dispatch service. + /// 11.0.0 + /// + /// + /// 应在 AddBootstrapBlazor 后调用,未配置的类型仅在服务端本地分发。单服务解析使用此注册,枚举分发服务时仍包含默认实现。 + /// Call after AddBootstrapBlazor. Unlisted types remain server-local. Single-service resolution uses this registration; enumerating dispatch services also includes the default implementation. + /// 消息不持久化,重连后不补发。同一 Hub 支持连接心跳,快照查询需显式启用。 + /// Messages are not persisted or replayed after reconnecting. The same Hub accepts connection heartbeats; snapshot queries require explicit opt-in. + /// + /// + /// + public static IServiceCollection AddBootstrapBlazorHub(this IServiceCollection services, Action configure) + { + var options = new BootstrapBlazorHubOptions(); + configure(options); + services.AddSingleton(options); + services.AddSignalR(); + services.AddSingleton(typeof(IDispatchService<>), typeof(DefaultDispatchService<>)); + return services; + } + + /// + /// 映射共享 Hub 端点,宿主可链式调用 RequireAuthorization 配置授权 + /// Maps the shared Hub endpoint. Hosts may chain RequireAuthorization to protect it. + /// 11.0.0 + /// + /// + /// + public static HubEndpointConventionBuilder MapBootstrapBlazorHub(this IEndpointRouteBuilder endpoints, string pattern = "/bb.hub") + => endpoints.MapHub(pattern); +}