Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Version>11.0.0-rc.3.2</Version>
</PropertyGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="BootstrapBlazor" Version="11.0.2-beta04" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
///
/// <para lang="zh">BootstrapBlazor 服务端中间件扩展</para>
/// <para lang="en">BootstrapBlazor server middleware extensions.</para>
/// </summary>
public static class BootstrapBlazorApplicationBuilderExtensions
{
/// <summary>
///
/// <para lang="zh">使用 BootstrapBlazor 中间件</para>
/// <para lang="en">Use BootstrapBlazor middleware</para>
/// </summary>
/// <param name="builder"></param>
/// <returns></returns>
/// <remarks>
/// <para lang="zh">提供客户端 IP 地址查询(/ip.axd)。</para>
/// <para lang="en">Provides client IP lookup (/ip.axd).</para>
/// </remarks>
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<string>();
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;
}
}
56 changes: 56 additions & 0 deletions src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHub.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// <para lang="zh">消息分发和在线连接共用的 SignalR Hub</para>
/// <para lang="en">Shared SignalR Hub for dispatch and online connections.</para>
/// <para><version>11.0.0</version></para>
/// </summary>
/// <remarks>
/// <para lang="zh">重连后需恢复订阅。宿主可对映射端点配置认证和授权。</para>
/// <para lang="en">Subscriptions must be restored after reconnecting. Hosts can configure authentication and authorization on the mapped endpoint.</para>
/// </remarks>
public sealed class BootstrapBlazorHub(BootstrapBlazorHubOptions options, IServiceProvider services) : Hub
{
/// <summary>
/// <para lang="zh">尝试将当前连接加入消息类型分组,区分订阅拒绝与服务端执行失败</para>
/// <para lang="en">Attempts to join an entry type group, distinguishing subscription rejection from server execution failures.</para>
/// </summary>
/// <param name="entryType"><para lang="zh">消息类型标识</para><para lang="en">The entry type identifier.</para></param>
/// <returns><para lang="zh">订阅成功时返回 true,类型未被允许时返回 false;加入分组失败时抛出异常</para><para lang="en">True when subscribed, or false when the type is disallowed. Group membership failures throw an exception.</para></returns>
public Task<bool> Subscribe(string entryType)
=> DispatchHandler.SubscribeAsync(Context, Groups, entryType, options);

/// <summary>
/// <para lang="zh">取消当前连接对指定消息类型的订阅</para>
/// <para lang="en">Unsubscribes the current connection from the specified entry type.</para>
/// </summary>
public Task Unsubscribe(string entryType)
=> DispatchHandler.UnsubscribeAsync(Context, Groups, entryType);

/// <summary>
/// <para lang="zh">将消息分发给服务端本地订阅者和其他已订阅连接,发送端在调用成功后自行显示消息</para>
/// <para lang="en">Dispatches a message locally and to other subscribed connections. The sender displays its own message after the invocation succeeds.</para>
/// </summary>
public Task Dispatch(string entryType, JsonElement entry)
=> DispatchHandler.DispatchAsync(Context, entryType, entry, options, services);

/// <summary>
/// <para lang="zh">上报客户端业务心跳,服务端禁用心跳时返回 false,传输层保活不能代替业务心跳</para>
/// <para lang="en">Reports an application heartbeat, returning false when heartbeats are disabled. Transport keepalive messages do not replace application heartbeats.</para>
/// </summary>
public Task<bool> Heartbeat(ClientInfo client)
=> ConnectionHandler.ReportHeartbeatAsync(Context, client, services);

/// <summary>
/// <para lang="zh">查询在线连接快照,仅返回给调用方,不向消息订阅者广播;查询被禁用或调用方未获授权时返回 null</para>
/// <para lang="en">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.</para>
/// </summary>
public Task<ConnectionSnapshot?> GetConnectionSnapshot()
=> ConnectionHandler.GetSnapshotAsync(Context, options, services);
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// <para lang="zh">共享 SignalR Hub 配置,消息类型和在线连接快照查询需显式启用</para>
/// <para lang="en">Shared SignalR Hub options. Dispatch types and connection snapshot queries require explicit opt-in.</para>
/// <para><version>11.0.0</version></para>
/// </summary>
public sealed class BootstrapBlazorHubOptions
{
private static readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);

internal Dictionary<string, Func<IServiceProvider, JsonElement, string, CancellationToken, Task>?> EntryTypes { get; } = new(StringComparer.Ordinal);

/// <summary>
/// <para lang="zh">获得/设置是否允许查询在线连接快照,快照包含客户端信息,默认关闭</para>
/// <para lang="en">Gets or sets whether connection snapshot queries are enabled. Disabled by default because snapshots contain client information.</para>
/// <para><version>11.0.0</version></para>
/// </summary>
public bool EnableConnectionSnapshots { get; set; }

/// <summary>
/// <para lang="zh">获得/设置独立于消息订阅的快照查询授权策略;未设置时,启用查询后所有获准连接 Hub 的客户端均可查询</para>
/// <para lang="en">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.</para>
/// <para><version>11.0.0</version></para>
/// </summary>
public string? ConnectionSnapshotAuthorizationPolicy { get; set; }

/// <summary>
/// <para lang="zh">允许客户端订阅指定消息类型,并可选择允许发布;不得公开包含凭据的内部 Webhook 载荷</para>
/// <para lang="en">Allows clients to subscribe to a type and optionally publish it. Internal webhook payloads containing credentials must not be exposed.</para>
/// </summary>
/// <typeparam name="TEntry"></typeparam>
/// <param name="allowClientPublish">
/// <para lang="zh">是否允许客户端发布,默认 false</para>
/// <para lang="en">Whether clients are allowed to publish. Defaults to false.</para>
/// </param>
public void Allow<TEntry>(bool allowClientPublish = false)
{
EntryTypes[typeof(TEntry).FullName ?? typeof(TEntry).Name] = allowClientPublish ? PublishAsync<TEntry> : null;
}

private static async Task PublishAsync<TEntry>(IServiceProvider services, JsonElement json, string connectionId, CancellationToken cancellationToken)
{
DispatchEntry<TEntry>? entry;
try
{
entry = json.Deserialize<DispatchEntry<TEntry>>(_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<TEntry>)services.GetRequiredService<IDispatchService<TEntry>>();
await service.DispatchAsync(entry, connectionId, cancellationToken);
}
}
75 changes: 75 additions & 0 deletions src/middleware/BootstrapBlazor.Middleware/ConnectionHandler.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// <para lang="zh">在线连接心跳和快照查询处理类</para>
/// <para lang="en">Handles online connection heartbeats and snapshot queries.</para>
/// </summary>
static class ConnectionHandler
{
public static async Task<bool> ReportHeartbeatAsync(HubCallerContext context, ClientInfo client, IServiceProvider services)
{
var bootstrapOptions = services.GetRequiredService<IOptions<BootstrapBlazorOptions>>().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<IIpLocatorFactory>();
client.City = await factory.Create(bootstrapOptions.IpLocatorOptions.ProviderName).Locate(client.Ip);
}

context.ConnectionAborted.ThrowIfCancellationRequested();
await services.GetRequiredService<IConnectionService>().AddOrUpdateAsync(client, context.ConnectionAborted);
return true;
}

public static async Task<ConnectionSnapshot?> GetSnapshotAsync(HubCallerContext context, BootstrapBlazorHubOptions options, IServiceProvider services)
{
if (!options.EnableConnectionSnapshots)
{
// 未开启快照查询功能直接返回 null
return null;
}

if (!string.IsNullOrEmpty(options.ConnectionSnapshotAuthorizationPolicy))
{
// 如果设置了授权策略则进行授权检查
var authorization = services.GetRequiredService<IAuthorizationService>();
var result = await authorization.AuthorizeAsync(context.User ?? new ClaimsPrincipal(), null, options.ConnectionSnapshotAuthorizationPolicy);
if (!result.Succeeded)
{
return null;
}
}

var service = services.GetRequiredService<IConnectionService>();
return new ConnectionSnapshot
{
Count = service.Count,
Connections = service.Connections
};
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// <para lang="zh">服务端本地和 SignalR 消息分发服务</para>
/// <para lang="en">Server-side local and SignalR message dispatch service.</para>
/// </summary>
sealed class DefaultDispatchService<TEntry>(
IHubContext<BootstrapBlazorHub> hub,
BootstrapBlazorHubOptions options,
ILogger<DefaultDispatchService<TEntry>> logger) : IDispatchService<TEntry>
{
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<Subscription> _subscriptions = [];

public Task DispatchAsync(DispatchEntry<TEntry> entry, CancellationToken cancellationToken = default)
=> DispatchAsync(entry, null, cancellationToken);

internal async Task DispatchAsync(DispatchEntry<TEntry> 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<DispatchEntry<TEntry>, Task> callback, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
lock (_locker)
{
_subscriptions.Add(new Subscription(callback));
}
return Task.CompletedTask;
}

public Task UnSubscribeAsync(Func<DispatchEntry<TEntry>, 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<DispatchEntry<TEntry>, Task> callback)
{
public Func<DispatchEntry<TEntry>, Task> Callback { get; } = callback;
}
}
Loading
Loading