feat(api)!: fold devices into users and report member presence
Contract 7.0.0 (breaking). Devices become an attribute of the account;
members are managed per user, and only unauthenticated guests keep a
per-terminal registration.
- rename seats -> max_members, seats_used -> members_used,
seats_full -> members_full, SeatJoined -> JoinResult, the `seats`
stream control message -> `members`, and POST /channels/{id}/seats ->
POST /channels/{id}/join (stored channel settings are migrated on start)
- drop per-terminal encrypted device labels and the seat list/release
endpoints; add GET/DELETE /channels/{id}/guests for guest terminals
- authenticate account members by account session only: the terminal key
is optional for logged-in accounts, no seat row is written for them,
and gated-download grants are counted per user (per terminal for guests)
- GET /channels/{id}/members now returns online, devices and
online_devices per account; presence is tracked in memory from active
WebSocket/SSE subscriptions and is not pushed over the stream
- owner invitation list no longer returns accounts that already joined
This commit is contained in:
1 parent
0fd2a269d9
commit
d946fc5e8a
35 files changed
+517
-490
No files matched your search
@@ -46,12 +46,12 @@ try {
|
||||
$ownerH = Headers 'owner' $ownerTok $ownerSecret
|
||||
|
||||
$created = Invoke-RestMethod "$base/api/v1/channels" -Method Post -Body $body -ContentType 'application/json' -Headers $ownerH
|
||||
Check ($created.id -eq (B64 $id) -and $created.seats_used -eq 1) '创建频道成功'
|
||||
Check ($created.id -eq (B64 $id) -and $created.members_used -eq 1) '创建频道成功'
|
||||
$cid = B64 $id
|
||||
|
||||
$memberH = Headers 'write' $writeTok $memberSecret
|
||||
$joined = Invoke-RestMethod "$base/api/v1/channels/$cid/seats" -Method Post -Headers $memberH
|
||||
Check ($joined.seats_used -eq 2) '成员加入'
|
||||
$joined = Invoke-RestMethod "$base/api/v1/channels/$cid/join" -Method Post -Headers $memberH
|
||||
Check ($joined.members_used -eq 2) '成员加入'
|
||||
|
||||
# enc 帧:负载 = 1024 档位 + 16 标签
|
||||
$item = Rand 16
|
||||
|
||||
@@ -71,8 +71,8 @@ try:
|
||||
check(status == 201, "创建频道")
|
||||
ch = b64(cid)
|
||||
|
||||
status, body = call("POST", f"/api/v1/channels/{ch}/seats", member_h)
|
||||
check(status == 200 and json.loads(body)["seats_used"] == 2, "成员加入")
|
||||
status, body = call("POST", f"/api/v1/channels/{ch}/join", member_h)
|
||||
check(status == 200 and json.loads(body)["members_used"] == 2, "成员加入")
|
||||
|
||||
item = os.urandom(16)
|
||||
f = frame(item, 0, 9, 1040, 0x42)
|
||||
|
||||
@@ -2,7 +2,8 @@ namespace WebDrop.Api;
|
||||
|
||||
public static class ApiContract
|
||||
{
|
||||
// v5 changes channel capacity from per-endpoint devices to account members.
|
||||
// v5: 频道容量按账号成员计;v7: seats 改名 max_members/members_used/members_full,
|
||||
// 去掉设备名与设备清单接口,访客改走 /guests。设备只是账号的属性。
|
||||
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
|
||||
public const string Version = "6.0.0";
|
||||
public const string Version = "7.0.0";
|
||||
}
|
||||
@@ -33,7 +33,7 @@ public sealed class DefaultsBody
|
||||
}
|
||||
public sealed class SettingChoicesBody
|
||||
{
|
||||
[JsonPropertyName("seats")] public required int[] Seats { get; init; }
|
||||
[JsonPropertyName("max_members")] public required int[] MaxMembers { get; init; }
|
||||
[JsonPropertyName("ttl_seconds")] public required int[] TtlSeconds { get; init; }
|
||||
[JsonPropertyName("file_max_downloads")] public int[] FileMaxDownloads { get; init; } = ServerSettingChoices.FileMaxDownloads;
|
||||
[JsonPropertyName("max_visitors")] public required int[] MaxVisitors { get; init; }
|
||||
@@ -61,7 +61,7 @@ public sealed class LimitsBody
|
||||
[JsonPropertyName("max_channel_bytes")] public required long MaxChannelBytes { get; init; }
|
||||
[JsonPropertyName("max_workspace_bytes")] public required long MaxWorkspaceBytes { get; init; }
|
||||
[JsonPropertyName("max_workspace_channel_bytes")] public required long MaxWorkspaceChannelBytes { get; init; }
|
||||
[JsonPropertyName("max_seats")] public required int MaxSeats { get; init; }
|
||||
[JsonPropertyName("max_members")] public required int MaxMembers { get; init; }
|
||||
[JsonPropertyName("max_ttl_seconds")] public required int MaxTtlSeconds { get; init; }
|
||||
[JsonPropertyName("min_ttl_seconds")] public required int MinTtlSeconds { get; init; }
|
||||
[JsonPropertyName("max_items")] public required int MaxItems { get; init; }
|
||||
@@ -152,6 +152,13 @@ public sealed class MemberBody
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("role")] public required string Role { get; init; }
|
||||
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
|
||||
|
||||
/// <summary>该账号此刻在本频道有至少一个活动订阅(WebSocket/SSE)。</summary>
|
||||
[JsonPropertyName("online")] public required bool Online { get; init; }
|
||||
|
||||
/// <summary>该账号在本频道登记过的设备(终端)数;设备只是账号的属性,不是独立成员。</summary>
|
||||
[JsonPropertyName("devices")] public required int Devices { get; init; }
|
||||
[JsonPropertyName("online_devices")] public required int OnlineDevices { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AccountProfileRequest
|
||||
@@ -227,8 +234,8 @@ public sealed class WorkspaceGroupCreatedBody
|
||||
[JsonSerializable(typeof(FinishShareRequest))]
|
||||
[JsonSerializable(typeof(CreateChannelRequest))]
|
||||
[JsonSerializable(typeof(ChannelSettingsPatch))]
|
||||
[JsonSerializable(typeof(SeatJoined))]
|
||||
[JsonSerializable(typeof(List<SeatInfo>))]
|
||||
[JsonSerializable(typeof(JoinResult))]
|
||||
[JsonSerializable(typeof(List<GuestInfo>))]
|
||||
[JsonSerializable(typeof(AppendResult))]
|
||||
[JsonSerializable(typeof(AccountRegisterRequest))]
|
||||
[JsonSerializable(typeof(AccountDeviceSigningRequest))]
|
||||
|
||||
@@ -12,6 +12,9 @@ public sealed class ChannelHub : IChannelEvents
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<Id16, Signal> _signals = [];
|
||||
|
||||
// 在线状态:频道 -> 账号 -> 账号设备 -> 活动订阅数。只记账号,访客没有可跨设备识别的身份,不参与。
|
||||
private readonly Dictionary<Id16, Dictionary<string, Dictionary<string, int>>> _presence = [];
|
||||
|
||||
public Subscription Subscribe(Id16 id)
|
||||
{
|
||||
lock (_gate)
|
||||
@@ -22,9 +25,57 @@ public sealed class ChannelHub : IChannelEvents
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>登记一个活动订阅,释放返回值即下线。只供成员接口读取,不向订阅流推送,以免改变流的线格式。</summary>
|
||||
public IDisposable Connected(Id16 id, string? account, string deviceKey)
|
||||
{
|
||||
if (account is null) return NoPresence.Instance;
|
||||
string endpoint = deviceKey;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_presence.TryGetValue(id, out var accounts)) _presence[id] = accounts = [];
|
||||
if (!accounts.TryGetValue(account, out var endpoints)) accounts[account] = endpoints = [];
|
||||
endpoints[endpoint] = endpoints.GetValueOrDefault(endpoint) + 1;
|
||||
}
|
||||
return new Connection(() => Disconnected(id, account, endpoint));
|
||||
}
|
||||
|
||||
private void Disconnected(Id16 id, string account, string endpoint)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_presence.TryGetValue(id, out var accounts) && accounts.TryGetValue(account, out var endpoints) && endpoints.TryGetValue(endpoint, out int count))
|
||||
{
|
||||
if (count <= 1) endpoints.Remove(endpoint); else endpoints[endpoint] = count - 1;
|
||||
if (endpoints.Count == 0) accounts.Remove(account);
|
||||
if (accounts.Count == 0) _presence.Remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public (bool Online, int Devices) Presence(Id16 id, string account)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _presence.TryGetValue(id, out var accounts) && accounts.TryGetValue(account, out var endpoints)
|
||||
? (endpoints.Count > 0, endpoints.Count) : (false, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Connection(Action release) : IDisposable
|
||||
{
|
||||
private Action? _release = release;
|
||||
public void Dispose() => Interlocked.Exchange(ref _release, null)?.Invoke();
|
||||
}
|
||||
|
||||
private sealed class NoPresence : IDisposable
|
||||
{
|
||||
public static readonly NoPresence Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
public void Advanced(Id16 channelId, long seq) => Publish(channelId, s => s.Seq = Math.Max(s.Seq, seq));
|
||||
public void SettingsChanged(Id16 channelId) => Publish(channelId, s => s.SettingsVersion++);
|
||||
public void SeatsChanged(Id16 channelId) => Publish(channelId, s => s.SeatsVersion++);
|
||||
public void MembersChanged(Id16 channelId) => Publish(channelId, s => s.MembersVersion++);
|
||||
public void ChannelDeleted(Id16 channelId) => Publish(channelId, s => s.Deleted = true);
|
||||
|
||||
private void Publish(Id16 id, Action<Signal> mutate)
|
||||
@@ -53,7 +104,7 @@ public sealed class ChannelHub : IChannelEvents
|
||||
public int Refs;
|
||||
public long Seq;
|
||||
public int SettingsVersion;
|
||||
public int SeatsVersion;
|
||||
public int MembersVersion;
|
||||
public bool Deleted;
|
||||
|
||||
public Task Changed => _tcs.Task;
|
||||
@@ -91,9 +142,9 @@ public sealed class ChannelHub : IChannelEvents
|
||||
get { lock (_hub._gate) return _signal.Changed; }
|
||||
}
|
||||
|
||||
public (int Settings, int Seats, bool Deleted) State
|
||||
public (int Settings, int MaxMembers, bool Deleted) State
|
||||
{
|
||||
get { lock (_hub._gate) return (_signal.SettingsVersion, _signal.SeatsVersion, _signal.Deleted); }
|
||||
get { lock (_hub._gate) return (_signal.SettingsVersion, _signal.MembersVersion, _signal.Deleted); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -47,8 +47,18 @@ public static partial class Endpoints
|
||||
if (accountStore.GetPublicKey(username) is null) throw new WebDropException(ErrorCode.NotFound);
|
||||
return Json(svc.InvitationPlan(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), username), ApiJson.Default.InvitationCryptoPlan);
|
||||
});
|
||||
api.MapGet("/channels/{id}/members", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
Json(svc.Members(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true)).Select(m => new MemberBody { Username = m.Username, Role = m.Role, JoinedAt = m.JoinedAt }).ToList(), ApiJson.Default.ListMemberBody));
|
||||
api.MapGet("/channels/{id}/members", (string id, HttpContext ctx, ChannelService svc, ChannelHub hub) =>
|
||||
{
|
||||
var channelId = ParseId(id);
|
||||
var members = svc.Members(channelId, RequestAuth.FromHeaders(ctx.Request, true)).Select(member =>
|
||||
{
|
||||
var (online, onlineDevices) = hub.Presence(channelId, member.Username);
|
||||
// 设备是账号的属性:总数取账号的登录设备,在线数取该账号在本频道有订阅的设备
|
||||
return new MemberBody { Username = member.Username, Role = member.Role, JoinedAt = member.JoinedAt,
|
||||
Online = online, Devices = Math.Max(accounts.Devices(member.Username, null).Count, onlineDevices), OnlineDevices = onlineDevices };
|
||||
}).ToList();
|
||||
return Json(members, ApiJson.Default.ListMemberBody);
|
||||
});
|
||||
api.MapPost("/channels/{id}/invitations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.InvitationRequest, ct);
|
||||
|
||||
@@ -32,7 +32,7 @@ public static partial class Endpoints
|
||||
Defaults = new DefaultsBody { Channel = CreationDefaults.Channel(limits), Share = CreationDefaults.Share(limits) },
|
||||
Choices = new SettingChoicesBody
|
||||
{
|
||||
Seats = new[] { 2, 3, 5, 10, limits.MaxSeats }.Where(n => n >= 1 && n <= limits.MaxSeats).Distinct().Order().ToArray(),
|
||||
MaxMembers = new[] { 2, 3, 5, 10, limits.MaxMembers }.Where(n => n >= 1 && n <= limits.MaxMembers).Distinct().Order().ToArray(),
|
||||
TtlSeconds = new[] { 3600, 86_400, 604_800, limits.MinTtlSeconds, limits.MaxTtlSeconds, CreationDefaults.Channel(limits).TtlSeconds }
|
||||
.Where(n => n >= limits.MinTtlSeconds && n <= limits.MaxTtlSeconds).Distinct().Order().ToArray(),
|
||||
MaxVisitors = new[] { 0, 2, 5, 10, 20, limits.MaxVisitors }.Where(n => n <= limits.MaxVisitors).Distinct().Order().ToArray(),
|
||||
@@ -44,7 +44,7 @@ public static partial class Endpoints
|
||||
MaxChannelBytes = limits.MaxChannelBytes,
|
||||
MaxWorkspaceBytes = limits.MaxWorkspaceBytes,
|
||||
MaxWorkspaceChannelBytes = limits.MaxWorkspaceChannelBytes,
|
||||
MaxSeats = limits.MaxSeats,
|
||||
MaxMembers = limits.MaxMembers,
|
||||
MaxTtlSeconds = limits.MaxTtlSeconds,
|
||||
MinTtlSeconds = limits.MinTtlSeconds,
|
||||
MaxItems = limits.MaxItemsLimit,
|
||||
@@ -85,42 +85,24 @@ public static partial class Endpoints
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
// ───────── 席位 ─────────
|
||||
// ───────── 加入 ─────────
|
||||
|
||||
api.MapPost("/channels/{id}/seats", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
api.MapPost("/channels/{id}/join", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var request = ctx.Request.ContentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) == true ? await ReadJson(ctx.Request, ApiJson.Default.JoinRequest, ct) : new JoinRequest();
|
||||
return Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true), request.Password), ApiJson.Default.SeatJoined);
|
||||
return Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true), request.Password), ApiJson.Default.JoinResult);
|
||||
});
|
||||
|
||||
MapChannelAccess(api, accounts);
|
||||
|
||||
api.MapGet("/channels/{id}/seats", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
var seats = svc.ListSeats(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false));
|
||||
return Json([.. seats], ApiJson.Default.ListSeatInfo);
|
||||
});
|
||||
// 访客是未登录的终端;账号成员只按用户管理(members / invitations),设备不对外列出
|
||||
api.MapGet("/channels/{id}/guests", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
Json([.. svc.ListGuests(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false))], ApiJson.Default.ListGuestInfo));
|
||||
|
||||
api.MapPut("/channels/{id}/seats/me", async (string id, HttpContext ctx, ChannelService svc, ServerLimits limits, CancellationToken ct) =>
|
||||
{
|
||||
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
|
||||
byte[] label = await ReadBody(ctx.Request, limits.MaxSeatLabelBytes, ct);
|
||||
svc.SetSeatLabel(ParseId(id), creds, label);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
api.MapPut("/channels/{id}/seats/{endpointHash}/label", async (string id, string endpointHash, HttpContext ctx, ChannelService svc, ServerLimits limits, CancellationToken ct) =>
|
||||
api.MapDelete("/channels/{id}/guests/{endpointHash}", (string id, string endpointHash, HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
if (!Codec.TryDecodeExact(endpointHash, TokenHash.Size, out var hash)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
byte[] label = await ReadBody(ctx.Request, limits.MaxSeatLabelBytes, ct);
|
||||
svc.SetSeatLabelForEndpoint(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), hash, label);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
api.MapDelete("/channels/{id}/seats/{endpointHash}", (string id, string endpointHash, HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
if (!Codec.TryDecodeExact(endpointHash, TokenHash.Size, out var hash)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
svc.ReleaseSeat(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), hash);
|
||||
svc.ReleaseGuest(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), hash);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ public static class LogStreamer
|
||||
{
|
||||
using var sub = hub.Subscribe(id);
|
||||
ILogSink? sink = null;
|
||||
IDisposable? presence = null;
|
||||
long cursor = from;
|
||||
var (settingsV, seatsV, _) = sub.State;
|
||||
|
||||
@@ -40,6 +41,8 @@ public static class LogStreamer
|
||||
var changed = sub.Changed; // 先取任务再读状态:读取期间的发布不会丢
|
||||
var page = await svc.ReadLogAsync(id, creds, cursor, PageSize, ct).ConfigureAwait(false);
|
||||
sink ??= await open().ConfigureAwait(false);
|
||||
// 鉴权与首次读取都成功之后才算在线
|
||||
presence ??= hub.Connected(id, creds.AccountName, creds.AccountDeviceId ?? Convert.ToHexString(creds.EndpointHash));
|
||||
|
||||
foreach (var record in page.Records)
|
||||
{
|
||||
@@ -56,7 +59,7 @@ public static class LogStreamer
|
||||
if (seatsNow != seatsV)
|
||||
{
|
||||
seatsV = seatsNow;
|
||||
await sink.SendControlAsync("""{"t":"seats"}""", ct).ConfigureAwait(false);
|
||||
await sink.SendControlAsync("""{"t":"members"}""", ct).ConfigureAwait(false);
|
||||
}
|
||||
if (deleted) throw new WebDropException(ErrorCode.NotFound);
|
||||
|
||||
@@ -80,6 +83,10 @@ public static class LogStreamer
|
||||
{
|
||||
await sink.FailAsync(e.Code, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
presence?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ public static class RequestAuth
|
||||
deviceId = accounts.SessionDevice(accountToken);
|
||||
}
|
||||
if (role == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint), accountName, deviceId, accountName is not null);
|
||||
// 账号以会话鉴权,终端密钥只对未登录访客是必需的
|
||||
return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint && accountName is null), accountName, deviceId, accountName is not null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -68,7 +69,7 @@ public static class RequestAuth
|
||||
}
|
||||
var parsedRole = ParseRole(role);
|
||||
if (parsedRole == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: true), accountName, deviceId, accountName is not null);
|
||||
return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: accountName is null), accountName, deviceId, accountName is not null);
|
||||
}
|
||||
|
||||
public static byte[] ParseEndpointHash(string? header) => ParseEndpoint(header, requireEndpoint: true);
|
||||
|
||||
@@ -42,7 +42,7 @@ public interface IChannelEvents
|
||||
{
|
||||
void Advanced(Id16 channelId, long seq);
|
||||
void SettingsChanged(Id16 channelId);
|
||||
void SeatsChanged(Id16 channelId);
|
||||
void MembersChanged(Id16 channelId);
|
||||
void ChannelDeleted(Id16 channelId);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ public sealed class NullChannelEvents : IChannelEvents
|
||||
{
|
||||
public void Advanced(Id16 channelId, long seq) { }
|
||||
public void SettingsChanged(Id16 channelId) { }
|
||||
public void SeatsChanged(Id16 channelId) { }
|
||||
public void MembersChanged(Id16 channelId) { }
|
||||
public void ChannelDeleted(Id16 channelId) { }
|
||||
}
|
||||
|
||||
@@ -130,7 +130,6 @@ public interface IResourceTx
|
||||
int CountMembers(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);
|
||||
|
||||
// 条目
|
||||
|
||||
@@ -56,7 +56,7 @@ public sealed partial class ChannelService
|
||||
tx.DenyAccount(id, username);
|
||||
return 0;
|
||||
});
|
||||
events.SeatsChanged(id);
|
||||
events.MembersChanged(id);
|
||||
}
|
||||
|
||||
public IReadOnlyList<ChannelMember> Members(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
@@ -72,7 +72,7 @@ public sealed partial class ChannelService
|
||||
public void ReleaseAccountDevice(string username, string deviceId)
|
||||
{
|
||||
var channels = store.Write(tx => tx.DeleteDeviceSeats(username, deviceId));
|
||||
foreach (var id in channels) events.SeatsChanged(id);
|
||||
foreach (var id in channels) events.MembersChanged(id);
|
||||
}
|
||||
|
||||
public void SaveOwnerKeyPackage(Id16 id, Credentials creds, string payload)
|
||||
|
||||
@@ -59,7 +59,6 @@ public sealed partial class ChannelService(
|
||||
tx.PutWorkspace(id, NewWorkspace(creatorAccount, now));
|
||||
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);
|
||||
@@ -112,92 +111,60 @@ public sealed partial class ChannelService(
|
||||
|
||||
// ───────────────────────── 席位 ─────────────────────────
|
||||
|
||||
public SeatJoined Join(Id16 id, Credentials creds, string? password = null)
|
||||
/// <summary>
|
||||
/// 加入频道。账号成员只登记成员关系(同一账号的多台设备不增加人数,也不登记终端);
|
||||
/// 未登录访客登记终端,每个终端按一个访客成员计入上限。
|
||||
/// </summary>
|
||||
public JoinResult 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 membersUsed = tx.CountMembers(id);
|
||||
bool accountAlreadyMember = creds.AccountName is not null && tx.GetChannelMember(id, creds.AccountName) is not null;
|
||||
if (existing is not null)
|
||||
JoinResult Result(long joinedAt, int used) => new() { JoinedAt = joinedAt, MembersUsed = used, MembersMax = channel.Settings.MaxMembers };
|
||||
|
||||
if (creds.AccountName 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 = membersUsed, SeatsMax = channel.Settings.Seats }, false);
|
||||
if (tx.GetChannelMember(id, creds.AccountName) is { } existingMember) return (Result(existingMember.JoinedAt, membersUsed), false);
|
||||
CheckAdmission(tx, channel, creds, password);
|
||||
if (membersUsed >= channel.Settings.MaxMembers) throw new WebDropException(ErrorCode.MembersFull);
|
||||
long memberAt = clock.NowMs;
|
||||
tx.PutChannelMember(id, new ChannelMember(creds.AccountName, "member", memberAt));
|
||||
return (Result(memberAt, membersUsed + 1), true);
|
||||
}
|
||||
|
||||
if (tx.GetSeat(id, principal.EndpointHash) is { } existing) return (Result(existing.JoinedAt, membersUsed), false);
|
||||
CheckAdmission(tx, channel, creds, password);
|
||||
|
||||
bool newMember = creds.AccountName is null || !accountAlreadyMember;
|
||||
if (newMember && membersUsed >= channel.Settings.Seats) throw new WebDropException(ErrorCode.SeatsFull);
|
||||
if (membersUsed >= channel.Settings.MaxMembers) throw new WebDropException(ErrorCode.MembersFull);
|
||||
long now = clock.NowMs;
|
||||
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 = membersUsed + (newMember ? 1 : 0), SeatsMax = channel.Settings.Seats }, true);
|
||||
tx.InsertSeat(id, new Seat(principal.EndpointHash, now));
|
||||
return (Result(now, membersUsed + 1), true);
|
||||
});
|
||||
if (isNew) events.SeatsChanged(id);
|
||||
if (isNew) events.MembersChanged(id);
|
||||
return joined;
|
||||
}
|
||||
|
||||
public IReadOnlyList<SeatInfo> ListSeats(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
/// <summary>所有者查看访客(未登录终端)。账号成员按用户管理,其设备不对外列出。</summary>
|
||||
public IReadOnlyList<GuestInfo> ListGuests(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),
|
||||
AccountName = s.AccountName,
|
||||
}).ToList();
|
||||
return (IReadOnlyList<GuestInfo>)tx.ListSeats(id).Where(s => s.AccountName is null)
|
||||
.Select(s => new GuestInfo { EndpointHash = Codec.Encode(s.EndpointHash), JoinedAt = s.JoinedAt }).ToList();
|
||||
});
|
||||
|
||||
public void SetSeatLabel(Id16 id, Credentials creds, byte[] label)
|
||||
{
|
||||
ValidateSeatLabel(label);
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
tx.SetSeatLabel(id, principal.EndpointHash, label);
|
||||
return 0;
|
||||
});
|
||||
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)
|
||||
/// <summary>所有者移除一个访客终端并禁止它再加入。账号成员改用撤销邀请(RevokeInvitation)。</summary>
|
||||
public void ReleaseGuest(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);
|
||||
if (tx.GetSeat(id, endpointHash) is not { AccountName: null } || !tx.DeleteSeat(id, endpointHash)) throw new WebDropException(ErrorCode.NotFound);
|
||||
tx.DenyEndpoint(id, endpointHash);
|
||||
return 0;
|
||||
});
|
||||
events.SeatsChanged(id);
|
||||
events.MembersChanged(id);
|
||||
}
|
||||
|
||||
// ───────────────────────── 追加帧 ─────────────────────────
|
||||
@@ -505,7 +472,7 @@ public sealed partial class ChannelService(
|
||||
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))
|
||||
if (item.IsGated && fromIndex > 0 && !tx.HasDownloadGrant(id, itemId, principal.GrantKey))
|
||||
throw new WebDropException(ErrorCode.Gone);
|
||||
|
||||
if (fromIndex == 0 && item.IsGated)
|
||||
@@ -518,7 +485,7 @@ public sealed partial class ChannelService(
|
||||
? now + limits.DownloadGraceSeconds * 1000L
|
||||
: item.CloseDeadline;
|
||||
tx.UpdateItem(item with { Downloads = downloads, CloseDeadline = closeAt });
|
||||
tx.AddDownloadGrant(id, itemId, principal.EndpointHash);
|
||||
tx.AddDownloadGrant(id, itemId, principal.GrantKey);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
@@ -536,7 +503,7 @@ public sealed partial class ChannelService(
|
||||
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
if (item.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.Gone);
|
||||
if (item.IsGated && !tx.HasDownloadGrant(id, itemId, principal.EndpointHash))
|
||||
if (item.IsGated && !tx.HasDownloadGrant(id, itemId, principal.GrantKey))
|
||||
throw new WebDropException(ErrorCode.Gone);
|
||||
if (item.CloseDeadline is { } d && clock.NowMs >= d) throw new WebDropException(ErrorCode.Gone);
|
||||
|
||||
@@ -712,12 +679,12 @@ public sealed partial class ChannelService(
|
||||
var role = VerifyRole(channel, creds);
|
||||
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)
|
||||
if (seat == SeatRule.Required && member is null)
|
||||
{
|
||||
// 账号成员只凭账号会话鉴权,不再需要终端登记;未登录访客必须先有终端登记
|
||||
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);
|
||||
if (endpoint.AccountName is not null) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
}
|
||||
return (channel, new Principal(role, creds.EndpointHash, creds.AccountName));
|
||||
}
|
||||
@@ -755,7 +722,7 @@ public sealed partial class ChannelService(
|
||||
PasswordRequired = channel.JoinPasswordHash is not null,
|
||||
Mode = channel.Mode,
|
||||
Settings = channel.Settings,
|
||||
SeatsUsed = membersUsed,
|
||||
MembersUsed = membersUsed,
|
||||
OldestSeq = channel.CompactedThrough + 1,
|
||||
LastSeq = channel.LastSeq,
|
||||
CreatedAt = channel.CreatedAt,
|
||||
|
||||
@@ -60,7 +60,7 @@ public sealed class ChannelInfo
|
||||
[JsonPropertyName("mode")] public required ChannelMode Mode { get; init; }
|
||||
[JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; }
|
||||
/// <summary>v5: 账号成员数加匿名访客终端数,不统计账号设备数。</summary>
|
||||
[JsonPropertyName("seats_used")] public required int SeatsUsed { get; init; }
|
||||
[JsonPropertyName("members_used")] public required int MembersUsed { get; init; }
|
||||
|
||||
/// <summary>从该 seq 起的续传有效;0 < from < oldest_seq 的续传会得到 410,需从 0 全量同步。</summary>
|
||||
[JsonPropertyName("oldest_seq")] public required long OldestSeq { get; init; }
|
||||
@@ -70,21 +70,19 @@ public sealed class ChannelInfo
|
||||
[JsonPropertyName("expires_at")] public required long ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SeatJoined
|
||||
public sealed class JoinResult
|
||||
{
|
||||
[JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; }
|
||||
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
|
||||
/// <summary>加入后的成员人数,同一账号的其他终端不增加此值。</summary>
|
||||
[JsonPropertyName("seats_used")] public required int SeatsUsed { get; init; }
|
||||
[JsonPropertyName("seats_max")] public required int SeatsMax { get; init; }
|
||||
[JsonPropertyName("members_used")] public required int MembersUsed { get; init; }
|
||||
[JsonPropertyName("members_max")] public required int MembersMax { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SeatInfo
|
||||
/// <summary>未登录访客的一次加入(终端)。账号成员按用户管理,不在这里出现。</summary>
|
||||
public sealed class GuestInfo
|
||||
{
|
||||
[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; }
|
||||
}
|
||||
|
||||
public sealed class AppendResult
|
||||
|
||||
@@ -6,7 +6,7 @@ public enum ErrorCode
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
NotJoined,
|
||||
SeatsFull,
|
||||
MembersFull,
|
||||
ModeMismatch,
|
||||
NotFound,
|
||||
Conflict,
|
||||
@@ -47,7 +47,7 @@ public static class ErrorCodes
|
||||
ErrorCode.Unauthorized => "unauthorized",
|
||||
ErrorCode.Forbidden => "forbidden",
|
||||
ErrorCode.NotJoined => "not_joined",
|
||||
ErrorCode.SeatsFull => "seats_full",
|
||||
ErrorCode.MembersFull => "members_full",
|
||||
ErrorCode.ModeMismatch => "mode_mismatch",
|
||||
ErrorCode.NotFound => "not_found",
|
||||
ErrorCode.Conflict => "conflict",
|
||||
@@ -70,7 +70,7 @@ public static class ErrorCodes
|
||||
{
|
||||
ErrorCode.BadRequest => 400,
|
||||
ErrorCode.Unauthorized => 401,
|
||||
ErrorCode.Forbidden or ErrorCode.NotJoined or ErrorCode.SeatsFull or ErrorCode.ModeMismatch => 403,
|
||||
ErrorCode.Forbidden or ErrorCode.NotJoined or ErrorCode.MembersFull or ErrorCode.ModeMismatch => 403,
|
||||
ErrorCode.NotFound => 404,
|
||||
ErrorCode.Conflict => 409,
|
||||
ErrorCode.Sealed => 409,
|
||||
|
||||
@@ -82,7 +82,8 @@ public sealed record Item(
|
||||
public bool IsComplete => FinalIndex is { } f && FrameCount == f + 1;
|
||||
}
|
||||
|
||||
public sealed record Seat(byte[] EndpointHash, long JoinedAt, byte[]? Label, string? AccountName = null, string? AccountDeviceId = null);
|
||||
/// <summary>频道内的终端登记(账号设备或访客终端)。设备只是账号的属性,不是成员;不再有单独的设备名。</summary>
|
||||
public sealed record Seat(byte[] EndpointHash, long JoinedAt, 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);
|
||||
@@ -101,7 +102,13 @@ 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, string? AccountName = null);
|
||||
public sealed record Principal(Role Role, byte[] EndpointHash, string? AccountName = null)
|
||||
{
|
||||
/// <summary>受限下载的计数单位:账号按用户计(设备只是账号的属性),访客按终端计。</summary>
|
||||
public byte[] GrantKey => AccountName is null
|
||||
? EndpointHash
|
||||
: System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes("webdrop/grant/v1/" + AccountName));
|
||||
}
|
||||
|
||||
/// <summary>请求携带的凭据,尚未验证。</summary>
|
||||
public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash, string? AccountName = null, string? AccountDeviceId = null,
|
||||
|
||||
@@ -11,7 +11,7 @@ public sealed class ServerLimits
|
||||
public long MaxWorkspaceBytes { get; set; } = 1L << 40;
|
||||
public long MaxWorkspaceChannelBytes { get; set; } = 10L << 30;
|
||||
/// <summary>最大账号成员数。属性名和 info 字段保持旧线格式兼容。</summary>
|
||||
public int MaxSeats { get; set; } = 64;
|
||||
public int MaxMembers { get; set; } = 64;
|
||||
public int MaxVisitors { get; set; } = 10_000;
|
||||
public int MinTtlSeconds { get; set; } = 60;
|
||||
public int MaxTtlSeconds { get; set; } = 604_800;
|
||||
@@ -19,7 +19,6 @@ 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 MaxSeatLabelBytes { get; set; } = 256;
|
||||
}
|
||||
|
||||
public static class ServerSettingChoices
|
||||
@@ -35,7 +34,7 @@ public sealed class ChannelSettings
|
||||
[JsonPropertyName("allow_guests")] public bool AllowGuests { get; set; } = true;
|
||||
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||
/// <summary>成员人数上限;线字段名保留为 seats 以兼容已有频道设置。</summary>
|
||||
[JsonPropertyName("seats")] public int Seats { get; set; } = 2;
|
||||
[JsonPropertyName("max_members")] public int MaxMembers { 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;
|
||||
@@ -47,7 +46,7 @@ public sealed class ChannelSettings
|
||||
{
|
||||
if (!Enum.IsDefined(JoinPolicy)) 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 (MaxMembers < 1 || MaxMembers > limits.MaxMembers) 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);
|
||||
@@ -60,7 +59,7 @@ public sealed class ChannelSettingsPatch
|
||||
[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("max_members")] public int? MaxMembers { 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; }
|
||||
@@ -73,7 +72,7 @@ public sealed class ChannelSettingsPatch
|
||||
JoinPolicy = JoinPolicy ?? s.JoinPolicy,
|
||||
AllowGuests = AllowGuests ?? s.AllowGuests,
|
||||
Name = Name is null ? s.Name : string.IsNullOrWhiteSpace(Name) ? null : Name.Trim(),
|
||||
Seats = Seats ?? s.Seats,
|
||||
MaxMembers = MaxMembers ?? s.MaxMembers,
|
||||
MembersCanUpload = MembersCanUpload ?? s.MembersCanUpload,
|
||||
DeletePolicy = DeletePolicy ?? s.DeletePolicy,
|
||||
TtlSeconds = TtlSeconds ?? s.TtlSeconds,
|
||||
@@ -120,7 +119,7 @@ public static class CreationDefaults
|
||||
public static ChannelSettings Channel(ServerLimits limits) => new()
|
||||
{
|
||||
JoinPolicy = JoinPolicy.Invite, AllowGuests = false,
|
||||
Seats = Math.Min(10, limits.MaxSeats),
|
||||
MaxMembers = Math.Min(10, limits.MaxMembers),
|
||||
TtlSeconds = Math.Clamp(86_400, limits.MinTtlSeconds, limits.MaxTtlSeconds),
|
||||
MaxItems = Math.Min(1000, limits.MaxItemsLimit),
|
||||
};
|
||||
|
||||
@@ -37,6 +37,8 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
columns.CommandText = "SELECT COUNT(*) FROM pragma_table_info('seats') WHERE name='account_device_id'";
|
||||
if ((long)columns.ExecuteScalar()! == 0) Exec(_writeConn, "ALTER TABLE seats ADD COLUMN account_device_id TEXT;");
|
||||
}
|
||||
// v7: 设置里的成员人数上限由 seats 改名为 max_members,迁移已有频道的设置 JSON(幂等)
|
||||
Exec(_writeConn, "UPDATE channels SET settings = json_remove(json_set(settings, '$.max_members', json_extract(settings, '$.seats')), '$.seats') WHERE json_extract(settings, '$.seats') IS NOT NULL AND json_extract(settings, '$.max_members') IS NULL;");
|
||||
// Additive table: retain the running v2 database and its existing channel data.
|
||||
Exec(_writeConn, """
|
||||
CREATE TABLE IF NOT EXISTS channel_key_packages (
|
||||
@@ -456,7 +458,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
public bool HasInvitation(Id16 id, string username) => Scalar("SELECT 1 FROM channel_invitations WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username)) is not null;
|
||||
public void PutInvitation(ChannelInvitation i) => Run("INSERT INTO channel_invitations(channel_id,username,payload,created_at) VALUES(@id,@u,@p,@t) ON CONFLICT(channel_id,username) DO UPDATE SET payload=excluded.payload,created_at=excluded.created_at", ("@id", i.ChannelId.ToArray()), ("@u", i.Username), ("@p", i.Payload), ("@t", i.CreatedAt));
|
||||
private static ChannelInvitation MapInvitation(SqliteDataReader r) => new(IdAt(r, 0), r.GetString(1), r.GetString(2), r.GetInt64(3));
|
||||
public IReadOnlyList<ChannelInvitation> ListInvitations(Id16 id) => Many("SELECT channel_id,username,payload,created_at FROM channel_invitations WHERE channel_id=@id", MapInvitation, ("@id", id.ToArray()));
|
||||
public IReadOnlyList<ChannelInvitation> ListInvitations(Id16 id) => Many("SELECT channel_id,username,payload,created_at FROM channel_invitations i WHERE channel_id=@id AND NOT EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id=i.channel_id AND m.username=i.username)", MapInvitation, ("@id", id.ToArray()));
|
||||
public IReadOnlyList<ChannelInvitation> ListUserInvitations(string username) => Many("SELECT i.channel_id,i.username,i.payload,i.created_at FROM channel_invitations i JOIN channels c ON c.id=i.channel_id WHERE i.username=@u AND c.expires_at>@now AND NOT EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id=i.channel_id AND m.username=i.username)", MapInvitation, ("@u", username), ("@now", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()));
|
||||
public void DeleteInvitation(Id16 id, string username) => Run("DELETE FROM channel_invitations WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username));
|
||||
public void PutKeyPackage(Id16 id, string username, string payload, long createdAt) => Run(
|
||||
@@ -513,7 +515,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
|
||||
// ───────── 席位 ─────────
|
||||
|
||||
private static Seat MapSeat(SqliteDataReader r) => new(Blob(r, 0), r.GetInt64(1), BlobOrNull(r, 2), r.IsDBNull(3) ? null : r.GetString(3), r.IsDBNull(4) ? null : r.GetString(4));
|
||||
private static Seat MapSeat(SqliteDataReader r) => new(Blob(r, 0), r.GetInt64(1), r.IsDBNull(3) ? null : r.GetString(3), r.IsDBNull(4) ? null : r.GetString(4));
|
||||
|
||||
public Seat? GetSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash) => One(
|
||||
"SELECT endpoint_hash, joined_at, label, account_name,account_device_id FROM seats WHERE channel_id=@c AND endpoint_hash=@e",
|
||||
@@ -526,17 +528,13 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
(int)(long)Scalar("SELECT (SELECT COUNT(*) FROM channel_members WHERE channel_id=@c) + (SELECT COUNT(*) FROM seats WHERE channel_id=@c AND account_name IS NULL)", ("@c", channelId.ToArray()))!;
|
||||
|
||||
public void InsertSeat(Id16 channelId, Seat seat) => Run(
|
||||
"INSERT INTO seats (channel_id, endpoint_hash, label, joined_at,account_name,account_device_id) VALUES (@c,@e,@l,@j,@a,@d)",
|
||||
("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@l", seat.Label), ("@j", seat.JoinedAt), ("@a", seat.AccountName), ("@d", seat.AccountDeviceId));
|
||||
"INSERT INTO seats (channel_id, endpoint_hash, joined_at,account_name,account_device_id) VALUES (@c,@e,@j,@a,@d)",
|
||||
("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@j", seat.JoinedAt), ("@a", seat.AccountName), ("@d", seat.AccountDeviceId));
|
||||
|
||||
public bool DeleteSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash) => Run(
|
||||
"DELETE FROM seats WHERE channel_id=@c AND endpoint_hash=@e",
|
||||
("@c", channelId.ToArray()), ("@e", endpointHash.ToArray())) > 0;
|
||||
|
||||
public void SetSeatLabel(Id16 channelId, ReadOnlySpan<byte> endpointHash, byte[] label) => Run(
|
||||
"UPDATE seats SET label=@l WHERE channel_id=@c AND endpoint_hash=@e",
|
||||
("@l", label), ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray()));
|
||||
|
||||
public IReadOnlyList<Seat> ListSeats(Id16 channelId) => Many(
|
||||
"SELECT endpoint_hash, joined_at, label, account_name,account_device_id FROM seats WHERE channel_id=@c ORDER BY joined_at, endpoint_hash",
|
||||
MapSeat, ("@c", channelId.ToArray()));
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
[Fact]
|
||||
public async Task AccountKeyPackagesAreScopedToAcceptedMembersAndRemovedOnRevocation()
|
||||
{
|
||||
var ch = await server.CreateAsync(settings: new { name = "Key package space", seats = 8 });
|
||||
var ch = await server.CreateAsync(settings: new { name = "Key package space", max_members = 8 });
|
||||
var username = "keys_" + Guid.NewGuid().ToString("N")[..8]; var token = await Register(username);
|
||||
var member = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32));
|
||||
using (var ownerPackage = server.Req(HttpMethod.Put, $"/api/v1/channels/{ch.Id}/key-package", ch.Owner, JsonBody(new { payload = "opaque-owner-package" })))
|
||||
@@ -31,7 +31,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
using (var invite = await InviteAsync(ch, username, "opaque-member-package")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
|
||||
using (var pending = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token))
|
||||
Assert.Empty((await ReadJsonAsync(await server.Http.SendAsync(pending))).EnumerateArray());
|
||||
using (var accept = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, token))
|
||||
using (var accept = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, token))
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accept)).StatusCode);
|
||||
using (var delivered = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token))
|
||||
{
|
||||
@@ -90,7 +90,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
byte[] ownerToken = RandomNumberGenerator.GetBytes(32); byte[] endpoint = RandomNumberGenerator.GetBytes(32);
|
||||
var owner = new Who("owner", ownerToken, endpoint); string spaceId = NewId();
|
||||
using (var create = Request(HttpMethod.Post, "/api/v1/channels", owner, token,
|
||||
new { id = spaceId, mode = "raw", settings = new { name = "Signed chat", seats = 2 }, owner_hash = B64(TokenHash.Compute(ownerToken)) }))
|
||||
new { id = spaceId, mode = "raw", settings = new { name = "Signed chat", max_members = 2 }, owner_hash = B64(TokenHash.Compute(ownerToken)) }))
|
||||
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(create)).StatusCode);
|
||||
string channelId;
|
||||
using (var structure = Request(HttpMethod.Get, $"/api/v1/spaces/{spaceId}/structure", owner, token))
|
||||
@@ -163,26 +163,27 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
var firstId = Guid.NewGuid().ToString(); var secondId = Guid.NewGuid().ToString();
|
||||
var firstToken = accounts.Authenticate(username, verifier, firstId, "Desktop")!;
|
||||
var secondToken = accounts.Authenticate(username, verifier, secondId, "Phone")!;
|
||||
var ch = await server.CreateAsync(settings: new { name = "Device admission space", seats = 2, join_policy = "open", allow_guests = true });
|
||||
var ch = await server.CreateAsync(settings: new { name = "Device admission space", max_members = 2, join_policy = "open", allow_guests = true });
|
||||
using (var invite = await InviteAsync(ch, username, "opaque-key-package")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
|
||||
var first = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32)); var second = first with { EndpointSecret = RandomNumberGenerator.GetBytes(32) };
|
||||
foreach (var (who, token) in new[] { (first, firstToken), (second, secondToken) })
|
||||
{
|
||||
using var joined = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who, token);
|
||||
using var joined = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", who, token);
|
||||
using var response = await server.Http.SendAsync(joined);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(2, (await ReadJsonAsync(response)).GetProperty("seats_used").GetInt32());
|
||||
Assert.Equal(2, (await ReadJsonAsync(response)).GetProperty("members_used").GetInt32());
|
||||
}
|
||||
using (var info = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner))
|
||||
Assert.Equal(2, (await ReadJsonAsync(await server.Http.SendAsync(info))).GetProperty("seats_used").GetInt32());
|
||||
Assert.Equal(2, (await ReadJsonAsync(await server.Http.SendAsync(info))).GetProperty("members_used").GetInt32());
|
||||
var unrelated = await Register("full_" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var third = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32));
|
||||
using (var full = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", third, unrelated))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(full), 403, "seats_full");
|
||||
using (var full = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", third, unrelated))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(full), 403, "members_full");
|
||||
Assert.Equal(2, accounts.Devices(username, firstId).Count);
|
||||
Assert.Single(accounts.Devices(username, firstId), d => d.Current);
|
||||
using (var impersonate = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", second, firstToken))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(impersonate), 401, "account_required");
|
||||
// 账号成员只凭账号会话鉴权,不再绑定到某个终端:同一账号的任一设备会话都能读取
|
||||
using (var anyDevice = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", second, firstToken))
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(anyDevice)).StatusCode);
|
||||
using (var revoke = Request(HttpMethod.Delete, $"/api/v1/accounts/devices/{secondId}", first, firstToken))
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode);
|
||||
Assert.True(accounts.HasSession(firstToken, out _)); Assert.False(accounts.HasSession(secondToken, out _));
|
||||
@@ -195,7 +196,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
[Fact]
|
||||
public void PartialSettingsPreserveAdmissionDefaults()
|
||||
{
|
||||
var request = JsonSerializer.Deserialize("{\"settings\":{\"seats\":8}}", ApiJson.Default.CreateChannelRequest)!;
|
||||
var request = JsonSerializer.Deserialize("{\"settings\":{\"max_members\":8}}", ApiJson.Default.CreateChannelRequest)!;
|
||||
Assert.Equal(JoinPolicy.Open, request.Settings!.JoinPolicy);
|
||||
Assert.True(request.Settings.AllowGuests);
|
||||
request.Settings.Validate(new ServerLimits());
|
||||
@@ -212,6 +213,32 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
await AssertErrorAsync(response, 400, "bad_request");
|
||||
}
|
||||
}
|
||||
[Fact]
|
||||
public async Task AccountMembersNeedNoTerminalKeyAndDoNotOccupyGuestRegistrations()
|
||||
{
|
||||
var ch = await server.CreateAsync(settings: new { name = "Account only", max_members = 3, join_policy = "invite", allow_guests = false });
|
||||
var username = "member_" + Guid.NewGuid().ToString("N")[..8];
|
||||
var account = await Register(username);
|
||||
using (var invite = await InviteAsync(ch, username, "opaque-key-package")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
|
||||
var who = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32));
|
||||
using (var join = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", who, account))
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(join)).StatusCode);
|
||||
|
||||
// 登录账号不带终端密钥也能鉴权
|
||||
using (var log = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", who, account))
|
||||
{
|
||||
log.Headers.Remove("X-WD-Endpoint");
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(log)).StatusCode);
|
||||
}
|
||||
// 账号成员不在访客清单里;成员清单里有它,设备数不小于在线设备数
|
||||
using (var guests = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/guests", ch.Owner))
|
||||
Assert.Equal(0, (await ReadJsonAsync(await server.Http.SendAsync(guests))).GetArrayLength());
|
||||
using var members = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/members", ch.Owner);
|
||||
var list = await ReadJsonAsync(await server.Http.SendAsync(members));
|
||||
var mine = list.EnumerateArray().Single(m => m.GetProperty("username").GetString() == username);
|
||||
Assert.True(mine.GetProperty("devices").GetInt32() >= mine.GetProperty("online_devices").GetInt32());
|
||||
}
|
||||
|
||||
private async Task<string> Register(string username)
|
||||
{
|
||||
var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
|
||||
@@ -290,32 +317,33 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
[InlineData(ChannelMode.Raw)]
|
||||
public async Task PasswordInvitationsAndRevocationUseTheSameChannelRules(ChannelMode mode)
|
||||
{
|
||||
var ch = await server.CreateAsync(mode, new { name = "Password space", seats = 8 });
|
||||
var ch = await server.CreateAsync(mode, new { name = "Password space", max_members = 8 });
|
||||
using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_password = "channel-secret" })))
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode);
|
||||
var guest = new Who("read", mode == ChannelMode.Enc ? ch.ReadToken : null, RandomNumberGenerator.GetBytes(32));
|
||||
using (var missing = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest))
|
||||
using (var missing = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(missing), 403, "channel_password_required");
|
||||
using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest, body: new { password = "wrong" }))
|
||||
using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, body: new { password = "wrong" }))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(wrong), 403, "channel_password_invalid");
|
||||
using (var correct = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest, body: new { password = "channel-secret" }))
|
||||
using (var correct = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, body: new { password = "channel-secret" }))
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(correct)).StatusCode);
|
||||
using (var closed = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { allow_guests = false })))
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(closed)).StatusCode);
|
||||
using (var stranger = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest with { EndpointSecret = RandomNumberGenerator.GetBytes(32) }, body: new { password = "channel-secret" }))
|
||||
using (var stranger = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest with { EndpointSecret = RandomNumberGenerator.GetBytes(32) }, body: new { password = "channel-secret" }))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(stranger), 401, "account_required");
|
||||
using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_policy = "invite", allow_guests = false })))
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode);
|
||||
var username = "invited_" + Guid.NewGuid().ToString("N")[..8];
|
||||
var account = await Register(username);
|
||||
var member = new Who("read", mode == ChannelMode.Enc ? ch.ReadToken : null, RandomNumberGenerator.GetBytes(32));
|
||||
using (var uninvited = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account))
|
||||
using (var uninvited = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, account))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(uninvited), 403, "invitation_required");
|
||||
using (var invite = await InviteAsync(ch, username, "client-encrypted-payload")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
|
||||
using (var accepted = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account))
|
||||
using (var accepted = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, account))
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accepted)).StatusCode);
|
||||
using (var impersonated = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", member))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(impersonated), 401, "account_required");
|
||||
// 只有链接令牌与终端密钥、没有账号会话的请求没有成员身份,也没有访客登记
|
||||
using (var withoutAccount = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", member))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(withoutAccount), 403, "not_joined");
|
||||
var writer = member with { Role = "write", Token = ch.WriteToken };
|
||||
string channelId;
|
||||
using (var structureRequest = server.Req(HttpMethod.Get, $"/api/v1/spaces/{ch.Id}/structure", ch.Owner))
|
||||
@@ -335,7 +363,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
}
|
||||
using (var revoke = server.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/{username}", ch.Owner))
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode);
|
||||
using (var rejoin = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account))
|
||||
using (var rejoin = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, account))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(rejoin), 403, "forbidden");
|
||||
}
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
var info = await ReadJsonAsync(get);
|
||||
Assert.Equal(ch.Id, info.GetProperty("id").GetString());
|
||||
Assert.Equal("enc", info.GetProperty("mode").GetString());
|
||||
Assert.Equal(1, info.GetProperty("seats_used").GetInt32());
|
||||
Assert.Equal(2, info.GetProperty("settings").GetProperty("seats").GetInt32());
|
||||
Assert.Equal(1, info.GetProperty("members_used").GetInt32());
|
||||
Assert.Equal(2, info.GetProperty("settings").GetProperty("max_members").GetInt32());
|
||||
Assert.Equal("own", info.GetProperty("settings").GetProperty("delete_policy").GetString());
|
||||
Assert.Equal(1, info.GetProperty("oldest_seq").GetInt64());
|
||||
|
||||
@@ -124,13 +124,13 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
return await s.Http.SendAsync(request);
|
||||
}
|
||||
|
||||
using (var patch = await OwnerRequest(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", JsonBody(new { seats = 3 })))
|
||||
using (var patch = await OwnerRequest(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", JsonBody(new { max_members = 3 })))
|
||||
Assert.Equal(HttpStatusCode.OK, patch.StatusCode);
|
||||
using (var seats = await OwnerRequest(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats"))
|
||||
Assert.Equal(HttpStatusCode.OK, seats.StatusCode);
|
||||
using (var absentSeat = await OwnerRequest(HttpMethod.Delete,
|
||||
$"/api/v1/channels/{ch.Id}/seats/{B64(RandomNumberGenerator.GetBytes(32))}"))
|
||||
await AssertErrorAsync(absentSeat, 404, "not_found");
|
||||
using (var guests = await OwnerRequest(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/guests"))
|
||||
Assert.Equal(HttpStatusCode.OK, guests.StatusCode);
|
||||
using (var absentGuest = await OwnerRequest(HttpMethod.Delete,
|
||||
$"/api/v1/channels/{ch.Id}/guests/{B64(RandomNumberGenerator.GetBytes(32))}"))
|
||||
await AssertErrorAsync(absentGuest, 404, "not_found");
|
||||
using (var deleted = await OwnerRequest(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}"))
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner)), 404, "not_found");
|
||||
@@ -232,33 +232,34 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeatsJoinListAndRelease()
|
||||
public async Task JoinListGuestsAndRelease()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
|
||||
using var joined = await s.JoinAsync(ch, ch.Reader);
|
||||
var j = await ReadJsonAsync(joined);
|
||||
Assert.Equal(2, j.GetProperty("seats_used").GetInt32());
|
||||
Assert.Equal(2, j.GetProperty("seats_max").GetInt32());
|
||||
string memberHash = j.GetProperty("endpoint_hash").GetString()!;
|
||||
Assert.Equal(2, j.GetProperty("members_used").GetInt32());
|
||||
Assert.Equal(2, j.GetProperty("members_max").GetInt32());
|
||||
Assert.False(j.TryGetProperty("endpoint_hash", out _)); // 终端不再对外暴露
|
||||
string memberHash = B64(System.Security.Cryptography.SHA256.HashData(ch.Reader.EndpointSecret));
|
||||
|
||||
// 第三个终端被拒绝
|
||||
var third = new Who("read", ch.ReadToken, System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
|
||||
await AssertErrorAsync(await s.JoinAsync(ch, third), 403, "seats_full");
|
||||
await AssertErrorAsync(await s.JoinAsync(ch, third), 403, "members_full");
|
||||
|
||||
// 成员不能看席位列表,所有者可以
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats", ch.Reader)), 403, "forbidden");
|
||||
using var list = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats", ch.Owner));
|
||||
Assert.Equal(2, (await ReadJsonAsync(list)).GetArrayLength());
|
||||
// 访客清单只有所有者能看;账号成员不在其中
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/guests", ch.Reader)), 403, "forbidden");
|
||||
using var list = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/guests", ch.Owner));
|
||||
var guests = await ReadJsonAsync(list);
|
||||
Assert.Equal(1, guests.GetArrayLength());
|
||||
Assert.Equal(memberHash, guests[0].GetProperty("endpoint_hash").GetString());
|
||||
|
||||
// 成员上传加密设备名
|
||||
// 设备名接口已移除
|
||||
using var label = await s.Http.SendAsync(s.Req(HttpMethod.Put, $"/api/v1/channels/{ch.Id}/seats/me", ch.Reader, Bytes([9, 8, 7])));
|
||||
Assert.Equal(HttpStatusCode.NoContent, label.StatusCode);
|
||||
using var list2 = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats", ch.Owner));
|
||||
Assert.Contains((await ReadJsonAsync(list2)).EnumerateArray(), e => e.TryGetProperty("label", out var l) && l.GetString() == B64([9, 8, 7]));
|
||||
Assert.Equal(HttpStatusCode.NotFound, label.StatusCode);
|
||||
|
||||
// 所有者释放后,成员失去访问
|
||||
using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/seats/{memberHash}", ch.Owner));
|
||||
// 所有者移除访客后,成员失去访问
|
||||
using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/guests/{memberHash}", ch.Owner));
|
||||
Assert.Equal(HttpStatusCode.NoContent, release.StatusCode);
|
||||
await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "not_joined");
|
||||
}
|
||||
@@ -269,17 +270,17 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
var ch = await s.CreateAsync();
|
||||
await s.JoinAsync(ch, ch.Member);
|
||||
|
||||
var patch = () => JsonBody(new { seats = 4, members_can_upload = false });
|
||||
var patch = () => JsonBody(new { max_members = 4, members_can_upload = false });
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Member, patch())), 403, "forbidden");
|
||||
|
||||
using var ok = await s.Http.SendAsync(s.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, patch()));
|
||||
var settings = (await ReadJsonAsync(ok)).GetProperty("settings");
|
||||
Assert.Equal(4, settings.GetProperty("seats").GetInt32());
|
||||
Assert.Equal(4, settings.GetProperty("max_members").GetInt32());
|
||||
Assert.False(settings.GetProperty("members_can_upload").GetBoolean());
|
||||
|
||||
await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "forbidden");
|
||||
await AssertErrorAsync(
|
||||
await s.Http.SendAsync(s.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { seats = 0 }))),
|
||||
await s.Http.SendAsync(s.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { max_members = 0 }))),
|
||||
400, "bad_request");
|
||||
}
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
|
||||
};
|
||||
if (mode == ChannelMode.Enc) body["read_hash"] = B64(TokenHash.Compute(ch.ReadToken));
|
||||
// General transport tests explicitly use open spaces; creation defaults have separate coverage.
|
||||
body["settings"] = settings ?? new { name = "Test space", seats = 2, join_policy = "open", allow_guests = true };
|
||||
body["settings"] = settings ?? new { name = "Test space", max_members = 2, join_policy = "open", allow_guests = true };
|
||||
|
||||
using var req = Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body));
|
||||
if (capToken is not null) req.Headers.Add("X-WD-Cap", capToken);
|
||||
@@ -136,7 +136,7 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> JoinAsync(Chan ch, Who who) =>
|
||||
await Http.SendAsync(Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who));
|
||||
await Http.SendAsync(Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", who));
|
||||
|
||||
public async Task<HttpResponseMessage> AppendAsync(
|
||||
Chan ch,
|
||||
|
||||
@@ -116,13 +116,13 @@ public class StreamingTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
public async Task WebSocketNotifiesSeatChangesAndClosesWhenRevoked()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
var joined = await s.JoinAsync(ch, ch.Member);
|
||||
string memberHash = (await ReadJsonAsync(joined)).GetProperty("endpoint_hash").GetString()!;
|
||||
await s.JoinAsync(ch, ch.Member);
|
||||
string memberHash = B64(System.Security.Cryptography.SHA256.HashData(ch.Member.EndpointSecret));
|
||||
|
||||
using var member = await ConnectWs(ch, ch.Member);
|
||||
|
||||
// 所有者释放席位:订阅者先收到 seats 控制消息,随后被以 not_joined 关闭
|
||||
using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/seats/{memberHash}", ch.Owner));
|
||||
using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/guests/{memberHash}", ch.Owner));
|
||||
Assert.Equal(HttpStatusCode.NoContent, release.StatusCode);
|
||||
|
||||
bool closed = false;
|
||||
@@ -137,7 +137,7 @@ public class StreamingTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
}
|
||||
else if (type == WebSocketMessageType.Text)
|
||||
{
|
||||
Assert.Equal("seats", JsonDocument.Parse(data).RootElement.GetProperty("t").GetString());
|
||||
Assert.Equal("members", JsonDocument.Parse(data).RootElement.GetProperty("t").GetString());
|
||||
}
|
||||
}
|
||||
Assert.True(closed);
|
||||
@@ -240,4 +240,39 @@ public class StreamingTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
var bad = new Who("owner", new byte[32], ch.OwnerSecret);
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/events", bad)), 401, "unauthorized");
|
||||
}
|
||||
[Fact]
|
||||
public async Task MembersReportAccountPresenceFromActiveSubscriptions()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
async Task<(bool Online, int Devices, int OnlineDevices)> Owner()
|
||||
{
|
||||
using var response = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/members", ch.Owner));
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var owner = doc.RootElement.EnumerateArray().First(m => m.GetProperty("role").GetString() == "owner");
|
||||
return (owner.GetProperty("online").GetBoolean(), owner.GetProperty("devices").GetInt32(), owner.GetProperty("online_devices").GetInt32());
|
||||
}
|
||||
async Task<(bool Online, int Devices, int OnlineDevices)> Eventually(Func<(bool Online, int Devices, int OnlineDevices), bool> done)
|
||||
{
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var state = await Owner();
|
||||
if (done(state)) return state;
|
||||
await Task.Delay(100);
|
||||
}
|
||||
return await Owner();
|
||||
}
|
||||
|
||||
Assert.False((await Owner()).Online);
|
||||
using var ws = await ConnectWs(ch, ch.Owner);
|
||||
var online = await Eventually(state => state.Online);
|
||||
Assert.True(online.Online);
|
||||
Assert.Equal(1, online.OnlineDevices);
|
||||
Assert.True(online.Devices >= 1);
|
||||
|
||||
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None);
|
||||
var offline = await Eventually(state => !state.Online);
|
||||
Assert.False(offline.Online);
|
||||
Assert.Equal(0, offline.OnlineDevices);
|
||||
}
|
||||
}
|
||||
@@ -47,18 +47,18 @@ public sealed class WorkspaceApiTests(ServerFixture server) : IClassFixture<Serv
|
||||
[Fact]
|
||||
public async Task AdvertisedDefaultsRespectDeploymentLimitsAndMatchCreation()
|
||||
{
|
||||
await using var isolated = WithSettings(new() { ["WebDrop:Limits:MaxSeats"] = "3", ["WebDrop:Limits:MinTtlSeconds"] = "120", ["WebDrop:Limits:MaxTtlSeconds"] = "3600" });
|
||||
await using var isolated = WithSettings(new() { ["WebDrop:Limits:MaxMembers"] = "3", ["WebDrop:Limits:MinTtlSeconds"] = "120", ["WebDrop:Limits:MaxTtlSeconds"] = "3600" });
|
||||
await isolated.InitializeAsync();
|
||||
using var infoResponse = await isolated.Http.GetAsync("/api/v1/info"); var info = await ReadJsonAsync(infoResponse);
|
||||
var defaults = info.GetProperty("defaults").GetProperty("channel");
|
||||
Assert.Equal("enc", info.GetProperty("defaults").GetProperty("channel_mode").GetString());
|
||||
Assert.Equal(3, defaults.GetProperty("seats").GetInt32()); Assert.Equal(3600, defaults.GetProperty("ttl_seconds").GetInt32());
|
||||
Assert.Equal(3, defaults.GetProperty("max_members").GetInt32()); Assert.Equal(3600, defaults.GetProperty("ttl_seconds").GetInt32());
|
||||
var ch = new Chan(NewId(), ChannelMode.Enc);
|
||||
using var create = isolated.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(new { id = ch.Id, mode = "enc", settings = new { name = "Limited space", seats = 3, ttl_seconds = 3600 }, read_hash = B64(TokenHash.Compute(ch.ReadToken)), owner_hash = B64(TokenHash.Compute(ch.OwnerToken)), write_hash = B64(TokenHash.Compute(ch.WriteToken)) }));
|
||||
using var create = isolated.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(new { id = ch.Id, mode = "enc", settings = new { name = "Limited space", max_members = 3, ttl_seconds = 3600 }, read_hash = B64(TokenHash.Compute(ch.ReadToken)), owner_hash = B64(TokenHash.Compute(ch.OwnerToken)), write_hash = B64(TokenHash.Compute(ch.WriteToken)) }));
|
||||
using var result = await isolated.Http.SendAsync(create); Assert.Equal(HttpStatusCode.Created, result.StatusCode);
|
||||
var settings = (await ReadJsonAsync(result)).GetProperty("settings");
|
||||
Assert.Equal("Limited space", settings.GetProperty("name").GetString());
|
||||
Assert.Equal(defaults.GetProperty("seats").GetInt32(), settings.GetProperty("seats").GetInt32());
|
||||
Assert.Equal(defaults.GetProperty("max_members").GetInt32(), settings.GetProperty("max_members").GetInt32());
|
||||
Assert.Equal(defaults.GetProperty("ttl_seconds").GetInt32(), settings.GetProperty("ttl_seconds").GetInt32());
|
||||
}
|
||||
}
|
||||
@@ -66,8 +66,8 @@ public class ChannelServiceTests : IDisposable
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var info = Svc.GetChannel(ch.Id, ch.Owner(_ownerEp));
|
||||
Assert.Equal(1, info.SeatsUsed);
|
||||
Assert.Equal(2, info.Settings.Seats);
|
||||
Assert.Equal(1, info.MembersUsed);
|
||||
Assert.Equal(2, info.Settings.MaxMembers);
|
||||
Assert.Equal(1, info.OldestSeq);
|
||||
Assert.Equal(0, info.LastSeq);
|
||||
}
|
||||
@@ -99,38 +99,36 @@ public class ChannelServiceTests : IDisposable
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var a = Svc.Join(ch.Id, ch.Reader(_memberEp));
|
||||
var again = Svc.Join(ch.Id, ch.Reader(_memberEp));
|
||||
Assert.Equal(a.EndpointHash, again.EndpointHash);
|
||||
Assert.Equal(2, again.SeatsUsed);
|
||||
Assert.Equal(2, again.SeatsMax);
|
||||
Assert.Equal(a.JoinedAt, again.JoinedAt);
|
||||
Assert.Equal(2, again.MembersUsed);
|
||||
Assert.Equal(2, again.MembersMax);
|
||||
|
||||
AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, ch.Reader(TestEnv.Endpoint())));
|
||||
Assert.Equal(1, _env.Events.Seats); // 只有真正新增时才通知
|
||||
AssertCode(ErrorCode.MembersFull, () => Svc.Join(ch.Id, ch.Reader(TestEnv.Endpoint())));
|
||||
Assert.Equal(1, _env.Events.Members); // 只有真正新增时才通知
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AccountMembersUseOneLimitAcrossUnlimitedDevices()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { Seats = 2 });
|
||||
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { MaxMembers = 2 });
|
||||
var memberOne = TestEnv.Endpoint();
|
||||
var memberTwo = TestEnv.Endpoint();
|
||||
var ownerTwo = TestEnv.Endpoint();
|
||||
Credentials alice(byte[] endpoint, string device) => new(Role.Read, ch.ReadToken, endpoint, "alice", device);
|
||||
|
||||
Assert.Equal(2, Svc.Join(ch.Id, alice(memberOne, "alice-1")).SeatsUsed);
|
||||
Assert.Equal(2, Svc.Join(ch.Id, alice(memberTwo, "alice-2")).SeatsUsed);
|
||||
Assert.Equal(2, Svc.Join(ch.Id, ch.Owner(ownerTwo)).SeatsUsed);
|
||||
Assert.Equal(4, Svc.ListSeats(ch.Id, ch.Owner(_ownerEp)).Count); // 设备仍单独登记,但不计入成员人数
|
||||
Assert.Equal(2, Svc.Join(ch.Id, alice(memberOne, "alice-1")).MembersUsed);
|
||||
Assert.Equal(2, Svc.Join(ch.Id, alice(memberTwo, "alice-2")).MembersUsed);
|
||||
Assert.Equal(2, Svc.Join(ch.Id, ch.Owner(ownerTwo)).MembersUsed);
|
||||
Assert.Empty(Svc.ListGuests(ch.Id, ch.Owner(_ownerEp))); // 账号的设备不对外列出,也不计入成员人数
|
||||
|
||||
var bob = new Credentials(Role.Read, ch.ReadToken, TestEnv.Endpoint(), "bob", "bob-1");
|
||||
AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, bob));
|
||||
AssertCode(ErrorCode.MembersFull, () => Svc.Join(ch.Id, bob));
|
||||
|
||||
Svc.ReleaseSeat(ch.Id, ch.Owner(_ownerEp), memberOne);
|
||||
Assert.Equal(2, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).SeatsUsed); // 移除一台设备不会释放账号成员名额
|
||||
AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }));
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.ReleaseGuest(ch.Id, ch.Owner(_ownerEp), memberOne)); // 账号设备不能按设备移除,只能撤销用户
|
||||
|
||||
Svc.RevokeInvitation(ch.Id, ch.Owner(_ownerEp), "alice");
|
||||
Assert.Equal(1, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).SeatsUsed);
|
||||
Assert.Equal(2, Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }).SeatsUsed);
|
||||
Assert.Equal(1, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).MembersUsed);
|
||||
Assert.Equal(2, Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }).MembersUsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -209,29 +207,25 @@ public class ChannelServiceTests : IDisposable
|
||||
public async Task ReleasedSeatLosesAccess()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var joined = Svc.Join(ch.Id, ch.Member(_memberEp));
|
||||
Svc.Join(ch.Id, ch.Member(_memberEp));
|
||||
await AppendSmall(ch, ch.Member(_memberEp));
|
||||
|
||||
Svc.ReleaseSeat(ch.Id, ch.Owner(_ownerEp), TokenHash.Compute(TestEnv.Random(1)).Length == 0 ? [] : Codec_Decode(joined.EndpointHash));
|
||||
Svc.ReleaseGuest(ch.Id, ch.Owner(_ownerEp), _memberEp);
|
||||
await AssertCode(ErrorCode.NotJoined, () => AppendSmall(ch, ch.Member(_memberEp)));
|
||||
}
|
||||
|
||||
private static byte[] Codec_Decode(string b64) =>
|
||||
Codec.TryDecodeExact(b64, 32, out var bytes) ? bytes : throw new InvalidOperationException();
|
||||
|
||||
[Fact]
|
||||
public void OnlyTheOwnerManagesSeatsAndSettings()
|
||||
public void OnlyTheOwnerManagesGuestsAndSettings()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
Svc.Join(ch.Id, ch.Member(_memberEp));
|
||||
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.ListSeats(ch.Id, ch.Member(_memberEp)));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.UpdateSettings(ch.Id, ch.Member(_memberEp), new ChannelSettingsPatch { Seats = 5 }));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.ListGuests(ch.Id, ch.Member(_memberEp)));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.UpdateSettings(ch.Id, ch.Member(_memberEp), new ChannelSettingsPatch { MaxMembers = 5 }));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.DeleteChannel(ch.Id, ch.Member(_memberEp)));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.ReleaseSeat(ch.Id, ch.Member(_memberEp), _ownerEp));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.ReleaseGuest(ch.Id, ch.Member(_memberEp), _ownerEp));
|
||||
|
||||
var seats = Svc.ListSeats(ch.Id, ch.Owner(_ownerEp));
|
||||
Assert.Equal(2, seats.Count);
|
||||
Assert.Single(Svc.ListGuests(ch.Id, ch.Owner(_ownerEp))); // 访客只有成员那一台
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -243,25 +237,13 @@ public class ChannelServiceTests : IDisposable
|
||||
|
||||
// Owner management uses A; an owner on a new device can manage a full channel without first taking a seat.
|
||||
Assert.Equal(ch.Id.ToBase64Url(), Svc.GetChannel(ch.Id, unseatedOwner).Id);
|
||||
Assert.Equal(3, Svc.UpdateSettings(ch.Id, unseatedOwner, new ChannelSettingsPatch { Seats = 3 }).Settings.Seats);
|
||||
Assert.Equal(2, Svc.ListSeats(ch.Id, unseatedOwner).Count);
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.ReleaseSeat(ch.Id, unseatedOwner, TestEnv.Endpoint()));
|
||||
Assert.Equal(3, Svc.UpdateSettings(ch.Id, unseatedOwner, new ChannelSettingsPatch { MaxMembers = 3 }).Settings.MaxMembers);
|
||||
Assert.Single(Svc.ListGuests(ch.Id, unseatedOwner));
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.ReleaseGuest(ch.Id, unseatedOwner, TestEnv.Endpoint()));
|
||||
Svc.DeleteChannel(ch.Id, unseatedOwner);
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeatLabelIsStoredAndBounded()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), [1, 2, 3]);
|
||||
var mine = Svc.ListSeats(ch.Id, ch.Owner(_ownerEp)).Single();
|
||||
Assert.Equal(Codec.Encode([1, 2, 3]), mine.Label);
|
||||
|
||||
AssertCode(ErrorCode.TooLarge, () => Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), new byte[257]));
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSettingsAppliesPatchAndRecomputesExpiry()
|
||||
{
|
||||
@@ -269,15 +251,15 @@ public class ChannelServiceTests : IDisposable
|
||||
var before = Svc.GetChannel(ch.Id, ch.Owner(_ownerEp));
|
||||
|
||||
_env.Clock.Advance(TimeSpan.FromMinutes(1));
|
||||
var after = Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { Seats = 3, TtlSeconds = 3600 });
|
||||
var after = Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { MaxMembers = 3, TtlSeconds = 3600 });
|
||||
|
||||
Assert.Equal(3, after.Settings.Seats);
|
||||
Assert.Equal(3, after.Settings.MaxMembers);
|
||||
Assert.Equal(3600, after.Settings.TtlSeconds);
|
||||
Assert.Equal(_env.Clock.NowMs + 3600_000L, after.ExpiresAt);
|
||||
Assert.True(after.ExpiresAt < before.ExpiresAt);
|
||||
Assert.Equal(1, _env.Events.Settings);
|
||||
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { Seats = 0 }));
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { MaxMembers = 0 }));
|
||||
}
|
||||
|
||||
// ───── 追加:幂等、形状、权限 ─────
|
||||
@@ -524,7 +506,7 @@ public class ChannelServiceTests : IDisposable
|
||||
public async Task ResumeNeedsACountedDownloadFromTheSameEndpoint()
|
||||
{
|
||||
var otherEndpoint = TestEnv.Endpoint();
|
||||
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { Seats = 3, FileMaxDownloads = 1 });
|
||||
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { MaxMembers = 3, FileMaxDownloads = 1 });
|
||||
Svc.Join(ch.Id, ch.Reader(_memberEp));
|
||||
Svc.Join(ch.Id, ch.Reader(otherEndpoint));
|
||||
var item = await UploadGated(ch, ch.Owner(_ownerEp), 2);
|
||||
|
||||
@@ -155,9 +155,9 @@ public class PolicyTests
|
||||
[Fact]
|
||||
public void SettingsPatchOnlyChangesProvidedFields()
|
||||
{
|
||||
var before = new ChannelSettings { Seats = 2, TtlSeconds = 100 };
|
||||
var after = new ChannelSettingsPatch { Seats = 5 }.ApplyTo(before);
|
||||
Assert.Equal(5, after.Seats);
|
||||
var before = new ChannelSettings { MaxMembers = 2, TtlSeconds = 100 };
|
||||
var after = new ChannelSettingsPatch { MaxMembers = 5 }.ApplyTo(before);
|
||||
Assert.Equal(5, after.MaxMembers);
|
||||
Assert.Equal(100, after.TtlSeconds);
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ public class PolicyTests
|
||||
[InlineData(2, 86400, 0)]
|
||||
public void SettingsValidationRejectsOutOfRange(int seats, int ttl, int maxItems)
|
||||
{
|
||||
var s = new ChannelSettings { Seats = seats, TtlSeconds = ttl, MaxItems = maxItems };
|
||||
var s = new ChannelSettings { MaxMembers = seats, TtlSeconds = ttl, MaxItems = maxItems };
|
||||
var ex = Assert.Throws<WebDropException>(() => s.Validate(new ServerLimits()));
|
||||
Assert.Equal(ErrorCode.BadRequest, ex.Code);
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@ public sealed class FakeClock : IClock
|
||||
public sealed class RecordingEvents : IChannelEvents
|
||||
{
|
||||
public List<(Id16 Channel, long Seq)> Advances { get; } = [];
|
||||
public int Settings, Seats, Deleted;
|
||||
public int Settings, Members, Deleted;
|
||||
public void Advanced(Id16 channelId, long seq) { lock (Advances) Advances.Add((channelId, seq)); }
|
||||
public void SettingsChanged(Id16 channelId) => Interlocked.Increment(ref Settings);
|
||||
public void SeatsChanged(Id16 channelId) => Interlocked.Increment(ref Seats);
|
||||
public void MembersChanged(Id16 channelId) => Interlocked.Increment(ref Members);
|
||||
public void ChannelDeleted(Id16 channelId) => Interlocked.Increment(ref Deleted);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user