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(),
};
}