Files
MoShiro/backend/src/MoShiro.Backend.Api/ApiModule.cs
T
justme 0f8f3f9a54
ci/woodpecker/push/main-amd64-build Pipeline was successful
feat: trusted release keys setting; drop the duplicate release registration route [CI]
The panel now lets the deployer decide which release keys it trusts. The
official CI key is built in and always trusted; an own P-256 public key can be
added (off by default, the private key is rejected) so manifests signed with it
are accepted too. Removing it drops the manifests only it signed. Changes are
audited and need update.confirm.

Admin UI: System > Trust > "Trusted release keys" with a collapsible guide on
generating a key pair and signing a manifest with openssl.

Registration now has one path, POST /api/release/offer (no token, signature
verified). The admin POST /releases route, the release.publish capability and
the ci-release preset are removed.
2026-10-08 18:50:16 +08:00

141 lines
5.8 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 MoShiro.Backend.Application;
namespace MoShiro.Backend.Api;
/// <summary>
/// Api 层的入口:服务注册、中间件、端点映射。新增资源时在 <see cref="MapMoShiroApi"/> 里加一行。
/// Api 只调用 Application;错误码到 HTTP 状态的映射只在这一层做一次(<see cref="ErrorCodeHttpMap"/>)。
/// </summary>
public static class ApiModule
{
public static IServiceCollection AddMoShiroApi(this IServiceCollection services)
{
services.AddSingleton<SseLimiter>();
services.AddSingleton<MoShiro.Backend.Application.IReleaseKey, EmbeddedReleaseKey>();
services.AddSingleton<ClientAddressResolver>();
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 UseMoShiroCors(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 UseMoShiroApi(this IApplicationBuilder app)
{
app.UseExceptionHandler();
app.UseStatusCodePages();
app.Use(PrincipalResolver.ResolveAsync);
return app;
}
public static IEndpointRouteBuilder MapMoShiroApi(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(CapabilityGuard.InvokeAsync);
AuthEndpoints.MapSession(guarded);
AgentEndpoints.Map(guarded);
TaskEndpoints.Map(guarded);
DataEndpoints.Map(guarded);
AuditEndpoints.Map(guarded);
TrustEndpoints.Map(guarded);
SystemEndpoints.Map(guarded);
BuildEndpoints.Map(guarded);
ReleaseKeyEndpoints.Map(guarded);
LinkEndpoints.Map(guarded);
OpsEndpoints.Map(app);
BuildEndpoints.MapOffer(app);
app.MapOpenApi("/openapi/v1.json");
app.MapStaticSite();
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",
};
}