375 lines
16 KiB
C#
375 lines
16 KiB
C#
using System.Security.Cryptography;
|
|
|
|
namespace WebDrop.Core;
|
|
|
|
/// <summary>便捷分享的草稿、封存、访客和下载规则。与频道共享帧格式及 blob 存储,不共享席位或日志。</summary>
|
|
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<AppendResult> AppendFrameAsync(
|
|
Id16 id, Credentials creds, ReadOnlyMemory<byte> 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<byte[]> 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<ItemChunk> 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<byte[]>(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<byte[]> 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<ShareItem> 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(),
|
|
};
|
|
}
|