using Microsoft.AspNetCore.Http.Features; using WebDrop.Core; using WebDrop.Storage; namespace WebDrop.Api; public static partial class Endpoints { private static void MapAccounts(RouteGroupBuilder api, SqliteAccountStore accounts) { api.MapGet("/accounts/profile", (HttpContext ctx) => { if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); return Json(accounts.Profile(username), ApiJson.Default.AccountProfile); }); api.MapPatch("/accounts/profile", async (HttpContext ctx, ChannelService svc, CancellationToken ct) => { if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); // 动图头像比其他请求大得多,单独放宽(全局上限 2 MB) if (ctx.Features.Get() is { IsReadOnly: false } size) size.MaxRequestBodySize = 8 * 1024 * 1024; var req = await ReadJson(ctx.Request, ApiJson.Default.AccountProfileRequest, ct); var profile = accounts.UpdateProfile(username, req.DisplayName, req.Avatar); svc.AccountProfileChanged(username); return Json(profile, ApiJson.Default.AccountProfile); }); api.MapPost("/accounts", async (HttpContext ctx, CancellationToken ct) => { var req = await ReadJson(ctx.Request, ApiJson.Default.AccountRegisterRequest, ct); var username = NormalizeUsername(req.Username); var salt = DecodeBase64(req.Salt, 16, 16); var verifier = DecodeBase64(req.Verifier, 32, 32); var vault = DecodeBase64(req.Vault, 28, 1_300_000); ValidatePublicKey(req.PublicKey); ValidatePublicKey(req.SigningPublicKey); if (!accounts.Register(username, salt, verifier, vault, req.PublicKey, req.SigningPublicKey)) return Json(new ErrorBody { Error = "username_unavailable" }, ApiJson.Default.ErrorBody, StatusCodes.Status409Conflict); return Results.NoContent(); }).RequireRateLimiting(CreatePolicy); api.MapPost("/accounts/salt", async (HttpContext ctx, CancellationToken ct) => { var req = await ReadJson(ctx.Request, ApiJson.Default.AccountSaltRequest, ct); var username = NormalizeUsername(req.Username); var salt = accounts.LoginSalt(username); return Json(new AccountSaltBody { Salt = Convert.ToBase64String(salt) }, ApiJson.Default.AccountSaltBody); }).RequireRateLimiting(CreatePolicy); api.MapPost("/accounts/session", async (HttpContext ctx, CancellationToken ct) => { var req = await ReadJson(ctx.Request, ApiJson.Default.AccountSessionRequest, ct); var username = NormalizeUsername(req.Username); var verifier = DecodeBase64(req.Verifier, 32, 32); var token = accounts.Authenticate(username, verifier, req.DeviceId, req.DeviceName); if (token is null) return Json(new ErrorBody { Error = "account_invalid" }, ApiJson.Default.ErrorBody, StatusCodes.Status401Unauthorized); return Json(new AccountSessionBody { Token = token, Username = username }, ApiJson.Default.AccountSessionBody); }).RequireRateLimiting(CreatePolicy); api.MapGet("/accounts/vault", (HttpContext ctx) => { if (!TryAccount(ctx, accounts, out var username)) return Json(new ErrorBody { Error = "account_invalid" }, ApiJson.Default.ErrorBody, StatusCodes.Status401Unauthorized); var vault = accounts.GetVault(username); return vault is null ? Results.NotFound() : Json(new AccountVaultBody { Revision = vault.Value.Revision, Vault = Convert.ToBase64String(vault.Value.Vault) }, ApiJson.Default.AccountVaultBody); }); api.MapGet("/accounts/devices", (HttpContext ctx) => { if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); var current = accounts.SessionDevice(ctx.Request.Headers["X-WD-Account"].ToString()); var devices = accounts.Devices(username, current).Select(d => new AccountDeviceBody { Id = d.Id, Name = d.Name, CreatedAt = d.CreatedAt, LastSeen = d.LastSeen, Current = d.Current }).ToList(); return Json(devices, ApiJson.Default.ListAccountDeviceBody); }); api.MapPatch("/accounts/devices/{id}", async (string id, HttpContext ctx, CancellationToken ct) => { if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); var req = await ReadJson(ctx.Request, ApiJson.Default.AccountDeviceNameRequest, ct); if (req.Name is null || !accounts.RenameDevice(username, id, req.Name.Trim())) throw new WebDropException(ErrorCode.BadRequest); return Results.NoContent(); }); api.MapPut("/accounts/devices/signing-key", async (HttpContext ctx, SqliteAccountStore accountStore, CancellationToken ct) => { if (!TryAccount(ctx, accountStore, out var username)) throw new WebDropException(ErrorCode.AccountRequired); var req = await ReadJson(ctx.Request, ApiJson.Default.AccountDeviceSigningRequest, ct); var token = ctx.Request.Headers["X-WD-Account"].ToString(); var deviceId = accountStore.SessionDevice(token); if (deviceId is null || !string.Equals(deviceId, req.DeviceId, StringComparison.OrdinalIgnoreCase)) throw new WebDropException(ErrorCode.Forbidden); ValidatePublicKey(req.PublicKey); var rootKey = accountStore.GetSigningPublicKey(username); if (rootKey is null) throw new WebDropException(ErrorCode.BadRequest); using var root = System.Text.Json.JsonDocument.Parse(rootKey); var rootObject = root.RootElement; if (!Codec.TryDecodeExact(rootObject.GetProperty("x").GetString(), 32, out var rootX) || !Codec.TryDecodeExact(rootObject.GetProperty("y").GetString(), 32, out var rootY)) throw new WebDropException(ErrorCode.BadRequest); using var device = System.Text.Json.JsonDocument.Parse(req.PublicKey); var deviceObject = device.RootElement; if (!Codec.TryDecodeExact(deviceObject.GetProperty("x").GetString(), 32, out var deviceX) || !Codec.TryDecodeExact(deviceObject.GetProperty("y").GetString(), 32, out var deviceY)) throw new WebDropException(ErrorCode.BadRequest); var payload = SignatureCodec.DeviceCertificatePayload(username, deviceId, deviceX, deviceY); var signature = DecodeBase64(req.Signature, 64, 64); if (!SignatureCodec.VerifyP256(rootX, rootY, "device.certificate", payload, signature)) throw new WebDropException(ErrorCode.Forbidden); if (!accountStore.SaveDeviceSigningKey(username, deviceId, req.PublicKey, signature)) throw new WebDropException(ErrorCode.Forbidden); return Results.NoContent(); }); api.MapDelete("/accounts/devices/{id}", (string id, HttpContext ctx, ChannelService svc) => { if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); accounts.RevokeDevice(username, id); return Results.NoContent(); }); api.MapPut("/accounts/vault", async (HttpContext ctx, CancellationToken ct) => { if (!TryAccount(ctx, accounts, out var username)) return Json(new ErrorBody { Error = "account_invalid" }, ApiJson.Default.ErrorBody, StatusCodes.Status401Unauthorized); var req = await ReadJson(ctx.Request, ApiJson.Default.AccountVaultRequest, ct); if (req.Revision < 0) return Results.BadRequest(); var vault = DecodeBase64(req.Vault, 28, 1_300_000); var next = accounts.PutVault(username, vault, req.Revision); return next is null ? Json(new ErrorBody { Error = "vault_conflict" }, ApiJson.Default.ErrorBody, StatusCodes.Status409Conflict) : Json(new AccountVaultBody { Revision = next.Value, Vault = req.Vault }, ApiJson.Default.AccountVaultBody); }); api.MapDelete("/accounts/session", (HttpContext ctx) => { var accountToken = ctx.Request.Headers["X-WD-Account"].ToString(); if (accountToken.Length > 0) { accounts.RevokeSession(accountToken); return Results.NoContent(); } var header = ctx.Request.Headers["Authorization"].ToString(); if (!header.StartsWith("Bearer ", StringComparison.Ordinal)) return Results.NoContent(); accounts.RevokeSession(header[7..]); return Results.NoContent(); }); } }