feat(api): add account workspaces and signed content events

This commit is contained in:
justme committed 2026-10-14 04:05:30 +08:00
1 parent 937b774a93
commit 7b8d61d1ba
63 files changed
+5407 -370

No files matched your search

+115 -17
View File
@@ -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,