diff --git a/contracts/api.schemas.json b/contracts/api.schemas.json index d5402ee..8ec56a0 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": "654ec761e3481b423296b402497a97f4092ce167e7a2ca51a47d25f0fca4466e", + "x-backend-contract-sha256": "ba787115471d5d33fb0c33c95af014b5cd0d535455bd4bea037cfdcc4f048828", "$defs": { "AccountChannelBody": { "type": "object", @@ -1431,6 +1431,12 @@ "max_visitors": { "type": "integer" }, + "max_workspace_bytes": { + "type": "integer" + }, + "max_workspace_channel_bytes": { + "type": "integer" + }, "min_ttl_seconds": { "type": "integer" } @@ -1443,6 +1449,8 @@ "max_seats", "max_ttl_seconds", "max_visitors", + "max_workspace_bytes", + "max_workspace_channel_bytes", "min_ttl_seconds" ] }, @@ -1748,6 +1756,9 @@ "createdBy": { "type": "string" }, + "fileBytes": { + "type": "integer" + }, "filesEpoch": { "type": "integer" }, @@ -1776,6 +1787,7 @@ "chatHistory", "createdAt", "createdBy", + "fileBytes", "filesEpoch", "filesHistory", "id", diff --git a/docs/api.md b/docs/api.md index 3ad48ad..1268f30 100644 --- a/docs/api.md +++ b/docs/api.md @@ -57,7 +57,7 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w 签名根密钥保存在加密保险库;设备签名私钥以不可导出的 CryptoKey 保存在本机账户数据库,不随保险库同步。已登记签名设备写入 Workspace 对象时,FINAL 帧和删除请求必须带签名事件;尚未把签名要求扩展到频道结构和成员/权限变更。 ### `GET /api/v1/info` -返回服务端能力:协议版本、限制、验证码配置(`captcha.provider`、`captcha.enabled`、`captcha.api_endpoint`,即 Cap widget 要连接的地址)、传输能力以及创建默认值/选项。`defaults.channel_mode`、`defaults.channel` 和 `choices` 是前端创建表单的初始值与可选项;服务端独立校验提交的设置。验证码的 secret 不会出现在这里。`transports.http3` 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。 +返回服务端能力:协议版本、限制、验证码配置(`captcha.provider`、`captcha.enabled`、`captcha.api_endpoint`,即 Cap widget 要连接的地址)、传输能力以及创建默认值/选项。`limits.max_workspace_bytes` 是单个空间全部资源的上限,`limits.max_workspace_channel_bytes` 是单个频道 Files 域的文件容量上限;频道结构的 `fileBytes` 为当前已用容量。前端只展示服务端返回的用量和限制。`defaults.channel_mode`、`defaults.channel` 和 `choices` 是前端创建表单的初始值与可选项;服务端独立校验提交的设置。验证码的 secret 不会出现在这里。`transports.http3` 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。 ### `POST /api/v1/channels` 创建者必须登录。创建请求必须提供 `settings.name`,服务端拒绝缺失、空白或纯空格名称并返回 `400 space_name_required`,存储时去除首尾空格;名称长度最多 48 个字符。只有一套频道/成员/线程模型,`mode=enc|raw` 仅决定客户端内容是否端到端加密,不改变业务权限。创建者自动写入 owner 成员记录并占一个成员名额,同时登记当前设备终端;该账号的其他设备不增加名额。服务端启用验证码时还需要 `X-WD-Cap`。 diff --git a/docs/protocol.md b/docs/protocol.md index d341460..55f24a7 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -224,6 +224,8 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ... | 每频道字节数 | 1 GiB | | 每频道条目数 | `max_items` | +工作区对象帧使用独立容量限制:单个工作区默认最多 1 TiB,单个 `WorkspaceChannel` 的 Files 域默认最多 10 GiB;Files 域用量以非 CONTROL 帧字节计,删除条目后释放用量。其他未绑定工作区对象的旧式频道仍遵循上表 1 GiB 限制。实际配置值由 `/info` 返回,客户端不得自行推导或执行配额。 + ## 14. 接收方校验算法 对 enc 频道的每个条目: diff --git a/server/src/WebDrop.Api/ApiJson.cs b/server/src/WebDrop.Api/ApiJson.cs index 2afd11d..323ca5b 100644 --- a/server/src/WebDrop.Api/ApiJson.cs +++ b/server/src/WebDrop.Api/ApiJson.cs @@ -59,6 +59,8 @@ public sealed class LimitsBody [JsonPropertyName("max_payload_bytes")] public required int MaxPayloadBytes { get; init; } [JsonPropertyName("max_frames_per_item")] public required int MaxFramesPerItem { get; init; } [JsonPropertyName("max_channel_bytes")] public required long MaxChannelBytes { get; init; } + [JsonPropertyName("max_workspace_bytes")] public required long MaxWorkspaceBytes { get; init; } + [JsonPropertyName("max_workspace_channel_bytes")] public required long MaxWorkspaceChannelBytes { get; init; } [JsonPropertyName("max_seats")] public required int MaxSeats { get; init; } [JsonPropertyName("max_ttl_seconds")] public required int MaxTtlSeconds { get; init; } [JsonPropertyName("min_ttl_seconds")] public required int MinTtlSeconds { get; init; } diff --git a/server/src/WebDrop.Api/Endpoints.cs b/server/src/WebDrop.Api/Endpoints.cs index 8590c3b..26b402b 100644 --- a/server/src/WebDrop.Api/Endpoints.cs +++ b/server/src/WebDrop.Api/Endpoints.cs @@ -42,6 +42,8 @@ public static partial class Endpoints MaxPayloadBytes = limits.MaxPayloadBytes, MaxFramesPerItem = limits.MaxFramesPerItem, MaxChannelBytes = limits.MaxChannelBytes, + MaxWorkspaceBytes = limits.MaxWorkspaceBytes, + MaxWorkspaceChannelBytes = limits.MaxWorkspaceChannelBytes, MaxSeats = limits.MaxSeats, MaxTtlSeconds = limits.MaxTtlSeconds, MinTtlSeconds = limits.MinTtlSeconds, diff --git a/server/src/WebDrop.Core/ChannelService.cs b/server/src/WebDrop.Core/ChannelService.cs index 883d1a5..e323534 100644 --- a/server/src/WebDrop.Core/ChannelService.cs +++ b/server/src/WebDrop.Core/ChannelService.cs @@ -245,14 +245,16 @@ public sealed partial class ChannelService( if (!outcome.Result.Created) DeleteBlobIfUnreferenced(blobKey); foreach (string prefix in outcome.BlobPrefixesToDelete) blobs.DeletePrefix(prefix); events.Advanced(channelId, outcome.LastSeq); + if (outcome.NotifySettings) events.SettingsChanged(channelId); return outcome.Result; } - private sealed class AppendOutcome(AppendResult result, long lastSeq, List prefixes) + private sealed class AppendOutcome(AppendResult result, long lastSeq, List prefixes, bool notifySettings = false) { public AppendResult Result { get; } = result; public long LastSeq { get; } = lastSeq; public List BlobPrefixesToDelete { get; } = prefixes; + public bool NotifySettings { get; } = notifySettings; } private AppendOutcome AppendInTx( @@ -272,6 +274,10 @@ public sealed partial class ChannelService( var item = tx.GetItem(channelId, env.ItemId); var crypto = tx.GetWorkspaceCrypto(channelId); var objectEnvelope = crypto.Objects.Find(o => o.ItemId == env.ItemId.ToBase64Url()); + var workspaceState = objectEnvelope is null ? null : tx.GetWorkspace(channelId); + var workspaceChannel = objectEnvelope is null ? null : workspaceState?.Channels.Find(c => c.Id == objectEnvelope.ChannelId); + if (objectEnvelope is not null && workspaceChannel is null) throw new WebDropException(ErrorCode.BadRequest); + bool countsTowardFileQuota = objectEnvelope?.Domain == CryptoDomain.Files && !env.IsControl; if (signedEvent is not null && (objectEnvelope is null || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest); if (objectEnvelope is not null && env.IsFinal) { @@ -336,7 +342,11 @@ public sealed partial class ChannelService( if (item.MaxIndex > env.FrameIndex) throw new WebDropException(ErrorCode.BadRequest); if (item.FinalIndex is { } f && f != env.FrameIndex) throw new WebDropException(ErrorCode.BadRequest); } - if (channel.Bytes + body.Length > limits.MaxChannelBytes) throw new WebDropException(ErrorCode.TooLarge); + if (objectEnvelope is null && channel.Bytes + body.Length > limits.MaxChannelBytes + || objectEnvelope is not null && channel.Bytes + body.Length > limits.MaxWorkspaceBytes) + throw new WebDropException(ErrorCode.TooLarge); + if (countsTowardFileQuota && workspaceChannel!.FileBytes + body.Length > limits.MaxWorkspaceChannelBytes) + throw new WebDropException(ErrorCode.TooLarge); long seq = tx.NextSeq(channelId); tx.InsertFrame(new StoredFrame(channelId, env.ItemId, env.FrameIndex, seq, body.Length, digest, @@ -391,7 +401,16 @@ public sealed partial class ChannelService( if (pruned > channel.CompactedThrough) tx.SetCompactedThrough(channelId, pruned); } - return new AppendOutcome(new AppendResult { Seq = seq, Created = true }, lastSeq, prefixes); + if (countsTowardFileQuota) + { + var latestWorkspace = tx.GetWorkspace(channelId) ?? throw new WebDropException(ErrorCode.NotFound); + var latestChannel = latestWorkspace.Channels.Find(c => c.Id == objectEnvelope!.ChannelId) ?? throw new WebDropException(ErrorCode.NotFound); + latestChannel.FileBytes += body.Length; + tx.PutWorkspace(channelId, latestWorkspace); + } + + return new AppendOutcome(new AppendResult { Seq = seq, Created = true }, lastSeq, prefixes, + countsTowardFileQuota && env.IsFinal); } private static AppendResult Resolve(StoredFrame existing, byte[] digest) => @@ -587,10 +606,23 @@ public sealed partial class ChannelService( }); foreach (string prefix in prefixes) blobs.DeletePrefix(prefix); events.Advanced(id, seq); + events.SettingsChanged(id); } private long DeleteItemCore(IResourceTx tx, Id16 channelId, Item item, TombstoneReason reason, List prefixes) { + var crypto = tx.GetWorkspaceCrypto(channelId); + var objectEnvelope = crypto.Objects.Find(o => o.ItemId == item.ItemId.ToBase64Url()); + if (objectEnvelope?.Domain == CryptoDomain.Files && (item.Flags & FrameFlags.Control) == 0) + { + var workspace = tx.GetWorkspace(channelId); + var workspaceChannel = workspace?.Channels.Find(c => c.Id == objectEnvelope.ChannelId); + if (workspace is not null && workspaceChannel is not null) + { + workspaceChannel.FileBytes = Math.Max(0, workspaceChannel.FileBytes - item.Bytes); + tx.PutWorkspace(channelId, workspace); + } + } tx.DeleteItemRows(channelId, item.ItemId); tx.AddChannelBytes(channelId, -item.Bytes); long seq = tx.NextSeq(channelId); diff --git a/server/src/WebDrop.Core/ChannelWorkspace.cs b/server/src/WebDrop.Core/ChannelWorkspace.cs index 2f750d0..e9d77a9 100644 --- a/server/src/WebDrop.Core/ChannelWorkspace.cs +++ b/server/src/WebDrop.Core/ChannelWorkspace.cs @@ -27,6 +27,11 @@ public sealed partial class ChannelService var grants = crypto.Access.Where(a => a.Username == principal.AccountName).ToList(); var ids = grants.Where(a => a.ChatRead || a.FilesRead).Select(a => a.ChannelId).ToHashSet(); state.Channels.RemoveAll(c => !ids.Contains(c.Id)); + foreach (var workspaceChannel in state.Channels) + { + var grant = grants.Find(a => a.ChannelId == workspaceChannel.Id); + if (grant is null || !grant.FilesRead && !grant.FilesUpload) workspaceChannel.FileBytes = 0; + } state.Folders.RemoveAll(f => !ids.Contains(f.ThreadId)); state.Locations.RemoveAll(l => !Id16.TryParse(l.ItemId, out var itemId) || !CanReadObject(tx, space, principal, itemId)); } diff --git a/server/src/WebDrop.Core/Settings.cs b/server/src/WebDrop.Core/Settings.cs index ee45a81..a18bb33 100644 --- a/server/src/WebDrop.Core/Settings.cs +++ b/server/src/WebDrop.Core/Settings.cs @@ -8,6 +8,8 @@ public sealed class ServerLimits public int MaxPayloadBytes { get; set; } = (1 << 20) + 16; public int MaxFramesPerItem { get; set; } = 65_536; public long MaxChannelBytes { get; set; } = 1L << 30; + public long MaxWorkspaceBytes { get; set; } = 1L << 40; + public long MaxWorkspaceChannelBytes { get; set; } = 10L << 30; /// 最大账号成员数。属性名和 info 字段保持旧线格式兼容。 public int MaxSeats { get; set; } = 64; public int MaxVisitors { get; set; } = 10_000; diff --git a/server/src/WebDrop.Core/Workspace.cs b/server/src/WebDrop.Core/Workspace.cs index 8378a56..ca15d68 100644 --- a/server/src/WebDrop.Core/Workspace.cs +++ b/server/src/WebDrop.Core/Workspace.cs @@ -8,6 +8,7 @@ public sealed class WorkspaceChannel [JsonPropertyName("filesEpoch")] public int FilesEpoch { get; set; } [JsonPropertyName("chatHistory")] public bool ChatHistory { get; set; } = true; [JsonPropertyName("filesHistory")] public bool FilesHistory { get; set; } = true; + [JsonPropertyName("fileBytes")] public long FileBytes { get; set; } [JsonPropertyName("id")] public required string Id { get; init; } [JsonPropertyName("name")] public required string Name { get; set; } [JsonPropertyName("createdAt")] public required long CreatedAt { get; init; } diff --git a/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs b/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs index 84bf4cc..cb64d12 100644 --- a/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs +++ b/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs @@ -163,6 +163,28 @@ public class ChannelServiceTests : IDisposable Assert.Equal("Team space", Svc.GetChannel(channel.Id, channel.Owner(_ownerEp)).Settings.Name); } + [Fact] + public async Task FileGroupQuotaIsPerWorkspaceChannelAndFreedOnDelete() + { + _env.Limits.MaxWorkspaceChannelBytes = 200; + var space = _env.Create(_ownerEp, ChannelMode.Raw); + var owner = space.Owner(_ownerEp); + var channelId = Svc.GetWorkspace(space.Id, owner).DefaultChannelId!; + var first = TestEnv.NewId(); var second = TestEnv.NewId(); + Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = first.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" }); + Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = second.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" }); + + byte[] firstFrame = TestEnv.Frame(first, 0, FrameFlags.Final, 120); + await Svc.AppendFrameAsync(space.Id, owner, firstFrame, null); + Assert.Equal(firstFrame.Length, Svc.GetWorkspace(space.Id, owner).Channels.Single().FileBytes); + await AssertCode(ErrorCode.TooLarge, () => Svc.AppendFrameAsync(space.Id, owner, TestEnv.Frame(second, 0, FrameFlags.Final, 60), null)); + + Svc.DeleteItem(space.Id, owner, first); + Assert.Equal(0, Svc.GetWorkspace(space.Id, owner).Channels.Single().FileBytes); + await Svc.AppendFrameAsync(space.Id, owner, TestEnv.Frame(second, 0, FrameFlags.Final, 60), null); + Assert.Equal(86, Svc.GetWorkspace(space.Id, owner).Channels.Single().FileBytes); + } + [Fact] public async Task ItemIdsAndFramesAreScopedToTheirChannel() { diff --git a/web/src/core-bridge/generated/api.ts b/web/src/core-bridge/generated/api.ts index af7ab0d..177c74b 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: 654ec761e3481b423296b402497a97f4092ce167e7a2ca51a47d25f0fca4466e +// backend-contract-sha256: ba787115471d5d33fb0c33c95af014b5cd0d535455bd4bea037cfdcc4f048828 export interface AccountChannelBody { channel_id: string; @@ -299,6 +299,8 @@ export interface LimitsBody { max_seats: number; max_ttl_seconds: number; max_visitors: number; + max_workspace_bytes: number; + max_workspace_channel_bytes: number; min_ttl_seconds: number; } export interface SettingChoicesBody { @@ -365,6 +367,7 @@ export interface WorkspaceChannel { chatHistory: boolean; createdAt: number; createdBy: string; + fileBytes: number; filesEpoch: number; filesHistory: boolean; groupId?: string | null; diff --git a/web/src/core-bridge/worker/channelEvents.ts b/web/src/core-bridge/worker/channelEvents.ts index c473ac7..c55af7d 100644 --- a/web/src/core-bridge/worker/channelEvents.ts +++ b/web/src/core-bridge/worker/channelEvents.ts @@ -55,6 +55,7 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann const signedEvent = await objectEventProjection(resource, session, event.item.itemId); if (signedEvent?.kind === 'chat.edit' && signedEvent.targetItemId) publishWorkspace(resource.id, { t: 'removed', id: signedEvent.targetItemId }); + if (signedEvent?.kind.startsWith('file.')) void loadWorkspace(resource.id).catch(() => {}); const control = readResourceRecord(event.item); if (control !== false) { if (control && control.t === 'file-catalog') { @@ -108,6 +109,7 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann fileCatalogs.delete(`${resource.id}:${event.itemId}`); blobs.delete(`${resource.id}:${event.itemId}`); publishWorkspace(resource.id, { t: 'removed', id: event.itemId }); + void loadWorkspace(resource.id).catch(() => {}); break; case 'rejected': showRejected(resource.id, event); diff --git a/web/src/core-bridge/worker/channelTransport.ts b/web/src/core-bridge/worker/channelTransport.ts index de91477..241164b 100644 --- a/web/src/core-bridge/worker/channelTransport.ts +++ b/web/src/core-bridge/worker/channelTransport.ts @@ -3,6 +3,7 @@ import { active, getSession } from './sessions'; import { apiFetch, type ApiError } from './http'; import { publishWorkspace } from './presentation'; import { channelMetadata, processChannelBytes, syncChannel } from './channelEvents'; +import { loadWorkspace } from './workspaceStructure'; import { flushOutbox } from './transfer'; import { socketStream, sseStream, delay, type StreamConsumer } from './transports'; import { errorMessage } from '../errors'; @@ -31,7 +32,11 @@ export async function connectChannel(id: string): Promise { const stream: StreamConsumer = { cursor: 0, async record(bytes) { stream.cursor = Math.max(stream.cursor, await processChannelBytes(resource, session, bytes)); }, - async control(value) { if (value.t === 'settings' || value.t === 'seats') await channelMetadata(resource, session); }, + async control(value) { + if (value.t === 'settings') { + await Promise.all([channelMetadata(resource, session), loadWorkspace(id)]); + } else if (value.t === 'seats') await channelMetadata(resource, session); + }, online() { if (!signal.aborted) publishWorkspace(id, { t: 'connection', state: 'online' }); }, }; let initialized = false; diff --git a/web/src/features/channel/ExplorerFileRow.tsx b/web/src/features/channel/ExplorerFileRow.tsx index 8332736..9d10653 100644 --- a/web/src/features/channel/ExplorerFileRow.tsx +++ b/web/src/features/channel/ExplorerFileRow.tsx @@ -24,10 +24,11 @@ export function ExplorerFileRow(props: Props) { onMouseEnter={(event) => props.onHover(id, event.currentTarget.getBoundingClientRect())} onMouseLeave={props.onLeave} draggable={props.enabled && available} onDragStart={(event) => props.onDrag(id, event)} onDragEnd={props.onDragEnd}> props.onToggle(id)} onClick={(event) => event.stopPropagation()} /> - {item.state.kind === 'receiving' ? {formatTransferProgress(item.state)} : null} + {item.author ? 上传者:{item.author.displayName || item.author.username} : null} + {item.state.kind === 'receiving' ? {formatTransferProgress(item.state)} : null} {item.state.kind === 'tampered' ? 内容校验失败 : null} {formatSize(item.size)}{formatTime(item.ts)}