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:
justme committed 2026-10-14 22:18:59 +08:00
1 parent 0fd2a269d9
commit d946fc5e8a
35 files changed
+517 -490

No files matched your search

+3 -2
View File
@@ -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";
}
+11 -4
View File
@@ -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))]
+55 -4
View File
@@ -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);
+10 -28
View File
@@ -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();
});
+8 -1
View File
@@ -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();
}
}
}
+3 -2
View File
@@ -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);