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
This commit is contained in:
justme committed 2026-10-19 13:38:40 +08:00
1 parent ccb2c074d6
commit 17638b6e20
36 files changed
+926 -589

No files matched your search

+101 -30
View File
@@ -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": {
+3 -3
View File
@@ -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];
+10 -3
View File
@@ -1,10 +1,17 @@
<Solution>
<Folder Name="/src/">
<!-- 主后端(hostAPI):账号、空间、频道、聊天、实时推送,以及内嵌的前端 -->
<Folder Name="/src/host/">
<Project Path="src/WebDrop.Api/WebDrop.Api.csproj" />
<Project Path="src/WebDrop.Core/WebDrop.Core.csproj" />
<Project Path="src/WebDrop.Frontend/WebDrop.Frontend.csproj" />
</Folder>
<!-- 文件服务(fileAPI):另一个进程,只保存超过阈值的大文件 -->
<Folder Name="/src/files/">
<Project Path="src/WebDrop.FileApi/WebDrop.FileApi.csproj" />
<Project Path="src/WebDrop.FileProtocol/WebDrop.FileProtocol.csproj" />
<Project Path="src/WebDrop.Frontend/WebDrop.Frontend.csproj" />
</Folder>
<!-- 两边共用:业务规则、数据库、S3 备份 -->
<Folder Name="/src/shared/">
<Project Path="src/WebDrop.Core/WebDrop.Core.csproj" />
<Project Path="src/WebDrop.Storage/WebDrop.Storage.csproj" />
<Project Path="src/WebDrop.Sync/WebDrop.Sync.csproj" />
</Folder>
@@ -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);
});
@@ -1,28 +1,58 @@
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
public static partial class Endpoints
{
/// <summary>文件服务的票据:客户端上传或下载文件内容前先来要一张,拿着它直接找文件服务。</summary>
/// <summary>
/// 文件服务的票据:权限由空间决定,通过后给客户端一张短期票据,客户端拿着它直接找文件服务。
/// create 是所有者/管理员创建文件域;put/get 是按频道权限上传、下载某个条目。
/// </summary>
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<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() 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<ChannelService.FrameEntry>((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);
});
}
}
@@ -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);
@@ -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);
});
@@ -1,21 +0,0 @@
using WebDrop.Core;
namespace WebDrop.Api;
/// <summary>
/// 频道用的 blob 存储:本地照旧,删除前缀时同时通知文件服务。
/// 条目被删、过期、频道被删时,文件服务上的那部分字节随之清掉(见 <see cref="RemoteDeleteQueue"/>)。
/// </summary>
public sealed class FileAwareBlobStore(IBlobStore inner, RemoteDeleteQueue remote) : IBlobStore
{
public ValueTask PutAsync(string key, ReadOnlyMemory<byte> data, CancellationToken ct = default) => inner.PutAsync(key, data, ct);
public ValueTask<byte[]?> 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);
}
}
@@ -0,0 +1,24 @@
using System.Text;
using WebDrop.Core;
using WebDrop.FileProtocol;
namespace WebDrop.Api;
/// <summary>
/// 主后端这一侧和文件服务的全部关系:拿共用的密钥给客户端签短期票据。
/// 它不调用文件服务、不保存文件服务的任何状态;文件域怎么创建、多大、用了多少,都是客户端直接和文件服务之间的事。
/// </summary>
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);
}
}
@@ -1,46 +0,0 @@
using WebDrop.Core;
namespace WebDrop.Api;
/// <summary>
/// 对账:文件服务上有、主后端已经没有记录的条目(上传到一半放弃、删除通知丢了)删掉。
/// 只处理超过一天的,正在上传的不会误删。每天跑一次,文件服务离线就等下一次。
/// </summary>
public sealed class FileReconcileWorker(IFileGateway gateway, WebDropOptions options, ChannelService service, IClock clock, RemoteDeleteQueue deletes, ILogger<FileReconcileWorker> 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<int> 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;
}
}
@@ -1,91 +0,0 @@
using WebDrop.Core;
namespace WebDrop.Api;
/// <summary>
/// 要在文件服务上删除的前缀(频道 或 频道/条目)。先写进磁盘再后台重试:
/// 家里的机器暂时不在线也不会丢,等它回来再删;删不掉的只是多占一点空间,不影响任何功能。
/// 没有启用文件服务时什么也不做。
/// </summary>
public sealed class RemoteDeleteQueue : BackgroundService
{
private readonly RemoteFileGateway? _gateway;
private readonly FileApiOptions _options;
private readonly string _path;
private readonly IClock _clock;
private readonly ILogger<RemoteDeleteQueue> _logger;
private readonly object _gate = new();
private readonly HashSet<string> _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<RemoteDeleteQueue> 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<bool> 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; }
}
}
@@ -1,26 +0,0 @@
using System.Text;
using WebDrop.Core;
using WebDrop.FileProtocol;
namespace WebDrop.Api;
/// <summary>主后端这一侧的文件服务网关:给客户端签票据、验文件服务的回执。</summary>
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);
/// <summary>主后端调用文件服务管理接口用的票据,有效期很短</summary>
public string AdminTicket(long nowMs) => FileTokens.Sign(_secret, new FileTicket(FileOp.Admin, "*", "*", 0, nowMs + 60_000));
}
@@ -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);
+4 -7
View File
@@ -51,16 +51,13 @@ public static class WebDropApp
builder.Services.AddSingleton<ChannelHub>();
builder.Services.AddSingleton<IChannelEvents>(sp => sp.GetRequiredService<ChannelHub>());
options.Files.Validate();
builder.Services.AddSingleton<IFileGateway>(_ => options.Files.Enabled ? new RemoteFileGateway(options.Files) : NoFileGateway.Instance);
builder.Services.AddSingleton<RemoteDeleteQueue>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<RemoteDeleteQueue>());
builder.Services.AddHostedService<FileReconcileWorker>();
builder.Services.AddSingleton<IFileGrants>(_ => options.Files.Enabled ? new FileGrants(options.Files) : NoFileGrants.Instance);
builder.Services.AddSingleton<BackupWorker>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<BackupWorker>());
builder.Services.AddSingleton<ChannelService>(sp => new ChannelService(
sp.GetRequiredService<IResourceStore>(),
new FileAwareBlobStore(sp.GetRequiredService<IBlobStore>(), sp.GetRequiredService<RemoteDeleteQueue>()),
sp.GetRequiredService<IClock>(), options.Limits, sp.GetRequiredService<IChannelEvents>(), sp.GetRequiredService<IFileGateway>()));
sp.GetRequiredService<IBlobStore>(),
sp.GetRequiredService<IClock>(), options.Limits, sp.GetRequiredService<IChannelEvents>(), sp.GetRequiredService<IFileGrants>()));
builder.Services.AddSingleton<ShareService>(sp => new ShareService(
sp.GetRequiredService<IResourceStore>(), sp.GetRequiredService<IBlobStore>(), sp.GetRequiredService<IClock>(),
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<ICaptchaVerifier>(), app.Services.GetRequiredService<SqliteAccountStore>(), app.Services.GetRequiredService<IFileGateway>());
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>(), app.Services.GetRequiredService<SqliteAccountStore>(), app.Services.GetRequiredService<IFileGrants>());
return app;
}
@@ -38,13 +38,13 @@ public sealed class WebDropOptions
/// <summary>文件服务(fileAPI)。关闭时文件帧仍存在本进程的存储里。</summary>
public FileApiOptions Files { get; set; } = new();
/// <summary>每天把数据库(聊天、头像、账号)做个快照,压缩后上传到外部存储(WebDAV,如 OpenList),保留 RetainDays 天</summary>
/// <summary>每天把数据库(聊天、头像、账号)做个快照,压缩后上传到外部存储(S3 协议),保留 RetainDays 天</summary>
public DailyBackupOptions Backup { get; set; } = new() { Time = "04:15" };
}
/// <summary>
/// 把受限文件的内容交给另一台机器上的文件服务保存(见 docs 的 fileAPI 说明)。
/// 主后端保存聊天、头像、空间结构和文件的元数据与摘要;所有文件的字节(不分大小)由客户端直接传给文件服务。
/// 文件的内容不放主后端,由另一台机器上的文件服务(fileAPI)保存。主后端只保存聊天、头像、空间结构和文件的元数据与摘要,
/// 并按空间里的权限给客户端签发访问文件服务的短期票据;客户端直接和文件服务通信,主后端不调用它。
/// </summary>
public sealed class FileApiOptions
{
@@ -53,10 +53,7 @@ public sealed class FileApiOptions
/// <summary>浏览器访问文件服务的地址(经反向隧道对外的那个),形如 https://files.example.com</summary>
public string PublicUrl { get; set; } = "";
/// <summary>主后端调用文件服务管理接口(删除、对账、复制)的地址,可以是隧道的内网端口,如 http://127.0.0.1:7100</summary>
public string InternalUrl { get; set; } = "";
/// <summary>和文件服务共用的密钥,至少 32 个字符。用来签票据、验回执</summary>
/// <summary>和文件服务共用的密钥,至少 32 个字符。用来给票据签名</summary>
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('/');
}
+21 -9
View File
@@ -28,27 +28,30 @@ public sealed class ServerInfoBody
public sealed class FilesInfoBody
{
/// <summary>true 时,文件的内容(不分大小)由客户端直接传给文件服务,主后端只登记元数据</summary>
/// <summary>true 时,超过 threshold_bytes 的文件由客户端直接传给文件服务,主后端只登记元数据</summary>
[JsonPropertyName("enabled")] public bool Enabled { get; init; }
/// <summary>超过这个大小的文件才存到文件服务;未开启时为 0</summary>
[JsonPropertyName("url")] public string? Url { get; init; }
}
public sealed class FileGrantRequest
{
/// <summary>条目 id(base64url)</summary>
[JsonPropertyName("item")] public required string Item { get; init; }
/// <summary>put:上传;get:下载或播放</summary>
/// <summary>create:创建文件域(空间所有者/管理员);put:上传某个条目;get:下载或播放某个条目;delete:删除某个条目的字节;domain:查询某个频道文件域的容量和用量</summary>
[JsonPropertyName("op")] public required string Op { get; init; }
/// <summary>put/get/delete 时的条目 id(base64url)</summary>
[JsonPropertyName("item")] public string? Item { get; init; }
/// <summary>domain 时的频道 id:查询这个频道文件域的容量和用量</summary>
[JsonPropertyName("channel")] public string? Channel { get; init; }
/// <summary>get 时从第几帧开始(0 才计一次下载)</summary>
[JsonPropertyName("from")] public long From { get; init; }
}
/// <summary>去文件服务用的凭证:地址、文件域 id(create 时为空)和一张短期票据</summary>
public sealed class FileGrantBody
{
/// <summary>local:这个条目的内容在主后端,走原来的接口;remote:去 url 用 ticket 取/传</summary>
[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; }
/// <summary>调用者能不能撤销这条邀请(后端按权限判断)</summary>
[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
/// <summary>该账号任一设备最近一次活跃的 Unix 时间戳(登录时是秒,活跃续期后是毫秒,客户端按量级区分);没有登录过设备时为 0。</summary>
[JsonPropertyName("last_seen")] public required long LastSeen { get; init; }
/// <summary>调用者能不能移除这位成员(后端按权限判断)。</summary>
[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; }
/// <summary>文件服务里为这个频道创建的文件域 id(不透明字符串,主后端只存不解释)</summary>
[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))]
@@ -30,15 +30,33 @@ public sealed partial class ChannelService
public IReadOnlyList<ChannelInvitation> 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);
});
/// <summary>成员列表,附带调用者能不能移除每一位(后端判断,前端只显示按钮)</summary>
public IReadOnlyList<MemberView> 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();
});
/// <summary>邀请列表,附带调用者能不能撤销每一条</summary>
public IReadOnlyList<InvitationView> 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();
});
/// <summary>非空间主的调用方:必须是已加入的成员,且所在身份组被授予该权限</summary>
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<ChannelMember> 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);
});
@@ -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):谁能读、写哪个频道的哪个数据域。其他地方只调用这里,不再各写一遍
/// <summary>空间的所有者:持有所有者令牌的人,或者空间的创建账号</summary>
public static bool IsSpaceOwner(Channel space, Principal p) => p.Role == Role.Owner || p.AccountName == space.CreatorAccount;
/// <summary>某个账号在某个频道上的访问授权;没有记录返回 null</summary>
public static ChannelAccess? AccessOf(CryptoState crypto, string? account, string channelId) =>
crypto.Access.Find(a => a.ChannelId == channelId && a.Username == account);
/// <summary>这条授权允许读这个数据域</summary>
public static bool CanReadDomain(ChannelAccess? access, CryptoDomain domain) =>
access is not null && (domain == CryptoDomain.Chat ? access.ChatRead : access.FilesRead);
/// <summary>这条授权允许写这个数据域(聊天域写消息,文件域上传)</summary>
public static bool CanWriteDomain(ChannelAccess? access, CryptoDomain domain) =>
access is not null && (domain == CryptoDomain.Chat ? access.ChatWrite : access.FilesUpload);
/// <summary>读一个对象:能读它所在的数据域,并且对象不早于授权生效的版本(关闭历史可读时,新成员看不到加入前的内容)</summary>
public static bool CanReadObject(ChannelAccess? access, CryptoDomain domain, long objectRevision) =>
CanReadDomain(access, domain) && objectRevision >= (domain == CryptoDomain.Chat ? access!.ChatFromRevision : access!.FilesFromRevision);
/// <summary>频道对这个账号可见:聊天或文件任一域能读</summary>
public static bool CanSeeChannel(ChannelAccess access) => access.ChatRead || access.FilesRead;
/// <summary>能碰这个频道的文件域:能读或能上传</summary>
public static bool CanUseFiles(ChannelAccess access) => access.FilesRead || access.FilesUpload;
/// <summary>能往频道里放东西:聊天或文件任一域能写</summary>
public static bool CanWriteAnyDomain(ChannelAccess? access) => access is not null && (access.ChatWrite || access.FilesUpload);
/// <summary>这个频道里能读某个数据域的账号</summary>
public static IEnumerable<ChannelAccess> Readers(CryptoState crypto, string channelId, CryptoDomain domain) =>
crypto.Access.Where(a => a.ChannelId == channelId && CanReadDomain(a, domain));
/// <summary>
/// 这个空间是否已经按授权记录来约束成员:加密空间有了密钥信封,明文空间有了授权记录或对象登记之后才约束。
/// 之前(刚建好、还没初始化)所有成员的行为和旧版一致。
/// </summary>
public static bool GrantsEnforced(Channel space, CryptoState crypto) =>
space.Mode == ChannelMode.Enc ? crypto.Envelopes.Count > 0 : crypto.Access.Count > 0 || crypto.Objects.Count > 0;
// ───── 空间权限(身份组授予)与结构管理
/// <summary>有没有这项空间权限:所有者拥有全部,其余看身份组授予</summary>
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;
/// <summary>重命名频道、调整频道所属分组、管理分组(所有者,或被授予「管理频道和分组」)</summary>
public static bool CanManageStructure(WorkspaceState? state, Principal p) => HasPermission(state, p, WorkspacePermission.ManageStructure);
/// <summary>移除成员、撤销邀请的权限(所有者,或被授予「移除成员」)。具体能移除谁见 <see cref="CanRemoveMember"/></summary>
public static bool CanRemoveMembers(WorkspaceState? state, Principal p) => HasPermission(state, p, WorkspacePermission.RemoveMembers);
/// <summary>
/// 谁能移除谁:所有者能移除除自己外的任何人;被授权的成员不能移除自己、空间主,也不能移除同样有移除权限的人。
/// 执行(RevokeInvitation)和给前端的可移除标记(MembersView)都用这一处。
/// </summary>
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);
// ───── 删除条目
/// <summary>能删任何人发的条目:所有者,或空间的删除策略是 anyone</summary>
public static bool CanDeleteAnyItem(Channel channel, Principal p) =>
IsOwner(p) || p.Role >= Role.Read && channel.Settings.DeletePolicy == DeletePolicy.Anyone;
/// <summary>能删自己发的条目:所有者,或删除策略是 own / anyone</summary>
public static bool CanDeleteOwnItem(Channel channel, Principal p) =>
IsOwner(p) || p.Role >= Role.Read && channel.Settings.DeletePolicy is DeletePolicy.Own or DeletePolicy.Anyone;
/// <summary>某个具体条目能不能删:能删任何人的,或者是自己的并且能删自己的</summary>
public static bool CanDeleteItem(Channel channel, Item item, Principal p) =>
CanDeleteAnyItem(channel, p) || item.AuthorAccount == p.AccountName && CanDeleteOwnItem(channel, p);
}
@@ -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;
/// <summary>帧内容在文件服务上的帧,BlobKey 以它开头;主后端只有头部、长度和摘要</summary>
private IFileGrants Files => fileGrants ?? NoFileGrants.Instance;
/// <summary>帧内容在文件域里的帧,BlobKey 以它开头;主后端只有头部、长度和摘要</summary>
public const string FilesPrefix = "files:";
/// <summary>这个条目是文件域的对象(不含目录控制帧)</summary>
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));
});
/// <summary>空间头像原图(预览时才下载)。需要能读这个空间</summary>
@@ -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;
}
/// <summary>登记整个文件时,每一帧的负载长度和摘要(摘要是整帧封装的 SHA-256)</summary>
public readonly record struct FrameEntry(int PayloadLength, byte[] Digest);
/// <summary>
/// 登记已经存进文件服务的一帧:请求体只有 26 字节头部,另带文件服务签的回执(摘要、长度)。
/// 之后的规则(帧序、配额、清单、签名事件、日志)和 <see cref="AppendFrameAsync"/> 完全一样。
/// 一次登记整个文件:字节都在文件域里,逐帧登记要为每个 1 MiB 往返一次主后端,大文件的上传速度被它拖死。
/// 请求带的是末帧的头部和每一帧的(长度,摘要)。规则和逐帧登记完全一样:第 0 帧创建条目(含下载次数、日志里的公布),
/// 中间帧只落库,末帧验签名事件(清单摘要要和这里登记的所有摘要对得上)。整个登记在一个事务里,要么全成要么全不成。
/// 重复登记同一个文件(摘要全一致)幂等。
/// </summary>
public async Task<AppendResult> CommitRemoteFrameAsync(
Id16 channelId, Credentials creds, ReadOnlyMemory<byte> header, string receiptToken,
public AppendResult RegisterFileItem(
Id16 channelId, Credentials creds, ReadOnlyMemory<byte> finalHeader, IReadOnlyList<FrameEntry> 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<byte>.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<byte>.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<byte>.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 };
}
/// <summary>主后端有没有这个条目的记录(文件服务对账用)</summary>
public bool ItemExists(Id16 channelId, Id16 itemId) => store.Read(tx => tx.GetItem(channelId, itemId) is not null);
/// <summary>上传票据:确认能写这个频道,然后给客户端文件服务的地址和一张只对这个条目有效的票据。</summary>
public FileGrant? IssueUploadGrant(Id16 id, Credentials creds, Id16 itemId)
/// <summary>条目所属频道的文件域(文件域的对象才有)</summary>
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;
}
/// <summary>创建文件域的票据:只有空间所有者和有「管理频道和分组」权限的人能要。票据绑定空间 id</summary>
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);
}
/// <summary>上传票据:确认能写这个条目所在的频道,然后给客户端文件域的访问凭证。</summary>
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);
}
/// <summary>
/// 下载票据。条目的帧在主后端时返回 null(客户端走原来的接口,这里不计次);在文件服务时,
/// 先按原来的规则计一次下载(只有 from=0 才计),再签票据。条目没传完时返回冲突,让客户端稍后再来。
/// 文件域的查询票据(看容量和用量):能读这个频道的文件就行。票据的条目是 "*",只能查域,读不了任何文件。
/// </summary>
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);
}
/// <summary>删除票据:确认调用者按原来的规则能删这个条目,再签。客户端先拿票据,再删主后端的条目,最后用票据清文件域里的字节</summary>
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);
}
/// <summary>
/// 下载票据:按原来的规则鉴权并计次(只有 from=0 才计),再签票据。条目没传完时返回冲突,让客户端稍后再来。
/// </summary>
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<string> 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<string> 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,
};
};
}
}
+15
View File
@@ -52,8 +52,23 @@ public sealed class ShareItemInfo
public sealed class ChannelInfo
{
// ───── 调用者在这个空间里的能力。前端只读不算:规则只在后端一处
/// <summary>调用者是空间所有者</summary>
[JsonPropertyName("is_owner")] public bool IsOwner { get; init; }
[JsonPropertyName("can_upload")] public bool CanUpload { get; init; }
/// <summary>能重命名频道、管理分组(所有者,或被授予「管理频道和分组」)</summary>
[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; }
/// <summary>能移除成员、撤销邀请;具体能移除谁见成员列表里每一项的 can_remove</summary>
[JsonPropertyName("can_remove_members")] public bool CanRemoveMembers { get; init; }
/// <summary>能删除任何人的消息和文件(所有者,或删除策略为 anyone)</summary>
[JsonPropertyName("can_delete_any")] public bool CanDeleteAny { get; init; }
/// <summary>能删除自己发的消息和文件</summary>
[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; }
/// <summary>空间头像(data URL,PNG/JPEG/WebP/GIF),没有设置时为 null。</summary>
@@ -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;
/// <summary>最大账号成员数。属性名和 info 字段保持旧线格式兼容。</summary>
public int MaxMembers { get; set; } = 64;
public int MaxVisitors { get; set; } = 10_000;
+7
View File
@@ -66,3 +66,10 @@ public enum TombstoneReason : byte
DownloadLimit = 3,
Superseded = 4,
}
/// <summary>便捷分享的状态(线格式的取值)。前端从生成的契约里取,不手写字符串</summary>
public static class ShareState
{
public const string Draft = "draft";
public const string Sealed = "sealed";
}
+2
View File
@@ -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);
/// <summary>已存储的一帧(完整封装)。Inline 与 BlobKey 二选一。</summary>
@@ -1,28 +0,0 @@
using WebDrop.FileProtocol;
namespace WebDrop.Core;
/// <summary>给客户端的文件服务入口:地址 + 一张短期票据</summary>
public sealed record FileGrant(string Url, string Ticket, long ExpiresAt);
/// <summary>
/// 主后端和文件服务之间的接口。关闭时(默认)所有帧仍然存在主后端自己的存储里,行为和以前完全一样;
/// 打开后,文件的内容(文件域的对象,不分大小)由客户端直接传给文件服务,主后端只登记元数据和摘要;聊天、头像、目录仍在主后端。
/// </summary>
public interface IFileGateway
{
bool Enabled { get; }
/// <summary>客户端拿到的文件服务根地址(经反向隧道对外的那个)</summary>
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; }
}
@@ -0,0 +1,27 @@
using WebDrop.FileProtocol;
namespace WebDrop.Core;
/// <summary>给客户端的访问凭证:文件服务地址、文件域 id 和一张短期票据</summary>
public sealed record FileGrant(string Url, string Domain, string Ticket, long ExpiresAt);
/// <summary>
/// 主后端只负责「权限」:判断某人在某个空间里能不能读写某个频道的文件,然后给文件服务(fileAPI)签一张短期票据。
/// 文件域的创建、容量、用量、字节的存取都是文件服务自己的事,主后端既不调用它,也不保存它的任何状态。
/// </summary>
public interface IFileGrants
{
bool Enabled { get; }
/// <summary>文件服务对外的地址,告诉客户端去哪里</summary>
string PublicUrl { get; }
/// <summary>space:签发票据的空间 id(十六进制)。scope:Create 时是空间 id;其余是文件域 id。item 是条目 id(十六进制)或 "*"</summary>
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);
}
@@ -355,7 +355,7 @@ public sealed class ShareService(
private static ShareInfo ToInfo(Share share, IReadOnlyList<ShareItem> 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,
+14 -21
View File
@@ -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);
@@ -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)
/// <summary>文件域的引用只是个不透明的字符串(文件服务给的域 id),主后端不解释它,只存下来、原样给成员</summary>
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;
/// <summary>给还没有文件域的频道指定一个(创建频道时没带,或旧频道)。指定后不能改:文件已经存在那里了</summary>
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 });
}
+2 -1
View File
@@ -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; }
/// <summary>这个频道的文件域在文件服务里的 id。不透明字符串:创建、容量、用量都归文件服务管,这里只是引用。null 表示还没分配</summary>
[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; }
+192 -112
View File
@@ -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;
}
/// <summary>空间里的默认频道:发布聊天和文件两个域的密钥(客户端创建空间时做的事),之后才能声明对象</summary>
/// <summary>空间里的默认频道:发布聊天和文件两个域的密钥(客户端创建空间时做的事),并为它创建一个文件域(同样是客户端做的事),之后才能声明对象</summary>
private async Task<string> 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<HttpResponseMessage> 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<string, string> _domains = [];
private async Task<(string Mode, string? Url, string? Ticket)> TicketAsync(Chan ch, string item, string op, uint from = 0)
private async Task<HttpResponseMessage> 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()!);
}
/// <summary>前端创建频道时做的事:向空间要「创建」票据,去文件服务建文件域,再把域 id 记到频道上</summary>
private async Task<string> 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;
}
/// <summary>把一帧存进文件域,返回文件服务算的摘要</summary>
private async Task<string> 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<HttpResponseMessage> CommitAsync(Chan ch, byte[] frame, string receipt, string? maxDownloads = null, Dictionary<string, string>? signed = null)
/// <summary>到主后端一次登记整个文件:末帧的 26 字节头部 + 每一帧的(负载长度,整帧摘要),不带任何字节</summary>
private async Task<HttpResponseMessage> RegisterAsync(Chan ch, IReadOnlyList<byte[]> frames, string? maxDownloads = null, Dictionary<string, string>? signed = null, IReadOnlyList<string>? 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);
}
/// <summary>上传一个文件条目的帧:每帧先传给文件服务拿回执,再把头部和回执交给主后端登记;最后一帧带签名事件</summary>
private async Task<List<HttpStatusCode>> UploadFileAsync(Chan ch, string channelId, string item, IReadOnlyList<byte[]> frames, string? maxDownloads = null, bool final = true)
{
var statuses = new List<HttpStatusCode>();
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)
/// <summary>上传一个文件:每一帧先存进文件域,全部存好后一次到主后端登记(末帧的签名事件盖着所有帧的摘要)</summary>
private async Task<HttpStatusCode> UploadFileAsync(Chan ch, string channelId, string item, IReadOnlyList<byte[]> 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<FrameStore>();
// 没传完的条目可以直接删(取消上传):文件服务上的帧跟着清掉
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<FrameStore>();
Assert.Contains($"{Hex(ch.Id)}/{Hex(item)}", store.ItemsOlderThan(TimeSpan.Zero));
var worker = _main.App.Services.GetServices<Microsoft.Extensions.Hosting.IHostedService>().OfType<WebDrop.Api.FileReconcileWorker>().Single();
var gateway = (WebDrop.Api.RemoteFileGateway)_main.App.Services.GetRequiredService<IFileGateway>();
Assert.True(await worker.ReconcileAsync(gateway, CancellationToken.None, TimeSpan.Zero) >= 1);
await WaitAsync(() => store.UsedBytes == 0);
}
private static async Task WaitAsync(Func<bool> 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()}");
}
}
@@ -4,6 +4,13 @@ namespace WebDrop.Core.Tests;
public sealed class ChannelCryptoTests
{
/// <summary>文件域的对象主后端只登记头部和摘要,字节不在这里:一个单帧文件</summary>
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<WebDropException>(() => 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()
{
@@ -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<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, header, other, 3)).Code); // 摘要不同就是冲突
Assert.Equal(ErrorCode.BadRequest, Assert.Throws<WebDropException>(() => 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<WebDropException>(() => 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]
@@ -0,0 +1,48 @@
using System.Text.RegularExpressions;
namespace WebDrop.Core.Tests;
/// <summary>
/// 权限规则只写在 ChannelPolicy 一处:别的地方只许调用,不许再各写一遍。
/// 这个测试扫描源码,发现在 ChannelPolicy 之外重新判断角色、读写授权就失败,防止重复实现长回来。
/// </summary>
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<string>();
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));
}
}
@@ -148,4 +148,54 @@ public sealed class WorkspaceStructureTests
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceChannel(ch.Id, alice, "新频道", null)); // 新建频道涉及密钥,只有空间主
Assert.Throws<WebDropException>(() => 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<WebDropException>(() => 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<WebDropException>(() => 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<WebDropException>(() => env.Service.RevokeInvitation(ch.Id, alice, "bob"));
}
}
+16 -2
View File
@@ -24,7 +24,8 @@ while (pending.Count > 0)
if (type.IsEnum)
{
var values = type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => "'" + (f.GetCustomAttribute<JsonStringEnumMemberNameAttribute>()?.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<JsonStringEnumMemberNameAttribute>()?.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<JsonStringEnumMemberNameAttribute>()?.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<ErrorCode>().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<Role>().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"))
{
+1 -1
View File
@@ -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.