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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user