feat: integrate client and server sharing workflows

This commit is contained in:
justme committed 2026-10-02 10:49:45 +08:00
1 parent 42b93ae2d8
commit 7406e2fe37
101 files changed
+3153 -253

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
namespace WebDrop.Api;
public static class ApiContract
{
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
public const string Version = "1.0.0";
}
+1
View File
@@ -17,6 +17,7 @@ public sealed class GoneLogBody
public sealed class ServerInfoBody
{
[JsonPropertyName("protocol")] public int Protocol { get; init; } = 1;
[JsonPropertyName("contract_version")] public string ContractVersion { get; init; } = ApiContract.Version;
[JsonPropertyName("captcha")] public required CaptchaBody Captcha { get; init; }
[JsonPropertyName("transports")] public required TransportsBody Transports { get; init; }
[JsonPropertyName("limits")] public required LimitsBody Limits { get; init; }
+2
View File
@@ -82,6 +82,8 @@ public interface IResourceTx
void InsertShareItem(ShareItem item);
void UpdateShareItem(ShareItem item);
void DeleteShareItemRows(Id16 shareId, Id16 itemId);
bool HasDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan<byte> endpointHash);
void AddDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan<byte> endpointHash);
ShareFrame? GetShareFrame(Id16 shareId, Id16 itemId, uint index);
void InsertShareFrame(ShareFrame frame);
IReadOnlyList<ShareFrame> ListShareFrames(Id16 shareId, Id16 itemId, uint fromIndex, int limit);
+17 -4
View File
@@ -324,6 +324,9 @@ public sealed class ChannelService(
if (!store.Read(tx => tx.BlobKeyExists(blobKey))) blobs.Delete(blobKey);
}
private static bool IsItemReadable(Item item, long now) =>
item.ExpiresAt > now && (item.CloseDeadline is null || item.CloseDeadline > now);
// ───────────────────────── 读取 ─────────────────────────
public async Task<LogPage> ReadLogAsync(Id16 id, Credentials creds, long from, int limit, CancellationToken ct = default)
@@ -338,16 +341,19 @@ public sealed class ChannelService(
if (from > 0 && from < channel.CompactedThrough + 1)
throw new WebDropException(ErrorCode.Gone, channel.CompactedThrough + 1);
long now = clock.NowMs;
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));
if (tx.GetItem(id, e.ItemId) is { } frameItem && IsItemReadable(frameItem, now)
&& 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)
if (tx.GetItem(id, e.ItemId) is { } it && IsItemReadable(it, now))
list.Add((e.Seq, LogRecord.EncodeAnnounce(e.Seq, e.ItemId, it.Flags, it.CreatedAt), null));
break;
case LogKind.Tombstone:
@@ -378,7 +384,10 @@ public sealed class ChannelService(
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
long now = clock.NowMs;
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now > deadline))
if (item.ExpiresAt <= now) throw new WebDropException(ErrorCode.Gone);
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now >= deadline))
throw new WebDropException(ErrorCode.Gone);
if (item.IsGated && fromIndex > 0 && !tx.HasDownloadGrant(id, itemId, principal.EndpointHash))
throw new WebDropException(ErrorCode.Gone);
if (fromIndex == 0 && item.IsGated)
@@ -391,6 +400,7 @@ public sealed class ChannelService(
? now + limits.DownloadGraceSeconds * 1000L
: item.CloseDeadline;
tx.UpdateItem(item with { Downloads = downloads, CloseDeadline = closeAt });
tx.AddDownloadGrant(id, itemId, principal.EndpointHash);
}
return 0;
});
@@ -406,7 +416,10 @@ public sealed class ChannelService(
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);
if (item.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.Gone);
if (item.IsGated && !tx.HasDownloadGrant(id, itemId, principal.EndpointHash))
throw new WebDropException(ErrorCode.Gone);
if (item.CloseDeadline is { } d && clock.NowMs >= d) throw new WebDropException(ErrorCode.Gone);
var taken = new List<StoredFrame>();
uint expected = fromIndex;
+10 -2
View File
@@ -210,11 +210,14 @@ public sealed class ShareService(
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);
if (fromIndex > 0 && !tx.HasDownloadGrant(id, itemId, creds.EndpointHash))
throw new WebDropException(ErrorCode.Gone);
long now = clock.NowMs;
if (item.CloseDeadline is { } d && (fromIndex == 0 || now > d)) throw new WebDropException(ErrorCode.Gone);
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now >= deadline))
throw new WebDropException(ErrorCode.Gone);
RegisterVisitor(tx, share, creds.EndpointHash);
if (fromIndex == 0)
{
if (item.MaxDownloads > 0 && item.Downloads >= item.MaxDownloads) throw new WebDropException(ErrorCode.Gone);
@@ -223,6 +226,7 @@ public sealed class ShareService(
? now + limits.DownloadGraceSeconds * 1000L
: item.CloseDeadline;
tx.UpdateShareItem(item with { Downloads = count, CloseDeadline = closeAt });
tx.AddDownloadGrant(id, itemId, creds.EndpointHash);
}
return 0;
});
@@ -234,6 +238,10 @@ public sealed class ShareService(
{
_ = 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)
@@ -10,7 +10,7 @@ namespace WebDrop.Storage;
/// </summary>
public sealed class SqliteChannelStore : IResourceStore, IDisposable
{
private const int SchemaVersion = 2;
private const int SchemaVersion = 3;
private readonly string _connectionString;
private readonly SqliteConnection _writeConn;
@@ -93,6 +93,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
using var tx = _writeConn.BeginTransaction();
if (version == 0) Exec(_writeConn, SchemaV1);
if (version < 2) Exec(_writeConn, SchemaV2);
if (version < 3) Exec(_writeConn, SchemaV3);
Exec(_writeConn, $"PRAGMA user_version={SchemaVersion};");
tx.Commit();
}
@@ -222,6 +223,16 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
CREATE INDEX ix_share_frames_blob ON share_frames(blob_key) WHERE blob_key IS NOT NULL;
""";
private const string SchemaV3 = """
CREATE TABLE download_grants (
resource_id BLOB NOT NULL,
item_id BLOB NOT NULL,
endpoint_hash BLOB NOT NULL,
PRIMARY KEY (resource_id, item_id, endpoint_hash)
) WITHOUT ROWID;
CREATE INDEX ix_download_grants_resource ON download_grants(resource_id);
""";
private sealed class SqliteTx(SqliteConnection conn) : IResourceTx
{
private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes";
@@ -319,6 +330,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
public void DeleteShareRows(Id16 id)
{
byte[] key = id.ToArray();
Run("DELETE FROM download_grants WHERE resource_id=@id", ("@id", key));
Run("DELETE FROM share_frames WHERE share_id=@id", ("@id", key));
Run("DELETE FROM share_items WHERE share_id=@id", ("@id", key));
Run("DELETE FROM share_visitors WHERE share_id=@id", ("@id", key));
@@ -351,10 +363,19 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline), ("@s", i.ShareId.ToArray()), ("@i", i.ItemId.ToArray()));
public void DeleteShareItemRows(Id16 shareId, Id16 itemId)
{
Run("DELETE FROM download_grants WHERE resource_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
Run("DELETE FROM share_frames WHERE share_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
Run("DELETE FROM share_items WHERE share_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
}
public bool HasDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan<byte> endpointHash) => Scalar(
"SELECT 1 FROM download_grants WHERE resource_id=@r AND item_id=@i AND endpoint_hash=@e",
("@r", resourceId.ToArray()), ("@i", itemId.ToArray()), ("@e", endpointHash.ToArray())) is not null;
public void AddDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan<byte> endpointHash) => Run(
"INSERT OR IGNORE INTO download_grants (resource_id,item_id,endpoint_hash) VALUES (@r,@i,@e)",
("@r", resourceId.ToArray()), ("@i", itemId.ToArray()), ("@e", endpointHash.ToArray()));
private static ShareFrame MapShareFrame(SqliteDataReader r) => new(
IdAt(r, 0), IdAt(r, 1), (uint)r.GetInt64(2), r.GetInt64(3), r.GetInt32(4), Blob(r, 5), BlobOrNull(r, 6),
r.IsDBNull(7) ? null : r.GetString(7));
@@ -411,6 +432,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
public void DeleteChannelRows(Id16 id)
{
byte[] key = id.ToArray();
Run("DELETE FROM download_grants WHERE resource_id=@c", ("@c", key));
Run("DELETE FROM frames WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM log WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM items WHERE channel_id=@c", ("@c", key));
@@ -476,6 +498,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
public void DeleteItemRows(Id16 channelId, Id16 itemId)
{
byte[] c = channelId.ToArray(), i = itemId.ToArray();
Run("DELETE FROM download_grants WHERE resource_id=@c AND item_id=@i", ("@c", c), ("@i", i));
Run("DELETE FROM frames WHERE channel_id=@c AND item_id=@i", ("@c", c), ("@i", i));
// 墓碑保留,其余记录(帧、announce)随条目一起删除
Run("DELETE FROM log WHERE channel_id=@c AND item_id=@i AND kind<>@t", ("@c", c), ("@i", i), ("@t", (int)LogKind.Tombstone));