- new projects WebDrop.FileApi (file service), WebDrop.FileProtocol (shared tickets and receipts) and WebDrop.Sync (WebDAV) - file content of any size goes to the file service; chat, avatars and space structure stay on the main backend - tickets issued by the main backend, receipts by the file service, deletion queue and daily reconciliation - daily database snapshot and file sync to external storage (OpenList/WebDAV) with a 30 day retention - reorganize Core, Storage and Api into feature folders (namespaces unchanged) - web client uploads file frames to the file service and reads them with tickets
98 lines
5.1 KiB
C#
98 lines
5.1 KiB
C#
using System.Security.Cryptography;
|
|
|
|
namespace WebDrop.Core;
|
|
|
|
public sealed partial class ChannelService
|
|
{
|
|
private static (byte[]? Hash, byte[]? Salt) HashJoinPassword(string? password)
|
|
{
|
|
if (string.IsNullOrEmpty(password)) return (null, null);
|
|
if (password.Length is < 8 or > 256) throw new WebDropException(ErrorCode.BadRequest);
|
|
var salt = RandomNumberGenerator.GetBytes(16);
|
|
return (Rfc2898DeriveBytes.Pbkdf2(password, salt, 210_000, HashAlgorithmName.SHA256, 32), salt);
|
|
}
|
|
|
|
private static void CheckAdmission(IResourceTx tx, Channel channel, Credentials creds, string? password)
|
|
{
|
|
string account = creds.AccountName ?? throw new WebDropException(ErrorCode.AccountRequired);
|
|
if (account == channel.CreatorAccount) return;
|
|
if (tx.IsJoinDenied(channel.Id, account)) throw new WebDropException(ErrorCode.Forbidden);
|
|
if (tx.HasInvitation(channel.Id, account)) return;
|
|
if (tx.GetChannelMember(channel.Id, account) is not null) return;
|
|
if (channel.Settings.JoinPolicy == JoinPolicy.Invite) throw new WebDropException(ErrorCode.InvitationRequired);
|
|
if (channel.JoinPasswordHash is null) return;
|
|
if (string.IsNullOrEmpty(password)) throw new WebDropException(ErrorCode.ChannelPasswordRequired);
|
|
if (password.Length > 256) throw new WebDropException(ErrorCode.ChannelPasswordInvalid);
|
|
var actual = Rfc2898DeriveBytes.Pbkdf2(password, channel.JoinPasswordSalt!, 210_000, HashAlgorithmName.SHA256, 32);
|
|
if (!CryptographicOperations.FixedTimeEquals(actual, channel.JoinPasswordHash)) throw new WebDropException(ErrorCode.ChannelPasswordInvalid);
|
|
}
|
|
|
|
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);
|
|
return tx.ListInvitations(id);
|
|
});
|
|
|
|
/// <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);
|
|
return principal;
|
|
}
|
|
|
|
public IReadOnlyList<ChannelInvitation> UserInvitations(string username) => store.Read(tx => tx.ListUserInvitations(username));
|
|
|
|
public void RevokeInvitation(Id16 id, Credentials creds, string username)
|
|
{
|
|
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);
|
|
}
|
|
tx.DeleteInvitation(id, username);
|
|
tx.DeleteKeyPackage(id, username);
|
|
tx.DeleteChannelMember(id, username);
|
|
var crypto = tx.GetWorkspaceCrypto(id);
|
|
crypto.Access.RemoveAll(a => a.Username == username);
|
|
crypto.Envelopes.RemoveAll(e => e.Username == username);
|
|
crypto.Revision++;
|
|
tx.PutWorkspaceCrypto(id, crypto);
|
|
tx.DenyAccount(id, username);
|
|
return 0;
|
|
});
|
|
events.MembersChanged(id);
|
|
}
|
|
|
|
/// <summary>空间里所有成员的账号(不做鉴权,供已经通过鉴权的接口补全成员资料)</summary>
|
|
public IReadOnlyList<string> MemberAccounts(Id16 id) => store.Read(tx => tx.ListChannelMembers(id).Select(m => m.Username).ToList());
|
|
|
|
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);
|
|
return tx.ListChannelMembers(id);
|
|
});
|
|
|
|
public IReadOnlyList<ChannelKeyPackage> AccountChannels(string username) =>
|
|
store.Read(tx => tx.ListUserKeyPackages(username, clock.NowMs));
|
|
|
|
public void SaveOwnerKeyPackage(Id16 id, Credentials creds, string payload)
|
|
{
|
|
if (payload is null || payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
|
|
store.Write(tx =>
|
|
{
|
|
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
|
RequireOwner(principal);
|
|
tx.PutKeyPackage(id, principal.AccountName!, payload, clock.NowMs);
|
|
return 0;
|
|
});
|
|
}
|
|
}
|