213 lines
16 KiB
C#
213 lines
16 KiB
C#
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<CryptoGrantBatch>();
|
|
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<DomainEnvelope> 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);
|
|
}
|
|
}
|