namespace WebDrop.Core; public sealed partial class ChannelService { public void AccountProfileChanged(string username) { foreach (var id in store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs))) events.SettingsChanged(id); } public IReadOnlyList AccountSpaces(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs).Select(id => { 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)); }).ToList()); private static WorkspaceState NewWorkspace(string username, long now) { var id = Guid.NewGuid().ToString(); var state = new WorkspaceState { Revision = 1, DefaultChannelId = id, Channels = [new WorkspaceChannel { Id = id, Name = "常规", CreatedAt = now, CreatedBy = username }] }; EnsureDefaultRole(state, now); return state; } /// 补上默认身份组(只补一次)。读取时只在内存里补,下一次写入才落库 private static void EnsureDefaultRole(WorkspaceState state, long now) { if (state.RolesSeeded) return; if (!state.Roles.Any(r => r.Id == WorkspaceRole.DefaultId)) state.Roles.Insert(0, WorkspaceRole.CreateDefault(now)); state.RolesSeeded = true; } public WorkspaceState GetWorkspace(Id16 id, Credentials creds) => store.Read(tx => { var (space, principal) = Authenticate(tx, id, creds, Membership.Required); var state = tx.GetWorkspace(id) ?? new WorkspaceState(); EnsureDefaultRole(state, clock.NowMs); if (principal.AccountName != space.CreatorAccount) { var crypto = tx.GetWorkspaceCrypto(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)); 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)); } state.Locations = state.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(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 => { var (channel, principal) = Authenticate(tx, id, creds, Membership.Required); 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); 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); ValidateWorkspace(current); current.Revision++; tx.PutWorkspace(id, current); return current; }); events.SettingsChanged(id); return state; } public (WorkspaceState State, WorkspaceChannel Channel) CreateWorkspaceChannelResult(Id16 id, Credentials creds, string name, string? groupId) { 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! }; workspace.Channels.Add(created); }); return (state, created!); } public WorkspaceState CreateWorkspaceChannel(Id16 id, Credentials creds, string name, string? groupId) => CreateWorkspaceChannelResult(id, creds, name, groupId).State; public WorkspaceState 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, _) => FindChannel(state, channelId).GroupId = groupId, permission: WorkspacePermission.ManageStructure); public (WorkspaceState State, WorkspaceGroup Group) CreateWorkspaceGroupResult(Id16 id, Credentials creds, string name) { WorkspaceGroup? created = null; var state = ChangeWorkspace(id, creds, (_, workspace, _) => { created = new WorkspaceGroup { Id = Guid.NewGuid().ToString(), Name = WorkspaceName(name), CreatedAt = clock.NowMs }; workspace.Groups.Add(created); }, permission: WorkspacePermission.ManageStructure); return (state, created!); } public WorkspaceState CreateWorkspaceGroup(Id16 id, Credentials creds, string name) => CreateWorkspaceGroupResult(id, creds, name).State; public WorkspaceState RenameWorkspaceGroup(Id16 id, Credentials creds, string groupId, string name) => ChangeWorkspace(id, creds, (_, state, _) => (state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name), permission: WorkspacePermission.ManageStructure); /// 删除分组:组里的频道回到未分组,频道本身和内容都不动 public WorkspaceState DeleteWorkspaceGroup(Id16 id, Credentials creds, string groupId) => ChangeWorkspace(id, creds, (_, state, _) => { var group = state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound); foreach (var channel in state.Channels.Where(c => c.GroupId == groupId)) channel.GroupId = null; state.Groups.Remove(group); }, 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(); if (string.IsNullOrEmpty(value)) return null; if (value.Length > 12 || value.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest); return value; } private static void ApplyRole(WorkspaceRole role, string name, string? prefix, string? suffix, string? color, IReadOnlyList permissions) { if (color is not null && !WorkspacePermission.Colors.Contains(color)) throw new WebDropException(ErrorCode.BadRequest); if (permissions.Any(p => !WorkspacePermission.All.Contains(p))) throw new WebDropException(ErrorCode.BadRequest); role.Name = WorkspaceName(name); role.Prefix = RoleLabel(prefix); role.Suffix = RoleLabel(suffix); role.Color = color; role.Permissions = permissions.Distinct().ToList(); } public (WorkspaceState State, WorkspaceRole Role) CreateWorkspaceRoleResult(Id16 id, Credentials creds, string name, string? prefix, string? suffix, string? color, IReadOnlyList permissions) { WorkspaceRole? created = null; var state = ChangeWorkspace(id, creds, (_, workspace, _) => { created = new WorkspaceRole { Id = Guid.NewGuid().ToString(), Name = "x", CreatedAt = clock.NowMs }; ApplyRole(created, name, prefix, suffix, color, permissions); workspace.Roles.Add(created); }); return (state, created!); } public WorkspaceState UpdateWorkspaceRole(Id16 id, Credentials creds, string roleId, string name, string? prefix, string? suffix, string? color, IReadOnlyList permissions) => ChangeWorkspace(id, creds, (_, state, _) => ApplyRole(FindRole(state, roleId), name, prefix, suffix, color, permissions)); public WorkspaceState DeleteWorkspaceRole(Id16 id, Credentials creds, string roleId) => ChangeWorkspace(id, creds, (_, state, _) => state.Roles.Remove(FindRole(state, roleId))); public WorkspaceState SetWorkspaceRoleMembers(Id16 id, Credentials creds, string roleId, IReadOnlyList members) => ChangeWorkspace(id, creds, (tx, state, _) => { var role = FindRole(state, roleId); var distinct = members.Select(m => m.Trim().ToLowerInvariant()).Distinct().ToList(); if (distinct.Count > 256) throw new WebDropException(ErrorCode.BadRequest); // 只能把已加入空间的账号放进身份组;空间主本来就有全部权限,不必加入 foreach (var member in distinct) if (tx.GetChannelMember(id, member) is not { } row || row.Role == "owner") throw new WebDropException(ErrorCode.BadRequest); role.Members = distinct; }); 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) })); 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); 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); state.Locations.RemoveAll(l => l.ItemId == itemId); state.Locations.Add(new WorkspaceLocation { ItemId = itemId, ThreadId = threadId, FolderId = folderId, AuthorAccount = item.AuthorAccount }); } }, requireAccount: false); private static WorkspaceChannel FindChannel(WorkspaceState state, string id) => state.Channels.Find(c => c.Id == id) ?? throw new WebDropException(ErrorCode.NotFound); private static string WorkspaceName(string name) { if (string.IsNullOrWhiteSpace(name) || name.Trim().Length > 80 || name.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest); return name.Trim(); } private static void ValidateWorkspace(WorkspaceState state) { if (state.Roles is null || state.Roles.Count > 32 || state.Roles.Any(r => r is null || r.Members is null || r.Permissions is null)) throw new WebDropException(ErrorCode.BadRequest); if (state.Channels is null || state.Groups is null || state.Folders is null || state.Locations is null || state.Channels.Count > 256 || state.Groups.Count > 128 || state.Folders.Count > 2048 || state.Locations.Count > 10_000) throw new WebDropException(ErrorCode.BadRequest); if (state.Channels.Any(c => c is null) || state.Groups.Any(g => g is null) || state.Folders.Any(f => f is null) || state.Locations.Any(l => l is null)) throw new WebDropException(ErrorCode.BadRequest); var allIds = state.Channels.Select(c => c.Id).Concat(state.Groups.Select(g => g.Id)).Concat(state.Folders.Select(f => f.Id)).Concat(state.Roles.Select(r => r.Id)).ToArray(); if (allIds.Any(id => !Guid.TryParseExact(id, "D", out _)) || allIds.Distinct().Count() != allIds.Length) throw new WebDropException(ErrorCode.BadRequest); foreach (var group in state.Groups) group.Name = WorkspaceName(group.Name); foreach (var channel in state.Channels) { channel.Name = WorkspaceName(channel.Name); if (channel.GroupId is not null && !state.Groups.Any(g => g.Id == channel.GroupId)) throw new WebDropException(ErrorCode.BadRequest); } foreach (var folder in state.Folders) { folder.Name = WorkspaceName(folder.Name); _ = FindChannel(state, folder.ThreadId); var visited = new HashSet { folder.Id }; for (var parentId = folder.ParentId; parentId is not null;) { var parent = state.Folders.Find(f => f.Id == parentId && f.ThreadId == folder.ThreadId); if (parent is null || !visited.Add(parent.Id)) throw new WebDropException(ErrorCode.BadRequest); parentId = parent.ParentId; } } if (state.Locations.Select(l => l.ItemId).Distinct().Count() != state.Locations.Count) throw new WebDropException(ErrorCode.BadRequest); foreach (var location in state.Locations) { _ = FindChannel(state, location.ThreadId); if (location.FolderId is not null && !state.Folders.Any(f => f.Id == location.FolderId && f.ThreadId == location.ThreadId)) throw new WebDropException(ErrorCode.BadRequest); } } }