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