feat(api): bind encrypted workspaces to accounts and devices

This commit is contained in:
justme committed 2026-10-13 01:38:19 +08:00
1 parent 019b15041a
commit c7d8ef15bc
49 files changed
+1904 -278

No files matched your search

+21
View File
@@ -92,6 +92,26 @@ public interface IResourceTx
Channel? GetChannel(Id16 id);
void InsertChannel(Channel channel);
void UpdateChannel(Id16 id, ChannelSettings settings, long expiresAt);
void SetJoinPassword(Id16 id, byte[]? hash, byte[]? salt);
bool HasInvitation(Id16 id, string username);
void PutInvitation(ChannelInvitation invitation);
IReadOnlyList<ChannelInvitation> ListInvitations(Id16 id);
IReadOnlyList<ChannelInvitation> ListUserInvitations(string username);
void DeleteInvitation(Id16 id, string username);
void PutKeyPackage(Id16 id, string username, string payload, long createdAt);
void DeleteKeyPackage(Id16 id, string username);
IReadOnlyList<ChannelKeyPackage> ListUserKeyPackages(string username, long now);
IReadOnlyList<Id16> DeleteDeviceSeats(string username, string deviceId);
void BindSeatDevice(Id16 id, byte[] endpointHash, string deviceId);
void DeleteAccountSeats(Id16 id, string username);
ChannelMember? GetChannelMember(Id16 id, string username);
IReadOnlyList<ChannelMember> ListChannelMembers(Id16 id);
void PutChannelMember(Id16 id, ChannelMember member);
void DeleteChannelMember(Id16 id, string username);
bool IsJoinDenied(Id16 id, string? username, byte[] endpointHash);
void DenyAccount(Id16 id, string username);
void AllowAccount(Id16 id, string username);
void DenyEndpoint(Id16 id, byte[] endpointHash);
void TouchChannel(Id16 id, long expiresAt);
void AddChannelBytes(Id16 id, long delta);
void SetCompactedThrough(Id16 id, long seq);
@@ -113,6 +133,7 @@ public interface IResourceTx
void UpdateItem(Item item);
void DeleteItemRows(Id16 channelId, Id16 itemId);
int CountItems(Id16 channelId);
int CountControlItems(Id16 channelId);
IReadOnlyList<Item> ListOldestItems(Id16 channelId, int limit);
Item? FindSnapshotItem(Id16 channelId, Id16 excludeItemId);
IReadOnlyList<Item> ListExpiredItems(long now, int limit);
@@ -0,0 +1,99 @@
using System.Security.Cryptography;
namespace WebDrop.Core;
public sealed partial class ChannelService
{
private static (byte[]? Hash, byte[]? Salt) HashJoinPassword(string? password)
{
if (string.IsNullOrEmpty(password)) return (null, null);
if (password.Length is < 8 or > 256) throw new WebDropException(ErrorCode.BadRequest);
var salt = RandomNumberGenerator.GetBytes(16);
return (Rfc2898DeriveBytes.Pbkdf2(password, salt, 210_000, HashAlgorithmName.SHA256, 32), salt);
}
private static void CheckAdmission(IResourceTx tx, Channel channel, Credentials creds, string? password)
{
if (creds.AccountName is not null && creds.AccountName == channel.CreatorAccount) return;
if (tx.IsJoinDenied(channel.Id, creds.AccountName, creds.EndpointHash)) throw new WebDropException(ErrorCode.Forbidden);
bool invited = creds.AccountName is not null && tx.HasInvitation(channel.Id, creds.AccountName);
if (invited) return;
if (creds.AccountName is not null && tx.GetChannelMember(channel.Id, creds.AccountName) is not null) return;
if (channel.Settings.JoinPolicy == "invite") throw new WebDropException(ErrorCode.InvitationRequired);
if (!channel.Settings.AllowGuests && creds.AccountName is null) throw new WebDropException(ErrorCode.AccountRequired);
if (channel.JoinPasswordHash is null) return;
if (string.IsNullOrEmpty(password)) throw new WebDropException(ErrorCode.ChannelPasswordRequired);
if (password.Length > 256) throw new WebDropException(ErrorCode.ChannelPasswordInvalid);
var actual = Rfc2898DeriveBytes.Pbkdf2(password, channel.JoinPasswordSalt!, 210_000, HashAlgorithmName.SHA256, 32);
if (!CryptographicOperations.FixedTimeEquals(actual, channel.JoinPasswordHash)) throw new WebDropException(ErrorCode.ChannelPasswordInvalid);
}
public void InviteUser(Id16 id, Credentials creds, string username, string payload)
{
if (payload is null || payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
tx.PutInvitation(new ChannelInvitation(id, username, payload, clock.NowMs));
if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden);
tx.PutKeyPackage(id, username, payload, clock.NowMs);
tx.AllowAccount(id, username);
return 0;
});
}
public IReadOnlyList<ChannelInvitation> Invitations(Id16 id, Credentials creds) => store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
return tx.ListInvitations(id);
});
public IReadOnlyList<ChannelInvitation> UserInvitations(string username) => store.Read(tx => tx.ListUserInvitations(username));
public void RevokeInvitation(Id16 id, Credentials creds, string username)
{
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden);
tx.DeleteInvitation(id, username);
tx.DeleteKeyPackage(id, username);
tx.DeleteAccountSeats(id, username);
tx.DeleteChannelMember(id, username);
tx.DenyAccount(id, username);
return 0;
});
events.SeatsChanged(id);
}
public IReadOnlyList<ChannelMember> Members(Id16 id, Credentials creds) => store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
if (principal.Role != Role.Owner) _ = Authenticate(tx, id, creds, SeatRule.Required);
return tx.ListChannelMembers(id);
});
public IReadOnlyList<ChannelKeyPackage> AccountChannels(string username) =>
store.Read(tx => tx.ListUserKeyPackages(username, clock.NowMs));
public void ReleaseAccountDevice(string username, string deviceId)
{
var channels = store.Write(tx => tx.DeleteDeviceSeats(username, deviceId));
foreach (var id in channels) events.SeatsChanged(id);
}
public void SaveOwnerKeyPackage(Id16 id, Credentials creds, string payload)
{
if (payload is null || payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
tx.PutKeyPackage(id, principal.AccountName!, payload, clock.NowMs);
return 0;
});
}
}
+3 -1
View File
@@ -19,7 +19,9 @@ public static class ChannelPolicy
return channel.Settings.DeletePolicy switch
{
DeletePolicy.Anyone => true,
DeletePolicy.Own => item.AuthorHash.AsSpan().SequenceEqual(p.EndpointHash),
DeletePolicy.Own => item.AuthorAccount is not null
? item.AuthorAccount == p.AccountName
: item.AuthorHash.AsSpan().SequenceEqual(p.EndpointHash),
_ => false,
};
}
+67 -17
View File
@@ -6,7 +6,7 @@ namespace WebDrop.Core;
/// 频道的全部业务逻辑。依赖抽象(存储、blob、时钟、事件),不含 HTTP、SQL、文件细节。
/// 约定:所有失败以 <see cref="WebDropException"/> 表达;提交成功后才发事件;blob 先写后提交。
/// </summary>
public sealed class ChannelService(
public sealed partial class ChannelService(
IResourceStore store,
IBlobStore blobs,
IClock clock,
@@ -17,8 +17,10 @@ public sealed class ChannelService(
// ───────────────────────── 频道 ─────────────────────────
public ChannelInfo CreateChannel(CreateChannelRequest req, byte[] endpointHash)
public ChannelInfo CreateChannel(CreateChannelRequest req, byte[] endpointHash, string creatorAccount, string? accountDeviceId = null)
{
if (string.IsNullOrWhiteSpace(creatorAccount)) throw new WebDropException(ErrorCode.AccountRequired);
if (req.KeyPackage is not null && req.KeyPackage.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
if (!Id16.TryParse(req.Id, out var id)) throw new WebDropException(ErrorCode.BadRequest);
if (req.Mode is not { } mode) throw new WebDropException(ErrorCode.BadRequest);
@@ -44,14 +46,17 @@ public sealed class ChannelService(
throw new WebDropException(ErrorCode.BadRequest);
long now = clock.NowMs;
var (passwordHash, passwordSalt) = HashJoinPassword(req.JoinPassword);
var channel = new Channel(id, mode, settings, readHash, writeHash, ownerHash,
now, now + settings.TtlSeconds * 1000L, 0, 0, 0);
now, now + settings.TtlSeconds * 1000L, 0, 0, 0, creatorAccount, passwordHash, passwordSalt);
store.Write(tx =>
{
if (tx.HasResourceId(id)) throw new WebDropException(ErrorCode.Conflict);
tx.InsertChannel(channel);
tx.InsertSeat(id, new Seat(endpointHash, now, null));
tx.PutChannelMember(id, new ChannelMember(creatorAccount, "owner", now));
if (req.KeyPackage is not null) tx.PutKeyPackage(id, creatorAccount, req.KeyPackage, now);
tx.InsertSeat(id, new Seat(endpointHash, now, null, creatorAccount, accountDeviceId));
return 0;
});
return ToInfo(channel, 1);
@@ -77,6 +82,12 @@ public sealed class ChannelService(
? clock.NowMs + updated.TtlSeconds * 1000L
: channel.ExpiresAt;
tx.UpdateChannel(id, updated, expiresAt);
if (patch.JoinPassword is not null)
{
var (hash, salt) = HashJoinPassword(patch.JoinPassword);
tx.SetJoinPassword(id, hash, salt);
channel = channel with { JoinPasswordHash = hash, JoinPasswordSalt = salt };
}
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountSeats(id));
});
events.SettingsChanged(id);
@@ -98,19 +109,28 @@ public sealed class ChannelService(
// ───────────────────────── 席位 ─────────────────────────
public SeatJoined Join(Id16 id, Credentials creds)
public SeatJoined Join(Id16 id, Credentials creds, string? password = null)
{
var (joined, isNew) = store.Write(tx =>
{
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None);
var existing = tx.GetSeat(id, principal.EndpointHash);
if (existing?.AccountName is not null && existing.AccountName != creds.AccountName) throw new WebDropException(ErrorCode.AccountRequired);
if (existing?.AccountDeviceId is not null && existing.AccountDeviceId != creds.AccountDeviceId) throw new WebDropException(ErrorCode.AccountRequired);
int used = tx.CountSeats(id);
if (existing is not null)
{
if (existing.AccountName is not null && existing.AccountDeviceId is null && creds.AccountDeviceId is not null)
tx.BindSeatDevice(id, principal.EndpointHash, creds.AccountDeviceId);
return (new SeatJoined { EndpointHash = Codec.Encode(existing.EndpointHash), JoinedAt = existing.JoinedAt, SeatsUsed = used, SeatsMax = channel.Settings.Seats }, false);
}
CheckAdmission(tx, channel, creds, password);
if (used >= channel.Settings.Seats) throw new WebDropException(ErrorCode.SeatsFull);
long now = clock.NowMs;
tx.InsertSeat(id, new Seat(principal.EndpointHash, now, null));
tx.InsertSeat(id, new Seat(principal.EndpointHash, now, null, creds.AccountName, creds.AccountDeviceId));
if (creds.AccountName is not null) tx.PutChannelMember(id, new ChannelMember(creds.AccountName, "member", now));
return (new SeatJoined { EndpointHash = Codec.Encode(principal.EndpointHash), JoinedAt = now, SeatsUsed = used + 1, SeatsMax = channel.Settings.Seats }, true);
});
if (isNew) events.SeatsChanged(id);
@@ -126,13 +146,13 @@ public sealed class ChannelService(
EndpointHash = Codec.Encode(s.EndpointHash),
JoinedAt = s.JoinedAt,
Label = s.Label is null ? null : Codec.Encode(s.Label),
AccountName = s.AccountName,
}).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);
ValidateSeatLabel(label);
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required);
@@ -142,6 +162,26 @@ public sealed class ChannelService(
events.SeatsChanged(id);
}
public void SetSeatLabelForEndpoint(Id16 id, Credentials creds, byte[] endpointHash, byte[] label)
{
ValidateSeatLabel(label);
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
if (tx.GetSeat(id, endpointHash) is null) throw new WebDropException(ErrorCode.NotFound);
tx.SetSeatLabel(id, endpointHash, label);
return 0;
});
events.SeatsChanged(id);
}
private void ValidateSeatLabel(byte[] label)
{
if (label.Length == 0) throw new WebDropException(ErrorCode.BadRequest);
if (label.Length > limits.MaxSeatLabelBytes) throw new WebDropException(ErrorCode.TooLarge);
}
public void ReleaseSeat(Id16 id, Credentials creds, byte[] endpointHash)
{
store.Write(tx =>
@@ -149,6 +189,7 @@ public sealed class ChannelService(
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
if (!tx.DeleteSeat(id, endpointHash)) throw new WebDropException(ErrorCode.NotFound);
tx.DenyEndpoint(id, endpointHash);
return 0;
});
events.SeatsChanged(id);
@@ -158,12 +199,10 @@ public sealed class ChannelService(
public async Task<AppendResult> AppendFrameAsync(
Id16 channelId, Credentials creds, ReadOnlyMemory<byte> body,
long? maxDownloads, string? label, CancellationToken ct = default)
long? maxDownloads, 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);
@@ -189,7 +228,7 @@ public sealed class ChannelService(
AppendOutcome outcome;
try
{
outcome = store.Write(tx => AppendInTx(tx, channelId, creds, env, body, digest, blobKey, maxDownloads, label));
outcome = store.Write(tx => AppendInTx(tx, channelId, creds, env, body, digest, blobKey, maxDownloads));
}
catch
{
@@ -212,7 +251,7 @@ public sealed class ChannelService(
private AppendOutcome AppendInTx(
IResourceTx tx, Id16 channelId, Credentials creds, Envelope env, ReadOnlyMemory<byte> body,
byte[] digest, string? blobKey, long? maxDownloads, string? label)
byte[] digest, string? blobKey, long? maxDownloads)
{
var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required);
CheckWritable(channel, principal, env);
@@ -227,10 +266,11 @@ public sealed class ChannelService(
if (env.FrameIndex == 0)
{
if (env.IsControl && tx.CountControlItems(channelId) >= 4096) throw new WebDropException(ErrorCode.TooLarge);
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);
now, env.IsControl ? long.MaxValue : now + ttlMs, null, principal.AccountName);
tx.InsertItem(item);
}
else
@@ -305,6 +345,7 @@ public sealed class ChannelService(
private void ValidateShape(Envelope env)
{
if (env.IsControl && (env.IsGated || env.IsSnapshot || env.FrameIndex != 0 || !env.IsFinal || env.PayloadLength > 16_400)) throw new WebDropException(ErrorCode.BadRequest);
// 非 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);
@@ -541,9 +582,16 @@ public sealed class ChannelService(
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));
var member = creds.AccountName is null ? null : tx.GetChannelMember(id, creds.AccountName);
if (role == Role.Owner && member?.Role != "owner") throw new WebDropException(ErrorCode.AccountRequired);
if (seat == SeatRule.Required)
{
var endpoint = tx.GetSeat(id, creds.EndpointHash);
if (endpoint is null) throw new WebDropException(ErrorCode.NotJoined);
if (endpoint.AccountName is not null && (endpoint.AccountName != creds.AccountName || member is null)) throw new WebDropException(ErrorCode.AccountRequired);
if (endpoint.AccountDeviceId is not null && endpoint.AccountDeviceId != creds.AccountDeviceId) throw new WebDropException(ErrorCode.AccountRequired);
}
return (channel, new Principal(role, creds.EndpointHash, creds.AccountName));
}
private static Role VerifyRole(Channel channel, Credentials creds)
@@ -573,6 +621,8 @@ public sealed class ChannelService(
private static ChannelInfo ToInfo(Channel channel, int seatsUsed) => new()
{
Id = channel.Id.ToBase64Url(),
CreatorAccount = channel.CreatorAccount,
PasswordRequired = channel.JoinPasswordHash is not null,
Mode = channel.Mode,
Settings = channel.Settings,
SeatsUsed = seatsUsed,
+6
View File
@@ -5,6 +5,8 @@ namespace WebDrop.Core;
public sealed class CreateChannelRequest
{
[JsonPropertyName("key_package")] public string? KeyPackage { get; init; }
[JsonPropertyName("join_password")] public string? JoinPassword { get; init; }
[JsonPropertyName("id")] public string? Id { get; init; }
[JsonPropertyName("mode")] public ChannelMode? Mode { get; init; }
[JsonPropertyName("settings")] public ChannelSettings? Settings { get; init; }
@@ -40,6 +42,7 @@ public sealed class ShareInfo
public sealed class ShareItemInfo
{
[JsonPropertyName("complete")] public bool Complete { get; init; }
[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; }
@@ -49,6 +52,8 @@ public sealed class ShareItemInfo
public sealed class ChannelInfo
{
[JsonPropertyName("password_required")] public bool PasswordRequired { get; init; }
[JsonPropertyName("creator_account")] public string? CreatorAccount { get; init; }
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("mode")] public required ChannelMode Mode { get; init; }
[JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; }
@@ -72,6 +77,7 @@ public sealed class SeatJoined
public sealed class SeatInfo
{
[JsonPropertyName("account_name")] public string? AccountName { get; init; }
[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; }
+1
View File
@@ -34,6 +34,7 @@ public enum FrameFlags : byte
Snapshot = 2,
Gated = 4,
Enc = 8,
Control = 16,
}
public enum LogKind : byte
+3 -2
View File
@@ -17,7 +17,7 @@ public readonly record struct Envelope(FrameFlags Flags, Id16 ItemId, uint Frame
if (data.Length < HeaderSize || data[0] != Version) return false;
byte flags = data[1];
if ((flags & 0xF0) != 0) return false;
if ((flags & 0xE0) != 0) return false;
uint payloadLen = BinaryPrimitives.ReadUInt32BigEndian(data[22..]);
if (payloadLen > int.MaxValue || data.Length != HeaderSize + (int)payloadLen) return false;
@@ -34,9 +34,10 @@ public readonly record struct Envelope(FrameFlags Flags, Id16 ItemId, uint Frame
public bool IsGated => (Flags & FrameFlags.Gated) != 0;
public bool IsSnapshot => (Flags & FrameFlags.Snapshot) != 0;
public bool IsEnc => (Flags & FrameFlags.Enc) != 0;
public bool IsControl => (Flags & FrameFlags.Control) != 0;
/// <summary>条目级标志:同一条目的所有帧必须一致。</summary>
public FrameFlags ItemFlags => Flags & (FrameFlags.Snapshot | FrameFlags.Gated);
public FrameFlags ItemFlags => Flags & (FrameFlags.Snapshot | FrameFlags.Gated | FrameFlags.Control);
}
public static class PayloadTiers
+10
View File
@@ -17,6 +17,10 @@ public enum ErrorCode
RateLimited,
VisitorsFull,
Sealed,
AccountRequired,
InvitationRequired,
ChannelPasswordRequired,
ChannelPasswordInvalid,
}
/// <summary>预期内的业务失败。消息只含错误码,绝不回显用户提交的内容。</summary>
@@ -53,6 +57,10 @@ public static class ErrorCodes
ErrorCode.RateLimited => "rate_limited",
ErrorCode.VisitorsFull => "visitors_full",
ErrorCode.Sealed => "sealed",
ErrorCode.AccountRequired => "account_required",
ErrorCode.InvitationRequired => "invitation_required",
ErrorCode.ChannelPasswordRequired => "channel_password_required",
ErrorCode.ChannelPasswordInvalid => "channel_password_invalid",
_ => "bad_request",
};
@@ -70,6 +78,8 @@ public static class ErrorCodes
ErrorCode.CaptchaUnavailable => 503,
ErrorCode.RateLimited => 429,
ErrorCode.VisitorsFull => 403,
ErrorCode.AccountRequired => 401,
ErrorCode.InvitationRequired or ErrorCode.ChannelPasswordRequired or ErrorCode.ChannelPasswordInvalid => 403,
_ => 400,
};
}
+13 -6
View File
@@ -11,7 +11,10 @@ public sealed record Channel(
long ExpiresAt,
long LastSeq,
long CompactedThrough,
long Bytes);
long Bytes,
string CreatorAccount,
byte[]? JoinPasswordHash = null,
byte[]? JoinPasswordSalt = null);
public sealed record Share(
Id16 Id,
@@ -23,7 +26,8 @@ public sealed record Share(
long ExpiresAt,
long? SealedAt,
Id16? ManifestItemId,
long Bytes)
long Bytes,
string CreatorAccount)
{
public bool IsSealed => SealedAt is not null;
}
@@ -71,14 +75,17 @@ public sealed record Item(
long CreatedAt,
long ExpiresAt,
long? CloseDeadline,
string? Label)
string? AuthorAccount)
{
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);
public sealed record Seat(byte[] EndpointHash, long JoinedAt, byte[]? Label, string? AccountName = null, string? AccountDeviceId = null);
public sealed record ChannelInvitation(Id16 ChannelId, string Username, string Payload, long CreatedAt);
public sealed record ChannelMember(string Username, string Role, long JoinedAt);
public sealed record ChannelKeyPackage(Id16 ChannelId, string Username, string Role, string Payload, long CreatedAt);
/// <summary>已存储的一帧(完整封装)。Inline 与 BlobKey 二选一。</summary>
public sealed record StoredFrame(
@@ -94,7 +101,7 @@ public sealed record StoredFrame(
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);
public sealed record Principal(Role Role, byte[] EndpointHash, string? AccountName = null);
/// <summary>请求携带的凭据,尚未验证。</summary>
public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash);
public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash, string? AccountName = null, string? AccountDeviceId = null);
+22 -11
View File
@@ -15,22 +15,26 @@ public sealed class ServerLimits
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; }
[JsonPropertyName("join_policy")] public string JoinPolicy { get; set; } = "open";
[JsonPropertyName("allow_guests")] public bool AllowGuests { get; set; } = true;
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("seats")] public int Seats { get; set; } = 2;
[JsonPropertyName("members_can_upload")] public bool MembersCanUpload { get; set; } = true;
[JsonPropertyName("delete_policy")] public DeletePolicy DeletePolicy { get; set; } = DeletePolicy.Own;
[JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; set; } = 86_400;
[JsonPropertyName("max_items")] public int MaxItems { get; set; } = 1000;
[JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; set; } = 10;
[JsonPropertyName("ui_restrict_copy_download")] public bool UiRestrictCopyDownload { get; set; }
public void Validate(ServerLimits limits)
{
if (JoinPolicy is not ("open" or "invite")) throw new WebDropException(ErrorCode.BadRequest);
if (Name is { } name && (name.Length > 48 || name.Any(char.IsControl))) throw new WebDropException(ErrorCode.BadRequest);
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);
@@ -40,6 +44,10 @@ public sealed class ChannelSettings
public sealed class ChannelSettingsPatch
{
[JsonPropertyName("join_policy")] public string? JoinPolicy { get; init; }
[JsonPropertyName("allow_guests")] public bool? AllowGuests { get; init; }
[JsonPropertyName("join_password")] public string? JoinPassword { get; init; }
[JsonPropertyName("name")] public string? Name { get; init; }
[JsonPropertyName("seats")] public int? Seats { get; init; }
[JsonPropertyName("members_can_upload")] public bool? MembersCanUpload { get; init; }
[JsonPropertyName("delete_policy")] public DeletePolicy? DeletePolicy { get; init; }
@@ -50,6 +58,9 @@ public sealed class ChannelSettingsPatch
public ChannelSettings ApplyTo(ChannelSettings s) => new()
{
JoinPolicy = JoinPolicy ?? s.JoinPolicy,
AllowGuests = AllowGuests ?? s.AllowGuests,
Name = Name is null ? s.Name : string.IsNullOrWhiteSpace(Name) ? null : Name.Trim(),
Seats = Seats ?? s.Seats,
MembersCanUpload = MembersCanUpload ?? s.MembersCanUpload,
DeletePolicy = DeletePolicy ?? s.DeletePolicy,
@@ -62,9 +73,9 @@ public sealed class ChannelSettingsPatch
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;
[JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; set; } = 86_400;
[JsonPropertyName("max_visitors")] public int MaxVisitors { get; set; }
[JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; set; } = 10;
public void Validate(ServerLimits limits)
{
+12 -6
View File
@@ -10,8 +10,9 @@ public sealed class ShareService(
ServerLimits limits,
int draftTtlSeconds)
{
public ShareInfo Create(CreateShareRequest req, byte[]? creatorEndpointHash)
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))
@@ -21,7 +22,7 @@ public sealed class ShareService(
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);
now + Math.Max(60, draftTtlSeconds) * 1000L, null, null, 0, creatorAccount);
store.Write(tx =>
{
@@ -48,6 +49,7 @@ public sealed class ShareService(
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);
@@ -66,7 +68,7 @@ public sealed class ShareService(
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.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);
@@ -198,7 +200,7 @@ public sealed class ShareService(
var frame = store.Write(tx =>
{
var share = SealedForRead(tx.GetShare(id), creds);
RegisterVisitor(tx, share, creds.EndpointHash);
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);
});
@@ -217,7 +219,7 @@ public sealed class ShareService(
long now = clock.NowMs;
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now >= deadline))
throw new WebDropException(ErrorCode.Gone);
RegisterVisitor(tx, share, creds.EndpointHash);
RegisterVisitor(tx, share, creds.EndpointHash, creds.AccountName);
if (fromIndex == 0)
{
if (item.MaxDownloads > 0 && item.Downloads >= item.MaxDownloads) throw new WebDropException(ErrorCode.Gone);
@@ -310,6 +312,8 @@ public sealed class ShareService(
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);
}
@@ -320,8 +324,9 @@ public sealed class ShareService(
return candidate;
}
private void RegisterVisitor(IResourceTx tx, Share share, byte[] endpointHash)
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)
@@ -360,6 +365,7 @@ public sealed class ShareService(
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(),