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