mirror of
https://github.com/bitwarden/server.git
synced 2025-07-01 08:02:49 -05:00
Direct upload to Azure/Local (#1188)
* Direct upload to azure To validate file sizes in the event of a rogue client, Azure event webhooks will be hooked up to AzureValidateFile. Sends outside of a grace size will be deleted as non-compliant. TODO: LocalSendFileStorageService direct upload method/endpoint. * Quick respond to no-body event calls These shouldn't happen, but might if some errant get requests occur * Event Grid only POSTS to webhook * Enable local storage direct file upload * Increase file size difference leeway * Upload through service * Fix LocalFileSendStorage It turns out that multipartHttpStreams do not have a length until read. this causes all long files to be "invalid". We need to write the entire stream, then validate length, just like Azure. the difference is, We can return an exception to local storage admonishing the client for lying * Update src/Api/Utilities/ApiHelpers.cs Co-authored-by: Chad Scharf <3904944+cscharf@users.noreply.github.com> * Do not delete directory if it has files * Allow large uploads for self hosted instances * Fix formatting * Re-verfiy access and increment access count on download of Send File * Update src/Core/Services/Implementations/SendService.cs Co-authored-by: Chad Scharf <3904944+cscharf@users.noreply.github.com> * Add back in original Send upload * Update size and mark as validated upon Send file validation * Log azure file validation errors * Lint fix Co-authored-by: Chad Scharf <3904944+cscharf@users.noreply.github.com>
This commit is contained in:
@ -26,6 +26,7 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.6" />
|
||||
<PackageReference Include="NewRelic.Agent" Version="8.30.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="5.5.1" />
|
||||
<PackageReference Include="Microsoft.Azure.EventGrid" Version="3.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
@ -7,11 +7,17 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Core.Models.Table;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Models.Api.Response;
|
||||
using Bit.Core.Enums;
|
||||
using Microsoft.Azure.EventGrid.Models;
|
||||
using Bit.Api.Utilities;
|
||||
using System.Collections.Generic;
|
||||
using Bit.Core.Models.Table;
|
||||
using Newtonsoft.Json;
|
||||
using Bit.Core.Models.Data;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Api.Controllers
|
||||
{
|
||||
@ -23,6 +29,7 @@ namespace Bit.Api.Controllers
|
||||
private readonly IUserService _userService;
|
||||
private readonly ISendService _sendService;
|
||||
private readonly ISendFileStorageService _sendFileStorageService;
|
||||
private readonly ILogger<SendsController> _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public SendsController(
|
||||
@ -30,12 +37,14 @@ namespace Bit.Api.Controllers
|
||||
IUserService userService,
|
||||
ISendService sendService,
|
||||
ISendFileStorageService sendFileStorageService,
|
||||
ILogger<SendsController> logger,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
_sendRepository = sendRepository;
|
||||
_userService = userService;
|
||||
_sendService = sendService;
|
||||
_sendFileStorageService = sendFileStorageService;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
|
||||
@ -160,12 +169,113 @@ namespace Bit.Api.Controllers
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var (madeSend, madeData) = model.ToSend(userId, fileName, _sendService);
|
||||
send = madeSend;
|
||||
await _sendService.CreateSendAsync(send, madeData, stream, model.FileLength.GetValueOrDefault(0));
|
||||
await _sendService.SaveFileSendAsync(send, madeData, model.FileLength.GetValueOrDefault(0));
|
||||
await _sendService.UploadFileToExistingSendAsync(stream, send);
|
||||
});
|
||||
|
||||
return new SendResponseModel(send, _globalSettings);
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("file/v2")]
|
||||
public async Task<SendFileUploadDataResponseModel> PostFile([FromBody] SendRequestModel model)
|
||||
{
|
||||
if (model.Type != SendType.File)
|
||||
{
|
||||
throw new BadRequestException("Invalid content.");
|
||||
}
|
||||
|
||||
if (!model.FileLength.HasValue)
|
||||
{
|
||||
throw new BadRequestException("Invalid content. File size hint is required.");
|
||||
}
|
||||
|
||||
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);
|
||||
return new SendFileUploadDataResponseModel
|
||||
{
|
||||
Url = uploadUrl,
|
||||
FileUploadType = _sendFileStorageService.FileUploadType,
|
||||
SendResponse = new SendResponseModel(send, _globalSettings)
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("{id}/file/{fileId}")]
|
||||
public async Task<SendFileUploadDataResponseModel> RenewFileUpload(string id, string fileId)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var sendId = new Guid(id);
|
||||
var send = await _sendRepository.GetByIdAsync(sendId);
|
||||
var fileData = JsonConvert.DeserializeObject<SendFileData>(send?.Data);
|
||||
|
||||
if (send == null || send.Type != SendType.File || (send.UserId.HasValue && send.UserId.Value != userId) ||
|
||||
!send.UserId.HasValue || fileData.Id != fileId || fileData.Validated)
|
||||
{
|
||||
// Not found if Send isn't found, user doesn't have access, request is faulty,
|
||||
// or we've already validated the file. This last is to emulate create-only blob permissions for Azure
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return new SendFileUploadDataResponseModel
|
||||
{
|
||||
Url = await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId),
|
||||
FileUploadType = _sendFileStorageService.FileUploadType,
|
||||
SendResponse = new SendResponseModel(send, _globalSettings),
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("{id}/file/{fileId}")]
|
||||
[DisableFormValueModelBinding]
|
||||
public async Task PostFileForExistingSend(string id, string fileId)
|
||||
{
|
||||
if (!Request?.ContentType.Contains("multipart/") ?? true)
|
||||
{
|
||||
throw new BadRequestException("Invalid content.");
|
||||
}
|
||||
|
||||
if (Request.ContentLength > 105906176 && !_globalSettings.SelfHosted) // 101 MB, give em' 1 extra MB for cushion
|
||||
{
|
||||
throw new BadRequestException("Max file size for direct upload is 100 MB.");
|
||||
}
|
||||
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(id));
|
||||
await Request.GetSendFileAsync(async (stream) =>
|
||||
{
|
||||
await _sendService.UploadFileToExistingSendAsync(stream, send);
|
||||
});
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("file/validate/azure")]
|
||||
public async Task<OkObjectResult> AzureValidateFile()
|
||||
{
|
||||
return await ApiHelpers.HandleAzureEvents(Request, new Dictionary<string, Func<EventGridEvent, Task>>
|
||||
{
|
||||
{
|
||||
"Microsoft.Storage.BlobCreated", async (eventGridEvent) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
|
||||
var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName);
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(sendId));
|
||||
if (send == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
await _sendService.ValidateSendFile(send);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonConvert.SerializeObject(eventGridEvent)}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<SendResponseModel> Put(string id, [FromBody] SendRequestModel model)
|
||||
{
|
||||
|
@ -1,5 +1,10 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Azure.EventGrid;
|
||||
using Microsoft.Azure.EventGrid.Models;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@ -29,5 +34,47 @@ namespace Bit.Api.Utilities
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates Azure event subscription and calls the appropriate event handler. Responds HttpOk.
|
||||
/// </summary>
|
||||
/// <param name="request">HttpRequest received from Azure</param>
|
||||
/// <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,
|
||||
Dictionary<string, Func<EventGridEvent, Task>> eventTypeHandlers)
|
||||
{
|
||||
var response = string.Empty;
|
||||
var requestContent = await new StreamReader(request.Body).ReadToEndAsync();
|
||||
if (string.IsNullOrWhiteSpace(requestContent))
|
||||
{
|
||||
return new OkObjectResult(response);
|
||||
}
|
||||
|
||||
var eventGridSubscriber = new EventGridSubscriber();
|
||||
var eventGridEvents = eventGridSubscriber.DeserializeEventGridEvents(requestContent);
|
||||
|
||||
foreach (var eventGridEvent in eventGridEvents)
|
||||
{
|
||||
if (eventGridEvent.Data is SubscriptionValidationEventData eventData)
|
||||
{
|
||||
// Might want to enable additional validation: subject, topic etc.
|
||||
|
||||
var responseData = new SubscriptionValidationResponse()
|
||||
{
|
||||
ValidationResponse = eventData.ValidationCode
|
||||
};
|
||||
|
||||
return new OkObjectResult(responseData);
|
||||
}
|
||||
else if (eventTypeHandlers.ContainsKey(eventGridEvent.EventType))
|
||||
{
|
||||
await eventTypeHandlers[eventGridEvent.EventType](eventGridEvent);
|
||||
}
|
||||
}
|
||||
|
||||
return new OkObjectResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -108,6 +108,27 @@ namespace Bit.Api.Utilities
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task GetSendFileAsync(this HttpRequest request, Func<Stream, Task> callback)
|
||||
{
|
||||
var boundary = GetBoundary(MediaTypeHeaderValue.Parse(request.ContentType),
|
||||
_defaultFormOptions.MultipartBoundaryLengthLimit);
|
||||
var reader = new MultipartReader(boundary, request.Body);
|
||||
|
||||
var dataSection = await reader.ReadNextSectionAsync();
|
||||
if (dataSection != null)
|
||||
{
|
||||
if (ContentDispositionHeaderValue.TryParse(dataSection.ContentDisposition, out var dataContent)
|
||||
&& HasFileContentDisposition(dataContent))
|
||||
{
|
||||
using (dataSection.Body)
|
||||
{
|
||||
await callback(dataSection.Body);
|
||||
}
|
||||
}
|
||||
dataSection = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static string GetBoundary(MediaTypeHeaderValue contentType, int lengthLimit)
|
||||
{
|
||||
|
Reference in New Issue
Block a user