feat(server): implement sealed share lifecycle

Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
justme committed 2026-10-02 00:59:56 +08:00
1 parent e02e024605
commit 42b93ae2d8
45 files changed
+6327

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
namespace WebDrop.Core;
public interface IClock
{
long NowMs { get; }
}
public sealed class SystemClock : IClock
{
public long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
/// <summary>大帧的字节存储。键由服务生成,实现不得解析。</summary>
public interface IBlobStore
{
ValueTask PutAsync(string key, ReadOnlyMemory<byte> data, CancellationToken ct = default);
ValueTask<byte[]?> ReadAsync(string key, CancellationToken ct = default);
void Delete(string key);
void DeletePrefix(string prefix);
IEnumerable<(string Key, DateTimeOffset LastWrite)> Enumerate();
}
/// <summary>
/// 创建频道前的人机/滥用校验(Cap 的 PoW 验证码)。验证码本身不属于本项目:
/// 校验失败抛 CaptchaRequired,验证服务不可用抛 CaptchaUnavailable(两者对用户的含义不同)。
/// </summary>
public interface ICaptchaVerifier
{
bool Enabled { get; }
Task VerifyAsync(string? token, CancellationToken ct);
}
/// <summary>不启用验证码(开发和小型自托管)。</summary>
public sealed class NullCaptchaVerifier : ICaptchaVerifier
{
public bool Enabled => false;
public Task VerifyAsync(string? token, CancellationToken ct) => Task.CompletedTask;
}
/// <summary>服务提交后发出的通知,传输层据此唤醒订阅者。</summary>
public interface IChannelEvents
{
void Advanced(Id16 channelId, long seq);
void SettingsChanged(Id16 channelId);
void SeatsChanged(Id16 channelId);
void ChannelDeleted(Id16 channelId);
}
public sealed class NullChannelEvents : IChannelEvents
{
public void Advanced(Id16 channelId, long seq) { }
public void SettingsChanged(Id16 channelId) { }
public void SeatsChanged(Id16 channelId) { }
public void ChannelDeleted(Id16 channelId) { }
}
/// <summary>频道和分享资源共用的读写事务入口。Write 串行执行,抛出异常即回滚。</summary>
public interface IResourceStore
{
T Read<T>(Func<IResourceTx, T> action);
T Write<T>(Func<IResourceTx, T> action);
}
/// <summary>单个数据库事务内的频道、分享与帧操作。</summary>
public interface IResourceTx
{
// 便捷分享
Share? GetShare(Id16 id);
void InsertShare(Share share);
void UpdateShare(Share share);
IReadOnlyList<Id16> ListExpiredShares(long now, int limit);
bool HasResourceId(Id16 id);
int CountShareVisitors(Id16 id);
bool HasShareVisitor(Id16 id, ReadOnlySpan<byte> endpointHash);
void InsertShareVisitor(Id16 id, ReadOnlySpan<byte> endpointHash, long joinedAt);
void DeleteShareRows(Id16 id);
IReadOnlyList<ShareItem> ListShareItems(Id16 id);
int CountShareItems(Id16 id);
void AddShareBytes(Id16 id, long delta);
IReadOnlyList<ShareItem> ListExpiredShareItems(long now, int limit);
ShareItem? GetShareItem(Id16 shareId, Id16 itemId);
void InsertShareItem(ShareItem item);
void UpdateShareItem(ShareItem item);
void DeleteShareItemRows(Id16 shareId, Id16 itemId);
ShareFrame? GetShareFrame(Id16 shareId, Id16 itemId, uint index);
void InsertShareFrame(ShareFrame frame);
IReadOnlyList<ShareFrame> ListShareFrames(Id16 shareId, Id16 itemId, uint fromIndex, int limit);
// 频道
Channel? GetChannel(Id16 id);
void InsertChannel(Channel channel);
void UpdateChannel(Id16 id, ChannelSettings settings, long expiresAt);
void TouchChannel(Id16 id, long expiresAt);
void AddChannelBytes(Id16 id, long delta);
void SetCompactedThrough(Id16 id, long seq);
long NextSeq(Id16 id);
void DeleteChannelRows(Id16 id);
IReadOnlyList<Id16> ListExpiredChannels(long now, int limit);
// 席位
Seat? GetSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash);
int CountSeats(Id16 channelId);
void InsertSeat(Id16 channelId, Seat seat);
bool DeleteSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash);
void SetSeatLabel(Id16 channelId, ReadOnlySpan<byte> endpointHash, byte[] label);
IReadOnlyList<Seat> ListSeats(Id16 channelId);
// 条目
Item? GetItem(Id16 channelId, Id16 itemId);
void InsertItem(Item item);
void UpdateItem(Item item);
void DeleteItemRows(Id16 channelId, Id16 itemId);
int CountItems(Id16 channelId);
IReadOnlyList<Item> ListOldestItems(Id16 channelId, int limit);
Item? FindSnapshotItem(Id16 channelId, Id16 excludeItemId);
IReadOnlyList<Item> ListExpiredItems(long now, int limit);
// 帧
StoredFrame? GetFrame(Id16 channelId, Id16 itemId, uint index);
void InsertFrame(StoredFrame frame);
IReadOnlyList<StoredFrame> ListFrames(Id16 channelId, Id16 itemId, uint fromIndex, int limit);
bool BlobKeyExists(string blobKey);
// 日志
void InsertLog(Id16 channelId, LogEntry entry);
IReadOnlyList<LogEntry> ReadLog(Id16 channelId, long fromSeq, int limit);
long PruneTombstones(Id16 channelId, long olderThanMs);
}
+19
View File
@@ -0,0 +1,19 @@
using System.Security.Cryptography;
namespace WebDrop.Core;
/// <summary>服务端只保存令牌的 SHA-256,验证时常量时间比较。</summary>
public static class TokenHash
{
public const int Size = 32;
public static byte[] Compute(ReadOnlySpan<byte> token) => SHA256.HashData(token);
public static bool Matches(ReadOnlySpan<byte> token, byte[]? expectedHash)
{
if (expectedHash is not { Length: Size }) return false;
Span<byte> actual = stackalloc byte[Size];
SHA256.HashData(token, actual);
return CryptographicOperations.FixedTimeEquals(actual, expectedHash);
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace WebDrop.Core;
/// <summary>谁能做什么。规则只写在这里,服务和测试都调用它。</summary>
public static class ChannelPolicy
{
public static bool IsOwner(Principal p) => p.Role == Role.Owner;
public static bool CanRead(Principal p) => p.Role >= Role.Read;
/// <summary>成员写入需要频道允许上传,且服务端持有写令牌哈希。</summary>
public static bool CanWrite(Channel channel, Principal p) =>
p.Role == Role.Owner
|| (p.Role == Role.Write && channel.Settings.MembersCanUpload && channel.WriteHash is not null);
public static bool CanDeleteItem(Channel channel, Item item, Principal p)
{
if (p.Role == Role.Owner) return true;
if (p.Role < Role.Read) return false;
return channel.Settings.DeletePolicy switch
{
DeletePolicy.Anyone => true,
DeletePolicy.Own => item.AuthorHash.AsSpan().SequenceEqual(p.EndpointHash),
_ => false,
};
}
}
+571
View File
@@ -0,0 +1,571 @@
using System.Security.Cryptography;
namespace WebDrop.Core;
/// <summary>
/// 频道的全部业务逻辑。依赖抽象(存储、blob、时钟、事件),不含 HTTP、SQL、文件细节。
/// 约定:所有失败以 <see cref="WebDropException"/> 表达;提交成功后才发事件;blob 先写后提交。
/// </summary>
public sealed class ChannelService(
IResourceStore store,
IBlobStore blobs,
IClock clock,
ServerLimits limits,
IChannelEvents events)
{
private enum SeatRule { None, Required }
// ───────────────────────── 频道 ─────────────────────────
public ChannelInfo CreateChannel(CreateChannelRequest req, byte[] endpointHash)
{
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();
settings.Validate(limits);
if (!Codec.TryDecodeExact(req.OwnerHash, TokenHash.Size, out var ownerHash))
throw new WebDropException(ErrorCode.BadRequest);
byte[]? readHash = null;
if (mode == ChannelMode.Enc)
{
if (!Codec.TryDecodeExact(req.ReadHash, TokenHash.Size, out readHash))
throw new WebDropException(ErrorCode.BadRequest);
}
else if (req.ReadHash is not null)
{
throw new WebDropException(ErrorCode.BadRequest); // raw 频道的读权限是 id 本身
}
byte[]? writeHash = null;
if (req.WriteHash is not null && !Codec.TryDecodeExact(req.WriteHash, TokenHash.Size, out writeHash))
throw new WebDropException(ErrorCode.BadRequest);
long now = clock.NowMs;
var channel = new Channel(id, mode, settings, readHash, writeHash, ownerHash,
now, now + settings.TtlSeconds * 1000L, 0, 0, 0);
store.Write(tx =>
{
if (tx.HasResourceId(id)) throw new WebDropException(ErrorCode.Conflict);
tx.InsertChannel(channel);
tx.InsertSeat(id, new Seat(endpointHash, now, null));
return 0;
});
return ToInfo(channel, 1);
}
public ChannelInfo GetChannel(Id16 id, Credentials creds) => store.Read(tx =>
{
var (channel, _) = Authenticate(tx, id, creds, SeatRule.None);
return ToInfo(channel, tx.CountSeats(id));
});
public ChannelInfo UpdateSettings(Id16 id, Credentials creds, ChannelSettingsPatch patch)
{
var info = store.Write(tx =>
{
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
var updated = patch.ApplyTo(channel.Settings);
updated.Validate(limits);
long expiresAt = updated.TtlSeconds != channel.Settings.TtlSeconds
? clock.NowMs + updated.TtlSeconds * 1000L
: channel.ExpiresAt;
tx.UpdateChannel(id, updated, expiresAt);
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountSeats(id));
});
events.SettingsChanged(id);
return info;
}
public void DeleteChannel(Id16 id, Credentials creds)
{
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
tx.DeleteChannelRows(id);
return 0;
});
blobs.DeletePrefix(id.ToHex());
events.ChannelDeleted(id);
}
// ───────────────────────── 席位 ─────────────────────────
public SeatJoined Join(Id16 id, Credentials creds)
{
var (joined, isNew) = store.Write(tx =>
{
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None);
var existing = tx.GetSeat(id, principal.EndpointHash);
int used = tx.CountSeats(id);
if (existing is not null)
return (new SeatJoined { EndpointHash = Codec.Encode(existing.EndpointHash), JoinedAt = existing.JoinedAt, SeatsUsed = used, SeatsMax = channel.Settings.Seats }, false);
if (used >= channel.Settings.Seats) throw new WebDropException(ErrorCode.SeatsFull);
long now = clock.NowMs;
tx.InsertSeat(id, new Seat(principal.EndpointHash, now, null));
return (new SeatJoined { EndpointHash = Codec.Encode(principal.EndpointHash), JoinedAt = now, SeatsUsed = used + 1, SeatsMax = channel.Settings.Seats }, true);
});
if (isNew) events.SeatsChanged(id);
return joined;
}
public IReadOnlyList<SeatInfo> ListSeats(Id16 id, Credentials creds) => store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
return (IReadOnlyList<SeatInfo>)tx.ListSeats(id).Select(s => new SeatInfo
{
EndpointHash = Codec.Encode(s.EndpointHash),
JoinedAt = s.JoinedAt,
Label = s.Label is null ? null : Codec.Encode(s.Label),
}).ToList();
});
public void SetSeatLabel(Id16 id, Credentials creds, byte[] label)
{
if (label.Length == 0) throw new WebDropException(ErrorCode.BadRequest);
if (label.Length > limits.MaxSeatLabelBytes) throw new WebDropException(ErrorCode.TooLarge);
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required);
tx.SetSeatLabel(id, principal.EndpointHash, label);
return 0;
});
events.SeatsChanged(id);
}
public void ReleaseSeat(Id16 id, Credentials creds, byte[] endpointHash)
{
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
if (!tx.DeleteSeat(id, endpointHash)) throw new WebDropException(ErrorCode.NotFound);
return 0;
});
events.SeatsChanged(id);
}
// ───────────────────────── 追加帧 ─────────────────────────
public async Task<AppendResult> AppendFrameAsync(
Id16 channelId, Credentials creds, ReadOnlyMemory<byte> body,
long? maxDownloads, string? label, CancellationToken ct = default)
{
if (!Envelope.TryParse(body.Span, out var env)) throw new WebDropException(ErrorCode.BadRequest);
if (env.PayloadLength > limits.MaxPayloadBytes) throw new WebDropException(ErrorCode.TooLarge);
if (label is not null && System.Text.Encoding.UTF8.GetByteCount(label) > limits.MaxLabelBytes)
throw new WebDropException(ErrorCode.BadRequest);
ValidateShape(env);
byte[] digest = SHA256.HashData(body.Span);
// 1. 预检:鉴权并处理幂等重发,避免为重复帧白写 blob
var existing = store.Read(tx =>
{
var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required);
CheckWritable(channel, principal, env);
return tx.GetFrame(channelId, env.ItemId, env.FrameIndex);
});
if (existing is not null) return Resolve(existing, digest);
// 2. 大帧先写 blob。键含摘要,内容不同的并发写不会互相覆盖
string? blobKey = null;
if (body.Length >= limits.InlineThresholdBytes)
{
blobKey = $"{channelId.ToHex()}/{env.ItemId.ToHex()}/{env.FrameIndex}.{Convert.ToHexStringLower(digest.AsSpan(0, 16))}";
await blobs.PutAsync(blobKey, body, ct).ConfigureAwait(false);
}
// 3. 单事务提交
AppendOutcome outcome;
try
{
outcome = store.Write(tx => AppendInTx(tx, channelId, creds, env, body, digest, blobKey, maxDownloads, label));
}
catch
{
DeleteBlobIfUnreferenced(blobKey);
throw;
}
if (!outcome.Result.Created) DeleteBlobIfUnreferenced(blobKey);
foreach (string prefix in outcome.BlobPrefixesToDelete) blobs.DeletePrefix(prefix);
events.Advanced(channelId, outcome.LastSeq);
return outcome.Result;
}
private sealed class AppendOutcome(AppendResult result, long lastSeq, List<string> prefixes)
{
public AppendResult Result { get; } = result;
public long LastSeq { get; } = lastSeq;
public List<string> BlobPrefixesToDelete { get; } = prefixes;
}
private AppendOutcome AppendInTx(
IResourceTx tx, Id16 channelId, Credentials creds, Envelope env, ReadOnlyMemory<byte> body,
byte[] digest, string? blobKey, long? maxDownloads, string? label)
{
var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required);
CheckWritable(channel, principal, env);
// 并发下预检之后可能已有同一帧
if (tx.GetFrame(channelId, env.ItemId, env.FrameIndex) is { } dup)
return new AppendOutcome(Resolve(dup, digest), dup.Seq, []);
long now = clock.NowMs;
long ttlMs = channel.Settings.TtlSeconds * 1000L;
var item = tx.GetItem(channelId, env.ItemId);
if (env.FrameIndex == 0)
{
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;
item = new Item(channelId, env.ItemId, principal.EndpointHash, env.ItemFlags, max, 0, 0, -1, null, 0,
now, now + ttlMs, null, label);
tx.InsertItem(item);
}
else
{
if (item is null) throw new WebDropException(ErrorCode.BadRequest);
if (item.Flags != env.ItemFlags) throw new WebDropException(ErrorCode.BadRequest);
if (item.CloseDeadline is not null) throw new WebDropException(ErrorCode.Gone);
if (item.FinalIndex is { } fi && env.FrameIndex > fi) throw new WebDropException(ErrorCode.BadRequest);
}
if (env.FrameIndex >= limits.MaxFramesPerItem) throw new WebDropException(ErrorCode.TooLarge);
if (env.IsFinal)
{
if (item.MaxIndex > env.FrameIndex) throw new WebDropException(ErrorCode.BadRequest);
if (item.FinalIndex is { } f && f != env.FrameIndex) throw new WebDropException(ErrorCode.BadRequest);
}
if (channel.Bytes + body.Length > limits.MaxChannelBytes) throw new WebDropException(ErrorCode.TooLarge);
long seq = tx.NextSeq(channelId);
tx.InsertFrame(new StoredFrame(channelId, env.ItemId, env.FrameIndex, seq, body.Length, digest,
blobKey is null ? body.ToArray() : null, blobKey));
if (!env.IsGated)
tx.InsertLog(channelId, new LogEntry(seq, LogKind.Frame, env.ItemId, env.FrameIndex, 0, now));
else if (env.FrameIndex == 0)
tx.InsertLog(channelId, new LogEntry(seq, LogKind.Announce, env.ItemId, 0, 0, now));
tx.UpdateItem(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.AddChannelBytes(channelId, body.Length);
tx.TouchChannel(channelId, Math.Max(channel.ExpiresAt, now + ttlMs));
long lastSeq = seq;
var prefixes = new List<string>();
if (env.FrameIndex == 0)
{
// 新快照取代旧快照
if (env.IsSnapshot && tx.FindSnapshotItem(channelId, env.ItemId) is { } old)
lastSeq = DeleteItemCore(tx, channelId, old, TombstoneReason.Superseded, prefixes);
// 超出条数上限:淘汰最旧的条目
int excess = tx.CountItems(channelId) - channel.Settings.MaxItems;
if (excess > 0)
{
foreach (var victim in tx.ListOldestItems(channelId, excess + 1))
{
if (excess <= 0) break;
if (victim.ItemId == env.ItemId) continue;
lastSeq = DeleteItemCore(tx, channelId, victim, TombstoneReason.Evicted, prefixes);
excess--;
}
}
// 清理过期的墓碑,并推进续传水位
long pruned = tx.PruneTombstones(channelId, now - limits.TombstoneRetentionSeconds * 1000L);
if (pruned > channel.CompactedThrough) tx.SetCompactedThrough(channelId, pruned);
}
return new AppendOutcome(new AppendResult { Seq = seq, Created = true }, lastSeq, prefixes);
}
private static AppendResult Resolve(StoredFrame existing, byte[] digest) =>
existing.Digest.AsSpan().SequenceEqual(digest)
? new AppendResult { Seq = existing.Seq, Created = false }
: throw new WebDropException(ErrorCode.Conflict);
private void ValidateShape(Envelope env)
{
// 非 GATED 条目只能是单帧且必须 FINAL;快照只能是非 GATED 的单帧
if (!env.IsGated && (env.FrameIndex != 0 || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest);
if (env.IsSnapshot && env.IsGated) throw new WebDropException(ErrorCode.BadRequest);
}
private static void CheckWritable(Channel channel, Principal principal, Envelope env)
{
if (!ChannelPolicy.CanWrite(channel, principal)) throw new WebDropException(ErrorCode.Forbidden);
if (env.IsEnc != (channel.Mode == ChannelMode.Enc)) throw new WebDropException(ErrorCode.ModeMismatch);
if (env.IsEnc && !PayloadTiers.IsValidEncPayloadLength(env.PayloadLength))
throw new WebDropException(ErrorCode.BadRequest);
}
private void DeleteBlobIfUnreferenced(string? blobKey)
{
if (blobKey is null) return;
if (!store.Read(tx => tx.BlobKeyExists(blobKey))) blobs.Delete(blobKey);
}
// ───────────────────────── 读取 ─────────────────────────
public async Task<LogPage> ReadLogAsync(Id16 id, Credentials creds, long from, int limit, CancellationToken ct = default)
{
limit = Math.Clamp(limit, 1, 1000);
var (pending, lastSeq) = store.Read(tx =>
{
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required);
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
// 续传点早于已被清理的墓碑:客户端可能漏掉删除事件,必须从 0 全量同步
if (from > 0 && from < channel.CompactedThrough + 1)
throw new WebDropException(ErrorCode.Gone, channel.CompactedThrough + 1);
var list = new List<(long Seq, byte[]? Bytes, StoredFrame? Frame)>();
foreach (var e in tx.ReadLog(id, from, limit))
{
switch (e.Kind)
{
case LogKind.Frame:
if (tx.GetFrame(id, e.ItemId, e.FrameIndex) is { } f) list.Add((e.Seq, null, f));
break;
case LogKind.Announce:
if (tx.GetItem(id, e.ItemId) is { } it)
list.Add((e.Seq, LogRecord.EncodeAnnounce(e.Seq, e.ItemId, it.Flags, it.CreatedAt), null));
break;
case LogKind.Tombstone:
list.Add((e.Seq, LogRecord.EncodeTombstone(e.Seq, e.ItemId, (TombstoneReason)e.Aux), null));
break;
}
}
return (list, channel.LastSeq);
});
var records = new List<LogRecord>(pending.Count);
foreach (var (seq, bytes, frame) in pending)
{
if (bytes is not null) { records.Add(new LogRecord(seq, bytes)); continue; }
byte[]? env = await LoadFrameBytesAsync(frame!, ct).ConfigureAwait(false);
if (env is not null) records.Add(new LogRecord(seq, LogRecord.Encode(seq, LogKind.Frame, env)));
}
return new LogPage(records, lastSeq);
}
/// <summary>开始拉取条目:鉴权,并按规则计一次下载。<paramref name="fromIndex"/> 为 0 才计数,大于 0 视为续传。</summary>
public void StartItemDownload(Id16 id, Credentials creds, Id16 itemId, uint fromIndex)
{
store.Write(tx =>
{
var (_, 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);
long now = clock.NowMs;
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now > deadline))
throw new WebDropException(ErrorCode.Gone);
if (fromIndex == 0 && item.IsGated)
{
if (item.MaxDownloads > 0 && item.Downloads >= item.MaxDownloads)
throw new WebDropException(ErrorCode.Gone);
long downloads = item.Downloads + 1;
long? closeAt = item.MaxDownloads > 0 && downloads >= item.MaxDownloads
? now + limits.DownloadGraceSeconds * 1000L
: item.CloseDeadline;
tx.UpdateItem(item with { Downloads = downloads, CloseDeadline = closeAt });
}
return 0;
});
}
/// <summary>读取自 <paramref name="fromIndex"/> 起连续的帧。条目未完成时可能返回空批次,调用方等待后重试。</summary>
public async Task<ItemChunk> ReadItemFramesAsync(
Id16 id, Credentials creds, Id16 itemId, uint fromIndex, int maxFrames, CancellationToken ct = default)
{
maxFrames = Math.Clamp(maxFrames, 1, 64);
var (frames, next, complete) = store.Read(tx =>
{
var (_, 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 (item.CloseDeadline is { } d && clock.NowMs > d) throw new WebDropException(ErrorCode.Gone);
var taken = new List<StoredFrame>();
uint expected = fromIndex;
foreach (var f in tx.ListFrames(id, itemId, fromIndex, maxFrames))
{
if (f.Index != expected) break; // 并行上传时可能有空洞
taken.Add(f);
expected++;
}
return (taken, expected, item.FinalIndex is { } fi && expected > fi);
});
var result = new List<byte[]>(frames.Count);
foreach (var f in frames)
{
byte[]? bytes = await LoadFrameBytesAsync(f, ct).ConfigureAwait(false);
if (bytes is null) throw new WebDropException(ErrorCode.Gone);
result.Add(bytes);
}
return new ItemChunk(result, next, complete);
}
private async ValueTask<byte[]?> LoadFrameBytesAsync(StoredFrame frame, CancellationToken ct) =>
frame.Inline ?? (frame.BlobKey is null ? null : await blobs.ReadAsync(frame.BlobKey, ct).ConfigureAwait(false));
// ───────────────────────── 删除 ─────────────────────────
public void DeleteItem(Id16 id, Credentials creds, Id16 itemId)
{
var (seq, prefixes) = store.Write(tx =>
{
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required);
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
if (!ChannelPolicy.CanDeleteItem(channel, item, principal)) throw new WebDropException(ErrorCode.Forbidden);
var list = new List<string>();
long s = DeleteItemCore(tx, id, item, TombstoneReason.Deleted, list);
return (s, list);
});
foreach (string prefix in prefixes) blobs.DeletePrefix(prefix);
events.Advanced(id, seq);
}
private long DeleteItemCore(IResourceTx tx, Id16 channelId, Item item, TombstoneReason reason, List<string> prefixes)
{
tx.DeleteItemRows(channelId, item.ItemId);
tx.AddChannelBytes(channelId, -item.Bytes);
long seq = tx.NextSeq(channelId);
tx.InsertLog(channelId, new LogEntry(seq, LogKind.Tombstone, item.ItemId, 0, (byte)reason, clock.NowMs));
prefixes.Add($"{channelId.ToHex()}/{item.ItemId.ToHex()}");
return seq;
}
// ───────────────────────── 后台清理 ─────────────────────────
/// <summary>清理过期条目、下载达上限的条目、过期频道;可选清理孤立 blob。返回删除的条目和频道总数。</summary>
public int Sweep(bool includeOrphanBlobs = false)
{
int removed = 0;
long now = clock.NowMs;
foreach (var group in store.Read(tx => tx.ListExpiredItems(now, 500)).GroupBy(i => i.ChannelId))
{
var (lastSeq, prefixes, count) = store.Write(tx =>
{
long last = 0;
int n = 0;
var list = new List<string>();
foreach (var stale in group)
{
// 重新读取,期间条目可能已被删除或续期
if (tx.GetItem(stale.ChannelId, stale.ItemId) is not { } item) continue;
bool closed = item.CloseDeadline is { } d && d <= now;
if (item.ExpiresAt > now && !closed) continue;
last = DeleteItemCore(tx, item.ChannelId, item, closed ? TombstoneReason.DownloadLimit : TombstoneReason.Expired, list);
n++;
}
return (last, list, n);
});
foreach (string prefix in prefixes) blobs.DeletePrefix(prefix);
if (count > 0) events.Advanced(group.Key, lastSeq);
removed += count;
}
foreach (var channelId in store.Read(tx => tx.ListExpiredChannels(now, 200)))
{
bool deleted = store.Write(tx =>
{
if (tx.GetChannel(channelId) is not { } c || c.ExpiresAt > now) return false;
tx.DeleteChannelRows(channelId);
return true;
});
if (!deleted) continue;
blobs.DeletePrefix(channelId.ToHex());
events.ChannelDeleted(channelId);
removed++;
}
if (includeOrphanBlobs) SweepOrphanBlobs(now);
return removed;
}
/// <summary>删除没有任何帧引用、且超过 10 分钟的 blob(崩溃发生在写 blob 与提交事务之间会留下它们)。</summary>
private void SweepOrphanBlobs(long now)
{
var cutoff = DateTimeOffset.FromUnixTimeMilliseconds(now).AddMinutes(-10);
foreach (var (key, lastWrite) in blobs.Enumerate().ToList())
{
if (lastWrite > cutoff) continue;
if (!store.Read(tx => tx.BlobKeyExists(key))) blobs.Delete(key);
}
}
// ───────────────────────── 鉴权 ─────────────────────────
private (Channel Channel, Principal Principal) Authenticate(IResourceTx tx, Id16 id, Credentials creds, SeatRule seat)
{
var channel = tx.GetChannel(id);
if (channel is null || channel.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.NotFound);
var role = VerifyRole(channel, creds);
if (seat == SeatRule.Required && tx.GetSeat(id, creds.EndpointHash) is null)
throw new WebDropException(ErrorCode.NotJoined);
return (channel, new Principal(role, creds.EndpointHash));
}
private static Role VerifyRole(Channel channel, Credentials creds)
{
switch (creds.ClaimedRole)
{
case Role.Read:
// raw 频道:知道 id 即可读;enc 频道必须出示读令牌
if (channel.Mode == ChannelMode.Raw) return Role.Read;
if (creds.Token is not null && TokenHash.Matches(creds.Token, channel.ReadHash)) return Role.Read;
break;
case Role.Write:
if (creds.Token is not null && TokenHash.Matches(creds.Token, channel.WriteHash)) return Role.Write;
break;
case Role.Owner:
if (creds.Token is not null && TokenHash.Matches(creds.Token, channel.OwnerHash)) return Role.Owner;
break;
}
throw new WebDropException(ErrorCode.Unauthorized);
}
private static void RequireOwner(Principal principal)
{
if (!ChannelPolicy.IsOwner(principal)) throw new WebDropException(ErrorCode.Forbidden);
}
private static ChannelInfo ToInfo(Channel channel, int seatsUsed) => new()
{
Id = channel.Id.ToBase64Url(),
Mode = channel.Mode,
Settings = channel.Settings,
SeatsUsed = seatsUsed,
OldestSeq = channel.CompactedThrough + 1,
LastSeq = channel.LastSeq,
CreatedAt = channel.CreatedAt,
ExpiresAt = channel.ExpiresAt,
};
}
+125
View File
@@ -0,0 +1,125 @@
using System.Buffers.Binary;
using System.Text.Json.Serialization;
namespace WebDrop.Core;
public sealed class CreateChannelRequest
{
[JsonPropertyName("id")] public string? Id { get; init; }
[JsonPropertyName("mode")] public ChannelMode? Mode { get; init; }
[JsonPropertyName("settings")] public ChannelSettings? Settings { get; init; }
[JsonPropertyName("read_hash")] public string? ReadHash { get; init; }
[JsonPropertyName("write_hash")] public string? WriteHash { get; init; }
[JsonPropertyName("owner_hash")] public string? OwnerHash { get; init; }
}
public sealed class CreateShareRequest
{
[JsonPropertyName("id")] public string? Id { get; init; }
[JsonPropertyName("read_hash")] public string? ReadHash { get; init; }
[JsonPropertyName("owner_hash")] public string? OwnerHash { get; init; }
[JsonPropertyName("settings")] public ShareSettings? Settings { get; init; }
}
public sealed class FinishShareRequest
{
[JsonPropertyName("manifest_item_id")] public string? ManifestItemId { get; init; }
}
public sealed class ShareInfo
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("state")] public required string State { get; init; }
[JsonPropertyName("expires_at")] public required long ExpiresAt { get; init; }
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
[JsonPropertyName("sealed_at")] public long? SealedAt { get; init; }
[JsonPropertyName("manifest_item_id")] public string? ManifestItemId { get; init; }
[JsonPropertyName("settings")] public required ShareSettings Settings { get; init; }
[JsonPropertyName("items")] public required IReadOnlyList<ShareItemInfo> Items { get; init; }
}
public sealed class ShareItemInfo
{
[JsonPropertyName("item_id")] public required string ItemId { get; init; }
[JsonPropertyName("bytes")] public required long Bytes { get; init; }
[JsonPropertyName("frames")] public required int Frames { get; init; }
[JsonPropertyName("max_downloads")] public required long MaxDownloads { get; init; }
[JsonPropertyName("downloads_left")] [JsonIgnore(Condition = JsonIgnoreCondition.Never)] public long? DownloadsLeft { get; init; }
}
public sealed class ChannelInfo
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("mode")] public required ChannelMode Mode { get; init; }
[JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; }
[JsonPropertyName("seats_used")] public required int SeatsUsed { get; init; }
/// <summary>从该 seq 起的续传有效;0 &lt; from &lt; oldest_seq 的续传会得到 410,需从 0 全量同步。</summary>
[JsonPropertyName("oldest_seq")] public required long OldestSeq { get; init; }
[JsonPropertyName("last_seq")] public required long LastSeq { get; init; }
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
[JsonPropertyName("expires_at")] public required long ExpiresAt { get; init; }
}
public sealed class SeatJoined
{
[JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; }
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
[JsonPropertyName("seats_used")] public required int SeatsUsed { get; init; }
[JsonPropertyName("seats_max")] public required int SeatsMax { get; init; }
}
public sealed class SeatInfo
{
[JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; }
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
[JsonPropertyName("label")] public string? Label { get; init; }
}
public sealed class AppendResult
{
[JsonPropertyName("seq")] public required long Seq { get; init; }
/// <summary>true 为新追加(201),false 为幂等重发(200)。</summary>
[JsonIgnore] public bool Created { get; init; }
}
/// <summary>一条日志记录的线格式:seq(u64) ‖ kind(u8) ‖ body。</summary>
public sealed record LogRecord(long Seq, byte[] Bytes)
{
public static byte[] Encode(long seq, LogKind kind, ReadOnlySpan<byte> body)
{
var bytes = new byte[9 + body.Length];
BinaryPrimitives.WriteInt64BigEndian(bytes, seq);
bytes[8] = (byte)kind;
body.CopyTo(bytes.AsSpan(9));
return bytes;
}
public static byte[] EncodeTombstone(long seq, Id16 itemId, TombstoneReason reason)
{
Span<byte> body = stackalloc byte[Id16.Size + 1];
itemId.WriteTo(body);
body[Id16.Size] = (byte)reason;
return Encode(seq, LogKind.Tombstone, body);
}
public static byte[] EncodeAnnounce(long seq, Id16 itemId, FrameFlags flags, long createdMs)
{
Span<byte> body = stackalloc byte[Id16.Size + 1 + 8];
itemId.WriteTo(body);
body[Id16.Size] = (byte)flags;
BinaryPrimitives.WriteInt64BigEndian(body[(Id16.Size + 1)..], createdMs);
return Encode(seq, LogKind.Announce, body);
}
}
public sealed record LogPage(IReadOnlyList<LogRecord> Records, long LastSeq);
/// <summary>GATED 条目拉取的一个批次:自 FromIndex 起连续的完整封装。</summary>
public sealed record ItemChunk(IReadOnlyList<byte[]> Frames, uint NextIndex, bool Complete);
[JsonSerializable(typeof(ChannelSettings))]
[JsonSerializable(typeof(ShareSettings))]
public sealed partial class CoreJsonContext : JsonSerializerContext;
+53
View File
@@ -0,0 +1,53 @@
using System.Text.Json.Serialization;
namespace WebDrop.Core;
[JsonConverter(typeof(JsonStringEnumConverter<ChannelMode>))]
public enum ChannelMode : byte
{
[JsonStringEnumMemberName("enc")] Enc = 1,
[JsonStringEnumMemberName("raw")] Raw = 2,
}
[JsonConverter(typeof(JsonStringEnumConverter<DeletePolicy>))]
public enum DeletePolicy : byte
{
[JsonStringEnumMemberName("owner_only")] OwnerOnly = 0,
[JsonStringEnumMemberName("own")] Own = 1,
[JsonStringEnumMemberName("anyone")] Anyone = 2,
}
/// <summary>请求声明并通过验证的角色,数值越大权限越高。</summary>
public enum Role : byte
{
None = 0,
Read = 1,
Write = 2,
Owner = 3,
}
[Flags]
public enum FrameFlags : byte
{
None = 0,
Final = 1,
Snapshot = 2,
Gated = 4,
Enc = 8,
}
public enum LogKind : byte
{
Frame = 1,
Tombstone = 2,
Announce = 3,
}
public enum TombstoneReason : byte
{
Deleted = 0,
Expired = 1,
Evicted = 2,
DownloadLimit = 3,
Superseded = 4,
}
+50
View File
@@ -0,0 +1,50 @@
using System.Buffers.Binary;
namespace WebDrop.Core;
/// <summary>帧封装的 26 字节明文头部,见 protocol.md §4。负载由服务端原样保存,从不解析。</summary>
public readonly record struct Envelope(FrameFlags Flags, Id16 ItemId, uint FrameIndex, int PayloadLength)
{
public const int HeaderSize = 26;
public const byte Version = 1;
public int TotalLength => HeaderSize + PayloadLength;
/// <summary>头部格式合法、保留位为 0、长度与 payload_len 严格一致。</summary>
public static bool TryParse(ReadOnlySpan<byte> data, out Envelope envelope)
{
envelope = default;
if (data.Length < HeaderSize || data[0] != Version) return false;
byte flags = data[1];
if ((flags & 0xF0) != 0) return false;
uint payloadLen = BinaryPrimitives.ReadUInt32BigEndian(data[22..]);
if (payloadLen > int.MaxValue || data.Length != HeaderSize + (int)payloadLen) return false;
envelope = new Envelope(
(FrameFlags)flags,
Id16.FromBytes(data.Slice(2, Id16.Size)),
BinaryPrimitives.ReadUInt32BigEndian(data[18..]),
(int)payloadLen);
return true;
}
public bool IsFinal => (Flags & FrameFlags.Final) != 0;
public bool IsGated => (Flags & FrameFlags.Gated) != 0;
public bool IsSnapshot => (Flags & FrameFlags.Snapshot) != 0;
public bool IsEnc => (Flags & FrameFlags.Enc) != 0;
/// <summary>条目级标志:同一条目的所有帧必须一致。</summary>
public FrameFlags ItemFlags => Flags & (FrameFlags.Snapshot | FrameFlags.Gated);
}
public static class PayloadTiers
{
public const int TagSize = 16;
private static readonly int[] Tiers = [1024, 4096, 16384, 65536, 262144, 1048576];
/// <summary>enc 负载 = 档位明文 + 16 字节 GCM 标签,服务端据此强制 padding。</summary>
public static bool IsValidEncPayloadLength(int payloadLength) =>
payloadLength > TagSize && Array.IndexOf(Tiers, payloadLength - TagSize) >= 0;
}
+75
View File
@@ -0,0 +1,75 @@
namespace WebDrop.Core;
public enum ErrorCode
{
BadRequest,
Unauthorized,
Forbidden,
NotJoined,
SeatsFull,
ModeMismatch,
NotFound,
Conflict,
Gone,
TooLarge,
CaptchaRequired,
CaptchaUnavailable,
RateLimited,
VisitorsFull,
Sealed,
}
/// <summary>预期内的业务失败。消息只含错误码,绝不回显用户提交的内容。</summary>
public sealed class WebDropException : Exception
{
public ErrorCode Code { get; }
/// <summary>仅在 410 日志续传失败时有值,见 api.md。</summary>
public long? OldestSeq { get; }
public WebDropException(ErrorCode code, long? oldestSeq = null) : base(code.ToWire())
{
Code = code;
OldestSeq = oldestSeq;
}
}
public static class ErrorCodes
{
public static string ToWire(this ErrorCode code) => code switch
{
ErrorCode.BadRequest => "bad_request",
ErrorCode.Unauthorized => "unauthorized",
ErrorCode.Forbidden => "forbidden",
ErrorCode.NotJoined => "not_joined",
ErrorCode.SeatsFull => "seats_full",
ErrorCode.ModeMismatch => "mode_mismatch",
ErrorCode.NotFound => "not_found",
ErrorCode.Conflict => "conflict",
ErrorCode.Gone => "gone",
ErrorCode.TooLarge => "too_large",
ErrorCode.CaptchaRequired => "captcha_required",
ErrorCode.CaptchaUnavailable => "captcha_unavailable",
ErrorCode.RateLimited => "rate_limited",
ErrorCode.VisitorsFull => "visitors_full",
ErrorCode.Sealed => "sealed",
_ => "bad_request",
};
public static int ToStatus(this ErrorCode code) => code switch
{
ErrorCode.BadRequest => 400,
ErrorCode.Unauthorized => 401,
ErrorCode.Forbidden or ErrorCode.NotJoined or ErrorCode.SeatsFull or ErrorCode.ModeMismatch => 403,
ErrorCode.NotFound => 404,
ErrorCode.Conflict => 409,
ErrorCode.Sealed => 409,
ErrorCode.Gone => 410,
ErrorCode.TooLarge => 413,
ErrorCode.CaptchaRequired => 428,
ErrorCode.CaptchaUnavailable => 503,
ErrorCode.RateLimited => 429,
ErrorCode.VisitorsFull => 403,
_ => 400,
};
}
+91
View File
@@ -0,0 +1,91 @@
using System.Buffers.Binary;
using System.Buffers.Text;
namespace WebDrop.Core;
/// <summary>16 字节标识(频道 id、条目 id),可作字典键。</summary>
public readonly struct Id16 : IEquatable<Id16>
{
public const int Size = 16;
private const int Base64Length = 22;
private readonly ulong _hi;
private readonly ulong _lo;
private Id16(ulong hi, ulong lo)
{
_hi = hi;
_lo = lo;
}
public static Id16 FromBytes(ReadOnlySpan<byte> bytes)
{
if (bytes.Length != Size) throw new ArgumentException("Id16 需要 16 字节", nameof(bytes));
return new Id16(BinaryPrimitives.ReadUInt64BigEndian(bytes), BinaryPrimitives.ReadUInt64BigEndian(bytes[8..]));
}
/// <summary>只接受规范的 22 字符 base64url,拒绝同一值的非规范编码。</summary>
public static bool TryParse(ReadOnlySpan<char> text, out Id16 id)
{
id = default;
if (text.Length != Base64Length) return false;
Span<byte> buf = stackalloc byte[Size];
if (!Codec.TryDecodeExact(text, buf)) return false;
id = FromBytes(buf);
return true;
}
public void WriteTo(Span<byte> destination)
{
BinaryPrimitives.WriteUInt64BigEndian(destination, _hi);
BinaryPrimitives.WriteUInt64BigEndian(destination[8..], _lo);
}
public byte[] ToArray()
{
var bytes = new byte[Size];
WriteTo(bytes);
return bytes;
}
public string ToBase64Url() => Codec.Encode(ToArray());
/// <summary>小写十六进制。用于文件路径,避免大小写不敏感的文件系统造成不同 id 冲突。</summary>
public string ToHex() => Convert.ToHexStringLower(ToArray());
public bool Equals(Id16 other) => _hi == other._hi && _lo == other._lo;
public override bool Equals(object? obj) => obj is Id16 other && Equals(other);
public override int GetHashCode() => HashCode.Combine(_hi, _lo);
public static bool operator ==(Id16 a, Id16 b) => a.Equals(b);
public static bool operator !=(Id16 a, Id16 b) => !a.Equals(b);
public override string ToString() => ToBase64Url();
}
/// <summary>base64url(无填充)编解码,解码要求规范编码。</summary>
public static class Codec
{
public static string Encode(ReadOnlySpan<byte> bytes) => Base64Url.EncodeToString(bytes);
/// <summary>解码为恰好 destination.Length 字节,且重新编码后必须与输入逐字符一致。</summary>
public static bool TryDecodeExact(ReadOnlySpan<char> text, Span<byte> destination)
{
// 注意:Base64Url.TryDecodeFromChars 遇到非法输入会抛 FormatException,这里用返回状态码的版本
var status = Base64Url.DecodeFromChars(text, destination, out int consumed, out int written, isFinalBlock: true);
if (status != System.Buffers.OperationStatus.Done || consumed != text.Length || written != destination.Length)
return false;
Span<char> check = stackalloc char[Base64Url.GetEncodedLength(destination.Length)];
return Base64Url.TryEncodeToChars(destination, check, out int n)
&& n == text.Length
&& check[..n].SequenceEqual(text);
}
public static bool TryDecodeExact(string? text, int length, out byte[] bytes)
{
bytes = [];
if (string.IsNullOrEmpty(text) || text.Length != Base64Url.GetEncodedLength(length)) return false;
var buf = new byte[length];
if (!TryDecodeExact(text.AsSpan(), buf)) return false;
bytes = buf;
return true;
}
}
+100
View File
@@ -0,0 +1,100 @@
namespace WebDrop.Core;
public sealed record Channel(
Id16 Id,
ChannelMode Mode,
ChannelSettings Settings,
byte[]? ReadHash,
byte[]? WriteHash,
byte[] OwnerHash,
long CreatedAt,
long ExpiresAt,
long LastSeq,
long CompactedThrough,
long Bytes);
public sealed record Share(
Id16 Id,
ShareSettings Settings,
byte[] ReadHash,
byte[]? OwnerHash,
byte[]? CreatorEndpointHash,
long CreatedAt,
long ExpiresAt,
long? SealedAt,
Id16? ManifestItemId,
long Bytes)
{
public bool IsSealed => SealedAt is not null;
}
public sealed record ShareItem(
Id16 ShareId,
Id16 ItemId,
FrameFlags Flags,
long MaxDownloads,
long Downloads,
int FrameCount,
long MaxIndex,
long? FinalIndex,
long Bytes,
long CreatedAt,
long ExpiresAt,
long? CloseDeadline)
{
public bool IsGated => (Flags & FrameFlags.Gated) != 0;
public bool IsSnapshot => (Flags & FrameFlags.Snapshot) != 0;
public bool IsComplete => FinalIndex is { } f && FrameCount == f + 1;
}
public sealed record ShareFrame(
Id16 ShareId,
Id16 ItemId,
uint Index,
long AckSeq,
int Length,
byte[] Digest,
byte[]? Inline,
string? BlobKey);
public sealed record Item(
Id16 ChannelId,
Id16 ItemId,
byte[] AuthorHash,
FrameFlags Flags,
long MaxDownloads,
long Downloads,
int FrameCount,
long MaxIndex,
long? FinalIndex,
long Bytes,
long CreatedAt,
long ExpiresAt,
long? CloseDeadline,
string? Label)
{
public bool IsGated => (Flags & FrameFlags.Gated) != 0;
public bool IsSnapshot => (Flags & FrameFlags.Snapshot) != 0;
public bool IsComplete => FinalIndex is { } f && FrameCount == f + 1;
}
public sealed record Seat(byte[] EndpointHash, long JoinedAt, byte[]? Label);
/// <summary>已存储的一帧(完整封装)。Inline 与 BlobKey 二选一。</summary>
public sealed record StoredFrame(
Id16 ChannelId,
Id16 ItemId,
uint Index,
long Seq,
int Length,
byte[] Digest,
byte[]? Inline,
string? BlobKey);
public sealed record LogEntry(long Seq, LogKind Kind, Id16 ItemId, uint FrameIndex, byte Aux, long CreatedAt);
/// <summary>一次请求验证通过后的身份。</summary>
public sealed record Principal(Role Role, byte[] EndpointHash);
/// <summary>请求携带的凭据,尚未验证。</summary>
public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash);
+90
View File
@@ -0,0 +1,90 @@
using System.Text.Json.Serialization;
namespace WebDrop.Core;
/// <summary>服务端限制,由配置绑定。</summary>
public sealed class ServerLimits
{
public int MaxPayloadBytes { get; set; } = (1 << 20) + 16;
public int MaxFramesPerItem { get; set; } = 65_536;
public long MaxChannelBytes { get; set; } = 1L << 30;
public int MaxSeats { get; set; } = 64;
public int MinTtlSeconds { get; set; } = 60;
public int MaxTtlSeconds { get; set; } = 604_800;
public int MaxItemsLimit { get; set; } = 10_000;
public int InlineThresholdBytes { get; set; } = 64 * 1024;
public int DownloadGraceSeconds { get; set; } = 300;
public int TombstoneRetentionSeconds { get; set; } = 604_800;
public int MaxLabelBytes { get; set; } = 255;
public int MaxSeatLabelBytes { get; set; } = 256;
}
public sealed class ChannelSettings
{
[JsonPropertyName("seats")] public int Seats { get; init; } = 2;
[JsonPropertyName("members_can_upload")] public bool MembersCanUpload { get; init; } = true;
[JsonPropertyName("delete_policy")] public DeletePolicy DeletePolicy { get; init; } = DeletePolicy.Own;
[JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; init; } = 86_400;
[JsonPropertyName("max_items")] public int MaxItems { get; init; } = 1000;
[JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; init; } = 10;
[JsonPropertyName("ui_restrict_copy_download")] public bool UiRestrictCopyDownload { get; init; }
public void Validate(ServerLimits limits)
{
if (Seats < 1 || Seats > limits.MaxSeats) throw new WebDropException(ErrorCode.BadRequest);
if (TtlSeconds < limits.MinTtlSeconds || TtlSeconds > limits.MaxTtlSeconds) throw new WebDropException(ErrorCode.BadRequest);
if (MaxItems < 1 || MaxItems > limits.MaxItemsLimit) throw new WebDropException(ErrorCode.BadRequest);
if (FileMaxDownloads < 0) throw new WebDropException(ErrorCode.BadRequest);
}
}
public sealed class ChannelSettingsPatch
{
[JsonPropertyName("seats")] public int? Seats { get; init; }
[JsonPropertyName("members_can_upload")] public bool? MembersCanUpload { get; init; }
[JsonPropertyName("delete_policy")] public DeletePolicy? DeletePolicy { get; init; }
[JsonPropertyName("ttl_seconds")] public int? TtlSeconds { get; init; }
[JsonPropertyName("max_items")] public int? MaxItems { get; init; }
[JsonPropertyName("file_max_downloads")] public int? FileMaxDownloads { get; init; }
[JsonPropertyName("ui_restrict_copy_download")] public bool? UiRestrictCopyDownload { get; init; }
public ChannelSettings ApplyTo(ChannelSettings s) => new()
{
Seats = Seats ?? s.Seats,
MembersCanUpload = MembersCanUpload ?? s.MembersCanUpload,
DeletePolicy = DeletePolicy ?? s.DeletePolicy,
TtlSeconds = TtlSeconds ?? s.TtlSeconds,
MaxItems = MaxItems ?? s.MaxItems,
FileMaxDownloads = FileMaxDownloads ?? s.FileMaxDownloads,
UiRestrictCopyDownload = UiRestrictCopyDownload ?? s.UiRestrictCopyDownload,
};
}
public sealed class ShareSettings
{
[JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; init; } = 86_400;
[JsonPropertyName("max_visitors")] public int MaxVisitors { get; init; }
[JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; init; } = 10;
public void Validate(ServerLimits limits)
{
if (TtlSeconds < limits.MinTtlSeconds || TtlSeconds > limits.MaxTtlSeconds
|| MaxVisitors < 0 || MaxVisitors > 10_000
|| FileMaxDownloads < 0)
throw new WebDropException(ErrorCode.BadRequest);
}
}
public sealed class ShareSettingsPatch
{
[JsonPropertyName("ttl_seconds")] public int? TtlSeconds { get; init; }
[JsonPropertyName("max_visitors")] public int? MaxVisitors { get; init; }
[JsonPropertyName("file_max_downloads")] public int? FileMaxDownloads { get; init; }
public ShareSettings ApplyTo(ShareSettings s) => new()
{
TtlSeconds = TtlSeconds ?? s.TtlSeconds,
MaxVisitors = MaxVisitors ?? s.MaxVisitors,
FileMaxDownloads = FileMaxDownloads ?? s.FileMaxDownloads,
};
}
+359
View File
@@ -0,0 +1,359 @@
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)
{
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 ?? new ShareSettings();
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);
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) 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;
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);
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);
RegisterVisitor(tx, share, creds.EndpointHash);
var item = tx.GetShareItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
if (!item.IsGated) throw new WebDropException(ErrorCode.BadRequest);
long now = clock.NowMs;
if (item.CloseDeadline is { } d && (fromIndex == 0 || now > d)) throw new WebDropException(ErrorCode.Gone);
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 });
}
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);
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 (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)
{
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,
MaxDownloads = i.MaxDownloads,
DownloadsLeft = i.MaxDownloads == 0 ? null : Math.Max(0, i.MaxDownloads - i.Downloads),
}).ToList(),
};
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>