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
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user