feat(api): delete workspace groups and record who signed a content event
This commit is contained in:
1 parent
55f1b70d89
commit
fc1dc08ebe
11 files changed
+80
-7
No files matched your search
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "8.0.0"
|
"version": "8.1.0"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"x-backend-contract-sha256": "655e918f3443073c270bdeb9ae4e69d79dacf83baa90dea1a0cdcfe71d2b8bd5",
|
"x-backend-contract-sha256": "22853abd6c1bf74053682018cd214fecb34c664e4abe043a15c1595566419350",
|
||||||
"$defs": {
|
"$defs": {
|
||||||
"AccountChannelBody": {
|
"AccountChannelBody": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1616,6 +1616,16 @@
|
|||||||
"signature": {
|
"signature": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"signerAccount": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"signerDeviceId": {
|
"signerDeviceId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -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` 修改。
|
空间结构使用 `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.0.0`。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.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;频道链接只对登录账号有效,未登录的人先登录再按加入规则加入。
|
||||||
|
|
||||||
`GET /info` 的 `defaults` 包含 `channel`、`share` 创建设置;省略创建请求的 settings 时服务端使用相同默认值。`choices.max_members` 与 `limits.max_members` 表示成员人数范围,不代表设备数。频道设置 JSON 字段为 `max_members`(旧存储里的 `seats` 在启动时迁移)。其他选项按部署限制收束,前端只格式化选项文案,不提供替代业务默认值。
|
`GET /info` 的 `defaults` 包含 `channel`、`share` 创建设置;省略创建请求的 settings 时服务端使用相同默认值。`choices.max_members` 与 `limits.max_members` 表示成员人数范围,不代表设备数。频道设置 JSON 字段为 `max_members`(旧存储里的 `seats` 在启动时迁移)。其他选项按部署限制收束,前端只格式化选项文案,不提供替代业务默认值。
|
||||||
|
|
||||||
|
|||||||
+32
-1
@@ -1,7 +1,7 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: WebDrop API
|
title: WebDrop API
|
||||||
version: 8.0.0
|
version: 8.1.0
|
||||||
servers:
|
servers:
|
||||||
- url: /api/v1
|
- url: /api/v1
|
||||||
|
|
||||||
@@ -291,6 +291,37 @@ paths:
|
|||||||
'403': { $ref: '#/components/responses/Error' }
|
'403': { $ref: '#/components/responses/Error' }
|
||||||
'404': { $ref: '#/components/responses/Error' }
|
'404': { $ref: '#/components/responses/Error' }
|
||||||
'409': { $ref: '#/components/responses/Error' }
|
'409': { $ref: '#/components/responses/Error' }
|
||||||
|
delete:
|
||||||
|
tags: [channel]
|
||||||
|
summary: 删除分组;组内频道回到未分组,频道与内容保留
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
- name: groupId
|
||||||
|
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'
|
||||||
|
'401': { $ref: '#/components/responses/Error' }
|
||||||
|
'403': { $ref: '#/components/responses/Error' }
|
||||||
|
'404': { $ref: '#/components/responses/Error' }
|
||||||
/spaces/{id}/folders:
|
/spaces/{id}/folders:
|
||||||
post:
|
post:
|
||||||
tags: [channel]
|
tags: [channel]
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ public static class ApiContract
|
|||||||
// 去掉设备名与设备清单接口。设备只是账号的属性。
|
// 去掉设备名与设备清单接口。设备只是账号的属性。
|
||||||
// v8: 频道强制登录,去掉未登录访客(allow_guests、/guests、终端席位);便捷分享不受影响。
|
// v8: 频道强制登录,去掉未登录访客(allow_guests、/guests、终端席位);便捷分享不受影响。
|
||||||
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
|
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
|
||||||
public const string Version = "8.0.0";
|
public const string Version = "8.1.0";
|
||||||
}
|
}
|
||||||
@@ -42,6 +42,8 @@ public static partial class Endpoints
|
|||||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
||||||
return Json(View(svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), 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.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}/folders", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
api.MapPost("/spaces/{id}/folders", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFolderRequest, ct);
|
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFolderRequest, ct);
|
||||||
|
|||||||
@@ -568,7 +568,7 @@ public sealed partial class ChannelService(
|
|||||||
{
|
{
|
||||||
EventId = Codec.Encode(signedEvent.EventIdBytes), Kind = signedEvent.EventKind, FrameCount = signedEvent.FrameCount,
|
EventId = Codec.Encode(signedEvent.EventIdBytes), Kind = signedEvent.EventKind, FrameCount = signedEvent.FrameCount,
|
||||||
ManifestHash = Convert.ToBase64String(signedEvent.ManifestHash).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
|
ManifestHash = Convert.ToBase64String(signedEvent.ManifestHash).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
|
||||||
SignerDeviceId = signedEvent.DeviceId,
|
SignerDeviceId = signedEvent.DeviceId, SignerAccount = signedEvent.Username,
|
||||||
Signature = Convert.ToBase64String(signedEvent.Signature).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
|
Signature = Convert.ToBase64String(signedEvent.Signature).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
|
||||||
TargetItemId = signedEvent.TargetItemId,
|
TargetItemId = signedEvent.TargetItemId,
|
||||||
SignerRootPublicKey = signedEvent.RootPublicKey, SignerDevicePublicKey = signedEvent.DevicePublicKey,
|
SignerRootPublicKey = signedEvent.RootPublicKey, SignerDevicePublicKey = signedEvent.DevicePublicKey,
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ public sealed partial class ChannelService
|
|||||||
public WorkspaceState CreateWorkspaceGroup(Id16 id, Credentials creds, string name) => CreateWorkspaceGroupResult(id, creds, name).State;
|
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, _) =>
|
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));
|
||||||
|
/// <summary>删除分组:组里的频道回到未分组,频道本身和内容都不动</summary>
|
||||||
|
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);
|
||||||
|
});
|
||||||
public WorkspaceState CreateWorkspaceFolder(Id16 id, Credentials creds, string threadId, string? parentId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
|
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) }));
|
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, _) =>
|
public WorkspaceState RenameWorkspaceFolder(Id16 id, Credentials creds, string folderId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ public sealed class SignedContentEventRecord
|
|||||||
[JsonPropertyName("frameCount")] public required int FrameCount { get; init; }
|
[JsonPropertyName("frameCount")] public required int FrameCount { get; init; }
|
||||||
[JsonPropertyName("manifestHash")] public required string ManifestHash { get; init; }
|
[JsonPropertyName("manifestHash")] public required string ManifestHash { get; init; }
|
||||||
[JsonPropertyName("signerDeviceId")] public required string SignerDeviceId { get; init; }
|
[JsonPropertyName("signerDeviceId")] public required string SignerDeviceId { get; init; }
|
||||||
|
/// <summary>签名者的账号。空间主撤回别人的消息时,签名者不是作者;旧记录没有这个字段,按作者处理</summary>
|
||||||
|
[JsonPropertyName("signerAccount")] public string? SignerAccount { get; init; }
|
||||||
[JsonPropertyName("signature")] public required string Signature { get; init; }
|
[JsonPropertyName("signature")] public required string Signature { get; init; }
|
||||||
[JsonPropertyName("targetItemId")] public string? TargetItemId { get; init; }
|
[JsonPropertyName("targetItemId")] public string? TargetItemId { get; init; }
|
||||||
[JsonPropertyName("signerRootPublicKey")] public required string SignerRootPublicKey { get; init; }
|
[JsonPropertyName("signerRootPublicKey")] public required string SignerRootPublicKey { get; init; }
|
||||||
|
|||||||
@@ -45,6 +45,26 @@ public sealed class WorkspaceApiTests(ServerFixture server) : IClassFixture<Serv
|
|||||||
using var loaded = await server.Http.SendAsync(read); Assert.Equal(2, (await ReadJsonAsync(loaded)).GetProperty("structure").GetProperty("channels").GetArrayLength());
|
using var loaded = await server.Http.SendAsync(read); Assert.Equal(2, (await ReadJsonAsync(loaded)).GetProperty("structure").GetProperty("channels").GetArrayLength());
|
||||||
}
|
}
|
||||||
[Fact]
|
[Fact]
|
||||||
|
public async Task DeletingAGroupMovesItsChannelsBackToUngrouped()
|
||||||
|
{
|
||||||
|
var a = await server.CreateAsync();
|
||||||
|
using var createGroup = server.Req(HttpMethod.Post, $"/api/v1/spaces/{a.Id}/groups", a.Owner, JsonBody(new { name = "待删分组" }));
|
||||||
|
using var groupResponse = await server.Http.SendAsync(createGroup);
|
||||||
|
var groupId = (await ReadJsonAsync(groupResponse)).GetProperty("group").GetProperty("id").GetString();
|
||||||
|
using var createChannel = server.Req(HttpMethod.Post, $"/api/v1/spaces/{a.Id}/channels", a.Owner, JsonBody(new { name = "组内频道", groupId }));
|
||||||
|
using var channelResponse = await server.Http.SendAsync(createChannel); Assert.Equal(HttpStatusCode.Created, channelResponse.StatusCode);
|
||||||
|
|
||||||
|
using var delete = server.Req(HttpMethod.Delete, $"/api/v1/spaces/{a.Id}/groups/{groupId}", a.Owner);
|
||||||
|
using var deleted = await server.Http.SendAsync(delete); Assert.Equal(HttpStatusCode.OK, deleted.StatusCode);
|
||||||
|
var structure = (await ReadJsonAsync(deleted)).GetProperty("structure");
|
||||||
|
Assert.DoesNotContain(structure.GetProperty("groups").EnumerateArray(), g => g.GetProperty("id").GetString() == groupId);
|
||||||
|
var moved = structure.GetProperty("channels").EnumerateArray().Single(ch => ch.GetProperty("name").GetString() == "组内频道");
|
||||||
|
Assert.True(!moved.TryGetProperty("groupId", out var remaining) || remaining.ValueKind == System.Text.Json.JsonValueKind.Null);
|
||||||
|
|
||||||
|
using var again = server.Req(HttpMethod.Delete, $"/api/v1/spaces/{a.Id}/groups/{groupId}", a.Owner);
|
||||||
|
using var missing = await server.Http.SendAsync(again); Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
|
||||||
|
}
|
||||||
|
[Fact]
|
||||||
public async Task AdvertisedDefaultsRespectDeploymentLimitsAndMatchCreation()
|
public async Task AdvertisedDefaultsRespectDeploymentLimitsAndMatchCreation()
|
||||||
{
|
{
|
||||||
await using var isolated = WithSettings(new() { ["WebDrop:Limits:MaxMembers"] = "3", ["WebDrop:Limits:MinTtlSeconds"] = "120", ["WebDrop:Limits:MaxTtlSeconds"] = "3600" });
|
await using var isolated = WithSettings(new() { ["WebDrop:Limits:MaxMembers"] = "3", ["WebDrop:Limits:MinTtlSeconds"] = "120", ["WebDrop:Limits:MaxTtlSeconds"] = "3600" });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Generated from backend DTOs; do not edit.
|
// Generated from backend DTOs; do not edit.
|
||||||
// backend-contract-sha256: 655e918f3443073c270bdeb9ae4e69d79dacf83baa90dea1a0cdcfe71d2b8bd5
|
// backend-contract-sha256: 22853abd6c1bf74053682018cd214fecb34c664e4abe043a15c1595566419350
|
||||||
|
|
||||||
export interface AccountChannelBody {
|
export interface AccountChannelBody {
|
||||||
channel_id: string;
|
channel_id: string;
|
||||||
@@ -338,6 +338,7 @@ export interface SignedContentEventRecord {
|
|||||||
kind: string;
|
kind: string;
|
||||||
manifestHash: string;
|
manifestHash: string;
|
||||||
signature: string;
|
signature: string;
|
||||||
|
signerAccount?: string | null;
|
||||||
signerDeviceId: string;
|
signerDeviceId: string;
|
||||||
signerDevicePublicKey: string;
|
signerDevicePublicKey: string;
|
||||||
signerRootPublicKey: string;
|
signerRootPublicKey: string;
|
||||||
|
|||||||
Reference in new issue
Block a user