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