diff --git a/src/Api/Controllers/CiphersController.cs b/src/Api/Controllers/CiphersController.cs index c7264f4da7..752ed628bf 100644 --- a/src/Api/Controllers/CiphersController.cs +++ b/src/Api/Controllers/CiphersController.cs @@ -9,6 +9,8 @@ using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core; using Bit.Api.Utilities; +using Bit.Core.Models.Data; +using Newtonsoft.Json; namespace Bit.Api.Controllers { @@ -219,13 +221,10 @@ namespace Bit.Api.Controllers string.IsNullOrWhiteSpace(model.FolderId) ? (Guid?)null : new Guid(model.FolderId), userId); } - [HttpPost("attachment")] + [HttpPost("{id}/attachment")] [DisableFormValueModelBinding] - public async Task Post(string id) + public async Task PostAttachment(string id) { - // throw for now - throw new NotImplementedException(); - if(!Request?.ContentType.Contains("multipart/") ?? true) { throw new BadRequestException("Invalid content."); @@ -239,22 +238,16 @@ namespace Bit.Api.Controllers throw new NotFoundException(); } - await Request.GetFilesAsync(async (stream, fileName) => + await Request.GetFileAsync(async (stream, fileName) => { - var attachmentId = Guid.NewGuid(); - // TODO: store attachmentId + fileName reference in database - var storedFilename = $"{idGuid}_{attachmentId}"; - await _attachmentStorageService.UploadAttachmentAsync(stream, storedFilename); + await _cipherService.AttachAsync(cipher, stream, fileName, Request.ContentLength.GetValueOrDefault(0), userId); }); } [HttpDelete("{id}/attachment/{attachmentId}")] [HttpPost("{id}/attachment/{attachmentId}/delete")] - public async Task Delete(string id, string attachmentId) + public async Task DeleteAttachment(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); @@ -263,11 +256,9 @@ namespace Bit.Api.Controllers throw new NotFoundException(); } - var attachmentIdGuid = new Guid(attachmentId); - // TODO: check and remove attachmentId from cipher in database - var storedFilename = $"{idGuid}_{attachmentId}"; + var storedFilename = $"{idGuid}/{attachmentId}"; await _attachmentStorageService.DeleteAttachmentAsync(storedFilename); } } diff --git a/src/Api/Utilities/MultipartFormDataHelper.cs b/src/Api/Utilities/MultipartFormDataHelper.cs index c7cef37041..20b55db09d 100644 --- a/src/Api/Utilities/MultipartFormDataHelper.cs +++ b/src/Api/Utilities/MultipartFormDataHelper.cs @@ -12,23 +12,38 @@ namespace Bit.Api.Utilities { private static readonly FormOptions _defaultFormOptions = new FormOptions(); - public static async Task GetFilesAsync(this HttpRequest request, Func callback) + public static async Task GetFileAsync(this HttpRequest request, Func callback) + { + await request.GetFilesAsync(1, callback); + } + + private static async Task GetFilesAsync(this HttpRequest request, int? fileCount, 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) + var fileNumber = 1; + while(section != null && fileNumber <= fileCount) { ContentDispositionHeaderValue content; if(ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out content) && HasFileContentDisposition(content)) { - await callback(section.Body, HeaderUtilities.RemoveQuotes(content.FileName)); + var fileName = HeaderUtilities.RemoveQuotes(content.FileName) ?? string.Empty; + await callback(section.Body, fileName); } - section = await reader.ReadNextSectionAsync(); + if(fileNumber >= fileCount) + { + section = null; + } + else + { + section = await reader.ReadNextSectionAsync(); + fileNumber++; + } } } diff --git a/src/Core/Models/Data/Attachment.cs b/src/Core/Models/Data/Attachment.cs new file mode 100644 index 0000000000..17c7283280 --- /dev/null +++ b/src/Core/Models/Data/Attachment.cs @@ -0,0 +1,19 @@ +using System; + +namespace Bit.Core.Models.Data +{ + public class CipherAttachment + { + public Guid Id { get; set; } + public Guid? UserId { get; set; } + public Guid? OrganizationId { get; set; } + public string AttachmentId { get; set; } + public string AttachmentData { get; set; } + + public class MetaData + { + public long Size { get; set; } + public string FileName { get; set; } + } + } +} diff --git a/src/Core/Models/Table/Cipher.cs b/src/Core/Models/Table/Cipher.cs index 868cd1a413..ffbb59e786 100644 --- a/src/Core/Models/Table/Cipher.cs +++ b/src/Core/Models/Table/Cipher.cs @@ -12,6 +12,7 @@ namespace Bit.Core.Models.Table public string Data { get; set; } public string Favorites { get; set; } public string Folders { get; set; } + public string Attachments { get; set; } public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; diff --git a/src/Core/Repositories/ICipherRepository.cs b/src/Core/Repositories/ICipherRepository.cs index 07b82c76ce..8a06e159b9 100644 --- a/src/Core/Repositories/ICipherRepository.cs +++ b/src/Core/Repositories/ICipherRepository.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Bit.Core.Models.Table; using Core.Models.Data; +using Bit.Core.Models.Data; namespace Bit.Core.Repositories { @@ -19,6 +20,7 @@ namespace Bit.Core.Repositories Task UpsertAsync(CipherDetails cipher); Task ReplaceAsync(Cipher obj, IEnumerable collectionIds); Task UpdatePartialAsync(Guid id, Guid userId, Guid? folderId, bool favorite); + Task UpdateAttachmentAsync(CipherAttachment attachment); Task DeleteAsync(IEnumerable ids, Guid userId); Task MoveAsync(IEnumerable ids, Guid? folderId, Guid userId); Task UpdateUserKeysAndCiphersAsync(User user, IEnumerable ciphers, IEnumerable folders); diff --git a/src/Core/Repositories/SqlServer/CipherRepository.cs b/src/Core/Repositories/SqlServer/CipherRepository.cs index 97f4ed5762..dbefc1182d 100644 --- a/src/Core/Repositories/SqlServer/CipherRepository.cs +++ b/src/Core/Repositories/SqlServer/CipherRepository.cs @@ -9,6 +9,7 @@ using Dapper; using Core.Models.Data; using Bit.Core.Utilities; using Newtonsoft.Json; +using Bit.Core.Models.Data; namespace Bit.Core.Repositories.SqlServer { @@ -176,6 +177,17 @@ namespace Bit.Core.Repositories.SqlServer } } + public async Task UpdateAttachmentAsync(CipherAttachment attachment) + { + using(var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.ExecuteAsync( + $"[{Schema}].[Cipher_UpdateAttachment]", + attachment, + commandType: CommandType.StoredProcedure); + } + } + public async Task DeleteAsync(IEnumerable ids, Guid userId) { using(var connection = new SqlConnection(ConnectionString)) diff --git a/src/Core/Services/ICipherService.cs b/src/Core/Services/ICipherService.cs index 9c59e23378..5b02af2608 100644 --- a/src/Core/Services/ICipherService.cs +++ b/src/Core/Services/ICipherService.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Bit.Core.Models.Table; using Core.Models.Data; using System; +using System.IO; namespace Bit.Core.Services { @@ -10,6 +11,8 @@ namespace Bit.Core.Services { Task SaveAsync(Cipher cipher, Guid savingUserId, bool orgAdmin = false); Task SaveDetailsAsync(CipherDetails cipher, Guid savingUserId); + Task AttachAsync(Cipher cipher, Stream stream, string fileName, long requestLength, Guid savingUserId, + bool orgAdmin = false); Task DeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false); Task DeleteManyAsync(IEnumerable cipherIds, Guid deletingUserId); Task MoveManyAsync(IEnumerable cipherIds, Guid? destinationFolderId, Guid movingUserId); diff --git a/src/Core/Services/Implementations/CipherService.cs b/src/Core/Services/Implementations/CipherService.cs index 1510dc5112..bdf3a8d6c9 100644 --- a/src/Core/Services/Implementations/CipherService.cs +++ b/src/Core/Services/Implementations/CipherService.cs @@ -6,6 +6,9 @@ using Bit.Core.Models.Table; using Bit.Core.Repositories; using Core.Models.Data; using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Newtonsoft.Json; +using System.IO; namespace Bit.Core.Services { @@ -18,6 +21,7 @@ namespace Bit.Core.Services private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ICollectionCipherRepository _collectionCipherRepository; private readonly IPushNotificationService _pushService; + private readonly IAttachmentStorageService _attachmentStorageService; public CipherService( ICipherRepository cipherRepository, @@ -26,7 +30,8 @@ namespace Bit.Core.Services IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, ICollectionCipherRepository collectionCipherRepository, - IPushNotificationService pushService) + IPushNotificationService pushService, + IAttachmentStorageService attachmentStorageService) { _cipherRepository = cipherRepository; _folderRepository = folderRepository; @@ -35,6 +40,7 @@ namespace Bit.Core.Services _organizationUserRepository = organizationUserRepository; _collectionCipherRepository = collectionCipherRepository; _pushService = pushService; + _attachmentStorageService = attachmentStorageService; } public async Task SaveAsync(Cipher cipher, Guid savingUserId, bool orgAdmin = false) @@ -86,6 +92,45 @@ namespace Bit.Core.Services } } + public async Task AttachAsync(Cipher cipher, Stream stream, string fileName, long requestLength, + Guid savingUserId, bool orgAdmin = false) + { + if(!orgAdmin && !(await UserCanEditAsync(cipher, savingUserId))) + { + throw new BadRequestException("You do not have permissions to edit this."); + } + + if(requestLength < 1) + { + throw new BadRequestException("No data."); + } + + // TODO: check available space against requestLength + + var attachmentId = Utilities.CoreHelpers.SecureRandomString(32, upper: false, special: false); + await _attachmentStorageService.UploadAttachmentAsync(stream, $"{cipher.Id}/{attachmentId}"); + + var data = new CipherAttachment.MetaData + { + FileName = fileName, + Size = stream.Length + }; + + var attachment = new CipherAttachment + { + Id = cipher.Id, + UserId = cipher.UserId, + OrganizationId = cipher.OrganizationId, + AttachmentId = attachmentId, + AttachmentData = JsonConvert.SerializeObject(data) + }; + + await _cipherRepository.UpdateAttachmentAsync(attachment); + + // push + await _pushService.PushSyncCipherUpdateAsync(cipher); + } + public async Task DeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false) { if(!orgAdmin && !(await UserCanEditAsync(cipher, deletingUserId))) diff --git a/src/Core/Utilities/ServiceCollectionExtensions.cs b/src/Core/Utilities/ServiceCollectionExtensions.cs index 0d15ec7a48..a063428a0c 100644 --- a/src/Core/Utilities/ServiceCollectionExtensions.cs +++ b/src/Core/Utilities/ServiceCollectionExtensions.cs @@ -53,8 +53,7 @@ namespace Bit.Core.Utilities services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - // noop for now - services.AddSingleton(); + services.AddSingleton(); } public static void AddNoopServices(this IServiceCollection services) diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index 645682a1f6..c1f3e8537f 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -200,5 +200,6 @@ + \ No newline at end of file diff --git a/src/Sql/dbo/Functions/CipherDetails.sql b/src/Sql/dbo/Functions/CipherDetails.sql index 098cf121e3..0f131475a3 100644 --- a/src/Sql/dbo/Functions/CipherDetails.sql +++ b/src/Sql/dbo/Functions/CipherDetails.sql @@ -7,6 +7,7 @@ SELECT C.[OrganizationId], C.[Type], C.[Data], + C.[Attachments], C.[CreationDate], C.[RevisionDate], CASE WHEN diff --git a/src/Sql/dbo/Stored Procedures/CipherDetails_Create.sql b/src/Sql/dbo/Stored Procedures/CipherDetails_Create.sql index d283768699..747453032f 100644 --- a/src/Sql/dbo/Stored Procedures/CipherDetails_Create.sql +++ b/src/Sql/dbo/Stored Procedures/CipherDetails_Create.sql @@ -6,6 +6,7 @@ @Data NVARCHAR(MAX), @Favorites NVARCHAR(MAX), -- not used @Folders NVARCHAR(MAX), -- not used + @Attachments NVARCHAR(MAX), -- not used @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7), @FolderId UNIQUEIDENTIFIER, diff --git a/src/Sql/dbo/Stored Procedures/CipherDetails_Update.sql b/src/Sql/dbo/Stored Procedures/CipherDetails_Update.sql index d0ec6c9977..b0c09bc13e 100644 --- a/src/Sql/dbo/Stored Procedures/CipherDetails_Update.sql +++ b/src/Sql/dbo/Stored Procedures/CipherDetails_Update.sql @@ -6,6 +6,7 @@ @Data NVARCHAR(MAX), @Favorites NVARCHAR(MAX), -- not used @Folders NVARCHAR(MAX), -- not used + @Attachments NVARCHAR(MAX), -- not used @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7), @FolderId UNIQUEIDENTIFIER, diff --git a/src/Sql/dbo/Stored Procedures/Cipher_Create.sql b/src/Sql/dbo/Stored Procedures/Cipher_Create.sql index 3dbf4a87e8..83a044cecd 100644 --- a/src/Sql/dbo/Stored Procedures/Cipher_Create.sql +++ b/src/Sql/dbo/Stored Procedures/Cipher_Create.sql @@ -6,6 +6,7 @@ @Data NVARCHAR(MAX), @Favorites NVARCHAR(MAX), @Folders NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -21,6 +22,7 @@ BEGIN [Data], [Favorites], [Folders], + [Attachments], [CreationDate], [RevisionDate] ) @@ -33,6 +35,7 @@ BEGIN @Data, @Favorites, @Folders, + @Attachments, @CreationDate, @RevisionDate ) diff --git a/src/Sql/dbo/Stored Procedures/Cipher_Update.sql b/src/Sql/dbo/Stored Procedures/Cipher_Update.sql index bbc39fb41d..32d08b7d26 100644 --- a/src/Sql/dbo/Stored Procedures/Cipher_Update.sql +++ b/src/Sql/dbo/Stored Procedures/Cipher_Update.sql @@ -6,6 +6,7 @@ @Data NVARCHAR(MAX), @Favorites NVARCHAR(MAX), @Folders NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7) AS @@ -21,6 +22,7 @@ BEGIN [Data] = @Data, [Favorites] = @Favorites, [Folders] = @Folders, + [Attachments] = @Attachments, [CreationDate] = @CreationDate, [RevisionDate] = @RevisionDate WHERE diff --git a/src/Sql/dbo/Stored Procedures/Cipher_UpdateAttachment.sql b/src/Sql/dbo/Stored Procedures/Cipher_UpdateAttachment.sql new file mode 100644 index 0000000000..9def1e5175 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Cipher_UpdateAttachment.sql @@ -0,0 +1,35 @@ +CREATE PROCEDURE [dbo].[Cipher_UpdateAttachment] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @AttachmentId VARCHAR(50), + @AttachmentData NVARCHAR(MAX) +AS +BEGIN + SET NOCOUNT ON + + DECLARE @AttachmentIdKey VARCHAR(50) = CONCAT('"', @AttachmentId, '"') + DECLARE @AttachmentIdPath VARCHAR(50) = CONCAT('$.', @AttachmentIdKey) + + UPDATE + [dbo].[Cipher] + SET + [Attachments] = + CASE + WHEN [Attachments] IS NULL THEN + CONCAT('{', @AttachmentIdKey, ':', @AttachmentData, '}') + ELSE + JSON_MODIFY([Attachments], @AttachmentIdPath, JSON_QUERY(@AttachmentData, '$')) + END + WHERE + [Id] = @Id + + 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 +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Cipher_UpdateWithCollections.sql b/src/Sql/dbo/Stored Procedures/Cipher_UpdateWithCollections.sql index 3d552149b9..1536918eb9 100644 --- a/src/Sql/dbo/Stored Procedures/Cipher_UpdateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/Cipher_UpdateWithCollections.sql @@ -6,6 +6,7 @@ @Data NVARCHAR(MAX), @Favorites NVARCHAR(MAX), @Folders NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), @CreationDate DATETIME2(7), @RevisionDate DATETIME2(7), @CollectionIds AS [dbo].[GuidIdArray] READONLY @@ -20,7 +21,7 @@ BEGIN [OrganizationId] = @OrganizationId, [Data] = @Data, [RevisionDate] = @RevisionDate - -- No need to update CreationDate, Favorites, Folders, or Type since that data will not change + -- No need to update Attachments, CreationDate, Favorites, Folders, or Type since that data will not change WHERE [Id] = @Id diff --git a/src/Sql/dbo/Tables/Cipher.sql b/src/Sql/dbo/Tables/Cipher.sql index 7a0e214965..93743579f5 100644 --- a/src/Sql/dbo/Tables/Cipher.sql +++ b/src/Sql/dbo/Tables/Cipher.sql @@ -6,6 +6,7 @@ [Data] NVARCHAR (MAX) NOT NULL, [Favorites] NVARCHAR (MAX) NULL, [Folders] NVARCHAR (MAX) NULL, + [Attachments] NVARCHAR (MAX) NULL, [CreationDate] DATETIME2 (7) NOT NULL, [RevisionDate] DATETIME2 (7) NOT NULL, CONSTRAINT [PK_Cipher] PRIMARY KEY CLUSTERED ([Id] ASC),