mirror of
https://github.com/bitwarden/server.git
synced 2025-07-01 08:02:49 -05:00
Attachment blob upload (#1229)
* Add Cipher attachment upload endpoints * Add validation bool to attachment storage data This bool is used to determine whether or not to renew upload links * Add model to request a new attachment to be made for later upload * Add model to respond with created attachment. The two cipher properties represent the two different cipher model types that can be returned. Cipher Response from personal items and mini response from organizations * Create Azure SAS-authorized upload links for both one-shot and block uploads * Add service methods to handle delayed upload and file size validation * Add emergency access method for downloading attachments direct from Azure * Add new attachment storage methods to other services * Update service interfaces * Log event grid exceptions * Limit Send and Attachment Size to 500MB * capitalize Key property * Add key validation to Azure Event Grid endpoint * Delete blob for unexpected blob creation events * Set Event Grid key at API startup * Change renew attachment upload url request path to match Send * Shore up attachment cleanup method. As long as we have the required information, we should always delete attachments from each the Repository, the cipher in memory, and the file storage service to ensure they're all synched.
This commit is contained in:
@ -12,7 +12,11 @@ using Bit.Api.Utilities;
|
||||
using System.Collections.Generic;
|
||||
using Bit.Core.Models.Table;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
using Core.Models.Data;
|
||||
using Microsoft.Azure.EventGrid.Models;
|
||||
using Bit.Core.Models.Data;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Bit.Api.Controllers
|
||||
{
|
||||
@ -26,6 +30,7 @@ namespace Bit.Api.Controllers
|
||||
private readonly IUserService _userService;
|
||||
private readonly IAttachmentStorageService _attachmentStorageService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly ILogger<CiphersController> _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public CiphersController(
|
||||
@ -35,6 +40,7 @@ namespace Bit.Api.Controllers
|
||||
IUserService userService,
|
||||
IAttachmentStorageService attachmentStorageService,
|
||||
ICurrentContext currentContext,
|
||||
ILogger<CiphersController> logger,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
_cipherRepository = cipherRepository;
|
||||
@ -43,6 +49,7 @@ namespace Bit.Api.Controllers
|
||||
_userService = userService;
|
||||
_attachmentStorageService = attachmentStorageService;
|
||||
_currentContext = currentContext;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
|
||||
@ -562,6 +569,82 @@ namespace Bit.Api.Controllers
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/attachment/v2")]
|
||||
public async Task<AttachmentUploadDataResponseModel> PostAttachment(string id, [FromBody] AttachmentRequestModel request)
|
||||
{
|
||||
var idGuid = new Guid(id);
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var cipher = request.AdminRequest ?
|
||||
await _cipherRepository.GetOrganizationDetailsByIdAsync(idGuid) :
|
||||
await _cipherRepository.GetByIdAsync(idGuid, userId);
|
||||
|
||||
if (cipher == null || (request.AdminRequest && (!cipher.OrganizationId.HasValue ||
|
||||
!_currentContext.ManageAllCollections(cipher.OrganizationId.Value))))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (request.FileSize > CipherService.MAX_FILE_SIZE && !_globalSettings.SelfHosted)
|
||||
{
|
||||
throw new BadRequestException($"Max file size is {CipherService.MAX_FILE_SIZE_READABLE}.");
|
||||
}
|
||||
|
||||
|
||||
var (attachmentId, uploadUrl) = await _cipherService.CreateAttachmentForDelayedUploadAsync(cipher, request, userId);
|
||||
return new AttachmentUploadDataResponseModel
|
||||
{
|
||||
AttachmentId = attachmentId,
|
||||
Url = uploadUrl,
|
||||
FileUploadType = _attachmentStorageService.FileUploadType,
|
||||
CipherResponse = request.AdminRequest ? null : new CipherResponseModel((CipherDetails)cipher, _globalSettings),
|
||||
CipherMiniResponse = request.AdminRequest ? new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp) : null,
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("{id}/attachment/{attachmentId}")]
|
||||
public async Task<AttachmentUploadDataResponseModel> RenewFileUploadUrl(string id, string attachmentId)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var cipherId = new Guid(id);
|
||||
var cipher = await _cipherRepository.GetByIdAsync(cipherId, userId);
|
||||
var attachments = cipher?.GetAttachments();
|
||||
|
||||
if (attachments == null || !attachments.ContainsKey(attachmentId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return new AttachmentUploadDataResponseModel
|
||||
{
|
||||
Url = await _attachmentStorageService.GetAttachmentUploadUrlAsync(cipher, attachments[attachmentId]),
|
||||
FileUploadType = _attachmentStorageService.FileUploadType,
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("{id}/attachment/{attachmentId}")]
|
||||
[DisableFormValueModelBinding]
|
||||
public async Task PostFileForExistingAttachment(string id, string attachmentId)
|
||||
{
|
||||
if (!Request?.ContentType.Contains("multipart/") ?? true)
|
||||
{
|
||||
throw new BadRequestException("Invalid content.");
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var cipher = await _cipherRepository.GetByIdAsync(new Guid(id), userId);
|
||||
var attachments = cipher?.GetAttachments();
|
||||
if (attachments == null || !attachments.ContainsKey(attachmentId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
var attachmentData = attachments[attachmentId];
|
||||
|
||||
await Request.GetFileAsync(async (stream) =>
|
||||
{
|
||||
await _cipherService.UploadFileForExistingAttachmentAsync(stream, cipher, attachmentData);
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("{id}/attachment")]
|
||||
[RequestSizeLimit(105_906_176)]
|
||||
[DisableFormValueModelBinding]
|
||||
@ -616,20 +699,7 @@ namespace Bit.Api.Controllers
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var cipher = await _cipherRepository.GetByIdAsync(new Guid(id), userId);
|
||||
var attachments = cipher.GetAttachments();
|
||||
|
||||
if (!attachments.ContainsKey(attachmentId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var data = attachments[attachmentId];
|
||||
var response = new AttachmentResponseModel(attachmentId, data, cipher, _globalSettings)
|
||||
{
|
||||
Url = await _attachmentStorageService.GetAttachmentDownloadUrlAsync(cipher, data)
|
||||
};
|
||||
|
||||
return response;
|
||||
return await _cipherService.GetAttachmentDownloadDataAsync(cipher, attachmentId);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/attachment/{attachmentId}/share")]
|
||||
@ -684,6 +754,44 @@ namespace Bit.Api.Controllers
|
||||
await _cipherService.DeleteAttachmentAsync(cipher, attachmentId, userId, true);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("attachment/validate/azure")]
|
||||
public async Task<ObjectResult> AzureValidateFile()
|
||||
{
|
||||
return await ApiHelpers.HandleAzureEvents(Request, new Dictionary<string, Func<EventGridEvent, Task>>
|
||||
{
|
||||
{
|
||||
"Microsoft.Storage.BlobCreated", async (eventGridEvent) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var blobName = eventGridEvent.Subject.Split($"{AzureAttachmentStorageService.EventGridEnabledContainerName}/blobs/")[1];
|
||||
var (cipherId, organizationId, attachmentId) = AzureAttachmentStorageService.IdentifiersFromBlobName(blobName);
|
||||
var cipher = await _cipherRepository.GetByIdAsync(new Guid(cipherId));
|
||||
var attachments = cipher?.GetAttachments() ?? new Dictionary<string, CipherAttachment.MetaData>();
|
||||
|
||||
if (cipher == null || !attachments.ContainsKey(attachmentId) || attachments[attachmentId].Validated)
|
||||
{
|
||||
if (_attachmentStorageService is AzureSendFileStorageService azureFileStorageService)
|
||||
{
|
||||
await azureFileStorageService.DeleteBlobAsync(blobName);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await _cipherService.ValidateCipherAttachmentFile(cipher, attachments[attachmentId]);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonConvert.SerializeObject(eventGridEvent)}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void ValidateAttachment()
|
||||
{
|
||||
if (!Request?.ContentType.Contains("multipart/") ?? true)
|
||||
|
@ -163,5 +163,12 @@ namespace Bit.Api.Controllers
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
return await _emergencyAccessService.ViewAsync(new Guid(id), user);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/{cipherId}/attachment/{attachmentId}")]
|
||||
public async Task<AttachmentResponseModel> GetAttachmentData(string id, string cipherId, string attachmentId)
|
||||
{
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
return await _emergencyAccessService.GetAttachmentDownloadAsync(new Guid(id), cipherId, attachmentId, user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -190,6 +190,11 @@ namespace Bit.Api.Controllers
|
||||
throw new BadRequestException("Invalid content. File size hint is required.");
|
||||
}
|
||||
|
||||
if (model.FileLength.Value > SendService.MAX_FILE_SIZE)
|
||||
{
|
||||
throw new BadRequestException($"Max file size is {SendService.MAX_FILE_SIZE_READABLE}.");
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var (send, data) = model.ToSend(userId, model.File.FileName, _sendService);
|
||||
var uploadUrl = await _sendService.SaveFileSendAsync(send, data, model.FileLength.Value);
|
||||
@ -240,7 +245,7 @@ namespace Bit.Api.Controllers
|
||||
}
|
||||
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(id));
|
||||
await Request.GetSendFileAsync(async (stream) =>
|
||||
await Request.GetFileAsync(async (stream) =>
|
||||
{
|
||||
await _sendService.UploadFileToExistingSendAsync(stream, send);
|
||||
});
|
||||
@ -248,7 +253,7 @@ namespace Bit.Api.Controllers
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("file/validate/azure")]
|
||||
public async Task<OkObjectResult> AzureValidateFile()
|
||||
public async Task<ObjectResult> AzureValidateFile()
|
||||
{
|
||||
return await ApiHelpers.HandleAzureEvents(Request, new Dictionary<string, Func<EventGridEvent, Task>>
|
||||
{
|
||||
@ -262,6 +267,10 @@ namespace Bit.Api.Controllers
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(sendId));
|
||||
if (send == null)
|
||||
{
|
||||
if (_sendFileStorageService is AzureSendFileStorageService azureSendFileStorageService)
|
||||
{
|
||||
await azureSendFileStorageService.DeleteBlobAsync(blobName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await _sendService.ValidateSendFile(send);
|
||||
|
@ -50,6 +50,12 @@ namespace Bit.Api
|
||||
// Data Protection
|
||||
services.AddCustomDataProtectionServices(Environment, globalSettings);
|
||||
|
||||
// Event Grid
|
||||
if (!string.IsNullOrWhiteSpace(globalSettings.EventGridKey))
|
||||
{
|
||||
ApiHelpers.EventGridKey = globalSettings.EventGridKey;
|
||||
}
|
||||
|
||||
// Stripe Billing
|
||||
StripeConfiguration.ApiKey = globalSettings.StripeApiKey;
|
||||
|
||||
|
@ -12,6 +12,7 @@ namespace Bit.Api.Utilities
|
||||
{
|
||||
public static class ApiHelpers
|
||||
{
|
||||
public static string EventGridKey { get; set; }
|
||||
public async static Task<T> ReadJsonFileFromBody<T>(HttpContext httpContext, IFormFile file, long maxSize = 51200)
|
||||
{
|
||||
T obj = default(T);
|
||||
@ -42,9 +43,16 @@ namespace Bit.Api.Utilities
|
||||
/// <param name="eventTypeHandlers">Dictionary of eventType strings and their associated handlers.</param>
|
||||
/// <returns>OkObjectResult</returns>
|
||||
/// <remarks>Reference https://docs.microsoft.com/en-us/azure/event-grid/receive-events</remarks>
|
||||
public async static Task<OkObjectResult> HandleAzureEvents(HttpRequest request,
|
||||
public async static Task<ObjectResult> HandleAzureEvents(HttpRequest request,
|
||||
Dictionary<string, Func<EventGridEvent, Task>> eventTypeHandlers)
|
||||
{
|
||||
var queryKey = request.Query["key"];
|
||||
|
||||
if (queryKey != EventGridKey)
|
||||
{
|
||||
return new UnauthorizedObjectResult("Authentication failed. Please use a valid key.");
|
||||
}
|
||||
|
||||
var response = string.Empty;
|
||||
var requestContent = await new StreamReader(request.Body).ReadToEndAsync();
|
||||
if (string.IsNullOrWhiteSpace(requestContent))
|
||||
|
@ -108,7 +108,7 @@ namespace Bit.Api.Utilities
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task GetSendFileAsync(this HttpRequest request, Func<Stream, Task> callback)
|
||||
public static async Task GetFileAsync(this HttpRequest request, Func<Stream, Task> callback)
|
||||
{
|
||||
var boundary = GetBoundary(MediaTypeHeaderValue.Parse(request.ContentType),
|
||||
_defaultFormOptions.MultipartBoundaryLengthLimit);
|
||||
|
Reference in New Issue
Block a user