1
0
mirror of https://github.com/bitwarden/server synced 2026-01-16 07:23:15 +00:00

PM-18939 refactoring send service to 'cqrs' (#5652)

* PM-18939 refactoring send service to 'cqrs'

* PM-18939 fixing import issue with sendValidationService

* PM-18939 fixing code based on PR comments

* PM-18339 reverting to previous code in test

* PM-18939 adding XMLdocs to services

* PM-18939 reverting send validation methods

* PM-18939 updating code to match main

* PM-18939 reverting validateUserCanSaveAsync to match main

* PM-18939 fill our param and return sections of XMLdocs

* PM-18939 updating XMLdocs based on PR comments

* Update src/Core/Tools/SendFeatures/Commands/Interfaces/IAnonymousSendCommand.cs

Co-authored-by:  Audrey  <ajensen@bitwarden.com>

* Update src/Core/Tools/SendFeatures/Commands/Interfaces/INonAnonymousSendCommand.cs

Co-authored-by:  Audrey  <ajensen@bitwarden.com>

* Update src/Core/Tools/SendFeatures/Commands/Interfaces/INonAnonymousSendCommand.cs

Co-authored-by:  Audrey  <ajensen@bitwarden.com>

* Update src/Core/Tools/SendFeatures/Services/Interfaces/ISendStorageService.cs

Co-authored-by:  Audrey  <ajensen@bitwarden.com>

* PM-18939 adding commits to change tuple to enum type

* PM-18939 resetting stream position to 0 when uploading file

* PM-18939 updating XMLdocs based on PR comments

* PM-18939 updating XMLdocs

* PM-18939 removing circular dependency

* PM-18939 fixing based on comments

* PM-18939 updating method name and documentation

---------

Co-authored-by:  Audrey  <ajensen@bitwarden.com>
This commit is contained in:
Graham Walker
2025-05-19 22:59:30 -05:00
committed by GitHub
parent 7b3e2a80f4
commit 818934487f
32 changed files with 2295 additions and 1368 deletions

View File

@@ -0,0 +1,52 @@
using Bit.Core.Exceptions;
using Bit.Core.Platform.Push;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.SendFeatures.Commands.Interfaces;
using Bit.Core.Tools.Services;
namespace Bit.Core.Tools.SendFeatures.Commands;
public class AnonymousSendCommand : IAnonymousSendCommand
{
private readonly ISendRepository _sendRepository;
private readonly ISendFileStorageService _sendFileStorageService;
private readonly IPushNotificationService _pushNotificationService;
private readonly ISendAuthorizationService _sendAuthorizationService;
public AnonymousSendCommand(
ISendRepository sendRepository,
ISendFileStorageService sendFileStorageService,
IPushNotificationService pushNotificationService,
ISendAuthorizationService sendAuthorizationService
)
{
_sendRepository = sendRepository;
_sendFileStorageService = sendFileStorageService;
_pushNotificationService = pushNotificationService;
_sendAuthorizationService = sendAuthorizationService;
}
// Response: Send, password required, password invalid
public async Task<(string, SendAccessResult)> GetSendFileDownloadUrlAsync(Send send, string fileId, string password)
{
if (send.Type != SendType.File)
{
throw new BadRequestException("Can only get a download URL for a file type of Send");
}
var result = _sendAuthorizationService.SendCanBeAccessed(send, password);
if (!result.Equals(SendAccessResult.Granted))
{
return (null, result);
}
send.AccessCount++;
await _sendRepository.ReplaceAsync(send);
await _pushNotificationService.PushSyncSendUpdateAsync(send);
return (await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId), result);
}
}

View File

@@ -0,0 +1,21 @@
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Models.Data;
namespace Bit.Core.Tools.SendFeatures.Commands.Interfaces;
/// <summary>
/// AnonymousSendCommand interface provides methods for managing anonymous Sends.
/// </summary>
public interface IAnonymousSendCommand
{
/// <summary>
/// Gets the Send file download URL for a Send object.
/// </summary>
/// <param name="send"><see cref="Send" /> used to help get file download url and validate file</param>
/// <param name="fileId">FileId get file download url</param>
/// <param name="password">A hashed and base64-encoded password. This is compared with the send's password to authorize access.</param>
/// <returns>Async Task object with Tuple containing the string of download url and <see cref="SendAccessResult" />
/// to determine if the user can access send.
/// </returns>
Task<(string, SendAccessResult)> GetSendFileDownloadUrlAsync(Send send, string fileId, string password);
}

View File

@@ -0,0 +1,53 @@
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Models.Data;
namespace Bit.Core.Tools.SendFeatures.Commands.Interfaces;
/// <summary>
/// NonAnonymousSendCommand interface provides methods for managing non-anonymous Sends.
/// </summary>
public interface INonAnonymousSendCommand
{
/// <summary>
/// Saves a <see cref="Send" /> to the database.
/// </summary>
/// <param name="send"><see cref="Send" /> that will save to database</param>
/// <returns>Task completes as <see cref="Send" /> saves to the database</returns>
Task SaveSendAsync(Send send);
/// <summary>
/// Saves the <see cref="Send" /> and <see cref="SendFileData" /> to the database.
/// </summary>
/// <param name="send"><see cref="Send" /> that will save to the database</param>
/// <param name="data"><see cref="SendFileData" /> that will save to file storage</param>
/// <param name="fileLength">Length of file help with saving to file storage</param>
/// <returns>Task object for async operations with file upload url</returns>
Task<string> SaveFileSendAsync(Send send, SendFileData data, long fileLength);
/// <summary>
/// Upload a file to an existing <see cref="Send" />.
/// </summary>
/// <param name="stream"><see cref="Stream" /> of file to be uploaded. The <see cref="Stream" /> position
/// will be set to 0 before uploading the file.</param>
/// <param name="send"><see cref="Send" /> used to help with uploading file</param>
/// <returns>Task completes after saving <see cref="Stream" /> and <see cref="Send" /> metadata to the file storage</returns>
Task UploadFileToExistingSendAsync(Stream stream, Send send);
/// <summary>
/// Deletes a <see cref="Send" /> from the database and file storage.
/// </summary>
/// <param name="send"><see cref="Send" /> is used to delete from database and file storage</param>
/// <returns>Task completes once <see cref="Send" /> has been deleted from database and file storage.</returns>
Task DeleteSendAsync(Send send);
/// <summary>
/// Stores the confirmed file size of a send; when the file size cannot be confirmed, the send is deleted.
/// </summary>
/// <param name="send">The <see cref="Send" /> this command acts upon</param>
/// <returns><see langword="true" /> when the file is confirmed, otherwise <see langword="false" /></returns>
/// <remarks>
/// When a file size cannot be confirmed, we assume we're working with a rogue client. The send is deleted out of
/// an abundance of caution.
/// </remarks>
Task<bool> ConfirmFileSize(Send send);
}

View File

@@ -0,0 +1,180 @@
using System.Text.Json;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Platform.Push;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.SendFeatures.Commands.Interfaces;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
namespace Bit.Core.Tools.SendFeatures.Commands;
public class NonAnonymousSendCommand : INonAnonymousSendCommand
{
private readonly ISendRepository _sendRepository;
private readonly ISendFileStorageService _sendFileStorageService;
private readonly IPushNotificationService _pushNotificationService;
private readonly ISendValidationService _sendValidationService;
private readonly IReferenceEventService _referenceEventService;
private readonly ICurrentContext _currentContext;
private readonly ISendCoreHelperService _sendCoreHelperService;
public NonAnonymousSendCommand(ISendRepository sendRepository,
ISendFileStorageService sendFileStorageService,
IPushNotificationService pushNotificationService,
ISendAuthorizationService sendAuthorizationService,
ISendValidationService sendValidationService,
IReferenceEventService referenceEventService,
ICurrentContext currentContext,
ISendCoreHelperService sendCoreHelperService)
{
_sendRepository = sendRepository;
_sendFileStorageService = sendFileStorageService;
_pushNotificationService = pushNotificationService;
_sendValidationService = sendValidationService;
_referenceEventService = referenceEventService;
_currentContext = currentContext;
_sendCoreHelperService = sendCoreHelperService;
}
public async Task SaveSendAsync(Send send)
{
// Make sure user can save Sends
await _sendValidationService.ValidateUserCanSaveAsync(send.UserId, send);
if (send.Id == default(Guid))
{
await _sendRepository.CreateAsync(send);
await _pushNotificationService.PushSyncSendCreateAsync(send);
await _referenceEventService.RaiseEventAsync(new ReferenceEvent
{
Id = send.UserId ?? default,
Type = ReferenceEventType.SendCreated,
Source = ReferenceEventSource.User,
SendType = send.Type,
MaxAccessCount = send.MaxAccessCount,
HasPassword = !string.IsNullOrWhiteSpace(send.Password),
SendHasNotes = send.Data?.Contains("Notes"),
ClientId = _currentContext.ClientId,
ClientVersion = _currentContext.ClientVersion
});
}
else
{
send.RevisionDate = DateTime.UtcNow;
await _sendRepository.UpsertAsync(send);
await _pushNotificationService.PushSyncSendUpdateAsync(send);
}
}
public async Task<string> SaveFileSendAsync(Send send, SendFileData data, long fileLength)
{
if (send.Type != SendType.File)
{
throw new BadRequestException("Send is not of type \"file\".");
}
if (fileLength < 1)
{
throw new BadRequestException("No file data.");
}
var storageBytesRemaining = await _sendValidationService.StorageRemainingForSendAsync(send);
if (storageBytesRemaining < fileLength)
{
throw new BadRequestException("Not enough storage available.");
}
var fileId = _sendCoreHelperService.SecureRandomString(32, useUpperCase: false, useSpecial: false);
try
{
data.Id = fileId;
data.Size = fileLength;
data.Validated = false;
send.Data = JsonSerializer.Serialize(data,
JsonHelpers.IgnoreWritingNull);
await SaveSendAsync(send);
return await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId);
}
catch
{
// Clean up since this is not transactional
await _sendFileStorageService.DeleteFileAsync(send, fileId);
throw;
}
}
public async Task UploadFileToExistingSendAsync(Stream stream, Send send)
{
if (stream.Position > 0)
{
stream.Position = 0;
}
if (send?.Data == null)
{
throw new BadRequestException("Send does not have file data");
}
if (send.Type != SendType.File)
{
throw new BadRequestException("Not a File Type Send.");
}
var data = JsonSerializer.Deserialize<SendFileData>(send.Data);
if (data.Validated)
{
throw new BadRequestException("File has already been uploaded.");
}
await _sendFileStorageService.UploadNewFileAsync(stream, send, data.Id);
if (!await ConfirmFileSize(send))
{
throw new BadRequestException("File received does not match expected file length.");
}
}
public async Task DeleteSendAsync(Send send)
{
await _sendRepository.DeleteAsync(send);
if (send.Type == Enums.SendType.File)
{
var data = JsonSerializer.Deserialize<SendFileData>(send.Data);
await _sendFileStorageService.DeleteFileAsync(send, data.Id);
}
await _pushNotificationService.PushSyncSendDeleteAsync(send);
}
public async Task<bool> ConfirmFileSize(Send send)
{
var fileData = JsonSerializer.Deserialize<SendFileData>(send.Data);
var (valid, realSize) = await _sendFileStorageService.ValidateFileAsync(send, fileData.Id, fileData.Size, SendFileSettingHelper.FILE_SIZE_LEEWAY);
if (!valid || realSize > SendFileSettingHelper.FILE_SIZE_LEEWAY)
{
// File reported differs in size from that promised. Must be a rogue client. Delete Send
await DeleteSendAsync(send);
return false;
}
// Update Send data if necessary
if (realSize != fileData.Size)
{
fileData.Size = realSize.Value;
}
fileData.Validated = true;
send.Data = JsonSerializer.Serialize(fileData,
JsonHelpers.IgnoreWritingNull);
await SaveSendAsync(send);
return valid;
}
}

View File

@@ -0,0 +1,18 @@
using Bit.Core.Tools.SendFeatures.Commands;
using Bit.Core.Tools.SendFeatures.Commands.Interfaces;
using Bit.Core.Tools.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Tools.SendFeatures;
public static class SendServiceCollectionExtension
{
public static void AddSendServices(this IServiceCollection services)
{
services.AddScoped<INonAnonymousSendCommand, NonAnonymousSendCommand>();
services.AddScoped<IAnonymousSendCommand, AnonymousSendCommand>();
services.AddScoped<ISendAuthorizationService, SendAuthorizationService>();
services.AddScoped<ISendValidationService, SendValidationService>();
services.AddScoped<ISendCoreHelperService, SendCoreHelperService>();
}
}

View File

@@ -0,0 +1,141 @@
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Azure.Storage.Sas;
using Bit.Core.Enums;
using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Tools.Services;
public class AzureSendFileStorageService : ISendFileStorageService
{
public const string FilesContainerName = "sendfiles";
private static readonly TimeSpan _downloadLinkLiveTime = TimeSpan.FromMinutes(1);
private readonly BlobServiceClient _blobServiceClient;
private readonly ILogger<AzureSendFileStorageService> _logger;
private BlobContainerClient _sendFilesContainerClient;
public FileUploadType FileUploadType => FileUploadType.Azure;
public static string SendIdFromBlobName(string blobName) => blobName.Split('/')[0];
public static string BlobName(Send send, string fileId) => $"{send.Id}/{fileId}";
public AzureSendFileStorageService(
GlobalSettings globalSettings,
ILogger<AzureSendFileStorageService> logger)
{
_blobServiceClient = new BlobServiceClient(globalSettings.Send.ConnectionString);
_logger = logger;
}
public async Task UploadNewFileAsync(Stream stream, Send send, string fileId)
{
await InitAsync();
var blobClient = _sendFilesContainerClient.GetBlobClient(BlobName(send, fileId));
var metadata = new Dictionary<string, string>();
if (send.UserId.HasValue)
{
metadata.Add("userId", send.UserId.Value.ToString());
}
else
{
metadata.Add("organizationId", send.OrganizationId.Value.ToString());
}
var headers = new BlobHttpHeaders
{
ContentDisposition = $"attachment; filename=\"{fileId}\""
};
await blobClient.UploadAsync(stream, new BlobUploadOptions { Metadata = metadata, HttpHeaders = headers });
}
public async Task DeleteFileAsync(Send send, string fileId) => await DeleteBlobAsync(BlobName(send, fileId));
public async Task DeleteBlobAsync(string blobName)
{
await InitAsync();
var blobClient = _sendFilesContainerClient.GetBlobClient(blobName);
await blobClient.DeleteIfExistsAsync();
}
public async Task DeleteFilesForOrganizationAsync(Guid organizationId)
{
await InitAsync();
}
public async Task DeleteFilesForUserAsync(Guid userId)
{
await InitAsync();
}
public async Task<string> GetSendFileDownloadUrlAsync(Send send, string fileId)
{
await InitAsync();
var blobClient = _sendFilesContainerClient.GetBlobClient(BlobName(send, fileId));
var sasUri = blobClient.GenerateSasUri(BlobSasPermissions.Read, DateTime.UtcNow.Add(_downloadLinkLiveTime));
return sasUri.ToString();
}
public async Task<string> GetSendFileUploadUrlAsync(Send send, string fileId)
{
await InitAsync();
var blobClient = _sendFilesContainerClient.GetBlobClient(BlobName(send, fileId));
var sasUri = blobClient.GenerateSasUri(BlobSasPermissions.Create | BlobSasPermissions.Write, DateTime.UtcNow.Add(_downloadLinkLiveTime));
return sasUri.ToString();
}
public async Task<(bool, long?)> ValidateFileAsync(Send send, string fileId, long expectedFileSize, long leeway)
{
await InitAsync();
var blobClient = _sendFilesContainerClient.GetBlobClient(BlobName(send, fileId));
try
{
var blobProperties = await blobClient.GetPropertiesAsync();
var metadata = blobProperties.Value.Metadata;
if (send.UserId.HasValue)
{
metadata["userId"] = send.UserId.Value.ToString();
}
else
{
metadata["organizationId"] = send.OrganizationId.Value.ToString();
}
await blobClient.SetMetadataAsync(metadata);
var headers = new BlobHttpHeaders
{
ContentDisposition = $"attachment; filename=\"{fileId}\""
};
await blobClient.SetHttpHeadersAsync(headers);
var length = blobProperties.Value.ContentLength;
if (length < expectedFileSize - leeway || length > expectedFileSize + leeway)
{
return (false, length);
}
return (true, length);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled error in ValidateFileAsync");
return (false, null);
}
}
private async Task InitAsync()
{
if (_sendFilesContainerClient == null)
{
_sendFilesContainerClient = _blobServiceClient.GetBlobContainerClient(FilesContainerName);
await _sendFilesContainerClient.CreateIfNotExistsAsync(PublicAccessType.None, null, null);
}
}
}

View File

@@ -0,0 +1,28 @@
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Models.Data;
namespace Bit.Core.Tools.Services;
/// <summary>
/// Send Authorization service is responsible for checking if a Send can be accessed.
/// </summary>
public interface ISendAuthorizationService
{
/// <summary>
/// Checks if a <see cref="Send" /> can be accessed while updating the <see cref="Send" />, pushing a notification, and sending a reference event.
/// </summary>
/// <param name="send"><see cref="Send" /> used to determine access</param>
/// <param name="password">A hashed and base64-encoded password. This is compared with the send's password to authorize access.</param>
/// <returns><see cref="SendAccessResult" /> will be returned to determine if the user can access send.
/// </returns>
Task<SendAccessResult> AccessAsync(Send send, string password);
SendAccessResult SendCanBeAccessed(Send send,
string password);
/// <summary>
/// Hashes the password using the password hasher.
/// </summary>
/// <param name="password">Password to be hashed</param>
/// <returns>Hashed password of the password given</returns>
string HashPassword(string password);
}

View File

@@ -0,0 +1,17 @@
namespace Bit.Core.Tools.Services;
/// <summary>
/// This interface provides helper methods for generating secure random strings. Making
/// it easier to mock the service in unit tests.
/// </summary>
public interface ISendCoreHelperService
{
/// <summary>
/// Securely generates a random string of the specified length.
/// </summary>
/// <param name="length">Desired string length to be returned</param>
/// <param name="useUpperCase">Desired casing for the string</param>
/// <param name="useSpecial">Determines if special characters will be used in string</param>
/// <returns>A secure random string with the desired parameters</returns>
string SecureRandomString(int length, bool useUpperCase, bool useSpecial);
}

View File

@@ -0,0 +1,71 @@
using Bit.Core.Enums;
using Bit.Core.Tools.Entities;
namespace Bit.Core.Tools.Services;
/// <summary>
/// Send File Storage Service is responsible for uploading, deleting, and validating files
/// whether they are in local storage or in cloud storage.
/// </summary>
public interface ISendFileStorageService
{
FileUploadType FileUploadType { get; }
/// <summary>
/// Uploads a new file to the storage.
/// </summary>
/// <param name="stream"><see cref="Stream" /> of the file</param>
/// <param name="send"><see cref="Send" /> for the file</param>
/// <param name="fileId">File id</param>
/// <returns>Task completes once <see cref="Stream" /> and <see cref="Send" /> have been saved to the database</returns>
Task UploadNewFileAsync(Stream stream, Send send, string fileId);
/// <summary>
/// Deletes a file from the storage.
/// </summary>
/// <param name="send"><see cref="Send" /> used to delete file</param>
/// <param name="fileId">File id of file to be deleted</param>
/// <returns>Task completes once <see cref="Send" /> has been deleted to the database</returns>
Task DeleteFileAsync(Send send, string fileId);
/// <summary>
/// Deletes all files for a specific organization.
/// </summary>
/// <param name="organizationId"><see cref="Guid" /> used to delete all files pertaining to organization</param>
/// <returns>Task completes after running code to delete files by organization id</returns>
Task DeleteFilesForOrganizationAsync(Guid organizationId);
/// <summary>
/// Deletes all files for a specific user.
/// </summary>
/// <param name="userId"><see cref="Guid" /> used to delete all files pertaining to user</param>
/// <returns>Task completes after running code to delete files by user id</returns>
Task DeleteFilesForUserAsync(Guid userId);
/// <summary>
/// Gets the download URL for a file.
/// </summary>
/// <param name="send"><see cref="Send" /> used to help get download url for file</param>
/// <param name="fileId">File id to help get download url for file</param>
/// <returns>Download url as a string</returns>
Task<string> GetSendFileDownloadUrlAsync(Send send, string fileId);
/// <summary>
/// Gets the upload URL for a file.
/// </summary>
/// <param name="send"><see cref="Send" /> used to help get upload url for file </param>
/// <param name="fileId">File id to help get upload url for file</param>
/// <returns>File upload url as string</returns>
Task<string> GetSendFileUploadUrlAsync(Send send, string fileId);
/// <summary>
/// Validates the file size of a file in the storage.
/// </summary>
/// <param name="send"><see cref="Send" /> used to help validate file</param>
/// <param name="fileId">File id to identify which file to validate</param>
/// <param name="expectedFileSize">Expected file size of the file</param>
/// <param name="leeway">
/// Send file size tolerance in bytes. When an uploaded file's `expectedFileSize`
/// is outside of the leeway, the storage operation fails.
/// </param>
/// <throws>
/// ❌ Fill this in with an explanation of the error thrown when `leeway` is incorrect
/// </throws>
/// <returns>Task object for async operations with Tuple of boolean that determines if file was valid and long that
/// the actual file size of the file.
/// </returns>
Task<(bool, long?)> ValidateFileAsync(Send send, string fileId, long expectedFileSize, long leeway);
}

View File

@@ -0,0 +1,35 @@
using Bit.Core.Tools.Entities;
namespace Bit.Core.Tools.Services;
public interface ISendValidationService
{
/// <summary>
/// Validates a file can be saved by specified user.
/// </summary>
/// <param name="userId"><see cref="Guid" /> needed to validate file for specific user</param>
/// <param name="send"><see cref="Send" /> needed to help validate file</param>
/// <returns>Task completes when a conditional statement has been met it will return out of the method or
/// throw a BadRequestException.
/// </returns>
Task ValidateUserCanSaveAsync(Guid? userId, Send send);
/// <summary>
/// Validates a file can be saved by specified user with different policy based on feature flag
/// </summary>
/// <param name="userId"><see cref="Guid" /> needed to validate file for specific user</param>
/// <param name="send"><see cref="Send" /> needed to help validate file</param>
/// <returns>Task completes when a conditional statement has been met it will return out of the method or
/// throw a BadRequestException.
/// </returns>
Task ValidateUserCanSaveAsync_vNext(Guid? userId, Send send);
/// <summary>
/// Calculates the remaining storage for a Send.
/// </summary>
/// <param name="send"><see cref="Send" /> needed to help calculate remaining storage</param>
/// <returns>Long with the remaining bytes for storage or will throw a BadRequestException if user cannot access
/// file or email is not verified.
/// </returns>
Task<long> StorageRemainingForSendAsync(Send send);
}

View File

@@ -0,0 +1,105 @@
using Bit.Core.Enums;
using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
namespace Bit.Core.Tools.Services;
public class LocalSendStorageService : ISendFileStorageService
{
private readonly string _baseDirPath;
private readonly string _baseSendUrl;
private string RelativeFilePath(Send send, string fileID) => $"{send.Id}/{fileID}";
private string FilePath(Send send, string fileID) => $"{_baseDirPath}/{RelativeFilePath(send, fileID)}";
public FileUploadType FileUploadType => FileUploadType.Direct;
public LocalSendStorageService(
GlobalSettings globalSettings)
{
_baseDirPath = globalSettings.Send.BaseDirectory;
_baseSendUrl = globalSettings.Send.BaseUrl;
}
public async Task UploadNewFileAsync(Stream stream, Send send, string fileId)
{
await InitAsync();
var path = FilePath(send, fileId);
Directory.CreateDirectory(Path.GetDirectoryName(path));
using (var fs = File.Create(path))
{
stream.Seek(0, SeekOrigin.Begin);
await stream.CopyToAsync(fs);
}
}
public async Task DeleteFileAsync(Send send, string fileId)
{
await InitAsync();
var path = FilePath(send, fileId);
DeleteFileIfExists(path);
DeleteDirectoryIfExistsAndEmpty(Path.GetDirectoryName(path));
}
public async Task DeleteFilesForOrganizationAsync(Guid organizationId)
{
await InitAsync();
}
public async Task DeleteFilesForUserAsync(Guid userId)
{
await InitAsync();
}
public async Task<string> GetSendFileDownloadUrlAsync(Send send, string fileId)
{
await InitAsync();
return $"{_baseSendUrl}/{RelativeFilePath(send, fileId)}";
}
private void DeleteFileIfExists(string path)
{
if (File.Exists(path))
{
File.Delete(path);
}
}
private void DeleteDirectoryIfExistsAndEmpty(string path)
{
if (Directory.Exists(path) && !Directory.EnumerateFiles(path).Any())
{
Directory.Delete(path);
}
}
private Task InitAsync()
{
if (!Directory.Exists(_baseDirPath))
{
Directory.CreateDirectory(_baseDirPath);
}
return Task.FromResult(0);
}
public Task<string> GetSendFileUploadUrlAsync(Send send, string fileId)
=> Task.FromResult($"/sends/{send.Id}/file/{fileId}");
public Task<(bool, long?)> ValidateFileAsync(Send send, string fileId, long expectedFileSize, long leeway)
{
long? length = null;
var path = FilePath(send, fileId);
if (!File.Exists(path))
{
return Task.FromResult((false, length));
}
length = new FileInfo(path).Length;
if (expectedFileSize < length - leeway || expectedFileSize > length + leeway)
{
return Task.FromResult((false, length));
}
return Task.FromResult((true, length));
}
}

View File

@@ -0,0 +1,101 @@
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Platform.Push;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories;
using Microsoft.AspNetCore.Identity;
namespace Bit.Core.Tools.Services;
public class SendAuthorizationService : ISendAuthorizationService
{
private readonly ISendRepository _sendRepository;
private readonly IPasswordHasher<User> _passwordHasher;
private readonly IPushNotificationService _pushNotificationService;
private readonly IReferenceEventService _referenceEventService;
private readonly ICurrentContext _currentContext;
public SendAuthorizationService(
ISendRepository sendRepository,
IPasswordHasher<User> passwordHasher,
IPushNotificationService pushNotificationService,
IReferenceEventService referenceEventService,
ICurrentContext currentContext)
{
_sendRepository = sendRepository;
_passwordHasher = passwordHasher;
_pushNotificationService = pushNotificationService;
_referenceEventService = referenceEventService;
_currentContext = currentContext;
}
public SendAccessResult SendCanBeAccessed(Send send,
string password)
{
var now = DateTime.UtcNow;
if (send == null || send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < now || send.Disabled ||
send.DeletionDate < now)
{
return SendAccessResult.Denied;
}
if (!string.IsNullOrWhiteSpace(send.Password))
{
if (string.IsNullOrWhiteSpace(password))
{
return SendAccessResult.PasswordRequired;
}
var passwordResult = _passwordHasher.VerifyHashedPassword(new User(), send.Password, password);
if (passwordResult == PasswordVerificationResult.SuccessRehashNeeded)
{
send.Password = HashPassword(password);
}
if (passwordResult == PasswordVerificationResult.Failed)
{
return SendAccessResult.PasswordInvalid;
}
}
return SendAccessResult.Granted;
}
public async Task<SendAccessResult> AccessAsync(Send sendToBeAccessed, string password)
{
var accessResult = SendCanBeAccessed(sendToBeAccessed, password);
if (!accessResult.Equals(SendAccessResult.Granted))
{
return accessResult;
}
if (sendToBeAccessed.Type != SendType.File)
{
// File sends are incremented during file download
sendToBeAccessed.AccessCount++;
}
await _sendRepository.ReplaceAsync(sendToBeAccessed);
await _pushNotificationService.PushSyncSendUpdateAsync(sendToBeAccessed);
await _referenceEventService.RaiseEventAsync(new ReferenceEvent
{
Id = sendToBeAccessed.UserId ?? default,
Type = ReferenceEventType.SendAccessed,
Source = ReferenceEventSource.User,
SendType = sendToBeAccessed.Type,
MaxAccessCount = sendToBeAccessed.MaxAccessCount,
HasPassword = !string.IsNullOrWhiteSpace(sendToBeAccessed.Password),
SendHasNotes = sendToBeAccessed.Data?.Contains("Notes"),
ClientId = _currentContext.ClientId,
ClientVersion = _currentContext.ClientVersion
});
return accessResult;
}
public string HashPassword(string password)
{
return _passwordHasher.HashPassword(new User(), password);
}
}

View File

@@ -0,0 +1,12 @@
using Bit.Core.Utilities;
namespace Bit.Core.Tools.Services;
public class SendCoreHelperService : ISendCoreHelperService
{
public string SecureRandomString(int length, bool useUpperCase, bool useSpecial)
{
return CoreHelpers.SecureRandomString(length, upper: useUpperCase, special: useSpecial);
}
}

View File

@@ -0,0 +1,26 @@
using Bit.Core.Tools.Entities;
namespace Bit.Core.Tools.SendFeatures;
/// <summary>
/// SendFileSettingHelper is a static class that provides constants and helper methods (if needed) for managing file
/// settings.
/// </summary>
public static class SendFileSettingHelper
{
/// <summary>
/// The leeway for the file size. This is the calculated 1 megabyte of cushion when doing comparisons of file sizes
/// within the system.
/// </summary>
public const long FILE_SIZE_LEEWAY = 1024L * 1024L; // 1MB
/// <summary>
/// The maximum file size for a file uploaded in a <see cref="Send" />. Units are calculated in bytes but
/// represent 501 megabytes. 1 megabyte is added for cushion to account for file size.
/// </summary>
public const long MAX_FILE_SIZE = Constants.FileSize501mb;
/// <summary>
/// String of the expected file size and to be used when needing to communicate the file size to the client/user.
/// </summary>
public const string MAX_FILE_SIZE_READABLE = "500 MB";
}

View File

@@ -0,0 +1,142 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
using Bit.Core.Utilities;
namespace Bit.Core.Tools.Services;
public class SendValidationService : ISendValidationService
{
private readonly IUserRepository _userRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IPolicyService _policyService;
private readonly IFeatureService _featureService;
private readonly IUserService _userService;
private readonly GlobalSettings _globalSettings;
private readonly ICurrentContext _currentContext;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
public SendValidationService(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IPolicyService policyService,
IFeatureService featureService,
IUserService userService,
IPolicyRequirementQuery policyRequirementQuery,
GlobalSettings globalSettings,
ICurrentContext currentContext)
{
_userRepository = userRepository;
_organizationRepository = organizationRepository;
_policyService = policyService;
_featureService = featureService;
_userService = userService;
_policyRequirementQuery = policyRequirementQuery;
_globalSettings = globalSettings;
_currentContext = currentContext;
}
public async Task ValidateUserCanSaveAsync(Guid? userId, Send send)
{
if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements))
{
await ValidateUserCanSaveAsync_vNext(userId, send);
return;
}
if (!userId.HasValue || (!_currentContext.Organizations?.Any() ?? true))
{
return;
}
var anyDisableSendPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(userId.Value,
PolicyType.DisableSend);
if (anyDisableSendPolicies)
{
throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send.");
}
if (send.HideEmail.GetValueOrDefault())
{
var sendOptionsPolicies = await _policyService.GetPoliciesApplicableToUserAsync(userId.Value, PolicyType.SendOptions);
if (sendOptionsPolicies.Any(p => CoreHelpers.LoadClassFromJsonData<SendOptionsPolicyData>(p.PolicyData)?.DisableHideEmail ?? false))
{
throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send.");
}
}
}
public async Task ValidateUserCanSaveAsync_vNext(Guid? userId, Send send)
{
if (!userId.HasValue)
{
return;
}
var disableSendRequirement = await _policyRequirementQuery.GetAsync<DisableSendPolicyRequirement>(userId.Value);
if (disableSendRequirement.DisableSend)
{
throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send.");
}
var sendOptionsRequirement = await _policyRequirementQuery.GetAsync<SendOptionsPolicyRequirement>(userId.Value);
if (sendOptionsRequirement.DisableHideEmail && send.HideEmail.GetValueOrDefault())
{
throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send.");
}
}
public async Task<long> StorageRemainingForSendAsync(Send send)
{
var storageBytesRemaining = 0L;
if (send.UserId.HasValue)
{
var user = await _userRepository.GetByIdAsync(send.UserId.Value);
if (!await _userService.CanAccessPremium(user))
{
throw new BadRequestException("You must have premium status to use file Sends.");
}
if (!user.EmailVerified)
{
throw new BadRequestException("You must confirm your email to use file Sends.");
}
if (user.Premium)
{
storageBytesRemaining = user.StorageBytesRemaining();
}
else
{
// Users that get access to file storage/premium from their organization get the default
// 1 GB max storage.
short limit = _globalSettings.SelfHosted ? (short)10240 : (short)1;
storageBytesRemaining = user.StorageBytesRemaining(limit);
}
}
else if (send.OrganizationId.HasValue)
{
var org = await _organizationRepository.GetByIdAsync(send.OrganizationId.Value);
if (!org.MaxStorageGb.HasValue)
{
throw new BadRequestException("This organization cannot use file sends.");
}
storageBytesRemaining = org.StorageBytesRemaining();
}
return storageBytesRemaining;
}
}