using System.Security.Cryptography; namespace WebDrop.Core; /// 便捷分享的草稿、封存、访客和下载规则。与频道共享帧格式及 blob 存储,不共享席位或日志。 public sealed class ShareService( IResourceStore store, IBlobStore blobs, IClock clock, ServerLimits limits, int draftTtlSeconds) { public ShareInfo Create(CreateShareRequest req, byte[]? creatorEndpointHash, string creatorAccount) { if (string.IsNullOrWhiteSpace(creatorAccount)) throw new WebDropException(ErrorCode.AccountRequired); if (!Id16.TryParse(req.Id, out var id) || !Codec.TryDecodeExact(req.ReadHash, TokenHash.Size, out var readHash) || !Codec.TryDecodeExact(req.OwnerHash, TokenHash.Size, out var ownerHash)) throw new WebDropException(ErrorCode.BadRequest); var settings = req.Settings ?? CreationDefaults.Share(limits); settings.Validate(limits); long now = clock.NowMs; var share = new Share(id, settings, readHash, ownerHash, creatorEndpointHash, now, now + Math.Max(60, draftTtlSeconds) * 1000L, null, null, 0, creatorAccount); store.Write(tx => { if (tx.HasResourceId(id)) throw new WebDropException(ErrorCode.Conflict); tx.InsertShare(share); return 0; }); return ToInfo(share, []); } public ShareInfo GetInfo(Id16 id, Credentials creds) => store.Read(tx => { var share = Live(tx.GetShare(id)); if (share.IsSealed) { if (creds.ClaimedRole != Role.Read || !TokenHash.Matches(creds.Token ?? [], share.ReadHash)) throw new WebDropException(ErrorCode.Unauthorized); } else { if (creds.ClaimedRole == Role.Read) throw new WebDropException(ErrorCode.NotFound); RequireOwner(share, creds); } return ToInfo(share, tx.ListShareItems(id)); }); public ShareInfo UpdateSettings(Id16 id, Credentials creds, ShareSettingsPatch patch) => store.Write(tx => { var share = Draft(tx.GetShare(id), creds); var settings = patch.ApplyTo(share.Settings); settings.Validate(limits); share = share with { Settings = settings }; tx.UpdateShare(share); return ToInfo(share, tx.ListShareItems(id)); }); public async Task AppendFrameAsync( Id16 id, Credentials creds, ReadOnlyMemory body, long? maxDownloads, CancellationToken ct = default) { if (!Envelope.TryParse(body.Span, out var env)) throw new WebDropException(ErrorCode.BadRequest); if (env.PayloadLength > limits.MaxPayloadBytes || body.Length > limits.MaxPayloadBytes + Envelope.HeaderSize) throw new WebDropException(ErrorCode.TooLarge); if (!env.IsEnc) throw new WebDropException(ErrorCode.ModeMismatch); if (!PayloadTiers.IsValidEncPayloadLength(env.PayloadLength)) throw new WebDropException(ErrorCode.BadRequest); if (env.IsSnapshot || env.IsControl) throw new WebDropException(ErrorCode.BadRequest); if (!env.IsGated && (env.FrameIndex != 0 || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest); if (maxDownloads is < 0) throw new WebDropException(ErrorCode.BadRequest); var existing = store.Read(tx => { _ = Draft(tx.GetShare(id), creds); return tx.GetShareFrame(id, env.ItemId, env.FrameIndex); }); byte[] digest = SHA256.HashData(body.Span); if (existing is not null) return Resolve(existing, digest); string? blobKey = null; if (body.Length >= limits.InlineThresholdBytes) { blobKey = $"{id.ToHex()}/{env.ItemId.ToHex()}/{env.FrameIndex}.{Convert.ToHexStringLower(digest.AsSpan(0, 16))}"; await blobs.PutAsync(blobKey, body, ct).ConfigureAwait(false); } try { var result = store.Write(tx => { var share = Draft(tx.GetShare(id), creds); if (tx.GetShareFrame(id, env.ItemId, env.FrameIndex) is { } duplicate) return Resolve(duplicate, digest); long now = clock.NowMs; var item = tx.GetShareItem(id, env.ItemId); if (env.FrameIndex == 0) { 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); } else { if (item is null || item.Flags != env.ItemFlags || item.CloseDeadline is not null || item.FinalIndex is { } final && env.FrameIndex > final) throw new WebDropException(ErrorCode.BadRequest); } if (env.FrameIndex >= limits.MaxFramesPerItem) throw new WebDropException(ErrorCode.TooLarge); if (env.IsFinal && (item!.MaxIndex > env.FrameIndex || item.FinalIndex is { } f && f != env.FrameIndex)) throw new WebDropException(ErrorCode.BadRequest); if (share.Bytes + body.Length > limits.MaxChannelBytes) throw new WebDropException(ErrorCode.TooLarge); tx.InsertShareFrame(new ShareFrame(id, env.ItemId, env.FrameIndex, env.FrameIndex, body.Length, digest, blobKey is null ? body.ToArray() : null, blobKey)); tx.UpdateShareItem(item! with { FrameCount = item!.FrameCount + 1, MaxIndex = Math.Max(item.MaxIndex, env.FrameIndex), FinalIndex = env.IsFinal ? env.FrameIndex : item.FinalIndex, Bytes = item.Bytes + body.Length, }); tx.AddShareBytes(id, body.Length); return new AppendResult { Seq = env.FrameIndex, Created = true }; }); if (!result.Created) DeleteBlobIfUnreferenced(blobKey); return result; } catch { DeleteBlobIfUnreferenced(blobKey); throw; } } public void DeleteItem(Id16 id, Credentials creds, Id16 itemId) { string prefix = $"{id.ToHex()}/{itemId.ToHex()}"; store.Write(tx => { _ = Draft(tx.GetShare(id), creds); var item = tx.GetShareItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound); tx.DeleteShareItemRows(id, itemId); tx.AddShareBytes(id, -item.Bytes); return 0; }); blobs.DeletePrefix(prefix); } public void DeleteDraft(Id16 id, Credentials creds) { store.Write(tx => { _ = Draft(tx.GetShare(id), creds); tx.DeleteShareRows(id); return 0; }); blobs.DeletePrefix(id.ToHex()); } public ShareInfo Finish(Id16 id, Credentials creds, string? manifestItemId) { if (!Id16.TryParse(manifestItemId, out var manifestId)) throw new WebDropException(ErrorCode.BadRequest); return store.Write(tx => { var share = Draft(tx.GetShare(id), creds); var items = tx.ListShareItems(id); var manifest = tx.GetShareItem(id, manifestId); if (manifest is null || manifest.IsGated || manifest.IsSnapshot || !manifest.IsComplete || manifest.FrameCount != 1 || manifest.FinalIndex != 0) throw new WebDropException(ErrorCode.BadRequest); if (items.Any(i => !i.IsComplete || i.ItemId != manifestId && !i.IsGated)) throw new WebDropException(ErrorCode.BadRequest); long now = clock.NowMs; long expiresAt = now + share.Settings.TtlSeconds * 1000L; var sealedShare = share with { OwnerHash = null, SealedAt = now, ExpiresAt = expiresAt, ManifestItemId = manifestId, }; tx.UpdateShare(sealedShare); foreach (var item in items) tx.UpdateShareItem(item with { ExpiresAt = expiresAt }); return ToInfo(sealedShare, tx.ListShareItems(id)); }); } public async Task ReadManifestAsync(Id16 id, Credentials creds) { var frame = store.Write(tx => { var share = SealedForRead(tx.GetShare(id), creds); RegisterVisitor(tx, share, creds.EndpointHash, creds.AccountName); var manifestId = share.ManifestItemId ?? throw new WebDropException(ErrorCode.NotFound); return tx.GetShareFrame(id, manifestId, 0) ?? throw new WebDropException(ErrorCode.Gone); }); return await LoadFrameAsync(frame).ConfigureAwait(false); } public void StartItemDownload(Id16 id, Credentials creds, Id16 itemId, uint fromIndex) { store.Write(tx => { var share = SealedForRead(tx.GetShare(id), creds); var item = tx.GetShareItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound); if (!item.IsGated) throw new WebDropException(ErrorCode.BadRequest); if (fromIndex > 0 && !tx.HasDownloadGrant(id, itemId, creds.EndpointHash)) throw new WebDropException(ErrorCode.Gone); long now = clock.NowMs; if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now >= deadline)) throw new WebDropException(ErrorCode.Gone); RegisterVisitor(tx, share, creds.EndpointHash, creds.AccountName); if (fromIndex == 0) { if (item.MaxDownloads > 0 && item.Downloads >= item.MaxDownloads) throw new WebDropException(ErrorCode.Gone); long count = item.Downloads + 1; long? closeAt = item.MaxDownloads > 0 && count >= item.MaxDownloads ? now + limits.DownloadGraceSeconds * 1000L : item.CloseDeadline; tx.UpdateShareItem(item with { Downloads = count, CloseDeadline = closeAt }); tx.AddDownloadGrant(id, itemId, creds.EndpointHash); } return 0; }); } public async Task ReadItemFramesAsync(Id16 id, Credentials creds, Id16 itemId, uint fromIndex, CancellationToken ct = default) { var frames = store.Read(tx => { _ = SealedForRead(tx.GetShare(id), creds); var item = tx.GetShareItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound); if (item.IsGated && !tx.HasDownloadGrant(id, itemId, creds.EndpointHash)) throw new WebDropException(ErrorCode.Gone); if (item.CloseDeadline is { } deadline && clock.NowMs >= deadline) throw new WebDropException(ErrorCode.Gone); var list = tx.ListShareFrames(id, itemId, fromIndex, 32); uint expected = fromIndex; foreach (var frame in list) { if (frame.Index != expected++) throw new WebDropException(ErrorCode.Gone); } return (Frames: list, Next: expected, Complete: item.FinalIndex is { } f && expected > f); }); var result = new List(frames.Frames.Count); foreach (var frame in frames.Frames) result.Add(await LoadFrameAsync(frame).ConfigureAwait(false)); return new ItemChunk(result, frames.Next, frames.Complete); } public int Sweep() { int removed = 0; long now = clock.NowMs; var stale = store.Read(tx => tx.ListExpiredShareItems(now, 500)); foreach (var group in stale.GroupBy(i => i.ShareId)) { foreach (var item in group) { bool didRemove = store.Write(tx => { if (tx.GetShareItem(item.ShareId, item.ItemId) is not { } current) return false; if (current.ExpiresAt > now && (current.CloseDeadline is null || current.CloseDeadline > now)) return false; tx.DeleteShareItemRows(item.ShareId, item.ItemId); tx.AddShareBytes(item.ShareId, -current.Bytes); return true; }); if (didRemove) { blobs.DeletePrefix($"{group.Key.ToHex()}/{item.ItemId.ToHex()}"); removed++; } } } foreach (var id in store.Read(tx => tx.ListExpiredShares(now, 200))) { store.Write(tx => { tx.DeleteShareRows(id); return 0; }); blobs.DeletePrefix(id.ToHex()); removed++; } return removed; } private Share SealedForRead(Share? candidate, Credentials creds) { var share = Live(candidate); if (!share.IsSealed || creds.ClaimedRole != Role.Read || !TokenHash.Matches(creds.Token ?? [], share.ReadHash)) throw new WebDropException(share.IsSealed ? ErrorCode.Unauthorized : ErrorCode.NotFound); return share; } private Share Draft(Share? candidate, Credentials creds) { var share = Live(candidate); if (share.IsSealed) throw new WebDropException(ErrorCode.Sealed); if (creds.ClaimedRole == Role.Read && TokenHash.Matches(creds.Token ?? [], share.ReadHash)) throw new WebDropException(ErrorCode.Forbidden); RequireOwner(share, creds); return share; } private void RequireOwner(Share share, Credentials creds) { if (share.CreatorAccount != creds.AccountName) throw new WebDropException(ErrorCode.AccountRequired); if (creds.ClaimedRole != Role.Owner || !TokenHash.Matches(creds.Token ?? [], share.OwnerHash)) throw new WebDropException(ErrorCode.Unauthorized); } private Share Live(Share? candidate) { if (candidate is null || candidate.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.NotFound); return candidate; } private void RegisterVisitor(IResourceTx tx, Share share, byte[] endpointHash, string? accountName) { if (accountName == share.CreatorAccount) return; if (share.CreatorEndpointHash is { } creator && CryptographicOperations.FixedTimeEquals(creator, endpointHash)) return; if (tx.HasShareVisitor(share.Id, endpointHash)) return; if (share.Settings.MaxVisitors > 0 && tx.CountShareVisitors(share.Id) >= share.Settings.MaxVisitors) throw new WebDropException(ErrorCode.VisitorsFull); tx.InsertShareVisitor(share.Id, endpointHash, clock.NowMs); } private async ValueTask LoadFrameAsync(ShareFrame frame) { if (frame.Inline is { } bytes) return bytes; if (frame.BlobKey is { } key && await blobs.ReadAsync(key).ConfigureAwait(false) is { } blob) return blob; throw new WebDropException(ErrorCode.Gone); } private void DeleteBlobIfUnreferenced(string? key) { if (key is not null && !store.Read(tx => tx.BlobKeyExists(key))) blobs.Delete(key); } private static AppendResult Resolve(ShareFrame existing, byte[] digest) => existing.Digest.AsSpan().SequenceEqual(digest) ? new AppendResult { Seq = existing.AckSeq, Created = false } : throw new WebDropException(ErrorCode.Conflict); private static ShareInfo ToInfo(Share share, IReadOnlyList items) => new() { Id = share.Id.ToBase64Url(), State = share.IsSealed ? "sealed" : "draft", ExpiresAt = share.ExpiresAt, CreatedAt = share.CreatedAt, SealedAt = share.SealedAt, ManifestItemId = share.ManifestItemId?.ToBase64Url(), Settings = share.Settings, Items = items.Where(i => !share.IsSealed || i.ItemId != share.ManifestItemId).Select(i => new ShareItemInfo { ItemId = i.ItemId.ToBase64Url(), Bytes = i.Bytes, Frames = i.FrameCount, Complete = i.IsComplete, MaxDownloads = i.MaxDownloads, DownloadsLeft = i.MaxDownloads == 0 ? null : Math.Max(0, i.MaxDownloads - i.Downloads), }).ToList(), }; }