feat(api): bind encrypted workspaces to accounts and devices

This commit is contained in:
justme committed 2026-10-13 01:38:19 +08:00
1 parent 019b15041a
commit c7d8ef15bc
49 files changed
+1904 -278

No files matched your search

+1 -1
View File
@@ -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";
}
+90
View File
@@ -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();
});
}
}
+90 -11
View File
@@ -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);
+2 -1
View File
@@ -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);
+28 -4
View File
@@ -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.&lt;role&gt;, wd.auth.&lt;b64url&gt;, wd.ep.&lt;b64url&gt;
/// </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);
+6 -4
View File
@@ -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;
}