diff --git a/contracts/api-contract.json b/contracts/api-contract.json index 306a0a4..fb57e40 100644 --- a/contracts/api-contract.json +++ b/contracts/api-contract.json @@ -1,3 +1,3 @@ { - "version": "7.0.0" + "version": "8.0.0" } diff --git a/contracts/api.schemas.json b/contracts/api.schemas.json index 57a0712..d371c06 100644 --- a/contracts/api.schemas.json +++ b/contracts/api.schemas.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "x-backend-contract-sha256": "54ea3f00fa672e0241efbb9fc8e7ad9e8fc68415ec124e08f70657c2d57d9ff9", + "x-backend-contract-sha256": "655e918f3443073c270bdeb9ae4e69d79dacf83baa90dea1a0cdcfe71d2b8bd5", "$defs": { "AccountChannelBody": { "type": "object", @@ -362,9 +362,6 @@ "ChannelSettings": { "type": "object", "properties": { - "allow_guests": { - "type": "boolean" - }, "delete_policy": { "$ref": "#/$defs/DeletePolicy" }, @@ -401,7 +398,6 @@ } }, "required": [ - "allow_guests", "delete_policy", "file_max_downloads", "join_policy", @@ -415,16 +411,6 @@ "ChannelSettingsPatch": { "type": "object", "properties": { - "allow_guests": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, "delete_policy": { "anyOf": [ { @@ -841,21 +827,6 @@ "oldest_seq" ] }, - "GuestInfo": { - "type": "object", - "properties": { - "endpoint_hash": { - "type": "string" - }, - "joined_at": { - "type": "integer" - } - }, - "required": [ - "endpoint_hash", - "joined_at" - ] - }, "InvitationBody": { "type": "object", "properties": { diff --git a/server/src/WebDrop.Api/ApiContract.cs b/server/src/WebDrop.Api/ApiContract.cs index bd3da84..e9ac5b6 100644 --- a/server/src/WebDrop.Api/ApiContract.cs +++ b/server/src/WebDrop.Api/ApiContract.cs @@ -3,7 +3,8 @@ namespace WebDrop.Api; public static class ApiContract { // v5: 频道容量按账号成员计;v7: seats 改名 max_members/members_used/members_full, - // 去掉设备名与设备清单接口,访客改走 /guests。设备只是账号的属性。 + // 去掉设备名与设备清单接口。设备只是账号的属性。 + // v8: 频道强制登录,去掉未登录访客(allow_guests、/guests、终端席位);便捷分享不受影响。 // Keep this aligned with contracts/api-contract.json and docs/openapi.yaml. - public const string Version = "7.0.0"; + public const string Version = "8.0.0"; } diff --git a/server/src/WebDrop.Api/ApiJson.cs b/server/src/WebDrop.Api/ApiJson.cs index 49548e9..ef3e095 100644 --- a/server/src/WebDrop.Api/ApiJson.cs +++ b/server/src/WebDrop.Api/ApiJson.cs @@ -235,7 +235,6 @@ public sealed class WorkspaceGroupCreatedBody [JsonSerializable(typeof(CreateChannelRequest))] [JsonSerializable(typeof(ChannelSettingsPatch))] [JsonSerializable(typeof(JoinResult))] -[JsonSerializable(typeof(List))] [JsonSerializable(typeof(AppendResult))] [JsonSerializable(typeof(AccountRegisterRequest))] [JsonSerializable(typeof(AccountDeviceSigningRequest))] diff --git a/server/src/WebDrop.Api/ChannelHub.cs b/server/src/WebDrop.Api/ChannelHub.cs index 1dec518..61ca094 100644 --- a/server/src/WebDrop.Api/ChannelHub.cs +++ b/server/src/WebDrop.Api/ChannelHub.cs @@ -12,7 +12,7 @@ public sealed class ChannelHub : IChannelEvents private readonly object _gate = new(); private readonly Dictionary _signals = []; - // 在线状态:频道 -> 账号 -> 账号设备 -> 活动订阅数。只记账号,访客没有可跨设备识别的身份,不参与。 + // 在线状态:频道 -> 账号 -> 账号设备 -> 活动订阅数。只记账号。 private readonly Dictionary>> _presence = []; public Subscription Subscribe(Id16 id) @@ -26,9 +26,8 @@ public sealed class ChannelHub : IChannelEvents } /// 登记一个活动订阅,释放返回值即下线。只供成员接口读取,不向订阅流推送,以免改变流的线格式。 - public IDisposable Connected(Id16 id, string? account, string deviceKey) + public IDisposable Connected(Id16 id, string account, string deviceKey) { - if (account is null) return NoPresence.Instance; string endpoint = deviceKey; lock (_gate) { @@ -67,12 +66,6 @@ public sealed class ChannelHub : IChannelEvents 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 MembersChanged(Id16 channelId) => Publish(channelId, s => s.MembersVersion++); diff --git a/server/src/WebDrop.Api/Endpoints.Accounts.cs b/server/src/WebDrop.Api/Endpoints.Accounts.cs index 0279b62..d004c91 100644 --- a/server/src/WebDrop.Api/Endpoints.Accounts.cs +++ b/server/src/WebDrop.Api/Endpoints.Accounts.cs @@ -101,7 +101,6 @@ public static partial class Endpoints { if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); accounts.RevokeDevice(username, id); - svc.ReleaseAccountDevice(username, id); return Results.NoContent(); }); diff --git a/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs b/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs index 3e0ca17..772df9a 100644 --- a/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs +++ b/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs @@ -50,7 +50,7 @@ public static partial class Endpoints 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 members = svc.Members(channelId, RequestAuth.FromHeaders(ctx.Request, false)).Select(member => { var (online, onlineDevices) = hub.Presence(channelId, member.Username); // 设备是账号的属性:总数取账号的登录设备,在线数取该账号在本频道有订阅的设备 diff --git a/server/src/WebDrop.Api/Endpoints.Workspace.cs b/server/src/WebDrop.Api/Endpoints.Workspace.cs index fdce5b3..61c6576 100644 --- a/server/src/WebDrop.Api/Endpoints.Workspace.cs +++ b/server/src/WebDrop.Api/Endpoints.Workspace.cs @@ -14,49 +14,49 @@ public static partial class Endpoints .Select(u => accounts.Profile(u!)).ToList(), }; api.MapGet("/spaces/{id}/structure", (string id, HttpContext ctx, ChannelService svc) => - Json(View(svc.GetWorkspace(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true))), ApiJson.Default.WorkspaceBody)); + Json(View(svc.GetWorkspace(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false))), ApiJson.Default.WorkspaceBody)); api.MapPost("/spaces/{id}/channels", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) => { var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceChannelRequest, ct); - var created = svc.CreateWorkspaceChannelResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.Name, req.GroupId); + var created = svc.CreateWorkspaceChannelResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name, req.GroupId); return Json(new WorkspaceChannelCreatedBody { Workspace = View(created.State), Channel = created.Channel }, ApiJson.Default.WorkspaceChannelCreatedBody, 201); }); api.MapPatch("/spaces/{id}/channels/{channelId}", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) => { var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct); - return Json(View(svc.RenameWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.Name)), ApiJson.Default.WorkspaceBody); + return Json(View(svc.RenameWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.Name)), ApiJson.Default.WorkspaceBody); }); api.MapPut("/spaces/{id}/channels/{channelId}/group", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) => { var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceGroupRequest, ct); - return Json(View(svc.GroupWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.GroupId)), ApiJson.Default.WorkspaceBody); + return Json(View(svc.GroupWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.GroupId)), ApiJson.Default.WorkspaceBody); }); api.MapPost("/spaces/{id}/groups", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) => { var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct); - var created = svc.CreateWorkspaceGroupResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.Name); + var created = svc.CreateWorkspaceGroupResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name); return Json(new WorkspaceGroupCreatedBody { Workspace = View(created.State), Group = created.Group }, ApiJson.Default.WorkspaceGroupCreatedBody, 201); }); api.MapPatch("/spaces/{id}/groups/{groupId}", async (string id, string groupId, HttpContext ctx, ChannelService svc, CancellationToken ct) => { var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct); - return Json(View(svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), groupId, req.Name)), ApiJson.Default.WorkspaceBody); + return Json(View(svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId, req.Name)), ApiJson.Default.WorkspaceBody); }); api.MapPost("/spaces/{id}/folders", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) => { var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFolderRequest, ct); - return Json(View(svc.CreateWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.ThreadId, req.ParentId, req.Name)), ApiJson.Default.WorkspaceBody, 201); + return Json(View(svc.CreateWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.ThreadId, req.ParentId, req.Name)), ApiJson.Default.WorkspaceBody, 201); }); api.MapPatch("/spaces/{id}/folders/{folderId}", async (string id, string folderId, HttpContext ctx, ChannelService svc, CancellationToken ct) => { var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct); - return Json(View(svc.RenameWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), folderId, req.Name)), ApiJson.Default.WorkspaceBody); + return Json(View(svc.RenameWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), folderId, req.Name)), ApiJson.Default.WorkspaceBody); }); api.MapPut("/spaces/{id}/locations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) => { var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceMoveRequest, ct); if (req.ItemIds is null) throw new WebDropException(ErrorCode.BadRequest); - return Json(View(svc.MoveWorkspaceItems(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.ItemIds, req.ThreadId, req.FolderId)), ApiJson.Default.WorkspaceBody); + return Json(View(svc.MoveWorkspaceItems(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.ItemIds, req.ThreadId, req.FolderId)), ApiJson.Default.WorkspaceBody); }); } } diff --git a/server/src/WebDrop.Api/Endpoints.WorkspaceCrypto.cs b/server/src/WebDrop.Api/Endpoints.WorkspaceCrypto.cs index 1575775..64d16c0 100644 --- a/server/src/WebDrop.Api/Endpoints.WorkspaceCrypto.cs +++ b/server/src/WebDrop.Api/Endpoints.WorkspaceCrypto.cs @@ -12,21 +12,21 @@ public static partial class Endpoints { private static void MapWorkspaceCrypto(RouteGroupBuilder api) { - api.MapGet("/spaces/{id}/crypto", (string id, HttpContext ctx, ChannelService svc) => Json(svc.ReadCrypto(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true)), ApiJson.Default.CryptoState)); + api.MapGet("/spaces/{id}/crypto", (string id, HttpContext ctx, ChannelService svc) => Json(svc.ReadCrypto(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false)), ApiJson.Default.CryptoState)); api.MapPut("/spaces/{id}/crypto/domains", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) => { var req = await ReadJson(ctx.Request, ApiJson.Default.DomainPublishRequest, ct); - svc.PublishDomain(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req); return Results.NoContent(); + svc.PublishDomain(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req); return Results.NoContent(); }); api.MapPost("/spaces/{id}/crypto/objects", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) => { var req = await ReadJson(ctx.Request, ApiJson.Default.ObjectEnvelope, ct); - return Json(svc.DeclareObject(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req), ApiJson.Default.ObjectEnvelope, 201); + return Json(svc.DeclareObject(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req), ApiJson.Default.ObjectEnvelope, 201); }); api.MapPatch("/spaces/{id}/channels/{channelId}/history", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) => { var req = await ReadJson(ctx.Request, ApiJson.Default.ChannelHistoryRequest, ct); - svc.SetChannelHistory(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.ChatHistory, req.FilesHistory); return Results.NoContent(); + svc.SetChannelHistory(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.ChatHistory, req.FilesHistory); return Results.NoContent(); }); } } diff --git a/server/src/WebDrop.Api/Endpoints.cs b/server/src/WebDrop.Api/Endpoints.cs index 6a4cae1..ed52404 100644 --- a/server/src/WebDrop.Api/Endpoints.cs +++ b/server/src/WebDrop.Api/Endpoints.cs @@ -63,10 +63,9 @@ public static partial class Endpoints { if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); var req = await ReadJson(ctx.Request, ApiJson.Default.CreateChannelRequest, ct); - var endpoint = RequestAuth.ParseEndpointHash(ctx.Request.Headers[RequestAuth.EndpointHeader]); string token = ctx.Request.Headers[RequestAuth.CaptchaHeader].ToString(); await captcha.VerifyAsync(token.Length > 0 ? token : null, ct); - return Json(svc.CreateChannel(req, endpoint, username, accounts.SessionDevice(ctx.Request.Headers["X-WD-Account"].ToString())), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created); + return Json(svc.CreateChannel(req, username), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created); }).RequireRateLimiting(CreatePolicy); api.MapGet("/channels/{id}", (string id, HttpContext ctx, ChannelService svc) => @@ -90,28 +89,17 @@ public static partial class Endpoints 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.JoinResult); + return Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), request.Password), ApiJson.Default.JoinResult); }); MapChannelAccess(api, accounts); - // 访客是未登录的终端;账号成员只按用户管理(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.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); - svc.ReleaseGuest(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), hash); - return Results.NoContent(); - }); - // ───────── 帧与日志 ───────── api.MapPost("/channels/{id}/frames", async (string id, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore, ServerLimits limits, CancellationToken ct) => { var channelId = ParseId(id); - var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true); + var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false); long? maxDownloads = ParseMaxDownloads(ctx.Request.Headers["X-WD-Max-Downloads"].ToString()); byte[] body = await ReadBody(ctx.Request, Envelope.HeaderSize + limits.MaxPayloadBytes, ct); @@ -123,7 +111,7 @@ public static partial class Endpoints api.MapGet("/channels/{id}/log", async (string id, HttpContext ctx, ChannelService svc, ChannelHub hub, CancellationToken ct) => { var channelId = ParseId(id); - var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true); + var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false); long from = QueryLong(ctx.Request, "from", 0); int limit = (int)Math.Clamp(QueryLong(ctx.Request, "limit", 200), 1, 1000); var wait = TimeSpan.FromSeconds(Math.Clamp(QueryLong(ctx.Request, "wait", 0), 0, 25)); @@ -171,7 +159,7 @@ public static partial class Endpoints api.MapGet("/channels/{id}/events", async (string id, HttpContext ctx, ChannelService svc, ChannelHub hub) => { var channelId = ParseId(id); - var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true); + var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false); long from = QueryLong(ctx.Request, "from", -1); if (from < 0) from = long.TryParse(ctx.Request.Headers["Last-Event-ID"].ToString(), out long last) && last >= 0 ? last + 1 : 0; @@ -192,7 +180,7 @@ public static partial class Endpoints { var channelId = ParseId(id); var item = ParseId(itemId); - var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true); + var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false); long from = QueryLong(ctx.Request, "from", 0); if (from is < 0 or > uint.MaxValue) throw new WebDropException(ErrorCode.BadRequest); @@ -220,7 +208,7 @@ public static partial class Endpoints api.MapDelete("/channels/{id}/items/{itemId}", (string id, string itemId, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore) => { var channelId = ParseId(id); var parsedItemId = ParseId(itemId); - var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true); + var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false); var proof = ParseSignedDeleteEvent(ctx.Request, accountStore, svc, channelId, parsedItemId, creds); svc.DeleteItem(channelId, creds, parsedItemId, proof); return Results.NoContent(); diff --git a/server/src/WebDrop.Api/LogStreamer.cs b/server/src/WebDrop.Api/LogStreamer.cs index f593959..bea1457 100644 --- a/server/src/WebDrop.Api/LogStreamer.cs +++ b/server/src/WebDrop.Api/LogStreamer.cs @@ -31,7 +31,7 @@ public static class LogStreamer ILogSink? sink = null; IDisposable? presence = null; long cursor = from; - var (settingsV, seatsV, _) = sub.State; + var (settingsV, membersV, _) = sub.State; try { @@ -42,7 +42,7 @@ public static class LogStreamer 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)); + presence ??= hub.Connected(id, creds.AccountName!, creds.AccountDeviceId ?? ""); foreach (var record in page.Records) { @@ -50,15 +50,15 @@ public static class LogStreamer cursor = record.Seq + 1; } - var (settingsNow, seatsNow, deleted) = sub.State; + var (settingsNow, membersNow, deleted) = sub.State; if (settingsNow != settingsV) { settingsV = settingsNow; await sink.SendControlAsync("""{"t":"settings"}""", ct).ConfigureAwait(false); } - if (seatsNow != seatsV) + if (membersNow != membersV) { - seatsV = seatsNow; + membersV = membersNow; await sink.SendControlAsync("""{"t":"members"}""", ct).ConfigureAwait(false); } if (deleted) throw new WebDropException(ErrorCode.NotFound); diff --git a/server/src/WebDrop.Api/RequestAuth.cs b/server/src/WebDrop.Api/RequestAuth.cs index 42c3343..d3ed955 100644 --- a/server/src/WebDrop.Api/RequestAuth.cs +++ b/server/src/WebDrop.Api/RequestAuth.cs @@ -35,7 +35,7 @@ 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 is null), accountName, deviceId, accountName is not null); } @@ -69,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: accountName is null), accountName, deviceId, accountName is not null); + return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: false), accountName, deviceId, accountName is not null); } public static byte[] ParseEndpointHash(string? header) => ParseEndpoint(header, requireEndpoint: true); diff --git a/server/src/WebDrop.Core/Abstractions.cs b/server/src/WebDrop.Core/Abstractions.cs index f991a14..d476b62 100644 --- a/server/src/WebDrop.Core/Abstractions.cs +++ b/server/src/WebDrop.Core/Abstractions.cs @@ -106,17 +106,13 @@ public interface IResourceTx void PutKeyPackage(Id16 id, string username, string payload, long createdAt); void DeleteKeyPackage(Id16 id, string username); IReadOnlyList ListUserKeyPackages(string username, long now); - IReadOnlyList DeleteDeviceSeats(string username, string deviceId); - void BindSeatDevice(Id16 id, byte[] endpointHash, string deviceId); - void DeleteAccountSeats(Id16 id, string username); ChannelMember? GetChannelMember(Id16 id, string username); IReadOnlyList ListChannelMembers(Id16 id); void PutChannelMember(Id16 id, ChannelMember member); void DeleteChannelMember(Id16 id, string username); - bool IsJoinDenied(Id16 id, string? username, byte[] endpointHash); + bool IsJoinDenied(Id16 id, string username); void DenyAccount(Id16 id, string username); void AllowAccount(Id16 id, string username); - void DenyEndpoint(Id16 id, byte[] endpointHash); void TouchChannel(Id16 id, long expiresAt); void AddChannelBytes(Id16 id, long delta); void SetCompactedThrough(Id16 id, long seq); @@ -124,13 +120,8 @@ public interface IResourceTx void DeleteChannelRows(Id16 id); IReadOnlyList ListExpiredChannels(long now, int limit); - // 席位 - Seat? GetSeat(Id16 channelId, ReadOnlySpan endpointHash); - int CountSeats(Id16 channelId); + // 成员 int CountMembers(Id16 channelId); - void InsertSeat(Id16 channelId, Seat seat); - bool DeleteSeat(Id16 channelId, ReadOnlySpan endpointHash); - IReadOnlyList ListSeats(Id16 channelId); // 条目 Item? GetItem(Id16 channelId, Id16 itemId); diff --git a/server/src/WebDrop.Core/ChannelAdmission.cs b/server/src/WebDrop.Core/ChannelAdmission.cs index e411fc1..593cbd6 100644 --- a/server/src/WebDrop.Core/ChannelAdmission.cs +++ b/server/src/WebDrop.Core/ChannelAdmission.cs @@ -14,13 +14,12 @@ public sealed partial class ChannelService private static void CheckAdmission(IResourceTx tx, Channel channel, Credentials creds, string? password) { - if (creds.AccountName is not null && creds.AccountName == channel.CreatorAccount) return; - if (tx.IsJoinDenied(channel.Id, creds.AccountName, creds.EndpointHash)) throw new WebDropException(ErrorCode.Forbidden); - bool invited = creds.AccountName is not null && tx.HasInvitation(channel.Id, creds.AccountName); - if (invited) return; - if (creds.AccountName is not null && tx.GetChannelMember(channel.Id, creds.AccountName) is not null) return; + string account = creds.AccountName ?? throw new WebDropException(ErrorCode.AccountRequired); + if (account == channel.CreatorAccount) return; + if (tx.IsJoinDenied(channel.Id, account)) throw new WebDropException(ErrorCode.Forbidden); + if (tx.HasInvitation(channel.Id, account)) return; + if (tx.GetChannelMember(channel.Id, account) is not null) return; if (channel.Settings.JoinPolicy == JoinPolicy.Invite) throw new WebDropException(ErrorCode.InvitationRequired); - if (!channel.Settings.AllowGuests && creds.AccountName is null) throw new WebDropException(ErrorCode.AccountRequired); if (channel.JoinPasswordHash is null) return; if (string.IsNullOrEmpty(password)) throw new WebDropException(ErrorCode.ChannelPasswordRequired); if (password.Length > 256) throw new WebDropException(ErrorCode.ChannelPasswordInvalid); @@ -30,7 +29,7 @@ public sealed partial class ChannelService public IReadOnlyList Invitations(Id16 id, Credentials creds) => store.Read(tx => { - var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); + var (_, principal) = Authenticate(tx, id, creds, Membership.None); RequireOwner(principal); return tx.ListInvitations(id); }); @@ -41,12 +40,11 @@ public sealed partial class ChannelService { store.Write(tx => { - var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); + var (_, principal) = Authenticate(tx, id, creds, Membership.None); RequireOwner(principal); if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden); tx.DeleteInvitation(id, username); tx.DeleteKeyPackage(id, username); - tx.DeleteAccountSeats(id, username); tx.DeleteChannelMember(id, username); var crypto = tx.GetWorkspaceCrypto(id); crypto.Access.RemoveAll(a => a.Username == username); @@ -61,26 +59,20 @@ public sealed partial class ChannelService public IReadOnlyList Members(Id16 id, Credentials creds) => store.Read(tx => { - var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); - if (principal.Role != Role.Owner) _ = Authenticate(tx, id, creds, SeatRule.Required); + var (_, principal) = Authenticate(tx, id, creds, Membership.None); + if (principal.Role != Role.Owner) _ = Authenticate(tx, id, creds, Membership.Required); return tx.ListChannelMembers(id); }); public IReadOnlyList AccountChannels(string username) => store.Read(tx => tx.ListUserKeyPackages(username, clock.NowMs)); - public void ReleaseAccountDevice(string username, string deviceId) - { - var channels = store.Write(tx => tx.DeleteDeviceSeats(username, deviceId)); - foreach (var id in channels) events.MembersChanged(id); - } - public void SaveOwnerKeyPackage(Id16 id, Credentials creds, string payload) { if (payload is null || payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest); store.Write(tx => { - var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); + var (_, principal) = Authenticate(tx, id, creds, Membership.None); RequireOwner(principal); tx.PutKeyPackage(id, principal.AccountName!, payload, clock.NowMs); return 0; diff --git a/server/src/WebDrop.Core/ChannelCrypto.cs b/server/src/WebDrop.Core/ChannelCrypto.cs index ff17f51..172fbf8 100644 --- a/server/src/WebDrop.Core/ChannelCrypto.cs +++ b/server/src/WebDrop.Core/ChannelCrypto.cs @@ -4,7 +4,7 @@ public sealed partial class ChannelService { public InvitationCryptoPlan InvitationPlan(Id16 id, Credentials creds, string username) => store.Read(tx => { - var (space, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal); + var (space, principal) = Authenticate(tx, id, creds, Membership.None); RequireOwner(principal); if (tx.GetChannelMember(id, username) is not null || tx.HasInvitation(id, username)) throw new WebDropException(ErrorCode.Conflict); var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound); var channelId = workspace.DefaultChannelId ?? throw new WebDropException(ErrorCode.Conflict); @@ -43,7 +43,7 @@ public sealed partial class ChannelService if (payload is null || payload.Length is < 1 or > 16_384 || envelopes is null) throw new WebDropException(ErrorCode.BadRequest); store.Write(tx => { - var (space, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal); + var (space, principal) = Authenticate(tx, id, creds, Membership.None); RequireOwner(principal); if (tx.GetChannelMember(id, username) is not null || tx.HasInvitation(id, username)) throw new WebDropException(ErrorCode.Conflict); if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden); var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound); @@ -136,7 +136,7 @@ public sealed partial class ChannelService } public CryptoState ReadCrypto(Id16 id, Credentials creds) => store.Read(tx => { - var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required); + var (space, principal) = Authenticate(tx, id, creds, Membership.Required); var state = tx.GetWorkspaceCrypto(id); if (principal.Role == Role.Owner || principal.AccountName == space.CreatorAccount) return state; var access = state.Access.Where(a => a.Username == principal.AccountName).ToList(); @@ -154,7 +154,7 @@ public sealed partial class ChannelService { store.Write(tx => { - var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); RequireOwner(principal); + var (_, principal) = Authenticate(tx, id, creds, Membership.Required); RequireOwner(principal); var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound); var channel = workspace.Channels.Find(c => c.Id == req.ChannelId) ?? throw new WebDropException(ErrorCode.NotFound); if (!Enum.IsDefined(req.Domain) || req.Envelopes is null || req.Envelopes.Count != 1 || req.Epoch != 1) throw new WebDropException(ErrorCode.BadRequest); @@ -177,7 +177,7 @@ public sealed partial class ChannelService } public ObjectEnvelope DeclareObject(Id16 id, Credentials creds, ObjectEnvelope req) => store.Write(tx => { - var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required); + var (space, principal) = Authenticate(tx, id, creds, Membership.Required); if (!ChannelPolicy.CanWrite(space, principal)) throw new WebDropException(ErrorCode.Forbidden); if (!Id16.TryParse(req.ItemId, out _) || !Enum.IsDefined(req.Domain) || req.KeyEnvelope is null || req.KeyEnvelope.Length > 1024) throw new WebDropException(ErrorCode.BadRequest); var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound); @@ -202,7 +202,7 @@ public sealed partial class ChannelService { store.Write(tx => { - var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); RequireOwner(principal); + var (_, principal) = Authenticate(tx, id, creds, Membership.Required); RequireOwner(principal); var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound); var channel = workspace.Channels.Find(c => c.Id == channelId) ?? throw new WebDropException(ErrorCode.NotFound); channel.ChatHistory = chat; channel.FilesHistory = files; workspace.Revision++; tx.PutWorkspace(id, workspace); return 0; diff --git a/server/src/WebDrop.Core/ChannelPolicy.cs b/server/src/WebDrop.Core/ChannelPolicy.cs index e9cbe79..671a326 100644 --- a/server/src/WebDrop.Core/ChannelPolicy.cs +++ b/server/src/WebDrop.Core/ChannelPolicy.cs @@ -19,9 +19,7 @@ public static class ChannelPolicy return channel.Settings.DeletePolicy switch { DeletePolicy.Anyone => true, - DeletePolicy.Own => item.AuthorAccount is not null - ? item.AuthorAccount == p.AccountName - : item.AuthorHash.AsSpan().SequenceEqual(p.EndpointHash), + DeletePolicy.Own => item.AuthorAccount == p.AccountName, _ => false, }; } diff --git a/server/src/WebDrop.Core/ChannelService.cs b/server/src/WebDrop.Core/ChannelService.cs index c2e52d5..e21f1c7 100644 --- a/server/src/WebDrop.Core/ChannelService.cs +++ b/server/src/WebDrop.Core/ChannelService.cs @@ -13,11 +13,12 @@ public sealed partial class ChannelService( ServerLimits limits, IChannelEvents events) { - private enum SeatRule { None, Required } + /// Required:必须已是频道成员;None:只验证身份(加入、查看信息等)。 + private enum Membership { None, Required } // ───────────────────────── 频道 ───────────────────────── - public ChannelInfo CreateChannel(CreateChannelRequest req, byte[] endpointHash, string creatorAccount, string? accountDeviceId = null) + public ChannelInfo CreateChannel(CreateChannelRequest req, string creatorAccount) { if (string.IsNullOrWhiteSpace(creatorAccount)) throw new WebDropException(ErrorCode.AccountRequired); if (req.KeyPackage is not null && req.KeyPackage.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest); @@ -66,7 +67,7 @@ public sealed partial class ChannelService( public ChannelInfo GetChannel(Id16 id, Credentials creds) => store.Read(tx => { - var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None); + var (channel, principal) = Authenticate(tx, id, creds, Membership.None); return ToInfo(channel, tx.CountMembers(id), principal); }); @@ -74,7 +75,7 @@ public sealed partial class ChannelService( { var info = store.Write(tx => { - var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None); + var (channel, principal) = Authenticate(tx, id, creds, Membership.None); RequireOwner(principal); var updated = patch.ApplyTo(channel.Settings); @@ -100,7 +101,7 @@ public sealed partial class ChannelService( { store.Write(tx => { - var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); + var (_, principal) = Authenticate(tx, id, creds, Membership.None); RequireOwner(principal); tx.DeleteChannelRows(id); return 0; @@ -111,62 +112,27 @@ public sealed partial class ChannelService( // ───────────────────────── 席位 ───────────────────────── - /// - /// 加入频道。账号成员只登记成员关系(同一账号的多台设备不增加人数,也不登记终端); - /// 未登录访客登记终端,每个终端按一个访客成员计入上限。 - /// + /// 加入频道。只有已登录的账号能加入:登记成员关系,同一账号的多台设备不增加人数。 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 (channel, _) = Authenticate(tx, id, creds, Membership.None); + string account = creds.AccountName!; int membersUsed = tx.CountMembers(id); JoinResult Result(long joinedAt, int used) => new() { JoinedAt = joinedAt, MembersUsed = used, MembersMax = channel.Settings.MaxMembers }; - if (creds.AccountName is not null) - { - 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); + if (tx.GetChannelMember(id, account) is { } existingMember) return (Result(existingMember.JoinedAt, membersUsed), false); CheckAdmission(tx, channel, creds, password); if (membersUsed >= channel.Settings.MaxMembers) throw new WebDropException(ErrorCode.MembersFull); - long now = clock.NowMs; - tx.InsertSeat(id, new Seat(principal.EndpointHash, now)); - return (Result(now, membersUsed + 1), true); + long memberAt = clock.NowMs; + tx.PutChannelMember(id, new ChannelMember(account, "member", memberAt)); + return (Result(memberAt, membersUsed + 1), true); }); if (isNew) events.MembersChanged(id); return joined; } - /// 所有者查看访客(未登录终端)。账号成员按用户管理,其设备不对外列出。 - public IReadOnlyList ListGuests(Id16 id, Credentials creds) => store.Read(tx => - { - var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); - RequireOwner(principal); - return (IReadOnlyList)tx.ListSeats(id).Where(s => s.AccountName is null) - .Select(s => new GuestInfo { EndpointHash = Codec.Encode(s.EndpointHash), JoinedAt = s.JoinedAt }).ToList(); - }); - - /// 所有者移除一个访客终端并禁止它再加入。账号成员改用撤销邀请(RevokeInvitation)。 - public void ReleaseGuest(Id16 id, Credentials creds, byte[] endpointHash) - { - store.Write(tx => - { - var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); - RequireOwner(principal); - 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.MembersChanged(id); - } - // ───────────────────────── 追加帧 ───────────────────────── public async Task AppendFrameAsync( @@ -182,7 +148,7 @@ public sealed partial class ChannelService( // 1. 预检:鉴权并处理幂等重发,避免为重复帧白写 blob var existing = store.Read(tx => { - var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required); + var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required); CheckWritable(channel, principal, env); CheckObjectWrite(tx, channel, principal, env); return tx.GetFrame(channelId, env.ItemId, env.FrameIndex); @@ -228,7 +194,7 @@ public sealed partial class ChannelService( IResourceTx tx, Id16 channelId, Credentials creds, Envelope env, ReadOnlyMemory body, byte[] digest, string? blobKey, long? maxDownloads, SignedContentEventProof? signedEvent) { - var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required); + var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required); CheckWritable(channel, principal, env); CheckObjectWrite(tx, channel, principal, env); @@ -291,7 +257,7 @@ public sealed partial class ChannelService( if (item is not null) throw new WebDropException(ErrorCode.BadRequest); long max = env.IsGated ? maxDownloads ?? channel.Settings.FileMaxDownloads : 0; if (env.IsGated && !ServerSettingChoices.IsFileMaxDownloadsAllowed(max, channel.Settings.FileMaxDownloads)) throw new WebDropException(ErrorCode.BadRequest); - item = new Item(channelId, env.ItemId, principal.EndpointHash, env.ItemFlags, max, 0, 0, -1, null, 0, + item = new Item(channelId, env.ItemId, principal.GrantKey, env.ItemFlags, max, 0, 0, -1, null, 0, now, env.IsControl ? long.MaxValue : now + ttlMs, null, principal.AccountName); tx.InsertItem(item); } @@ -417,7 +383,7 @@ public sealed partial class ChannelService( limit = Math.Clamp(limit, 1, 1000); var (pending, lastSeq) = store.Read(tx => { - var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required); + var (channel, principal) = Authenticate(tx, id, creds, Membership.Required); if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden); // 续传点早于已被清理的墓碑:客户端可能漏掉删除事件,必须从 0 全量同步 @@ -463,7 +429,7 @@ public sealed partial class ChannelService( { store.Write(tx => { - var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required); + var (space, principal) = Authenticate(tx, id, creds, Membership.Required); if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden); var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound); if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden); @@ -498,7 +464,7 @@ public sealed partial class ChannelService( maxFrames = Math.Clamp(maxFrames, 1, 64); var (frames, next, complete) = store.Read(tx => { - var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required); + var (space, principal) = Authenticate(tx, id, creds, Membership.Required); if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden); var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound); if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden); @@ -535,7 +501,7 @@ public sealed partial class ChannelService( public ObjectEnvelope? ObjectSigningContext(Id16 id, Credentials creds, Id16 itemId) => store.Read(tx => { - _ = Authenticate(tx, id, creds, SeatRule.Required); + _ = Authenticate(tx, id, creds, Membership.Required); return tx.GetWorkspaceCrypto(id).Objects.Find(o => o.ItemId == itemId.ToBase64Url()); }); @@ -543,7 +509,7 @@ public sealed partial class ChannelService( { var (seq, prefixes) = store.Write(tx => { - var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required); + var (channel, principal) = Authenticate(tx, id, creds, Membership.Required); var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound); if (!ChannelPolicy.CanDeleteItem(channel, item, principal)) throw new WebDropException(ErrorCode.Forbidden); @@ -671,22 +637,18 @@ public sealed partial class ChannelService( // ───────────────────────── 鉴权 ───────────────────────── - private (Channel Channel, Principal Principal) Authenticate(IResourceTx tx, Id16 id, Credentials creds, SeatRule seat) + private (Channel Channel, Principal Principal) Authenticate(IResourceTx tx, Id16 id, Credentials creds, Membership seat) { + // 频道强制登录:没有账号会话的请求一律拒绝(便捷分享不受影响) + string account = creds.AccountName ?? throw new WebDropException(ErrorCode.AccountRequired); var channel = tx.GetChannel(id); if (channel is null || channel.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.NotFound); var role = VerifyRole(channel, creds); - var member = creds.AccountName is null ? null : tx.GetChannelMember(id, creds.AccountName); + var member = tx.GetChannelMember(id, account); if (role == Role.Owner && member?.Role != "owner") throw new WebDropException(ErrorCode.AccountRequired); - 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) throw new WebDropException(ErrorCode.AccountRequired); - } - return (channel, new Principal(role, creds.EndpointHash, creds.AccountName)); + if (seat == Membership.Required && member is null) throw new WebDropException(ErrorCode.NotJoined); + return (channel, new Principal(role, account)); } private static Role VerifyRole(Channel channel, Credentials creds) @@ -715,7 +677,7 @@ public sealed partial class ChannelService( private static ChannelInfo ToInfo(Channel channel, int membersUsed, Principal? principal = null) => new() { - CanUpload = ChannelPolicy.CanWrite(channel, principal ?? new Principal(Role.Owner, [], channel.CreatorAccount)), + CanUpload = ChannelPolicy.CanWrite(channel, principal ?? new Principal(Role.Owner, channel.CreatorAccount)), CanManageStructure = principal is null || principal.Role == Role.Owner, Id = channel.Id.ToBase64Url(), CreatorAccount = channel.CreatorAccount, diff --git a/server/src/WebDrop.Core/ChannelWorkspace.cs b/server/src/WebDrop.Core/ChannelWorkspace.cs index e9d77a9..a2b5092 100644 --- a/server/src/WebDrop.Core/ChannelWorkspace.cs +++ b/server/src/WebDrop.Core/ChannelWorkspace.cs @@ -10,7 +10,7 @@ public sealed partial class ChannelService { var channel = tx.GetChannel(id)!; var role = tx.GetChannelMember(id, username)?.Role == "owner" ? Role.Owner : channel.WriteHash is not null ? Role.Write : Role.Read; - return ToInfo(channel, tx.CountMembers(id), new Principal(role, [], username)); + return ToInfo(channel, tx.CountMembers(id), new Principal(role, username)); }).ToList()); private static WorkspaceState NewWorkspace(string username, long now) { @@ -19,7 +19,7 @@ public sealed partial class ChannelService } public WorkspaceState GetWorkspace(Id16 id, Credentials creds) => store.Read(tx => { - var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required); + var (space, principal) = Authenticate(tx, id, creds, Membership.Required); var state = tx.GetWorkspace(id) ?? new WorkspaceState(); if (principal.AccountName != space.CreatorAccount) { @@ -42,8 +42,7 @@ public sealed partial class ChannelService { var state = store.Write(tx => { - var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required); - if (requireAccount && principal.AccountName is null) throw new WebDropException(ErrorCode.AccountRequired); + var (channel, principal) = Authenticate(tx, id, creds, Membership.Required); if (requireAccount && principal.Role != Role.Owner) throw new WebDropException(ErrorCode.Forbidden); if (!ChannelPolicy.CanWrite(channel, principal)) throw new WebDropException(ErrorCode.Forbidden); var current = tx.GetWorkspace(id) ?? new WorkspaceState(); @@ -97,7 +96,7 @@ public sealed partial class ChannelService if (!Id16.TryParse(itemId, out var parsed)) throw new WebDropException(ErrorCode.BadRequest); var item = tx.GetItem(id, parsed) ?? throw new WebDropException(ErrorCode.NotFound); if ((item.Flags & (FrameFlags.Control | FrameFlags.Snapshot)) != 0) throw new WebDropException(ErrorCode.BadRequest); - if (principal.Role != Role.Owner && (item.AuthorAccount is not null ? item.AuthorAccount != principal.AccountName : !item.AuthorHash.AsSpan().SequenceEqual(principal.EndpointHash))) throw new WebDropException(ErrorCode.Forbidden); + if (principal.Role != Role.Owner && item.AuthorAccount != principal.AccountName) throw new WebDropException(ErrorCode.Forbidden); state.Locations.RemoveAll(l => l.ItemId == itemId); state.Locations.Add(new WorkspaceLocation { ItemId = itemId, ThreadId = threadId, FolderId = folderId, AuthorAccount = item.AuthorAccount }); } diff --git a/server/src/WebDrop.Core/Dtos.cs b/server/src/WebDrop.Core/Dtos.cs index 01a7c4e..e6f4875 100644 --- a/server/src/WebDrop.Core/Dtos.cs +++ b/server/src/WebDrop.Core/Dtos.cs @@ -59,7 +59,7 @@ public sealed class ChannelInfo [JsonPropertyName("id")] public required string Id { get; init; } [JsonPropertyName("mode")] public required ChannelMode Mode { get; init; } [JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; } - /// v5: 账号成员数加匿名访客终端数,不统计账号设备数。 + /// 频道成员数(账号),不统计账号设备数。 [JsonPropertyName("members_used")] public required int MembersUsed { get; init; } /// 从该 seq 起的续传有效;0 < from < oldest_seq 的续传会得到 410,需从 0 全量同步。 @@ -78,13 +78,6 @@ public sealed class JoinResult [JsonPropertyName("members_max")] public required int MembersMax { get; init; } } -/// 未登录访客的一次加入(终端)。账号成员按用户管理,不在这里出现。 -public sealed class GuestInfo -{ - [JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; } - [JsonPropertyName("joined_at")] public required long JoinedAt { get; init; } -} - public sealed class AppendResult { [JsonPropertyName("seq")] public required long Seq { get; init; } diff --git a/server/src/WebDrop.Core/Models.cs b/server/src/WebDrop.Core/Models.cs index 1581bae..922ca89 100644 --- a/server/src/WebDrop.Core/Models.cs +++ b/server/src/WebDrop.Core/Models.cs @@ -82,8 +82,6 @@ public sealed record Item( public bool IsComplete => FinalIndex is { } f && FrameCount == f + 1; } -/// 频道内的终端登记(账号设备或访客终端)。设备只是账号的属性,不是成员;不再有单独的设备名。 -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,13 +99,11 @@ public sealed record StoredFrame( public sealed record LogEntry(long Seq, LogKind Kind, Id16 ItemId, uint FrameIndex, byte Aux, long CreatedAt); -/// 一次请求验证通过后的身份。 -public sealed record Principal(Role Role, byte[] EndpointHash, string? AccountName = null) +/// 一次请求验证通过后的身份。频道只接受已登录账号。 +public sealed record Principal(Role Role, string AccountName) { - /// 受限下载的计数单位:账号按用户计(设备只是账号的属性),访客按终端计。 - public byte[] GrantKey => AccountName is null - ? EndpointHash - : System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes("webdrop/grant/v1/" + AccountName)); + /// 受限下载的计数单位:按用户计(设备只是账号的属性)。 + public byte[] GrantKey => System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes("webdrop/grant/v1/" + AccountName)); } /// 请求携带的凭据,尚未验证。 diff --git a/server/src/WebDrop.Core/Settings.cs b/server/src/WebDrop.Core/Settings.cs index 80c2e46..6d1aff4 100644 --- a/server/src/WebDrop.Core/Settings.cs +++ b/server/src/WebDrop.Core/Settings.cs @@ -31,7 +31,6 @@ public static class ServerSettingChoices public sealed class ChannelSettings { [JsonPropertyName("join_policy")] public JoinPolicy JoinPolicy { get; set; } = JoinPolicy.Open; - [JsonPropertyName("allow_guests")] public bool AllowGuests { get; set; } = true; [JsonPropertyName("name")] public string? Name { get; set; } /// 成员人数上限;线字段名保留为 seats 以兼容已有频道设置。 [JsonPropertyName("max_members")] public int MaxMembers { get; set; } = 2; @@ -56,7 +55,6 @@ public sealed class ChannelSettings public sealed class ChannelSettingsPatch { [JsonPropertyName("join_policy")] public JoinPolicy? JoinPolicy { get; init; } - [JsonPropertyName("allow_guests")] public bool? AllowGuests { get; init; } [JsonPropertyName("join_password")] public string? JoinPassword { get; init; } [JsonPropertyName("name")] public string? Name { get; init; } [JsonPropertyName("max_members")] public int? MaxMembers { get; init; } @@ -70,7 +68,6 @@ public sealed class ChannelSettingsPatch public ChannelSettings ApplyTo(ChannelSettings s) => new() { JoinPolicy = JoinPolicy ?? s.JoinPolicy, - AllowGuests = AllowGuests ?? s.AllowGuests, Name = Name is null ? s.Name : string.IsNullOrWhiteSpace(Name) ? null : Name.Trim(), MaxMembers = MaxMembers ?? s.MaxMembers, MembersCanUpload = MembersCanUpload ?? s.MembersCanUpload, @@ -118,7 +115,7 @@ public static class CreationDefaults public static ChannelSettings Channel(ServerLimits limits) => new() { - JoinPolicy = JoinPolicy.Invite, AllowGuests = false, + JoinPolicy = JoinPolicy.Invite, MaxMembers = Math.Min(10, limits.MaxMembers), TtlSeconds = Math.Clamp(86_400, limits.MinTtlSeconds, limits.MaxTtlSeconds), MaxItems = Math.Min(1000, limits.MaxItemsLimit), diff --git a/server/src/WebDrop.Storage/SqliteChannelStore.cs b/server/src/WebDrop.Storage/SqliteChannelStore.cs index 8a6c677..3bb504f 100644 --- a/server/src/WebDrop.Storage/SqliteChannelStore.cs +++ b/server/src/WebDrop.Storage/SqliteChannelStore.cs @@ -32,11 +32,9 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable _writeConn = Open(); Exec(_writeConn, "PRAGMA journal_mode=WAL;"); InitializeSchema(); - using (var columns = _writeConn.CreateCommand()) - { - 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;"); - } + // v8: 频道只接受登录账号,不再有访客席位;丢弃旧的席位表和针对终端的拒绝记录(幂等) + Exec(_writeConn, "DROP TABLE IF EXISTS seats;"); + Exec(_writeConn, "DELETE FROM channel_join_denials WHERE target LIKE 'endpoint:%';"); // 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. @@ -131,15 +129,6 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable ); CREATE INDEX ix_channels_expires ON channels(expires_at); - CREATE TABLE seats ( - channel_id BLOB NOT NULL, - endpoint_hash BLOB NOT NULL, - label BLOB, - account_name TEXT, - joined_at INTEGER NOT NULL, - PRIMARY KEY (channel_id, endpoint_hash) - ) WITHOUT ROWID; - CREATE TABLE items ( channel_id BLOB NOT NULL, item_id BLOB NOT NULL, @@ -473,16 +462,14 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable WHERE k.username=@u AND c.expires_at>@now """, r => new ChannelKeyPackage(IdAt(r, 0), r.GetString(1), r.GetString(2), r.GetString(3), r.GetInt64(4)), ("@u", username), ("@now", now)); - public void DeleteAccountSeats(Id16 id, string username) => Run("DELETE FROM seats WHERE channel_id=@id AND account_name=@u", ("@id", id.ToArray()), ("@u", username)); private static ChannelMember MapMember(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.GetInt64(2)); public ChannelMember? GetChannelMember(Id16 id, string username) => One("SELECT username,role,joined_at FROM channel_members WHERE channel_id=@id AND username=@u", MapMember, ("@id", id.ToArray()), ("@u", username)); public IReadOnlyList ListChannelMembers(Id16 id) => Many("SELECT username,role,joined_at FROM channel_members WHERE channel_id=@id ORDER BY joined_at,username", MapMember, ("@id", id.ToArray())); public void PutChannelMember(Id16 id, ChannelMember member) => Run("INSERT OR IGNORE INTO channel_members(channel_id,username,role,joined_at) VALUES(@id,@u,@r,@t)", ("@id", id.ToArray()), ("@u", member.Username), ("@r", member.Role), ("@t", member.JoinedAt)); public void DeleteChannelMember(Id16 id, string username) => Run("DELETE FROM channel_members WHERE channel_id=@id AND username=@u AND role<>'owner'", ("@id", id.ToArray()), ("@u", username)); - public bool IsJoinDenied(Id16 id, string? username, byte[] endpointHash) => Scalar("SELECT 1 FROM channel_join_denials WHERE channel_id=@id AND (target=@a OR target=@e) LIMIT 1", ("@id", id.ToArray()), ("@a", username is null ? null : "account:" + username), ("@e", "endpoint:" + Codec.Encode(endpointHash))) is not null; + public bool IsJoinDenied(Id16 id, string username) => Scalar("SELECT 1 FROM channel_join_denials WHERE channel_id=@id AND target=@a LIMIT 1", ("@id", id.ToArray()), ("@a", "account:" + username)) is not null; public void DenyAccount(Id16 id, string username) => Run("INSERT OR IGNORE INTO channel_join_denials VALUES(@id,@t)", ("@id", id.ToArray()), ("@t", "account:" + username)); public void AllowAccount(Id16 id, string username) => Run("DELETE FROM channel_join_denials WHERE channel_id=@id AND target=@t", ("@id", id.ToArray()), ("@t", "account:" + username)); - public void DenyEndpoint(Id16 id, byte[] endpointHash) => Run("INSERT OR IGNORE INTO channel_join_denials VALUES(@id,@t)", ("@id", id.ToArray()), ("@t", "endpoint:" + Codec.Encode(endpointHash))); public void AddChannelBytes(Id16 id, long delta) => Run("UPDATE channels SET bytes=bytes+@d WHERE id=@id", ("@d", delta), ("@id", id.ToArray())); @@ -500,7 +487,6 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable Run("DELETE FROM frames WHERE channel_id=@c", ("@c", key)); Run("DELETE FROM log WHERE channel_id=@c", ("@c", key)); Run("DELETE FROM items WHERE channel_id=@c", ("@c", key)); - Run("DELETE FROM seats WHERE channel_id=@c", ("@c", key)); Run("DELETE FROM channels WHERE id=@c", ("@c", key)); Run("DELETE FROM channel_invitations WHERE channel_id=@c", ("@c", key)); Run("DELETE FROM channel_key_packages WHERE channel_id=@c", ("@c", key)); @@ -513,41 +499,10 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable public IReadOnlyList ListExpiredChannels(long now, int limit) => Many("SELECT id FROM channels WHERE expires_at<=@now LIMIT @n", r => IdAt(r, 0), ("@now", now), ("@n", limit)); - // ───────── 席位 ───────── - - 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 endpointHash) => One( - "SELECT endpoint_hash, joined_at, label, account_name,account_device_id FROM seats WHERE channel_id=@c AND endpoint_hash=@e", - MapSeat, ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray())); - - public int CountSeats(Id16 channelId) => - (int)(long)Scalar("SELECT COUNT(*) FROM seats WHERE channel_id=@c", ("@c", channelId.ToArray()))!; + // ───────── 成员 ───────── public int CountMembers(Id16 channelId) => - (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, 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 endpointHash) => Run( - "DELETE FROM seats WHERE channel_id=@c AND endpoint_hash=@e", - ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray())) > 0; - - public IReadOnlyList 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())); - - public IReadOnlyList DeleteDeviceSeats(string username, string deviceId) - { - var ids = Many("SELECT DISTINCT channel_id FROM seats WHERE account_name=@u AND account_device_id=@d", r => IdAt(r, 0), ("@u", username), ("@d", deviceId)); - Run("DELETE FROM seats WHERE account_name=@u AND account_device_id=@d", ("@u", username), ("@d", deviceId)); - return ids; - } - public void BindSeatDevice(Id16 id, byte[] endpointHash, string deviceId) => Run( - "UPDATE seats SET account_device_id=@d WHERE channel_id=@c AND endpoint_hash=@e", - ("@d", deviceId), ("@c", id.ToArray()), ("@e", endpointHash)); + (int)(long)Scalar("SELECT COUNT(*) FROM channel_members WHERE channel_id=@c", ("@c", channelId.ToArray()))!; // ───────── 条目 ───────── diff --git a/server/tests/WebDrop.Api.Tests/AccountAdmissionTests.cs b/server/tests/WebDrop.Api.Tests/AccountAdmissionTests.cs index 4b3f1f3..038e7e5 100644 --- a/server/tests/WebDrop.Api.Tests/AccountAdmissionTests.cs +++ b/server/tests/WebDrop.Api.Tests/AccountAdmissionTests.cs @@ -163,7 +163,7 @@ 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", max_members = 2, join_policy = "open", allow_guests = true }); + var ch = await server.CreateAsync(settings: new { name = "Device admission space", max_members = 2, join_policy = "open" }); 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) }) @@ -198,7 +198,6 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture< { 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()); var share = JsonSerializer.Deserialize("{\"settings\":{\"max_visitors\":1}}", ApiJson.Default.CreateShareRequest)!; Assert.Equal(86400, share.Settings!.TtlSeconds); share.Settings.Validate(new ServerLimits()); @@ -214,9 +213,9 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture< } } [Fact] - public async Task AccountMembersNeedNoTerminalKeyAndDoNotOccupyGuestRegistrations() + public async Task AccountMembersNeedNoTerminalKey() { - var ch = await server.CreateAsync(settings: new { name = "Account only", max_members = 3, join_policy = "invite", allow_guests = false }); + var ch = await server.CreateAsync(settings: new { name = "Account only", max_members = 3, join_policy = "invite" }); 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); @@ -230,9 +229,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture< 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); @@ -321,17 +318,17 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture< 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}/join", guest)) + var joiner = await Register("joiner_" + Guid.NewGuid().ToString("N")[..8]); + using (var missing = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, joiner)) await AssertErrorAsync(await server.Http.SendAsync(missing), 403, "channel_password_required"); - using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, body: new { password = "wrong" })) + using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, joiner, 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}/join", guest, body: new { password = "channel-secret" })) + using (var correct = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, joiner, 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}/join", guest with { EndpointSecret = RandomNumberGenerator.GetBytes(32) }, body: new { password = "channel-secret" })) + // 没有账号会话的请求一律被拒绝,哪怕链接令牌和密码都对 + using (var stranger = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, 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 }))) + using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_policy = "invite" }))) Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode); var username = "invited_" + Guid.NewGuid().ToString("N")[..8]; var account = await Register(username); @@ -341,9 +338,9 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture< 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}/join", member, account)) Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accepted)).StatusCode); - // 只有链接令牌与终端密钥、没有账号会话的请求没有成员身份,也没有访客登记 + // 只有链接令牌与终端密钥、没有账号会话的请求没有任何身份 using (var withoutAccount = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", member)) - await AssertErrorAsync(await server.Http.SendAsync(withoutAccount), 403, "not_joined"); + await AssertErrorAsync(await server.Http.SendAsync(withoutAccount), 401, "account_required"); 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)) diff --git a/server/tests/WebDrop.Api.Tests/RestTests.cs b/server/tests/WebDrop.Api.Tests/RestTests.cs index 63a4ea1..88b38ec 100644 --- a/server/tests/WebDrop.Api.Tests/RestTests.cs +++ b/server/tests/WebDrop.Api.Tests/RestTests.cs @@ -126,11 +126,6 @@ public class RestTests(ServerFixture s) : IClassFixture 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 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,7 +227,7 @@ public class RestTests(ServerFixture s) : IClassFixture } [Fact] - public async Task JoinListGuestsAndRelease() + public async Task JoinMembersAndRevoke() { var ch = await s.CreateAsync(); @@ -241,26 +236,24 @@ public class RestTests(ServerFixture s) : IClassFixture 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)); + // 第三个账号被拒绝:成员已满 + var thirdAccount = s.NewAccountToken("third_" + Guid.NewGuid().ToString("N")[..8]); + var third = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32), thirdAccount); 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}/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 guests = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/guests", ch.Owner)); + Assert.Equal(HttpStatusCode.NotFound, guests.StatusCode); - // 设备名接口已移除 - 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.NotFound, label.StatusCode); + // 没有账号会话的请求被拒绝 + using var anonymous = s.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", ch.Reader); + anonymous.Headers.Remove("X-WD-Account"); + await AssertErrorAsync(await s.Http.SendAsync(anonymous), 401, "account_required"); - // 所有者移除访客后,成员失去访问 - 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); + // 所有者撤销成员后,成员失去访问 + using var revoke = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/fixture_member", ch.Owner)); + Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode); await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "not_joined"); } diff --git a/server/tests/WebDrop.Api.Tests/ServerFixture.cs b/server/tests/WebDrop.Api.Tests/ServerFixture.cs index 9155234..ed9159f 100644 --- a/server/tests/WebDrop.Api.Tests/ServerFixture.cs +++ b/server/tests/WebDrop.Api.Tests/ServerFixture.cs @@ -23,6 +23,8 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable public HttpClient Http { get; private set; } = null!; public Uri BaseUri { get; private set; } = null!; public string OwnerAccountToken { get; private set; } = null!; + /// 非所有者的默认账号。频道只接受登录账号,所以测试里的「成员」「读者」都是这个账号。 + public string MemberAccountToken { get; private set; } = null!; public ServerFixture() : this([]) { } @@ -47,12 +49,20 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable string address = App.Services.GetRequiredService().Features.Get()!.Addresses.First(); BaseUri = new Uri(address); Http = new HttpClient { BaseAddress = BaseUri, Timeout = TimeSpan.FromSeconds(30) }; + var accounts = App.Services.GetRequiredService(); + OwnerAccountToken = NewAccountToken("fixture_owner"); + MemberAccountToken = NewAccountToken("fixture_member"); + } + + /// 注册一个账号并返回它的会话令牌。 + public string NewAccountToken(string username) + { var accounts = App.Services.GetRequiredService(); var verifier = RandomNumberGenerator.GetBytes(32); using var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256); var point = key.ExportParameters(false).Q; - accounts.Register("fixture_owner", RandomNumberGenerator.GetBytes(16), verifier, new byte[28], JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(point.X!), y = B64(point.Y!) })); - OwnerAccountToken = accounts.Authenticate("fixture_owner", verifier)!; + accounts.Register(username, RandomNumberGenerator.GetBytes(16), verifier, new byte[28], JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(point.X!), y = B64(point.Y!) })); + return accounts.Authenticate(username, verifier)!; } public async Task DisposeAsync() @@ -82,7 +92,8 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable public Who Reader => new("read", ReadToken, MemberSecret); } - public sealed record Who(string Role, byte[]? Token, byte[] EndpointSecret); + /// AccountToken 为空时:所有者用 OwnerAccountToken,其余用 MemberAccountToken。 + public sealed record Who(string Role, byte[]? Token, byte[] EndpointSecret, string? AccountToken = null); public static string B64(byte[] bytes) => Codec.Encode(bytes); public static string NewId() => B64(RandomNumberGenerator.GetBytes(16)); @@ -93,7 +104,10 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable req.Headers.Add("X-WD-Role", who.Role); if (who.Token is not null) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", B64(who.Token)); req.Headers.Add("X-WD-Endpoint", B64(who.EndpointSecret)); - if (who.Role == "owner") req.Headers.Add("X-WD-Account", OwnerAccountToken); + // 频道和空间只接受登录账号;便捷分享(/shares)仍由未登录终端使用 + if (path.StartsWith("/api/v1/channels", StringComparison.Ordinal) || path.StartsWith("/api/v1/spaces", StringComparison.Ordinal)) + req.Headers.Add("X-WD-Account", who.AccountToken ?? (who.Role == "owner" ? OwnerAccountToken : MemberAccountToken)); + else if (who.Role == "owner") req.Headers.Add("X-WD-Account", OwnerAccountToken); return req; } @@ -118,7 +132,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", max_members = 2, join_policy = "open", allow_guests = true }; + body["settings"] = settings ?? new { name = "Test space", max_members = 2, join_policy = "open" }; 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); diff --git a/server/tests/WebDrop.Api.Tests/StreamingTests.cs b/server/tests/WebDrop.Api.Tests/StreamingTests.cs index ac4a725..bb949af 100644 --- a/server/tests/WebDrop.Api.Tests/StreamingTests.cs +++ b/server/tests/WebDrop.Api.Tests/StreamingTests.cs @@ -20,7 +20,8 @@ public class StreamingTests(ServerFixture s) : IClassFixture ws.Options.AddSubProtocol($"wd.role.{who.Role}"); if (who.Token is not null) ws.Options.AddSubProtocol($"wd.auth.{B64(who.Token)}"); ws.Options.AddSubProtocol($"wd.ep.{B64(who.EndpointSecret)}"); - if (who.Role == "owner") ws.Options.AddSubProtocol($"wd.account.{B64(Convert.FromBase64String(s.OwnerAccountToken))}"); + string account = who.AccountToken ?? (who.Role == "owner" ? s.OwnerAccountToken : s.MemberAccountToken); + ws.Options.AddSubProtocol($"wd.account.{B64(Convert.FromBase64String(account))}"); var uri = new UriBuilder(s.BaseUri) { Scheme = "ws", Path = $"/api/v1/channels/{ch.Id}/ws", Query = $"from={from}" }.Uri; using var cts = new CancellationTokenSource(Timeout); @@ -106,23 +107,22 @@ public class StreamingTests(ServerFixture s) : IClassFixture var ex = await Assert.ThrowsAsync(() => ConnectWs(ch, bad)); Assert.Contains("401", ex.Message); - // 未入席 + // 未入席(已登录,但还不是成员) var stranger = new Who("write", ch.WriteToken, new byte[32]); var ex2 = await Assert.ThrowsAsync(() => ConnectWs(ch, stranger)); Assert.Contains("403", ex2.Message); } [Fact] - public async Task WebSocketNotifiesSeatChangesAndClosesWhenRevoked() + public async Task WebSocketNotifiesMemberChangesAndClosesWhenRevoked() { var ch = await s.CreateAsync(); 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}/guests/{memberHash}", ch.Owner)); + // 所有者撤销成员:订阅者先收到 members 控制消息,随后被以 not_joined 关闭 + using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/fixture_member", ch.Owner)); Assert.Equal(HttpStatusCode.NoContent, release.StatusCode); bool closed = false; diff --git a/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs b/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs index 793f833..5a4cd81 100644 --- a/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs +++ b/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs @@ -45,7 +45,7 @@ public class ChannelServiceTests : IDisposable ReadHash = Codec.Encode(TokenHash.Compute(ch.ReadToken)), OwnerHash = Codec.Encode(TokenHash.Compute(ch.OwnerToken)), }; - AssertCode(ErrorCode.Conflict, () => Svc.CreateChannel(dup, _ownerEp, "owner")); + AssertCode(ErrorCode.Conflict, () => Svc.CreateChannel(dup, "owner")); } [Fact] @@ -56,9 +56,9 @@ public class ChannelServiceTests : IDisposable var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, Settings = new ChannelSettings { Name = "Enc space" }, OwnerHash = h() }; var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw, Settings = new ChannelSettings { Name = "No owner space" } }; - AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, _ownerEp, "owner")); - AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, _ownerEp, "owner")); - AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, _ownerEp, "owner")); + AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, "owner")); + AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, "owner")); + AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, "owner")); } [Fact] @@ -78,10 +78,10 @@ public class ChannelServiceTests : IDisposable public void WrongTokenIsUnauthorized() { var ch = _env.Create(_ownerEp); - AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, TestEnv.Random(32), _ownerEp))); - AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Read, null, _ownerEp))); + AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, TestEnv.Random(32), _ownerEp, "owner"))); + AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Read, null, _ownerEp, "owner"))); // 用读令牌冒充所有者 - AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, ch.ReadToken, _ownerEp))); + AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, ch.ReadToken, _ownerEp, "owner"))); } [Fact] @@ -119,13 +119,10 @@ public class ChannelServiceTests : IDisposable 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.MembersFull, () => Svc.Join(ch.Id, bob)); - 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)).MembersUsed); Assert.Equal(2, Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }).MembersUsed); @@ -151,7 +148,7 @@ public class ChannelServiceTests : IDisposable Settings = new ChannelSettings { Name = name }, OwnerHash = Codec.Encode(TokenHash.Compute(TestEnv.Random(32))), }; - AssertCode(ErrorCode.SpaceNameRequired, () => Svc.CreateChannel(request, _ownerEp, "owner")); + AssertCode(ErrorCode.SpaceNameRequired, () => Svc.CreateChannel(request, "owner")); } [Fact] @@ -204,28 +201,36 @@ public class ChannelServiceTests : IDisposable } [Fact] - public async Task ReleasedSeatLosesAccess() + public async Task RevokedMemberLosesAccess() { var ch = _env.Create(_ownerEp); Svc.Join(ch.Id, ch.Member(_memberEp)); await AppendSmall(ch, ch.Member(_memberEp)); - Svc.ReleaseGuest(ch.Id, ch.Owner(_ownerEp), _memberEp); + Svc.RevokeInvitation(ch.Id, ch.Owner(_ownerEp), ch.AccountOf(_memberEp)); await AssertCode(ErrorCode.NotJoined, () => AppendSmall(ch, ch.Member(_memberEp))); + AssertCode(ErrorCode.Forbidden, () => Svc.Join(ch.Id, ch.Member(_memberEp))); // 被移除的账号不能凭链接再进来 } [Fact] - public void OnlyTheOwnerManagesGuestsAndSettings() + public async Task ChannelsRequireALoggedInAccount() + { + var ch = _env.Create(_ownerEp); + var anonymous = new Credentials(Role.Read, ch.ReadToken, _memberEp); + AssertCode(ErrorCode.AccountRequired, () => Svc.GetChannel(ch.Id, anonymous)); + AssertCode(ErrorCode.AccountRequired, () => Svc.Join(ch.Id, anonymous)); + await AssertCode(ErrorCode.AccountRequired, () => LogOf(ch, anonymous)); + await AssertCode(ErrorCode.AccountRequired, () => AppendSmall(ch, anonymous with { ClaimedRole = Role.Write, Token = ch.WriteToken })); + } + + [Fact] + public void OnlyTheOwnerManagesSettings() { var ch = _env.Create(_ownerEp); Svc.Join(ch.Id, ch.Member(_memberEp)); - 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.ReleaseGuest(ch.Id, ch.Member(_memberEp), _ownerEp)); - - Assert.Single(Svc.ListGuests(ch.Id, ch.Owner(_ownerEp))); // 访客只有成员那一台 } [Fact] @@ -238,8 +243,6 @@ 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 { 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))); } @@ -346,7 +349,7 @@ public class ChannelServiceTests : IDisposable var enc = _env.Create(_ownerEp, ChannelMode.Enc); await Append(raw, raw.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 10)); - var anonymous = new Credentials(Role.Read, null, _memberEp); + var anonymous = new Credentials(Role.Read, null, _memberEp, "reader"); // raw 频道知道 id 即可读,但仍要登录 Svc.Join(raw.Id, anonymous); Assert.Single((await Svc.ReadLogAsync(raw.Id, anonymous, 0, 10)).Records); diff --git a/server/tests/WebDrop.Core.Tests/PrimitivesTests.cs b/server/tests/WebDrop.Core.Tests/PrimitivesTests.cs index 00231be..e89a654 100644 --- a/server/tests/WebDrop.Core.Tests/PrimitivesTests.cs +++ b/server/tests/WebDrop.Core.Tests/PrimitivesTests.cs @@ -116,8 +116,8 @@ public class PolicyTests var first = TestEnv.Endpoint(); var second = TestEnv.Endpoint(); var item = ItemBy(first) with { AuthorAccount = "member" }; var channel = Chan(new ChannelSettings { DeletePolicy = DeletePolicy.Own }); - Assert.True(ChannelPolicy.CanDeleteItem(channel, item, new Principal(Role.Read, second, "member"))); - Assert.False(ChannelPolicy.CanDeleteItem(channel, item, new Principal(Role.Read, first, "other-account"))); + Assert.True(ChannelPolicy.CanDeleteItem(channel, item, new Principal(Role.Read, "member"))); + Assert.False(ChannelPolicy.CanDeleteItem(channel, item, new Principal(Role.Read, "other-account"))); } private static Channel Chan(ChannelSettings s, bool writeHash = true) => new( TestEnv.NewId(), ChannelMode.Enc, s, new byte[32], writeHash ? new byte[32] : null, new byte[32], 0, 0, 0, 0, 0, "owner"); @@ -129,12 +129,12 @@ public class PolicyTests public void WritePermission() { var ep = TestEnv.Endpoint(); - Assert.True(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Owner, ep))); - Assert.True(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Write, ep))); - Assert.False(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Read, ep))); - Assert.False(ChannelPolicy.CanWrite(Chan(new() { MembersCanUpload = false }), new Principal(Role.Write, ep))); - Assert.False(ChannelPolicy.CanWrite(Chan(new(), writeHash: false), new Principal(Role.Write, ep))); - Assert.True(ChannelPolicy.CanWrite(Chan(new() { MembersCanUpload = false }), new Principal(Role.Owner, ep))); + Assert.True(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Owner, "someone"))); + Assert.True(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Write, "someone"))); + Assert.False(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Read, "someone"))); + Assert.False(ChannelPolicy.CanWrite(Chan(new() { MembersCanUpload = false }), new Principal(Role.Write, "someone"))); + Assert.False(ChannelPolicy.CanWrite(Chan(new(), writeHash: false), new Principal(Role.Write, "someone"))); + Assert.True(ChannelPolicy.CanWrite(Chan(new() { MembersCanUpload = false }), new Principal(Role.Owner, "someone"))); } [Theory] @@ -146,10 +146,9 @@ public class PolicyTests [InlineData(DeletePolicy.Anyone, Role.Read, false, true)] public void DeletePermission(DeletePolicy policy, Role role, bool isAuthor, bool expected) { - var me = TestEnv.Endpoint(); - var item = ItemBy(isAuthor ? me : TestEnv.Endpoint()); + var item = ItemBy(TestEnv.Endpoint()) with { AuthorAccount = isAuthor ? "me" : "someone-else" }; var channel = Chan(new() { DeletePolicy = policy }); - Assert.Equal(expected, ChannelPolicy.CanDeleteItem(channel, item, new Principal(role, me))); + Assert.Equal(expected, ChannelPolicy.CanDeleteItem(channel, item, new Principal(role, "me"))); } [Fact] diff --git a/server/tests/WebDrop.Core.Tests/TestEnv.cs b/server/tests/WebDrop.Core.Tests/TestEnv.cs index 2f40973..8c63c8a 100644 --- a/server/tests/WebDrop.Core.Tests/TestEnv.cs +++ b/server/tests/WebDrop.Core.Tests/TestEnv.cs @@ -30,9 +30,11 @@ public sealed class TestChannel(Id16 id, ChannelMode mode, byte[] read, byte[] w public byte[] WriteToken { get; } = write; public byte[] OwnerToken { get; } = owner; + /// 测试里用 endpoint 当作「哪一个人」:同一个 endpoint 对应同一个账号。 + public string AccountOf(byte[] endpoint) => endpoint.AsSpan().SequenceEqual(ownerEndpoint) ? "owner" : "u" + Convert.ToHexString(endpoint)[..8].ToLowerInvariant(); public Credentials Owner(byte[] endpoint) => new(Role.Owner, OwnerToken, endpoint, "owner"); - public Credentials Member(byte[] endpoint) => new(Role.Write, WriteToken, endpoint, endpoint.AsSpan().SequenceEqual(ownerEndpoint) ? "owner" : null); - public Credentials Reader(byte[] endpoint) => new(Role.Read, ReadToken, endpoint, endpoint.AsSpan().SequenceEqual(ownerEndpoint) ? "owner" : null); + public Credentials Member(byte[] endpoint) => new(Role.Write, WriteToken, endpoint, AccountOf(endpoint)); + public Credentials Reader(byte[] endpoint) => new(Role.Read, ReadToken, endpoint, AccountOf(endpoint)); } public sealed class TestEnv : IDisposable @@ -78,7 +80,7 @@ public sealed class TestEnv : IDisposable ReadHash = mode == ChannelMode.Enc ? Codec.Encode(TokenHash.Compute(read)) : null, WriteHash = withWriteHash ? Codec.Encode(TokenHash.Compute(write)) : null, OwnerHash = Codec.Encode(TokenHash.Compute(owner)), - }, ownerEndpoint, "owner"); + }, "owner"); return new TestChannel(id, mode, read, write, owner, ownerEndpoint); } diff --git a/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs b/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs index 5841f01..2843e4e 100644 --- a/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs +++ b/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs @@ -57,15 +57,15 @@ public sealed class WorkspaceStructureTests Assert.Equal("新频道", added.Channels.Single().Name); Assert.Equal(1, added.Revision); } [Fact] - public async Task AllowedGuestsCanPlaceTheirUploadsButCannotCreateBusinessStructure() + public async Task MembersCanPlaceTheirUploadsButCannotCreateBusinessStructure() { - using var env = new TestEnv(); var ownerEp = TestEnv.Endpoint(); var guestEp = TestEnv.Endpoint(); var ch = env.Create(ownerEp); - var guest = ch.Member(guestEp); env.Service.Join(ch.Id, guest); + using var env = new TestEnv(); var ownerEp = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var ch = env.Create(ownerEp); + var guest = ch.Member(memberEp); env.Service.Join(ch.Id, guest); var item = TestEnv.NewId(); await env.Service.AppendFrameAsync(ch.Id, guest, TestEnv.Frame(item, 0, TestEnv.SmallEnc, TestEnv.EncPayload), null); var channelId = env.Service.GetWorkspace(ch.Id, ch.Owner(ownerEp)).Channels.Single().Id; Assert.Single(env.Service.MoveWorkspaceItems(ch.Id, guest, [item.ToBase64Url()], channelId, null).Locations); - Assert.Equal(ErrorCode.AccountRequired, Assert.Throws(() => env.Service.CreateWorkspaceGroup(ch.Id, guest, "访客分组")).Code); + Assert.Equal(ErrorCode.Forbidden, Assert.Throws(() => env.Service.CreateWorkspaceGroup(ch.Id, guest, "成员分组")).Code); } } diff --git a/web/src/core-bridge/generated/api.ts b/web/src/core-bridge/generated/api.ts index 8b80900..6c3071f 100644 --- a/web/src/core-bridge/generated/api.ts +++ b/web/src/core-bridge/generated/api.ts @@ -1,5 +1,5 @@ // Generated from backend DTOs; do not edit. -// backend-contract-sha256: 54ea3f00fa672e0241efbb9fc8e7ad9e8fc68415ec124e08f70657c2d57d9ff9 +// backend-contract-sha256: 655e918f3443073c270bdeb9ae4e69d79dacf83baa90dea1a0cdcfe71d2b8bd5 export interface AccountChannelBody { channel_id: string; @@ -88,7 +88,6 @@ export interface ChannelInfo { export type ChannelMode = 'enc' | 'raw'; export interface ChannelSettings { - allow_guests: boolean; delete_policy: DeletePolicy; file_max_downloads: number; join_policy: JoinPolicy; @@ -100,7 +99,6 @@ export interface ChannelSettings { ui_restrict_copy_download: boolean; } export interface ChannelSettingsPatch { - allow_guests?: boolean | null; delete_policy?: DeletePolicy | null; file_max_downloads?: number | null; join_password?: string | null; @@ -171,10 +169,6 @@ export interface GoneLogBody { error: string; oldest_seq: number; } -export interface GuestInfo { - endpoint_hash: string; - joined_at: number; -} export interface InvitationBody { channel_id: string; created_at: number;