Squash of recent dev work: return_session login, backend switcher, line detail timeline, overview strips, node platform/country/ISP info with flags and icons, build info (version, tag, time) behind site.show_build, and the acrylic display theme.
133 lines
5.4 KiB
C#
133 lines
5.4 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Http.Json;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Monitor.Backend.Application;
|
|
|
|
namespace Monitor.Backend.Api;
|
|
|
|
/// <summary>
|
|
/// Api 层的入口:服务注册、中间件、端点映射。新增资源时在 <see cref="MapMonitorApi"/> 里加一行。
|
|
/// Api 只调用 Application;错误码到 HTTP 状态的映射只在这一层做一次(<see cref="ErrorCodeHttpMap"/>)。
|
|
/// </summary>
|
|
public static class ApiModule
|
|
{
|
|
public static IServiceCollection AddMonitorApi(this IServiceCollection services)
|
|
{
|
|
services.AddSingleton<SseLimiter>();
|
|
services.Configure<JsonOptions>(options =>
|
|
{
|
|
JsonSerializerOptions json = options.SerializerOptions;
|
|
json.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
|
json.DictionaryKeyPolicy = null;
|
|
json.DefaultIgnoreCondition = JsonIgnoreCondition.Never;
|
|
json.Converters.Add(new Rfc3339Converter());
|
|
json.TypeInfoResolverChain.Insert(0, ApiJsonContext.Default);
|
|
});
|
|
|
|
// 框架自己产生的错误(请求体格式不对、路由不存在、未处理的异常)也统一成 problem+json,并带上 code 和 trace_id
|
|
services.AddProblemDetails(options => options.CustomizeProblemDetails = context =>
|
|
{
|
|
int status = context.ProblemDetails.Status ?? context.HttpContext.Response.StatusCode;
|
|
context.ProblemDetails.Extensions["code"] = DefaultCode(status);
|
|
context.ProblemDetails.Extensions["trace_id"] = System.Diagnostics.Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
|
|
if (status >= 500)
|
|
{
|
|
context.ProblemDetails.Detail = null;
|
|
}
|
|
});
|
|
|
|
services.AddOpenApi("v1");
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 跨域(可选):只对 <c>/api/public</c> 和 <c>/api/admin</c> 生效,只放行名单里的来源,且不带凭据(Cookie 不会跨域),
|
|
/// 所以跨域的前端只能用 Bearer 令牌。需要放在认证和路由之前。
|
|
/// </summary>
|
|
public static IApplicationBuilder UseMonitorCors(this IApplicationBuilder app, string? origins)
|
|
{
|
|
string[] allowed = (origins ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
if (allowed.Length == 0)
|
|
{
|
|
return app;
|
|
}
|
|
|
|
bool any = allowed.Contains("*");
|
|
return app.Use(async (http, next) =>
|
|
{
|
|
string? origin = http.Request.Headers.Origin;
|
|
if (origin is null || !http.Request.Path.StartsWithSegments("/api") || !(any || allowed.Contains(origin.TrimEnd('/'), StringComparer.OrdinalIgnoreCase)))
|
|
{
|
|
await next(http);
|
|
return;
|
|
}
|
|
|
|
http.Response.Headers.AccessControlAllowOrigin = any ? "*" : origin;
|
|
http.Response.Headers.Vary = "Origin";
|
|
http.Response.Headers.AccessControlExposeHeaders = "Retry-After";
|
|
if (HttpMethods.IsOptions(http.Request.Method) && http.Request.Headers.ContainsKey("Access-Control-Request-Method"))
|
|
{
|
|
http.Response.Headers.AccessControlAllowMethods = "GET, POST, PATCH, PUT, DELETE, OPTIONS";
|
|
http.Response.Headers.AccessControlAllowHeaders = "Authorization, Content-Type, X-CSRF-Token";
|
|
http.Response.Headers.AccessControlMaxAge = "600";
|
|
http.Response.StatusCode = StatusCodes.Status204NoContent;
|
|
return;
|
|
}
|
|
|
|
await next(http);
|
|
});
|
|
}
|
|
|
|
/// <summary>认证中间件:解析 Bearer 令牌或会话 Cookie。需要放在路由之后、端点之前。</summary>
|
|
public static IApplicationBuilder UseMonitorApi(this IApplicationBuilder app)
|
|
{
|
|
app.UseExceptionHandler();
|
|
app.UseStatusCodePages();
|
|
app.Use(PrincipalResolver.ResolveAsync);
|
|
return app;
|
|
}
|
|
|
|
public static IEndpointRouteBuilder MapMonitorApi(this IEndpointRouteBuilder app)
|
|
{
|
|
RouteGroupBuilder publicGroup = app.MapGroup("/api/public").AddEndpointFilter(PublicGuard.InvokeAsync);
|
|
QueryEndpoints.Map(publicGroup);
|
|
LiveEndpoints.Map(publicGroup);
|
|
HostEndpoints.Map(publicGroup);
|
|
NodeEndpoints.Map(publicGroup);
|
|
CatalogEndpoints.Map(publicGroup);
|
|
|
|
RouteGroupBuilder admin = app.MapGroup("/api/admin");
|
|
AuthEndpoints.MapLogin(admin);
|
|
|
|
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(AdminGuard.InvokeAsync);
|
|
AuthEndpoints.MapSession(guarded);
|
|
AgentEndpoints.Map(guarded);
|
|
TaskEndpoints.Map(guarded);
|
|
DataEndpoints.Map(guarded);
|
|
SystemEndpoints.Map(guarded);
|
|
BuildEndpoints.Map(guarded);
|
|
|
|
OpsEndpoints.Map(app);
|
|
app.MapOpenApi("/openapi/v1.json");
|
|
return app;
|
|
}
|
|
|
|
private static string DefaultCode(int status) => status switch
|
|
{
|
|
400 => "validation_failed",
|
|
401 => "unauthorized",
|
|
403 => "forbidden",
|
|
404 => "not_found",
|
|
405 => "method_not_allowed",
|
|
413 => "payload_too_large",
|
|
415 => "unsupported_media_type",
|
|
429 => "too_many_requests",
|
|
503 => "storage_unavailable",
|
|
_ => "internal_error",
|
|
};
|
|
}
|