diff --git a/src/Api/Controllers/CiphersController.cs b/src/Api/Controllers/CiphersController.cs index 24eb3f828..c7264f4da 100644 --- a/src/Api/Controllers/CiphersController.cs +++ b/src/Api/Controllers/CiphersController.cs @@ -8,6 +8,7 @@ using Bit.Core.Models.Api; using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core; +using Bit.Api.Utilities; namespace Bit.Api.Controllers { @@ -19,6 +20,7 @@ namespace Bit.Api.Controllers private readonly ICollectionCipherRepository _collectionCipherRepository; private readonly ICipherService _cipherService; private readonly IUserService _userService; + private readonly IAttachmentStorageService _attachmentStorageService; private readonly CurrentContext _currentContext; public CiphersController( @@ -26,12 +28,14 @@ namespace Bit.Api.Controllers ICollectionCipherRepository collectionCipherRepository, ICipherService cipherService, IUserService userService, + IAttachmentStorageService attachmentStorageService, CurrentContext currentContext) { _cipherRepository = cipherRepository; _collectionCipherRepository = collectionCipherRepository; _cipherService = cipherService; _userService = userService; + _attachmentStorageService = attachmentStorageService; _currentContext = currentContext; } @@ -214,5 +218,57 @@ namespace Bit.Api.Controllers await _cipherService.MoveManyAsync(model.Ids.Select(i => new Guid(i)), string.IsNullOrWhiteSpace(model.FolderId) ? (Guid?)null : new Guid(model.FolderId), userId); } + + [HttpPost("attachment")] + [DisableFormValueModelBinding] + public async Task Post(string id) + { + // throw for now + throw new NotImplementedException(); + + if(!Request?.ContentType.Contains("multipart/") ?? true) + { + throw new BadRequestException("Invalid content."); + } + + var idGuid = new Guid(id); + var userId = _userService.GetProperUserId(User).Value; + var cipher = await _cipherRepository.GetByIdAsync(idGuid, userId); + if(cipher == null) + { + throw new NotFoundException(); + } + + await Request.GetFilesAsync(async (stream, fileName) => + { + var attachmentId = Guid.NewGuid(); + // TODO: store attachmentId + fileName reference in database + var storedFilename = $"{idGuid}_{attachmentId}"; + await _attachmentStorageService.UploadAttachmentAsync(stream, storedFilename); + }); + } + + [HttpDelete("{id}/attachment/{attachmentId}")] + [HttpPost("{id}/attachment/{attachmentId}/delete")] + public async Task Delete(string id, string attachmentId) + { + // throw for now + throw new NotImplementedException(); + + var idGuid = new Guid(id); + var userId = _userService.GetProperUserId(User).Value; + var cipher = await _cipherRepository.GetByIdAsync(idGuid, userId); + if(cipher == null) + { + throw new NotFoundException(); + } + + var attachmentIdGuid = new Guid(attachmentId); + + // TODO: check and remove attachmentId from cipher in database + + var storedFilename = $"{idGuid}_{attachmentId}"; + await _attachmentStorageService.DeleteAttachmentAsync(storedFilename); + } } } diff --git a/src/Api/Utilities/DisableFormValueModelBindingAttribute.cs b/src/Api/Utilities/DisableFormValueModelBindingAttribute.cs new file mode 100644 index 000000000..3881d0a63 --- /dev/null +++ b/src/Api/Utilities/DisableFormValueModelBindingAttribute.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using System; +using System.Linq; + +namespace Bit.Api.Utilities +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter + { + public void OnResourceExecuting(ResourceExecutingContext context) + { + var formValue = context.ValueProviderFactories.OfType().FirstOrDefault(); + if(formValue != null) + { + context.ValueProviderFactories.Remove(formValue); + } + + var jqFormValue = context.ValueProviderFactories.OfType().FirstOrDefault(); + if(jqFormValue != null) + { + context.ValueProviderFactories.Remove(jqFormValue); + } + } + + public void OnResourceExecuted(ResourceExecutedContext context) + { + } + } +} diff --git a/src/Api/Utilities/MultipartFormDataHelper.cs b/src/Api/Utilities/MultipartFormDataHelper.cs new file mode 100644 index 000000000..c7cef3704 --- /dev/null +++ b/src/Api/Utilities/MultipartFormDataHelper.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Net.Http.Headers; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Bit.Api.Utilities +{ + public static class MultipartFormDataHelper + { + private static readonly FormOptions _defaultFormOptions = new FormOptions(); + + public static async Task GetFilesAsync(this HttpRequest request, Func callback) + { + var boundary = GetBoundary(MediaTypeHeaderValue.Parse(request.ContentType), + _defaultFormOptions.MultipartBoundaryLengthLimit); + var reader = new MultipartReader(boundary, request.Body); + + var section = await reader.ReadNextSectionAsync(); + while(section != null) + { + ContentDispositionHeaderValue content; + if(ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out content) && + HasFileContentDisposition(content)) + { + await callback(section.Body, HeaderUtilities.RemoveQuotes(content.FileName)); + } + + section = await reader.ReadNextSectionAsync(); + } + } + + private static string GetBoundary(MediaTypeHeaderValue contentType, int lengthLimit) + { + var boundary = HeaderUtilities.RemoveQuotes(contentType.Boundary); + if(string.IsNullOrWhiteSpace(boundary)) + { + throw new InvalidDataException("Missing content-type boundary."); + } + + if(boundary.Length > lengthLimit) + { + throw new InvalidDataException($"Multipart boundary length limit {lengthLimit} exceeded."); + } + + return boundary; + } + + private static bool HasFileContentDisposition(ContentDispositionHeaderValue content) + { + // Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg" + return content != null && content.DispositionType.Equals("form-data") && + (!string.IsNullOrEmpty(content.FileName) || !string.IsNullOrEmpty(content.FileNameStar)); + } + } +} diff --git a/src/Api/settings.json b/src/Api/settings.json index 7822808cd..4d7a14b90 100644 --- a/src/Api/settings.json +++ b/src/Api/settings.json @@ -27,6 +27,10 @@ "storage": { "connectionString": "SECRET" }, + "attachment": { + "connectionString": "SECRET", + "baseUrl": "http://localhost:4000/" + }, "documentDb": { "uri": "SECRET", "key": "SECRET" diff --git a/src/Core/GlobalSettings.cs b/src/Core/GlobalSettings.cs index ab77ace8f..b31422e80 100644 --- a/src/Core/GlobalSettings.cs +++ b/src/Core/GlobalSettings.cs @@ -10,6 +10,7 @@ public virtual MailSettings Mail { get; set; } = new MailSettings(); public virtual PushSettings Push { get; set; } = new PushSettings(); public virtual StorageSettings Storage { get; set; } = new StorageSettings(); + public virtual AttachmentSettings Attachment { get; set; } = new AttachmentSettings(); public virtual IdentityServerSettings IdentityServer { get; set; } = new IdentityServerSettings(); public virtual DataProtectionSettings DataProtection { get; set; } = new DataProtectionSettings(); public virtual DocumentDbSettings DocumentDb { get; set; } = new DocumentDbSettings(); @@ -26,6 +27,12 @@ public string ConnectionString { get; set; } } + public class AttachmentSettings + { + public string ConnectionString { get; set; } + public string BaseUrl { get; set; } + } + public class MailSettings { public string ReplyToEmail { get; set; } diff --git a/src/Core/Services/IAttachmentStorageService.cs b/src/Core/Services/IAttachmentStorageService.cs new file mode 100644 index 000000000..d9204e466 --- /dev/null +++ b/src/Core/Services/IAttachmentStorageService.cs @@ -0,0 +1,11 @@ +using System.IO; +using System.Threading.Tasks; + +namespace Bit.Core.Services +{ + public interface IAttachmentStorageService + { + Task UploadAttachmentAsync(Stream stream, string name); + Task DeleteAttachmentAsync(string name); + } +} diff --git a/src/Core/Services/Implementations/AzureAttachmentStorageService.cs b/src/Core/Services/Implementations/AzureAttachmentStorageService.cs new file mode 100644 index 000000000..e4cd0fe6a --- /dev/null +++ b/src/Core/Services/Implementations/AzureAttachmentStorageService.cs @@ -0,0 +1,45 @@ +using System.Threading.Tasks; +using Microsoft.WindowsAzure.Storage; +using Microsoft.WindowsAzure.Storage.Blob; +using System.IO; + +namespace Bit.Core.Services +{ + public class AzureAttachmentStorageService : IAttachmentStorageService + { + private const string AttchmentContainerName = "attachments"; + + private readonly CloudBlobClient _blobClient; + private CloudBlobContainer _attachmentsContainer; + + public AzureAttachmentStorageService( + GlobalSettings globalSettings) + { + var storageAccount = CloudStorageAccount.Parse(globalSettings.Storage.ConnectionString); + _blobClient = storageAccount.CreateCloudBlobClient(); + } + + public async Task UploadAttachmentAsync(Stream stream, string name) + { + await InitAsync(); + var blob = _attachmentsContainer.GetBlockBlobReference(name); + await blob.UploadFromStreamAsync(stream); + } + + public async Task DeleteAttachmentAsync(string name) + { + await InitAsync(); + var blob = _attachmentsContainer.GetBlockBlobReference(name); + await blob.DeleteIfExistsAsync(); + } + + private async Task InitAsync() + { + if(_attachmentsContainer == null) + { + _attachmentsContainer = _blobClient.GetContainerReference(AttchmentContainerName); + await _attachmentsContainer.CreateIfNotExistsAsync(); + } + } + } +} diff --git a/src/Core/Services/NoopImplementations/NoopAttachmentStorageService.cs b/src/Core/Services/NoopImplementations/NoopAttachmentStorageService.cs new file mode 100644 index 000000000..5b504c90a --- /dev/null +++ b/src/Core/Services/NoopImplementations/NoopAttachmentStorageService.cs @@ -0,0 +1,18 @@ +using System.IO; +using System.Threading.Tasks; + +namespace Bit.Core.Services +{ + public class NoopAttachmentStorageService : IAttachmentStorageService + { + public Task DeleteAttachmentAsync(string name) + { + return Task.FromResult(0); + } + + public Task UploadAttachmentAsync(Stream stream, string name) + { + return Task.FromResult(0); + } + } +} diff --git a/src/Core/Utilities/ServiceCollectionExtensions.cs b/src/Core/Utilities/ServiceCollectionExtensions.cs index 2422b1bcd..d3709bbfb 100644 --- a/src/Core/Utilities/ServiceCollectionExtensions.cs +++ b/src/Core/Utilities/ServiceCollectionExtensions.cs @@ -53,6 +53,8 @@ namespace Bit.Core.Utilities services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + // noop for now + services.AddSingleton(); } public static void AddNoopServices(this IServiceCollection services) @@ -61,6 +63,7 @@ namespace Bit.Core.Utilities services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } public static IdentityBuilder AddCustomIdentityServices(