From 1dd54243ae1a415b2d3dbda84ce0ba7384c534c0 Mon Sep 17 00:00:00 2001 From: JustMe Date: Fri, 16 Oct 2026 19:42:31 +0800 Subject: [PATCH] feat(api): add space roles with delegated member removal and structure management --- contracts/api-contract.json | 2 +- contracts/api.schemas.json | 155 +++++++++++++++++- docs/api.md | 2 +- docs/openapi.yaml | 143 +++++++++++++++- server/src/WebDrop.Api/ApiContract.cs | 2 +- server/src/WebDrop.Api/ApiJson.cs | 17 ++ server/src/WebDrop.Api/Endpoints.Workspace.cs | 18 ++ server/src/WebDrop.Core/ChannelAdmission.cs | 17 +- server/src/WebDrop.Core/ChannelWorkspace.cs | 79 +++++++-- server/src/WebDrop.Core/Workspace.cs | 35 ++++ .../WorkspaceStructureTests.cs | 72 ++++++++ web/src/core-bridge/generated/api.ts | 28 +++- 12 files changed, 551 insertions(+), 19 deletions(-) diff --git a/contracts/api-contract.json b/contracts/api-contract.json index 8857000..40f8aee 100644 --- a/contracts/api-contract.json +++ b/contracts/api-contract.json @@ -1,3 +1,3 @@ { - "version": "8.1.0" + "version": "8.2.0" } diff --git a/contracts/api.schemas.json b/contracts/api.schemas.json index e7a20b9..7582d8f 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": "22853abd6c1bf74053682018cd214fecb34c664e4abe043a15c1595566419350", + "x-backend-contract-sha256": "2a28bf52c136caa7c0b86fc2630153cb71529e3c5d85c3b7196e9d9a04d63b75", "$defs": { "AccountChannelBody": { "type": "object", @@ -1898,6 +1898,146 @@ "name" ] }, + "WorkspaceRoleCreatedBody": { + "type": "object", + "properties": { + "role": { + "$ref": "#/$defs/WorkspaceRole" + }, + "workspace": { + "$ref": "#/$defs/WorkspaceBody" + } + }, + "required": [ + "role", + "workspace" + ] + }, + "WorkspaceRole": { + "type": "object", + "properties": { + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "members": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "prefix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "suffix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "createdAt", + "id", + "members", + "name", + "permissions" + ] + }, + "WorkspaceRoleMembersRequest": { + "type": "object", + "properties": { + "members": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "members" + ] + }, + "WorkspaceRoleRequest": { + "type": "object", + "properties": { + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "prefix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "suffix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name", + "permissions" + ] + }, "WorkspaceState": { "type": "object", "properties": { @@ -1937,6 +2077,15 @@ }, "revision": { "type": "integer" + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/$defs/WorkspaceRole" + } + }, + "rolesSeeded": { + "type": "boolean" } }, "required": [ @@ -1944,7 +2093,9 @@ "folders", "groups", "locations", - "revision" + "revision", + "roles", + "rolesSeeded" ] }, "WorkspaceFolder": { diff --git a/docs/api.md b/docs/api.md index 1ee8fd2..2e1c48e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,7 +4,7 @@ 空间结构使用 `GET /spaces/{id}/structure`,包含 revision、channels、groups、folders、locations。频道与分组分别通过 `/spaces/{id}/channels` 和 `/spaces/{id}/groups` 创建;响应同时包含完整结构快照和服务端新建实体及 ID。其他字段通过 PATCH 重命名;频道归属通过 `PUT /spaces/{id}/channels/{channelId}/group` 修改。文件夹通过 `/spaces/{id}/folders` 创建及 PATCH 重命名,文件归属通过 `PUT /spaces/{id}/locations` 修改。 频道、分组和目录写操作需要账号会话和空间写权限;允许上传的成员可以关联和移动自己的资源,文件移动必须满足每个条目的作者或管理权限,跨空间、跨频道的错误分组/目录关联被拒绝。结构修改后通知订阅设备重新拉取。 -URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `8.1.0`(8.1 新增 `DELETE /spaces/{id}/groups/{groupId}`:删除分组,组内频道回到未分组)。v8 让频道强制登录:去掉未登录访客(设置 `allow_guests`、`/guests` 接口、访客终端席位),频道与空间的所有接口都要求账号会话,缺少时返回 `401 account_required`;便捷分享(`/shares`)不受影响。v7 把「设备」收束为账号的属性:成员只按用户管理,`seats` 系列命名改为 `max_members` / `members_used` / `members_full`,去掉设备名与设备清单接口,登录账号不再需要终端密钥。v6 引入账号签名根密钥、设备证书和频道对象签名事件;不兼容的旧主版本客户端会被契约预检拒绝。账号资料与空间结构由服务端管理。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;频道链接只对登录账号有效,未登录的人先登录再按加入规则加入。 +URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `8.2.0`(8.2 新增身份组 `/spaces/{id}/roles`:结构快照多了 `roles`、`rolesSeeded`,内容事件记录多了 `signerAccount`;8.1 新增 `DELETE /spaces/{id}/groups/{groupId}`:删除分组,组内频道回到未分组)。v8 让频道强制登录:去掉未登录访客(设置 `allow_guests`、`/guests` 接口、访客终端席位),频道与空间的所有接口都要求账号会话,缺少时返回 `401 account_required`;便捷分享(`/shares`)不受影响。v7 把「设备」收束为账号的属性:成员只按用户管理,`seats` 系列命名改为 `max_members` / `members_used` / `members_full`,去掉设备名与设备清单接口,登录账号不再需要终端密钥。v6 引入账号签名根密钥、设备证书和频道对象签名事件;不兼容的旧主版本客户端会被契约预检拒绝。账号资料与空间结构由服务端管理。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;频道链接只对登录账号有效,未登录的人先登录再按加入规则加入。 `GET /info` 的 `defaults` 包含 `channel`、`share` 创建设置;省略创建请求的 settings 时服务端使用相同默认值。`choices.max_members` 与 `limits.max_members` 表示成员人数范围,不代表设备数。频道设置 JSON 字段为 `max_members`(旧存储里的 `seats` 在启动时迁移)。其他选项按部署限制收束,前端只格式化选项文案,不提供替代业务默认值。 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index d1ce4b4..a65ab68 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: WebDrop API - version: 8.1.0 + version: 8.2.0 servers: - url: /api/v1 @@ -322,6 +322,147 @@ paths: '401': { $ref: '#/components/responses/Error' } '403': { $ref: '#/components/responses/Error' } '404': { $ref: '#/components/responses/Error' } + /spaces/{id}/roles: + post: + tags: [channel] + summary: 新建身份组(只有空间主) + parameters: + - name: id + in: path + required: true + schema: { type: string } + - name: X-WD-Account + in: header + required: true + schema: { type: string } + - $ref: '#/components/parameters/RoleWriter' + - $ref: '#/components/parameters/Account' + - name: Authorization + in: header + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceRoleRequest' } + responses: + '201': + description: 完整结构快照和新建的身份组 + content: + application/json: + schema: + $ref: '../contracts/api.schemas.json#/$defs/WorkspaceRoleCreatedBody' + '400': { $ref: '#/components/responses/Error' } + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + /spaces/{id}/roles/{roleId}: + patch: + tags: [channel] + summary: 修改身份组的名称、前缀后缀、颜色和权限(只有空间主) + parameters: + - name: id + in: path + required: true + schema: { type: string } + - name: roleId + in: path + required: true + schema: { type: string, format: uuid } + - name: X-WD-Account + in: header + required: true + schema: { type: string } + - $ref: '#/components/parameters/RoleWriter' + - $ref: '#/components/parameters/Account' + - name: Authorization + in: header + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceRoleRequest' } + responses: + '200': + description: 服务端权威数据 + content: + application/json: + schema: + $ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody' + '400': { $ref: '#/components/responses/Error' } + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + delete: + tags: [channel] + summary: 删除身份组(只有空间主);其成员失去它授予的权限 + parameters: + - name: id + in: path + required: true + schema: { type: string } + - name: roleId + in: path + required: true + schema: { type: string, format: uuid } + - name: X-WD-Account + in: header + required: true + schema: { type: string } + - $ref: '#/components/parameters/RoleWriter' + - $ref: '#/components/parameters/Account' + - name: Authorization + in: header + schema: { type: string } + responses: + '200': + description: 服务端权威数据 + content: + application/json: + schema: + $ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody' + '400': { $ref: '#/components/responses/Error' } + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + /spaces/{id}/roles/{roleId}/members: + put: + tags: [channel] + summary: 整体替换身份组的成员(只有空间主);成员必须已加入空间 + parameters: + - name: id + in: path + required: true + schema: { type: string } + - name: roleId + in: path + required: true + schema: { type: string, format: uuid } + - name: X-WD-Account + in: header + required: true + schema: { type: string } + - $ref: '#/components/parameters/RoleWriter' + - $ref: '#/components/parameters/Account' + - name: Authorization + in: header + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceRoleMembersRequest' } + responses: + '200': + description: 服务端权威数据 + content: + application/json: + schema: + $ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody' + '400': { $ref: '#/components/responses/Error' } + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } /spaces/{id}/folders: post: tags: [channel] diff --git a/server/src/WebDrop.Api/ApiContract.cs b/server/src/WebDrop.Api/ApiContract.cs index 49502fd..02c6bd0 100644 --- a/server/src/WebDrop.Api/ApiContract.cs +++ b/server/src/WebDrop.Api/ApiContract.cs @@ -6,5 +6,5 @@ public static class ApiContract // 去掉设备名与设备清单接口。设备只是账号的属性。 // v8: 频道强制登录,去掉未登录访客(allow_guests、/guests、终端席位);便捷分享不受影响。 // Keep this aligned with contracts/api-contract.json and docs/openapi.yaml. - public const string Version = "8.1.0"; + public const string Version = "8.2.0"; } diff --git a/server/src/WebDrop.Api/ApiJson.cs b/server/src/WebDrop.Api/ApiJson.cs index ef3e095..371ef12 100644 --- a/server/src/WebDrop.Api/ApiJson.cs +++ b/server/src/WebDrop.Api/ApiJson.cs @@ -172,6 +172,20 @@ public sealed class WorkspaceChannelRequest [JsonPropertyName("name")] public required string Name { get; init; } [JsonPropertyName("groupId")] public string? GroupId { get; init; } } +public sealed class WorkspaceRoleRequest +{ + [JsonPropertyName("name")] public required string Name { get; init; } + [JsonPropertyName("prefix")] public string? Prefix { get; init; } + [JsonPropertyName("suffix")] public string? Suffix { get; init; } + [JsonPropertyName("color")] public string? Color { get; init; } + [JsonPropertyName("permissions")] public required List Permissions { get; init; } +} +public sealed class WorkspaceRoleMembersRequest { [JsonPropertyName("members")] public required List Members { get; init; } } +public sealed class WorkspaceRoleCreatedBody +{ + [JsonPropertyName("workspace")] public required WorkspaceBody Workspace { get; init; } + [JsonPropertyName("role")] public required WorkspaceRole Role { get; init; } +} public sealed class WorkspaceGroupRequest { [JsonPropertyName("groupId")] public string? GroupId { get; init; } } public sealed class WorkspaceFolderRequest { @@ -222,6 +236,9 @@ public sealed class WorkspaceGroupCreatedBody [JsonSerializable(typeof(WorkspaceNameRequest))] [JsonSerializable(typeof(WorkspaceChannelRequest))] [JsonSerializable(typeof(WorkspaceGroupRequest))] +[JsonSerializable(typeof(WorkspaceRoleRequest))] +[JsonSerializable(typeof(WorkspaceRoleMembersRequest))] +[JsonSerializable(typeof(WorkspaceRoleCreatedBody))] [JsonSerializable(typeof(WorkspaceFolderRequest))] [JsonSerializable(typeof(WorkspaceMoveRequest))] [JsonSerializable(typeof(GoneLogBody))] diff --git a/server/src/WebDrop.Api/Endpoints.Workspace.cs b/server/src/WebDrop.Api/Endpoints.Workspace.cs index 79d4075..ab839a1 100644 --- a/server/src/WebDrop.Api/Endpoints.Workspace.cs +++ b/server/src/WebDrop.Api/Endpoints.Workspace.cs @@ -44,6 +44,24 @@ public static partial class Endpoints }); api.MapDelete("/spaces/{id}/groups/{groupId}", (string id, string groupId, HttpContext ctx, ChannelService svc) => Json(View(svc.DeleteWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId)), ApiJson.Default.WorkspaceBody)); + api.MapPost("/spaces/{id}/roles", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) => + { + var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceRoleRequest, ct); + var created = svc.CreateWorkspaceRoleResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name, req.Prefix, req.Suffix, req.Color, req.Permissions); + return Json(new WorkspaceRoleCreatedBody { Workspace = View(created.State), Role = created.Role }, ApiJson.Default.WorkspaceRoleCreatedBody, 201); + }); + api.MapPatch("/spaces/{id}/roles/{roleId}", async (string id, string roleId, HttpContext ctx, ChannelService svc, CancellationToken ct) => + { + var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceRoleRequest, ct); + return Json(View(svc.UpdateWorkspaceRole(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId, req.Name, req.Prefix, req.Suffix, req.Color, req.Permissions)), ApiJson.Default.WorkspaceBody); + }); + api.MapDelete("/spaces/{id}/roles/{roleId}", (string id, string roleId, HttpContext ctx, ChannelService svc) => + Json(View(svc.DeleteWorkspaceRole(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId)), ApiJson.Default.WorkspaceBody)); + api.MapPut("/spaces/{id}/roles/{roleId}/members", async (string id, string roleId, HttpContext ctx, ChannelService svc, CancellationToken ct) => + { + var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceRoleMembersRequest, ct); + return Json(View(svc.SetWorkspaceRoleMembers(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId, req.Members)), 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); diff --git a/server/src/WebDrop.Core/ChannelAdmission.cs b/server/src/WebDrop.Core/ChannelAdmission.cs index 593cbd6..7a0378d 100644 --- a/server/src/WebDrop.Core/ChannelAdmission.cs +++ b/server/src/WebDrop.Core/ChannelAdmission.cs @@ -30,10 +30,18 @@ public sealed partial class ChannelService public IReadOnlyList Invitations(Id16 id, Credentials creds) => store.Read(tx => { var (_, principal) = Authenticate(tx, id, creds, Membership.None); - RequireOwner(principal); + if (principal.Role != Role.Owner) RequireMemberPermission(tx, id, creds, WorkspacePermission.RemoveMembers); return tx.ListInvitations(id); }); + /// 非空间主的调用方:必须是已加入的成员,且所在身份组被授予该权限 + private Principal RequireMemberPermission(IResourceTx tx, Id16 id, Credentials creds, string permission) + { + var (_, principal) = Authenticate(tx, id, creds, Membership.Required); + if (!HasWorkspacePermission(tx.GetWorkspace(id), principal, permission)) throw new WebDropException(ErrorCode.Forbidden); + return principal; + } + public IReadOnlyList UserInvitations(string username) => store.Read(tx => tx.ListUserInvitations(username)); public void RevokeInvitation(Id16 id, Credentials creds, string username) @@ -41,8 +49,13 @@ public sealed partial class ChannelService store.Write(tx => { var (_, principal) = Authenticate(tx, id, creds, Membership.None); - RequireOwner(principal); if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden); + if (principal.Role != Role.Owner) + { + // 被授权的成员不能移除自己,也不能移除同样有移除权限的人 + var acting = RequireMemberPermission(tx, id, creds, WorkspacePermission.RemoveMembers); + if (username == acting.AccountName || HasWorkspacePermission(tx.GetWorkspace(id), new Principal(Role.Write, username), WorkspacePermission.RemoveMembers)) throw new WebDropException(ErrorCode.Forbidden); + } tx.DeleteInvitation(id, username); tx.DeleteKeyPackage(id, username); tx.DeleteChannelMember(id, username); diff --git a/server/src/WebDrop.Core/ChannelWorkspace.cs b/server/src/WebDrop.Core/ChannelWorkspace.cs index 8617131..3462da8 100644 --- a/server/src/WebDrop.Core/ChannelWorkspace.cs +++ b/server/src/WebDrop.Core/ChannelWorkspace.cs @@ -15,12 +15,22 @@ public sealed partial class ChannelService private static WorkspaceState NewWorkspace(string username, long now) { var id = Guid.NewGuid().ToString(); - return new() { Revision = 1, DefaultChannelId = id, Channels = [new WorkspaceChannel { Id = id, Name = "常规", CreatedAt = now, CreatedBy = username }] }; + var state = new WorkspaceState { Revision = 1, DefaultChannelId = id, Channels = [new WorkspaceChannel { Id = id, Name = "常规", CreatedAt = now, CreatedBy = username }] }; + EnsureDefaultRole(state, now); + return state; + } + /// 补上默认身份组(只补一次)。读取时只在内存里补,下一次写入才落库 + private static void EnsureDefaultRole(WorkspaceState state, long now) + { + if (state.RolesSeeded) return; + if (!state.Roles.Any(r => r.Id == WorkspaceRole.DefaultId)) state.Roles.Insert(0, WorkspaceRole.CreateDefault(now)); + state.RolesSeeded = true; } public WorkspaceState GetWorkspace(Id16 id, Credentials creds) => store.Read(tx => { var (space, principal) = Authenticate(tx, id, creds, Membership.Required); var state = tx.GetWorkspace(id) ?? new WorkspaceState(); + EnsureDefaultRole(state, clock.NowMs); if (principal.AccountName != space.CreatorAccount) { var crypto = tx.GetWorkspaceCrypto(id); @@ -38,14 +48,17 @@ public sealed partial class ChannelService state.Locations = state.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is { } item && item.ExpiresAt > clock.NowMs).ToList(); return state; }); - private WorkspaceState ChangeWorkspace(Id16 id, Credentials creds, Action change, bool requireAccount = true) + private WorkspaceState ChangeWorkspace(Id16 id, Credentials creds, Action change, bool requireAccount = true, string? permission = null) { var state = store.Write(tx => { 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(); + EnsureDefaultRole(current, clock.NowMs); + // 被授予对应权限的身份组成员可以代行,不要求频道开放成员上传 + bool delegated = permission is not null && principal.Role != Role.Owner && HasWorkspacePermission(current, principal, permission); + if (requireAccount && principal.Role != Role.Owner && !delegated) throw new WebDropException(ErrorCode.Forbidden); + if (!delegated && !ChannelPolicy.CanWrite(channel, principal)) throw new WebDropException(ErrorCode.Forbidden); current.Locations = current.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is not null).ToList(); change(tx, current, principal); ValidateWorkspace(current); @@ -68,9 +81,9 @@ public sealed partial class ChannelService } public WorkspaceState CreateWorkspaceChannel(Id16 id, Credentials creds, string name, string? groupId) => CreateWorkspaceChannelResult(id, creds, name, groupId).State; public WorkspaceState RenameWorkspaceChannel(Id16 id, Credentials creds, string channelId, string name) => ChangeWorkspace(id, creds, (_, state, _) => - FindChannel(state, channelId).Name = WorkspaceName(name)); + FindChannel(state, channelId).Name = WorkspaceName(name), permission: WorkspacePermission.ManageStructure); public WorkspaceState GroupWorkspaceChannel(Id16 id, Credentials creds, string channelId, string? groupId) => ChangeWorkspace(id, creds, (_, state, _) => - FindChannel(state, channelId).GroupId = groupId); + FindChannel(state, channelId).GroupId = groupId, permission: WorkspacePermission.ManageStructure); public (WorkspaceState State, WorkspaceGroup Group) CreateWorkspaceGroupResult(Id16 id, Credentials creds, string name) { WorkspaceGroup? created = null; @@ -78,19 +91,64 @@ public sealed partial class ChannelService { created = new WorkspaceGroup { Id = Guid.NewGuid().ToString(), Name = WorkspaceName(name), CreatedAt = clock.NowMs }; workspace.Groups.Add(created); - }); + }, permission: WorkspacePermission.ManageStructure); return (state, created!); } public WorkspaceState CreateWorkspaceGroup(Id16 id, Credentials creds, string name) => CreateWorkspaceGroupResult(id, creds, name).State; public WorkspaceState RenameWorkspaceGroup(Id16 id, Credentials creds, string groupId, string name) => ChangeWorkspace(id, creds, (_, state, _) => - (state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name)); + (state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name), permission: WorkspacePermission.ManageStructure); /// 删除分组:组里的频道回到未分组,频道本身和内容都不动 public WorkspaceState DeleteWorkspaceGroup(Id16 id, Credentials creds, string groupId) => ChangeWorkspace(id, creds, (_, state, _) => { var group = state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound); foreach (var channel in state.Channels.Where(c => c.GroupId == groupId)) channel.GroupId = null; state.Groups.Remove(group); - }); + }, permission: WorkspacePermission.ManageStructure); + // ───── 身份组(只有空间主能增删改、分配成员) ───── + public static bool HasWorkspacePermission(WorkspaceState? state, Principal principal, string permission) => + principal.Role == Role.Owner + || principal.AccountName is { } account && state?.Roles.Any(r => r.Members.Contains(account) && r.Permissions.Contains(permission)) == true; + private static string? RoleLabel(string? value) + { + value = value?.Trim(); + if (string.IsNullOrEmpty(value)) return null; + if (value.Length > 12 || value.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest); + return value; + } + private static void ApplyRole(WorkspaceRole role, string name, string? prefix, string? suffix, string? color, IReadOnlyList permissions) + { + if (color is not null && !WorkspacePermission.Colors.Contains(color)) throw new WebDropException(ErrorCode.BadRequest); + if (permissions.Any(p => !WorkspacePermission.All.Contains(p))) throw new WebDropException(ErrorCode.BadRequest); + role.Name = WorkspaceName(name); role.Prefix = RoleLabel(prefix); role.Suffix = RoleLabel(suffix); role.Color = color; + role.Permissions = permissions.Distinct().ToList(); + } + public (WorkspaceState State, WorkspaceRole Role) CreateWorkspaceRoleResult(Id16 id, Credentials creds, string name, string? prefix, string? suffix, string? color, IReadOnlyList permissions) + { + WorkspaceRole? created = null; + var state = ChangeWorkspace(id, creds, (_, workspace, _) => + { + created = new WorkspaceRole { Id = Guid.NewGuid().ToString(), Name = "x", CreatedAt = clock.NowMs }; + ApplyRole(created, name, prefix, suffix, color, permissions); + workspace.Roles.Add(created); + }); + return (state, created!); + } + public WorkspaceState UpdateWorkspaceRole(Id16 id, Credentials creds, string roleId, string name, string? prefix, string? suffix, string? color, IReadOnlyList permissions) => + ChangeWorkspace(id, creds, (_, state, _) => ApplyRole(FindRole(state, roleId), name, prefix, suffix, color, permissions)); + public WorkspaceState DeleteWorkspaceRole(Id16 id, Credentials creds, string roleId) => + ChangeWorkspace(id, creds, (_, state, _) => state.Roles.Remove(FindRole(state, roleId))); + public WorkspaceState SetWorkspaceRoleMembers(Id16 id, Credentials creds, string roleId, IReadOnlyList members) => + ChangeWorkspace(id, creds, (tx, state, _) => + { + var role = FindRole(state, roleId); + var distinct = members.Select(m => m.Trim().ToLowerInvariant()).Distinct().ToList(); + if (distinct.Count > 256) throw new WebDropException(ErrorCode.BadRequest); + // 只能把已加入空间的账号放进身份组;空间主本来就有全部权限,不必加入 + foreach (var member in distinct) + if (tx.GetChannelMember(id, member) is not { } row || row.Role == "owner") throw new WebDropException(ErrorCode.BadRequest); + role.Members = distinct; + }); + private static WorkspaceRole FindRole(WorkspaceState state, string id) => state.Roles.Find(r => r.Id == id) ?? throw new WebDropException(ErrorCode.NotFound); public WorkspaceState CreateWorkspaceFolder(Id16 id, Credentials creds, string threadId, string? parentId, string name) => ChangeWorkspace(id, creds, (_, state, _) => state.Folders.Add(new WorkspaceFolder { Id = Guid.NewGuid().ToString(), ThreadId = threadId, ParentId = parentId, Name = WorkspaceName(name) })); public WorkspaceState RenameWorkspaceFolder(Id16 id, Credentials creds, string folderId, string name) => ChangeWorkspace(id, creds, (_, state, _) => @@ -116,11 +174,12 @@ public sealed partial class ChannelService } private static void ValidateWorkspace(WorkspaceState state) { + if (state.Roles is null || state.Roles.Count > 32 || state.Roles.Any(r => r is null || r.Members is null || r.Permissions is null)) throw new WebDropException(ErrorCode.BadRequest); if (state.Channels is null || state.Groups is null || state.Folders is null || state.Locations is null || state.Channels.Count > 256 || state.Groups.Count > 128 || state.Folders.Count > 2048 || state.Locations.Count > 10_000) throw new WebDropException(ErrorCode.BadRequest); if (state.Channels.Any(c => c is null) || state.Groups.Any(g => g is null) || state.Folders.Any(f => f is null) || state.Locations.Any(l => l is null)) throw new WebDropException(ErrorCode.BadRequest); - var allIds = state.Channels.Select(c => c.Id).Concat(state.Groups.Select(g => g.Id)).Concat(state.Folders.Select(f => f.Id)).ToArray(); + var allIds = state.Channels.Select(c => c.Id).Concat(state.Groups.Select(g => g.Id)).Concat(state.Folders.Select(f => f.Id)).Concat(state.Roles.Select(r => r.Id)).ToArray(); if (allIds.Any(id => !Guid.TryParseExact(id, "D", out _)) || allIds.Distinct().Count() != allIds.Length) throw new WebDropException(ErrorCode.BadRequest); foreach (var group in state.Groups) group.Name = WorkspaceName(group.Name); foreach (var channel in state.Channels) diff --git a/server/src/WebDrop.Core/Workspace.cs b/server/src/WebDrop.Core/Workspace.cs index ca15d68..a3b4063 100644 --- a/server/src/WebDrop.Core/Workspace.cs +++ b/server/src/WebDrop.Core/Workspace.cs @@ -35,12 +35,47 @@ public sealed class WorkspaceLocation [JsonPropertyName("folderId")] public string? FolderId { get; init; } [JsonPropertyName("authorAccount")] public string? AuthorAccount { get; init; } } +/// 身份组可授予的权限。空间主拥有全部权限,不属于任何身份组 +public static class WorkspacePermission +{ + /// 移除成员、撤销邀请 + public const string RemoveMembers = "members.remove"; + /// 重命名频道、调整频道所属分组、管理分组(新建频道涉及密钥,仍只有空间主) + public const string ManageStructure = "structure.manage"; + public static readonly IReadOnlyList All = [RemoveMembers, ManageStructure]; + public static readonly IReadOnlyList Colors = ["gray", "blue", "green", "amber", "red", "purple"]; +} +public sealed class WorkspaceRole +{ + /// 每个空间都有的默认「管理组」,id 固定,老空间第一次读写时补上 + public const string DefaultId = "00000000-0000-4000-8000-000000000001"; + public static WorkspaceRole CreateDefault(long now) => new() + { + Id = DefaultId, Name = "管理组", Suffix = "管理员", Color = "blue", CreatedAt = now, + Permissions = [.. WorkspacePermission.All], + }; + + [JsonPropertyName("id")] public required string Id { get; init; } + [JsonPropertyName("name")] public required string Name { get; set; } + /// 聊天里显示在名字前、后的文字(如「频道主」「管理员」),可只填其一 + [JsonPropertyName("prefix")] public string? Prefix { get; set; } + [JsonPropertyName("suffix")] public string? Suffix { get; set; } + [JsonPropertyName("color")] public string? Color { get; set; } + [JsonPropertyName("permissions")] public List Permissions { get; set; } = []; + [JsonPropertyName("members")] public List Members { get; set; } = []; + [JsonPropertyName("createdAt")] public required long CreatedAt { get; init; } +} public sealed class WorkspaceState { [JsonPropertyName("defaultChannelId")] public string? DefaultChannelId { get; init; } [JsonPropertyName("revision")] public long Revision { get; set; } [JsonPropertyName("channels")] public List Channels { get; init; } = []; [JsonPropertyName("groups")] public List Groups { get; init; } = []; + /// 旧版本存下来的结构里没有 roles,反序列化后是 null,这里统一当作空列表 + [JsonPropertyName("roles")] public List Roles { get => _roles ??= []; init => _roles = value; } + private List? _roles; + /// 默认身份组已经放进来过。之后空间主删掉它不会再自动出现 + [JsonPropertyName("rolesSeeded")] public bool RolesSeeded { get; set; } [JsonPropertyName("folders")] public List Folders { get; init; } = []; [JsonPropertyName("locations")] public List Locations { get; set; } = []; } diff --git a/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs b/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs index 2843e4e..c66ed69 100644 --- a/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs +++ b/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs @@ -68,4 +68,76 @@ public sealed class WorkspaceStructureTests Assert.Equal(ErrorCode.Forbidden, Assert.Throws(() => env.Service.CreateWorkspaceGroup(ch.Id, guest, "成员分组")).Code); } + // ───── 身份组 ───── + private static (TestEnv Env, TestChannel Ch, Credentials Owner, Credentials Alice, Credentials Bob, Credentials Carol) RoleSpace() + { + var env = new TestEnv(); var ownerEp = TestEnv.Endpoint(); var ch = env.Create(ownerEp, settings: new ChannelSettings { MaxMembers = 10 }); + Credentials Join(string name) { var creds = ch.Member(TestEnv.Endpoint()) with { AccountName = name }; env.Service.Join(ch.Id, creds); return creds; } + return (env, ch, ch.Owner(ownerEp), Join("alice"), Join("bob"), Join("carol")); + } + [Fact] + public void OnlyTheOwnerManagesRolesAndMembersMustHaveJoined() + { + var (env, ch, owner, alice, _, _) = RoleSpace(); using var _env = env; + var created = env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "管理组", null, "管理员", "blue", [WorkspacePermission.RemoveMembers]); + Assert.Throws(() => env.Service.CreateWorkspaceRoleResult(ch.Id, alice, "越权", null, null, null, [])); + Assert.Throws(() => env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "坏权限", null, null, null, ["nope"])); + Assert.Throws(() => env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "坏颜色", null, null, "pink", [])); + Assert.Throws(() => env.Service.SetWorkspaceRoleMembers(ch.Id, owner, created.Role.Id, ["stranger"])); + Assert.Throws(() => env.Service.SetWorkspaceRoleMembers(ch.Id, alice, created.Role.Id, ["alice"])); + var state = env.Service.SetWorkspaceRoleMembers(ch.Id, owner, created.Role.Id, ["Alice", "alice"]); + var role = state.Roles.Single(r => r.Id == created.Role.Id); + Assert.Equal("管理员", role.Suffix); Assert.Equal(["alice"], role.Members); + // 默认的「管理组」一直在;删掉自己建的不影响它,删掉默认组后也不会再自动出现 + var remaining = env.Service.DeleteWorkspaceRole(ch.Id, owner, created.Role.Id); + Assert.Equal(WorkspaceRole.DefaultId, Assert.Single(remaining.Roles).Id); + Assert.Empty(env.Service.DeleteWorkspaceRole(ch.Id, owner, WorkspaceRole.DefaultId).Roles); + Assert.Empty(env.Service.CreateWorkspaceChannel(ch.Id, owner, "再写一次", null).Roles); + } + [Fact] + public void StructureStoredBeforeRolesExistedStillLoadsWithAnEmptyRoleList() + { + var old = System.Text.Json.JsonSerializer.Deserialize("{\"revision\":3,\"channels\":[],\"groups\":[],\"folders\":[],\"locations\":[]}", WorkspaceJson.Default.WorkspaceState)!; + Assert.Empty(old.Roles); Assert.False(old.RolesSeeded); + } + [Fact] + public void EverySpaceStartsWithADefaultAdminGroupThatCanHoldMembers() + { + var (env, ch, owner, alice, bob, _) = RoleSpace(); using var _env = env; + var admin = Assert.Single(env.Service.GetWorkspace(ch.Id, owner).Roles); + Assert.Equal(WorkspaceRole.DefaultId, admin.Id); Assert.Equal("管理组", admin.Name); Assert.Empty(admin.Members); + Assert.Contains(WorkspacePermission.RemoveMembers, admin.Permissions); Assert.Contains(WorkspacePermission.ManageStructure, admin.Permissions); + env.Service.SetWorkspaceRoleMembers(ch.Id, owner, admin.Id, ["alice"]); + env.Service.RevokeInvitation(ch.Id, alice, "bob"); // 加入默认管理组即可移除成员 + Assert.Throws(() => env.Service.RevokeInvitation(ch.Id, bob, "alice")); + } + [Fact] + public void ARoleWithRemovePermissionCanKickOrdinaryMembersOnly() + { + var (env, ch, owner, alice, bob, carol) = RoleSpace(); using var _env = env; + var peer = env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "管理组", null, "管理员", "blue", [WorkspacePermission.RemoveMembers]).Role; + env.Service.SetWorkspaceRoleMembers(ch.Id, owner, peer.Id, ["alice", "carol"]); + Assert.Throws(() => env.Service.RevokeInvitation(ch.Id, bob, "alice")); // 没有权限 + Assert.Throws(() => env.Service.RevokeInvitation(ch.Id, alice, "owner")); // 不能踢空间主 + Assert.Throws(() => env.Service.RevokeInvitation(ch.Id, alice, "carol")); // 不能踢同样有权限的人 + Assert.Throws(() => env.Service.RevokeInvitation(ch.Id, alice, "alice")); // 不能踢自己 + env.Service.RevokeInvitation(ch.Id, alice, "bob"); + Assert.DoesNotContain(env.Service.Members(ch.Id, owner), member => member.Username == "bob"); + _ = carol; + } + [Fact] + public void ARoleWithStructurePermissionManagesGroupsButCannotCreateChannels() + { + var (env, ch, owner, alice, bob, _) = RoleSpace(); using var _env = env; + var role = env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "整理组", "【", "整理】", "green", [WorkspacePermission.ManageStructure]).Role; + env.Service.SetWorkspaceRoleMembers(ch.Id, owner, role.Id, ["alice"]); + var channel = env.Service.GetWorkspace(ch.Id, owner).Channels.Single(); + Assert.Throws(() => env.Service.CreateWorkspaceGroup(ch.Id, bob, "越权")); + var group = env.Service.CreateWorkspaceGroup(ch.Id, alice, "项目").Groups.Single(); + env.Service.RenameWorkspaceChannel(ch.Id, alice, channel.Id, "改名"); + env.Service.GroupWorkspaceChannel(ch.Id, alice, channel.Id, group.Id); + Assert.Empty(env.Service.DeleteWorkspaceGroup(ch.Id, alice, group.Id).Groups); + Assert.Throws(() => env.Service.CreateWorkspaceChannel(ch.Id, alice, "新频道", null)); // 新建频道涉及密钥,只有空间主 + Assert.Throws(() => env.Service.CreateWorkspaceRoleResult(ch.Id, alice, "自封", null, null, null, [])); + } } diff --git a/web/src/core-bridge/generated/api.ts b/web/src/core-bridge/generated/api.ts index 349bded..f7e2691 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: 22853abd6c1bf74053682018cd214fecb34c664e4abe043a15c1595566419350 +// backend-contract-sha256: 2a28bf52c136caa7c0b86fc2630153cb71529e3c5d85c3b7196e9d9a04d63b75 export interface AccountChannelBody { channel_id: string; @@ -398,6 +398,30 @@ export interface WorkspaceMoveRequest { export interface WorkspaceNameRequest { name: string; } +export interface WorkspaceRoleCreatedBody { + role: WorkspaceRole; + workspace: WorkspaceBody; +} +export interface WorkspaceRole { + color?: string | null; + createdAt: number; + id: string; + members: string[]; + name: string; + permissions: string[]; + prefix?: string | null; + suffix?: string | null; +} +export interface WorkspaceRoleMembersRequest { + members: string[]; +} +export interface WorkspaceRoleRequest { + color?: string | null; + name: string; + permissions: string[]; + prefix?: string | null; + suffix?: string | null; +} export interface WorkspaceState { channels: WorkspaceChannel[]; defaultChannelId?: string | null; @@ -405,6 +429,8 @@ export interface WorkspaceState { groups: WorkspaceGroup[]; locations: WorkspaceLocation[]; revision: number; + roles: WorkspaceRole[]; + rolesSeeded: boolean; } export interface WorkspaceFolder { id: string;