From 17638b6e20c1b1347b7fc74167c7101c1c9aa3fd Mon Sep 17 00:00:00 2001 From: JustMe Date: Mon, 19 Oct 2026 13:38:40 +0800 Subject: [PATCH] refactor(host): single-source ChannelPolicy, space-signed file grants, whole-file registration - all authorization rules (owner/creator, per-channel read/write grants, who may remove whom, delete rules, structure management) live in ChannelPolicy; a test fails if they are reimplemented elsewhere - ChannelInfo carries what the caller may do (can_*), member and invitation lists say who can be removed; clients display it and never derive it - the host no longer stores or calls the file service: it only signs short-lived tickets from space permissions (create/put/get/delete/domain), and keeps an opaque fileDomain reference on the channel - a whole file is registered in one request (last frame header + per-frame length and digest) instead of one request per 1 MiB frame; the signed manifest still has to match every digest - drop file quota/threshold, receipts, reconcile and delete-queue code from the host - contract generator emits constants for enums, error codes, token roles, permissions and share states --- contracts/api.schemas.json | 131 ++++++-- scripts/check-api-contract.mjs | 6 +- server/WebDrop.slnx | 13 +- .../Endpoints/Endpoints.ChannelAccess.cs | 8 +- .../WebDrop.Api/Endpoints/Endpoints.Files.cs | 54 +++- .../Endpoints/Endpoints.Workspace.cs | 7 +- server/src/WebDrop.Api/Endpoints/Endpoints.cs | 9 +- .../WebDrop.Api/Files/FileAwareBlobStore.cs | 21 -- server/src/WebDrop.Api/Files/FileGrants.cs | 24 ++ .../WebDrop.Api/Files/FileReconcileWorker.cs | 46 --- .../WebDrop.Api/Files/RemoteDeleteQueue.cs | 91 ------ .../WebDrop.Api/Files/RemoteFileGateway.cs | 26 -- .../src/WebDrop.Api/Hosting/BackupWorker.cs | 10 +- server/src/WebDrop.Api/Hosting/WebDropApp.cs | 11 +- .../src/WebDrop.Api/Hosting/WebDropOptions.cs | 12 +- server/src/WebDrop.Api/Json/ApiJson.cs | 30 +- .../WebDrop.Core/Channels/ChannelAdmission.cs | 34 +- .../WebDrop.Core/Channels/ChannelPolicy.cs | 95 +++++- .../WebDrop.Core/Channels/ChannelService.cs | 238 +++++++++----- server/src/WebDrop.Core/Channels/Dtos.cs | 15 + server/src/WebDrop.Core/Channels/Settings.cs | 1 - server/src/WebDrop.Core/Common/Enums.cs | 7 + server/src/WebDrop.Core/Common/Models.cs | 2 + server/src/WebDrop.Core/Files/IFileGateway.cs | 28 -- server/src/WebDrop.Core/Files/IFileGrants.cs | 27 ++ .../src/WebDrop.Core/Shares/ShareService.cs | 2 +- .../src/WebDrop.Core/Spaces/ChannelCrypto.cs | 35 +- .../WebDrop.Core/Spaces/ChannelWorkspace.cs | 45 +-- server/src/WebDrop.Core/Spaces/Workspace.cs | 3 +- .../tests/WebDrop.Api.Tests/FileApiTests.cs | 304 +++++++++++------- .../WebDrop.Core.Tests/ChannelCryptoTests.cs | 25 +- .../WebDrop.Core.Tests/ChannelServiceTests.cs | 37 ++- .../PolicySingleSourceTests.cs | 48 +++ .../WorkspaceStructureTests.cs | 50 +++ tools/ContractGenerator/Program.cs | 18 +- tools/ContractGenerator/README.md | 2 +- 36 files changed, 926 insertions(+), 589 deletions(-) delete mode 100644 server/src/WebDrop.Api/Files/FileAwareBlobStore.cs create mode 100644 server/src/WebDrop.Api/Files/FileGrants.cs delete mode 100644 server/src/WebDrop.Api/Files/FileReconcileWorker.cs delete mode 100644 server/src/WebDrop.Api/Files/RemoteDeleteQueue.cs delete mode 100644 server/src/WebDrop.Api/Files/RemoteFileGateway.cs delete mode 100644 server/src/WebDrop.Core/Files/IFileGateway.cs create mode 100644 server/src/WebDrop.Core/Files/IFileGrants.cs create mode 100644 server/tests/WebDrop.Core.Tests/PolicySingleSourceTests.cs diff --git a/contracts/api.schemas.json b/contracts/api.schemas.json index 4b20444..ddab721 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": "6e4037d39ac5ae791724731810e3df5b791839e8ab29cf54643ec1a294132964", + "x-backend-contract-sha256": "340876061d7b324e4fd16972e276ffb0f238eeac66b214a043b3b4b98d560fcf", "$defs": { "AccountChannelBody": { "type": "object", @@ -322,9 +322,33 @@ } ] }, + "can_create_channel": { + "type": "boolean" + }, + "can_delete_any": { + "type": "boolean" + }, + "can_delete_own": { + "type": "boolean" + }, + "can_edit_settings": { + "type": "boolean" + }, + "can_invite": { + "type": "boolean" + }, + "can_manage_folders": { + "type": "boolean" + }, + "can_manage_roles": { + "type": "boolean" + }, "can_manage_structure": { "type": "boolean" }, + "can_remove_members": { + "type": "boolean" + }, "can_upload": { "type": "boolean" }, @@ -347,6 +371,9 @@ "id": { "type": "string" }, + "is_owner": { + "type": "boolean" + }, "last_seq": { "type": "integer" }, @@ -367,11 +394,20 @@ } }, "required": [ + "can_create_channel", + "can_delete_any", + "can_delete_own", + "can_edit_settings", + "can_invite", + "can_manage_folders", + "can_manage_roles", "can_manage_structure", + "can_remove_members", "can_upload", "created_at", "expires_at", "id", + "is_owner", "last_seq", "members_used", "mode", @@ -847,46 +883,51 @@ "FileGrantBody": { "type": "object", "properties": { + "domain": { + "type": "string" + }, "expires_at": { "type": "integer" }, - "mode": { + "ticket": { "type": "string" }, - "ticket": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, "url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "type": "string" } }, "required": [ + "domain", "expires_at", - "mode" + "ticket", + "url" ] }, "FileGrantRequest": { "type": "object", "properties": { + "channel": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "from": { "type": "integer" }, "item": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, "op": { "type": "string" @@ -894,7 +935,6 @@ }, "required": [ "from", - "item", "op" ] }, @@ -932,6 +972,9 @@ "InvitationBody": { "type": "object", "properties": { + "can_revoke": { + "type": "boolean" + }, "channel_id": { "type": "string" }, @@ -946,6 +989,7 @@ } }, "required": [ + "can_revoke", "channel_id", "created_at", "payload", @@ -1084,6 +1128,9 @@ "MemberBody": { "type": "object", "properties": { + "can_remove": { + "type": "boolean" + }, "devices": { "type": "integer" }, @@ -1107,6 +1154,7 @@ } }, "required": [ + "can_remove", "devices", "joined_at", "last_seen", @@ -1524,9 +1572,6 @@ "max_workspace_bytes": { "type": "integer" }, - "max_workspace_channel_bytes": { - "type": "integer" - }, "min_ttl_seconds": { "type": "integer" } @@ -1540,7 +1585,6 @@ "max_ttl_seconds", "max_visitors", "max_workspace_bytes", - "max_workspace_channel_bytes", "min_ttl_seconds" ] }, @@ -1856,8 +1900,15 @@ "createdBy": { "type": "string" }, - "fileBytes": { - "type": "integer" + "fileDomain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, "filesEpoch": { "type": "integer" @@ -1887,7 +1938,6 @@ "chatHistory", "createdAt", "createdBy", - "fileBytes", "filesEpoch", "filesHistory", "id", @@ -1897,6 +1947,16 @@ "WorkspaceChannelRequest": { "type": "object", "properties": { + "fileDomain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "groupId": { "anyOf": [ { @@ -1915,6 +1975,17 @@ "name" ] }, + "WorkspaceFileDomainRequest": { + "type": "object", + "properties": { + "fileDomain": { + "type": "string" + } + }, + "required": [ + "fileDomain" + ] + }, "WorkspaceFolderRequest": { "type": "object", "properties": { diff --git a/scripts/check-api-contract.mjs b/scripts/check-api-contract.mjs index b4823c0..e33b54b 100644 --- a/scripts/check-api-contract.mjs +++ b/scripts/check-api-contract.mjs @@ -11,7 +11,7 @@ const fail = (message) => { const dtoSources = ['server/src/WebDrop.Api/Json/ApiJson.cs', 'server/src/WebDrop.Core/Channels/Dtos.cs', 'server/src/WebDrop.Core/Channels/Settings.cs', 'server/src/WebDrop.Core/Common/Enums.cs', 'server/src/WebDrop.Core/Spaces/Workspace.cs', 'server/src/WebDrop.Core/Accounts/AccountProfile.cs', 'server/src/WebDrop.Api/Passkeys/PasskeyDtos.cs', 'server/src/WebDrop.Core/Spaces/WorkspaceCrypto.cs', 'server/src/WebDrop.Api/Endpoints/Endpoints.WorkspaceCrypto.cs']; const dtoDigest = createHash('sha256').update(dtoSources.map(path => `${path}\n${read(path).replace(/\r\n/g, '\n')}\n`).join('')).digest('hex'); -const generatedDigest = /backend-contract-sha256: ([a-f0-9]{64})/.exec(read('web/src/core-bridge/generated/api.ts'))?.[1]; +const generatedDigest = /backend-contract-sha256: ([a-f0-9]{64})/.exec(read('web/src/api/generated.ts'))?.[1]; if (generatedDigest !== dtoDigest) fail('Backend DTOs changed. Regenerate with dotnet run --project tools/ContractGenerator -- .'); if (JSON.parse(read('contracts/api.schemas.json'))['x-backend-contract-sha256'] !== dtoDigest) fail('Generated API schemas are stale. Run ContractGenerator.'); @@ -28,10 +28,10 @@ const backend = read('server/src/WebDrop.Api/Hosting/ApiContract.cs'); const backendVersion = /const string Version = "([^"]+)"/.exec(backend)?.[1]; if (backendVersion !== contract.version) fail(`backend version ${backendVersion ?? '(missing)'} differs from ${contract.version}.`); -const frontend = read('web/src/core-bridge/contract.ts'); +const frontend = read('web/src/api/contract.ts'); const supportedMajor = Number(/SUPPORTED_API_CONTRACT_MAJOR\s*=\s*(\d+)/.exec(frontend)?.[1]); if (supportedMajor !== apiMajor) { - fail(`backend contract is ${contract.version}; frontend still supports API major ${supportedMajor || '(missing)'}. Update web/src/core-bridge/contract.ts.`); + fail(`backend contract is ${contract.version}; frontend still supports API major ${supportedMajor || '(missing)'}. Update web/src/api/contract.ts.`); } const tag = process.argv[2]; diff --git a/server/WebDrop.slnx b/server/WebDrop.slnx index b5a1418..c46c406 100644 --- a/server/WebDrop.slnx +++ b/server/WebDrop.slnx @@ -1,10 +1,17 @@ - + + - + + + + - + + + + diff --git a/server/src/WebDrop.Api/Endpoints/Endpoints.ChannelAccess.cs b/server/src/WebDrop.Api/Endpoints/Endpoints.ChannelAccess.cs index dcd0050..9d2b9b6 100644 --- a/server/src/WebDrop.Api/Endpoints/Endpoints.ChannelAccess.cs +++ b/server/src/WebDrop.Api/Endpoints/Endpoints.ChannelAccess.cs @@ -78,7 +78,8 @@ public static partial class Endpoints return Json(InvitationBodies(svc.UserInvitations(username)), ApiJson.Default.ListInvitationBody); }); api.MapGet("/channels/{id}/invitations", (string id, HttpContext ctx, ChannelService svc) => - Json(InvitationBodies(svc.Invitations(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false))), ApiJson.Default.ListInvitationBody)); + Json(svc.InvitationsView(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false)).Select(view => new InvitationBody + { ChannelId = view.Invitation.ChannelId.ToBase64Url(), Username = view.Invitation.Username, Payload = view.Invitation.Payload, CreatedAt = view.Invitation.CreatedAt, CanRevoke = view.CanRevoke }).ToList(), ApiJson.Default.ListInvitationBody)); api.MapGet("/channels/{id}/invitations/{username}/crypto-plan", (string id, string username, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore) => { username = NormalizeUsername(username); @@ -88,14 +89,15 @@ 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, false)).Select(member => + var members = svc.MembersView(channelId, RequestAuth.FromHeaders(ctx.Request, false)).Select(view => { + var member = view.Member; var (online, onlineDevices) = hub.Presence(channelId, member.Username); // 设备是账号的属性:总数取账号的登录设备,在线数取该账号在本频道有订阅的设备 var devices = accounts.Devices(member.Username, null); return new MemberBody { Username = member.Username, Role = member.Role, JoinedAt = member.JoinedAt, Online = online, Devices = Math.Max(devices.Count, onlineDevices), OnlineDevices = onlineDevices, - LastSeen = devices.Count == 0 ? 0 : devices.Max(device => device.LastSeen) }; + LastSeen = devices.Count == 0 ? 0 : devices.Max(device => device.LastSeen), CanRemove = view.CanRemove }; }).ToList(); return Json(members, ApiJson.Default.ListMemberBody); }); diff --git a/server/src/WebDrop.Api/Endpoints/Endpoints.Files.cs b/server/src/WebDrop.Api/Endpoints/Endpoints.Files.cs index 9a47333..9c2fe41 100644 --- a/server/src/WebDrop.Api/Endpoints/Endpoints.Files.cs +++ b/server/src/WebDrop.Api/Endpoints/Endpoints.Files.cs @@ -1,28 +1,58 @@ using WebDrop.Core; +using WebDrop.Storage; namespace WebDrop.Api; public static partial class Endpoints { - /// 文件服务的票据:客户端上传或下载文件内容前先来要一张,拿着它直接找文件服务。 + /// + /// 文件服务的票据:权限由空间决定,通过后给客户端一张短期票据,客户端拿着它直接找文件服务。 + /// create 是所有者/管理员创建文件域;put/get 是按频道权限上传、下载某个条目。 + /// private static void MapFiles(RouteGroupBuilder api) { - api.MapPost("/channels/{id}/files/ticket", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) => + // 一次登记整个文件:请求体 = 末帧的 26 字节头部 + 每一帧的 [负载长度 u32 大端][整帧 SHA-256 32 字节]。 + // 字节早已存进文件域;逐帧登记要为每个 1 MiB 往返一次,这里一次搞定 + api.MapPost("/channels/{id}/files", async (string id, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore, ServerLimits limits, CancellationToken ct) => { var channelId = ParseId(id); var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false); + long? maxDownloads = ParseMaxDownloads(ctx.Request.Headers["X-WD-Max-Downloads"].ToString()); + const int EntrySize = 36; + int max = Envelope.HeaderSize + limits.MaxFramesPerItem * EntrySize; + if (ctx.Features.Get() is { IsReadOnly: false } size) size.MaxRequestBodySize = max; + byte[] body = await ReadBody(ctx.Request, max, ct); + if (body.Length < Envelope.HeaderSize + EntrySize || (body.Length - Envelope.HeaderSize) % EntrySize != 0) throw new WebDropException(ErrorCode.BadRequest); + var header = body.AsMemory(0, Envelope.HeaderSize); + var entries = new List((body.Length - Envelope.HeaderSize) / EntrySize); + for (int at = Envelope.HeaderSize; at < body.Length; at += EntrySize) + entries.Add(new ChannelService.FrameEntry((int)System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(at, 4)), body.AsSpan(at + 4, 32).ToArray())); + var signedEvent = ParseSignedContentEvent(ctx.Request, accountStore, channelId, creds, header.ToArray()); + var result = svc.RegisterFileItem(channelId, creds, header, entries, maxDownloads, signedEvent); + return Json(result, ApiJson.Default.AppendResult, result.Created ? StatusCodes.Status201Created : StatusCodes.Status200OK); + }); + + api.MapPost("/spaces/{id}/file-grants", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) => + { + var spaceId = ParseId(id); + var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false); var req = await ReadJson(ctx.Request, ApiJson.Default.FileGrantRequest, ct); - var item = ParseId(req.Item); - if (req.From is < 0 or > uint.MaxValue) throw new WebDropException(ErrorCode.BadRequest); - FileGrant? grant = req.Op switch + FileGrant grant; + if (req.Op == "create") grant = svc.IssueCreateGrant(spaceId, creds); + else if (req.Op == "domain") grant = svc.IssueDomainGrant(spaceId, creds, req.Channel ?? throw new WebDropException(ErrorCode.BadRequest)); + else { - "put" => svc.IssueUploadGrant(channelId, creds, item), - "get" => svc.IssueDownloadGrant(channelId, creds, item, (uint)req.From), - _ => throw new WebDropException(ErrorCode.BadRequest), - }; - return Json(grant is null - ? new FileGrantBody { Mode = "local" } - : new FileGrantBody { Mode = "remote", Url = grant.Url, Ticket = grant.Ticket, ExpiresAt = grant.ExpiresAt }, ApiJson.Default.FileGrantBody); + if (req.Item is null || req.From is < 0 or > uint.MaxValue) throw new WebDropException(ErrorCode.BadRequest); + var item = ParseId(req.Item); + grant = req.Op switch + { + "put" => svc.IssueUploadGrant(spaceId, creds, item), + "get" => svc.IssueDownloadGrant(spaceId, creds, item, (uint)req.From), + "delete" => svc.IssueDeleteGrant(spaceId, creds, item), + _ => throw new WebDropException(ErrorCode.BadRequest), + }; + } + return Json(new FileGrantBody { Url = grant.Url, Domain = grant.Domain, Ticket = grant.Ticket, ExpiresAt = grant.ExpiresAt }, ApiJson.Default.FileGrantBody); }); } } diff --git a/server/src/WebDrop.Api/Endpoints/Endpoints.Workspace.cs b/server/src/WebDrop.Api/Endpoints/Endpoints.Workspace.cs index 902e984..b3e54e0 100644 --- a/server/src/WebDrop.Api/Endpoints/Endpoints.Workspace.cs +++ b/server/src/WebDrop.Api/Endpoints/Endpoints.Workspace.cs @@ -20,7 +20,7 @@ public static partial class Endpoints 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, false), req.Name, req.GroupId); + var created = svc.CreateWorkspaceChannelResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name, req.GroupId, req.FileDomain); return Json(new WorkspaceChannelCreatedBody { Workspace = View(svc, ParseId(id), 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) => @@ -28,6 +28,11 @@ public static partial class Endpoints var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct); return Json(View(svc, ParseId(id), svc.RenameWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.Name)), ApiJson.Default.WorkspaceBody); }); + api.MapPut("/spaces/{id}/channels/{channelId}/file-domain", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) => + { + var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFileDomainRequest, ct); + return Json(View(svc, ParseId(id), svc.SetWorkspaceChannelFileDomain(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.FileDomain)), 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); diff --git a/server/src/WebDrop.Api/Endpoints/Endpoints.cs b/server/src/WebDrop.Api/Endpoints/Endpoints.cs index 648bf07..76dc3d4 100644 --- a/server/src/WebDrop.Api/Endpoints/Endpoints.cs +++ b/server/src/WebDrop.Api/Endpoints/Endpoints.cs @@ -17,7 +17,7 @@ public static partial class Endpoints private const string OctetStream = "application/octet-stream"; private static readonly TimeSpan MaxLongPoll = TimeSpan.FromSeconds(25); - public static void MapApi(this IEndpointRouteBuilder app, WebDropOptions options, ICaptchaVerifier captcha, SqliteAccountStore accounts, IFileGateway files) + public static void MapApi(this IEndpointRouteBuilder app, WebDropOptions options, ICaptchaVerifier captcha, SqliteAccountStore accounts, IFileGrants files) { var api = app.MapGroup("/api/v1"); @@ -45,7 +45,6 @@ public static partial class Endpoints MaxFramesPerItem = limits.MaxFramesPerItem, MaxChannelBytes = limits.MaxChannelBytes, MaxWorkspaceBytes = limits.MaxWorkspaceBytes, - MaxWorkspaceChannelBytes = limits.MaxWorkspaceChannelBytes, MaxMembers = limits.MaxMembers, MaxTtlSeconds = limits.MaxTtlSeconds, MinTtlSeconds = limits.MinTtlSeconds, @@ -116,11 +115,7 @@ public static partial class Endpoints byte[] body = await ReadBody(ctx.Request, Envelope.HeaderSize + limits.MaxPayloadBytes, ct); var signedEvent = ParseSignedContentEvent(ctx.Request, accountStore, channelId, creds, body); - // 带文件服务回执的是「登记」:请求体只有头部,字节已经在文件服务里 - var receipt = ctx.Request.Headers[FileProtocol.FileWire.ReceiptHeader].ToString(); - var result = receipt.Length > 0 - ? await svc.CommitRemoteFrameAsync(channelId, creds, body, receipt, maxDownloads, signedEvent) - : await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, ct, signedEvent); + var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, ct, signedEvent); return Json(result, ApiJson.Default.AppendResult, result.Created ? StatusCodes.Status201Created : StatusCodes.Status200OK); }); diff --git a/server/src/WebDrop.Api/Files/FileAwareBlobStore.cs b/server/src/WebDrop.Api/Files/FileAwareBlobStore.cs deleted file mode 100644 index 7009cb8..0000000 --- a/server/src/WebDrop.Api/Files/FileAwareBlobStore.cs +++ /dev/null @@ -1,21 +0,0 @@ -using WebDrop.Core; - -namespace WebDrop.Api; - -/// -/// 频道用的 blob 存储:本地照旧,删除前缀时同时通知文件服务。 -/// 条目被删、过期、频道被删时,文件服务上的那部分字节随之清掉(见 )。 -/// -public sealed class FileAwareBlobStore(IBlobStore inner, RemoteDeleteQueue remote) : IBlobStore -{ - public ValueTask PutAsync(string key, ReadOnlyMemory data, CancellationToken ct = default) => inner.PutAsync(key, data, ct); - public ValueTask ReadAsync(string key, CancellationToken ct = default) => inner.ReadAsync(key, ct); - public void Delete(string key) => inner.Delete(key); - public IEnumerable<(string Key, DateTimeOffset LastWrite)> Enumerate() => inner.Enumerate(); - - public void DeletePrefix(string prefix) - { - inner.DeletePrefix(prefix); - remote.Enqueue(prefix); - } -} diff --git a/server/src/WebDrop.Api/Files/FileGrants.cs b/server/src/WebDrop.Api/Files/FileGrants.cs new file mode 100644 index 0000000..4f42eac --- /dev/null +++ b/server/src/WebDrop.Api/Files/FileGrants.cs @@ -0,0 +1,24 @@ +using System.Text; +using WebDrop.Core; +using WebDrop.FileProtocol; + +namespace WebDrop.Api; + +/// +/// 主后端这一侧和文件服务的全部关系:拿共用的密钥给客户端签短期票据。 +/// 它不调用文件服务、不保存文件服务的任何状态;文件域怎么创建、多大、用了多少,都是客户端直接和文件服务之间的事。 +/// +public sealed class FileGrants(FileApiOptions options) : IFileGrants +{ + private readonly byte[] _secret = Encoding.UTF8.GetBytes(options.Secret); + private readonly string _publicUrl = options.PublicUrl.TrimEnd('/'); + + public bool Enabled => true; + public string PublicUrl => _publicUrl; + + public FileGrant Issue(FileOp op, string space, string scope, string item, uint from, long nowMs) + { + long expires = nowMs + Math.Max(30, options.TicketSeconds) * 1000L; + return new FileGrant(_publicUrl, op == FileOp.Create ? "" : scope, FileTokens.Sign(_secret, new FileTicket(op, scope, item, from, expires, space)), expires); + } +} diff --git a/server/src/WebDrop.Api/Files/FileReconcileWorker.cs b/server/src/WebDrop.Api/Files/FileReconcileWorker.cs deleted file mode 100644 index 5071528..0000000 --- a/server/src/WebDrop.Api/Files/FileReconcileWorker.cs +++ /dev/null @@ -1,46 +0,0 @@ -using WebDrop.Core; - -namespace WebDrop.Api; - -/// -/// 对账:文件服务上有、主后端已经没有记录的条目(上传到一半放弃、删除通知丢了)删掉。 -/// 只处理超过一天的,正在上传的不会误删。每天跑一次,文件服务离线就等下一次。 -/// -public sealed class FileReconcileWorker(IFileGateway gateway, WebDropOptions options, ChannelService service, IClock clock, RemoteDeleteQueue deletes, ILogger logger) : BackgroundService -{ - private static readonly TimeSpan MinAge = TimeSpan.FromHours(24); - private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(60) }; - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - if (gateway is not RemoteFileGateway remote) return; - using var timer = new PeriodicTimer(TimeSpan.FromHours(24)); - try { await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) { return; } - do - { - try { await ReconcileAsync(remote, stoppingToken).ConfigureAwait(false); } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } - catch (Exception e) { logger.LogWarning("文件对账失败:{Type}", e.GetType().Name); } - } - while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)); - } - - public async Task ReconcileAsync(RemoteFileGateway remote, CancellationToken ct, TimeSpan? minAge = null) - { - using var request = new HttpRequestMessage(HttpMethod.Get, $"{options.Files.InternalUrl.TrimEnd('/')}/internal/items?olderThanMs={(long)(minAge ?? MinAge).TotalMilliseconds}"); - request.Headers.Add(FileProtocol.FileWire.TicketHeader, remote.AdminTicket(clock.NowMs)); - using var response = await _http.SendAsync(request, ct).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - int orphans = 0; - foreach (var line in (await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false)).Split('\n', StringSplitOptions.RemoveEmptyEntries)) - { - var parts = line.Trim().Split('/'); - if (parts.Length != 2 || !Id16.TryParseHex(parts[0], out var channel) || !Id16.TryParseHex(parts[1], out var item)) continue; - if (service.ItemExists(channel, item)) continue; - deletes.Enqueue(line.Trim()); - orphans++; - } - if (orphans > 0) logger.LogInformation("文件服务上有 {Count} 个孤立条目,已安排删除", orphans); - return orphans; - } -} diff --git a/server/src/WebDrop.Api/Files/RemoteDeleteQueue.cs b/server/src/WebDrop.Api/Files/RemoteDeleteQueue.cs deleted file mode 100644 index c336b96..0000000 --- a/server/src/WebDrop.Api/Files/RemoteDeleteQueue.cs +++ /dev/null @@ -1,91 +0,0 @@ -using WebDrop.Core; - -namespace WebDrop.Api; - -/// -/// 要在文件服务上删除的前缀(频道 或 频道/条目)。先写进磁盘再后台重试: -/// 家里的机器暂时不在线也不会丢,等它回来再删;删不掉的只是多占一点空间,不影响任何功能。 -/// 没有启用文件服务时什么也不做。 -/// -public sealed class RemoteDeleteQueue : BackgroundService -{ - private readonly RemoteFileGateway? _gateway; - private readonly FileApiOptions _options; - private readonly string _path; - private readonly IClock _clock; - private readonly ILogger _logger; - private readonly object _gate = new(); - private readonly HashSet _pending = []; - private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(30) }; - private readonly SemaphoreSlim _wake = new(0); - - public RemoteDeleteQueue(WebDropOptions options, IFileGateway gateway, IClock clock, ILogger logger) - { - _options = options.Files; - _gateway = gateway as RemoteFileGateway; - _clock = clock; - _logger = logger; - _path = Path.Combine(options.DataDir, "v2", "file-deletes.txt"); - if (_gateway is null) return; - Directory.CreateDirectory(Path.GetDirectoryName(_path)!); - if (File.Exists(_path)) foreach (var line in File.ReadAllLines(_path)) if (line.Length > 0) _pending.Add(line); - } - - public int Count { get { lock (_gate) return _pending.Count; } } - - public void Enqueue(string prefix) - { - if (_gateway is null || prefix.Length == 0 || prefix.Any(c => !(c is >= '0' and <= '9' or >= 'a' and <= 'f' or '/'))) return; - lock (_gate) - { - if (!_pending.Add(prefix)) return; - File.AppendAllText(_path, prefix + "\n"); - } - _wake.Release(); - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - if (_gateway is null) return; - while (!stoppingToken.IsCancellationRequested) - { - try { await DrainAsync(stoppingToken).ConfigureAwait(false); } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } - catch (Exception e) { _logger.LogWarning("文件服务清理失败:{Type}", e.GetType().Name); } - // 有新的就立刻处理;没有也每 30 秒重试一次上次没删掉的 - try { await _wake.WaitAsync(TimeSpan.FromSeconds(30), stoppingToken).ConfigureAwait(false); } - catch (OperationCanceledException) { break; } - } - } - - public async Task DrainAsync(CancellationToken ct) - { - string[] batch; - lock (_gate) batch = [.. _pending]; - foreach (var prefix in batch) - { - if (await DeleteRemoteAsync(prefix, ct).ConfigureAwait(false)) - { - lock (_gate) - { - _pending.Remove(prefix); - File.WriteAllLines(_path, _pending); - } - } - else break; // 文件服务连不上:这一轮不再逐个尝试 - } - } - - private async Task DeleteRemoteAsync(string prefix, CancellationToken ct) - { - try - { - using var request = new HttpRequestMessage(HttpMethod.Delete, $"{_options.InternalUrl.TrimEnd('/')}/internal/prefix?p={Uri.EscapeDataString(prefix)}"); - request.Headers.Add(FileProtocol.FileWire.TicketHeader, _gateway!.AdminTicket(_clock.NowMs)); - using var response = await _http.SendAsync(request, ct).ConfigureAwait(false); - return response.IsSuccessStatusCode || (int)response.StatusCode == 404; - } - catch (HttpRequestException) { return false; } - catch (TaskCanceledException) when (!ct.IsCancellationRequested) { return false; } - } -} diff --git a/server/src/WebDrop.Api/Files/RemoteFileGateway.cs b/server/src/WebDrop.Api/Files/RemoteFileGateway.cs deleted file mode 100644 index 93ed0ff..0000000 --- a/server/src/WebDrop.Api/Files/RemoteFileGateway.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Text; -using WebDrop.Core; -using WebDrop.FileProtocol; - -namespace WebDrop.Api; - -/// 主后端这一侧的文件服务网关:给客户端签票据、验文件服务的回执。 -public sealed class RemoteFileGateway(FileApiOptions options) : IFileGateway -{ - private readonly byte[] _secret = Encoding.UTF8.GetBytes(options.Secret); - private readonly string _publicUrl = options.PublicUrl.TrimEnd('/'); - - public bool Enabled => true; - public string PublicUrl => _publicUrl; - - public FileGrant Issue(FileOp op, Id16 channel, Id16 item, uint from, long nowMs) - { - long expires = nowMs + Math.Max(30, options.TicketSeconds) * 1000L; - return new FileGrant(_publicUrl, FileTokens.Sign(_secret, new FileTicket(op, channel.ToHex(), item.ToHex(), from, expires)), expires); - } - - public bool TryVerifyReceipt(string? token, long nowMs, out FileReceipt receipt) => FileTokens.TryVerify(_secret, token, nowMs, out receipt); - - /// 主后端调用文件服务管理接口用的票据,有效期很短 - public string AdminTicket(long nowMs) => FileTokens.Sign(_secret, new FileTicket(FileOp.Admin, "*", "*", 0, nowMs + 60_000)); -} diff --git a/server/src/WebDrop.Api/Hosting/BackupWorker.cs b/server/src/WebDrop.Api/Hosting/BackupWorker.cs index fc0f64c..9c3b9ce 100644 --- a/server/src/WebDrop.Api/Hosting/BackupWorker.cs +++ b/server/src/WebDrop.Api/Hosting/BackupWorker.cs @@ -17,7 +17,7 @@ public sealed class BackupWorker(WebDropOptions options, SqliteChannelStore stor protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var backup = options.Backup; - if (!backup.Enabled || !backup.WebDav.Configured) return; + if (!backup.Enabled || !backup.S3.Configured) return; while (!stoppingToken.IsCancellationRequested) { try { await Task.Delay(DailySchedule.Next(DateTime.Now, backup.TimeOfDay) - DateTime.Now, stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; } @@ -30,7 +30,7 @@ public sealed class BackupWorker(WebDropOptions options, SqliteChannelStore stor public async Task<(string Name, int Purged)> RunOnceAsync(DateTime now, CancellationToken ct) { var backup = options.Backup; - var dav = new WebDavClient(_http, backup.WebDav); + var s3 = new S3Client(_http, backup.S3); string tmp = Path.Combine(options.DataDir, "v2", "backup-tmp"); Directory.CreateDirectory(tmp); string name = $"{Prefix}{now:yyyyMMdd-HHmm}.db.gz"; @@ -43,7 +43,7 @@ public sealed class BackupWorker(WebDropOptions options, SqliteChannelStore stor await using (var zip = new GZipStream(target, CompressionLevel.SmallestSize)) await source.CopyToAsync(zip, ct).ConfigureAwait(false); await using (var upload = File.OpenRead(gz)) - await dav.PutAsync($"chat/{name}", upload, ct).ConfigureAwait(false); + await s3.PutAsync($"chat/{name}", upload, ct).ConfigureAwait(false); } finally { @@ -53,11 +53,11 @@ public sealed class BackupWorker(WebDropOptions options, SqliteChannelStore stor // 保留期:按文件名里的日期清理更早的 int purged = 0; var cutoff = now.Date.AddDays(-Math.Max(0, backup.RetainDays)); - foreach (var existing in await dav.ListAsync("chat", ct).ConfigureAwait(false)) + foreach (var existing in await s3.ListAsync("chat", ct).ConfigureAwait(false)) { if (!existing.StartsWith(Prefix, StringComparison.Ordinal) || existing.Length < Prefix.Length + 8) continue; if (!DateTime.TryParseExact(existing.AsSpan(Prefix.Length, 8), "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) || date >= cutoff) continue; - if (await dav.DeleteAsync($"chat/{existing}", ct).ConfigureAwait(false)) purged++; + if (await s3.DeleteAsync($"chat/{existing}", ct).ConfigureAwait(false)) purged++; } if (purged > 0) logger.LogInformation("清理了 {Count} 份过期的数据库备份", purged); return (name, purged); diff --git a/server/src/WebDrop.Api/Hosting/WebDropApp.cs b/server/src/WebDrop.Api/Hosting/WebDropApp.cs index 99637f0..ae676e8 100644 --- a/server/src/WebDrop.Api/Hosting/WebDropApp.cs +++ b/server/src/WebDrop.Api/Hosting/WebDropApp.cs @@ -51,16 +51,13 @@ public static class WebDropApp builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); options.Files.Validate(); - builder.Services.AddSingleton(_ => options.Files.Enabled ? new RemoteFileGateway(options.Files) : NoFileGateway.Instance); - builder.Services.AddSingleton(); - builder.Services.AddHostedService(sp => sp.GetRequiredService()); - builder.Services.AddHostedService(); + builder.Services.AddSingleton(_ => options.Files.Enabled ? new FileGrants(options.Files) : NoFileGrants.Instance); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(sp => new ChannelService( sp.GetRequiredService(), - new FileAwareBlobStore(sp.GetRequiredService(), sp.GetRequiredService()), - sp.GetRequiredService(), options.Limits, sp.GetRequiredService(), sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService(), options.Limits, sp.GetRequiredService(), sp.GetRequiredService())); builder.Services.AddSingleton(sp => new ShareService( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), options.Limits, options.ShareDraftTtlSeconds)); @@ -123,7 +120,7 @@ public static class WebDropApp app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = LogStreamer.Heartbeat }); app.UseRateLimiter(); EmbeddedFrontend.Use(app, options.Captcha.ApiEndpoint); - app.MapApi(options, app.Services.GetRequiredService(), app.Services.GetRequiredService(), app.Services.GetRequiredService()); + app.MapApi(options, app.Services.GetRequiredService(), app.Services.GetRequiredService(), app.Services.GetRequiredService()); return app; } diff --git a/server/src/WebDrop.Api/Hosting/WebDropOptions.cs b/server/src/WebDrop.Api/Hosting/WebDropOptions.cs index eaa01d1..57d3b94 100644 --- a/server/src/WebDrop.Api/Hosting/WebDropOptions.cs +++ b/server/src/WebDrop.Api/Hosting/WebDropOptions.cs @@ -38,13 +38,13 @@ public sealed class WebDropOptions /// 文件服务(fileAPI)。关闭时文件帧仍存在本进程的存储里。 public FileApiOptions Files { get; set; } = new(); - /// 每天把数据库(聊天、头像、账号)做个快照,压缩后上传到外部存储(WebDAV,如 OpenList),保留 RetainDays 天 + /// 每天把数据库(聊天、头像、账号)做个快照,压缩后上传到外部存储(S3 协议),保留 RetainDays 天 public DailyBackupOptions Backup { get; set; } = new() { Time = "04:15" }; } /// -/// 把受限文件的内容交给另一台机器上的文件服务保存(见 docs 的 fileAPI 说明)。 -/// 主后端保存聊天、头像、空间结构和文件的元数据与摘要;所有文件的字节(不分大小)由客户端直接传给文件服务。 +/// 文件的内容不放主后端,由另一台机器上的文件服务(fileAPI)保存。主后端只保存聊天、头像、空间结构和文件的元数据与摘要, +/// 并按空间里的权限给客户端签发访问文件服务的短期票据;客户端直接和文件服务通信,主后端不调用它。 /// public sealed class FileApiOptions { @@ -53,10 +53,7 @@ public sealed class FileApiOptions /// 浏览器访问文件服务的地址(经反向隧道对外的那个),形如 https://files.example.com public string PublicUrl { get; set; } = ""; - /// 主后端调用文件服务管理接口(删除、对账、复制)的地址,可以是隧道的内网端口,如 http://127.0.0.1:7100 - public string InternalUrl { get; set; } = ""; - - /// 和文件服务共用的密钥,至少 32 个字符。用来签票据、验回执 + /// 和文件服务共用的密钥,至少 32 个字符。用来给票据签名 public string Secret { get; set; } = ""; public int TicketSeconds { get; set; } = 600; @@ -66,7 +63,6 @@ public sealed class FileApiOptions { if (!Enabled) return ""; if (!Uri.TryCreate(PublicUrl, UriKind.Absolute, out var pub) || pub.Scheme is not ("http" or "https")) throw new InvalidOperationException("WebDrop:Files:PublicUrl 必须是 http(s) 绝对地址"); - if (!Uri.TryCreate(InternalUrl, UriKind.Absolute, out var inner) || inner.Scheme is not ("http" or "https")) throw new InvalidOperationException("WebDrop:Files:InternalUrl 必须是 http(s) 绝对地址"); if (Secret.Length < 32) throw new InvalidOperationException("WebDrop:Files:Secret 至少 32 个字符"); return pub.GetLeftPart(UriPartial.Authority).TrimEnd('/'); } diff --git a/server/src/WebDrop.Api/Json/ApiJson.cs b/server/src/WebDrop.Api/Json/ApiJson.cs index cb58c75..0b32912 100644 --- a/server/src/WebDrop.Api/Json/ApiJson.cs +++ b/server/src/WebDrop.Api/Json/ApiJson.cs @@ -28,27 +28,30 @@ public sealed class ServerInfoBody public sealed class FilesInfoBody { - /// true 时,文件的内容(不分大小)由客户端直接传给文件服务,主后端只登记元数据 + /// true 时,超过 threshold_bytes 的文件由客户端直接传给文件服务,主后端只登记元数据 [JsonPropertyName("enabled")] public bool Enabled { get; init; } + /// 超过这个大小的文件才存到文件服务;未开启时为 0 [JsonPropertyName("url")] public string? Url { get; init; } } public sealed class FileGrantRequest { - /// 条目 id(base64url) - [JsonPropertyName("item")] public required string Item { get; init; } - /// put:上传;get:下载或播放 + /// create:创建文件域(空间所有者/管理员);put:上传某个条目;get:下载或播放某个条目;delete:删除某个条目的字节;domain:查询某个频道文件域的容量和用量 [JsonPropertyName("op")] public required string Op { get; init; } + /// put/get/delete 时的条目 id(base64url) + [JsonPropertyName("item")] public string? Item { get; init; } + /// domain 时的频道 id:查询这个频道文件域的容量和用量 + [JsonPropertyName("channel")] public string? Channel { get; init; } /// get 时从第几帧开始(0 才计一次下载) [JsonPropertyName("from")] public long From { get; init; } } +/// 去文件服务用的凭证:地址、文件域 id(create 时为空)和一张短期票据 public sealed class FileGrantBody { - /// local:这个条目的内容在主后端,走原来的接口;remote:去 url 用 ticket 取/传 - [JsonPropertyName("mode")] public required string Mode { get; init; } - [JsonPropertyName("url")] public string? Url { get; init; } - [JsonPropertyName("ticket")] public string? Ticket { get; init; } + [JsonPropertyName("url")] public required string Url { get; init; } + [JsonPropertyName("domain")] public required string Domain { get; init; } + [JsonPropertyName("ticket")] public required string Ticket { get; init; } [JsonPropertyName("expires_at")] public long ExpiresAt { get; init; } } @@ -87,7 +90,6 @@ public sealed class LimitsBody [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_members")] public required int MaxMembers { get; init; } [JsonPropertyName("max_ttl_seconds")] public required int MaxTtlSeconds { get; init; } [JsonPropertyName("min_ttl_seconds")] public required int MinTtlSeconds { get; init; } @@ -149,6 +151,8 @@ public sealed class InvitationBody [JsonPropertyName("username")] public required string Username { get; init; } [JsonPropertyName("payload")] public required string Payload { get; init; } [JsonPropertyName("created_at")] public required long CreatedAt { get; init; } + /// 调用者能不能撤销这条邀请(后端按权限判断) + [JsonPropertyName("can_revoke")] public bool CanRevoke { get; init; } } public sealed class JoinRequest { [JsonPropertyName("password")] public string? Password { get; init; } } public sealed class KeyPackageRequest { [JsonPropertyName("payload")] public required string Payload { get; init; } } @@ -165,6 +169,7 @@ public sealed class AccountDeviceBody [JsonPropertyName("name")] public required string Name { get; init; } [JsonPropertyName("created_at")] public required long CreatedAt { get; init; } [JsonPropertyName("last_seen")] public required long LastSeen { get; init; } + [JsonPropertyName("current")] public required bool Current { get; init; } } public sealed class AccountChannelBody @@ -189,6 +194,9 @@ public sealed class MemberBody /// 该账号任一设备最近一次活跃的 Unix 时间戳(登录时是秒,活跃续期后是毫秒,客户端按量级区分);没有登录过设备时为 0。 [JsonPropertyName("last_seen")] public required long LastSeen { get; init; } + + /// 调用者能不能移除这位成员(后端按权限判断)。 + [JsonPropertyName("can_remove")] public bool CanRemove { get; init; } } public sealed class AccountProfileRequest @@ -205,7 +213,10 @@ public sealed class WorkspaceChannelRequest { [JsonPropertyName("name")] public required string Name { get; init; } [JsonPropertyName("groupId")] public string? GroupId { get; init; } + /// 文件服务里为这个频道创建的文件域 id(不透明字符串,主后端只存不解释) + [JsonPropertyName("fileDomain")] public string? FileDomain { get; init; } } +public sealed class WorkspaceFileDomainRequest { [JsonPropertyName("fileDomain")] public required string FileDomain { get; init; } } public sealed class WorkspaceRoleRequest { [JsonPropertyName("name")] public required string Name { get; init; } @@ -269,6 +280,7 @@ public sealed class WorkspaceGroupCreatedBody [JsonSerializable(typeof(WorkspaceGroupCreatedBody))] [JsonSerializable(typeof(WorkspaceNameRequest))] [JsonSerializable(typeof(WorkspaceChannelRequest))] +[JsonSerializable(typeof(WorkspaceFileDomainRequest))] [JsonSerializable(typeof(WorkspaceGroupRequest))] [JsonSerializable(typeof(WorkspaceRoleRequest))] [JsonSerializable(typeof(WorkspaceRoleMembersRequest))] diff --git a/server/src/WebDrop.Core/Channels/ChannelAdmission.cs b/server/src/WebDrop.Core/Channels/ChannelAdmission.cs index c30ba63..de7aac1 100644 --- a/server/src/WebDrop.Core/Channels/ChannelAdmission.cs +++ b/server/src/WebDrop.Core/Channels/ChannelAdmission.cs @@ -30,15 +30,33 @@ public sealed partial class ChannelService public IReadOnlyList Invitations(Id16 id, Credentials creds) => store.Read(tx => { var (_, principal) = Authenticate(tx, id, creds, Membership.None); - if (principal.Role != Role.Owner) RequireMemberPermission(tx, id, creds, WorkspacePermission.RemoveMembers); + if (!ChannelPolicy.IsOwner(principal)) RequireMemberPermission(tx, id, creds, WorkspacePermission.RemoveMembers); return tx.ListInvitations(id); }); + /// 成员列表,附带调用者能不能移除每一位(后端判断,前端只显示按钮) + public IReadOnlyList MembersView(Id16 id, Credentials creds) => store.Read(tx => + { + var (_, principal) = Authenticate(tx, id, creds, Membership.None); + if (!ChannelPolicy.IsOwner(principal)) _ = Authenticate(tx, id, creds, Membership.Required); + var state = tx.GetWorkspace(id); + return tx.ListChannelMembers(id).Select(m => new MemberView(m, ChannelPolicy.CanRemoveMember(state, principal, m.Username, m.Role == "owner"))).ToList(); + }); + + /// 邀请列表,附带调用者能不能撤销每一条 + public IReadOnlyList InvitationsView(Id16 id, Credentials creds) => store.Read(tx => + { + var (_, principal) = Authenticate(tx, id, creds, Membership.None); + if (!ChannelPolicy.IsOwner(principal)) principal = RequireMemberPermission(tx, id, creds, WorkspacePermission.RemoveMembers); + var state = tx.GetWorkspace(id); + return tx.ListInvitations(id).Select(i => new InvitationView(i, ChannelPolicy.CanRemoveMember(state, principal, i.Username, false))).ToList(); + }); + /// 非空间主的调用方:必须是已加入的成员,且所在身份组被授予该权限 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); + if (!ChannelPolicy.HasPermission(tx.GetWorkspace(id), principal, permission)) throw new WebDropException(ErrorCode.Forbidden); return principal; } @@ -49,13 +67,9 @@ public sealed partial class ChannelService store.Write(tx => { var (_, principal) = Authenticate(tx, id, creds, Membership.None); - 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); - } + bool targetIsOwner = tx.GetChannelMember(id, username)?.Role == "owner"; + if (!ChannelPolicy.IsOwner(principal)) principal = RequireMemberPermission(tx, id, creds, WorkspacePermission.RemoveMembers); + if (!ChannelPolicy.CanRemoveMember(tx.GetWorkspace(id), principal, username, targetIsOwner)) throw new WebDropException(ErrorCode.Forbidden); tx.DeleteInvitation(id, username); tx.DeleteKeyPackage(id, username); tx.DeleteChannelMember(id, username); @@ -76,7 +90,7 @@ public sealed partial class ChannelService public IReadOnlyList Members(Id16 id, Credentials creds) => store.Read(tx => { var (_, principal) = Authenticate(tx, id, creds, Membership.None); - if (principal.Role != Role.Owner) _ = Authenticate(tx, id, creds, Membership.Required); + if (!ChannelPolicy.IsOwner(principal)) _ = Authenticate(tx, id, creds, Membership.Required); return tx.ListChannelMembers(id); }); diff --git a/server/src/WebDrop.Core/Channels/ChannelPolicy.cs b/server/src/WebDrop.Core/Channels/ChannelPolicy.cs index 671a326..4c4c6c3 100644 --- a/server/src/WebDrop.Core/Channels/ChannelPolicy.cs +++ b/server/src/WebDrop.Core/Channels/ChannelPolicy.cs @@ -12,15 +12,92 @@ public static class ChannelPolicy p.Role == Role.Owner || (p.Role == Role.Write && channel.Settings.MembersCanUpload && channel.WriteHash is not null); - public static bool CanDeleteItem(Channel channel, Item item, Principal p) + // ───── 空间所有者与频道访问授权(ChannelAccess):谁能读、写哪个频道的哪个数据域。其他地方只调用这里,不再各写一遍 + + /// 空间的所有者:持有所有者令牌的人,或者空间的创建账号 + public static bool IsSpaceOwner(Channel space, Principal p) => p.Role == Role.Owner || p.AccountName == space.CreatorAccount; + + /// 某个账号在某个频道上的访问授权;没有记录返回 null + public static ChannelAccess? AccessOf(CryptoState crypto, string? account, string channelId) => + crypto.Access.Find(a => a.ChannelId == channelId && a.Username == account); + + /// 这条授权允许读这个数据域 + public static bool CanReadDomain(ChannelAccess? access, CryptoDomain domain) => + access is not null && (domain == CryptoDomain.Chat ? access.ChatRead : access.FilesRead); + + /// 这条授权允许写这个数据域(聊天域写消息,文件域上传) + public static bool CanWriteDomain(ChannelAccess? access, CryptoDomain domain) => + access is not null && (domain == CryptoDomain.Chat ? access.ChatWrite : access.FilesUpload); + + /// 读一个对象:能读它所在的数据域,并且对象不早于授权生效的版本(关闭历史可读时,新成员看不到加入前的内容) + public static bool CanReadObject(ChannelAccess? access, CryptoDomain domain, long objectRevision) => + CanReadDomain(access, domain) && objectRevision >= (domain == CryptoDomain.Chat ? access!.ChatFromRevision : access!.FilesFromRevision); + + /// 频道对这个账号可见:聊天或文件任一域能读 + public static bool CanSeeChannel(ChannelAccess access) => access.ChatRead || access.FilesRead; + + /// 能碰这个频道的文件域:能读或能上传 + public static bool CanUseFiles(ChannelAccess access) => access.FilesRead || access.FilesUpload; + + /// 能往频道里放东西:聊天或文件任一域能写 + public static bool CanWriteAnyDomain(ChannelAccess? access) => access is not null && (access.ChatWrite || access.FilesUpload); + + /// 这个频道里能读某个数据域的账号 + public static IEnumerable Readers(CryptoState crypto, string channelId, CryptoDomain domain) => + crypto.Access.Where(a => a.ChannelId == channelId && CanReadDomain(a, domain)); + + /// + /// 这个空间是否已经按授权记录来约束成员:加密空间有了密钥信封,明文空间有了授权记录或对象登记之后才约束。 + /// 之前(刚建好、还没初始化)所有成员的行为和旧版一致。 + /// + public static bool GrantsEnforced(Channel space, CryptoState crypto) => + space.Mode == ChannelMode.Enc ? crypto.Envelopes.Count > 0 : crypto.Access.Count > 0 || crypto.Objects.Count > 0; + + // ───── 空间权限(身份组授予)与结构管理 + + /// 有没有这项空间权限:所有者拥有全部,其余看身份组授予 + public static bool HasPermission(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; + + /// 重命名频道、调整频道所属分组、管理分组(所有者,或被授予「管理频道和分组」) + public static bool CanManageStructure(WorkspaceState? state, Principal p) => HasPermission(state, p, WorkspacePermission.ManageStructure); + + /// 移除成员、撤销邀请的权限(所有者,或被授予「移除成员」)。具体能移除谁见 + public static bool CanRemoveMembers(WorkspaceState? state, Principal p) => HasPermission(state, p, WorkspacePermission.RemoveMembers); + + /// + /// 谁能移除谁:所有者能移除除自己外的任何人;被授权的成员不能移除自己、空间主,也不能移除同样有移除权限的人。 + /// 执行(RevokeInvitation)和给前端的可移除标记(MembersView)都用这一处。 + /// + public static bool CanRemoveMember(WorkspaceState? state, Principal acting, string target, bool targetIsOwner) { - if (p.Role == Role.Owner) return true; - if (p.Role < Role.Read) return false; - return channel.Settings.DeletePolicy switch - { - DeletePolicy.Anyone => true, - DeletePolicy.Own => item.AuthorAccount == p.AccountName, - _ => false, - }; + if (targetIsOwner) return false; + if (acting.Role == Role.Owner) return true; + return acting.AccountName is { } actor && actor != target + && CanRemoveMembers(state, acting) + && !CanRemoveMembers(state, new Principal(Role.Write, target)); } + + // 下面这几项目前都只有所有者:新建频道(涉及密钥)、文件夹、邀请、改空间设置。要放开时只改这里 + + public static bool CanCreateChannel(Principal p) => IsOwner(p); + public static bool CanManageFolders(Principal p) => IsOwner(p); + public static bool CanInvite(Principal p) => IsOwner(p); + public static bool CanEditSettings(Principal p) => IsOwner(p); + public static bool CanManageRoles(Principal p) => IsOwner(p); + + // ───── 删除条目 + + /// 能删任何人发的条目:所有者,或空间的删除策略是 anyone + public static bool CanDeleteAnyItem(Channel channel, Principal p) => + IsOwner(p) || p.Role >= Role.Read && channel.Settings.DeletePolicy == DeletePolicy.Anyone; + + /// 能删自己发的条目:所有者,或删除策略是 own / anyone + public static bool CanDeleteOwnItem(Channel channel, Principal p) => + IsOwner(p) || p.Role >= Role.Read && channel.Settings.DeletePolicy is DeletePolicy.Own or DeletePolicy.Anyone; + + /// 某个具体条目能不能删:能删任何人的,或者是自己的并且能删自己的 + public static bool CanDeleteItem(Channel channel, Item item, Principal p) => + CanDeleteAnyItem(channel, p) || item.AuthorAccount == p.AccountName && CanDeleteOwnItem(channel, p); } diff --git a/server/src/WebDrop.Core/Channels/ChannelService.cs b/server/src/WebDrop.Core/Channels/ChannelService.cs index 1893371..0897994 100644 --- a/server/src/WebDrop.Core/Channels/ChannelService.cs +++ b/server/src/WebDrop.Core/Channels/ChannelService.cs @@ -13,10 +13,10 @@ public sealed partial class ChannelService( IClock clock, ServerLimits limits, IChannelEvents events, - IFileGateway? fileGateway = null) + IFileGrants? fileGrants = null) { - private IFileGateway Files => fileGateway ?? NoFileGateway.Instance; - /// 帧内容在文件服务上的帧,BlobKey 以它开头;主后端只有头部、长度和摘要 + private IFileGrants Files => fileGrants ?? NoFileGrants.Instance; + /// 帧内容在文件域里的帧,BlobKey 以它开头;主后端只有头部、长度和摘要 public const string FilesPrefix = "files:"; /// 这个条目是文件域的对象(不含目录控制帧) private static bool IsFileObject(IResourceTx tx, Id16 channelId, Envelope env) => @@ -79,7 +79,7 @@ public sealed partial class ChannelService( public ChannelInfo GetChannel(Id16 id, Credentials creds) => store.Read(tx => { var (channel, principal) = Authenticate(tx, id, creds, Membership.None); - return ToInfo(channel, tx.CountMembers(id), principal, tx.GetChannelAvatar(id, thumb: true)); + return ToInfo(channel, tx.CountMembers(id), principal, tx.GetChannelAvatar(id, thumb: true), tx.GetWorkspace(id)); }); /// 空间头像原图(预览时才下载)。需要能读这个空间 @@ -171,9 +171,9 @@ public sealed partial class ChannelService( { var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required); CheckWritable(channel, principal, env); - CheckObjectWrite(tx, channel, principal, env); - // 开启文件服务后,文件的内容(文件域的对象)只能存在文件服务上 - if (Files.Enabled && IsFileObject(tx, channelId, env)) throw new WebDropException(ErrorCode.UseFileApi); + CheckObjectWrite(tx, channel, principal, env.ItemId); + // 文件域的对象不存主后端:字节在文件域里,主后端只在整个文件传完后一次登记头部和摘要(见 RegisterFileItem) + if (IsFileObject(tx, channelId, env)) throw new WebDropException(ErrorCode.UseFileApi); return tx.GetFrame(channelId, env.ItemId, env.FrameIndex); }); if (existing is not null) return Resolve(existing, digest); @@ -205,78 +205,164 @@ public sealed partial class ChannelService( return outcome.Result; } + /// 登记整个文件时,每一帧的负载长度和摘要(摘要是整帧封装的 SHA-256) + public readonly record struct FrameEntry(int PayloadLength, byte[] Digest); + /// - /// 登记已经存进文件服务的一帧:请求体只有 26 字节头部,另带文件服务签的回执(摘要、长度)。 - /// 之后的规则(帧序、配额、清单、签名事件、日志)和 完全一样。 + /// 一次登记整个文件:字节都在文件域里,逐帧登记要为每个 1 MiB 往返一次主后端,大文件的上传速度被它拖死。 + /// 请求带的是末帧的头部和每一帧的(长度,摘要)。规则和逐帧登记完全一样:第 0 帧创建条目(含下载次数、日志里的公布), + /// 中间帧只落库,末帧验签名事件(清单摘要要和这里登记的所有摘要对得上)。整个登记在一个事务里,要么全成要么全不成。 + /// 重复登记同一个文件(摘要全一致)幂等。 /// - public async Task CommitRemoteFrameAsync( - Id16 channelId, Credentials creds, ReadOnlyMemory header, string receiptToken, + public AppendResult RegisterFileItem( + Id16 channelId, Credentials creds, ReadOnlyMemory finalHeader, IReadOnlyList frames, long? maxDownloads, SignedContentEventProof? signedEvent = null) { - if (!Files.Enabled) throw new WebDropException(ErrorCode.NotFound); - if (!Envelope.TryParseHeader(header.Span, out var env)) throw new WebDropException(ErrorCode.BadRequest); - if (env.PayloadLength > limits.MaxPayloadBytes) throw new WebDropException(ErrorCode.TooLarge); - ValidateShape(env); - if (!Files.TryVerifyReceipt(receiptToken, clock.NowMs, out var receipt) - || receipt.Channel != channelId.ToHex() || receipt.Item != env.ItemId.ToHex() - || receipt.Index != env.FrameIndex || receipt.Length != env.TotalLength) - throw new WebDropException(ErrorCode.Forbidden); - byte[] digest = Convert.FromHexString(receipt.Digest); + if (!Envelope.TryParseHeader(finalHeader.Span, out var last) || !last.IsFinal || frames.Count < 1 + || last.FrameIndex != frames.Count - 1 || frames[^1].PayloadLength != last.PayloadLength) throw new WebDropException(ErrorCode.BadRequest); + if (frames.Count > limits.MaxFramesPerItem) throw new WebDropException(ErrorCode.TooLarge); + if (frames.Any(f => f.Digest.Length != 32 || f.PayloadLength < 0 || f.PayloadLength > limits.MaxPayloadBytes)) throw new WebDropException(ErrorCode.BadRequest); + // 每一帧的头部由末帧头部推出:条目级标志一致,只有末帧带 FINAL + FrameFlags itemFlags = last.Flags & ~FrameFlags.Final; + Envelope EnvelopeOf(int index) => new(index == frames.Count - 1 ? last.Flags : itemFlags, last.ItemId, (uint)index, frames[index].PayloadLength); + ValidateShape(EnvelopeOf(0)); ValidateShape(last); - var existing = store.Read(tx => + var outcomes = store.Write<(AppendOutcome? First, AppendOutcome? Last, long Seq)>(tx => { var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required); - CheckWritable(channel, principal, env); - CheckObjectWrite(tx, channel, principal, env); - if (!IsFileObject(tx, channelId, env)) throw new WebDropException(ErrorCode.BadRequest); - return tx.GetFrame(channelId, env.ItemId, env.FrameIndex); - }); - if (existing is not null) return Resolve(existing, digest); + for (int i = 0; i < frames.Count; i++) CheckWritable(channel, principal, EnvelopeOf(i)); + CheckObjectWrite(tx, channel, principal, last.ItemId); + if (!IsFileObject(tx, channelId, last)) throw new WebDropException(ErrorCode.BadRequest); - string blobKey = $"{FilesPrefix}{channelId.ToHex()}/{env.ItemId.ToHex()}/{env.FrameIndex}"; - var outcome = store.Write(tx => AppendInTx(tx, channelId, creds, env, ReadOnlyMemory.Empty, digest, blobKey, maxDownloads, signedEvent)); - foreach (string prefix in outcome.BlobPrefixesToDelete) blobs.DeletePrefix(prefix); - events.Advanced(channelId, outcome.LastSeq); - if (outcome.NotifySettings) events.SettingsChanged(channelId); - return outcome.Result; + // 已经登记过同一个文件:摘要全部一致就是重复请求,否则冲突 + if (tx.GetItem(channelId, last.ItemId) is not null) + { + var stored = tx.ListFrames(channelId, last.ItemId, 0, limits.MaxFramesPerItem); + if (stored.Count != frames.Count || stored.Select((f, i) => f.Index == i && f.Digest.AsSpan().SequenceEqual(frames[i].Digest)).Any(same => !same)) + throw new WebDropException(ErrorCode.Conflict); + return (First: (AppendOutcome?)null, Last: (AppendOutcome?)null, Seq: stored[^1].Seq); + } + long middleBytes = 0; + for (int i = 1; i < frames.Count - 1; i++) middleBytes += EnvelopeOf(i).TotalLength; + if (channel.Bytes + middleBytes > limits.MaxWorkspaceBytes) throw new WebDropException(ErrorCode.TooLarge); + + string BlobKeyOf(int i) => $"{FilesPrefix}{channelId.ToHex()}/{last.ItemId.ToHex()}/{i}"; + var first = AppendInTx(tx, channelId, creds, EnvelopeOf(0), ReadOnlyMemory.Empty, frames[0].Digest, BlobKeyOf(0), maxDownloads, + frames.Count == 1 ? signedEvent : null); + if (frames.Count == 1) return (First: (AppendOutcome?)first, Last: null, Seq: first.LastSeq); + + // 中间帧(受限条目的中间帧不进日志):只落库,条目计数一次性补上 + for (int i = 1; i < frames.Count - 1; i++) + { + var env = EnvelopeOf(i); + tx.InsertFrame(new StoredFrame(channelId, last.ItemId, (uint)i, tx.NextSeq(channelId), env.TotalLength, frames[i].Digest, null, BlobKeyOf(i))); + } + if (frames.Count > 2) + { + var item = tx.GetItem(channelId, last.ItemId)!; + tx.UpdateItem(item with { FrameCount = item.FrameCount + frames.Count - 2, MaxIndex = Math.Max(item.MaxIndex, frames.Count - 2), Bytes = item.Bytes + middleBytes }); + tx.AddChannelBytes(channelId, middleBytes); + } + var final = AppendInTx(tx, channelId, creds, last, ReadOnlyMemory.Empty, frames[^1].Digest, BlobKeyOf(frames.Count - 1), null, signedEvent); + return (First: (AppendOutcome?)first, Last: final, Seq: final.Result.Seq); + }); + + foreach (var outcome in new[] { outcomes.First, outcomes.Last }) + { + if (outcome is null) continue; + foreach (string prefix in outcome.BlobPrefixesToDelete) blobs.DeletePrefix(prefix); + events.Advanced(channelId, outcome.LastSeq); + if (outcome.NotifySettings) events.SettingsChanged(channelId); + } + return new AppendResult { Seq = outcomes.Seq, Created = outcomes.First is not null }; } - /// 主后端有没有这个条目的记录(文件服务对账用) - public bool ItemExists(Id16 channelId, Id16 itemId) => store.Read(tx => tx.GetItem(channelId, itemId) is not null); - - /// 上传票据:确认能写这个频道,然后给客户端文件服务的地址和一张只对这个条目有效的票据。 - public FileGrant? IssueUploadGrant(Id16 id, Credentials creds, Id16 itemId) + /// 条目所属频道的文件域(文件域的对象才有) + private static string? FileDomainOf(IResourceTx tx, Id16 spaceId, Id16 itemId) { - if (!Files.Enabled) return null; + var obj = tx.GetWorkspaceCrypto(spaceId).Objects.Find(o => o.ItemId == itemId.ToBase64Url() && o.Domain == CryptoDomain.Files); + return obj is null ? null : tx.GetWorkspace(spaceId)?.Channels.Find(c => c.Id == obj.ChannelId)?.FileDomain; + } + + /// 创建文件域的票据:只有空间所有者和有「管理频道和分组」权限的人能要。票据绑定空间 id + public FileGrant IssueCreateGrant(Id16 spaceId, Credentials creds) + { + if (!Files.Enabled) throw new WebDropException(ErrorCode.NotFound); store.Read(tx => { - var (channel, principal) = Authenticate(tx, id, creds, Membership.Required); - if (!ChannelPolicy.CanWrite(channel, principal)) throw new WebDropException(ErrorCode.Forbidden); + var (space, principal) = Authenticate(tx, spaceId, creds, Membership.Required); + if (!ChannelPolicy.CanManageStructure(tx.GetWorkspace(spaceId), principal)) throw new WebDropException(ErrorCode.Forbidden); return 0; }); - return Files.Issue(FileOp.Put, id, itemId, 0, clock.NowMs); + return Files.Issue(FileOp.Create, spaceId.ToHex(), spaceId.ToHex(), "*", 0, clock.NowMs); + } + + /// 上传票据:确认能写这个条目所在的频道,然后给客户端文件域的访问凭证。 + public FileGrant IssueUploadGrant(Id16 id, Credentials creds, Id16 itemId) + { + if (!Files.Enabled) throw new WebDropException(ErrorCode.NotFound); + string domain = store.Read(tx => + { + var (space, principal) = Authenticate(tx, id, creds, Membership.Required); + if (!ChannelPolicy.CanWrite(space, principal)) throw new WebDropException(ErrorCode.Forbidden); + CheckObjectWrite(tx, space, principal, itemId); + return FileDomainOf(tx, id, itemId) ?? throw new WebDropException(ErrorCode.Conflict); + }); + return Files.Issue(FileOp.Put, id.ToHex(), domain, itemId.ToHex(), 0, clock.NowMs); } /// - /// 下载票据。条目的帧在主后端时返回 null(客户端走原来的接口,这里不计次);在文件服务时, - /// 先按原来的规则计一次下载(只有 from=0 才计),再签票据。条目没传完时返回冲突,让客户端稍后再来。 + /// 文件域的查询票据(看容量和用量):能读这个频道的文件就行。票据的条目是 "*",只能查域,读不了任何文件。 /// - public FileGrant? IssueDownloadGrant(Id16 id, Credentials creds, Id16 itemId, uint fromIndex) + public FileGrant IssueDomainGrant(Id16 id, Credentials creds, string threadId) { - if (!Files.Enabled) return null; - bool remote = store.Read(tx => + if (!Files.Enabled) throw new WebDropException(ErrorCode.NotFound); + string domain = store.Read(tx => + { + var (space, principal) = Authenticate(tx, id, creds, Membership.Required); + var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound); + var thread = workspace.Channels.Find(c => c.Id == threadId) ?? throw new WebDropException(ErrorCode.NotFound); + var crypto = tx.GetWorkspaceCrypto(id); + bool allowed = ChannelPolicy.IsSpaceOwner(space, principal) + || crypto.Access.Count == 0 && space.Mode == ChannelMode.Raw + || ChannelPolicy.AccessOf(crypto, principal.AccountName, threadId) is { } access && ChannelPolicy.CanUseFiles(access); + if (!allowed) throw new WebDropException(ErrorCode.Forbidden); + return thread.FileDomain ?? throw new WebDropException(ErrorCode.NotFound); + }); + return Files.Issue(FileOp.Get, id.ToHex(), domain, "*", 0, clock.NowMs); + } + + /// 删除票据:确认调用者按原来的规则能删这个条目,再签。客户端先拿票据,再删主后端的条目,最后用票据清文件域里的字节 + public FileGrant IssueDeleteGrant(Id16 id, Credentials creds, Id16 itemId) + { + if (!Files.Enabled) throw new WebDropException(ErrorCode.NotFound); + string domain = store.Read(tx => + { + var (space, principal) = Authenticate(tx, id, creds, Membership.Required); + var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound); + if (!ChannelPolicy.CanDeleteItem(space, item, principal)) throw new WebDropException(ErrorCode.Forbidden); + return FileDomainOf(tx, id, itemId) ?? throw new WebDropException(ErrorCode.NotFound); + }); + return Files.Issue(FileOp.Delete, id.ToHex(), domain, itemId.ToHex(), 0, clock.NowMs); + } + + /// + /// 下载票据:按原来的规则鉴权并计次(只有 from=0 才计),再签票据。条目没传完时返回冲突,让客户端稍后再来。 + /// + public FileGrant IssueDownloadGrant(Id16 id, Credentials creds, Id16 itemId, uint fromIndex) + { + if (!Files.Enabled) throw new WebDropException(ErrorCode.NotFound); + string domain = store.Read(tx => { 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); - if (!IsRemote(tx.GetFrame(id, itemId, 0)?.BlobKey)) return false; if (item.FinalIndex is null) throw new WebDropException(ErrorCode.Conflict); - return true; + return FileDomainOf(tx, id, itemId) ?? throw new WebDropException(ErrorCode.NotFound); }); - if (!remote) return null; StartItemDownload(id, creds, itemId, fromIndex, allowRemote: true); - return Files.Issue(FileOp.Get, id, itemId, fromIndex, clock.NowMs); + return Files.Issue(FileOp.Get, id.ToHex(), domain, itemId.ToHex(), fromIndex, clock.NowMs); } private sealed class AppendOutcome(AppendResult result, long lastSeq, List prefixes, bool notifySettings = false) @@ -293,7 +379,7 @@ public sealed partial class ChannelService( { var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required); CheckWritable(channel, principal, env); - CheckObjectWrite(tx, channel, principal, env); + CheckObjectWrite(tx, channel, principal, env.ItemId); int bodyLength = IsRemote(blobKey) ? env.TotalLength : body.Length; // 并发下预检之后可能已有同一帧 @@ -308,7 +394,6 @@ public sealed partial class ChannelService( 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) { @@ -376,8 +461,6 @@ public sealed partial class ChannelService( if (objectEnvelope is null && channel.Bytes + bodyLength > limits.MaxChannelBytes || objectEnvelope is not null && channel.Bytes + bodyLength > limits.MaxWorkspaceBytes) throw new WebDropException(ErrorCode.TooLarge); - if (countsTowardFileQuota && workspaceChannel!.FileBytes + bodyLength > limits.MaxWorkspaceChannelBytes) - throw new WebDropException(ErrorCode.TooLarge); long seq = tx.NextSeq(channelId); tx.InsertFrame(new StoredFrame(channelId, env.ItemId, env.FrameIndex, seq, bodyLength, digest, @@ -433,16 +516,7 @@ public sealed partial class ChannelService( if (pruned > channel.CompactedThrough) tx.SetCompactedThrough(channelId, pruned); } - 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 += bodyLength; - tx.PutWorkspace(channelId, latestWorkspace); - } - - return new AppendOutcome(new AppendResult { Seq = seq, Created = true }, lastSeq, prefixes, - countsTowardFileQuota && env.IsFinal); + return new AppendOutcome(new AppendResult { Seq = seq, Created = true }, lastSeq, prefixes); } private static AppendResult Resolve(StoredFrame existing, byte[] digest) => @@ -649,18 +723,6 @@ public sealed partial class ChannelService( 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); @@ -780,11 +842,24 @@ public sealed partial class ChannelService( if (!ChannelPolicy.IsOwner(principal)) throw new WebDropException(ErrorCode.Forbidden); } - private static ChannelInfo ToInfo(Channel channel, int membersUsed, Principal? principal = null, string? avatar = null) => new() + private static ChannelInfo ToInfo(Channel channel, int membersUsed, Principal? principal = null, string? avatar = null, WorkspaceState? workspace = null) { + // 没有调用者(创建、改设置时)就是所有者本人 + var who = principal ?? new Principal(Role.Owner, channel.CreatorAccount); + return new() + { Avatar = avatar, - CanUpload = ChannelPolicy.CanWrite(channel, principal ?? new Principal(Role.Owner, channel.CreatorAccount)), - CanManageStructure = principal is null || principal.Role == Role.Owner, + IsOwner = ChannelPolicy.IsOwner(who), + CanUpload = ChannelPolicy.CanWrite(channel, who), + CanManageStructure = ChannelPolicy.CanManageStructure(workspace, who), + CanCreateChannel = ChannelPolicy.CanCreateChannel(who), + CanManageFolders = ChannelPolicy.CanManageFolders(who), + CanInvite = ChannelPolicy.CanInvite(who), + CanEditSettings = ChannelPolicy.CanEditSettings(who), + CanManageRoles = ChannelPolicy.CanManageRoles(who), + CanRemoveMembers = ChannelPolicy.CanRemoveMembers(workspace, who), + CanDeleteAny = ChannelPolicy.CanDeleteAnyItem(channel, who), + CanDeleteOwn = ChannelPolicy.CanDeleteOwnItem(channel, who), Id = channel.Id.ToBase64Url(), CreatorAccount = channel.CreatorAccount, PasswordRequired = channel.JoinPasswordHash is not null, @@ -795,5 +870,6 @@ public sealed partial class ChannelService( LastSeq = channel.LastSeq, CreatedAt = channel.CreatedAt, ExpiresAt = channel.ExpiresAt, - }; + }; + } } diff --git a/server/src/WebDrop.Core/Channels/Dtos.cs b/server/src/WebDrop.Core/Channels/Dtos.cs index f277eb8..384f73b 100644 --- a/server/src/WebDrop.Core/Channels/Dtos.cs +++ b/server/src/WebDrop.Core/Channels/Dtos.cs @@ -52,8 +52,23 @@ public sealed class ShareItemInfo public sealed class ChannelInfo { + // ───── 调用者在这个空间里的能力。前端只读不算:规则只在后端一处 + /// 调用者是空间所有者 + [JsonPropertyName("is_owner")] public bool IsOwner { get; init; } [JsonPropertyName("can_upload")] public bool CanUpload { get; init; } + /// 能重命名频道、管理分组(所有者,或被授予「管理频道和分组」) [JsonPropertyName("can_manage_structure")] public bool CanManageStructure { get; init; } + [JsonPropertyName("can_create_channel")] public bool CanCreateChannel { get; init; } + [JsonPropertyName("can_manage_folders")] public bool CanManageFolders { get; init; } + [JsonPropertyName("can_invite")] public bool CanInvite { get; init; } + [JsonPropertyName("can_edit_settings")] public bool CanEditSettings { get; init; } + [JsonPropertyName("can_manage_roles")] public bool CanManageRoles { get; init; } + /// 能移除成员、撤销邀请;具体能移除谁见成员列表里每一项的 can_remove + [JsonPropertyName("can_remove_members")] public bool CanRemoveMembers { get; init; } + /// 能删除任何人的消息和文件(所有者,或删除策略为 anyone) + [JsonPropertyName("can_delete_any")] public bool CanDeleteAny { get; init; } + /// 能删除自己发的消息和文件 + [JsonPropertyName("can_delete_own")] public bool CanDeleteOwn { get; init; } [JsonPropertyName("password_required")] public bool PasswordRequired { get; init; } [JsonPropertyName("creator_account")] public string? CreatorAccount { get; init; } /// 空间头像(data URL,PNG/JPEG/WebP/GIF),没有设置时为 null。 diff --git a/server/src/WebDrop.Core/Channels/Settings.cs b/server/src/WebDrop.Core/Channels/Settings.cs index 8e556af..3dfd121 100644 --- a/server/src/WebDrop.Core/Channels/Settings.cs +++ b/server/src/WebDrop.Core/Channels/Settings.cs @@ -9,7 +9,6 @@ public sealed class ServerLimits 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 MaxMembers { get; set; } = 64; public int MaxVisitors { get; set; } = 10_000; diff --git a/server/src/WebDrop.Core/Common/Enums.cs b/server/src/WebDrop.Core/Common/Enums.cs index fec6786..3eb74d2 100644 --- a/server/src/WebDrop.Core/Common/Enums.cs +++ b/server/src/WebDrop.Core/Common/Enums.cs @@ -66,3 +66,10 @@ public enum TombstoneReason : byte DownloadLimit = 3, Superseded = 4, } + +/// 便捷分享的状态(线格式的取值)。前端从生成的契约里取,不手写字符串 +public static class ShareState +{ + public const string Draft = "draft"; + public const string Sealed = "sealed"; +} diff --git a/server/src/WebDrop.Core/Common/Models.cs b/server/src/WebDrop.Core/Common/Models.cs index 922ca89..c24b9c1 100644 --- a/server/src/WebDrop.Core/Common/Models.cs +++ b/server/src/WebDrop.Core/Common/Models.cs @@ -84,6 +84,8 @@ public sealed record Item( 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 MemberView(ChannelMember Member, bool CanRemove); +public sealed record InvitationView(ChannelInvitation Invitation, bool CanRevoke); public sealed record ChannelKeyPackage(Id16 ChannelId, string Username, string Role, string Payload, long CreatedAt); /// 已存储的一帧(完整封装)。Inline 与 BlobKey 二选一。 diff --git a/server/src/WebDrop.Core/Files/IFileGateway.cs b/server/src/WebDrop.Core/Files/IFileGateway.cs deleted file mode 100644 index d4114a9..0000000 --- a/server/src/WebDrop.Core/Files/IFileGateway.cs +++ /dev/null @@ -1,28 +0,0 @@ -using WebDrop.FileProtocol; - -namespace WebDrop.Core; - -/// 给客户端的文件服务入口:地址 + 一张短期票据 -public sealed record FileGrant(string Url, string Ticket, long ExpiresAt); - -/// -/// 主后端和文件服务之间的接口。关闭时(默认)所有帧仍然存在主后端自己的存储里,行为和以前完全一样; -/// 打开后,文件的内容(文件域的对象,不分大小)由客户端直接传给文件服务,主后端只登记元数据和摘要;聊天、头像、目录仍在主后端。 -/// -public interface IFileGateway -{ - bool Enabled { get; } - /// 客户端拿到的文件服务根地址(经反向隧道对外的那个) - string PublicUrl { get; } - FileGrant Issue(FileOp op, Id16 channel, Id16 item, uint from, long nowMs); - bool TryVerifyReceipt(string? token, long nowMs, out FileReceipt receipt); -} - -public sealed class NoFileGateway : IFileGateway -{ - public static readonly NoFileGateway Instance = new(); - public bool Enabled => false; - public string PublicUrl => ""; - public FileGrant Issue(FileOp op, Id16 channel, Id16 item, uint from, long nowMs) => throw new WebDropException(ErrorCode.NotFound); - public bool TryVerifyReceipt(string? token, long nowMs, out FileReceipt receipt) { receipt = null!; return false; } -} diff --git a/server/src/WebDrop.Core/Files/IFileGrants.cs b/server/src/WebDrop.Core/Files/IFileGrants.cs new file mode 100644 index 0000000..c7541f6 --- /dev/null +++ b/server/src/WebDrop.Core/Files/IFileGrants.cs @@ -0,0 +1,27 @@ +using WebDrop.FileProtocol; + +namespace WebDrop.Core; + +/// 给客户端的访问凭证:文件服务地址、文件域 id 和一张短期票据 +public sealed record FileGrant(string Url, string Domain, string Ticket, long ExpiresAt); + +/// +/// 主后端只负责「权限」:判断某人在某个空间里能不能读写某个频道的文件,然后给文件服务(fileAPI)签一张短期票据。 +/// 文件域的创建、容量、用量、字节的存取都是文件服务自己的事,主后端既不调用它,也不保存它的任何状态。 +/// +public interface IFileGrants +{ + bool Enabled { get; } + /// 文件服务对外的地址,告诉客户端去哪里 + string PublicUrl { get; } + /// space:签发票据的空间 id(十六进制)。scope:Create 时是空间 id;其余是文件域 id。item 是条目 id(十六进制)或 "*" + FileGrant Issue(FileOp op, string space, string scope, string item, uint from, long nowMs); +} + +public sealed class NoFileGrants : IFileGrants +{ + public static readonly NoFileGrants Instance = new(); + public bool Enabled => false; + public string PublicUrl => ""; + public FileGrant Issue(FileOp op, string space, string scope, string item, uint from, long nowMs) => throw new WebDropException(ErrorCode.NotFound); +} diff --git a/server/src/WebDrop.Core/Shares/ShareService.cs b/server/src/WebDrop.Core/Shares/ShareService.cs index b0b2e82..4cea2dd 100644 --- a/server/src/WebDrop.Core/Shares/ShareService.cs +++ b/server/src/WebDrop.Core/Shares/ShareService.cs @@ -355,7 +355,7 @@ public sealed class ShareService( private static ShareInfo ToInfo(Share share, IReadOnlyList items) => new() { Id = share.Id.ToBase64Url(), - State = share.IsSealed ? "sealed" : "draft", + State = share.IsSealed ? ShareState.Sealed : ShareState.Draft, ExpiresAt = share.ExpiresAt, CreatedAt = share.CreatedAt, SealedAt = share.SealedAt, diff --git a/server/src/WebDrop.Core/Spaces/ChannelCrypto.cs b/server/src/WebDrop.Core/Spaces/ChannelCrypto.cs index 172fbf8..7b22dd8 100644 --- a/server/src/WebDrop.Core/Spaces/ChannelCrypto.cs +++ b/server/src/WebDrop.Core/Spaces/ChannelCrypto.cs @@ -27,8 +27,7 @@ public sealed partial class ChannelService } else { - var recipients = crypto.Access.Where(a => a.ChannelId == channelId && (domain == CryptoDomain.Chat ? a.ChatRead : a.FilesRead) - && (a.Username == space.CreatorAccount || tx.GetChannelMember(id, a.Username) is not null || tx.HasInvitation(id, a.Username))) + var recipients = ChannelPolicy.Readers(crypto, channelId, domain).Where(a => (a.Username == space.CreatorAccount || tx.GetChannelMember(id, a.Username) is not null || tx.HasInvitation(id, a.Username))) .Select(a => a.Username).Append(space.CreatorAccount!).Append(username).Distinct().Order(StringComparer.Ordinal).ToList(); if (recipients.Count > 256) throw new WebDropException(ErrorCode.TooLarge); batches.Add(new CryptoGrantBatch { Domain = domain, Epoch = currentEpoch + 1, Recipients = recipients, Rotate = true }); @@ -67,8 +66,7 @@ public sealed partial class ChannelService } else { - var recipients = state.Access.Where(a => a.ChannelId == channelId && (domain == CryptoDomain.Chat ? a.ChatRead : a.FilesRead) - && (a.Username == space.CreatorAccount || tx.GetChannelMember(id, a.Username) is not null || tx.HasInvitation(id, a.Username))) + var recipients = ChannelPolicy.Readers(state, channelId, domain).Where(a => (a.Username == space.CreatorAccount || tx.GetChannelMember(id, a.Username) is not null || tx.HasInvitation(id, a.Username))) .Select(a => a.Username).Append(space.CreatorAccount!).Append(username).Distinct().ToHashSet(StringComparer.Ordinal); if (recipients.Count > 256) throw new WebDropException(ErrorCode.TooLarge); expected.AddRange(recipients.Select(recipient => (domain, currentEpoch + 1, recipient))); @@ -111,38 +109,34 @@ public sealed partial class ChannelService if (crypto.Envelopes.Count == 0 && crypto.Objects.Count == 0 && space.Mode == ChannelMode.Enc) return true; var item = crypto.Objects.Find(o => o.ItemId == itemId.ToBase64Url()); if (item is null) return false; - if (principal.Role == Role.Owner || principal.AccountName == space.CreatorAccount) return true; - var access = crypto.Access.Find(a => a.ChannelId == item.ChannelId && a.Username == principal.AccountName); - if (access is null || (item.Domain == CryptoDomain.Chat ? !access.ChatRead || item.CreatedRevision < access.ChatFromRevision : !access.FilesRead || item.CreatedRevision < access.FilesFromRevision)) return false; + if (ChannelPolicy.IsSpaceOwner(space, principal)) return true; + if (!ChannelPolicy.CanReadObject(ChannelPolicy.AccessOf(crypto, principal.AccountName, item.ChannelId), item.Domain, item.CreatedRevision)) return false; if (space.Mode == ChannelMode.Raw) return true; return crypto.Envelopes.Exists(e => e.ChannelId == item.ChannelId && e.Domain == item.Domain && e.Epoch == item.Epoch && e.Username == principal.AccountName); } - private static void CheckObjectWrite(IResourceTx tx, Channel space, Principal principal, Envelope envelope) + private static void CheckObjectWrite(IResourceTx tx, Channel space, Principal principal, Id16 itemId) { var crypto = tx.GetWorkspaceCrypto(space.Id); - if (space.Mode == ChannelMode.Enc && crypto.Envelopes.Count == 0) return; - if (space.Mode == ChannelMode.Raw && crypto.Access.Count == 0 && crypto.Objects.Count == 0) return; - var item = crypto.Objects.Find(o => o.ItemId == envelope.ItemId.ToBase64Url()) ?? throw new WebDropException(ErrorCode.BadRequest); + if (!ChannelPolicy.GrantsEnforced(space, crypto)) return; + var item = crypto.Objects.Find(o => o.ItemId == itemId.ToBase64Url()) ?? throw new WebDropException(ErrorCode.BadRequest); if (item.AuthorAccount != principal.AccountName) throw new WebDropException(ErrorCode.Forbidden); var channel = tx.GetWorkspace(space.Id)?.Channels.Find(c => c.Id == item.ChannelId); if (space.Mode == ChannelMode.Enc && channel is not null && item.Epoch != (item.Domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch)) throw new WebDropException(ErrorCode.Conflict); - if (principal.Role != Role.Owner) + if (!ChannelPolicy.IsOwner(principal)) { - var access = crypto.Access.Find(a => a.ChannelId == item.ChannelId && a.Username == principal.AccountName); - if (access is null && (space.Mode == ChannelMode.Enc || crypto.Access.Count > 0) - || access is not null && (item.Domain == CryptoDomain.Chat ? !access.ChatWrite : !access.FilesUpload)) throw new WebDropException(ErrorCode.Forbidden); + var access = ChannelPolicy.AccessOf(crypto, principal.AccountName, item.ChannelId); + if (access is null ? space.Mode == ChannelMode.Enc || crypto.Access.Count > 0 : !ChannelPolicy.CanWriteDomain(access, item.Domain)) throw new WebDropException(ErrorCode.Forbidden); } } public CryptoState ReadCrypto(Id16 id, Credentials creds) => store.Read(tx => { 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; + if (ChannelPolicy.IsSpaceOwner(space, principal)) return state; var access = state.Access.Where(a => a.Username == principal.AccountName).ToList(); var allowed = access.ToDictionary(a => a.ChannelId); - bool CanRead(string channelId, CryptoDomain domain) => allowed.TryGetValue(channelId, out var grant) - && (domain == CryptoDomain.Chat ? grant.ChatRead : grant.FilesRead); + bool CanRead(string channelId, CryptoDomain domain) => ChannelPolicy.CanReadDomain(allowed.GetValueOrDefault(channelId), domain); var envelopes = state.Envelopes.Where(e => e.Username == principal.AccountName && CanRead(e.ChannelId, e.Domain)).ToList(); var epochs = envelopes.Select(e => (e.ChannelId, e.Domain, e.Epoch)).ToHashSet(); return new CryptoState { Envelopes = envelopes, @@ -183,10 +177,9 @@ public sealed partial class ChannelService 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); var state = tx.GetWorkspaceCrypto(id); - if (principal.Role != Role.Owner && (space.Mode == ChannelMode.Enc || state.Access.Count > 0)) + if (!ChannelPolicy.IsOwner(principal) && (space.Mode == ChannelMode.Enc || state.Access.Count > 0)) { - var access = state.Access.Find(a => a.ChannelId == req.ChannelId && a.Username == principal.AccountName); - if (access is null || req.Domain == CryptoDomain.Chat && !access.ChatWrite || req.Domain == CryptoDomain.Files && !access.FilesUpload) throw new WebDropException(ErrorCode.Forbidden); + if (!ChannelPolicy.CanWriteDomain(ChannelPolicy.AccessOf(state, principal.AccountName, req.ChannelId), req.Domain)) throw new WebDropException(ErrorCode.Forbidden); } if (space.Mode == ChannelMode.Enc && req.Epoch != (req.Domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch)) throw new WebDropException(ErrorCode.Conflict); var existing = state.Objects.Find(o => o.ItemId == req.ItemId); diff --git a/server/src/WebDrop.Core/Spaces/ChannelWorkspace.cs b/server/src/WebDrop.Core/Spaces/ChannelWorkspace.cs index 6787d6b..b789e64 100644 --- a/server/src/WebDrop.Core/Spaces/ChannelWorkspace.cs +++ b/server/src/WebDrop.Core/Spaces/ChannelWorkspace.cs @@ -14,7 +14,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), tx.GetChannelAvatar(id, thumb: true)); + return ToInfo(channel, tx.CountMembers(id), new Principal(role, username), tx.GetChannelAvatar(id, thumb: true), tx.GetWorkspace(id)); }).ToList()); private static WorkspaceState NewWorkspace(string username, long now) { @@ -52,13 +52,8 @@ public sealed partial class ChannelService { var crypto = tx.GetWorkspaceCrypto(space.Id); 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(); + var ids = grants.Where(ChannelPolicy.CanSeeChannel).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)); } @@ -73,8 +68,8 @@ public sealed partial class ChannelService 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); + bool delegated = permission is not null && !ChannelPolicy.IsOwner(principal) && ChannelPolicy.HasPermission(current, principal, permission); + if (requireAccount && !ChannelPolicy.IsOwner(principal) && !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); @@ -87,17 +82,33 @@ public sealed partial class ChannelService events.SettingsChanged(id); return state; } - public (WorkspaceState State, WorkspaceChannel Channel) CreateWorkspaceChannelResult(Id16 id, Credentials creds, string name, string? groupId) + /// 文件域的引用只是个不透明的字符串(文件服务给的域 id),主后端不解释它,只存下来、原样给成员 + private static string? FileDomainRef(string? value) { + if (string.IsNullOrEmpty(value)) return null; + if (value.Length > 64 || !value.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_')) throw new WebDropException(ErrorCode.BadRequest); + return value; + } + public (WorkspaceState State, WorkspaceChannel Channel) CreateWorkspaceChannelResult(Id16 id, Credentials creds, string name, string? groupId, string? fileDomain = null) + { + string? domain = FileDomainRef(fileDomain); WorkspaceChannel? created = null; var state = ChangeWorkspace(id, creds, (_, workspace, principal) => { - created = new WorkspaceChannel { Id = Guid.NewGuid().ToString(), Name = WorkspaceName(name), GroupId = groupId, CreatedAt = clock.NowMs, CreatedBy = principal.AccountName! }; + created = new WorkspaceChannel { Id = Guid.NewGuid().ToString(), Name = WorkspaceName(name), GroupId = groupId, CreatedAt = clock.NowMs, CreatedBy = principal.AccountName!, FileDomain = domain }; workspace.Channels.Add(created); }); return (state, created!); } public WorkspaceState CreateWorkspaceChannel(Id16 id, Credentials creds, string name, string? groupId) => CreateWorkspaceChannelResult(id, creds, name, groupId).State; + /// 给还没有文件域的频道指定一个(创建频道时没带,或旧频道)。指定后不能改:文件已经存在那里了 + public WorkspaceState SetWorkspaceChannelFileDomain(Id16 id, Credentials creds, string channelId, string fileDomain) => ChangeWorkspace(id, creds, (_, state, _) => + { + var channel = FindChannel(state, channelId); + string domain = FileDomainRef(fileDomain) ?? throw new WebDropException(ErrorCode.BadRequest); + if (channel.FileDomain is not null && channel.FileDomain != domain) throw new WebDropException(ErrorCode.Conflict); + channel.FileDomain = domain; + }, permission: WorkspacePermission.ManageStructure); public WorkspaceState RenameWorkspaceChannel(Id16 id, Credentials creds, string channelId, string name) => ChangeWorkspace(id, creds, (_, state, _) => FindChannel(state, channelId).Name = WorkspaceName(name), permission: WorkspacePermission.ManageStructure); public WorkspaceState GroupWorkspaceChannel(Id16 id, Credentials creds, string channelId, string? groupId) => ChangeWorkspace(id, creds, (_, state, _) => @@ -123,9 +134,6 @@ public sealed partial class ChannelService 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(); @@ -193,17 +201,14 @@ public sealed partial class ChannelService if (!state.Channels.Exists(c => c.Id == threadId)) throw new WebDropException(ErrorCode.NotFound); var space = tx.GetChannel(id)!; var crypto = tx.GetWorkspaceCrypto(id); // 与 CheckObjectWrite 同一口径:加密空间或已有授权记录时,必须有目的频道的写入授权 - if (principal.Role != Role.Owner && principal.AccountName != space.CreatorAccount && (space.Mode == ChannelMode.Enc ? crypto.Envelopes.Count > 0 : crypto.Access.Count > 0 || crypto.Objects.Count > 0)) - { - var grant = crypto.Access.Find(a => a.ChannelId == threadId && a.Username == principal.AccountName); - if (grant is null || !(grant.ChatWrite || grant.FilesUpload)) throw new WebDropException(ErrorCode.Forbidden); - } + if (!ChannelPolicy.IsSpaceOwner(space, principal) && ChannelPolicy.GrantsEnforced(space, crypto) + && !ChannelPolicy.CanWriteAnyDomain(ChannelPolicy.AccessOf(crypto, principal.AccountName, threadId))) throw new WebDropException(ErrorCode.Forbidden); foreach (var itemId in itemIds) { 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 != principal.AccountName) throw new WebDropException(ErrorCode.Forbidden); + if (!ChannelPolicy.IsOwner(principal) && 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/Spaces/Workspace.cs b/server/src/WebDrop.Core/Spaces/Workspace.cs index db05994..fd88b1f 100644 --- a/server/src/WebDrop.Core/Spaces/Workspace.cs +++ b/server/src/WebDrop.Core/Spaces/Workspace.cs @@ -8,7 +8,8 @@ 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; } + /// 这个频道的文件域在文件服务里的 id。不透明字符串:创建、容量、用量都归文件服务管,这里只是引用。null 表示还没分配 + [JsonPropertyName("fileDomain")] public string? FileDomain { 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.Api.Tests/FileApiTests.cs b/server/tests/WebDrop.Api.Tests/FileApiTests.cs index c4341cf..09e8b1e 100644 --- a/server/tests/WebDrop.Api.Tests/FileApiTests.cs +++ b/server/tests/WebDrop.Api.Tests/FileApiTests.cs @@ -44,7 +44,6 @@ public sealed class FileApiTests : IAsyncLifetime { ["WebDrop:Files:Enabled"] = "true", ["WebDrop:Files:PublicUrl"] = _fileUrl, - ["WebDrop:Files:InternalUrl"] = _fileUrl, ["WebDrop:Files:Secret"] = Secret, }); await _main.InitializeAsync(); @@ -116,7 +115,7 @@ public sealed class FileApiTests : IAsyncLifetime return ch; } - /// 空间里的默认频道:发布聊天和文件两个域的密钥(客户端创建空间时做的事),之后才能声明对象 + /// 空间里的默认频道:发布聊天和文件两个域的密钥(客户端创建空间时做的事),并为它创建一个文件域(同样是客户端做的事),之后才能声明对象 private async Task PrepareAsync(Chan ch) { string channelId; @@ -129,6 +128,7 @@ public sealed class FileApiTests : IAsyncLifetime JsonBody(new { channelId, domain, epoch = 1, envelopes = new[] { new { channelId, domain, epoch = 1, username = _owner.Username, payload = $"owner-{domain}" } } })); Assert.Equal(HttpStatusCode.NoContent, (await _main.Http.SendAsync(bootstrap)).StatusCode); } + await CreateDomainAsync(ch, channelId); return channelId; } @@ -139,58 +139,79 @@ public sealed class FileApiTests : IAsyncLifetime Assert.Equal(HttpStatusCode.Created, (await _main.Http.SendAsync(declare)).StatusCode); } - // ───── 文件服务和主后端的请求 ───── + // ───── 主后端发票据,文件服务存字节 ───── - private async Task TicketRequestAsync(Chan ch, string item, string op, uint from = 0) => - await _main.Http.SendAsync(_main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/files/ticket", Owner(ch), - new StringContent(JsonSerializer.Serialize(new { item, op, from }), Encoding.UTF8, "application/json"))); + private readonly Dictionary _domains = []; - private async Task<(string Mode, string? Url, string? Ticket)> TicketAsync(Chan ch, string item, string op, uint from = 0) + private async Task GrantRequestAsync(Chan ch, string op, string? item = null, uint from = 0) => + await _main.Http.SendAsync(_main.Req(HttpMethod.Post, $"/api/v1/spaces/{ch.Id}/file-grants", Owner(ch), + new StringContent(JsonSerializer.Serialize(new { op, item, from }), Encoding.UTF8, "application/json"))); + + private async Task<(string Url, string Domain, string Ticket)> GrantAsync(Chan ch, string op, string? item = null, uint from = 0) { - using var response = await TicketRequestAsync(ch, item, op, from); - if (response.StatusCode != HttpStatusCode.OK) throw new HttpRequestException($"ticket {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}"); + using var response = await GrantRequestAsync(ch, op, item, from); + if (response.StatusCode != HttpStatusCode.OK) throw new HttpRequestException($"grant {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}"); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); var root = doc.RootElement; - return (root.GetProperty("mode").GetString()!, root.TryGetProperty("url", out var u) ? u.GetString() : null, root.TryGetProperty("ticket", out var t) ? t.GetString() : null); + return (root.GetProperty("url").GetString()!, root.GetProperty("domain").GetString()!, root.GetProperty("ticket").GetString()!); } + /// 前端创建频道时做的事:向空间要「创建」票据,去文件服务建文件域,再把域 id 记到频道上 + private async Task CreateDomainAsync(Chan ch, string channelId) + { + var grant = await GrantAsync(ch, "create"); + using var create = new HttpRequestMessage(HttpMethod.Post, "/file/v1/domains") { Content = new StringContent("{}", Encoding.UTF8, "application/json") }; + create.Headers.Add("X-WD-Ticket", grant.Ticket); + using var created = await _fileHttp.SendAsync(create); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + using var doc = JsonDocument.Parse(await created.Content.ReadAsStringAsync()); + string domain = doc.RootElement.GetProperty("id").GetString()!; + using var record = _main.Req(HttpMethod.Put, $"/api/v1/spaces/{ch.Id}/channels/{channelId}/file-domain", Owner(ch), JsonBody(new { fileDomain = domain })); + Assert.Equal(HttpStatusCode.OK, (await _main.Http.SendAsync(record)).StatusCode); + return _domains[ch.Id] = domain; + } + + /// 把一帧存进文件域,返回文件服务算的摘要 private async Task StoreAsync(Chan ch, string item, byte[] frame, uint index) { - var (_, _, ticket) = await TicketAsync(ch, item, "put"); - using var put = new HttpRequestMessage(HttpMethod.Put, $"/f/{Hex(ch.Id)}/{Hex(item)}/{index}") { Content = new ByteArrayContent(frame) }; - put.Headers.Add("X-WD-Ticket", ticket); + var grant = await GrantAsync(ch, "put", item); + using var put = new HttpRequestMessage(HttpMethod.Put, $"/file/v1/domains/{grant.Domain}/items/{Hex(item)}/frames/{index}") { Content = new ByteArrayContent(frame) }; + put.Headers.Add("X-WD-Ticket", grant.Ticket); using var stored = await _fileHttp.SendAsync(put); Assert.Equal(HttpStatusCode.OK, stored.StatusCode); using var doc = JsonDocument.Parse(await stored.Content.ReadAsStringAsync()); - return doc.RootElement.GetProperty("receipt").GetString()!; + string digest = doc.RootElement.GetProperty("digest").GetString()!; + Assert.Equal(Convert.ToHexStringLower(SHA256.HashData(frame)), digest); + return digest; } - private async Task CommitAsync(Chan ch, byte[] frame, string receipt, string? maxDownloads = null, Dictionary? signed = null) + /// 到主后端一次登记整个文件:末帧的 26 字节头部 + 每一帧的(负载长度,整帧摘要),不带任何字节 + private async Task RegisterAsync(Chan ch, IReadOnlyList frames, string? maxDownloads = null, Dictionary? signed = null, IReadOnlyList? digests = null) { - var commit = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", Owner(ch), new ByteArrayContent(frame[..26])); - commit.Headers.Add("X-WD-Receipt", receipt); - if (maxDownloads is not null) commit.Headers.Add("X-WD-Max-Downloads", maxDownloads); - foreach (var (name, value) in signed ?? []) commit.Headers.Add(name, value); - return await _main.Http.SendAsync(commit); - } - - /// 上传一个文件条目的帧:每帧先传给文件服务拿回执,再把头部和回执交给主后端登记;最后一帧带签名事件 - private async Task> UploadFileAsync(Chan ch, string channelId, string item, IReadOnlyList frames, string? maxDownloads = null, bool final = true) - { - var statuses = new List(); + var body = new byte[26 + frames.Count * 36]; + frames[^1][..26].CopyTo(body, 0); for (int i = 0; i < frames.Count; i++) { - string receipt = await StoreAsync(ch, item, frames[i], (uint)i); - var signed = final && i == frames.Count - 1 ? SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", frames) : null; - using var response = await CommitAsync(ch, frames[i], receipt, i == 0 ? maxDownloads : null, signed); - statuses.Add(response.StatusCode); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(26 + i * 36), (uint)(frames[i].Length - 26)); + Convert.FromHexString(digests?[i] ?? Convert.ToHexStringLower(SHA256.HashData(frames[i]))).CopyTo(body, 26 + i * 36 + 4); } - return statuses; + var register = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/files", Owner(ch), new ByteArrayContent(body)); + if (maxDownloads is not null) register.Headers.Add("X-WD-Max-Downloads", maxDownloads); + foreach (var (name, value) in signed ?? []) register.Headers.Add(name, value); + return await _main.Http.SendAsync(register); } - private async Task<(HttpResponseMessage Response, byte[] Body)> ReadFromFilesAsync(Chan ch, string item, string ticket, uint from) + /// 上传一个文件:每一帧先存进文件域,全部存好后一次到主后端登记(末帧的签名事件盖着所有帧的摘要) + private async Task UploadFileAsync(Chan ch, string channelId, string item, IReadOnlyList frames, string? maxDownloads = null) { - using var get = new HttpRequestMessage(HttpMethod.Get, $"/f/{Hex(ch.Id)}/{Hex(item)}?from={from}"); + for (int i = 0; i < frames.Count; i++) await StoreAsync(ch, item, frames[i], (uint)i); + using var response = await RegisterAsync(ch, frames, maxDownloads, SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", frames)); + return response.StatusCode; + } + + private async Task<(HttpResponseMessage Response, byte[] Body)> ReadFromFilesAsync(string domain, string item, string ticket, uint from) + { + using var get = new HttpRequestMessage(HttpMethod.Get, $"/file/v1/domains/{domain}/items/{Hex(item)}/frames?from={from}"); get.Headers.Add("X-WD-Ticket", ticket); var response = await _fileHttp.SendAsync(get); return (response, await response.Content.ReadAsByteArrayAsync()); @@ -207,18 +228,20 @@ public sealed class FileApiTests : IAsyncLifetime // ───── 测试 ───── [Fact] - public async Task FileContentOfAnySizeGoesToTheFileServiceWhileChatStaysOnTheMainBackend() + public async Task FileBytesLiveInTheFileDomainWhileChatStaysOnTheHost() { - Assert.Contains("\"files\":{\"enabled\":true", await _main.Http.GetStringAsync("/api/v1/info")); + var info = await _main.Http.GetStringAsync("/api/v1/info"); + Assert.Contains("\"files\":{\"enabled\":true", info); + Assert.DoesNotContain("threshold", info); var ch = await CreateSpaceAsync(); string channelId = await PrepareAsync(ch); string item = NewId(), tiny = NewId(), chat = NewId(); await DeclareAsync(ch, channelId, item); await DeclareAsync(ch, channelId, tiny); await DeclareAsync(ch, channelId, chat, "chat"); var frames = new[] { Frame(item, 0, GatedEnc, EncPayload, 1), Frame(item, 1, GatedEnc, EncPayload, 2), Frame(item, 2, GatedFinalEnc, EncPayload, 3) }; - var tinyFrame = Small(tiny, 7); // 单帧、不限次的小文件,同样放文件服务 + var tinyFrame = Small(tiny, 7); - // 文件的内容不能直接传给主后端,不管是多帧受限的还是单帧的小文件 + // 文件域的对象不能把字节直接交给主后端,不管多大 using (var direct = await _main.AppendAsync(ch, Owner(ch), frames[0], maxDownloads: "1")) Assert.Equal(HttpStatusCode.Conflict, direct.StatusCode); using (var directSmall = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", Owner(ch), Bytes(tinyFrame))) { @@ -227,8 +250,8 @@ public sealed class FileApiTests : IAsyncLifetime Assert.Equal(HttpStatusCode.Conflict, rejected.StatusCode); } - Assert.All(await UploadFileAsync(ch, channelId, item, frames, "1"), s => Assert.Equal(HttpStatusCode.Created, s)); - Assert.All(await UploadFileAsync(ch, channelId, tiny, [tinyFrame]), s => Assert.Equal(HttpStatusCode.Created, s)); + Assert.Equal(HttpStatusCode.Created, await UploadFileAsync(ch, channelId, item, frames, "1")); + Assert.Equal(HttpStatusCode.Created, await UploadFileAsync(ch, channelId, tiny, [tinyFrame])); // 聊天(聊天域的对象)照旧存在主后端,日志里带着内容 var chatFrame = Small(chat, 5); @@ -245,124 +268,181 @@ public sealed class FileApiTests : IAsyncLifetime await AssertErrorAsync(await _main.Http.SendAsync(_main.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/items/{item}/frames", Owner(ch))), 409, "use_file_api"); // 受限条目:要票据,按原来的规则计次(上限 1 次) - var grant = await TicketAsync(ch, item, "get"); - Assert.Equal("remote", grant.Mode); - var (read, body) = await ReadFromFilesAsync(ch, item, grant.Ticket!, 0); + var grant = await GrantAsync(ch, "get", item); + Assert.Equal(_domains[ch.Id], grant.Domain); + var (read, body) = await ReadFromFilesAsync(grant.Domain, item, grant.Ticket, 0); Assert.Equal(HttpStatusCode.OK, read.StatusCode); Assert.Equal(frames.SelectMany(f => f).ToArray(), body); - using (var second = await TicketRequestAsync(ch, item, "get")) Assert.Equal(HttpStatusCode.Gone, second.StatusCode); - var resume = await TicketAsync(ch, item, "get", 2); - Assert.Equal(frames[2], (await ReadFromFilesAsync(ch, item, resume.Ticket!, 2)).Body); + using (var second = await GrantRequestAsync(ch, "get", item)) Assert.Equal(HttpStatusCode.Gone, second.StatusCode); + var resume = await GrantAsync(ch, "get", item, 2); + Assert.Equal(frames[2], (await ReadFromFilesAsync(resume.Domain, item, resume.Ticket, 2)).Body); - // 单帧小文件不受限:想读几次都行 + // 单帧不限次的文件:想读几次都行 for (int i = 0; i < 3; i++) { - var t = await TicketAsync(ch, tiny, "get"); - Assert.Equal("remote", t.Mode); - Assert.Equal(tinyFrame, (await ReadFromFilesAsync(ch, tiny, t.Ticket!, 0)).Body); + var t = await GrantAsync(ch, "get", tiny); + Assert.Equal(tinyFrame, (await ReadFromFilesAsync(t.Domain, tiny, t.Ticket, 0)).Body); } - // 聊天条目请求下载票据得到 local:客户端继续走原来的接口 - Assert.Equal("local", (await TicketAsync(ch, chat, "get")).Mode); + // 聊天条目没有文件域,不发文件票据 + using (var chatGrant = await GrantRequestAsync(ch, "get", chat)) Assert.Equal(HttpStatusCode.NotFound, chatGrant.StatusCode); } [Fact] - public async Task TicketsAndReceiptsAreBoundToTheirItemAndFrame() + public async Task TicketsFollowTheSpacePermissionsAndAreBoundToTheirItem() { var ch = await CreateSpaceAsync(); string channelId = await PrepareAsync(ch); string item = NewId(), other = NewId(); - await DeclareAsync(ch, channelId, item); + await DeclareAsync(ch, channelId, item); await DeclareAsync(ch, channelId, other); var f0 = Frame(item, 0, GatedEnc, EncPayload, 1); var f1 = Frame(item, 1, GatedFinalEnc, EncPayload, 2); - // 别的条目的票据传不进这个条目;没有票据也不行 - var (_, _, otherTicket) = await TicketAsync(ch, other, "put"); - using (var put = new HttpRequestMessage(HttpMethod.Put, $"/f/{Hex(ch.Id)}/{Hex(item)}/0") { Content = new ByteArrayContent(f0) }) - { - put.Headers.Add("X-WD-Ticket", otherTicket); - using var denied = await _fileHttp.SendAsync(put); - Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode); - } - using (var noTicket = await _fileHttp.PutAsync($"/f/{Hex(ch.Id)}/{Hex(item)}/0", new ByteArrayContent(f0))) Assert.Equal(HttpStatusCode.Unauthorized, noTicket.StatusCode); + // 没有空间里的身份,什么票据都拿不到 + var stranger = ch.Owner with { Token = new byte[] { 1, 2, 3 }, AccountToken = null }; + using (var denied = await _main.Http.SendAsync(_main.Req(HttpMethod.Post, $"/api/v1/spaces/{ch.Id}/file-grants", stranger, + new StringContent(JsonSerializer.Serialize(new { op = "create" }), Encoding.UTF8, "application/json")))) + Assert.False(denied.IsSuccessStatusCode); - // 回执被篡改、或拿第 0 帧的回执去登记第 1 帧,都会被拒绝 - string receipt0 = await StoreAsync(ch, item, f0, 0); - using (var forged = await CommitAsync(ch, f0, "x.y", "1")) Assert.Equal(HttpStatusCode.Forbidden, forged.StatusCode); - using (var committed0 = await CommitAsync(ch, f0, receipt0, "1")) Assert.Equal(HttpStatusCode.Created, committed0.StatusCode); - await StoreAsync(ch, item, f1, 1); - using (var swapped = await CommitAsync(ch, f1, receipt0, null, SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", [f0, f1]))) - Assert.Equal(HttpStatusCode.Forbidden, swapped.StatusCode); + // 别的条目的票据传不进这个条目;没有票据也不行 + var otherGrant = await GrantAsync(ch, "put", other); + using (var put = new HttpRequestMessage(HttpMethod.Put, $"/file/v1/domains/{otherGrant.Domain}/items/{Hex(item)}/frames/0") { Content = new ByteArrayContent(f0) }) + { + put.Headers.Add("X-WD-Ticket", otherGrant.Ticket); + using var wrongItem = await _fileHttp.SendAsync(put); + Assert.Equal(HttpStatusCode.Unauthorized, wrongItem.StatusCode); + } + using (var noTicket = await _fileHttp.PutAsync($"/file/v1/domains/{otherGrant.Domain}/items/{Hex(item)}/frames/0", new ByteArrayContent(f0))) Assert.Equal(HttpStatusCode.Unauthorized, noTicket.StatusCode); // 同一个序号不能存不同的内容;同样的内容重传是幂等的 - var (_, _, ticketAgain) = await TicketAsync(ch, item, "put"); - using (var put = new HttpRequestMessage(HttpMethod.Put, $"/f/{Hex(ch.Id)}/{Hex(item)}/0") { Content = new ByteArrayContent(Frame(item, 0, GatedEnc, EncPayload, 9)) }) + string digest0 = await StoreAsync(ch, item, f0, 0); + var grant = await GrantAsync(ch, "put", item); + using (var put = new HttpRequestMessage(HttpMethod.Put, $"/file/v1/domains/{grant.Domain}/items/{Hex(item)}/frames/0") { Content = new ByteArrayContent(Frame(item, 0, GatedEnc, EncPayload, 9)) }) { - put.Headers.Add("X-WD-Ticket", ticketAgain); + put.Headers.Add("X-WD-Ticket", grant.Ticket); using var conflict = await _fileHttp.SendAsync(put); Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode); } - using (var again = await CommitAsync(ch, f0, await StoreAsync(ch, item, f0, 0), "1")) Assert.True(again.IsSuccessStatusCode); + Assert.Equal(digest0, await StoreAsync(ch, item, f0, 0)); - // 没传完的条目不能下载;不是文件的条目不能走文件服务登记 - using (var notReady = await TicketRequestAsync(ch, item, "get")) Assert.Equal(HttpStatusCode.Conflict, notReady.StatusCode); + // 登记整个文件(一次请求):先把第二帧也存进文件域 + string digest1 = await StoreAsync(ch, item, f1, 1); + // 末帧的签名清单盖着所有帧的摘要:登记的摘要和清单对不上,整个文件就不被接受 + using (var wrongDigest = await RegisterAsync(ch, [f0, f1], "1", SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", [f0, f1]), [digest0, digest0])) + Assert.Equal(HttpStatusCode.Conflict, wrongDigest.StatusCode); + using (var good = await RegisterAsync(ch, [f0, f1], "1", SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", [f0, f1]))) + Assert.Equal(HttpStatusCode.Created, good.StatusCode); + // 同一个文件重复登记幂等;摘要不同(同一个条目)就是冲突 + using (var again = await RegisterAsync(ch, [f0, f1], "1", SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", [f0, f1]))) Assert.True(again.IsSuccessStatusCode); + using (var different = await RegisterAsync(ch, [f0, f1], "1", SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", [f0, f1]), [digest0, new string('a', 64)])) + Assert.Equal(HttpStatusCode.Conflict, different.StatusCode); + // 请求体格式不对 + using (var malformed = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/files", Owner(ch), new ByteArrayContent(new byte[10]))) + using (var bad = await _main.Http.SendAsync(malformed)) Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode); + + // 不是文件域的条目不能走登记 string chat = NewId(); await DeclareAsync(ch, channelId, chat, "chat"); var chatFrame = Small(chat); - string chatReceipt = await StoreAsync(ch, chat, chatFrame, 0); - using (var notAFile = await CommitAsync(ch, chatFrame, chatReceipt, null, SignedHeaders(ch.Id, channelId, chat, CryptoDomain.Chat, "chat.create", [chatFrame]))) + using (var notAFile = await RegisterAsync(ch, [chatFrame], null, SignedHeaders(ch.Id, channelId, chat, CryptoDomain.Chat, "chat.create", [chatFrame]))) Assert.Equal(HttpStatusCode.BadRequest, notAFile.StatusCode); } [Fact] - public async Task DeletingAnItemOrChannelAlsoClearsTheFileService() + public async Task ItemsInAChannelWithoutAFileDomainGetNoTicketsAndNeverRegisteredFilesCannotBeDownloaded() + { + var ch = await CreateSpaceAsync(); + string channelId = await PrepareAsync(ch); + string item = NewId(); + await DeclareAsync(ch, channelId, item); + // 字节存进了文件域、但没有到主后端登记的文件(上传到一半放弃):主后端不认识它,不发下载票据 + await StoreAsync(ch, item, Frame(item, 0, GatedEnc, EncPayload, 1), 0); + using (var notRegistered = await GrantRequestAsync(ch, "get", item)) Assert.Equal(HttpStatusCode.NotFound, notRegistered.StatusCode); + + // 第二个空间里的默认频道没有文件域:声明了文件对象也要不到上传票据 + var bare = await CreateSpaceAsync(); + string bareChannel; + using (var request = _main.Req(HttpMethod.Get, $"/api/v1/spaces/{bare.Id}/structure", Owner(bare))) + using (var response = await _main.Http.SendAsync(request)) + bareChannel = (await ReadJsonAsync(response)).GetProperty("structure").GetProperty("defaultChannelId").GetString()!; + foreach (var domain in new[] { "chat", "files" }) + { + using var bootstrap = _main.Req(HttpMethod.Put, $"/api/v1/spaces/{bare.Id}/crypto/domains", Owner(bare), + JsonBody(new { channelId = bareChannel, domain, epoch = 1, envelopes = new[] { new { channelId = bareChannel, domain, epoch = 1, username = _owner.Username, payload = $"owner-{domain}" } } })); + Assert.Equal(HttpStatusCode.NoContent, (await _main.Http.SendAsync(bootstrap)).StatusCode); + } + string orphan = NewId(); + await DeclareAsync(bare, bareChannel, orphan); + using (var noDomain = await GrantRequestAsync(bare, "put", orphan)) Assert.Equal(HttpStatusCode.Conflict, noDomain.StatusCode); + } + + [Fact] + public async Task DeletingAFileTakesAGrantThenDeletesOnTheHostAndInTheFileDomain() { var ch = await CreateSpaceAsync(); string channelId = await PrepareAsync(ch); var store = _files.Services.GetRequiredService(); - // 没传完的条目可以直接删(取消上传):文件服务上的帧跟着清掉 - string unfinished = NewId(); - await DeclareAsync(ch, channelId, unfinished); - Assert.All(await UploadFileAsync(ch, channelId, unfinished, [Frame(unfinished, 0, GatedEnc, EncPayload, 1)], "0", final: false), s => Assert.Equal(HttpStatusCode.Created, s)); - Assert.True(store.UsedBytes > 0); - using (var delete = await _main.Http.SendAsync(_main.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/items/{unfinished}", Owner(ch)))) Assert.Equal(HttpStatusCode.NoContent, delete.StatusCode); - await WaitAsync(() => store.UsedBytes == 0); - - // 删除整个空间:里面所有文件一起清掉 string finished = NewId(); await DeclareAsync(ch, channelId, finished); - Assert.All(await UploadFileAsync(ch, channelId, finished, [Small(finished, 3)]), s => Assert.Equal(HttpStatusCode.Created, s)); - Assert.True(store.UsedBytes > 0); - using (var del = await _main.Http.SendAsync(_main.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}", Owner(ch)))) Assert.Equal(HttpStatusCode.NoContent, del.StatusCode); - await WaitAsync(() => store.UsedBytes == 0); + var finishedFrame = Small(finished, 3); + Assert.Equal(HttpStatusCode.Created, await UploadFileAsync(ch, channelId, finished, [finishedFrame])); + string domain = _domains[ch.Id]; + Assert.True(store.UsedBy(domain) > 0); + + foreach (var item in new[] { finished }) + { + // 先向空间要「删除」票据(主后端确认能删),再删主后端的条目,最后用票据清文件域里的字节 + var grant = await GrantAsync(ch, "delete", item); + using (var hostDelete = _main.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/items/{item}", Owner(ch))) + { + // 删除文件要带作者设备签名的 file.delete 事件(清单和发布时一致) + foreach (var (name, value) in SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.delete", [finishedFrame])) hostDelete.Headers.Add(name, value); + using var host = await _main.Http.SendAsync(hostDelete); + Assert.Equal(HttpStatusCode.NoContent, host.StatusCode); + } + using var remove = new HttpRequestMessage(HttpMethod.Delete, $"/file/v1/domains/{grant.Domain}/items/{Hex(item)}"); + remove.Headers.Add("X-WD-Ticket", grant.Ticket); + using var removed = await _fileHttp.SendAsync(remove); + Assert.Equal(HttpStatusCode.NoContent, removed.StatusCode); + } + Assert.Equal(0, store.UsedBy(domain)); + + // 取消上传:主后端那边还没有这个条目(整个文件传完才登记),用上传这个条目的票据就能清掉自己传的字节 + string cancelled = NewId(); + await DeclareAsync(ch, channelId, cancelled); + await StoreAsync(ch, cancelled, Frame(cancelled, 0, GatedEnc, EncPayload, 5), 0); + Assert.True(store.UsedBy(domain) > 0); + var put = await GrantAsync(ch, "put", cancelled); + using var abandon = new HttpRequestMessage(HttpMethod.Delete, $"/file/v1/domains/{put.Domain}/items/{Hex(cancelled)}"); + abandon.Headers.Add("X-WD-Ticket", put.Ticket); + using var abandoned = await _fileHttp.SendAsync(abandon); + Assert.Equal(HttpStatusCode.NoContent, abandoned.StatusCode); + Assert.Equal(0, store.UsedBy(domain)); } [Fact] - public async Task OrphansLeftByAbandonedUploadsAreFoundByReconciliation() + public async Task AFileUploadAsTheClientDoesItCompletesWithCatalogAndLocation() { var ch = await CreateSpaceAsync(); - string item = NewId(); - // 只传给文件服务、从没在主后端登记过(上传到一半放弃) - var (_, _, ticket) = await TicketAsync(ch, item, "put"); - using (var put = new HttpRequestMessage(HttpMethod.Put, $"/f/{Hex(ch.Id)}/{Hex(item)}/0") { Content = new ByteArrayContent(Frame(item, 0, GatedEnc, EncPayload, 1)) }) + string channelId = await PrepareAsync(ch); + string item = NewId(), catalog = NewId(); + await DeclareAsync(ch, channelId, item); await DeclareAsync(ch, channelId, catalog); + var frames = new[] { Frame(item, 0, GatedEnc, EncPayload, 1), Frame(item, 1, GatedEnc, EncPayload, 2), Frame(item, 2, GatedFinalEnc, EncPayload, 3) }; + + // 1. 字节进文件域,整个文件一次登记 + Assert.Equal(HttpStatusCode.Created, await UploadFileAsync(ch, channelId, item, frames, "0")); + // 2. 目录条目:一个控制帧(小,不受限),走主后端,带 file.catalog 签名事件 + var control = Frame(catalog, 0, 16 | 8 | 1, EncPayload, 7); + using (var request = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", Owner(ch), Bytes(control))) { - put.Headers.Add("X-WD-Ticket", ticket); - using var stored = await _fileHttp.SendAsync(put); - Assert.Equal(HttpStatusCode.OK, stored.StatusCode); + foreach (var (name, value) in SignedHeaders(ch.Id, channelId, catalog, CryptoDomain.Files, "file.catalog", [control])) request.Headers.Add(name, value); + using var response = await _main.Http.SendAsync(request); + Assert.True(response.IsSuccessStatusCode, $"目录条目被拒绝:{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}"); } - var store = _files.Services.GetRequiredService(); - Assert.Contains($"{Hex(ch.Id)}/{Hex(item)}", store.ItemsOlderThan(TimeSpan.Zero)); - - var worker = _main.App.Services.GetServices().OfType().Single(); - var gateway = (WebDrop.Api.RemoteFileGateway)_main.App.Services.GetRequiredService(); - Assert.True(await worker.ReconcileAsync(gateway, CancellationToken.None, TimeSpan.Zero) >= 1); - await WaitAsync(() => store.UsedBytes == 0); - } - - private static async Task WaitAsync(Func condition) - { - for (int i = 0; i < 100 && !condition(); i++) await Task.Delay(100); - Assert.True(condition()); + // 3. 位置登记:文件放进频道 + using (var location = _main.Req(HttpMethod.Put, $"/api/v1/spaces/{ch.Id}/locations", Owner(ch), JsonBody(new { itemIds = new[] { item }, threadId = channelId }))) + using (var response = await _main.Http.SendAsync(location)) + Assert.True(response.IsSuccessStatusCode, $"位置登记被拒绝:{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}"); } } diff --git a/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs b/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs index 13e8455..c22aa6a 100644 --- a/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs +++ b/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs @@ -4,6 +4,13 @@ namespace WebDrop.Core.Tests; public sealed class ChannelCryptoTests { + /// 文件域的对象主后端只登记头部和摘要,字节不在这里:一个单帧文件 + private static Task Register(ChannelService svc, Id16 id, Credentials creds, byte[] frame) + { + svc.RegisterFileItem(id, creds, frame.AsMemory(0, Envelope.HeaderSize), [new ChannelService.FrameEntry(frame.Length - Envelope.HeaderSize, System.Security.Cryptography.SHA256.HashData(frame))], null); + return Task.CompletedTask; + } + [Fact] public void ClientMayBootstrapButCannotChooseAChannelEpochOrGrantAccess() { @@ -81,7 +88,7 @@ public sealed class ChannelCryptoTests var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id; var oldFile = TestEnv.NewId(); env.Service.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = oldFile.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" }); - await env.Service.AppendFrameAsync(space.Id, owner, TestEnv.Frame(oldFile, 0, FrameFlags.Final, 16), null); + await Register(env.Service, space.Id, owner, TestEnv.Frame(oldFile, 0, FrameFlags.Final, 16)); env.Service.MoveWorkspaceItems(space.Id, owner, [oldFile.ToBase64Url()], channelId, null); env.Service.SetChannelHistory(space.Id, owner, channelId, chat: false, files: false); @@ -98,22 +105,6 @@ public sealed class ChannelCryptoTests Assert.Single((await env.Service.ReadLogAsync(space.Id, member, 0, 100)).Records); } - [Fact] - public async Task AnUnfinishedUploadCanBeDeletedWithoutASignedEvent() - { - using var env = new TestEnv(); var ownerEndpoint = TestEnv.Endpoint(); var memberEndpoint = TestEnv.Endpoint(); - var space = env.Create(ownerEndpoint, ChannelMode.Raw); var owner = space.Owner(ownerEndpoint) with { RequireSignedContent = true }; - var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id; - var member = space.Member(memberEndpoint) with { AccountName = "member", RequireSignedContent = true }; env.Service.Join(space.Id, member); - var file = TestEnv.NewId(); - env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = file.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" }); - // 只传了第 0 帧,没有末帧:上传被打断 - await env.Service.AppendFrameAsync(space.Id, member, TestEnv.Frame(file, 0, FrameFlags.Gated, 16), null); - Assert.Throws(() => env.Service.DeleteItem(space.Id, space.Member(TestEnv.Endpoint()) with { AccountName = "stranger", RequireSignedContent = true }, file)); - env.Service.DeleteItem(space.Id, owner, file); - Assert.Null(env.Service.ReadCrypto(space.Id, owner).Objects.Find(o => o.ItemId == file.ToBase64Url())?.Event); - } - [Fact] public void MemberOnlySeesChannelsItWasGrantedAndNeverTheirKeysObjectsOrWrites() { diff --git a/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs b/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs index 5a4cd81..6e69762 100644 --- a/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs +++ b/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs @@ -159,25 +159,36 @@ public class ChannelServiceTests : IDisposable } [Fact] - public async Task FileGroupQuotaIsPerWorkspaceChannelAndFreedOnDelete() + public async Task FileObjectBytesAreNeverStoredByTheHostAWholeFileIsRegisteredByDigestsInOneStep() { - _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 = "" }); + var item = TestEnv.NewId(); + Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = item.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)); + // 文件域的对象不能把字节直接交给主后端 + byte[] first = TestEnv.Frame(item, 0, FrameFlags.Gated, 120); + await AssertCode(ErrorCode.UseFileApi, () => Svc.AppendFrameAsync(space.Id, owner, first, 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); + // 整个文件一次登记:末帧头部 + 每一帧的(负载长度,整帧摘要) + byte[][] frames = [first, TestEnv.Frame(item, 1, FrameFlags.Gated, 80), TestEnv.Frame(item, 2, FrameFlags.Gated | FrameFlags.Final, 40)]; + ChannelService.FrameEntry[] entries = frames.Select(f => new ChannelService.FrameEntry(f.Length - Envelope.HeaderSize, System.Security.Cryptography.SHA256.HashData(f))).ToArray(); + var header = frames[^1].AsMemory(0, Envelope.HeaderSize); + Assert.True(Svc.RegisterFileItem(space.Id, owner, header, entries, 3).Created); + Assert.False(Svc.RegisterFileItem(space.Id, owner, header, entries, 3).Created); // 同一个文件重复登记幂等 + var other = entries.ToArray(); other[1] = new ChannelService.FrameEntry(other[1].PayloadLength, new byte[32]); + Assert.Equal(ErrorCode.Conflict, Assert.Throws(() => Svc.RegisterFileItem(space.Id, owner, header, other, 3)).Code); // 摘要不同就是冲突 + Assert.Equal(ErrorCode.BadRequest, Assert.Throws(() => Svc.RegisterFileItem(space.Id, owner, frames[0].AsMemory(0, Envelope.HeaderSize), entries, 3)).Code); // 头部必须是末帧 + + // 聊天域的对象不能这样登记(它们的字节就存在主后端) + var chatItem = TestEnv.NewId(); + Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = chatItem.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 0, KeyEnvelope = "" }); + var chatFrame = TestEnv.Frame(chatItem, 0, FrameFlags.Final, 40); + Assert.Equal(ErrorCode.BadRequest, Assert.Throws(() => Svc.RegisterFileItem(space.Id, owner, chatFrame.AsMemory(0, Envelope.HeaderSize), + [new ChannelService.FrameEntry(40, System.Security.Cryptography.SHA256.HashData(chatFrame))], null)).Code); + + Svc.DeleteItem(space.Id, owner, item); } [Fact] diff --git a/server/tests/WebDrop.Core.Tests/PolicySingleSourceTests.cs b/server/tests/WebDrop.Core.Tests/PolicySingleSourceTests.cs new file mode 100644 index 0000000..e9b5b10 --- /dev/null +++ b/server/tests/WebDrop.Core.Tests/PolicySingleSourceTests.cs @@ -0,0 +1,48 @@ +using System.Text.RegularExpressions; + +namespace WebDrop.Core.Tests; + +/// +/// 权限规则只写在 ChannelPolicy 一处:别的地方只许调用,不许再各写一遍。 +/// 这个测试扫描源码,发现在 ChannelPolicy 之外重新判断角色、读写授权就失败,防止重复实现长回来。 +/// +public sealed class PolicySingleSourceTests +{ + private static string SourceRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "src", "WebDrop.Core"))) dir = dir.Parent; + return Path.Combine(dir?.FullName ?? throw new InvalidOperationException("找不到 server/src"), "src"); + } + + // 这些写法只能出现在 ChannelPolicy.cs(数据类的定义和令牌校验除外) + private static readonly (string Name, Regex Pattern)[] Forbidden = + [ + ("直接读频道访问授权的读写位", new Regex(@"\.(ChatRead|ChatWrite|FilesRead|FilesUpload)\b")), + ("直接比较是不是所有者", new Regex(@"Role\s*[!=]=\s*Role\.Owner")), + ("自己拼「所有者或创建者」", new Regex(@"AccountName\s*==\s*\w+\.CreatorAccount")), + ]; + + // 令牌校验(凭据层,不是授权规则)和数据定义,允许出现 + private static readonly string[] Allowed = + ["ChannelPolicy.cs", "WorkspaceCrypto.cs", "RequestAuth.cs", "ShareService.cs"]; + + [Fact] + public void AuthorizationRulesAreWrittenOnlyInChannelPolicy() + { + var offenders = new List(); + foreach (var file in Directory.EnumerateFiles(SourceRoot(), "*.cs", SearchOption.AllDirectories)) + { + if (file.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar) || file.Contains(Path.DirectorySeparatorChar + "bin" + Path.DirectorySeparatorChar)) continue; + if (Allowed.Contains(Path.GetFileName(file))) continue; + var lines = File.ReadAllLines(file); + for (int i = 0; i < lines.Length; i++) + { + if (lines[i].TrimStart().StartsWith("//") || lines[i].TrimStart().StartsWith("///")) continue; + foreach (var (name, pattern) in Forbidden) + if (pattern.IsMatch(lines[i])) offenders.Add($"{Path.GetFileName(file)}:{i + 1} {name}: {lines[i].Trim()}"); + } + } + Assert.True(offenders.Count == 0, "这些地方在 ChannelPolicy 之外重新判断了权限,请改成调用 ChannelPolicy:\n" + string.Join("\n", offenders)); + } +} diff --git a/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs b/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs index bffb162..b33da82 100644 --- a/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs +++ b/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs @@ -148,4 +148,54 @@ public sealed class WorkspaceStructureTests Assert.Throws(() => env.Service.CreateWorkspaceChannel(ch.Id, alice, "新频道", null)); // 新建频道涉及密钥,只有空间主 Assert.Throws(() => env.Service.CreateWorkspaceRoleResult(ch.Id, alice, "自封", null, null, null, [])); } + + [Fact] + public void ChannelFileDomainIsAnOpaqueReferenceSetOnceAndNeverInterpreted() + { + using var env = new TestEnv(); var endpoint = TestEnv.Endpoint(); var ch = env.Create(endpoint); var owner = ch.Owner(endpoint); + Assert.Null(env.Service.GetWorkspace(ch.Id, owner).Channels.Single().FileDomain); + var created = env.Service.CreateWorkspaceChannelResult(ch.Id, owner, "文件", null, "ab12-CD_34").Channel; + Assert.Equal("ab12-CD_34", created.FileDomain); + Assert.Throws(() => env.Service.CreateWorkspaceChannelResult(ch.Id, owner, "乱", null, "bad domain!")); + + // 创建时没带的,以后可以补一次;补了就不能换成别的(文件已经存在那里了) + var later = env.Service.CreateWorkspaceChannelResult(ch.Id, owner, "稍后", null).Channel; + env.Service.SetWorkspaceChannelFileDomain(ch.Id, owner, later.Id, "dom-1"); + env.Service.SetWorkspaceChannelFileDomain(ch.Id, owner, later.Id, "dom-1"); + Assert.Throws(() => env.Service.SetWorkspaceChannelFileDomain(ch.Id, owner, later.Id, "dom-2")); + } + + [Fact] + public void ChannelInfoStatesWhatTheCallerMayDoSoClientsNeverDeriveIt() + { + var (env, ch, owner, alice, bob, carol) = RoleSpace(); using var _env = env; + var admins = env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "管理组", null, null, null, [WorkspacePermission.RemoveMembers]).Role; + var movers = env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "结构组", null, null, null, [WorkspacePermission.ManageStructure]).Role; + env.Service.SetWorkspaceRoleMembers(ch.Id, owner, admins.Id, ["alice", "bob"]); + env.Service.SetWorkspaceRoleMembers(ch.Id, owner, movers.Id, ["carol"]); + + // 所有者:全部能力 + var asOwner = env.Service.GetChannel(ch.Id, owner); + Assert.True(asOwner.IsOwner); Assert.True(asOwner.CanManageStructure); Assert.True(asOwner.CanDeleteAny); Assert.True(asOwner.CanDeleteOwn); + Assert.True(asOwner.CanCreateChannel && asOwner.CanManageFolders && asOwner.CanInvite && asOwner.CanEditSettings && asOwner.CanRemoveMembers); + + // 身份组授予的权限:被授予「管理频道和分组」的不是所有者,却能管理结构;其他所有者专属的操作它仍然做不了 + var asCarol = env.Service.GetChannel(ch.Id, carol); + Assert.False(asCarol.IsOwner); Assert.True(asCarol.CanManageStructure); Assert.False(asCarol.CanRemoveMembers); + Assert.False(asCarol.CanCreateChannel || asCarol.CanManageFolders || asCarol.CanInvite || asCarol.CanEditSettings); + var asAlice = env.Service.GetChannel(ch.Id, alice); + Assert.False(asAlice.IsOwner); Assert.False(asAlice.CanManageStructure); Assert.True(asAlice.CanRemoveMembers); + // 默认删除策略:成员只能删自己的 + Assert.False(asAlice.CanDeleteAny); Assert.True(asAlice.CanDeleteOwn); + + // 谁能移除谁:所有者能移除除自己外的人;被授权的不能移除自己、所有者、同样被授权的人 + var byOwner = env.Service.MembersView(ch.Id, owner).ToDictionary(v => v.Member.Username, v => v.CanRemove); + Assert.True(byOwner["alice"]); Assert.True(byOwner["carol"]); + Assert.False(byOwner[env.Service.GetChannel(ch.Id, owner).CreatorAccount!]); + var byAlice = env.Service.MembersView(ch.Id, alice).ToDictionary(v => v.Member.Username, v => v.CanRemove); + Assert.False(byAlice["alice"]); Assert.False(byAlice["bob"]); Assert.True(byAlice["carol"]); + Assert.All(env.Service.MembersView(ch.Id, carol), view => Assert.False(view.CanRemove)); // 没有移除权限 + // 展示和执行用同一条规则:展示说不能移除的,真去移除也会被拒绝 + Assert.Throws(() => env.Service.RevokeInvitation(ch.Id, alice, "bob")); + } } diff --git a/tools/ContractGenerator/Program.cs b/tools/ContractGenerator/Program.cs index 7116a97..11a4b0b 100644 --- a/tools/ContractGenerator/Program.cs +++ b/tools/ContractGenerator/Program.cs @@ -24,7 +24,8 @@ while (pending.Count > 0) if (type.IsEnum) { var values = type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => "'" + (f.GetCustomAttribute()?.Name ?? f.Name) + "'"); - output.AppendLine($"export type {type.Name} = {string.Join(" | ", values)};\n"); + output.AppendLine($"export type {type.Name} = {string.Join(" | ", values)};"); + output.AppendLine($"export const {type.Name} = {{ {string.Join(", ", type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => f.Name + ": '" + (f.GetCustomAttribute()?.Name ?? f.Name) + "'"))} }} as const;\n"); definitions[type.Name] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray(type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => JsonValue.Create(f.GetCustomAttribute()?.Name ?? f.Name) as JsonNode).ToArray()) }; continue; } @@ -46,7 +47,20 @@ while (pending.Count > 0) output.AppendLine("}"); definitions[type.Name] = new JsonObject { ["type"] = "object", ["properties"] = properties, ["required"] = required }; } -var path = Path.Combine(root, "web/src/core-bridge/generated/api.ts"); +// 不在 DTO 里、但前端要用的取值:错误码、令牌角色、空间权限、分享状态。一律由后端定义、生成常量 +output.AppendLine("/** 错误响应里的 error 取值 */"); +output.AppendLine($"export const ErrorCode = {{ {string.Join(", ", Enum.GetValues().Select(c => c + ": '" + c.ToWire() + "'"))} }} as const;"); +output.AppendLine("export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];\n"); +output.AppendLine("/** X-WD-Role 请求头:这次请求出示哪张令牌 */"); +output.AppendLine($"export const TokenRole = {{ {string.Join(", ", Enum.GetValues().Where(r => r != Role.None).Select(r => r + ": '" + r.ToString().ToLowerInvariant() + "'"))} }} as const;"); +output.AppendLine("export type TokenRole = (typeof TokenRole)[keyof typeof TokenRole];\n"); +output.AppendLine("/** 空间权限(身份组授予) */"); +output.AppendLine($"export const WorkspacePermission = {{ {string.Join(", ", typeof(WorkspacePermission).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy).Where(f => f.IsLiteral && f.FieldType == typeof(string) && f.Name != "Colors").Select(f => f.Name + ": '" + f.GetRawConstantValue() + "'"))} }} as const;"); +output.AppendLine("export type WorkspacePermission = (typeof WorkspacePermission)[keyof typeof WorkspacePermission];\n"); +output.AppendLine("/** 便捷分享的状态 */"); +output.AppendLine($"export const ShareState = {{ {string.Join(", ", typeof(ShareState).GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => f.Name + ": '" + f.GetRawConstantValue() + "'"))} }} as const;"); +output.AppendLine("export type ShareState = (typeof ShareState)[keyof typeof ShareState];"); +var path = Path.Combine(root, "web/src/api/generated.ts"); var text = output.ToString().Replace("\r\n", "\n"); if (args.Contains("--check")) { diff --git a/tools/ContractGenerator/README.md b/tools/ContractGenerator/README.md index c36b08f..dceb2bc 100644 --- a/tools/ContractGenerator/README.md +++ b/tools/ContractGenerator/README.md @@ -8,6 +8,6 @@ dotnet run --project tools/ContractGenerator -- . --check node scripts/check-api-contract.mjs ``` -The tool reads public DTOs registered in `WebDrop.Api.ApiJson` and their referenced types. It writes TypeScript interfaces to `web/src/core-bridge/generated/api.ts` and JSON Schema definitions to `contracts/api.schemas.json`. OpenAPI references these generated schemas. Backend-to-Cap serialization uses a separate context and is excluded from the browser contract. +The tool reads public DTOs registered in `WebDrop.Api.ApiJson` and their referenced types. It writes TypeScript interfaces to `web/src/api/generated.ts` and JSON Schema definitions to `contracts/api.schemas.json`. OpenAPI references these generated schemas. Backend-to-Cap serialization uses a separate context and is excluded from the browser contract. Reflection is used only by this development tool. The production API keeps source-generated, NativeAOT-compatible serialization. Generated files are committed; no extra runtime dependency is added to the frontend.