diff --git a/src/Api/Controllers/CiphersController.cs b/src/Api/Controllers/CiphersController.cs index 1b06db2d9..f5a333ec0 100644 --- a/src/Api/Controllers/CiphersController.cs +++ b/src/Api/Controllers/CiphersController.cs @@ -9,6 +9,7 @@ using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core; using Bit.Api.Utilities; +using Bit.Core.Utilities; namespace Bit.Api.Controllers { @@ -133,14 +134,15 @@ namespace Bit.Api.Controllers public async Task PutShare(string id, [FromBody]CipherShareRequestModel model) { var userId = _userService.GetProperUserId(User).Value; - var cipher = await _cipherRepository.GetByIdAsync(new Guid(id), userId); + var cipher = await _cipherRepository.GetByIdAsync(new Guid(id)); if(cipher == null || cipher.UserId != userId || !_currentContext.OrganizationUser(new Guid(model.Cipher.OrganizationId))) { throw new NotFoundException(); } - await _cipherService.ShareAsync(model.Cipher.ToCipher(cipher), new Guid(model.Cipher.OrganizationId), + var original = CoreHelpers.CloneObject(cipher); + await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher), new Guid(model.Cipher.OrganizationId), model.CollectionIds.Select(c => new Guid(c)), userId); } @@ -224,15 +226,7 @@ namespace Bit.Api.Controllers [DisableFormValueModelBinding] public async Task PostAttachment(string id) { - if(!Request?.ContentType.Contains("multipart/") ?? true) - { - throw new BadRequestException("Invalid content."); - } - - if(Request.ContentLength > 105906176) // 101 MB, give em' 1 extra MB for cushion - { - throw new BadRequestException("Max file size is 100 MB."); - } + ValidateAttachment(); var idGuid = new Guid(id); var userId = _userService.GetProperUserId(User).Value; @@ -244,7 +238,28 @@ namespace Bit.Api.Controllers await Request.GetFileAsync(async (stream, fileName) => { - await _cipherService.CreateAttachmentAsync(cipher, stream, fileName, Request.ContentLength.GetValueOrDefault(0), userId); + await _cipherService.CreateAttachmentAsync(cipher, stream, fileName, + Request.ContentLength.GetValueOrDefault(0), userId); + }); + } + + [HttpPost("{id}/attachment/{attachmentId}/share")] + [DisableFormValueModelBinding] + public async Task PostAttachmentShare(string id, string attachmentId, Guid organizationId) + { + ValidateAttachment(); + + var userId = _userService.GetProperUserId(User).Value; + var cipher = await _cipherRepository.GetByIdAsync(new Guid(id)); + if(cipher == null || cipher.UserId != userId || !_currentContext.OrganizationUser(organizationId)) + { + throw new NotFoundException(); + } + + await Request.GetFileAsync(async (stream, fileName) => + { + await _cipherService.CreateAttachmentShareAsync(cipher, stream, fileName, + Request.ContentLength.GetValueOrDefault(0), attachmentId, organizationId); }); } @@ -262,5 +277,18 @@ namespace Bit.Api.Controllers await _cipherService.DeleteAttachmentAsync(cipher, attachmentId, userId, false); } + + private void ValidateAttachment() + { + if(!Request?.ContentType.Contains("multipart/") ?? true) + { + throw new BadRequestException("Invalid content."); + } + + if(Request.ContentLength > 105906176) // 101 MB, give em' 1 extra MB for cushion + { + throw new BadRequestException("Max file size is 100 MB."); + } + } } } diff --git a/src/Core/Models/Api/Request/CipherRequestModel.cs b/src/Core/Models/Api/Request/CipherRequestModel.cs index 71b948ec6..f119173cf 100644 --- a/src/Core/Models/Api/Request/CipherRequestModel.cs +++ b/src/Core/Models/Api/Request/CipherRequestModel.cs @@ -48,7 +48,7 @@ namespace Bit.Core.Models.Api }); } - public Cipher ToCipher(Cipher existingCipher) + public virtual Cipher ToCipher(Cipher existingCipher) { switch(existingCipher.Type) { @@ -64,12 +64,35 @@ namespace Bit.Core.Models.Api } } + public class CipherAttachmentRequestModel : CipherRequestModel + { + public Dictionary Attachments { get; set; } + + public override Cipher ToCipher(Cipher existingCipher) + { + base.ToCipher(existingCipher); + + var attachments = existingCipher.GetAttachments(); + if((Attachments?.Count ?? 0) > 0 && (attachments?.Count ?? 0) > 0) + { + foreach(var attachment in existingCipher.GetAttachments().Where(a => Attachments.ContainsKey(a.Key))) + { + attachment.Value.FileName = Attachments[attachment.Key]; + } + + existingCipher.SetAttachments(attachments); + } + + return existingCipher; + } + } + public class CipherShareRequestModel : IValidatableObject { [Required] public IEnumerable CollectionIds { get; set; } [Required] - public CipherRequestModel Cipher { get; set; } + public CipherAttachmentRequestModel Cipher { get; set; } public IEnumerable Validate(ValidationContext validationContext) { diff --git a/src/Core/Services/IAttachmentStorageService.cs b/src/Core/Services/IAttachmentStorageService.cs index d9204e466..c32b6243a 100644 --- a/src/Core/Services/IAttachmentStorageService.cs +++ b/src/Core/Services/IAttachmentStorageService.cs @@ -1,11 +1,16 @@ -using System.IO; +using System; +using System.IO; using System.Threading.Tasks; namespace Bit.Core.Services { public interface IAttachmentStorageService { - Task UploadAttachmentAsync(Stream stream, string name); - Task DeleteAttachmentAsync(string name); + Task UploadNewAttachmentAsync(Stream stream, Guid cipherId, string attachmentId); + Task UploadShareAttachmentAsync(Stream stream, Guid cipherId, Guid organizationId, string attachmentId); + Task StartShareAttachmentAsync(Guid cipherId, Guid organizationId, string attachmentId); + Task CommitShareAttachmentAsync(Guid cipherId, Guid organizationId, string attachmentId); + Task RollbackShareAttachmentAsync(Guid cipherId, Guid organizationId, string attachmentId); + Task DeleteAttachmentAsync(Guid cipherId, string attachmentId); } } diff --git a/src/Core/Services/ICipherService.cs b/src/Core/Services/ICipherService.cs index 2c4943c5e..e9d08c6d7 100644 --- a/src/Core/Services/ICipherService.cs +++ b/src/Core/Services/ICipherService.cs @@ -13,13 +13,15 @@ namespace Bit.Core.Services Task SaveDetailsAsync(CipherDetails cipher, Guid savingUserId); Task CreateAttachmentAsync(Cipher cipher, Stream stream, string fileName, long requestLength, Guid savingUserId, bool orgAdmin = false); + Task CreateAttachmentShareAsync(Cipher cipher, Stream stream, string fileName, long requestLength, string attachmentId, + Guid organizationShareId); Task DeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false); Task DeleteManyAsync(IEnumerable cipherIds, Guid deletingUserId); Task DeleteAttachmentAsync(Cipher cipher, string attachmentId, Guid deletingUserId, bool orgAdmin = false); Task MoveManyAsync(IEnumerable cipherIds, Guid? destinationFolderId, Guid movingUserId); Task SaveFolderAsync(Folder folder); Task DeleteFolderAsync(Folder folder); - Task ShareAsync(Cipher cipher, Guid organizationId, IEnumerable collectionIds, Guid userId); + Task ShareAsync(Cipher originalCipher, Cipher cipher, Guid organizationId, IEnumerable collectionIds, Guid userId); Task SaveCollectionsAsync(Cipher cipher, IEnumerable collectionIds, Guid savingUserId, bool orgAdmin); Task ImportCiphersAsync(List folders, List ciphers, IEnumerable> folderRelationships); diff --git a/src/Core/Services/Implementations/AzureAttachmentStorageService.cs b/src/Core/Services/Implementations/AzureAttachmentStorageService.cs index 5e253b34f..0a6e37c4a 100644 --- a/src/Core/Services/Implementations/AzureAttachmentStorageService.cs +++ b/src/Core/Services/Implementations/AzureAttachmentStorageService.cs @@ -2,6 +2,7 @@ using Microsoft.WindowsAzure.Storage; using Microsoft.WindowsAzure.Storage.Blob; using System.IO; +using System; namespace Bit.Core.Services { @@ -19,20 +20,80 @@ namespace Bit.Core.Services _blobClient = storageAccount.CreateCloudBlobClient(); } - public async Task UploadAttachmentAsync(Stream stream, string name) + public async Task UploadNewAttachmentAsync(Stream stream, Guid cipherId, string attachmentId) { - await InitAsync(); - var blob = _attachmentsContainer.GetBlockBlobReference(name); - await blob.UploadFromStreamAsync(stream); + await UploadAttachmentAsync(stream, $"{cipherId}/{attachmentId}"); } - public async Task DeleteAttachmentAsync(string name) + public async Task UploadShareAttachmentAsync(Stream stream, Guid cipherId, Guid organizationId, string attachmentId) + { + await UploadAttachmentAsync(stream, $"{cipherId}/share/{organizationId}/{attachmentId}"); + } + + public async Task StartShareAttachmentAsync(Guid cipherId, Guid organizationId, string attachmentId) { await InitAsync(); - var blob = _attachmentsContainer.GetBlockBlobReference(name); + var source = _attachmentsContainer.GetBlockBlobReference($"{cipherId}/share/{organizationId}/{attachmentId}"); + if(!await source.ExistsAsync()) + { + return; + } + + var dest = _attachmentsContainer.GetBlockBlobReference($"{cipherId}/{attachmentId}"); + if(!await dest.ExistsAsync()) + { + return; + } + + var original = _attachmentsContainer.GetBlockBlobReference($"{cipherId}/temp/{attachmentId}"); + await original.DeleteIfExistsAsync(); + await original.StartCopyAsync(dest); + + await dest.DeleteIfExistsAsync(); + await dest.StartCopyAsync(source); + } + + public async Task CommitShareAttachmentAsync(Guid cipherId, Guid organizationId, string attachmentId) + { + await InitAsync(); + var source = _attachmentsContainer.GetBlockBlobReference($"{cipherId}/share/{organizationId}/{attachmentId}"); + var original = _attachmentsContainer.GetBlockBlobReference($"{cipherId}/temp/{attachmentId}"); + await original.DeleteIfExistsAsync(); + await source.DeleteIfExistsAsync(); + } + + public async Task RollbackShareAttachmentAsync(Guid cipherId, Guid organizationId, string attachmentId) + { + await InitAsync(); + var source = _attachmentsContainer.GetBlockBlobReference($"{cipherId}/share/{organizationId}/{attachmentId}"); + var dest = _attachmentsContainer.GetBlockBlobReference($"{cipherId}/{attachmentId}"); + var original = _attachmentsContainer.GetBlockBlobReference($"{cipherId}/temp/{attachmentId}"); + if(!await original.ExistsAsync()) + { + return; + } + + await dest.DeleteIfExistsAsync(); + await dest.StartCopyAsync(original); + await original.DeleteIfExistsAsync(); + await source.DeleteIfExistsAsync(); + } + + public async Task DeleteAttachmentAsync(Guid cipherId, string attachmentId) + { + await InitAsync(); + var blobName = $"{cipherId}/{attachmentId}"; + var blob = _attachmentsContainer.GetBlockBlobReference(blobName); await blob.DeleteIfExistsAsync(); } + private async Task UploadAttachmentAsync(Stream stream, string blobName) + { + await InitAsync(); + var blob = _attachmentsContainer.GetBlockBlobReference(blobName); + await blob.UploadFromStreamAsync(stream); + } + private async Task InitAsync() { if(_attachmentsContainer == null) diff --git a/src/Core/Services/Implementations/CipherService.cs b/src/Core/Services/Implementations/CipherService.cs index 4b8bc6235..8cff36478 100644 --- a/src/Core/Services/Implementations/CipherService.cs +++ b/src/Core/Services/Implementations/CipherService.cs @@ -139,8 +139,7 @@ namespace Bit.Core.Services } var attachmentId = Utilities.CoreHelpers.SecureRandomString(32, upper: false, special: false); - var storageId = $"{cipher.Id}/{attachmentId}"; - await _attachmentStorageService.UploadAttachmentAsync(stream, storageId); + await _attachmentStorageService.UploadNewAttachmentAsync(stream, cipher.Id, attachmentId); try { @@ -165,7 +164,7 @@ namespace Bit.Core.Services catch { // Clean up since this is not transactional - await _attachmentStorageService.DeleteAttachmentAsync(storageId); + await _attachmentStorageService.DeleteAttachmentAsync(cipher.Id, attachmentId); throw; } @@ -173,6 +172,29 @@ namespace Bit.Core.Services await _pushService.PushSyncCipherUpdateAsync(cipher); } + public async Task CreateAttachmentShareAsync(Cipher cipher, Stream stream, string fileName, long requestLength, + string attachmentId, Guid organizationId) + { + if(requestLength < 1) + { + throw new BadRequestException("No data to attach."); + } + + var org = await _organizationRepository.GetByIdAsync(organizationId); + if(!org.MaxStorageGb.HasValue) + { + throw new BadRequestException("This organization cannot use attachments."); + } + + var storageBytesRemaining = org.StorageBytesRemaining(); + if(storageBytesRemaining < requestLength) + { + throw new BadRequestException("Not enough storage available for this organization."); + } + + await _attachmentStorageService.UploadShareAttachmentAsync(stream, cipher.Id, organizationId, attachmentId); + } + public async Task DeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false) { if(!orgAdmin && !(await UserCanEditAsync(cipher, deletingUserId))) @@ -207,9 +229,7 @@ namespace Bit.Core.Services await _cipherRepository.DeleteAttachmentAsync(cipher.Id, attachmentId); cipher.DeleteAttachment(attachmentId); - - var storedFilename = $"{cipher.Id}/{attachmentId}"; - await _attachmentStorageService.DeleteAttachmentAsync(storedFilename); + await _attachmentStorageService.DeleteAttachmentAsync(cipher.Id, attachmentId); // push await _pushService.PushSyncCipherUpdateAsync(cipher); @@ -258,7 +278,8 @@ namespace Bit.Core.Services await _pushService.PushSyncFolderDeleteAsync(folder); } - public async Task ShareAsync(Cipher cipher, Guid organizationId, IEnumerable collectionIds, Guid sharingUserId) + public async Task ShareAsync(Cipher originalCipher, Cipher cipher, Guid organizationId, + IEnumerable collectionIds, Guid sharingUserId) { if(cipher.Id == default(Guid)) { @@ -275,11 +296,48 @@ namespace Bit.Core.Services throw new NotFoundException(); } - // Sproc will not save this UserId on the cipher. It is used limit scope of the collectionIds. - cipher.UserId = sharingUserId; - cipher.OrganizationId = organizationId; - cipher.RevisionDate = DateTime.UtcNow; - await _cipherRepository.ReplaceAsync(cipher, collectionIds); + var attachments = cipher.GetAttachments(); + var hasAttachments = (attachments?.Count ?? 0) > 0; + + try + { + // Sproc will not save this UserId on the cipher. It is used limit scope of the collectionIds. + cipher.UserId = sharingUserId; + cipher.OrganizationId = organizationId; + cipher.RevisionDate = DateTime.UtcNow; + await _cipherRepository.ReplaceAsync(cipher, collectionIds); + + if(hasAttachments) + { + // migrate attachments + foreach(var attachment in attachments) + { + await _attachmentStorageService.StartShareAttachmentAsync(cipher.Id, organizationId, attachment.Key); + } + } + } + catch + { + // roll everything back + await _cipherRepository.ReplaceAsync(originalCipher); + if(!hasAttachments) + { + throw; + } + + foreach(var attachment in attachments) + { + await _attachmentStorageService.RollbackShareAttachmentAsync(cipher.Id, organizationId, attachment.Key); + } + + throw; + } + + // commit attachment migration + foreach(var attachment in attachments) + { + await _attachmentStorageService.CommitShareAttachmentAsync(cipher.Id, organizationId, attachment.Key); + } // push await _pushService.PushSyncCipherUpdateAsync(cipher); diff --git a/src/Core/Services/NoopImplementations/NoopAttachmentStorageService.cs b/src/Core/Services/NoopImplementations/NoopAttachmentStorageService.cs index 5b504c90a..c48c546f5 100644 --- a/src/Core/Services/NoopImplementations/NoopAttachmentStorageService.cs +++ b/src/Core/Services/NoopImplementations/NoopAttachmentStorageService.cs @@ -1,16 +1,37 @@ -using System.IO; +using System; +using System.IO; using System.Threading.Tasks; namespace Bit.Core.Services { public class NoopAttachmentStorageService : IAttachmentStorageService { - public Task DeleteAttachmentAsync(string name) + public Task CommitShareAttachmentAsync(Guid cipherId, Guid organizationId, string attachmentId) { return Task.FromResult(0); } - public Task UploadAttachmentAsync(Stream stream, string name) + public Task DeleteAttachmentAsync(Guid cipherId, string attachmentId) + { + return Task.FromResult(0); + } + + public Task RollbackShareAttachmentAsync(Guid cipherId, Guid organizationId, string attachmentId) + { + return Task.FromResult(0); + } + + public Task StartShareAttachmentAsync(Guid cipherId, Guid organizationId, string attachmentId) + { + return Task.FromResult(0); + } + + public Task UploadNewAttachmentAsync(Stream stream, Guid cipherId, string attachmentId) + { + return Task.FromResult(0); + } + + public Task UploadShareAttachmentAsync(Stream stream, Guid cipherId, Guid organizationId, string attachmentId) { return Task.FromResult(0); } diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index ab5f7dab4..a178eec06 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -1,6 +1,7 @@ using Bit.Core.Models.Data; using Bit.Core.Models.Table; using Dapper; +using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Data; @@ -242,5 +243,10 @@ namespace Bit.Core.Utilities // Return formatted number with suffix return readable.ToString("0.## ") + suffix; } + + public static T CloneObject(T obj) + { + return JsonConvert.DeserializeObject(JsonConvert.SerializeObject(obj)); + } } } diff --git a/src/Sql/dbo/Stored Procedures/Cipher_UpdateWithCollections.sql b/src/Sql/dbo/Stored Procedures/Cipher_UpdateWithCollections.sql index 1536918eb..dd3f8c798 100644 --- a/src/Sql/dbo/Stored Procedures/Cipher_UpdateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/Cipher_UpdateWithCollections.sql @@ -14,14 +14,31 @@ AS BEGIN SET NOCOUNT ON + DECLARE @CipherAttachments NVARCHAR(MAX) + SELECT + @CipherAttachments = [Attachments] + FROM + [dbo].[Cipher] + WHERE [Id] = @Id + + DECLARE @Size BIGINT + + SELECT + @Size = SUM(CAST(JSON_VALUE(value,'$.Size') AS BIGINT)) + FROM + OPENJSON(@CipherAttachments) + + DECLARE @SizeDec BIGINT = @Size * -1 + UPDATE [dbo].[Cipher] SET [UserId] = NULL, [OrganizationId] = @OrganizationId, [Data] = @Data, + [Attachments] = @Attachments, [RevisionDate] = @RevisionDate - -- No need to update Attachments, CreationDate, Favorites, Folders, or Type since that data will not change + -- No need to update CreationDate, Favorites, Folders, or Type since that data will not change WHERE [Id] = @Id @@ -66,12 +83,7 @@ BEGIN WHERE [Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) - IF @OrganizationId IS NOT NULL - BEGIN - EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId - END - ELSE IF @UserId IS NOT NULL - BEGIN - EXEC [dbo].[User_BumpAccountRevisionDate] @UserId - END + EXEC [dbo].[Organization_UpdateStorage] @OrganizationId, @Size + EXEC [dbo].[User_UpdateStorage] @UserId, @SizeDec + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId END \ No newline at end of file