feat(api): bind encrypted workspaces to accounts and devices
This commit is contained in:
1 parent
019b15041a
commit
c7d8ef15bc
49 files changed
+1904
-278
No files matched your search
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -34,6 +34,7 @@ public enum FrameFlags : byte
|
||||
Snapshot = 2,
|
||||
Gated = 4,
|
||||
Enc = 8,
|
||||
Control = 16,
|
||||
}
|
||||
|
||||
public enum LogKind : byte
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user