namespace WebDrop.Core; public sealed partial class ChannelService { public InvitationCryptoPlan InvitationPlan(Id16 id, Credentials creds, string username) => store.Read(tx => { var (space, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal); if (tx.GetChannelMember(id, username) is not null || tx.HasInvitation(id, username)) throw new WebDropException(ErrorCode.Conflict); var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound); var channelId = workspace.DefaultChannelId ?? throw new WebDropException(ErrorCode.Conflict); var channel = workspace.Channels.Find(c => c.Id == channelId) ?? throw new WebDropException(ErrorCode.Conflict); var batches = new List(); if (space.Mode == ChannelMode.Enc) { var crypto = tx.GetWorkspaceCrypto(id); foreach (var domain in new[] { CryptoDomain.Chat, CryptoDomain.Files }) { var currentEpoch = domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch; if (currentEpoch < 1) throw new WebDropException(ErrorCode.Conflict); var history = domain == CryptoDomain.Chat ? channel.ChatHistory : channel.FilesHistory; if (history) { var epochs = crypto.Envelopes.Where(e => e.ChannelId == channelId && e.Domain == domain && e.Username == space.CreatorAccount) .Select(e => e.Epoch).Append(currentEpoch).Distinct().Order().ToList(); if (epochs.Count > 256) throw new WebDropException(ErrorCode.TooLarge); foreach (var epoch in epochs) batches.Add(new CryptoGrantBatch { Domain = domain, Epoch = epoch, Recipients = [username], Rotate = false }); } 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))) .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 }); } } } return new InvitationCryptoPlan { ChannelId = channelId, Username = username, Batches = batches }; }); public void InviteUser(Id16 id, Credentials creds, string username, string payload, IReadOnlyList envelopes) { if (payload is null || payload.Length is < 1 or > 16_384 || envelopes is null) throw new WebDropException(ErrorCode.BadRequest); store.Write(tx => { var (space, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal); if (tx.GetChannelMember(id, username) is not null || tx.HasInvitation(id, username)) throw new WebDropException(ErrorCode.Conflict); if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden); var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound); var channelId = workspace.DefaultChannelId ?? throw new WebDropException(ErrorCode.Conflict); var channel = workspace.Channels.Find(c => c.Id == channelId) ?? throw new WebDropException(ErrorCode.Conflict); var state = tx.GetWorkspaceCrypto(id); var expected = new List<(CryptoDomain Domain, int Epoch, string Username)>(); if (space.Mode == ChannelMode.Enc) { foreach (var domain in new[] { CryptoDomain.Chat, CryptoDomain.Files }) { var currentEpoch = domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch; if (currentEpoch < 1) throw new WebDropException(ErrorCode.Conflict); var history = domain == CryptoDomain.Chat ? channel.ChatHistory : channel.FilesHistory; if (history) { var epochs = state.Envelopes.Where(e => e.ChannelId == channelId && e.Domain == domain && e.Username == space.CreatorAccount) .Select(e => e.Epoch).Append(currentEpoch).Distinct().Order().ToList(); if (epochs.Count > 256) throw new WebDropException(ErrorCode.TooLarge); expected.AddRange(epochs.Select(epoch => (domain, epoch, username))); } 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))) .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))); } } } var actual = envelopes.Select(e => (e.Domain, e.Epoch, e.Username)).ToList(); if (actual.Count != expected.Count || actual.Distinct().Count() != actual.Count || !actual.ToHashSet().SetEquals(expected)) throw new WebDropException(ErrorCode.BadRequest); foreach (var envelope in envelopes) { if (envelope.ChannelId != channelId || envelope.Payload is null || envelope.Payload.Length is < 1 or > 16_384 || !Enum.IsDefined(envelope.Domain) || envelope.Epoch < 1) throw new WebDropException(ErrorCode.BadRequest); var previous = state.Envelopes.Find(e => e.ChannelId == channelId && e.Domain == envelope.Domain && e.Epoch == envelope.Epoch && e.Username == envelope.Username); if (previous is not null && previous.Payload != envelope.Payload) throw new WebDropException(ErrorCode.Conflict); if (previous is null) state.Envelopes.Add(envelope); if (envelope.Domain == CryptoDomain.Chat) channel.ChatEpoch = Math.Max(channel.ChatEpoch, envelope.Epoch); else channel.FilesEpoch = Math.Max(channel.FilesEpoch, envelope.Epoch); } state.Access.RemoveAll(a => a.ChannelId == channelId && a.Username == username); state.Access.Add(new ChannelAccess { ChannelId = channelId, Username = username, ChatRead = true, ChatWrite = true, FilesRead = true, FilesUpload = true, ChatFromRevision = channel.ChatHistory ? 0 : state.Revision + 1, FilesFromRevision = channel.FilesHistory ? 0 : state.Revision + 1, }); state.Revision++; workspace.Revision++; tx.PutWorkspace(id, workspace); tx.PutWorkspaceCrypto(id, state); tx.PutInvitation(new ChannelInvitation(id, username, payload, clock.NowMs)); tx.PutKeyPackage(id, username, payload, clock.NowMs); tx.AllowAccount(id, username); return 0; }); events.SettingsChanged(id); } private static bool CanReadObject(IResourceTx tx, Channel space, Principal principal, Id16 itemId) { var crypto = tx.GetWorkspaceCrypto(space.Id); if (crypto.Access.Count == 0 && crypto.Envelopes.Count == 0 && space.Mode == ChannelMode.Raw) return true; 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 (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) { 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 (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) { 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); } } public CryptoState ReadCrypto(Id16 id, Credentials creds) => store.Read(tx => { var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required); var state = tx.GetWorkspaceCrypto(id); if (principal.Role == Role.Owner || principal.AccountName == space.CreatorAccount) 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); 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, Objects = state.Objects.Where(o => CanRead(o.ChannelId, o.Domain) && (space.Mode == ChannelMode.Raw || epochs.Contains((o.ChannelId, o.Domain, o.Epoch)))).ToList(), Access = access }; }); public void PublishDomain(Id16 id, Credentials creds, DomainPublishRequest req) { store.Write(tx => { var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); RequireOwner(principal); 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); if (!Enum.IsDefined(req.Domain) || req.Envelopes is null || req.Envelopes.Count != 1 || req.Epoch != 1) throw new WebDropException(ErrorCode.BadRequest); var epoch = req.Domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch; if (epoch > 1) throw new WebDropException(ErrorCode.Conflict); if (req.Envelopes[0].Username != principal.AccountName || req.Envelopes[0].ChannelId != req.ChannelId || req.Envelopes[0].Domain != req.Domain || req.Envelopes[0].Epoch != 1 || req.Envelopes[0].Payload is null || req.Envelopes[0].Payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest); var state = tx.GetWorkspaceCrypto(id); var envelope = req.Envelopes[0]; var previous = state.Envelopes.Find(e => e.ChannelId == envelope.ChannelId && e.Domain == envelope.Domain && e.Epoch == 1 && e.Username == envelope.Username); if (previous is not null && previous.Payload != envelope.Payload) throw new WebDropException(ErrorCode.Conflict); if (previous is null) state.Envelopes.Add(envelope); var ownerAccess = state.Access.Find(a => a.ChannelId == req.ChannelId && a.Username == principal.AccountName); if (ownerAccess is null) state.Access.Add(new ChannelAccess { ChannelId = req.ChannelId, Username = principal.AccountName!, ChatRead = true, ChatWrite = true, FilesRead = true, FilesUpload = true }); if (req.Domain == CryptoDomain.Chat) channel.ChatEpoch = Math.Max(channel.ChatEpoch, req.Epoch); else channel.FilesEpoch = Math.Max(channel.FilesEpoch, req.Epoch); state.Revision++; workspace.Revision++; tx.PutWorkspace(id, workspace); tx.PutWorkspaceCrypto(id, state); return 0; }); events.SettingsChanged(id); } public ObjectEnvelope DeclareObject(Id16 id, Credentials creds, ObjectEnvelope req) => store.Write(tx => { var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required); if (!ChannelPolicy.CanWrite(space, principal)) throw new WebDropException(ErrorCode.Forbidden); if (!Id16.TryParse(req.ItemId, out _) || !Enum.IsDefined(req.Domain) || req.KeyEnvelope is null || req.KeyEnvelope.Length > 1024) throw new WebDropException(ErrorCode.BadRequest); 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)) { 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 (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); if (existing is not null) { if (existing.AuthorAccount != principal.AccountName || existing.ChannelId != req.ChannelId || existing.Domain != req.Domain || existing.Epoch != req.Epoch || existing.KeyEnvelope != req.KeyEnvelope) throw new WebDropException(ErrorCode.Conflict); return existing; } if (state.Objects.Count >= 20_000) throw new WebDropException(ErrorCode.TooLarge); req.AuthorAccount = principal.AccountName; req.CreatedRevision = state.Revision + 1; state.Revision++; state.Objects.Add(req); tx.PutWorkspaceCrypto(id, state); return req; }); public void SetChannelHistory(Id16 id, Credentials creds, string channelId, bool chat, bool files) { store.Write(tx => { var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); RequireOwner(principal); var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound); var channel = workspace.Channels.Find(c => c.Id == channelId) ?? throw new WebDropException(ErrorCode.NotFound); channel.ChatHistory = chat; channel.FilesHistory = files; workspace.Revision++; tx.PutWorkspace(id, workspace); return 0; }); events.SettingsChanged(id); } }