feat(api): add account workspaces and signed content events
This commit is contained in:
1 parent
937b774a93
commit
7b8d61d1ba
63 files changed
+5407
-370
No files matched your search
@@ -2,6 +2,7 @@ namespace WebDrop.Api;
|
||||
|
||||
public static class ApiContract
|
||||
{
|
||||
// v5 changes channel capacity from per-endpoint devices to account members.
|
||||
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
|
||||
public const string Version = "2.1.0";
|
||||
public const string Version = "6.0.0";
|
||||
}
|
||||
@@ -21,6 +21,22 @@ public sealed class ServerInfoBody
|
||||
[JsonPropertyName("captcha")] public required CaptchaBody Captcha { get; init; }
|
||||
[JsonPropertyName("transports")] public required TransportsBody Transports { get; init; }
|
||||
[JsonPropertyName("limits")] public required LimitsBody Limits { get; init; }
|
||||
[JsonPropertyName("defaults")] public required DefaultsBody Defaults { get; init; }
|
||||
[JsonPropertyName("choices")] public required SettingChoicesBody Choices { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DefaultsBody
|
||||
{
|
||||
[JsonPropertyName("channel_mode")] public ChannelMode ChannelMode { get; init; } = CreationDefaults.ChannelMode;
|
||||
[JsonPropertyName("channel")] public required ChannelSettings Channel { get; init; }
|
||||
[JsonPropertyName("share")] public required ShareSettings Share { get; init; }
|
||||
}
|
||||
public sealed class SettingChoicesBody
|
||||
{
|
||||
[JsonPropertyName("seats")] public required int[] Seats { get; init; }
|
||||
[JsonPropertyName("ttl_seconds")] public required int[] TtlSeconds { get; init; }
|
||||
[JsonPropertyName("file_max_downloads")] public int[] FileMaxDownloads { get; init; } = ServerSettingChoices.FileMaxDownloads;
|
||||
[JsonPropertyName("max_visitors")] public required int[] MaxVisitors { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>告诉前端要不要出示验证码、widget 该连哪里(Cap 的 data-cap-api-endpoint)。</summary>
|
||||
@@ -45,11 +61,15 @@ public sealed class LimitsBody
|
||||
[JsonPropertyName("max_channel_bytes")] public required long MaxChannelBytes { get; init; }
|
||||
[JsonPropertyName("max_seats")] public required int MaxSeats { get; init; }
|
||||
[JsonPropertyName("max_ttl_seconds")] public required int MaxTtlSeconds { get; init; }
|
||||
[JsonPropertyName("min_ttl_seconds")] public required int MinTtlSeconds { get; init; }
|
||||
[JsonPropertyName("max_items")] public required int MaxItems { get; init; }
|
||||
[JsonPropertyName("max_visitors")] public required int MaxVisitors { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AccountRegisterRequest
|
||||
{
|
||||
[JsonPropertyName("public_key")] public required string PublicKey { get; init; }
|
||||
[JsonPropertyName("signing_public_key")] public required string SigningPublicKey { 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; }
|
||||
@@ -83,11 +103,16 @@ public sealed class AccountVaultBody
|
||||
[JsonPropertyName("vault")] public required string Vault { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PublicKeyBody { [JsonPropertyName("public_key")] public required string PublicKey { get; init; } }
|
||||
public sealed class PublicKeyBody
|
||||
{
|
||||
[JsonPropertyName("public_key")] public required string PublicKey { get; init; }
|
||||
[JsonPropertyName("signing_public_key")] public string? SigningPublicKey { get; init; }
|
||||
}
|
||||
public sealed class InvitationRequest
|
||||
{
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("payload")] public required string Payload { get; init; }
|
||||
[JsonPropertyName("domain_envelopes")] public List<DomainEnvelope>? DomainEnvelopes { get; init; }
|
||||
}
|
||||
public sealed class InvitationBody
|
||||
{
|
||||
@@ -99,6 +124,12 @@ public sealed class InvitationBody
|
||||
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 AccountDeviceSigningRequest
|
||||
{
|
||||
[JsonPropertyName("device_id")] public required string DeviceId { get; init; }
|
||||
[JsonPropertyName("public_key")] public required string PublicKey { get; init; }
|
||||
[JsonPropertyName("signature")] public required string Signature { get; init; }
|
||||
}
|
||||
public sealed class AccountDeviceBody
|
||||
{
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
@@ -121,10 +152,73 @@ public sealed class MemberBody
|
||||
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AccountProfileRequest
|
||||
{
|
||||
[JsonPropertyName("displayName")] public required string DisplayName { get; init; }
|
||||
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceNameRequest { [JsonPropertyName("name")] public required string Name { get; init; } }
|
||||
public sealed class WorkspaceChannelRequest
|
||||
{
|
||||
[JsonPropertyName("name")] public required string Name { get; init; }
|
||||
[JsonPropertyName("groupId")] public string? GroupId { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceGroupRequest { [JsonPropertyName("groupId")] public string? GroupId { get; init; } }
|
||||
public sealed class WorkspaceFolderRequest
|
||||
{
|
||||
[JsonPropertyName("name")] public required string Name { get; init; }
|
||||
[JsonPropertyName("threadId")] public required string ThreadId { get; init; }
|
||||
[JsonPropertyName("parentId")] public string? ParentId { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceMoveRequest
|
||||
{
|
||||
[JsonPropertyName("itemIds")] public required string[] ItemIds { get; init; }
|
||||
[JsonPropertyName("threadId")] public required string ThreadId { get; init; }
|
||||
[JsonPropertyName("folderId")] public string? FolderId { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceBody
|
||||
{
|
||||
[JsonPropertyName("structure")] public required WorkspaceState Structure { get; init; }
|
||||
[JsonPropertyName("people")] public required List<AccountProfile> People { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceChannelCreatedBody
|
||||
{
|
||||
[JsonPropertyName("workspace")] public required WorkspaceBody Workspace { get; init; }
|
||||
[JsonPropertyName("channel")] public required WorkspaceChannel Channel { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceGroupCreatedBody
|
||||
{
|
||||
[JsonPropertyName("workspace")] public required WorkspaceBody Workspace { get; init; }
|
||||
[JsonPropertyName("group")] public required WorkspaceGroup Group { get; init; }
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(ErrorBody))]
|
||||
[JsonSerializable(typeof(CryptoState))]
|
||||
[JsonSerializable(typeof(DomainPublishRequest))]
|
||||
[JsonSerializable(typeof(ObjectEnvelope))]
|
||||
[JsonSerializable(typeof(ChannelHistoryRequest))]
|
||||
[JsonSerializable(typeof(PasskeyBeginRequest))]
|
||||
[JsonSerializable(typeof(PasskeyOptionsBody))]
|
||||
[JsonSerializable(typeof(PasskeyFinishRequest))]
|
||||
[JsonSerializable(typeof(PasskeyInfoBody))]
|
||||
[JsonSerializable(typeof(List<PasskeyInfoBody>))]
|
||||
[JsonSerializable(typeof(PasskeySessionBody))]
|
||||
[JsonSerializable(typeof(PasskeyEnvelopeRequest))]
|
||||
[JsonSerializable(typeof(AccountProfile))]
|
||||
[JsonSerializable(typeof(AccountProfileRequest))]
|
||||
[JsonSerializable(typeof(WorkspaceState))]
|
||||
[JsonSerializable(typeof(WorkspaceBody))]
|
||||
[JsonSerializable(typeof(WorkspaceChannelCreatedBody))]
|
||||
[JsonSerializable(typeof(WorkspaceGroupCreatedBody))]
|
||||
[JsonSerializable(typeof(WorkspaceNameRequest))]
|
||||
[JsonSerializable(typeof(WorkspaceChannelRequest))]
|
||||
[JsonSerializable(typeof(WorkspaceGroupRequest))]
|
||||
[JsonSerializable(typeof(WorkspaceFolderRequest))]
|
||||
[JsonSerializable(typeof(WorkspaceMoveRequest))]
|
||||
[JsonSerializable(typeof(GoneLogBody))]
|
||||
[JsonSerializable(typeof(ServerInfoBody))]
|
||||
[JsonSerializable(typeof(ChannelInfo))]
|
||||
[JsonSerializable(typeof(List<ChannelInfo>))]
|
||||
[JsonSerializable(typeof(ShareInfo))]
|
||||
[JsonSerializable(typeof(CreateShareRequest))]
|
||||
[JsonSerializable(typeof(ShareSettingsPatch))]
|
||||
@@ -134,9 +228,8 @@ public sealed class MemberBody
|
||||
[JsonSerializable(typeof(SeatJoined))]
|
||||
[JsonSerializable(typeof(List<SeatInfo>))]
|
||||
[JsonSerializable(typeof(AppendResult))]
|
||||
[JsonSerializable(typeof(SiteverifyRequest))]
|
||||
[JsonSerializable(typeof(SiteverifyResponse))]
|
||||
[JsonSerializable(typeof(AccountRegisterRequest))]
|
||||
[JsonSerializable(typeof(AccountDeviceSigningRequest))]
|
||||
[JsonSerializable(typeof(AccountSaltRequest))]
|
||||
[JsonSerializable(typeof(AccountSessionRequest))]
|
||||
[JsonSerializable(typeof(AccountVaultRequest))]
|
||||
@@ -145,6 +238,7 @@ public sealed class MemberBody
|
||||
[JsonSerializable(typeof(AccountVaultBody))]
|
||||
[JsonSerializable(typeof(PublicKeyBody))]
|
||||
[JsonSerializable(typeof(InvitationRequest))]
|
||||
[JsonSerializable(typeof(InvitationCryptoPlan))]
|
||||
[JsonSerializable(typeof(List<InvitationBody>))]
|
||||
[JsonSerializable(typeof(JoinRequest))]
|
||||
[JsonSerializable(typeof(List<MemberBody>))]
|
||||
|
||||
@@ -16,6 +16,11 @@ public sealed class SiteverifyResponse
|
||||
[JsonPropertyName("success")] public bool Success { get; init; }
|
||||
}
|
||||
|
||||
// Backend-to-Cap DTOs are not part of the browser API contract.
|
||||
[JsonSerializable(typeof(SiteverifyRequest))]
|
||||
[JsonSerializable(typeof(SiteverifyResponse))]
|
||||
public sealed partial class CaptchaJson : JsonSerializerContext;
|
||||
|
||||
/// <summary>
|
||||
/// 用 Cap(https://github.com/tiagozip/cap)校验创建频道的验证码。
|
||||
/// 工作量证明的出题、求解、验证全部由 Cap 完成,本项目不实现任何 PoW。
|
||||
@@ -36,7 +41,7 @@ public sealed class CapCaptchaVerifier(HttpClient http, CaptchaOptions options,
|
||||
throw new WebDropException(ErrorCode.CaptchaRequired);
|
||||
|
||||
var body = JsonSerializer.Serialize(
|
||||
new SiteverifyRequest { Secret = options.Secret, Response = token }, ApiJson.Default.SiteverifyRequest);
|
||||
new SiteverifyRequest { Secret = options.Secret, Response = token }, CaptchaJson.Default.SiteverifyRequest);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -55,7 +60,7 @@ public sealed class CapCaptchaVerifier(HttpClient http, CaptchaOptions options,
|
||||
|
||||
var result = await JsonSerializer.DeserializeAsync(
|
||||
await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false),
|
||||
ApiJson.Default.SiteverifyResponse, ct).ConfigureAwait(false);
|
||||
CaptchaJson.Default.SiteverifyResponse, ct).ConfigureAwait(false);
|
||||
|
||||
if (result is not { Success: true }) throw new WebDropException(ErrorCode.CaptchaRequired);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,19 @@ 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);
|
||||
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);
|
||||
@@ -15,7 +28,8 @@ public static partial class Endpoints
|
||||
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);
|
||||
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);
|
||||
|
||||
@@ -59,6 +73,30 @@ public static partial class Endpoints
|
||||
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);
|
||||
|
||||
@@ -7,6 +7,11 @@ public static partial class Endpoints
|
||||
{
|
||||
private static void MapChannelAccess(RouteGroupBuilder api, SqliteAccountStore accounts)
|
||||
{
|
||||
api.MapGet("/accounts/spaces", (HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
return Json(svc.AccountSpaces(username).ToList(), ApiJson.Default.ListChannelInfo);
|
||||
});
|
||||
api.MapGet("/accounts/channels", (HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
@@ -27,7 +32,7 @@ public static partial class Endpoints
|
||||
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);
|
||||
return Json(new PublicKeyBody { PublicKey = key, SigningPublicKey = accounts.GetSigningPublicKey(NormalizeUsername(username)) }, ApiJson.Default.PublicKeyBody);
|
||||
});
|
||||
api.MapGet("/accounts/invitations", (HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
@@ -36,6 +41,12 @@ public static partial class Endpoints
|
||||
});
|
||||
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}/invitations/{username}/crypto-plan", (string id, string username, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore) =>
|
||||
{
|
||||
username = NormalizeUsername(username);
|
||||
if (accountStore.GetPublicKey(username) is null) throw new WebDropException(ErrorCode.NotFound);
|
||||
return Json(svc.InvitationPlan(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), username), ApiJson.Default.InvitationCryptoPlan);
|
||||
});
|
||||
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) =>
|
||||
@@ -43,7 +54,7 @@ public static partial class Endpoints
|
||||
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();
|
||||
svc.InviteUser(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), username, req.Payload, req.DomainEnvelopes ?? []); return Results.NoContent();
|
||||
});
|
||||
api.MapDelete("/channels/{id}/invitations/{username}", (string id, string username, HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using WebDrop.Core;
|
||||
using WebDrop.Storage;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public static partial class Endpoints
|
||||
{
|
||||
private static PasskeyInfoBody PasskeyInfo(StoredPasskey p) => new() { Id = p.Id, Name = p.Name, PrfSalt = p.PrfSalt, CanRecover = p.KeyEnvelope is not null, CreatedAt = p.CreatedAt };
|
||||
private static void ValidateRecoveryEnvelope(string? value)
|
||||
{
|
||||
if (value is not null) _ = DecodeBase64(value, 28, 512);
|
||||
}
|
||||
private static void MapPasskeys(RouteGroupBuilder api, SqliteAccountStore accounts)
|
||||
{
|
||||
api.MapGet("/accounts/passkeys", (HttpContext ctx) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
return Json(accounts.Passkeys(username).Select(PasskeyInfo).ToList(), ApiJson.Default.ListPasskeyInfoBody);
|
||||
});
|
||||
api.MapDelete("/accounts/passkeys/{id}", (string id, HttpContext ctx) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
accounts.RemovePasskey(id, username); return Results.NoContent();
|
||||
});
|
||||
api.MapPost("/accounts/passkeys/register/options", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, PasskeyCeremonies ceremonies, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.PasskeyBeginRequest, ct);
|
||||
if (NormalizeUsername(req.Username) != username || req.Verifier is null || !accounts.VerifyLogin(username, DecodeBase64(req.Verifier, 32, 32))) throw new WebDropException(ErrorCode.Unauthorized);
|
||||
var result = await handler.MakeCreationOptionsAsync(new() { Id = username, Name = username, DisplayName = accounts.Profile(username).DisplayName }, ctx);
|
||||
var salt = Codec.Encode(RandomNumberGenerator.GetBytes(32));
|
||||
var id = ceremonies.Put(new(username, "register", result.AttestationState ?? throw new WebDropException(ErrorCode.BadRequest), salt, PasskeyCeremonies.Hash(ctx.Request.Headers["X-WD-Account"].ToString()), DateTimeOffset.UtcNow.AddMinutes(2)));
|
||||
return Json(new PasskeyOptionsBody { Id = id, Options = result.CreationOptionsJson, PrfSalt = salt }, ApiJson.Default.PasskeyOptionsBody);
|
||||
}).RequireRateLimiting(CreatePolicy);
|
||||
api.MapPost("/accounts/passkeys/register", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, PasskeyCeremonies ceremonies, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.PasskeyFinishRequest, ct);
|
||||
var state = ceremonies.Take(req.Id, username, "register", ctx.Request.Headers["X-WD-Account"].ToString());
|
||||
if (NormalizeUsername(req.Username) != username || req.Credential.Length > 40_000 || req.Name is null || req.Name.Trim().Length is < 1 or > 80 || req.Name.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
ValidateRecoveryEnvelope(req.KeyEnvelope);
|
||||
var result = await handler.PerformAttestationAsync(new() { HttpContext = ctx, CredentialJson = req.Credential, AttestationState = state.State });
|
||||
if (!result.Succeeded || result.UserEntity?.Id != username || result.Passkey is null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
result.Passkey.Name = req.Name.Trim();
|
||||
var row = new StoredPasskey(Codec.Encode(result.Passkey.CredentialId), username, result.Passkey.Name,
|
||||
JsonSerializer.Serialize(result.Passkey, PasskeyStorageJson.Default.UserPasskeyInfo), state.PrfSalt, req.KeyEnvelope, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
accounts.AddPasskey(row); return Json(PasskeyInfo(row), ApiJson.Default.PasskeyInfoBody, 201);
|
||||
}).RequireRateLimiting(CreatePolicy);
|
||||
api.MapPost("/accounts/passkeys/login/options", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, PasskeyCeremonies ceremonies, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountSaltRequest, ct); var username = NormalizeUsername(req.Username);
|
||||
var options = await handler.MakeRequestOptionsAsync(new PasskeyAccount(username), ctx);
|
||||
var id = ceremonies.Put(new(username, "login", options.AssertionState ?? throw new WebDropException(ErrorCode.BadRequest), "", null, DateTimeOffset.UtcNow.AddMinutes(2)));
|
||||
return Json(new PasskeyOptionsBody { Id = id, Options = options.RequestOptionsJson, Credentials = accounts.Passkeys(username).Select(PasskeyInfo).ToList() }, ApiJson.Default.PasskeyOptionsBody);
|
||||
}).RequireRateLimiting(CreatePolicy);
|
||||
api.MapPost("/accounts/passkeys/login", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, UserManager<PasskeyAccount> manager, PasskeyCeremonies ceremonies, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.PasskeyFinishRequest, ct); var username = NormalizeUsername(req.Username);
|
||||
var state = ceremonies.Take(req.Id, username, "login", null);
|
||||
if (req.Credential.Length > 40_000 || req.DeviceId is null || req.DeviceName is null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var result = await handler.PerformAssertionAsync(new() { HttpContext = ctx, CredentialJson = req.Credential, AssertionState = state.State });
|
||||
if (!result.Succeeded || result.User?.Username != username || result.Passkey is null) throw new WebDropException(ErrorCode.Unauthorized);
|
||||
var id = Codec.Encode(result.Passkey.CredentialId); var row = accounts.Passkey(id) ?? throw new WebDropException(ErrorCode.Unauthorized);
|
||||
var updated = await manager.AddOrUpdatePasskeyAsync(result.User, result.Passkey);
|
||||
if (!updated.Succeeded) throw new WebDropException(ErrorCode.Unauthorized);
|
||||
var token = accounts.CreateDeviceSession(username, req.DeviceId, req.DeviceName) ?? throw new WebDropException(ErrorCode.BadRequest);
|
||||
return Json(new PasskeySessionBody { Username = username, Token = token, CredentialId = id, PrfSalt = row.PrfSalt, KeyEnvelope = row.KeyEnvelope }, ApiJson.Default.PasskeySessionBody);
|
||||
}).RequireRateLimiting(CreatePolicy);
|
||||
api.MapPost("/accounts/passkeys/recovery/options", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, PasskeyCeremonies ceremonies) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var options = await handler.MakeRequestOptionsAsync(new PasskeyAccount(username), ctx);
|
||||
var id = ceremonies.Put(new(username, "recovery", options.AssertionState ?? throw new WebDropException(ErrorCode.BadRequest), "", PasskeyCeremonies.Hash(ctx.Request.Headers["X-WD-Account"].ToString()), DateTimeOffset.UtcNow.AddMinutes(2)));
|
||||
return Json(new PasskeyOptionsBody { Id = id, Options = options.RequestOptionsJson, Credentials = accounts.Passkeys(username).Select(PasskeyInfo).ToList() }, ApiJson.Default.PasskeyOptionsBody);
|
||||
}).RequireRateLimiting(CreatePolicy);
|
||||
api.MapPut("/accounts/passkeys/recovery", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, UserManager<PasskeyAccount> manager, PasskeyCeremonies ceremonies, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.PasskeyEnvelopeRequest, ct);
|
||||
var state = ceremonies.Take(req.Id, username, "recovery", ctx.Request.Headers["X-WD-Account"].ToString());
|
||||
if (NormalizeUsername(req.Username) != username || req.Credential.Length > 40_000) throw new WebDropException(ErrorCode.BadRequest);
|
||||
ValidateRecoveryEnvelope(req.KeyEnvelope);
|
||||
var result = await handler.PerformAssertionAsync(new() { HttpContext = ctx, CredentialJson = req.Credential, AssertionState = state.State });
|
||||
if (!result.Succeeded || result.User?.Username != username || result.Passkey is null) throw new WebDropException(ErrorCode.Unauthorized);
|
||||
if (!(await manager.AddOrUpdatePasskeyAsync(result.User, result.Passkey)).Succeeded) throw new WebDropException(ErrorCode.Unauthorized);
|
||||
accounts.UpdatePasskeyEnvelope(Codec.Encode(result.Passkey.CredentialId), username, req.KeyEnvelope);
|
||||
return Results.NoContent();
|
||||
}).RequireRateLimiting(CreatePolicy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using WebDrop.Core;
|
||||
using WebDrop.Storage;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public static partial class Endpoints
|
||||
{
|
||||
private static void MapWorkspace(RouteGroupBuilder api, SqliteAccountStore accounts)
|
||||
{
|
||||
WorkspaceBody View(WorkspaceState state) => new()
|
||||
{
|
||||
Structure = state,
|
||||
People = state.Locations.Select(l => l.AuthorAccount).Where(u => u is not null).Distinct()
|
||||
.Select(u => accounts.Profile(u!)).ToList(),
|
||||
};
|
||||
api.MapGet("/spaces/{id}/structure", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
Json(View(svc.GetWorkspace(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true))), ApiJson.Default.WorkspaceBody));
|
||||
api.MapPost("/spaces/{id}/channels", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceChannelRequest, ct);
|
||||
var created = svc.CreateWorkspaceChannelResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.Name, req.GroupId);
|
||||
return Json(new WorkspaceChannelCreatedBody { Workspace = View(created.State), Channel = created.Channel }, ApiJson.Default.WorkspaceChannelCreatedBody, 201);
|
||||
});
|
||||
api.MapPatch("/spaces/{id}/channels/{channelId}", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
||||
return Json(View(svc.RenameWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.Name)), ApiJson.Default.WorkspaceBody);
|
||||
});
|
||||
api.MapPut("/spaces/{id}/channels/{channelId}/group", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceGroupRequest, ct);
|
||||
return Json(View(svc.GroupWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.GroupId)), ApiJson.Default.WorkspaceBody);
|
||||
});
|
||||
api.MapPost("/spaces/{id}/groups", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
||||
var created = svc.CreateWorkspaceGroupResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.Name);
|
||||
return Json(new WorkspaceGroupCreatedBody { Workspace = View(created.State), Group = created.Group }, ApiJson.Default.WorkspaceGroupCreatedBody, 201);
|
||||
});
|
||||
api.MapPatch("/spaces/{id}/groups/{groupId}", async (string id, string groupId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
||||
return Json(View(svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), groupId, req.Name)), ApiJson.Default.WorkspaceBody);
|
||||
});
|
||||
api.MapPost("/spaces/{id}/folders", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFolderRequest, ct);
|
||||
return Json(View(svc.CreateWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.ThreadId, req.ParentId, req.Name)), ApiJson.Default.WorkspaceBody, 201);
|
||||
});
|
||||
api.MapPatch("/spaces/{id}/folders/{folderId}", async (string id, string folderId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
||||
return Json(View(svc.RenameWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), folderId, req.Name)), ApiJson.Default.WorkspaceBody);
|
||||
});
|
||||
api.MapPut("/spaces/{id}/locations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceMoveRequest, ct);
|
||||
if (req.ItemIds is null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
return Json(View(svc.MoveWorkspaceItems(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.ItemIds, req.ThreadId, req.FolderId)), ApiJson.Default.WorkspaceBody);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public sealed class ChannelHistoryRequest
|
||||
{
|
||||
[JsonPropertyName("chatHistory")] public bool ChatHistory { get; init; }
|
||||
[JsonPropertyName("filesHistory")] public bool FilesHistory { get; init; }
|
||||
}
|
||||
public static partial class Endpoints
|
||||
{
|
||||
private static void MapWorkspaceCrypto(RouteGroupBuilder api)
|
||||
{
|
||||
api.MapGet("/spaces/{id}/crypto", (string id, HttpContext ctx, ChannelService svc) => Json(svc.ReadCrypto(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true)), ApiJson.Default.CryptoState));
|
||||
api.MapPut("/spaces/{id}/crypto/domains", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.DomainPublishRequest, ct);
|
||||
svc.PublishDomain(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req); return Results.NoContent();
|
||||
});
|
||||
api.MapPost("/spaces/{id}/crypto/objects", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.ObjectEnvelope, ct);
|
||||
return Json(svc.DeclareObject(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req), ApiJson.Default.ObjectEnvelope, 201);
|
||||
});
|
||||
api.MapPatch("/spaces/{id}/channels/{channelId}/history", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.ChannelHistoryRequest, ct);
|
||||
svc.SetChannelHistory(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.ChatHistory, req.FilesHistory); return Results.NoContent();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Buffers;
|
||||
using System.Diagnostics;
|
||||
using System.Net.WebSockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using WebDrop.Core;
|
||||
@@ -28,6 +29,14 @@ public static partial class Endpoints
|
||||
ApiEndpoint = captcha.Enabled ? options.Captcha.ApiEndpoint : null,
|
||||
},
|
||||
Transports = new TransportsBody { Http3 = options.EnableHttp3 },
|
||||
Defaults = new DefaultsBody { Channel = CreationDefaults.Channel(limits), Share = CreationDefaults.Share(limits) },
|
||||
Choices = new SettingChoicesBody
|
||||
{
|
||||
Seats = new[] { 2, 3, 5, 10, limits.MaxSeats }.Where(n => n >= 1 && n <= limits.MaxSeats).Distinct().Order().ToArray(),
|
||||
TtlSeconds = new[] { 3600, 86_400, 604_800, limits.MinTtlSeconds, limits.MaxTtlSeconds, CreationDefaults.Channel(limits).TtlSeconds }
|
||||
.Where(n => n >= limits.MinTtlSeconds && n <= limits.MaxTtlSeconds).Distinct().Order().ToArray(),
|
||||
MaxVisitors = new[] { 0, 2, 5, 10, 20, limits.MaxVisitors }.Where(n => n <= limits.MaxVisitors).Distinct().Order().ToArray(),
|
||||
},
|
||||
Limits = new LimitsBody
|
||||
{
|
||||
MaxPayloadBytes = limits.MaxPayloadBytes,
|
||||
@@ -35,10 +44,16 @@ public static partial class Endpoints
|
||||
MaxChannelBytes = limits.MaxChannelBytes,
|
||||
MaxSeats = limits.MaxSeats,
|
||||
MaxTtlSeconds = limits.MaxTtlSeconds,
|
||||
MinTtlSeconds = limits.MinTtlSeconds,
|
||||
MaxItems = limits.MaxItemsLimit,
|
||||
MaxVisitors = limits.MaxVisitors,
|
||||
},
|
||||
}, ApiJson.Default.ServerInfoBody));
|
||||
|
||||
MapAccounts(api, accounts);
|
||||
MapPasskeys(api, accounts);
|
||||
MapWorkspace(api, accounts);
|
||||
MapWorkspaceCrypto(api);
|
||||
|
||||
// ───────── 频道 ─────────
|
||||
|
||||
@@ -109,14 +124,15 @@ public static partial class Endpoints
|
||||
|
||||
// ───────── 帧与日志 ─────────
|
||||
|
||||
api.MapPost("/channels/{id}/frames", async (string id, HttpContext ctx, ChannelService svc, ServerLimits limits, CancellationToken ct) =>
|
||||
api.MapPost("/channels/{id}/frames", async (string id, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore, ServerLimits limits, CancellationToken ct) =>
|
||||
{
|
||||
var channelId = ParseId(id);
|
||||
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
|
||||
long? maxDownloads = ParseMaxDownloads(ctx.Request.Headers["X-WD-Max-Downloads"].ToString());
|
||||
|
||||
byte[] body = await ReadBody(ctx.Request, Envelope.HeaderSize + limits.MaxPayloadBytes, ct);
|
||||
var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, ct);
|
||||
var signedEvent = ParseSignedContentEvent(ctx.Request, accountStore, channelId, creds, body);
|
||||
var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, ct, signedEvent);
|
||||
return Json(result, ApiJson.Default.AppendResult, result.Created ? StatusCodes.Status201Created : StatusCodes.Status200OK);
|
||||
});
|
||||
|
||||
@@ -217,9 +233,12 @@ public static partial class Endpoints
|
||||
return Results.Empty;
|
||||
});
|
||||
|
||||
api.MapDelete("/channels/{id}/items/{itemId}", (string id, string itemId, HttpContext ctx, ChannelService svc) =>
|
||||
api.MapDelete("/channels/{id}/items/{itemId}", (string id, string itemId, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore) =>
|
||||
{
|
||||
svc.DeleteItem(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true), ParseId(itemId));
|
||||
var channelId = ParseId(id); var parsedItemId = ParseId(itemId);
|
||||
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
|
||||
var proof = ParseSignedDeleteEvent(ctx.Request, accountStore, svc, channelId, parsedItemId, creds);
|
||||
svc.DeleteItem(channelId, creds, parsedItemId, proof);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
@@ -355,6 +374,85 @@ public static partial class Endpoints
|
||||
catch (Exception e) when (e is JsonException or KeyNotFoundException or InvalidOperationException) { throw new WebDropException(ErrorCode.BadRequest); }
|
||||
}
|
||||
|
||||
private static SignedContentEventProof? ParseSignedContentEvent(HttpRequest request, SqliteAccountStore accounts,
|
||||
Id16 spaceId, Credentials creds, byte[] frame)
|
||||
{
|
||||
string[] names = ["X-WD-Event-Id", "X-WD-Event-Kind", "X-WD-Event-Channel", "X-WD-Event-Domain",
|
||||
"X-WD-Event-Epoch", "X-WD-Event-Frames", "X-WD-Event-Manifest", "X-WD-Event-Signature", "X-WD-Event-Target"];
|
||||
var values = names.Select(name => request.Headers[name].ToString()).ToArray();
|
||||
bool any = values.Any(value => value.Length > 0);
|
||||
if (!any)
|
||||
{
|
||||
if (Envelope.TryParse(frame, out var legacyEnvelope) && legacyEnvelope.IsFinal && creds.AccountName is not null
|
||||
&& creds.AccountDeviceId is not null && accounts.DeviceSigningKey(creds.AccountName, creds.AccountDeviceId) is not null)
|
||||
throw new WebDropException(ErrorCode.Forbidden);
|
||||
return null;
|
||||
}
|
||||
if (!Envelope.TryParse(frame, out var envelope) || !envelope.IsFinal || creds.AccountName is null || creds.AccountDeviceId is null
|
||||
|| values.Take(8).Any(string.IsNullOrEmpty)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (!Guid.TryParseExact(values[2], "D", out _) || values[3] is not ("chat" or "files")
|
||||
|| !int.TryParse(values[4], out var epoch) || epoch < 0
|
||||
|| !int.TryParse(values[5], out var frameCount) || frameCount < 1 || frameCount > 65_536)
|
||||
throw new WebDropException(ErrorCode.BadRequest);
|
||||
var domain = values[3] == "chat" ? CryptoDomain.Chat : CryptoDomain.Files;
|
||||
var eventKind = values[1];
|
||||
if (domain == CryptoDomain.Chat && !eventKind.StartsWith("chat.", StringComparison.Ordinal)
|
||||
|| domain == CryptoDomain.Files && !eventKind.StartsWith("file.", StringComparison.Ordinal)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (!Codec.TryDecodeExact(values[0], 16, out var eventId)
|
||||
|| !Codec.TryDecodeExact(values[6], 32, out var manifestHash)
|
||||
|| !Codec.TryDecodeExact(values[7], 64, out var signature)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
byte[] target = [];
|
||||
if (values[8].Length > 0 && !Codec.TryDecodeExact(values[8], 16, out target)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
|
||||
var deviceKey = accounts.DeviceSigningKey(creds.AccountName, creds.AccountDeviceId)
|
||||
?? throw new WebDropException(ErrorCode.Forbidden);
|
||||
var rootPublicKey = accounts.GetSigningPublicKey(creds.AccountName) ?? throw new WebDropException(ErrorCode.Forbidden);
|
||||
using var publicKey = JsonDocument.Parse(deviceKey.PublicKey);
|
||||
var jwk = publicKey.RootElement;
|
||||
if (!Codec.TryDecodeExact(jwk.GetProperty("x").GetString(), 32, out var x)
|
||||
|| !Codec.TryDecodeExact(jwk.GetProperty("y").GetString(), 32, out var y)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var payload = SignatureCodec.ContentEventPayload(spaceId.ToArray(), values[2], creds.AccountName,
|
||||
creds.AccountDeviceId, eventId, eventKind, domain, epoch, envelope.ItemId.ToArray(), frameCount, manifestHash, target);
|
||||
if (!SignatureCodec.VerifyP256(x, y, "content.event", payload, signature)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
return new SignedContentEventProof(eventId, eventKind, creds.AccountName, values[2], domain, epoch, frameCount,
|
||||
manifestHash, creds.AccountDeviceId, signature, values[8].Length == 0 ? null : values[8],
|
||||
rootPublicKey, deviceKey.PublicKey, deviceKey.CertificateSignature);
|
||||
}
|
||||
|
||||
private static SignedContentEventProof? ParseSignedDeleteEvent(HttpRequest request, SqliteAccountStore accounts,
|
||||
ChannelService service, Id16 spaceId, Id16 itemId, Credentials creds)
|
||||
{
|
||||
string[] names = ["X-WD-Event-Id", "X-WD-Event-Kind", "X-WD-Event-Channel", "X-WD-Event-Domain",
|
||||
"X-WD-Event-Epoch", "X-WD-Event-Frames", "X-WD-Event-Manifest", "X-WD-Event-Signature"];
|
||||
var values = names.Select(name => request.Headers[name].ToString()).ToArray();
|
||||
if (values.All(string.IsNullOrEmpty)) return null;
|
||||
if (creds.AccountName is null || creds.AccountDeviceId is null || values.Any(string.IsNullOrEmpty)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var obj = service.ObjectSigningContext(spaceId, creds, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
var createdEvent = obj.Event ?? throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (!Guid.TryParseExact(values[2], "D", out _) || values[3] is not ("chat" or "files")
|
||||
|| !int.TryParse(values[4], out var epoch) || !int.TryParse(values[5], out var frameCount)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var domain = values[3] == "chat" ? CryptoDomain.Chat : CryptoDomain.Files;
|
||||
var kind = domain == CryptoDomain.Chat ? "chat.delete" : "file.delete";
|
||||
if (values[1] != kind || obj.ChannelId != values[2] || obj.Domain != domain || obj.Epoch != epoch
|
||||
|| createdEvent.FrameCount != frameCount
|
||||
|| !Codec.TryDecodeExact(values[0], 16, out var eventId)
|
||||
|| !Codec.TryDecodeExact(values[6], 32, out var manifest)
|
||||
|| !Codec.TryDecodeExact(values[7], 64, out var signature)
|
||||
|| !Codec.TryDecodeExact(createdEvent.ManifestHash, 32, out var objectManifest)
|
||||
|| !CryptographicOperations.FixedTimeEquals(manifest, objectManifest)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
|
||||
var deviceKey = accounts.DeviceSigningKey(creds.AccountName, creds.AccountDeviceId) ?? throw new WebDropException(ErrorCode.Forbidden);
|
||||
using var publicKey = JsonDocument.Parse(deviceKey.PublicKey); var jwk = publicKey.RootElement;
|
||||
if (!Codec.TryDecodeExact(jwk.GetProperty("x").GetString(), 32, out var x)
|
||||
|| !Codec.TryDecodeExact(jwk.GetProperty("y").GetString(), 32, out var y)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var payload = SignatureCodec.ContentEventPayload(spaceId.ToArray(), obj.ChannelId, creds.AccountName,
|
||||
creds.AccountDeviceId, eventId, kind, domain, epoch, itemId.ToArray(), frameCount, manifest, []);
|
||||
if (!SignatureCodec.VerifyP256(x, y, "content.event", payload, signature)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
return new SignedContentEventProof(eventId, kind, creds.AccountName, obj.ChannelId, domain, epoch, frameCount,
|
||||
manifest, creds.AccountDeviceId, signature, null, accounts.GetSigningPublicKey(creds.AccountName)!,
|
||||
deviceKey.PublicKey, deviceKey.CertificateSignature);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public sealed record PasskeyCeremony(string Username, string Purpose, string State, string PrfSalt, string? SessionHash, DateTimeOffset Expires);
|
||||
public sealed class PasskeyCeremonies
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PasskeyCeremony> _pending = new();
|
||||
public string Put(PasskeyCeremony ceremony)
|
||||
{
|
||||
foreach (var entry in _pending) if (entry.Value.Expires <= DateTimeOffset.UtcNow) _pending.TryRemove(entry.Key, out _);
|
||||
if (_pending.Count >= 1000) throw new WebDropException(ErrorCode.RateLimited);
|
||||
var id = Codec.Encode(RandomNumberGenerator.GetBytes(32));
|
||||
_pending[id] = ceremony; return id;
|
||||
}
|
||||
public PasskeyCeremony Take(string id, string username, string purpose, string? token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id) || id.Length > 100 || !_pending.TryRemove(id, out var ceremony) || ceremony.Expires <= DateTimeOffset.UtcNow
|
||||
|| ceremony.Username != username || ceremony.Purpose != purpose || ceremony.SessionHash != Hash(token)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
return ceremony;
|
||||
}
|
||||
public static string? Hash(string? token) => token is null ? null : Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public sealed class PasskeyBeginRequest
|
||||
{
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("verifier")] public string? Verifier { get; init; }
|
||||
}
|
||||
public sealed class PasskeyOptionsBody
|
||||
{
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("options")] public required string Options { get; init; }
|
||||
[JsonPropertyName("credentials")] public List<PasskeyInfoBody> Credentials { get; init; } = [];
|
||||
[JsonPropertyName("prf_salt")] public string? PrfSalt { get; init; }
|
||||
}
|
||||
public sealed class PasskeyFinishRequest
|
||||
{
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("credential")] public required string Credential { get; init; }
|
||||
[JsonPropertyName("name")] public string? Name { get; init; }
|
||||
[JsonPropertyName("key_envelope")] public string? KeyEnvelope { get; init; }
|
||||
[JsonPropertyName("device_id")] public string? DeviceId { get; init; }
|
||||
[JsonPropertyName("device_name")] public string? DeviceName { get; init; }
|
||||
}
|
||||
public sealed class PasskeyInfoBody
|
||||
{
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("name")] public required string Name { get; init; }
|
||||
[JsonPropertyName("prf_salt")] public required string PrfSalt { get; init; }
|
||||
[JsonPropertyName("can_recover")] public bool CanRecover { get; init; }
|
||||
[JsonPropertyName("created_at")] public long CreatedAt { get; init; }
|
||||
}
|
||||
public sealed class PasskeySessionBody
|
||||
{
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("token")] public required string Token { get; init; }
|
||||
[JsonPropertyName("credential_id")] public required string CredentialId { get; init; }
|
||||
[JsonPropertyName("prf_salt")] public required string PrfSalt { get; init; }
|
||||
[JsonPropertyName("key_envelope")] public string? KeyEnvelope { get; init; }
|
||||
}
|
||||
public sealed class PasskeyEnvelopeRequest
|
||||
{
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("credential")] public required string Credential { get; init; }
|
||||
[JsonPropertyName("key_envelope")] public required string KeyEnvelope { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using WebDrop.Core;
|
||||
using WebDrop.Storage;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public sealed record PasskeyAccount(string Username);
|
||||
|
||||
[JsonSerializable(typeof(UserPasskeyInfo))]
|
||||
public sealed partial class PasskeyStorageJson : JsonSerializerContext;
|
||||
|
||||
/// <summary>Reuse the framework's WebAuthn verifier without replacing the existing account/session system.</summary>
|
||||
public sealed class PasskeyUserStore(SqliteAccountStore accounts) : IUserPasskeyStore<PasskeyAccount>
|
||||
{
|
||||
private readonly Dictionary<string, string> _readVersions = [];
|
||||
public void Dispose() { }
|
||||
public Task<string> GetUserIdAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult(user.Username);
|
||||
public Task<string?> GetUserNameAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult<string?>(user.Username);
|
||||
public Task<string?> GetNormalizedUserNameAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult<string?>(user.Username.ToUpperInvariant());
|
||||
public Task SetUserNameAsync(PasskeyAccount user, string? name, CancellationToken ct) => name == user.Username ? Task.CompletedTask : throw new NotSupportedException();
|
||||
public Task SetNormalizedUserNameAsync(PasskeyAccount user, string? name, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task<IdentityResult> CreateAsync(PasskeyAccount user, CancellationToken ct) => throw new NotSupportedException();
|
||||
public Task<IdentityResult> DeleteAsync(PasskeyAccount user, CancellationToken ct) => throw new NotSupportedException();
|
||||
public Task<IdentityResult> UpdateAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult(IdentityResult.Success);
|
||||
public Task<PasskeyAccount?> FindByIdAsync(string id, CancellationToken ct) => Task.FromResult(accounts.GetPublicKey(id) is null ? null : new PasskeyAccount(id.ToLowerInvariant()));
|
||||
public Task<PasskeyAccount?> FindByNameAsync(string name, CancellationToken ct) => FindByIdAsync(name, ct);
|
||||
public Task<PasskeyAccount?> FindByPasskeyIdAsync(byte[] id, CancellationToken ct) => Task.FromResult(accounts.Passkey(Codec.Encode(id)) is { } record ? new PasskeyAccount(record.Username) : null);
|
||||
public Task<UserPasskeyInfo?> FindPasskeyAsync(PasskeyAccount user, byte[] id, CancellationToken ct)
|
||||
{
|
||||
var record = accounts.Passkey(Codec.Encode(id));
|
||||
return Task.FromResult(record?.Username == user.Username ? Read(record) : null);
|
||||
}
|
||||
public Task<IList<UserPasskeyInfo>> GetPasskeysAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult<IList<UserPasskeyInfo>>(accounts.Passkeys(user.Username).Select(Read).ToList());
|
||||
private UserPasskeyInfo Read(StoredPasskey record)
|
||||
{
|
||||
_readVersions[record.Id] = record.Data;
|
||||
return JsonSerializer.Deserialize(record.Data, PasskeyStorageJson.Default.UserPasskeyInfo)!;
|
||||
}
|
||||
public Task AddOrUpdatePasskeyAsync(PasskeyAccount user, UserPasskeyInfo passkey, CancellationToken ct)
|
||||
{
|
||||
var id = Codec.Encode(passkey.CredentialId);
|
||||
var latest = accounts.Passkey(id) ?? throw new WebDropException(ErrorCode.Unauthorized);
|
||||
var previous = JsonSerializer.Deserialize(latest.Data, PasskeyStorageJson.Default.UserPasskeyInfo)!;
|
||||
if ((previous.SignCount != 0 || passkey.SignCount != 0) && passkey.SignCount <= previous.SignCount) throw new WebDropException(ErrorCode.Unauthorized);
|
||||
if (!_readVersions.TryGetValue(id, out var old) || !accounts.UpdatePasskeyData(id, user.Username, old, JsonSerializer.Serialize(passkey, PasskeyStorageJson.Default.UserPasskeyInfo))) throw new WebDropException(ErrorCode.Conflict);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task RemovePasskeyAsync(PasskeyAccount user, byte[] id, CancellationToken ct) { accounts.RemovePasskey(Codec.Encode(id), user.Username); return Task.CompletedTask; }
|
||||
}
|
||||
@@ -35,7 +35,7 @@ public static class RequestAuth
|
||||
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);
|
||||
return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint), accountName, deviceId, accountName is not null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -68,7 +68,7 @@ public static class RequestAuth
|
||||
}
|
||||
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);
|
||||
return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: true), accountName, deviceId, accountName is not null);
|
||||
}
|
||||
|
||||
public static byte[] ParseEndpointHash(string? header) => ParseEndpoint(header, requireEndpoint: true);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
using WebDrop.Core;
|
||||
using WebDrop.Frontend;
|
||||
using WebDrop.Storage;
|
||||
@@ -27,6 +29,23 @@ public static class WebDropApp
|
||||
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<PasskeyCeremonies>();
|
||||
builder.Services.AddScoped<PasskeyUserStore>();
|
||||
builder.Services.AddScoped<UserManager<PasskeyAccount>>(sp => new(
|
||||
sp.GetRequiredService<PasskeyUserStore>(), Options.Create(new IdentityOptions()), new PasswordHasher<PasskeyAccount>(), [], [],
|
||||
new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), sp, sp.GetRequiredService<ILogger<UserManager<PasskeyAccount>>>()));
|
||||
builder.Services.Configure<IdentityPasskeyOptions>(o =>
|
||||
{
|
||||
o.ServerDomain = options.PasskeyRpId;
|
||||
o.UserVerificationRequirement = "required"; o.ResidentKeyRequirement = "required";
|
||||
o.AttestationConveyancePreference = "none"; o.AuthenticatorTimeout = TimeSpan.FromMinutes(2);
|
||||
o.IsAllowedAlgorithm = algorithm => algorithm == -7;
|
||||
o.ValidateOrigin = context => ValueTask.FromResult(!context.CrossOrigin && context.TopOrigin is null
|
||||
&& Uri.TryCreate(context.Origin, UriKind.Absolute, out var origin)
|
||||
&& (options.CorsOrigins.Contains(origin.GetLeftPart(UriPartial.Authority), StringComparer.Ordinal)
|
||||
|| origin.GetLeftPart(UriPartial.Authority) == $"{context.HttpContext.Request.Scheme}://{context.HttpContext.Request.Host}"));
|
||||
});
|
||||
builder.Services.AddScoped<IPasskeyHandler<PasskeyAccount>, PasskeyHandler<PasskeyAccount>>();
|
||||
builder.Services.AddSingleton<IResourceStore>(sp => sp.GetRequiredService<SqliteChannelStore>());
|
||||
builder.Services.AddSingleton<ICaptchaVerifier>(sp => CreateCaptchaVerifier(options.Captcha, sp));
|
||||
builder.Services.AddSingleton<ChannelHub>();
|
||||
@@ -43,7 +62,9 @@ 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-Max-Downloads", "X-WD-Account")
|
||||
"X-WD-Role", "X-WD-Endpoint", "X-WD-Cap", "X-WD-Max-Downloads", "X-WD-Account",
|
||||
"X-WD-Event-Id", "X-WD-Event-Kind", "X-WD-Event-Channel", "X-WD-Event-Domain",
|
||||
"X-WD-Event-Epoch", "X-WD-Event-Frames", "X-WD-Event-Manifest", "X-WD-Event-Signature", "X-WD-Event-Target")
|
||||
.WithExposedHeaders("X-WD-Last-Seq")
|
||||
.SetPreflightMaxAge(TimeSpan.FromHours(1))));
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ public sealed class WebDropOptions
|
||||
/// 服务端进程本身不内置 QUIC:CreateSlimBuilder 不含 HTTP/3,需要时再接入。
|
||||
/// </summary>
|
||||
public bool EnableHttp3 { get; set; }
|
||||
public string? PasskeyRpId { get; set; }
|
||||
|
||||
public int RequestsPerMinute { get; set; } = 600;
|
||||
public int ChannelCreatesPerMinute { get; set; } = 30;
|
||||
|
||||
@@ -91,6 +91,11 @@ public interface IResourceTx
|
||||
// 频道
|
||||
Channel? GetChannel(Id16 id);
|
||||
void InsertChannel(Channel channel);
|
||||
WorkspaceState? GetWorkspace(Id16 id);
|
||||
CryptoState GetWorkspaceCrypto(Id16 id);
|
||||
void PutWorkspaceCrypto(Id16 id, CryptoState state);
|
||||
IReadOnlyList<Id16> ListAccountSpaces(string username, long now);
|
||||
void PutWorkspace(Id16 id, WorkspaceState state);
|
||||
void UpdateChannel(Id16 id, ChannelSettings settings, long expiresAt);
|
||||
void SetJoinPassword(Id16 id, byte[]? hash, byte[]? salt);
|
||||
bool HasInvitation(Id16 id, string username);
|
||||
@@ -122,6 +127,7 @@ public interface IResourceTx
|
||||
// 席位
|
||||
Seat? GetSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash);
|
||||
int CountSeats(Id16 channelId);
|
||||
int CountMembers(Id16 channelId);
|
||||
void InsertSeat(Id16 channelId, Seat seat);
|
||||
bool DeleteSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash);
|
||||
void SetSeatLabel(Id16 channelId, ReadOnlySpan<byte> endpointHash, byte[] label);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace WebDrop.Core;
|
||||
|
||||
public sealed class AccountProfile
|
||||
{
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("displayName")] public required string DisplayName { get; init; }
|
||||
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
|
||||
[JsonPropertyName("modifiedAt")] public required long ModifiedAt { get; init; }
|
||||
}
|
||||
@@ -19,7 +19,7 @@ public sealed partial class ChannelService
|
||||
bool invited = creds.AccountName is not null && tx.HasInvitation(channel.Id, creds.AccountName);
|
||||
if (invited) return;
|
||||
if (creds.AccountName is not null && tx.GetChannelMember(channel.Id, creds.AccountName) is not null) return;
|
||||
if (channel.Settings.JoinPolicy == "invite") throw new WebDropException(ErrorCode.InvitationRequired);
|
||||
if (channel.Settings.JoinPolicy == JoinPolicy.Invite) throw new WebDropException(ErrorCode.InvitationRequired);
|
||||
if (!channel.Settings.AllowGuests && creds.AccountName is null) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
if (channel.JoinPasswordHash is null) return;
|
||||
if (string.IsNullOrEmpty(password)) throw new WebDropException(ErrorCode.ChannelPasswordRequired);
|
||||
@@ -28,21 +28,6 @@ public sealed partial class ChannelService
|
||||
if (!CryptographicOperations.FixedTimeEquals(actual, channel.JoinPasswordHash)) throw new WebDropException(ErrorCode.ChannelPasswordInvalid);
|
||||
}
|
||||
|
||||
public void InviteUser(Id16 id, Credentials creds, string username, string payload)
|
||||
{
|
||||
if (payload is null || payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
RequireOwner(principal);
|
||||
tx.PutInvitation(new ChannelInvitation(id, username, payload, clock.NowMs));
|
||||
if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden);
|
||||
tx.PutKeyPackage(id, username, payload, clock.NowMs);
|
||||
tx.AllowAccount(id, username);
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
public IReadOnlyList<ChannelInvitation> Invitations(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
@@ -63,6 +48,11 @@ public sealed partial class ChannelService
|
||||
tx.DeleteKeyPackage(id, username);
|
||||
tx.DeleteAccountSeats(id, username);
|
||||
tx.DeleteChannelMember(id, username);
|
||||
var crypto = tx.GetWorkspaceCrypto(id);
|
||||
crypto.Access.RemoveAll(a => a.Username == username);
|
||||
crypto.Envelopes.RemoveAll(e => e.Username == username);
|
||||
crypto.Revision++;
|
||||
tx.PutWorkspaceCrypto(id, crypto);
|
||||
tx.DenyAccount(id, username);
|
||||
return 0;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
namespace WebDrop.Core;
|
||||
|
||||
public sealed partial class ChannelService
|
||||
{
|
||||
public InvitationCryptoPlan InvitationPlan(Id16 id, Credentials creds, string username) => store.Read(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal);
|
||||
if (tx.GetChannelMember(id, username) is not null || tx.HasInvitation(id, username)) throw new WebDropException(ErrorCode.Conflict);
|
||||
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
var channelId = workspace.DefaultChannelId ?? throw new WebDropException(ErrorCode.Conflict);
|
||||
var channel = workspace.Channels.Find(c => c.Id == channelId) ?? throw new WebDropException(ErrorCode.Conflict);
|
||||
var batches = new List<CryptoGrantBatch>();
|
||||
if (space.Mode == ChannelMode.Enc)
|
||||
{
|
||||
var crypto = tx.GetWorkspaceCrypto(id);
|
||||
foreach (var domain in new[] { CryptoDomain.Chat, CryptoDomain.Files })
|
||||
{
|
||||
var currentEpoch = domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch;
|
||||
if (currentEpoch < 1) throw new WebDropException(ErrorCode.Conflict);
|
||||
var history = domain == CryptoDomain.Chat ? channel.ChatHistory : channel.FilesHistory;
|
||||
if (history)
|
||||
{
|
||||
var epochs = crypto.Envelopes.Where(e => e.ChannelId == channelId && e.Domain == domain && e.Username == space.CreatorAccount)
|
||||
.Select(e => e.Epoch).Append(currentEpoch).Distinct().Order().ToList();
|
||||
if (epochs.Count > 256) throw new WebDropException(ErrorCode.TooLarge);
|
||||
foreach (var epoch in epochs) batches.Add(new CryptoGrantBatch { Domain = domain, Epoch = epoch, Recipients = [username], Rotate = false });
|
||||
}
|
||||
else
|
||||
{
|
||||
var recipients = crypto.Access.Where(a => a.ChannelId == channelId && (domain == CryptoDomain.Chat ? a.ChatRead : a.FilesRead)
|
||||
&& (a.Username == space.CreatorAccount || tx.GetChannelMember(id, a.Username) is not null || tx.HasInvitation(id, a.Username)))
|
||||
.Select(a => a.Username).Append(space.CreatorAccount!).Append(username).Distinct().Order(StringComparer.Ordinal).ToList();
|
||||
if (recipients.Count > 256) throw new WebDropException(ErrorCode.TooLarge);
|
||||
batches.Add(new CryptoGrantBatch { Domain = domain, Epoch = currentEpoch + 1, Recipients = recipients, Rotate = true });
|
||||
}
|
||||
}
|
||||
}
|
||||
return new InvitationCryptoPlan { ChannelId = channelId, Username = username, Batches = batches };
|
||||
});
|
||||
|
||||
public void InviteUser(Id16 id, Credentials creds, string username, string payload, IReadOnlyList<DomainEnvelope> envelopes)
|
||||
{
|
||||
if (payload is null || payload.Length is < 1 or > 16_384 || envelopes is null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal);
|
||||
if (tx.GetChannelMember(id, username) is not null || tx.HasInvitation(id, username)) throw new WebDropException(ErrorCode.Conflict);
|
||||
if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden);
|
||||
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
var channelId = workspace.DefaultChannelId ?? throw new WebDropException(ErrorCode.Conflict);
|
||||
var channel = workspace.Channels.Find(c => c.Id == channelId) ?? throw new WebDropException(ErrorCode.Conflict);
|
||||
var state = tx.GetWorkspaceCrypto(id);
|
||||
var expected = new List<(CryptoDomain Domain, int Epoch, string Username)>();
|
||||
if (space.Mode == ChannelMode.Enc)
|
||||
{
|
||||
foreach (var domain in new[] { CryptoDomain.Chat, CryptoDomain.Files })
|
||||
{
|
||||
var currentEpoch = domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch;
|
||||
if (currentEpoch < 1) throw new WebDropException(ErrorCode.Conflict);
|
||||
var history = domain == CryptoDomain.Chat ? channel.ChatHistory : channel.FilesHistory;
|
||||
if (history)
|
||||
{
|
||||
var epochs = state.Envelopes.Where(e => e.ChannelId == channelId && e.Domain == domain && e.Username == space.CreatorAccount)
|
||||
.Select(e => e.Epoch).Append(currentEpoch).Distinct().Order().ToList();
|
||||
if (epochs.Count > 256) throw new WebDropException(ErrorCode.TooLarge);
|
||||
expected.AddRange(epochs.Select(epoch => (domain, epoch, username)));
|
||||
}
|
||||
else
|
||||
{
|
||||
var recipients = state.Access.Where(a => a.ChannelId == channelId && (domain == CryptoDomain.Chat ? a.ChatRead : a.FilesRead)
|
||||
&& (a.Username == space.CreatorAccount || tx.GetChannelMember(id, a.Username) is not null || tx.HasInvitation(id, a.Username)))
|
||||
.Select(a => a.Username).Append(space.CreatorAccount!).Append(username).Distinct().ToHashSet(StringComparer.Ordinal);
|
||||
if (recipients.Count > 256) throw new WebDropException(ErrorCode.TooLarge);
|
||||
expected.AddRange(recipients.Select(recipient => (domain, currentEpoch + 1, recipient)));
|
||||
}
|
||||
}
|
||||
}
|
||||
var actual = envelopes.Select(e => (e.Domain, e.Epoch, e.Username)).ToList();
|
||||
if (actual.Count != expected.Count || actual.Distinct().Count() != actual.Count || !actual.ToHashSet().SetEquals(expected)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
foreach (var envelope in envelopes)
|
||||
{
|
||||
if (envelope.ChannelId != channelId || envelope.Payload is null || envelope.Payload.Length is < 1 or > 16_384
|
||||
|| !Enum.IsDefined(envelope.Domain) || envelope.Epoch < 1) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var previous = state.Envelopes.Find(e => e.ChannelId == channelId && e.Domain == envelope.Domain && e.Epoch == envelope.Epoch && e.Username == envelope.Username);
|
||||
if (previous is not null && previous.Payload != envelope.Payload) throw new WebDropException(ErrorCode.Conflict);
|
||||
if (previous is null) state.Envelopes.Add(envelope);
|
||||
if (envelope.Domain == CryptoDomain.Chat) channel.ChatEpoch = Math.Max(channel.ChatEpoch, envelope.Epoch);
|
||||
else channel.FilesEpoch = Math.Max(channel.FilesEpoch, envelope.Epoch);
|
||||
}
|
||||
state.Access.RemoveAll(a => a.ChannelId == channelId && a.Username == username);
|
||||
state.Access.Add(new ChannelAccess
|
||||
{
|
||||
ChannelId = channelId, Username = username, ChatRead = true, ChatWrite = true, FilesRead = true, FilesUpload = true,
|
||||
ChatFromRevision = channel.ChatHistory ? 0 : state.Revision + 1,
|
||||
FilesFromRevision = channel.FilesHistory ? 0 : state.Revision + 1,
|
||||
});
|
||||
state.Revision++;
|
||||
workspace.Revision++;
|
||||
tx.PutWorkspace(id, workspace); tx.PutWorkspaceCrypto(id, state);
|
||||
tx.PutInvitation(new ChannelInvitation(id, username, payload, clock.NowMs));
|
||||
tx.PutKeyPackage(id, username, payload, clock.NowMs); tx.AllowAccount(id, username);
|
||||
return 0;
|
||||
});
|
||||
events.SettingsChanged(id);
|
||||
}
|
||||
|
||||
private static bool CanReadObject(IResourceTx tx, Channel space, Principal principal, Id16 itemId)
|
||||
{
|
||||
var crypto = tx.GetWorkspaceCrypto(space.Id);
|
||||
if (crypto.Access.Count == 0 && crypto.Envelopes.Count == 0 && space.Mode == ChannelMode.Raw) return true;
|
||||
if (crypto.Envelopes.Count == 0 && crypto.Objects.Count == 0 && space.Mode == ChannelMode.Enc) return true;
|
||||
var item = crypto.Objects.Find(o => o.ItemId == itemId.ToBase64Url());
|
||||
if (item is null) return false;
|
||||
if (principal.Role == Role.Owner || principal.AccountName == space.CreatorAccount) return true;
|
||||
var access = crypto.Access.Find(a => a.ChannelId == item.ChannelId && a.Username == principal.AccountName);
|
||||
if (access is null || (item.Domain == CryptoDomain.Chat ? !access.ChatRead || item.CreatedRevision < access.ChatFromRevision : !access.FilesRead || item.CreatedRevision < access.FilesFromRevision)) return false;
|
||||
if (space.Mode == ChannelMode.Raw) return true;
|
||||
return crypto.Envelopes.Exists(e => e.ChannelId == item.ChannelId && e.Domain == item.Domain && e.Epoch == item.Epoch && e.Username == principal.AccountName);
|
||||
}
|
||||
|
||||
private static void CheckObjectWrite(IResourceTx tx, Channel space, Principal principal, Envelope envelope)
|
||||
{
|
||||
var crypto = tx.GetWorkspaceCrypto(space.Id);
|
||||
if (space.Mode == ChannelMode.Enc && crypto.Envelopes.Count == 0) return;
|
||||
if (space.Mode == ChannelMode.Raw && crypto.Access.Count == 0 && crypto.Objects.Count == 0) return;
|
||||
var item = crypto.Objects.Find(o => o.ItemId == envelope.ItemId.ToBase64Url()) ?? throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (item.AuthorAccount != principal.AccountName) throw new WebDropException(ErrorCode.Forbidden);
|
||||
var channel = tx.GetWorkspace(space.Id)?.Channels.Find(c => c.Id == item.ChannelId);
|
||||
if (space.Mode == ChannelMode.Enc && channel is not null && item.Epoch != (item.Domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch)) throw new WebDropException(ErrorCode.Conflict);
|
||||
if (principal.Role != Role.Owner)
|
||||
{
|
||||
var access = crypto.Access.Find(a => a.ChannelId == item.ChannelId && a.Username == principal.AccountName);
|
||||
if (access is null && (space.Mode == ChannelMode.Enc || crypto.Access.Count > 0)
|
||||
|| access is not null && (item.Domain == CryptoDomain.Chat ? !access.ChatWrite : !access.FilesUpload)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
}
|
||||
}
|
||||
public CryptoState ReadCrypto(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var state = tx.GetWorkspaceCrypto(id);
|
||||
if (principal.Role == Role.Owner || principal.AccountName == space.CreatorAccount) return state;
|
||||
var access = state.Access.Where(a => a.Username == principal.AccountName).ToList();
|
||||
var allowed = access.ToDictionary(a => a.ChannelId);
|
||||
bool CanRead(string channelId, CryptoDomain domain) => allowed.TryGetValue(channelId, out var grant)
|
||||
&& (domain == CryptoDomain.Chat ? grant.ChatRead : grant.FilesRead);
|
||||
var envelopes = state.Envelopes.Where(e => e.Username == principal.AccountName && CanRead(e.ChannelId, e.Domain)).ToList();
|
||||
var epochs = envelopes.Select(e => (e.ChannelId, e.Domain, e.Epoch)).ToHashSet();
|
||||
return new CryptoState { Envelopes = envelopes,
|
||||
Objects = state.Objects.Where(o => CanRead(o.ChannelId, o.Domain)
|
||||
&& (space.Mode == ChannelMode.Raw || epochs.Contains((o.ChannelId, o.Domain, o.Epoch)))).ToList(),
|
||||
Access = access };
|
||||
});
|
||||
public void PublishDomain(Id16 id, Credentials creds, DomainPublishRequest req)
|
||||
{
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); RequireOwner(principal);
|
||||
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
var channel = workspace.Channels.Find(c => c.Id == req.ChannelId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (!Enum.IsDefined(req.Domain) || req.Envelopes is null || req.Envelopes.Count != 1 || req.Epoch != 1) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var epoch = req.Domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch;
|
||||
if (epoch > 1) throw new WebDropException(ErrorCode.Conflict);
|
||||
if (req.Envelopes[0].Username != principal.AccountName || req.Envelopes[0].ChannelId != req.ChannelId || req.Envelopes[0].Domain != req.Domain || req.Envelopes[0].Epoch != 1
|
||||
|| req.Envelopes[0].Payload is null || req.Envelopes[0].Payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var state = tx.GetWorkspaceCrypto(id);
|
||||
var envelope = req.Envelopes[0];
|
||||
var previous = state.Envelopes.Find(e => e.ChannelId == envelope.ChannelId && e.Domain == envelope.Domain && e.Epoch == 1 && e.Username == envelope.Username);
|
||||
if (previous is not null && previous.Payload != envelope.Payload) throw new WebDropException(ErrorCode.Conflict);
|
||||
if (previous is null) state.Envelopes.Add(envelope);
|
||||
var ownerAccess = state.Access.Find(a => a.ChannelId == req.ChannelId && a.Username == principal.AccountName);
|
||||
if (ownerAccess is null) state.Access.Add(new ChannelAccess { ChannelId = req.ChannelId, Username = principal.AccountName!, ChatRead = true, ChatWrite = true, FilesRead = true, FilesUpload = true });
|
||||
if (req.Domain == CryptoDomain.Chat) channel.ChatEpoch = Math.Max(channel.ChatEpoch, req.Epoch); else channel.FilesEpoch = Math.Max(channel.FilesEpoch, req.Epoch);
|
||||
state.Revision++;
|
||||
workspace.Revision++; tx.PutWorkspace(id, workspace); tx.PutWorkspaceCrypto(id, state); return 0;
|
||||
});
|
||||
events.SettingsChanged(id);
|
||||
}
|
||||
public ObjectEnvelope DeclareObject(Id16 id, Credentials creds, ObjectEnvelope req) => store.Write(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
if (!ChannelPolicy.CanWrite(space, principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
if (!Id16.TryParse(req.ItemId, out _) || !Enum.IsDefined(req.Domain) || req.KeyEnvelope is null || req.KeyEnvelope.Length > 1024) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
var channel = workspace.Channels.Find(c => c.Id == req.ChannelId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
var state = tx.GetWorkspaceCrypto(id);
|
||||
if (principal.Role != Role.Owner && (space.Mode == ChannelMode.Enc || state.Access.Count > 0))
|
||||
{
|
||||
var access = state.Access.Find(a => a.ChannelId == req.ChannelId && a.Username == principal.AccountName);
|
||||
if (access is null || req.Domain == CryptoDomain.Chat && !access.ChatWrite || req.Domain == CryptoDomain.Files && !access.FilesUpload) throw new WebDropException(ErrorCode.Forbidden);
|
||||
}
|
||||
if (space.Mode == ChannelMode.Enc && req.Epoch != (req.Domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch)) throw new WebDropException(ErrorCode.Conflict);
|
||||
var existing = state.Objects.Find(o => o.ItemId == req.ItemId);
|
||||
if (existing is not null)
|
||||
{
|
||||
if (existing.AuthorAccount != principal.AccountName || existing.ChannelId != req.ChannelId || existing.Domain != req.Domain || existing.Epoch != req.Epoch || existing.KeyEnvelope != req.KeyEnvelope) throw new WebDropException(ErrorCode.Conflict);
|
||||
return existing;
|
||||
}
|
||||
if (state.Objects.Count >= 20_000) throw new WebDropException(ErrorCode.TooLarge);
|
||||
req.AuthorAccount = principal.AccountName; req.CreatedRevision = state.Revision + 1; state.Revision++; state.Objects.Add(req); tx.PutWorkspaceCrypto(id, state); return req;
|
||||
});
|
||||
public void SetChannelHistory(Id16 id, Credentials creds, string channelId, bool chat, bool files)
|
||||
{
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); RequireOwner(principal);
|
||||
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
var channel = workspace.Channels.Find(c => c.Id == channelId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
channel.ChatHistory = chat; channel.FilesHistory = files; workspace.Revision++; tx.PutWorkspace(id, workspace); return 0;
|
||||
});
|
||||
events.SettingsChanged(id);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,9 @@ public sealed partial class ChannelService(
|
||||
if (!Id16.TryParse(req.Id, out var id)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (req.Mode is not { } mode) throw new WebDropException(ErrorCode.BadRequest);
|
||||
|
||||
var settings = req.Settings ?? new ChannelSettings();
|
||||
var settings = req.Settings ?? CreationDefaults.Channel(limits);
|
||||
if (string.IsNullOrWhiteSpace(settings.Name)) throw new WebDropException(ErrorCode.SpaceNameRequired);
|
||||
settings.Name = settings.Name.Trim();
|
||||
settings.Validate(limits);
|
||||
|
||||
if (!Codec.TryDecodeExact(req.OwnerHash, TokenHash.Size, out var ownerHash))
|
||||
@@ -54,6 +56,7 @@ public sealed partial class ChannelService(
|
||||
{
|
||||
if (tx.HasResourceId(id)) throw new WebDropException(ErrorCode.Conflict);
|
||||
tx.InsertChannel(channel);
|
||||
tx.PutWorkspace(id, NewWorkspace(creatorAccount, now));
|
||||
tx.PutChannelMember(id, new ChannelMember(creatorAccount, "owner", now));
|
||||
if (req.KeyPackage is not null) tx.PutKeyPackage(id, creatorAccount, req.KeyPackage, now);
|
||||
tx.InsertSeat(id, new Seat(endpointHash, now, null, creatorAccount, accountDeviceId));
|
||||
@@ -64,8 +67,8 @@ public sealed partial class ChannelService(
|
||||
|
||||
public ChannelInfo GetChannel(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (channel, _) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
return ToInfo(channel, tx.CountSeats(id));
|
||||
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
return ToInfo(channel, tx.CountMembers(id), principal);
|
||||
});
|
||||
|
||||
public ChannelInfo UpdateSettings(Id16 id, Credentials creds, ChannelSettingsPatch patch)
|
||||
@@ -88,7 +91,7 @@ public sealed partial class ChannelService(
|
||||
tx.SetJoinPassword(id, hash, salt);
|
||||
channel = channel with { JoinPasswordHash = hash, JoinPasswordSalt = salt };
|
||||
}
|
||||
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountSeats(id));
|
||||
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountMembers(id));
|
||||
});
|
||||
events.SettingsChanged(id);
|
||||
return info;
|
||||
@@ -117,21 +120,23 @@ public sealed partial class ChannelService(
|
||||
var existing = tx.GetSeat(id, principal.EndpointHash);
|
||||
if (existing?.AccountName is not null && existing.AccountName != creds.AccountName) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
if (existing?.AccountDeviceId is not null && existing.AccountDeviceId != creds.AccountDeviceId) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
int used = tx.CountSeats(id);
|
||||
int membersUsed = tx.CountMembers(id);
|
||||
bool accountAlreadyMember = creds.AccountName is not null && tx.GetChannelMember(id, creds.AccountName) is not null;
|
||||
if (existing is not null)
|
||||
{
|
||||
if (existing.AccountName is not null && existing.AccountDeviceId is null && creds.AccountDeviceId is not null)
|
||||
tx.BindSeatDevice(id, principal.EndpointHash, creds.AccountDeviceId);
|
||||
return (new SeatJoined { EndpointHash = Codec.Encode(existing.EndpointHash), JoinedAt = existing.JoinedAt, SeatsUsed = used, SeatsMax = channel.Settings.Seats }, false);
|
||||
return (new SeatJoined { EndpointHash = Codec.Encode(existing.EndpointHash), JoinedAt = existing.JoinedAt, SeatsUsed = membersUsed, SeatsMax = channel.Settings.Seats }, false);
|
||||
}
|
||||
|
||||
CheckAdmission(tx, channel, creds, password);
|
||||
|
||||
if (used >= channel.Settings.Seats) throw new WebDropException(ErrorCode.SeatsFull);
|
||||
bool newMember = creds.AccountName is null || !accountAlreadyMember;
|
||||
if (newMember && membersUsed >= channel.Settings.Seats) throw new WebDropException(ErrorCode.SeatsFull);
|
||||
long now = clock.NowMs;
|
||||
tx.InsertSeat(id, new Seat(principal.EndpointHash, now, null, creds.AccountName, creds.AccountDeviceId));
|
||||
if (creds.AccountName is not null) tx.PutChannelMember(id, new ChannelMember(creds.AccountName, "member", now));
|
||||
return (new SeatJoined { EndpointHash = Codec.Encode(principal.EndpointHash), JoinedAt = now, SeatsUsed = used + 1, SeatsMax = channel.Settings.Seats }, true);
|
||||
return (new SeatJoined { EndpointHash = Codec.Encode(principal.EndpointHash), JoinedAt = now, SeatsUsed = membersUsed + (newMember ? 1 : 0), SeatsMax = channel.Settings.Seats }, true);
|
||||
});
|
||||
if (isNew) events.SeatsChanged(id);
|
||||
return joined;
|
||||
@@ -199,7 +204,7 @@ public sealed partial class ChannelService(
|
||||
|
||||
public async Task<AppendResult> AppendFrameAsync(
|
||||
Id16 channelId, Credentials creds, ReadOnlyMemory<byte> body,
|
||||
long? maxDownloads, CancellationToken ct = default)
|
||||
long? maxDownloads, CancellationToken ct = default, SignedContentEventProof? signedEvent = null)
|
||||
{
|
||||
if (!Envelope.TryParse(body.Span, out var env)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (env.PayloadLength > limits.MaxPayloadBytes) throw new WebDropException(ErrorCode.TooLarge);
|
||||
@@ -212,6 +217,7 @@ public sealed partial class ChannelService(
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required);
|
||||
CheckWritable(channel, principal, env);
|
||||
CheckObjectWrite(tx, channel, principal, env);
|
||||
return tx.GetFrame(channelId, env.ItemId, env.FrameIndex);
|
||||
});
|
||||
if (existing is not null) return Resolve(existing, digest);
|
||||
@@ -228,7 +234,7 @@ public sealed partial class ChannelService(
|
||||
AppendOutcome outcome;
|
||||
try
|
||||
{
|
||||
outcome = store.Write(tx => AppendInTx(tx, channelId, creds, env, body, digest, blobKey, maxDownloads));
|
||||
outcome = store.Write(tx => AppendInTx(tx, channelId, creds, env, body, digest, blobKey, maxDownloads, signedEvent));
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -251,10 +257,11 @@ public sealed partial class ChannelService(
|
||||
|
||||
private AppendOutcome AppendInTx(
|
||||
IResourceTx tx, Id16 channelId, Credentials creds, Envelope env, ReadOnlyMemory<byte> body,
|
||||
byte[] digest, string? blobKey, long? maxDownloads)
|
||||
byte[] digest, string? blobKey, long? maxDownloads, SignedContentEventProof? signedEvent)
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required);
|
||||
CheckWritable(channel, principal, env);
|
||||
CheckObjectWrite(tx, channel, principal, env);
|
||||
|
||||
// 并发下预检之后可能已有同一帧
|
||||
if (tx.GetFrame(channelId, env.ItemId, env.FrameIndex) is { } dup)
|
||||
@@ -263,12 +270,54 @@ public sealed partial class ChannelService(
|
||||
long now = clock.NowMs;
|
||||
long ttlMs = channel.Settings.TtlSeconds * 1000L;
|
||||
var item = tx.GetItem(channelId, env.ItemId);
|
||||
var crypto = tx.GetWorkspaceCrypto(channelId);
|
||||
var objectEnvelope = crypto.Objects.Find(o => o.ItemId == env.ItemId.ToBase64Url());
|
||||
if (signedEvent is not null && (objectEnvelope is null || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (objectEnvelope is not null && env.IsFinal)
|
||||
{
|
||||
if (signedEvent is null)
|
||||
{
|
||||
if (creds.RequireSignedContent) throw new WebDropException(ErrorCode.Forbidden);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (objectEnvelope.AuthorAccount != principal.AccountName || signedEvent.Username != principal.AccountName || creds.AccountDeviceId != signedEvent.DeviceId
|
||||
|| objectEnvelope.ChannelId != signedEvent.ChannelId || objectEnvelope.Domain != signedEvent.Domain
|
||||
|| objectEnvelope.Epoch != signedEvent.Epoch || signedEvent.FrameCount != env.FrameIndex + 1
|
||||
|| signedEvent.EventIdBytes.Length != 16 || signedEvent.ManifestHash.Length != 32 || signedEvent.Signature.Length != 64
|
||||
|| objectEnvelope.Event is not null
|
||||
|| signedEvent.Domain == CryptoDomain.Chat && signedEvent.EventKind is not ("chat.create" or "chat.edit" or "chat.snapshot")
|
||||
|| signedEvent.Domain == CryptoDomain.Files && signedEvent.EventKind is not ("file.publish" or "file.catalog"))
|
||||
throw new WebDropException(ErrorCode.Forbidden);
|
||||
if (signedEvent.EventKind == "chat.edit")
|
||||
{
|
||||
if (signedEvent.TargetItemId is null || !Codec.TryDecodeExact(signedEvent.TargetItemId, 16, out var targetId)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var targetItem = tx.GetItem(channelId, Id16.FromBytes(targetId));
|
||||
var targetObject = crypto.Objects.Find(o => o.ItemId == signedEvent.TargetItemId);
|
||||
if (targetItem is null || targetObject?.Domain != CryptoDomain.Chat || !ChannelPolicy.CanDeleteItem(channel, targetItem, principal))
|
||||
throw new WebDropException(ErrorCode.Forbidden);
|
||||
}
|
||||
else if (signedEvent.TargetItemId is not null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var manifestFrames = tx.ListFrames(channelId, env.ItemId, 0, limits.MaxFramesPerItem);
|
||||
if (manifestFrames.Count != env.FrameIndex) throw new WebDropException(ErrorCode.Conflict);
|
||||
var digests = new List<byte[]>(manifestFrames.Count + 1);
|
||||
for (int i = 0; i < manifestFrames.Count; i++)
|
||||
{
|
||||
if (manifestFrames[i].Index != i) throw new WebDropException(ErrorCode.Conflict);
|
||||
digests.Add(manifestFrames[i].Digest);
|
||||
}
|
||||
digests.Add(digest);
|
||||
if (!CryptographicOperations.FixedTimeEquals(SignatureCodec.FrameManifestHash(digests), signedEvent.ManifestHash))
|
||||
throw new WebDropException(ErrorCode.Conflict);
|
||||
}
|
||||
}
|
||||
|
||||
if (env.FrameIndex == 0)
|
||||
{
|
||||
if (env.IsControl && tx.CountControlItems(channelId) >= 4096) throw new WebDropException(ErrorCode.TooLarge);
|
||||
if (item is not null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
long max = env.IsGated ? Math.Clamp(maxDownloads ?? channel.Settings.FileMaxDownloads, 0, 1_000_000) : 0;
|
||||
long max = env.IsGated ? maxDownloads ?? channel.Settings.FileMaxDownloads : 0;
|
||||
if (env.IsGated && !ServerSettingChoices.IsFileMaxDownloadsAllowed(max, channel.Settings.FileMaxDownloads)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
item = new Item(channelId, env.ItemId, principal.EndpointHash, env.ItemFlags, max, 0, 0, -1, null, 0,
|
||||
now, env.IsControl ? long.MaxValue : now + ttlMs, null, principal.AccountName);
|
||||
tx.InsertItem(item);
|
||||
@@ -293,6 +342,13 @@ public sealed partial class ChannelService(
|
||||
tx.InsertFrame(new StoredFrame(channelId, env.ItemId, env.FrameIndex, seq, body.Length, digest,
|
||||
blobKey is null ? body.ToArray() : null, blobKey));
|
||||
|
||||
if (signedEvent is not null && objectEnvelope is not null)
|
||||
{
|
||||
objectEnvelope.Event = ToSignedContentEventRecord(signedEvent);
|
||||
crypto.Revision++;
|
||||
tx.PutWorkspaceCrypto(channelId, crypto);
|
||||
}
|
||||
|
||||
if (!env.IsGated)
|
||||
tx.InsertLog(channelId, new LogEntry(seq, LogKind.Frame, env.ItemId, env.FrameIndex, 0, now));
|
||||
else if (env.FrameIndex == 0)
|
||||
@@ -386,6 +442,7 @@ public sealed partial class ChannelService(
|
||||
var list = new List<(long Seq, byte[]? Bytes, StoredFrame? Frame)>();
|
||||
foreach (var e in tx.ReadLog(id, from, limit))
|
||||
{
|
||||
if (e.ItemId != default && !CanReadObject(tx, channel, principal, e.ItemId)) continue;
|
||||
switch (e.Kind)
|
||||
{
|
||||
case LogKind.Frame:
|
||||
@@ -420,9 +477,10 @@ public sealed partial class ChannelService(
|
||||
{
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
|
||||
long now = clock.NowMs;
|
||||
if (item.ExpiresAt <= now) throw new WebDropException(ErrorCode.Gone);
|
||||
@@ -454,9 +512,10 @@ public sealed partial class ChannelService(
|
||||
maxFrames = Math.Clamp(maxFrames, 1, 64);
|
||||
var (frames, next, complete) = store.Read(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
if (item.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.Gone);
|
||||
if (item.IsGated && !tx.HasDownloadGrant(id, itemId, principal.EndpointHash))
|
||||
throw new WebDropException(ErrorCode.Gone);
|
||||
@@ -488,7 +547,13 @@ public sealed partial class ChannelService(
|
||||
|
||||
// ───────────────────────── 删除 ─────────────────────────
|
||||
|
||||
public void DeleteItem(Id16 id, Credentials creds, Id16 itemId)
|
||||
public ObjectEnvelope? ObjectSigningContext(Id16 id, Credentials creds, Id16 itemId) => store.Read(tx =>
|
||||
{
|
||||
_ = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
return tx.GetWorkspaceCrypto(id).Objects.Find(o => o.ItemId == itemId.ToBase64Url());
|
||||
});
|
||||
|
||||
public void DeleteItem(Id16 id, Credentials creds, Id16 itemId, SignedContentEventProof? signedEvent = null)
|
||||
{
|
||||
var (seq, prefixes) = store.Write(tx =>
|
||||
{
|
||||
@@ -496,6 +561,26 @@ public sealed partial class ChannelService(
|
||||
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (!ChannelPolicy.CanDeleteItem(channel, item, principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
|
||||
var crypto = tx.GetWorkspaceCrypto(id);
|
||||
var obj = crypto.Objects.Find(o => o.ItemId == itemId.ToBase64Url());
|
||||
if (creds.RequireSignedContent && obj is not null)
|
||||
{
|
||||
if (signedEvent is null || obj?.Event is null || signedEvent.Username != principal.AccountName
|
||||
|| signedEvent.DeviceId != creds.AccountDeviceId || signedEvent.ChannelId != obj.ChannelId
|
||||
|| signedEvent.Domain != obj.Domain || signedEvent.Epoch != obj.Epoch
|
||||
|| signedEvent.FrameCount != obj.Event.FrameCount || signedEvent.ManifestHash.Length != 32
|
||||
|| !Codec.TryDecodeExact(obj.Event.ManifestHash, 32, out var oldManifest)
|
||||
|| !CryptographicOperations.FixedTimeEquals(signedEvent.ManifestHash, oldManifest)
|
||||
|| signedEvent.EventKind != (obj.Domain == CryptoDomain.Chat ? "chat.delete" : "file.delete")
|
||||
|| signedEvent.TargetItemId is not null || signedEvent.EventIdBytes.Length != 16 || signedEvent.Signature.Length != 64
|
||||
|| obj.DeleteEvent is not null)
|
||||
throw new WebDropException(ErrorCode.Forbidden);
|
||||
obj.DeleteEvent = ToSignedContentEventRecord(signedEvent);
|
||||
crypto.Revision++;
|
||||
tx.PutWorkspaceCrypto(id, crypto);
|
||||
}
|
||||
else if (signedEvent is not null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
|
||||
var list = new List<string>();
|
||||
long s = DeleteItemCore(tx, id, item, TombstoneReason.Deleted, list);
|
||||
return (s, list);
|
||||
@@ -514,6 +599,17 @@ public sealed partial class ChannelService(
|
||||
return seq;
|
||||
}
|
||||
|
||||
private static SignedContentEventRecord ToSignedContentEventRecord(SignedContentEventProof signedEvent) => new()
|
||||
{
|
||||
EventId = Codec.Encode(signedEvent.EventIdBytes), Kind = signedEvent.EventKind, FrameCount = signedEvent.FrameCount,
|
||||
ManifestHash = Convert.ToBase64String(signedEvent.ManifestHash).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
|
||||
SignerDeviceId = signedEvent.DeviceId,
|
||||
Signature = Convert.ToBase64String(signedEvent.Signature).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
|
||||
TargetItemId = signedEvent.TargetItemId,
|
||||
SignerRootPublicKey = signedEvent.RootPublicKey, SignerDevicePublicKey = signedEvent.DevicePublicKey,
|
||||
DeviceCertificateSignature = Convert.ToBase64String(signedEvent.DeviceCertificateSignature).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
|
||||
};
|
||||
|
||||
// ───────────────────────── 后台清理 ─────────────────────────
|
||||
|
||||
/// <summary>清理过期条目、下载达上限的条目、过期频道;可选清理孤立 blob。返回删除的条目和频道总数。</summary>
|
||||
@@ -618,14 +714,16 @@ public sealed partial class ChannelService(
|
||||
if (!ChannelPolicy.IsOwner(principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
}
|
||||
|
||||
private static ChannelInfo ToInfo(Channel channel, int seatsUsed) => new()
|
||||
private static ChannelInfo ToInfo(Channel channel, int membersUsed, Principal? principal = null) => new()
|
||||
{
|
||||
CanUpload = ChannelPolicy.CanWrite(channel, principal ?? new Principal(Role.Owner, [], channel.CreatorAccount)),
|
||||
CanManageStructure = principal is null || principal.Role == Role.Owner,
|
||||
Id = channel.Id.ToBase64Url(),
|
||||
CreatorAccount = channel.CreatorAccount,
|
||||
PasswordRequired = channel.JoinPasswordHash is not null,
|
||||
Mode = channel.Mode,
|
||||
Settings = channel.Settings,
|
||||
SeatsUsed = seatsUsed,
|
||||
SeatsUsed = membersUsed,
|
||||
OldestSeq = channel.CompactedThrough + 1,
|
||||
LastSeq = channel.LastSeq,
|
||||
CreatedAt = channel.CreatedAt,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
namespace WebDrop.Core;
|
||||
|
||||
public sealed partial class ChannelService
|
||||
{
|
||||
public void AccountProfileChanged(string username)
|
||||
{
|
||||
foreach (var id in store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs))) events.SettingsChanged(id);
|
||||
}
|
||||
public IReadOnlyList<ChannelInfo> AccountSpaces(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs).Select(id =>
|
||||
{
|
||||
var channel = tx.GetChannel(id)!;
|
||||
var role = tx.GetChannelMember(id, username)?.Role == "owner" ? Role.Owner : channel.WriteHash is not null ? Role.Write : Role.Read;
|
||||
return ToInfo(channel, tx.CountMembers(id), new Principal(role, [], username));
|
||||
}).ToList());
|
||||
private static WorkspaceState NewWorkspace(string username, long now)
|
||||
{
|
||||
var id = Guid.NewGuid().ToString();
|
||||
return new() { Revision = 1, DefaultChannelId = id, Channels = [new WorkspaceChannel { Id = id, Name = "常规", CreatedAt = now, CreatedBy = username }] };
|
||||
}
|
||||
public WorkspaceState GetWorkspace(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var state = tx.GetWorkspace(id) ?? new WorkspaceState();
|
||||
if (principal.AccountName != space.CreatorAccount)
|
||||
{
|
||||
var crypto = tx.GetWorkspaceCrypto(id);
|
||||
var grants = crypto.Access.Where(a => a.Username == principal.AccountName).ToList();
|
||||
var ids = grants.Where(a => a.ChatRead || a.FilesRead).Select(a => a.ChannelId).ToHashSet();
|
||||
state.Channels.RemoveAll(c => !ids.Contains(c.Id));
|
||||
state.Folders.RemoveAll(f => !ids.Contains(f.ThreadId));
|
||||
state.Locations.RemoveAll(l => !Id16.TryParse(l.ItemId, out var itemId) || !CanReadObject(tx, space, principal, itemId));
|
||||
}
|
||||
state.Locations = state.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is { } item && item.ExpiresAt > clock.NowMs).ToList();
|
||||
return state;
|
||||
});
|
||||
private WorkspaceState ChangeWorkspace(Id16 id, Credentials creds, Action<IResourceTx, WorkspaceState, Principal> change, bool requireAccount = true)
|
||||
{
|
||||
var state = store.Write(tx =>
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
if (requireAccount && principal.AccountName is null) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
if (requireAccount && principal.Role != Role.Owner) throw new WebDropException(ErrorCode.Forbidden);
|
||||
if (!ChannelPolicy.CanWrite(channel, principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
var current = tx.GetWorkspace(id) ?? new WorkspaceState();
|
||||
current.Locations = current.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is not null).ToList();
|
||||
change(tx, current, principal);
|
||||
ValidateWorkspace(current);
|
||||
current.Revision++;
|
||||
tx.PutWorkspace(id, current);
|
||||
return current;
|
||||
});
|
||||
events.SettingsChanged(id);
|
||||
return state;
|
||||
}
|
||||
public (WorkspaceState State, WorkspaceChannel Channel) CreateWorkspaceChannelResult(Id16 id, Credentials creds, string name, string? groupId)
|
||||
{
|
||||
WorkspaceChannel? created = null;
|
||||
var state = ChangeWorkspace(id, creds, (_, workspace, principal) =>
|
||||
{
|
||||
created = new WorkspaceChannel { Id = Guid.NewGuid().ToString(), Name = WorkspaceName(name), GroupId = groupId, CreatedAt = clock.NowMs, CreatedBy = principal.AccountName! };
|
||||
workspace.Channels.Add(created);
|
||||
});
|
||||
return (state, created!);
|
||||
}
|
||||
public WorkspaceState CreateWorkspaceChannel(Id16 id, Credentials creds, string name, string? groupId) => CreateWorkspaceChannelResult(id, creds, name, groupId).State;
|
||||
public WorkspaceState RenameWorkspaceChannel(Id16 id, Credentials creds, string channelId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
|
||||
FindChannel(state, channelId).Name = WorkspaceName(name));
|
||||
public WorkspaceState GroupWorkspaceChannel(Id16 id, Credentials creds, string channelId, string? groupId) => ChangeWorkspace(id, creds, (_, state, _) =>
|
||||
FindChannel(state, channelId).GroupId = groupId);
|
||||
public (WorkspaceState State, WorkspaceGroup Group) CreateWorkspaceGroupResult(Id16 id, Credentials creds, string name)
|
||||
{
|
||||
WorkspaceGroup? created = null;
|
||||
var state = ChangeWorkspace(id, creds, (_, workspace, _) =>
|
||||
{
|
||||
created = new WorkspaceGroup { Id = Guid.NewGuid().ToString(), Name = WorkspaceName(name), CreatedAt = clock.NowMs };
|
||||
workspace.Groups.Add(created);
|
||||
});
|
||||
return (state, created!);
|
||||
}
|
||||
public WorkspaceState CreateWorkspaceGroup(Id16 id, Credentials creds, string name) => CreateWorkspaceGroupResult(id, creds, name).State;
|
||||
public WorkspaceState RenameWorkspaceGroup(Id16 id, Credentials creds, string groupId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
|
||||
(state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name));
|
||||
public WorkspaceState CreateWorkspaceFolder(Id16 id, Credentials creds, string threadId, string? parentId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
|
||||
state.Folders.Add(new WorkspaceFolder { Id = Guid.NewGuid().ToString(), ThreadId = threadId, ParentId = parentId, Name = WorkspaceName(name) }));
|
||||
public WorkspaceState RenameWorkspaceFolder(Id16 id, Credentials creds, string folderId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
|
||||
(state.Folders.Find(f => f.Id == folderId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name));
|
||||
public WorkspaceState MoveWorkspaceItems(Id16 id, Credentials creds, string[] itemIds, string threadId, string? folderId) => ChangeWorkspace(id, creds, (tx, state, principal) =>
|
||||
{
|
||||
if (itemIds.Length is < 1 or > 100 || itemIds.Distinct().Count() != itemIds.Length) throw new WebDropException(ErrorCode.BadRequest);
|
||||
foreach (var itemId in itemIds)
|
||||
{
|
||||
if (!Id16.TryParse(itemId, out var parsed)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var item = tx.GetItem(id, parsed) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if ((item.Flags & (FrameFlags.Control | FrameFlags.Snapshot)) != 0) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (principal.Role != Role.Owner && (item.AuthorAccount is not null ? item.AuthorAccount != principal.AccountName : !item.AuthorHash.AsSpan().SequenceEqual(principal.EndpointHash))) throw new WebDropException(ErrorCode.Forbidden);
|
||||
state.Locations.RemoveAll(l => l.ItemId == itemId);
|
||||
state.Locations.Add(new WorkspaceLocation { ItemId = itemId, ThreadId = threadId, FolderId = folderId, AuthorAccount = item.AuthorAccount });
|
||||
}
|
||||
}, requireAccount: false);
|
||||
private static WorkspaceChannel FindChannel(WorkspaceState state, string id) => state.Channels.Find(c => c.Id == id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
private static string WorkspaceName(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name) || name.Trim().Length > 80 || name.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
return name.Trim();
|
||||
}
|
||||
private static void ValidateWorkspace(WorkspaceState state)
|
||||
{
|
||||
if (state.Channels is null || state.Groups is null || state.Folders is null || state.Locations is null
|
||||
|| state.Channels.Count > 256 || state.Groups.Count > 128 || state.Folders.Count > 2048 || state.Locations.Count > 10_000)
|
||||
throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (state.Channels.Any(c => c is null) || state.Groups.Any(g => g is null) || state.Folders.Any(f => f is null) || state.Locations.Any(l => l is null)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var allIds = state.Channels.Select(c => c.Id).Concat(state.Groups.Select(g => g.Id)).Concat(state.Folders.Select(f => f.Id)).ToArray();
|
||||
if (allIds.Any(id => !Guid.TryParseExact(id, "D", out _)) || allIds.Distinct().Count() != allIds.Length) throw new WebDropException(ErrorCode.BadRequest);
|
||||
foreach (var group in state.Groups) group.Name = WorkspaceName(group.Name);
|
||||
foreach (var channel in state.Channels)
|
||||
{
|
||||
channel.Name = WorkspaceName(channel.Name);
|
||||
if (channel.GroupId is not null && !state.Groups.Any(g => g.Id == channel.GroupId)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
}
|
||||
foreach (var folder in state.Folders)
|
||||
{
|
||||
folder.Name = WorkspaceName(folder.Name);
|
||||
_ = FindChannel(state, folder.ThreadId);
|
||||
var visited = new HashSet<string> { folder.Id };
|
||||
for (var parentId = folder.ParentId; parentId is not null;)
|
||||
{
|
||||
var parent = state.Folders.Find(f => f.Id == parentId && f.ThreadId == folder.ThreadId);
|
||||
if (parent is null || !visited.Add(parent.Id)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
parentId = parent.ParentId;
|
||||
}
|
||||
}
|
||||
if (state.Locations.Select(l => l.ItemId).Distinct().Count() != state.Locations.Count) throw new WebDropException(ErrorCode.BadRequest);
|
||||
foreach (var location in state.Locations)
|
||||
{
|
||||
_ = FindChannel(state, location.ThreadId);
|
||||
if (location.FolderId is not null && !state.Folders.Any(f => f.Id == location.FolderId && f.ThreadId == location.ThreadId)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,11 +52,14 @@ public sealed class ShareItemInfo
|
||||
|
||||
public sealed class ChannelInfo
|
||||
{
|
||||
[JsonPropertyName("can_upload")] public bool CanUpload { get; init; }
|
||||
[JsonPropertyName("can_manage_structure")] public bool CanManageStructure { get; init; }
|
||||
[JsonPropertyName("password_required")] public bool PasswordRequired { get; init; }
|
||||
[JsonPropertyName("creator_account")] public string? CreatorAccount { get; init; }
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("mode")] public required ChannelMode Mode { get; init; }
|
||||
[JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; }
|
||||
/// <summary>v5: 账号成员数加匿名访客终端数,不统计账号设备数。</summary>
|
||||
[JsonPropertyName("seats_used")] public required int SeatsUsed { get; init; }
|
||||
|
||||
/// <summary>从该 seq 起的续传有效;0 < from < oldest_seq 的续传会得到 410,需从 0 全量同步。</summary>
|
||||
@@ -71,6 +74,7 @@ public sealed class SeatJoined
|
||||
{
|
||||
[JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; }
|
||||
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
|
||||
/// <summary>加入后的成员人数,同一账号的其他终端不增加此值。</summary>
|
||||
[JsonPropertyName("seats_used")] public required int SeatsUsed { get; init; }
|
||||
[JsonPropertyName("seats_max")] public required int SeatsMax { get; init; }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,20 @@ public enum ChannelMode : byte
|
||||
[JsonStringEnumMemberName("raw")] Raw = 2,
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<CryptoDomain>))]
|
||||
public enum CryptoDomain : byte
|
||||
{
|
||||
[JsonStringEnumMemberName("chat")] Chat = 1,
|
||||
[JsonStringEnumMemberName("files")] Files = 2,
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<JoinPolicy>))]
|
||||
public enum JoinPolicy : byte
|
||||
{
|
||||
[JsonStringEnumMemberName("open")] Open = 1,
|
||||
[JsonStringEnumMemberName("invite")] Invite = 2,
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<DeletePolicy>))]
|
||||
public enum DeletePolicy : byte
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ public enum ErrorCode
|
||||
InvitationRequired,
|
||||
ChannelPasswordRequired,
|
||||
ChannelPasswordInvalid,
|
||||
SpaceNameRequired,
|
||||
}
|
||||
|
||||
/// <summary>预期内的业务失败。消息只含错误码,绝不回显用户提交的内容。</summary>
|
||||
@@ -61,6 +62,7 @@ public static class ErrorCodes
|
||||
ErrorCode.InvitationRequired => "invitation_required",
|
||||
ErrorCode.ChannelPasswordRequired => "channel_password_required",
|
||||
ErrorCode.ChannelPasswordInvalid => "channel_password_invalid",
|
||||
ErrorCode.SpaceNameRequired => "space_name_required",
|
||||
_ => "bad_request",
|
||||
};
|
||||
|
||||
@@ -72,6 +74,7 @@ public static class ErrorCodes
|
||||
ErrorCode.NotFound => 404,
|
||||
ErrorCode.Conflict => 409,
|
||||
ErrorCode.Sealed => 409,
|
||||
ErrorCode.SpaceNameRequired => 400,
|
||||
ErrorCode.Gone => 410,
|
||||
ErrorCode.TooLarge => 413,
|
||||
ErrorCode.CaptchaRequired => 428,
|
||||
|
||||
@@ -104,4 +104,5 @@ public sealed record LogEntry(long Seq, LogKind Kind, Id16 ItemId, uint FrameInd
|
||||
public sealed record Principal(Role Role, byte[] EndpointHash, string? AccountName = null);
|
||||
|
||||
/// <summary>请求携带的凭据,尚未验证。</summary>
|
||||
public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash, string? AccountName = null, string? AccountDeviceId = null);
|
||||
public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash, string? AccountName = null, string? AccountDeviceId = null,
|
||||
bool RequireSignedContent = false);
|
||||
@@ -8,7 +8,9 @@ public sealed class ServerLimits
|
||||
public int MaxPayloadBytes { get; set; } = (1 << 20) + 16;
|
||||
public int MaxFramesPerItem { get; set; } = 65_536;
|
||||
public long MaxChannelBytes { get; set; } = 1L << 30;
|
||||
/// <summary>最大账号成员数。属性名和 info 字段保持旧线格式兼容。</summary>
|
||||
public int MaxSeats { get; set; } = 64;
|
||||
public int MaxVisitors { get; set; } = 10_000;
|
||||
public int MinTtlSeconds { get; set; } = 60;
|
||||
public int MaxTtlSeconds { get; set; } = 604_800;
|
||||
public int MaxItemsLimit { get; set; } = 10_000;
|
||||
@@ -18,11 +20,19 @@ public sealed class ServerLimits
|
||||
public int MaxSeatLabelBytes { get; set; } = 256;
|
||||
}
|
||||
|
||||
public static class ServerSettingChoices
|
||||
{
|
||||
public static readonly int[] FileMaxDownloads = [0, 1, 3, 5, 10, 20];
|
||||
public static bool IsFileMaxDownloadsAllowed(long value, int currentDefault) => value == currentDefault
|
||||
|| value is >= int.MinValue and <= int.MaxValue && Array.IndexOf(FileMaxDownloads, (int)value) >= 0;
|
||||
}
|
||||
|
||||
public sealed class ChannelSettings
|
||||
{
|
||||
[JsonPropertyName("join_policy")] public string JoinPolicy { get; set; } = "open";
|
||||
[JsonPropertyName("join_policy")] public JoinPolicy JoinPolicy { get; set; } = JoinPolicy.Open;
|
||||
[JsonPropertyName("allow_guests")] public bool AllowGuests { get; set; } = true;
|
||||
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||
/// <summary>成员人数上限;线字段名保留为 seats 以兼容已有频道设置。</summary>
|
||||
[JsonPropertyName("seats")] public int Seats { get; set; } = 2;
|
||||
[JsonPropertyName("members_can_upload")] public bool MembersCanUpload { get; set; } = true;
|
||||
[JsonPropertyName("delete_policy")] public DeletePolicy DeletePolicy { get; set; } = DeletePolicy.Own;
|
||||
@@ -33,7 +43,7 @@ public sealed class ChannelSettings
|
||||
|
||||
public void Validate(ServerLimits limits)
|
||||
{
|
||||
if (JoinPolicy is not ("open" or "invite")) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (!Enum.IsDefined(JoinPolicy)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (Name is { } name && (name.Length > 48 || name.Any(char.IsControl))) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (Seats < 1 || Seats > limits.MaxSeats) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (TtlSeconds < limits.MinTtlSeconds || TtlSeconds > limits.MaxTtlSeconds) throw new WebDropException(ErrorCode.BadRequest);
|
||||
@@ -44,7 +54,7 @@ public sealed class ChannelSettings
|
||||
|
||||
public sealed class ChannelSettingsPatch
|
||||
{
|
||||
[JsonPropertyName("join_policy")] public string? JoinPolicy { get; init; }
|
||||
[JsonPropertyName("join_policy")] public JoinPolicy? JoinPolicy { get; init; }
|
||||
[JsonPropertyName("allow_guests")] public bool? AllowGuests { get; init; }
|
||||
[JsonPropertyName("join_password")] public string? JoinPassword { get; init; }
|
||||
[JsonPropertyName("name")] public string? Name { get; init; }
|
||||
@@ -80,7 +90,7 @@ public sealed class ShareSettings
|
||||
public void Validate(ServerLimits limits)
|
||||
{
|
||||
if (TtlSeconds < limits.MinTtlSeconds || TtlSeconds > limits.MaxTtlSeconds
|
||||
|| MaxVisitors < 0 || MaxVisitors > 10_000
|
||||
|| MaxVisitors < 0 || MaxVisitors > limits.MaxVisitors
|
||||
|| FileMaxDownloads < 0)
|
||||
throw new WebDropException(ErrorCode.BadRequest);
|
||||
}
|
||||
@@ -99,3 +109,22 @@ public sealed class ShareSettingsPatch
|
||||
FileMaxDownloads = FileMaxDownloads ?? s.FileMaxDownloads,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>创建接口与 /info 使用同一个配置来源;旧存储对象的字段默认值不作迁移。</summary>
|
||||
public static class CreationDefaults
|
||||
{
|
||||
public const ChannelMode ChannelMode = WebDrop.Core.ChannelMode.Enc;
|
||||
|
||||
public static ChannelSettings Channel(ServerLimits limits) => new()
|
||||
{
|
||||
JoinPolicy = JoinPolicy.Invite, AllowGuests = false,
|
||||
Seats = Math.Min(10, limits.MaxSeats),
|
||||
TtlSeconds = Math.Clamp(86_400, limits.MinTtlSeconds, limits.MaxTtlSeconds),
|
||||
MaxItems = Math.Min(1000, limits.MaxItemsLimit),
|
||||
};
|
||||
public static ShareSettings Share(ServerLimits limits) => new()
|
||||
{
|
||||
TtlSeconds = Math.Clamp(86_400, limits.MinTtlSeconds, limits.MaxTtlSeconds),
|
||||
MaxVisitors = Math.Min(10, limits.MaxVisitors),
|
||||
};
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public sealed class ShareService(
|
||||
|| !Codec.TryDecodeExact(req.OwnerHash, TokenHash.Size, out var ownerHash))
|
||||
throw new WebDropException(ErrorCode.BadRequest);
|
||||
|
||||
var settings = req.Settings ?? new ShareSettings();
|
||||
var settings = req.Settings ?? CreationDefaults.Share(limits);
|
||||
settings.Validate(limits);
|
||||
long now = clock.NowMs;
|
||||
var share = new Share(id, settings, readHash, ownerHash, creatorEndpointHash, now,
|
||||
@@ -102,6 +102,7 @@ public sealed class ShareService(
|
||||
if (item is not null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (tx.CountShareItems(id) >= limits.MaxItemsLimit) throw new WebDropException(ErrorCode.TooLarge);
|
||||
long cap = env.IsGated ? maxDownloads ?? share.Settings.FileMaxDownloads : 0;
|
||||
if (env.IsGated && !ServerSettingChoices.IsFileMaxDownloadsAllowed(cap, share.Settings.FileMaxDownloads)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
item = new ShareItem(id, env.ItemId, env.ItemFlags, cap, 0, 0, -1, null,
|
||||
0, now, share.ExpiresAt, null);
|
||||
tx.InsertShareItem(item);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace WebDrop.Core;
|
||||
|
||||
/// <summary>Protocol signature framing. Payload encodings are defined by their signed statement type.</summary>
|
||||
public static class SignatureCodec
|
||||
{
|
||||
private static ReadOnlySpan<byte> Magic => [0x57, 0x44, 0x53, 0x01]; // WDS\x01
|
||||
private const int MaxKindBytes = 64;
|
||||
private const int MaxPayloadBytes = 1_048_576;
|
||||
|
||||
public static byte[] DeviceCertificatePayload(string username, string deviceId, ReadOnlySpan<byte> x, ReadOnlySpan<byte> y)
|
||||
{
|
||||
if (string.IsNullOrEmpty(username) || username.Length > 32 || username.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '_' or '-'))
|
||||
|| !Guid.TryParseExact(deviceId, "D", out _) || x.Length != 32 || y.Length != 32)
|
||||
throw new ArgumentException("Invalid device certificate fields.");
|
||||
var user = Encoding.ASCII.GetBytes(username.ToLowerInvariant());
|
||||
var device = Encoding.ASCII.GetBytes(deviceId.ToLowerInvariant());
|
||||
var payload = new byte[1 + user.Length + 1 + device.Length + 64];
|
||||
int offset = 0;
|
||||
payload[offset++] = (byte)user.Length; user.CopyTo(payload, offset); offset += user.Length;
|
||||
payload[offset++] = (byte)device.Length; device.CopyTo(payload, offset); offset += device.Length;
|
||||
x.CopyTo(payload.AsSpan(offset)); y.CopyTo(payload.AsSpan(offset + 32));
|
||||
return payload;
|
||||
}
|
||||
|
||||
public static byte[] ContentEventPayload(ReadOnlySpan<byte> spaceId, string channelId, string username, string deviceId,
|
||||
ReadOnlySpan<byte> eventId, string eventKind, CryptoDomain domain, int epoch, ReadOnlySpan<byte> itemId,
|
||||
int frameCount, ReadOnlySpan<byte> manifestHash, ReadOnlySpan<byte> targetItemId)
|
||||
{
|
||||
if (spaceId.Length != 16 || eventId.Length != 16 || itemId.Length != 16 || manifestHash.Length != 32
|
||||
|| targetItemId.Length is not (0 or 16) || epoch < 0 || frameCount < 1
|
||||
|| !Guid.TryParseExact(channelId, "D", out _) || !Enum.IsDefined(domain)
|
||||
|| !IsAsciiIdentity(username, 32) || !Guid.TryParseExact(deviceId, "D", out _)
|
||||
|| eventKind.Length is < 1 or > 32 || eventKind.Any(c => c is not (>= 'a' and <= 'z') and not '.' and not '-'))
|
||||
throw new ArgumentException("Invalid signed content event fields.");
|
||||
|
||||
byte[] channel = Encoding.ASCII.GetBytes(channelId.ToLowerInvariant());
|
||||
byte[] user = Encoding.ASCII.GetBytes(username.ToLowerInvariant());
|
||||
byte[] device = Encoding.ASCII.GetBytes(deviceId.ToLowerInvariant());
|
||||
byte[] kind = Encoding.ASCII.GetBytes(eventKind);
|
||||
var payload = new byte[16 + 1 + channel.Length + 1 + user.Length + 1 + device.Length + 16 + 1 + kind.Length + 1 + 4 + 16 + 4 + 32 + 1 + 16];
|
||||
int offset = 0;
|
||||
spaceId.CopyTo(payload.AsSpan(offset)); offset += 16;
|
||||
payload[offset++] = (byte)channel.Length; channel.CopyTo(payload, offset); offset += channel.Length;
|
||||
payload[offset++] = (byte)user.Length; user.CopyTo(payload, offset); offset += user.Length;
|
||||
payload[offset++] = (byte)device.Length; device.CopyTo(payload, offset); offset += device.Length;
|
||||
eventId.CopyTo(payload.AsSpan(offset)); offset += 16;
|
||||
payload[offset++] = (byte)kind.Length; kind.CopyTo(payload, offset); offset += kind.Length;
|
||||
payload[offset++] = (byte)domain;
|
||||
BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(offset), (uint)epoch); offset += 4;
|
||||
itemId.CopyTo(payload.AsSpan(offset)); offset += 16;
|
||||
BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(offset), (uint)frameCount); offset += 4;
|
||||
manifestHash.CopyTo(payload.AsSpan(offset)); offset += 32;
|
||||
payload[offset++] = targetItemId.IsEmpty ? (byte)0 : (byte)1;
|
||||
if (!targetItemId.IsEmpty) targetItemId.CopyTo(payload.AsSpan(offset));
|
||||
return payload;
|
||||
}
|
||||
|
||||
public static byte[] FrameManifestHash(IReadOnlyList<byte[]> frameDigests)
|
||||
{
|
||||
if (frameDigests.Count is < 1 or > 65_536 || frameDigests.Any(d => d.Length != 32)) throw new ArgumentException("Invalid frame manifest.");
|
||||
var data = new byte[8 + frameDigests.Count * 36];
|
||||
"WD1M"u8.CopyTo(data);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(4), (uint)frameDigests.Count);
|
||||
int offset = 8;
|
||||
for (int i = 0; i < frameDigests.Count; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(offset), (uint)i); offset += 4;
|
||||
frameDigests[i].CopyTo(data, offset); offset += 32;
|
||||
}
|
||||
return SHA256.HashData(data);
|
||||
}
|
||||
|
||||
private static bool IsAsciiIdentity(string value, int maxLength) => value.Length is >= 3 && value.Length <= maxLength
|
||||
&& value.All(c => char.IsAsciiLetterOrDigit(c) || c is '_' or '-');
|
||||
|
||||
public static byte[] BuildMessage(string kind, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (string.IsNullOrEmpty(kind) || kind.Length > MaxKindBytes
|
||||
|| kind[0] is < 'a' or > 'z'
|
||||
|| kind.Any(c => c is not (>= 'a' and <= 'z') and not (>= '0' and <= '9') and not '.' and not '_' and not '-'))
|
||||
throw new ArgumentException("Invalid signature kind.", nameof(kind));
|
||||
if (payload.Length > MaxPayloadBytes) throw new ArgumentOutOfRangeException(nameof(payload));
|
||||
|
||||
var kindBytes = Encoding.ASCII.GetBytes(kind);
|
||||
var message = new byte[Magic.Length + 2 + kindBytes.Length + 4 + payload.Length];
|
||||
int offset = 0;
|
||||
Magic.CopyTo(message.AsSpan(offset)); offset += Magic.Length;
|
||||
message[offset++] = (byte)(kindBytes.Length >> 8);
|
||||
message[offset++] = (byte)kindBytes.Length;
|
||||
kindBytes.CopyTo(message, offset); offset += kindBytes.Length;
|
||||
uint length = (uint)payload.Length;
|
||||
message[offset++] = (byte)(length >> 24);
|
||||
message[offset++] = (byte)(length >> 16);
|
||||
message[offset++] = (byte)(length >> 8);
|
||||
message[offset++] = (byte)length;
|
||||
payload.CopyTo(message.AsSpan(offset));
|
||||
return message;
|
||||
}
|
||||
|
||||
/// <summary>Verifies WebCrypto ECDSA P-256/SHA-256 signatures in fixed-width IEEE P1363 form.</summary>
|
||||
public static bool VerifyP256(ReadOnlySpan<byte> x, ReadOnlySpan<byte> y, string kind,
|
||||
ReadOnlySpan<byte> payload, ReadOnlySpan<byte> signature)
|
||||
{
|
||||
if (x.Length != 32 || y.Length != 32 || signature.Length != 64) return false;
|
||||
try
|
||||
{
|
||||
using var key = ECDsa.Create(new ECParameters
|
||||
{
|
||||
Curve = ECCurve.NamedCurves.nistP256,
|
||||
Q = new ECPoint { X = x.ToArray(), Y = y.ToArray() },
|
||||
});
|
||||
return key.VerifyData(BuildMessage(kind, payload), signature, HashAlgorithmName.SHA256,
|
||||
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||
}
|
||||
catch (ArgumentException) { return false; }
|
||||
catch (CryptographicException) { return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace WebDrop.Core;
|
||||
|
||||
public sealed class WorkspaceChannel
|
||||
{
|
||||
[JsonPropertyName("chatEpoch")] public int ChatEpoch { get; set; }
|
||||
[JsonPropertyName("filesEpoch")] public int FilesEpoch { get; set; }
|
||||
[JsonPropertyName("chatHistory")] public bool ChatHistory { get; set; } = true;
|
||||
[JsonPropertyName("filesHistory")] public bool FilesHistory { get; set; } = true;
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("name")] public required string Name { get; set; }
|
||||
[JsonPropertyName("createdAt")] public required long CreatedAt { get; init; }
|
||||
[JsonPropertyName("createdBy")] public required string CreatedBy { get; init; }
|
||||
[JsonPropertyName("groupId")] public string? GroupId { get; set; }
|
||||
}
|
||||
public sealed class WorkspaceGroup
|
||||
{
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("name")] public required string Name { get; set; }
|
||||
[JsonPropertyName("createdAt")] public required long CreatedAt { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceFolder
|
||||
{
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("threadId")] public required string ThreadId { get; init; }
|
||||
[JsonPropertyName("parentId")] public string? ParentId { get; init; }
|
||||
[JsonPropertyName("name")] public required string Name { get; set; }
|
||||
}
|
||||
public sealed class WorkspaceLocation
|
||||
{
|
||||
[JsonPropertyName("itemId")] public required string ItemId { get; init; }
|
||||
[JsonPropertyName("threadId")] public required string ThreadId { get; init; }
|
||||
[JsonPropertyName("folderId")] public string? FolderId { get; init; }
|
||||
[JsonPropertyName("authorAccount")] public string? AuthorAccount { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceState
|
||||
{
|
||||
[JsonPropertyName("defaultChannelId")] public string? DefaultChannelId { get; init; }
|
||||
[JsonPropertyName("revision")] public long Revision { get; set; }
|
||||
[JsonPropertyName("channels")] public List<WorkspaceChannel> Channels { get; init; } = [];
|
||||
[JsonPropertyName("groups")] public List<WorkspaceGroup> Groups { get; init; } = [];
|
||||
[JsonPropertyName("folders")] public List<WorkspaceFolder> Folders { get; init; } = [];
|
||||
[JsonPropertyName("locations")] public List<WorkspaceLocation> Locations { get; set; } = [];
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(WorkspaceState))]
|
||||
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public sealed partial class WorkspaceJson : JsonSerializerContext;
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace WebDrop.Core;
|
||||
|
||||
public sealed class DomainEnvelope
|
||||
{
|
||||
[JsonPropertyName("channelId")] public required string ChannelId { get; init; }
|
||||
[JsonPropertyName("domain")] public required CryptoDomain Domain { get; init; }
|
||||
[JsonPropertyName("epoch")] public required int Epoch { get; init; }
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("payload")] public required string Payload { get; init; }
|
||||
}
|
||||
public sealed class ObjectEnvelope
|
||||
{
|
||||
[JsonPropertyName("createdRevision")] public long CreatedRevision { get; set; }
|
||||
[JsonPropertyName("itemId")] public required string ItemId { get; init; }
|
||||
[JsonPropertyName("channelId")] public required string ChannelId { get; init; }
|
||||
[JsonPropertyName("domain")] public required CryptoDomain Domain { get; init; }
|
||||
[JsonPropertyName("epoch")] public required int Epoch { get; init; }
|
||||
[JsonPropertyName("keyEnvelope")] public required string KeyEnvelope { get; init; }
|
||||
[JsonPropertyName("authorAccount")] public string? AuthorAccount { get; set; }
|
||||
[JsonPropertyName("event")] public SignedContentEventRecord? Event { get; set; }
|
||||
[JsonPropertyName("deleteEvent")] public SignedContentEventRecord? DeleteEvent { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SignedContentEventRecord
|
||||
{
|
||||
[JsonPropertyName("eventId")] public required string EventId { get; init; }
|
||||
[JsonPropertyName("kind")] public required string Kind { get; init; }
|
||||
[JsonPropertyName("frameCount")] public required int FrameCount { get; init; }
|
||||
[JsonPropertyName("manifestHash")] public required string ManifestHash { get; init; }
|
||||
[JsonPropertyName("signerDeviceId")] public required string SignerDeviceId { get; init; }
|
||||
[JsonPropertyName("signature")] public required string Signature { get; init; }
|
||||
[JsonPropertyName("targetItemId")] public string? TargetItemId { get; init; }
|
||||
[JsonPropertyName("signerRootPublicKey")] public required string SignerRootPublicKey { get; init; }
|
||||
[JsonPropertyName("signerDevicePublicKey")] public required string SignerDevicePublicKey { get; init; }
|
||||
[JsonPropertyName("deviceCertificateSignature")] public required string DeviceCertificateSignature { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SignedContentEventProof(byte[] EventIdBytes, string EventKind, string Username, string ChannelId, CryptoDomain Domain, int Epoch,
|
||||
int FrameCount, byte[] ManifestHash, string DeviceId, byte[] Signature, string? TargetItemId,
|
||||
string RootPublicKey, string DevicePublicKey, byte[] DeviceCertificateSignature);
|
||||
public sealed class ChannelAccess
|
||||
{
|
||||
[JsonPropertyName("chatFromRevision")] public long ChatFromRevision { get; set; }
|
||||
[JsonPropertyName("channelId")] public required string ChannelId { get; init; }
|
||||
[JsonPropertyName("filesFromRevision")] public long FilesFromRevision { get; set; }
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("chatRead")] public bool ChatRead { get; set; } = true;
|
||||
[JsonPropertyName("chatWrite")] public bool ChatWrite { get; set; } = true;
|
||||
[JsonPropertyName("filesRead")] public bool FilesRead { get; set; } = true;
|
||||
[JsonPropertyName("filesUpload")] public bool FilesUpload { get; set; } = true;
|
||||
}
|
||||
public sealed class CryptoGrantBatch
|
||||
{
|
||||
[JsonPropertyName("domain")] public required CryptoDomain Domain { get; init; }
|
||||
[JsonPropertyName("epoch")] public required int Epoch { get; init; }
|
||||
[JsonPropertyName("recipients")] public required List<string> Recipients { get; init; }
|
||||
[JsonPropertyName("rotate")] public required bool Rotate { get; init; }
|
||||
}
|
||||
public sealed class InvitationCryptoPlan
|
||||
{
|
||||
[JsonPropertyName("channelId")] public required string ChannelId { get; init; }
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("batches")] public required List<CryptoGrantBatch> Batches { get; init; }
|
||||
}
|
||||
public sealed class CryptoState
|
||||
{
|
||||
[JsonPropertyName("revision")] public long Revision { get; set; }
|
||||
[JsonPropertyName("envelopes")] public List<DomainEnvelope> Envelopes { get; init; } = [];
|
||||
[JsonPropertyName("objects")] public List<ObjectEnvelope> Objects { get; init; } = [];
|
||||
[JsonPropertyName("access")] public List<ChannelAccess> Access { get; init; } = [];
|
||||
}
|
||||
public sealed class DomainPublishRequest
|
||||
{
|
||||
[JsonPropertyName("channelId")] public required string ChannelId { get; init; }
|
||||
[JsonPropertyName("domain")] public required CryptoDomain Domain { get; init; }
|
||||
[JsonPropertyName("epoch")] public required int Epoch { get; init; }
|
||||
[JsonPropertyName("envelopes")] public required List<DomainEnvelope> Envelopes { get; init; }
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(CryptoState))]
|
||||
public sealed partial class WorkspaceCryptoJson : JsonSerializerContext;
|
||||
@@ -0,0 +1,41 @@
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Storage;
|
||||
|
||||
public sealed partial class SqliteAccountStore
|
||||
{
|
||||
public AccountProfile Profile(string username)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT display_name,avatar,modified_at FROM account_profiles WHERE username=$u";
|
||||
cmd.Parameters.AddWithValue("$u", username);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
return reader.Read() ? new AccountProfile { Username = username, DisplayName = reader.GetString(0), Avatar = reader.IsDBNull(1) ? null : reader.GetString(1), ModifiedAt = reader.GetInt64(2) }
|
||||
: new AccountProfile { Username = username, DisplayName = username, ModifiedAt = 0 };
|
||||
}
|
||||
}
|
||||
public AccountProfile UpdateProfile(string username, string displayName, string? avatar)
|
||||
{
|
||||
if (displayName is null || displayName.Trim().Length > 48 || displayName.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (avatar is not null)
|
||||
{
|
||||
string? prefix = new[] { "data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64," }.FirstOrDefault(p => avatar.StartsWith(p, StringComparison.Ordinal));
|
||||
if (prefix is null || avatar.Length > 350_000 || avatar.Length <= prefix.Length) throw new WebDropException(ErrorCode.BadRequest);
|
||||
try { _ = Convert.FromBase64String(avatar[prefix.Length..]); }
|
||||
catch (FormatException) { throw new WebDropException(ErrorCode.BadRequest); }
|
||||
}
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "INSERT INTO account_profiles(username,display_name,avatar,modified_at) VALUES($u,$n,$a,$t) ON CONFLICT(username) DO UPDATE SET display_name=excluded.display_name,avatar=excluded.avatar,modified_at=MAX(account_profiles.modified_at+1,excluded.modified_at)";
|
||||
cmd.Parameters.AddWithValue("$u", username);
|
||||
cmd.Parameters.AddWithValue("$n", displayName.Trim());
|
||||
cmd.Parameters.AddWithValue("$a", (object?)avatar ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$t", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
cmd.ExecuteNonQuery();
|
||||
return Profile(username);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace WebDrop.Storage;
|
||||
|
||||
public sealed record AccountDeviceSigningKey(string PublicKey, byte[] CertificateSignature);
|
||||
|
||||
public sealed partial class SqliteAccountStore
|
||||
{
|
||||
public AccountDeviceSigningKey? DeviceSigningKey(string username, string deviceId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT public_key,certificate_signature FROM account_device_signing_keys WHERE username=$u AND device_id=$d";
|
||||
cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$d", deviceId);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
return reader.Read() ? new(reader.GetString(0), (byte[])reader[1]) : null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool SaveDeviceSigningKey(string username, string deviceId, string publicKey, byte[] signature)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var device = _connection.CreateCommand();
|
||||
device.CommandText = "SELECT 1 FROM account_devices WHERE username=$u AND id=$d";
|
||||
device.Parameters.AddWithValue("$u", username); device.Parameters.AddWithValue("$d", deviceId);
|
||||
if (device.ExecuteScalar() is null) return false;
|
||||
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO account_device_signing_keys(username,device_id,public_key,certificate_signature,modified_at)
|
||||
VALUES($u,$d,$p,$s,$m)
|
||||
ON CONFLICT(username,device_id) DO UPDATE SET public_key=excluded.public_key,
|
||||
certificate_signature=excluded.certificate_signature,modified_at=excluded.modified_at
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$d", deviceId);
|
||||
cmd.Parameters.AddWithValue("$p", publicKey); cmd.Parameters.AddWithValue("$s", signature);
|
||||
cmd.Parameters.AddWithValue("$m", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
return cmd.ExecuteNonQuery() == 1;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using Microsoft.Data.Sqlite;
|
||||
namespace WebDrop.Storage;
|
||||
|
||||
/// <summary>Account authentication and opaque client-encrypted vault storage.</summary>
|
||||
public sealed class SqliteAccountStore : IDisposable
|
||||
public sealed partial class SqliteAccountStore : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly object _gate = new();
|
||||
@@ -20,8 +20,25 @@ public sealed class SqliteAccountStore : IDisposable
|
||||
CREATE TABLE IF NOT EXISTS account_sessions (token_hash BLOB PRIMARY KEY, username TEXT NOT NULL, expires INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS account_devices (username TEXT NOT NULL, id TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, last_seen INTEGER NOT NULL, PRIMARY KEY(username,id));
|
||||
CREATE TABLE IF NOT EXISTS account_device_sessions (token_hash BLOB PRIMARY KEY, username TEXT NOT NULL, device_id TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS account_device_signing_keys (username TEXT NOT NULL, device_id TEXT NOT NULL, public_key TEXT NOT NULL, certificate_signature BLOB NOT NULL, modified_at INTEGER NOT NULL, PRIMARY KEY(username,device_id));
|
||||
CREATE TABLE IF NOT EXISTS account_profiles (username TEXT PRIMARY KEY COLLATE NOCASE, display_name TEXT NOT NULL, avatar TEXT, modified_at INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS account_passkeys (id TEXT PRIMARY KEY, username TEXT NOT NULL, name TEXT NOT NULL, data TEXT NOT NULL, prf_salt TEXT NOT NULL, key_envelope TEXT, created_at INTEGER NOT NULL);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
using (var columns = _connection.CreateCommand())
|
||||
{
|
||||
columns.CommandText = "PRAGMA table_info(accounts)";
|
||||
using var reader = columns.ExecuteReader();
|
||||
bool hasSigningKey = false;
|
||||
while (reader.Read()) if (reader.GetString(1) == "signing_public_key") hasSigningKey = true;
|
||||
reader.Close();
|
||||
if (!hasSigningKey)
|
||||
{
|
||||
using var alter = _connection.CreateCommand();
|
||||
alter.CommandText = "ALTER TABLE accounts ADD COLUMN signing_public_key TEXT";
|
||||
alter.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] HashVerifier(byte[] verifier, byte[] salt) => Rfc2898DeriveBytes.Pbkdf2(verifier, salt, 210_000, HashAlgorithmName.SHA256, 32);
|
||||
@@ -45,7 +62,7 @@ public sealed class SqliteAccountStore : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public bool Register(string username, byte[] salt, byte[] verifier, byte[] vault, string publicKey)
|
||||
public bool Register(string username, byte[] salt, byte[] verifier, byte[] vault, string publicKey, string? signingPublicKey = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
@@ -53,10 +70,11 @@ public sealed class SqliteAccountStore : IDisposable
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
var verifierSalt = RandomNumberGenerator.GetBytes(16);
|
||||
cmd.CommandText = "INSERT INTO accounts(username,salt,verifier,vault,revision,public_key,verifier_salt) VALUES($u,$s,$v,$b,1,$p,$vs)";
|
||||
cmd.CommandText = "INSERT INTO accounts(username,salt,verifier,vault,revision,public_key,verifier_salt,signing_public_key) VALUES($u,$s,$v,$b,1,$p,$vs,$sp)";
|
||||
cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$s", salt);
|
||||
cmd.Parameters.AddWithValue("$v", HashVerifier(verifier, verifierSalt)); cmd.Parameters.AddWithValue("$b", vault);
|
||||
cmd.Parameters.AddWithValue("$p", publicKey); cmd.Parameters.AddWithValue("$vs", verifierSalt);
|
||||
cmd.Parameters.AddWithValue("$sp", (object?)signingPublicKey ?? DBNull.Value);
|
||||
cmd.ExecuteNonQuery(); return true;
|
||||
}
|
||||
catch (SqliteException e) when (e.SqliteErrorCode == 19) { return false; }
|
||||
@@ -77,6 +95,26 @@ public sealed class SqliteAccountStore : IDisposable
|
||||
r.Close();
|
||||
var expected = HashVerifier(suppliedVerifier, verifierSalt);
|
||||
if (!CryptographicOperations.FixedTimeEquals(verifier, expected) || !found) return null;
|
||||
return CreateDeviceSession(username, deviceId, deviceName);
|
||||
}
|
||||
}
|
||||
|
||||
public bool VerifyLogin(string username, byte[] verifier)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT verifier,verifier_salt FROM accounts WHERE username=$u";
|
||||
cmd.Parameters.AddWithValue("$u", username); using var reader = cmd.ExecuteReader();
|
||||
var found = reader.Read(); var expected = found ? (byte[])reader[0] : new byte[32]; var salt = found ? (byte[])reader[1] : new byte[16];
|
||||
return CryptographicOperations.FixedTimeEquals(expected, HashVerifier(verifier, salt)) && found;
|
||||
}
|
||||
}
|
||||
public string? CreateDeviceSession(string username, string? deviceId, string? deviceName)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var exists = _connection.CreateCommand(); exists.CommandText = "SELECT 1 FROM accounts WHERE username=$u";
|
||||
exists.Parameters.AddWithValue("$u", username); if (exists.ExecuteScalar() is null) return null;
|
||||
if (deviceId is not null && (!Guid.TryParseExact(deviceId, "D", out _) || deviceName is null || deviceName.Length is < 1 or > 80 || deviceName.Any(char.IsControl))) return null;
|
||||
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
using var transaction = _connection.BeginTransaction();
|
||||
@@ -231,6 +269,16 @@ public sealed class SqliteAccountStore : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public string? GetSigningPublicKey(string username)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT signing_public_key FROM accounts WHERE username=$u";
|
||||
cmd.Parameters.AddWithValue("$u", username);
|
||||
return cmd.ExecuteScalar() as string;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public sealed record AccountDevice(string Id, string Name, long CreatedAt, long LastSeen, bool Current);
|
||||
@@ -44,6 +44,8 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
created_at INTEGER NOT NULL, PRIMARY KEY (channel_id, username)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX IF NOT EXISTS ix_channel_keys_username ON channel_key_packages(username);
|
||||
CREATE TABLE IF NOT EXISTS workspace_structures (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
|
||||
CREATE TABLE IF NOT EXISTS workspace_crypto (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
|
||||
""");
|
||||
}
|
||||
|
||||
@@ -253,6 +255,19 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
private sealed class SqliteTx(SqliteConnection conn) : IResourceTx
|
||||
{
|
||||
private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes, creator_account, join_password_hash, join_password_salt";
|
||||
public WorkspaceState? GetWorkspace(Id16 id) => One("SELECT state FROM workspace_structures WHERE space_id=@id",
|
||||
r => JsonSerializer.Deserialize(r.GetString(0), WorkspaceJson.Default.WorkspaceState)!, ("@id", id.ToArray()));
|
||||
public CryptoState GetWorkspaceCrypto(Id16 id) => One("SELECT state FROM workspace_crypto WHERE space_id=@id",
|
||||
r => JsonSerializer.Deserialize(r.GetString(0), WorkspaceCryptoJson.Default.CryptoState)!, ("@id", id.ToArray())) ?? new CryptoState();
|
||||
public void PutWorkspaceCrypto(Id16 id, CryptoState state) => Run(
|
||||
"INSERT INTO workspace_crypto(space_id,state) VALUES(@id,@s) ON CONFLICT(space_id) DO UPDATE SET state=excluded.state",
|
||||
("@id", id.ToArray()), ("@s", JsonSerializer.Serialize(state, WorkspaceCryptoJson.Default.CryptoState)));
|
||||
public IReadOnlyList<Id16> ListAccountSpaces(string username, long now) => Many(
|
||||
"SELECT c.id FROM channels c JOIN channel_members m ON m.channel_id=c.id WHERE m.username=@u AND c.expires_at>@n ORDER BY c.created_at DESC",
|
||||
r => Id16.FromBytes((byte[])r[0]), ("@u", username), ("@n", now));
|
||||
public void PutWorkspace(Id16 id, WorkspaceState state) => Run(
|
||||
"INSERT INTO workspace_structures(space_id,state) VALUES(@id,@s) ON CONFLICT(space_id) DO UPDATE SET state=excluded.state",
|
||||
("@id", id.ToArray()), ("@s", JsonSerializer.Serialize(state, WorkspaceJson.Default.WorkspaceState)));
|
||||
private const string ItemCols = "channel_id, item_id, author_hash, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline, author_account";
|
||||
private const string FrameCols = "channel_id, item_id, frame_index, seq, length, digest, inline_blob, blob_key";
|
||||
private const string ShareItemCols = "share_id, item_id, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline";
|
||||
@@ -487,6 +502,8 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
Run("DELETE FROM channels WHERE id=@c", ("@c", key));
|
||||
Run("DELETE FROM channel_invitations WHERE channel_id=@c", ("@c", key));
|
||||
Run("DELETE FROM channel_key_packages WHERE channel_id=@c", ("@c", key));
|
||||
Run("DELETE FROM workspace_structures WHERE space_id=@c", ("@c", key));
|
||||
Run("DELETE FROM workspace_crypto WHERE space_id=@c", ("@c", key));
|
||||
Run("DELETE FROM channel_members WHERE channel_id=@c", ("@c", key));
|
||||
Run("DELETE FROM channel_join_denials WHERE channel_id=@c", ("@c", key));
|
||||
}
|
||||
@@ -505,6 +522,9 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
public int CountSeats(Id16 channelId) =>
|
||||
(int)(long)Scalar("SELECT COUNT(*) FROM seats WHERE channel_id=@c", ("@c", channelId.ToArray()))!;
|
||||
|
||||
public int CountMembers(Id16 channelId) =>
|
||||
(int)(long)Scalar("SELECT (SELECT COUNT(*) FROM channel_members WHERE channel_id=@c) + (SELECT COUNT(*) FROM seats WHERE channel_id=@c AND account_name IS NULL)", ("@c", channelId.ToArray()))!;
|
||||
|
||||
public void InsertSeat(Id16 channelId, Seat seat) => Run(
|
||||
"INSERT INTO seats (channel_id, endpoint_hash, label, joined_at,account_name,account_device_id) VALUES (@c,@e,@l,@j,@a,@d)",
|
||||
("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@l", seat.Label), ("@j", seat.JoinedAt), ("@a", seat.AccountName), ("@d", seat.AccountDeviceId));
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Storage;
|
||||
|
||||
public sealed record StoredPasskey(string Id, string Username, string Name, string Data, string PrfSalt, string? KeyEnvelope, long CreatedAt);
|
||||
public sealed partial class SqliteAccountStore
|
||||
{
|
||||
public IReadOnlyList<StoredPasskey> Passkeys(string username)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT id,username,name,data,prf_salt,key_envelope,created_at FROM account_passkeys WHERE username=$u ORDER BY created_at";
|
||||
cmd.Parameters.AddWithValue("$u", username); using var reader = cmd.ExecuteReader(); var rows = new List<StoredPasskey>();
|
||||
while (reader.Read()) rows.Add(new(reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.GetString(3), reader.GetString(4), reader.IsDBNull(5) ? null : reader.GetString(5), reader.GetInt64(6)));
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
public StoredPasskey? Passkey(string id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT username FROM account_passkeys WHERE id=$i"; cmd.Parameters.AddWithValue("$i", id);
|
||||
return cmd.ExecuteScalar() is string username ? Passkeys(username).FirstOrDefault(p => p.Id == id) : null;
|
||||
}
|
||||
}
|
||||
public void AddPasskey(StoredPasskey passkey)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (Passkeys(passkey.Username).Count >= 16 || Passkey(passkey.Id) is not null) throw new WebDropException(ErrorCode.Conflict);
|
||||
using var cmd = _connection.CreateCommand(); cmd.CommandText = "INSERT INTO account_passkeys(id,username,name,data,prf_salt,key_envelope,created_at) VALUES($i,$u,$n,$d,$s,$k,$t)";
|
||||
cmd.Parameters.AddWithValue("$i", passkey.Id); cmd.Parameters.AddWithValue("$u", passkey.Username); cmd.Parameters.AddWithValue("$n", passkey.Name);
|
||||
cmd.Parameters.AddWithValue("$d", passkey.Data); cmd.Parameters.AddWithValue("$s", passkey.PrfSalt); cmd.Parameters.AddWithValue("$k", (object?)passkey.KeyEnvelope ?? DBNull.Value); cmd.Parameters.AddWithValue("$t", passkey.CreatedAt); cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
public bool UpdatePasskeyData(string id, string username, string oldData, string newData)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand(); cmd.CommandText = "UPDATE account_passkeys SET data=$n WHERE id=$i AND username=$u AND data=$old";
|
||||
cmd.Parameters.AddWithValue("$i", id); cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$n", newData); cmd.Parameters.AddWithValue("$old", oldData); return cmd.ExecuteNonQuery() == 1;
|
||||
}
|
||||
}
|
||||
public void UpdatePasskeyEnvelope(string id, string username, string envelope)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand(); cmd.CommandText = "UPDATE account_passkeys SET key_envelope=$k WHERE id=$i AND username=$u";
|
||||
cmd.Parameters.AddWithValue("$i", id); cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$k", envelope);
|
||||
if (cmd.ExecuteNonQuery() != 1) throw new WebDropException(ErrorCode.NotFound);
|
||||
}
|
||||
}
|
||||
public void RemovePasskey(string id, string username)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand(); cmd.CommandText = "DELETE FROM account_passkeys WHERE id=$i AND username=$u";
|
||||
cmd.Parameters.AddWithValue("$i", id); cmd.Parameters.AddWithValue("$u", username); cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user