diff --git a/server/src/WebDrop.Api/Endpoints.Accounts.cs b/server/src/WebDrop.Api/Endpoints.Accounts.cs
index 299b23e..bae5618 100644
--- a/server/src/WebDrop.Api/Endpoints.Accounts.cs
+++ b/server/src/WebDrop.Api/Endpoints.Accounts.cs
@@ -14,9 +14,11 @@ public static partial class Endpoints
return Json(accounts.Profile(username).WithoutAvatar(), ApiJson.Default.AccountProfile);
});
// 头像单独取:默认给有损的缩略图(列表、聊天用),size=full 才是原图(预览时)。带 ETag,没变化时只回 304。需要登录账号
- api.MapGet("/accounts/users/{username}/avatar", (string username, HttpContext ctx) =>
+ api.MapGet("/accounts/users/{username}/avatar", (string username, HttpContext ctx, ChannelService svc) =>
{
- if (!TryAccount(ctx, accounts, out _)) throw new WebDropException(ErrorCode.AccountRequired);
+ if (!TryAccount(ctx, accounts, out var viewer)) throw new WebDropException(ErrorCode.AccountRequired);
+ // 只有和对方同在一个空间(或就是自己)才能看头像,不能靠猜用户名批量抓取
+ if (!svc.SharesSpace(viewer, NormalizeUsername(username))) throw new WebDropException(ErrorCode.NotFound);
var profile = accounts.Profile(NormalizeUsername(username));
var full = ctx.Request.Query["size"] == "full";
return AvatarResult(ctx, full ? profile.Avatar : profile.AvatarThumb ?? profile.Avatar, $"{profile.ModifiedAt}-{(full ? "f" : "t")}");
diff --git a/server/src/WebDrop.Core/ChannelWorkspace.cs b/server/src/WebDrop.Core/ChannelWorkspace.cs
index 7129723..6787d6b 100644
--- a/server/src/WebDrop.Core/ChannelWorkspace.cs
+++ b/server/src/WebDrop.Core/ChannelWorkspace.cs
@@ -6,6 +6,10 @@ public sealed partial class ChannelService
{
foreach (var id in store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs))) events.SettingsChanged(id);
}
+ /// 两个账号是否在同一个空间里(自己总是可以)。成员资料只对有交集的账号开放
+ public bool SharesSpace(string username, string other) => username == other
+ || store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs).Any(id => tx.GetChannelMember(id, other) is not null));
+ public IReadOnlyList AccountSpaceIds(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs));
public IReadOnlyList AccountSpaces(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs).Select(id =>
{
var channel = tx.GetChannel(id)!;
@@ -35,9 +39,18 @@ public sealed partial class ChannelService
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
var state = tx.GetWorkspace(id) ?? new WorkspaceState();
EnsureDefaultRole(state, clock.NowMs);
+ return ViewFor(tx, space, principal, state);
+ });
+ ///
+ /// 空间结构按调用者的授权裁剪:没授权的频道、目录和条目位置一律不下发。
+ /// 读取和所有修改接口的返回值都必须经过它,否则任何一个成员的一次修改(比如发消息后登记条目位置)
+ /// 都会把整份结构返回给他。
+ ///
+ private WorkspaceState ViewFor(IResourceTx tx, Channel space, Principal principal, WorkspaceState state)
+ {
if (principal.AccountName != space.CreatorAccount)
{
- var crypto = tx.GetWorkspaceCrypto(id);
+ 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();
state.Channels.RemoveAll(c => !ids.Contains(c.Id));
@@ -49,9 +62,9 @@ public sealed partial class ChannelService
state.Folders.RemoveAll(f => !ids.Contains(f.ThreadId));
state.Locations.RemoveAll(l => !Id16.TryParse(l.ItemId, out var itemId) || !CanReadObject(tx, space, principal, itemId));
}
- state.Locations = state.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is { } item && item.ExpiresAt > clock.NowMs).ToList();
+ state.Locations = state.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(space.Id, itemId) is { } item && item.ExpiresAt > clock.NowMs).ToList();
return state;
- });
+ }
private WorkspaceState ChangeWorkspace(Id16 id, Credentials creds, Action change, bool requireAccount = true, string? permission = null)
{
var state = store.Write(tx =>
@@ -68,7 +81,8 @@ public sealed partial class ChannelService
ValidateWorkspace(current);
current.Revision++;
tx.PutWorkspace(id, current);
- return current;
+ // 先落库完整的结构,再按调用者裁剪后返回
+ return ViewFor(tx, channel, principal, current);
});
events.SettingsChanged(id);
return state;
@@ -166,12 +180,24 @@ public sealed partial class ChannelService
});
private static WorkspaceRole FindRole(WorkspaceState state, string id) => state.Roles.Find(r => r.Id == id) ?? throw new WebDropException(ErrorCode.NotFound);
public WorkspaceState CreateWorkspaceFolder(Id16 id, Credentials creds, string threadId, string? parentId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
- state.Folders.Add(new WorkspaceFolder { Id = Guid.NewGuid().ToString(), ThreadId = threadId, ParentId = parentId, Name = WorkspaceName(name) }));
+ {
+ FindChannel(state, threadId);
+ state.Folders.Add(new WorkspaceFolder { Id = Guid.NewGuid().ToString(), ThreadId = threadId, ParentId = parentId, Name = WorkspaceName(name) });
+ });
public WorkspaceState RenameWorkspaceFolder(Id16 id, Credentials creds, string folderId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
(state.Folders.Find(f => f.Id == folderId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name));
public WorkspaceState MoveWorkspaceItems(Id16 id, Credentials creds, string[] itemIds, string threadId, string? folderId) => ChangeWorkspace(id, creds, (tx, state, principal) =>
{
if (itemIds.Length is < 1 or > 100 || itemIds.Distinct().Count() != itemIds.Length) throw new WebDropException(ErrorCode.BadRequest);
+ // 目的频道必须存在,而且成员只能往自己有写入授权的频道放条目
+ 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);
+ }
foreach (var itemId in itemIds)
{
if (!Id16.TryParse(itemId, out var parsed)) throw new WebDropException(ErrorCode.BadRequest);
diff --git a/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs b/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs
index 6530b33..13e8455 100644
--- a/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs
+++ b/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs
@@ -114,6 +114,33 @@ public sealed class ChannelCryptoTests
Assert.Null(env.Service.ReadCrypto(space.Id, owner).Objects.Find(o => o.ItemId == file.ToBase64Url())?.Event);
}
+ [Fact]
+ public void MemberOnlySeesChannelsItWasGrantedAndNeverTheirKeysObjectsOrWrites()
+ {
+ using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep);
+ var defaultId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
+ Bootstrap(env, space, owner, defaultId);
+ var secret = env.Service.CreateWorkspaceChannelResult(space.Id, owner, "secret", null).Channel.Id;
+ Bootstrap(env, space, owner, secret);
+ var hidden = TestEnv.NewId().ToBase64Url();
+ env.Service.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = hidden, ChannelId = secret, Domain = CryptoDomain.Chat, Epoch = 1, KeyEnvelope = "k" });
+
+ var plan = env.Service.InvitationPlan(space.Id, owner, "member");
+ var packages = plan.Batches.SelectMany(batch => batch.Recipients.Select(recipient => new DomainEnvelope
+ { ChannelId = plan.ChannelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = recipient, Payload = $"wrapped-{batch.Domain}-{recipient}" })).ToList();
+ env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", packages);
+ var member = space.Member(memberEp) with { AccountName = "member" }; env.Service.Join(space.Id, member);
+
+ // 结构里没有没授权的频道;密钥包、对象登记里也没有它的任何东西
+ Assert.Equal(new[] { defaultId }, env.Service.GetWorkspace(space.Id, member).Channels.Select(c => c.Id));
+ var crypto = env.Service.ReadCrypto(space.Id, member);
+ Assert.DoesNotContain(crypto.Envelopes, e => e.ChannelId == secret);
+ Assert.DoesNotContain(crypto.Objects, o => o.ChannelId == secret);
+ Assert.DoesNotContain(crypto.Access, a => a.ChannelId == secret);
+ // 也不能往没授权的频道登记对象
+ Assert.Throws(() => env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = TestEnv.NewId().ToBase64Url(), ChannelId = secret, Domain = CryptoDomain.Chat, Epoch = 1, KeyEnvelope = "x" }));
+ }
+
private static void Bootstrap(TestEnv env, TestChannel space, Credentials owner, string channelId)
{
foreach (var domain in new[] { CryptoDomain.Chat, CryptoDomain.Files }) env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest