feat(api): bind encrypted workspaces to accounts and devices
This commit is contained in:
1 parent
019b15041a
commit
c7d8ef15bc
49 files changed
+1904
-278
No files matched your search
@@ -3,5 +3,5 @@ namespace WebDrop.Api;
|
||||
public static class ApiContract
|
||||
{
|
||||
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
|
||||
public const string Version = "1.0.0";
|
||||
public const string Version = "2.1.0";
|
||||
}
|
||||
@@ -47,6 +47,80 @@ public sealed class LimitsBody
|
||||
[JsonPropertyName("max_ttl_seconds")] public required int MaxTtlSeconds { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AccountRegisterRequest
|
||||
{
|
||||
[JsonPropertyName("public_key")] public required string PublicKey { get; init; }
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("salt")] public required string Salt { get; init; }
|
||||
[JsonPropertyName("verifier")] public required string Verifier { get; init; }
|
||||
[JsonPropertyName("vault")] public required string Vault { get; init; }
|
||||
}
|
||||
public sealed class AccountSaltRequest { [JsonPropertyName("username")] public required string Username { get; init; } }
|
||||
public sealed class AccountSessionRequest
|
||||
{
|
||||
[JsonPropertyName("device_id")] public string? DeviceId { get; init; }
|
||||
[JsonPropertyName("device_name")] public string? DeviceName { get; init; }
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("verifier")] public required string Verifier { get; init; }
|
||||
}
|
||||
public sealed class AccountVaultRequest
|
||||
{
|
||||
[JsonPropertyName("revision")] public required long Revision { get; init; }
|
||||
[JsonPropertyName("vault")] public required string Vault { get; init; }
|
||||
}
|
||||
public sealed class AccountSaltBody
|
||||
{
|
||||
[JsonPropertyName("salt")] public required string Salt { get; init; }
|
||||
}
|
||||
public sealed class AccountSessionBody
|
||||
{
|
||||
[JsonPropertyName("token")] public required string Token { get; init; }
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
}
|
||||
public sealed class AccountVaultBody
|
||||
{
|
||||
[JsonPropertyName("revision")] public required long Revision { get; init; }
|
||||
[JsonPropertyName("vault")] public required string Vault { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PublicKeyBody { [JsonPropertyName("public_key")] public required string PublicKey { get; init; } }
|
||||
public sealed class InvitationRequest
|
||||
{
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("payload")] public required string Payload { get; init; }
|
||||
}
|
||||
public sealed class InvitationBody
|
||||
{
|
||||
[JsonPropertyName("channel_id")] public required string ChannelId { get; init; }
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("payload")] public required string Payload { get; init; }
|
||||
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
|
||||
}
|
||||
public sealed class JoinRequest { [JsonPropertyName("password")] public string? Password { get; init; } }
|
||||
public sealed class KeyPackageRequest { [JsonPropertyName("payload")] public required string Payload { get; init; } }
|
||||
public sealed class AccountDeviceNameRequest { [JsonPropertyName("name")] public required string Name { get; init; } }
|
||||
public sealed class AccountDeviceBody
|
||||
{
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("name")] public required string Name { get; init; }
|
||||
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
|
||||
[JsonPropertyName("last_seen")] public required long LastSeen { get; init; }
|
||||
[JsonPropertyName("current")] public required bool Current { get; init; }
|
||||
}
|
||||
public sealed class AccountChannelBody
|
||||
{
|
||||
[JsonPropertyName("channel_id")] public required string ChannelId { get; init; }
|
||||
[JsonPropertyName("role")] public required string Role { get; init; }
|
||||
[JsonPropertyName("payload")] public required string Payload { get; init; }
|
||||
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
|
||||
}
|
||||
public sealed class MemberBody
|
||||
{
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("role")] public required string Role { get; init; }
|
||||
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(ErrorBody))]
|
||||
[JsonSerializable(typeof(GoneLogBody))]
|
||||
[JsonSerializable(typeof(ServerInfoBody))]
|
||||
@@ -62,5 +136,21 @@ public sealed class LimitsBody
|
||||
[JsonSerializable(typeof(AppendResult))]
|
||||
[JsonSerializable(typeof(SiteverifyRequest))]
|
||||
[JsonSerializable(typeof(SiteverifyResponse))]
|
||||
[JsonSerializable(typeof(AccountRegisterRequest))]
|
||||
[JsonSerializable(typeof(AccountSaltRequest))]
|
||||
[JsonSerializable(typeof(AccountSessionRequest))]
|
||||
[JsonSerializable(typeof(AccountVaultRequest))]
|
||||
[JsonSerializable(typeof(AccountSaltBody))]
|
||||
[JsonSerializable(typeof(AccountSessionBody))]
|
||||
[JsonSerializable(typeof(AccountVaultBody))]
|
||||
[JsonSerializable(typeof(PublicKeyBody))]
|
||||
[JsonSerializable(typeof(InvitationRequest))]
|
||||
[JsonSerializable(typeof(List<InvitationBody>))]
|
||||
[JsonSerializable(typeof(JoinRequest))]
|
||||
[JsonSerializable(typeof(List<MemberBody>))]
|
||||
[JsonSerializable(typeof(KeyPackageRequest))]
|
||||
[JsonSerializable(typeof(List<AccountChannelBody>))]
|
||||
[JsonSerializable(typeof(AccountDeviceNameRequest))]
|
||||
[JsonSerializable(typeof(List<AccountDeviceBody>))]
|
||||
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public sealed partial class ApiJson : JsonSerializerContext;
|
||||
@@ -0,0 +1,91 @@
|
||||
using WebDrop.Core;
|
||||
using WebDrop.Storage;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public static partial class Endpoints
|
||||
{
|
||||
private static void MapAccounts(RouteGroupBuilder api, SqliteAccountStore accounts)
|
||||
{
|
||||
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);
|
||||
if (!accounts.Register(username, salt, verifier, vault, req.PublicKey)) 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.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);
|
||||
svc.ReleaseAccountDevice(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();
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using WebDrop.Core;
|
||||
using WebDrop.Storage;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public static partial class Endpoints
|
||||
{
|
||||
private static void MapChannelAccess(RouteGroupBuilder api, SqliteAccountStore accounts)
|
||||
{
|
||||
api.MapGet("/accounts/channels", (HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var packages = svc.AccountChannels(username).Select(k => new AccountChannelBody
|
||||
{
|
||||
ChannelId = k.ChannelId.ToBase64Url(), Role = k.Role, Payload = k.Payload, CreatedAt = k.CreatedAt,
|
||||
}).ToList();
|
||||
return Json(packages, ApiJson.Default.ListAccountChannelBody);
|
||||
});
|
||||
api.MapPut("/channels/{id}/key-package", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.KeyPackageRequest, ct);
|
||||
svc.SaveOwnerKeyPackage(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Payload);
|
||||
return Results.NoContent();
|
||||
});
|
||||
api.MapGet("/accounts/users/{username}/key", (string username, HttpContext ctx) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out _)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var key = accounts.GetPublicKey(NormalizeUsername(username));
|
||||
if (key is null) throw new WebDropException(ErrorCode.NotFound);
|
||||
return Json(new PublicKeyBody { PublicKey = key }, ApiJson.Default.PublicKeyBody);
|
||||
});
|
||||
api.MapGet("/accounts/invitations", (HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
return Json(InvitationBodies(svc.UserInvitations(username)), ApiJson.Default.ListInvitationBody);
|
||||
});
|
||||
api.MapGet("/channels/{id}/invitations", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
Json(InvitationBodies(svc.Invitations(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false))), ApiJson.Default.ListInvitationBody));
|
||||
api.MapGet("/channels/{id}/members", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
Json(svc.Members(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true)).Select(m => new MemberBody { Username = m.Username, Role = m.Role, JoinedAt = m.JoinedAt }).ToList(), ApiJson.Default.ListMemberBody));
|
||||
api.MapPost("/channels/{id}/invitations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.InvitationRequest, ct);
|
||||
var username = NormalizeUsername(req.Username);
|
||||
if (accounts.GetPublicKey(username) is null) throw new WebDropException(ErrorCode.NotFound);
|
||||
svc.InviteUser(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), username, req.Payload); return Results.NoContent();
|
||||
});
|
||||
api.MapDelete("/channels/{id}/invitations/{username}", (string id, string username, HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
svc.RevokeInvitation(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), NormalizeUsername(username)); return Results.NoContent();
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,18 @@ using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using WebDrop.Core;
|
||||
using WebDrop.Storage;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public static class Endpoints
|
||||
public static partial class Endpoints
|
||||
{
|
||||
public const string CreatePolicy = "create";
|
||||
private const string LastSeqHeader = "X-WD-Last-Seq";
|
||||
private const string OctetStream = "application/octet-stream";
|
||||
private static readonly TimeSpan MaxLongPoll = TimeSpan.FromSeconds(25);
|
||||
|
||||
public static void MapApi(this IEndpointRouteBuilder app, WebDropOptions options, ICaptchaVerifier captcha)
|
||||
public static void MapApi(this IEndpointRouteBuilder app, WebDropOptions options, ICaptchaVerifier captcha, SqliteAccountStore accounts)
|
||||
{
|
||||
var api = app.MapGroup("/api/v1");
|
||||
|
||||
@@ -37,15 +38,18 @@ public static class Endpoints
|
||||
},
|
||||
}, ApiJson.Default.ServerInfoBody));
|
||||
|
||||
MapAccounts(api, accounts);
|
||||
|
||||
// ───────── 频道 ─────────
|
||||
|
||||
api.MapPost("/channels", async (HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.CreateChannelRequest, ct);
|
||||
var endpoint = RequestAuth.ParseEndpointHash(ctx.Request.Headers[RequestAuth.EndpointHeader]);
|
||||
string token = ctx.Request.Headers[RequestAuth.CaptchaHeader].ToString();
|
||||
await captcha.VerifyAsync(token.Length > 0 ? token : null, ct);
|
||||
return Json(svc.CreateChannel(req, endpoint), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created);
|
||||
return Json(svc.CreateChannel(req, endpoint, username, accounts.SessionDevice(ctx.Request.Headers["X-WD-Account"].ToString())), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created);
|
||||
}).RequireRateLimiting(CreatePolicy);
|
||||
|
||||
api.MapGet("/channels/{id}", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
@@ -66,8 +70,13 @@ public static class Endpoints
|
||||
|
||||
// ───────── 席位 ─────────
|
||||
|
||||
api.MapPost("/channels/{id}/seats", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true)), ApiJson.Default.SeatJoined));
|
||||
api.MapPost("/channels/{id}/seats", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var request = ctx.Request.ContentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) == true ? await ReadJson(ctx.Request, ApiJson.Default.JoinRequest, ct) : new JoinRequest();
|
||||
return Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true), request.Password), ApiJson.Default.SeatJoined);
|
||||
});
|
||||
|
||||
MapChannelAccess(api, accounts);
|
||||
|
||||
api.MapGet("/channels/{id}/seats", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
@@ -83,6 +92,14 @@ public static class Endpoints
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
api.MapPut("/channels/{id}/seats/{endpointHash}/label", async (string id, string endpointHash, HttpContext ctx, ChannelService svc, ServerLimits limits, CancellationToken ct) =>
|
||||
{
|
||||
if (!Codec.TryDecodeExact(endpointHash, TokenHash.Size, out var hash)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
byte[] label = await ReadBody(ctx.Request, limits.MaxSeatLabelBytes, ct);
|
||||
svc.SetSeatLabelForEndpoint(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), hash, label);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
api.MapDelete("/channels/{id}/seats/{endpointHash}", (string id, string endpointHash, HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
if (!Codec.TryDecodeExact(endpointHash, TokenHash.Size, out var hash)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
@@ -97,10 +114,9 @@ public static class Endpoints
|
||||
var channelId = ParseId(id);
|
||||
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
|
||||
long? maxDownloads = ParseMaxDownloads(ctx.Request.Headers["X-WD-Max-Downloads"].ToString());
|
||||
string label = ctx.Request.Headers["X-WD-Label"].ToString();
|
||||
|
||||
byte[] body = await ReadBody(ctx.Request, Envelope.HeaderSize + limits.MaxPayloadBytes, ct);
|
||||
var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, label.Length > 0 ? label : null, ct);
|
||||
var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, ct);
|
||||
return Json(result, ApiJson.Default.AppendResult, result.Created ? StatusCodes.Status201Created : StatusCodes.Status200OK);
|
||||
});
|
||||
|
||||
@@ -131,7 +147,7 @@ public static class Endpoints
|
||||
throw new WebDropException(ErrorCode.BadRequest);
|
||||
|
||||
var channelId = ParseId(id);
|
||||
var creds = RequestAuth.FromSubprotocols(ctx.WebSockets.WebSocketRequestedProtocols);
|
||||
var creds = RequestAuth.FromSubprotocols(ctx.WebSockets.WebSocketRequestedProtocols, accounts);
|
||||
long from = QueryLong(ctx.Request, "from", 0);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted);
|
||||
@@ -143,7 +159,7 @@ public static class Endpoints
|
||||
ws = await ctx.WebSockets.AcceptWebSocketAsync("wd.v1");
|
||||
_ = WatchClose(ws, cts);
|
||||
return new WebSocketSink(ws);
|
||||
}, cts.Token);
|
||||
}, cts.Token, () => StreamAccountValid(ctx, accounts, creds));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -166,7 +182,7 @@ public static class Endpoints
|
||||
ctx.Response.Headers["X-Accel-Buffering"] = "no";
|
||||
await ctx.Response.StartAsync();
|
||||
return new SseSink(ctx.Response);
|
||||
}, ctx.RequestAborted);
|
||||
}, ctx.RequestAborted, () => StreamAccountValid(ctx, accounts, creds));
|
||||
return Results.Empty;
|
||||
});
|
||||
|
||||
@@ -211,11 +227,12 @@ public static class Endpoints
|
||||
|
||||
api.MapPost("/shares", async (HttpContext ctx, ShareService svc, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.CreateShareRequest, ct);
|
||||
string token = ctx.Request.Headers[RequestAuth.CaptchaHeader].ToString();
|
||||
await captcha.VerifyAsync(token.Length > 0 ? token : null, ct);
|
||||
byte[] endpoint = RequestAuth.ParseEndpointHashOptional(ctx.Request.Headers[RequestAuth.EndpointHeader]);
|
||||
return Json(svc.Create(req, endpoint.Length == 0 ? null : endpoint), ApiJson.Default.ShareInfo, StatusCodes.Status201Created);
|
||||
return Json(svc.Create(req, endpoint.Length == 0 ? null : endpoint, username), ApiJson.Default.ShareInfo, StatusCodes.Status201Created);
|
||||
}).RequireRateLimiting(CreatePolicy);
|
||||
|
||||
api.MapPatch("/shares/{id}/settings", async (string id, HttpContext ctx, ShareService svc, CancellationToken ct) =>
|
||||
@@ -287,6 +304,68 @@ public static class Endpoints
|
||||
private static IResult Json<T>(T value, JsonTypeInfo<T> info, int? status = null) =>
|
||||
Results.Json(value, info, statusCode: status);
|
||||
|
||||
private static bool TryAccount(HttpContext ctx, SqliteAccountStore accounts, out string username)
|
||||
{
|
||||
username = "";
|
||||
var accountToken = ctx.Request.Headers["X-WD-Account"].ToString();
|
||||
if (accountToken.Length > 0) return accounts.HasSession(accountToken, out username);
|
||||
var header = ctx.Request.Headers["Authorization"].ToString();
|
||||
return header.StartsWith("Bearer ", StringComparison.Ordinal) && accounts.HasSession(header[7..], out username);
|
||||
}
|
||||
|
||||
private static bool StreamAccountValid(HttpContext ctx, SqliteAccountStore accounts, Credentials creds)
|
||||
{
|
||||
if (creds.AccountName is null) return true;
|
||||
string token = ctx.Request.Headers["X-WD-Account"].ToString();
|
||||
if (ctx.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
var protocol = ctx.WebSockets.WebSocketRequestedProtocols.FirstOrDefault(p => p.StartsWith("wd.account.", StringComparison.Ordinal));
|
||||
if (protocol is null || !Codec.TryDecodeExact(protocol[11..], 32, out var raw)) return false;
|
||||
token = Convert.ToBase64String(raw);
|
||||
}
|
||||
return accounts.HasSession(token, out var username) && username == creds.AccountName;
|
||||
}
|
||||
|
||||
private static string NormalizeUsername(string? value)
|
||||
{
|
||||
if (value is null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var name = value.Trim().ToLowerInvariant();
|
||||
if (name.Length is < 3 or > 32 || name.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '_' or '-')))
|
||||
throw new WebDropException(ErrorCode.BadRequest);
|
||||
return name;
|
||||
}
|
||||
|
||||
private static List<InvitationBody> InvitationBodies(IReadOnlyList<ChannelInvitation> invitations) => invitations.Select(i => new InvitationBody
|
||||
{
|
||||
ChannelId = i.ChannelId.ToBase64Url(), Username = i.Username, Payload = i.Payload, CreatedAt = i.CreatedAt,
|
||||
}).ToList();
|
||||
|
||||
private static void ValidatePublicKey(string? value)
|
||||
{
|
||||
if (value is null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (value.Length > 1024) throw new WebDropException(ErrorCode.BadRequest);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(value); var root = doc.RootElement;
|
||||
if (root.GetProperty("kty").GetString() != "EC" || root.GetProperty("crv").GetString() != "P-256"
|
||||
|| !Codec.TryDecodeExact(root.GetProperty("x").GetString(), 32, out _)
|
||||
|| !Codec.TryDecodeExact(root.GetProperty("y").GetString(), 32, out _) || root.TryGetProperty("d", out _))
|
||||
throw new WebDropException(ErrorCode.BadRequest);
|
||||
}
|
||||
catch (Exception e) when (e is JsonException or KeyNotFoundException or InvalidOperationException) { throw new WebDropException(ErrorCode.BadRequest); }
|
||||
}
|
||||
|
||||
private static byte[] DecodeBase64(string? value, int minLength, int maxLength)
|
||||
{
|
||||
if (value is null || value.Length > ((maxLength + 2) / 3 * 4)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(value);
|
||||
return bytes.Length >= minLength && bytes.Length <= maxLength ? bytes : throw new WebDropException(ErrorCode.BadRequest);
|
||||
}
|
||||
catch (FormatException) { throw new WebDropException(ErrorCode.BadRequest); }
|
||||
}
|
||||
|
||||
private static Id16 ParseId(string text) =>
|
||||
Id16.TryParse(text, out var id) ? id : throw new WebDropException(ErrorCode.BadRequest);
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ public static class LogStreamer
|
||||
/// </summary>
|
||||
public static async Task RunAsync(
|
||||
ChannelService svc, ChannelHub hub, Id16 id, Credentials creds, long from,
|
||||
Func<Task<ILogSink>> open, CancellationToken ct)
|
||||
Func<Task<ILogSink>> open, CancellationToken ct, Func<bool>? validateAccount = null)
|
||||
{
|
||||
using var sub = hub.Subscribe(id);
|
||||
ILogSink? sink = null;
|
||||
@@ -36,6 +36,7 @@ public static class LogStreamer
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
if (validateAccount is not null && !validateAccount()) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var changed = sub.Changed; // 先取任务再读状态:读取期间的发布不会丢
|
||||
var page = await svc.ReadLogAsync(id, creds, cursor, PageSize, ct).ConfigureAwait(false);
|
||||
sink ??= await open().ConfigureAwait(false);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using WebDrop.Core;
|
||||
using WebDrop.Storage;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
@@ -23,28 +24,51 @@ public static class RequestAuth
|
||||
throw new WebDropException(ErrorCode.BadRequest);
|
||||
}
|
||||
|
||||
return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint));
|
||||
string? accountName = null;
|
||||
string? deviceId = null;
|
||||
var accountToken = req.Headers["X-WD-Account"].ToString();
|
||||
if (accountToken.Length > 0)
|
||||
{
|
||||
var accounts = req.HttpContext.RequestServices.GetRequiredService<SqliteAccountStore>();
|
||||
if (!accounts.HasSession(accountToken, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
accountName = username;
|
||||
deviceId = accounts.SessionDevice(accountToken);
|
||||
}
|
||||
if (role == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint), accountName, deviceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 浏览器的 WebSocket 不能设请求头,凭据放在子协议里:
|
||||
/// wd.v1, wd.role.<role>, wd.auth.<b64url>, wd.ep.<b64url>
|
||||
/// </summary>
|
||||
public static Credentials FromSubprotocols(IEnumerable<string> protocols)
|
||||
public static Credentials FromSubprotocols(IEnumerable<string> protocols, SqliteAccountStore? accounts = null)
|
||||
{
|
||||
string? role = null, auth = null, endpoint = null;
|
||||
string? role = null, auth = null, endpoint = null, accountToken = null;
|
||||
foreach (string p in protocols)
|
||||
{
|
||||
if (p.StartsWith("wd.role.", StringComparison.Ordinal)) role = p["wd.role.".Length..];
|
||||
else if (p.StartsWith("wd.auth.", StringComparison.Ordinal)) auth = p["wd.auth.".Length..];
|
||||
else if (p.StartsWith("wd.ep.", StringComparison.Ordinal)) endpoint = p["wd.ep.".Length..];
|
||||
else if (p.StartsWith("wd.account.", StringComparison.Ordinal)) accountToken = p["wd.account.".Length..];
|
||||
}
|
||||
|
||||
byte[]? token = null;
|
||||
if (auth is not null && !Codec.TryDecodeExact(auth, TokenHash.Size, out token))
|
||||
throw new WebDropException(ErrorCode.BadRequest);
|
||||
|
||||
return new Credentials(ParseRole(role), token, ParseEndpoint(endpoint, requireEndpoint: true));
|
||||
string? accountName = null;
|
||||
string? deviceId = null;
|
||||
if (accountToken is not null)
|
||||
{
|
||||
if (accounts is null || !Codec.TryDecodeExact(accountToken, 32, out var raw)
|
||||
|| !accounts.HasSession(Convert.ToBase64String(raw), out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
accountName = username;
|
||||
deviceId = accounts.SessionDevice(Convert.ToBase64String(raw));
|
||||
}
|
||||
var parsedRole = ParseRole(role);
|
||||
if (parsedRole == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: true), accountName, deviceId);
|
||||
}
|
||||
|
||||
public static byte[] ParseEndpointHash(string? header) => ParseEndpoint(header, requireEndpoint: true);
|
||||
|
||||
@@ -24,8 +24,9 @@ public static class WebDropApp
|
||||
builder.Services.AddSingleton(options);
|
||||
builder.Services.AddSingleton(options.Limits);
|
||||
builder.Services.AddSingleton<IClock, SystemClock>();
|
||||
builder.Services.AddSingleton<IBlobStore>(_ => new DiskBlobStore(Path.Combine(options.DataDir, "blobs")));
|
||||
builder.Services.AddSingleton<SqliteChannelStore>(_ => new SqliteChannelStore(Path.Combine(options.DataDir, "webdrop.db")));
|
||||
builder.Services.AddSingleton<IBlobStore>(_ => new DiskBlobStore(Path.Combine(options.DataDir, "v2", "blobs")));
|
||||
builder.Services.AddSingleton<SqliteChannelStore>(_ => new SqliteChannelStore(Path.Combine(options.DataDir, "v2", "webdrop.db")));
|
||||
builder.Services.AddSingleton<SqliteAccountStore>(_ => new SqliteAccountStore(Path.Combine(options.DataDir, "v2", "webdrop.db")));
|
||||
builder.Services.AddSingleton<IResourceStore>(sp => sp.GetRequiredService<SqliteChannelStore>());
|
||||
builder.Services.AddSingleton<ICaptchaVerifier>(sp => CreateCaptchaVerifier(options.Captcha, sp));
|
||||
builder.Services.AddSingleton<ChannelHub>();
|
||||
@@ -42,7 +43,7 @@ public static class WebDropApp
|
||||
.WithOrigins(options.CorsOrigins)
|
||||
.AllowAnyMethod()
|
||||
.WithHeaders("Authorization", "Content-Type", "Last-Event-ID",
|
||||
"X-WD-Role", "X-WD-Endpoint", "X-WD-Cap", "X-WD-Label", "X-WD-Max-Downloads")
|
||||
"X-WD-Role", "X-WD-Endpoint", "X-WD-Cap", "X-WD-Max-Downloads", "X-WD-Account")
|
||||
.WithExposedHeaders("X-WD-Last-Seq")
|
||||
.SetPreflightMaxAge(TimeSpan.FromHours(1))));
|
||||
}
|
||||
@@ -64,6 +65,7 @@ public static class WebDropApp
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
_ = app.Services.GetRequiredService<SqliteChannelStore>();
|
||||
|
||||
if (options.TrustForwardedHeaders)
|
||||
{
|
||||
@@ -82,7 +84,7 @@ public static class WebDropApp
|
||||
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = LogStreamer.Heartbeat });
|
||||
app.UseRateLimiter();
|
||||
EmbeddedFrontend.Use(app, options.Captcha.ApiEndpoint);
|
||||
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>());
|
||||
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>(), app.Services.GetRequiredService<SqliteAccountStore>());
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user