feat: integrate client and server sharing workflows
This commit is contained in:
1 parent
42b93ae2d8
commit
7406e2fe37
101 files changed
+3153
-253
No files matched your search
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user