From 5b300de5f46de6c782435c77f43d7b25e420d9a0 Mon Sep 17 00:00:00 2001 From: jaasen-livefront Date: Fri, 14 Nov 2025 18:48:22 -0800 Subject: [PATCH] update to cipher archive --- .../Queries/UserCipherDetailsQuery.cs | 21 ++++- .../Vault/Repositories/CipherRepository.cs | 65 ++++++++++++--- .../Queries/CipherDetailsQuery.cs | 81 ++++++++++++++----- .../Cipher/CipherDetails_ReadByUserId.sql | 4 + ...tails_ReadWithoutOrganizationsByUserId.sql | 3 + .../Cipher/Cipher_Archive.sql | 50 +++++------- .../Cipher/Cipher_Unarchive.sql | 41 +++------- .../Cipher/Cipher_Update.sql | 6 +- src/Sql/dbo/Vault/Tables/CipherArchive.sql | 5 ++ 9 files changed, 179 insertions(+), 97 deletions(-) diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs index b196a07e9b..b76b1fda3c 100644 --- a/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs +++ b/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs @@ -18,6 +18,7 @@ public class UserCipherDetailsQuery : IQuery public virtual IQueryable Run(DatabaseContext dbContext) { + var userId = _userId; var query = from c in dbContext.Ciphers join ou in dbContext.OrganizationUsers @@ -49,7 +50,15 @@ public class UserCipherDetailsQuery : IQuery join cg in dbContext.CollectionGroups on new { cc.CollectionId, gu.GroupId } equals new { cg.CollectionId, cg.GroupId } into cg_g + + join ca in dbContext.CipherArchives + on c.Id equals ca.CipherId + into caGroup + from cg in cg_g.DefaultIfEmpty() + from ca in caGroup + .Where(a => userId.HasValue && a.UserId == userId.Value) + .DefaultIfEmpty() where (cu == null ? (Guid?)null : cu.CollectionId) != null || (cg == null ? (Guid?)null : cg.CollectionId) != null @@ -72,11 +81,19 @@ public class UserCipherDetailsQuery : IQuery OrganizationUseTotp = o.UseTotp, c.Reprompt, c.Key, - c.ArchivedDate + ArchivedDate = (DateTime?)ca.ArchivedDate }; var query2 = from c in dbContext.Ciphers where c.UserId == _userId + + join ca in dbContext.CipherArchives + on c.Id equals ca.CipherId + into caGroup + from ca in caGroup + .Where(a => userId.HasValue && a.UserId == userId.Value) + .DefaultIfEmpty() + select new { c.Id, @@ -96,7 +113,7 @@ public class UserCipherDetailsQuery : IQuery OrganizationUseTotp = false, c.Reprompt, c.Key, - c.ArchivedDate + ArchivedDate = (DateTime?)ca.ArchivedDate }; var union = query.Union(query2).Select(c => new CipherDetails diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index 3c45afe530..6e45906197 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -786,7 +786,7 @@ public class CipherRepository : Repository ucd.ArchivedDate != null, CipherStateAction.Archive => ucd.ArchivedDate == null, - _ => true + _ => true, }; } @@ -794,21 +794,65 @@ public class CipherRepository : Repository ids.Contains(c.Id)).ToListAsync(); - var query = from ucd in await userCipherDetailsQuery.Run(dbContext).ToListAsync() - join c in cipherEntitiesToCheck - on ucd.Id equals c.Id - where ucd.Edit && FilterArchivedDate(action, ucd) - select c; + + var userCipherDetails = await userCipherDetailsQuery + .Run(dbContext) + .Where(ucd => ids.Contains(ucd.Id) && ucd.Edit) + .ToListAsync(); var utcNow = DateTime.UtcNow; - var cipherIdsToModify = query.Select(c => c.Id); - var cipherEntitiesToModify = dbContext.Ciphers.Where(x => cipherIdsToModify.Contains(x.Id)); + var cipherIdsToModify = userCipherDetails + .Where(ucd => FilterArchivedDate(action, ucd)) + .Select(ucd => ucd.Id) + .Distinct() + .ToList(); + + if (!cipherIdsToModify.Any()) + { + return utcNow; + } + + if (action == CipherStateAction.Archive) + { + var existingArchiveCipherIds = await dbContext.CipherArchives + .Where(ca => ca.UserId == userId && cipherIdsToModify.Contains(ca.CipherId)) + .Select(ca => ca.CipherId) + .ToListAsync(); + + var cipherIdsToArchive = cipherIdsToModify + .Except(existingArchiveCipherIds) + .ToList(); + + if (cipherIdsToArchive.Any()) + { + var archives = cipherIdsToArchive.Select(id => new CipherArchive + { + CipherId = id, + UserId = userId, + ArchivedDate = utcNow, + }); + + await dbContext.CipherArchives.AddRangeAsync(archives); + } + } + else if (action == CipherStateAction.Unarchive) + { + var archivesToRemove = await dbContext.CipherArchives + .Where(ca => ca.UserId == userId && cipherIdsToModify.Contains(ca.CipherId)) + .ToListAsync(); + + if (archivesToRemove.Count > 0) + { + dbContext.CipherArchives.RemoveRange(archivesToRemove); + } + } + + // Keep the behavior that archive/unarchive "touches" the cipher row for sync. + var cipherEntitiesToModify = dbContext.Ciphers.Where(c => cipherIdsToModify.Contains(c.Id)); await cipherEntitiesToModify.ForEachAsync(cipher => { dbContext.Attach(cipher); - cipher.ArchivedDate = action == CipherStateAction.Unarchive ? null : utcNow; cipher.RevisionDate = utcNow; }); @@ -819,6 +863,7 @@ public class CipherRepository : Repository ToggleDeleteCipherStatesAsync(IEnumerable ids, Guid userId, CipherStateAction action) { static bool FilterDeletedDate(CipherStateAction action, CipherDetails ucd) diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherDetailsQuery.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherDetailsQuery.cs index 880ee77854..9bdab214d7 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherDetailsQuery.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherDetailsQuery.cs @@ -9,33 +9,72 @@ public class CipherDetailsQuery : IQuery { private readonly Guid? _userId; private readonly bool _ignoreFolders; + public CipherDetailsQuery(Guid? userId, bool ignoreFolders = false) { _userId = userId; _ignoreFolders = ignoreFolders; } + public virtual IQueryable Run(DatabaseContext dbContext) { - var query = from c in dbContext.Ciphers - select new CipherDetails - { - Id = c.Id, - UserId = c.UserId, - OrganizationId = c.OrganizationId, - Type = c.Type, - Data = c.Data, - Attachments = c.Attachments, - CreationDate = c.CreationDate, - RevisionDate = c.RevisionDate, - DeletedDate = c.DeletedDate, - Reprompt = c.Reprompt, - Key = c.Key, - Favorite = _userId.HasValue && c.Favorites != null && c.Favorites.ToLowerInvariant().Contains($"\"{_userId}\":true"), - FolderId = (_ignoreFolders || !_userId.HasValue || c.Folders == null || !c.Folders.ToLowerInvariant().Contains(_userId.Value.ToString())) ? - null : - CoreHelpers.LoadClassFromJsonData>(c.Folders)[_userId.Value], - ArchivedDate = c.ArchivedDate, - }; - return query; + // No user context: we can’t resolve per-user favorites/folders/archive. + if (!_userId.HasValue) + { + var query = from c in dbContext.Ciphers + select new CipherDetails + { + Id = c.Id, + UserId = c.UserId, + OrganizationId = c.OrganizationId, + Type = c.Type, + Data = c.Data, + Attachments = c.Attachments, + CreationDate = c.CreationDate, + RevisionDate = c.RevisionDate, + DeletedDate = c.DeletedDate, + Reprompt = c.Reprompt, + Key = c.Key, + Favorite = false, + FolderId = null, + ArchivedDate = null, + }; + + return query; + } + + var userId = _userId.Value; + + var queryWithArchive = + from c in dbContext.Ciphers + join ca in dbContext.CipherArchives + on new { CipherId = c.Id, UserId = userId } + equals new { CipherId = ca.CipherId, ca.UserId } + into caGroup + from ca in caGroup.DefaultIfEmpty() + select new CipherDetails + { + Id = c.Id, + UserId = c.UserId, + OrganizationId = c.OrganizationId, + Type = c.Type, + Data = c.Data, + Attachments = c.Attachments, + CreationDate = c.CreationDate, + RevisionDate = c.RevisionDate, + DeletedDate = c.DeletedDate, + Reprompt = c.Reprompt, + Key = c.Key, + Favorite = c.Favorites != null && + c.Favorites.ToLowerInvariant().Contains($"\"{userId}\":true"), + FolderId = (_ignoreFolders || + c.Folders == null || + !c.Folders.ToLowerInvariant().Contains(userId.ToString())) + ? null + : CoreHelpers.LoadClassFromJsonData>(c.Folders)[userId], + ArchivedDate = ca.ArchivedDate, + }; + + return queryWithArchive; } } diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByUserId.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByUserId.sql index 00f265b3e7..3e793ce24b 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByUserId.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByUserId.sql @@ -8,4 +8,8 @@ BEGIN * FROM [dbo].[UserCipherDetails](@UserId) + LEFT JOIN [dbo].[CipherArchive] ca + ON ca.CipherId = c.Id + AND ca.UserId = @UserId + WHERE END \ No newline at end of file diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadWithoutOrganizationsByUserId.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadWithoutOrganizationsByUserId.sql index 170fdc895d..215f1d5a0a 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadWithoutOrganizationsByUserId.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadWithoutOrganizationsByUserId.sql @@ -12,6 +12,9 @@ BEGIN 0 [OrganizationUseTotp] FROM [dbo].[CipherDetails](@UserId) + LEFT JOIN [dbo].[CipherArchive] ca + ON ca.CipherId = c.Id + AND ca.UserId = @UserId WHERE [UserId] = @UserId END \ No newline at end of file diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Archive.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Archive.sql index 68f11c0d4f..ed2dafba4d 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Archive.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Archive.sql @@ -1,39 +1,31 @@ CREATE PROCEDURE [dbo].[Cipher_Archive] - @Ids AS [dbo].[GuidIdArray] READONLY, - @UserId AS UNIQUEIDENTIFIER + @Ids dbo.GuidIdArray READONLY, + @UserId UNIQUEIDENTIFIER AS BEGIN - SET NOCOUNT ON - - CREATE TABLE #Temp - ( - [Id] UNIQUEIDENTIFIER NOT NULL, - [UserId] UNIQUEIDENTIFIER NULL - ) - - INSERT INTO #Temp - SELECT - [Id], - [UserId] - FROM - [dbo].[UserCipherDetails](@UserId) - WHERE - [Edit] = 1 - AND [ArchivedDate] IS NULL - AND [Id] IN (SELECT * FROM @Ids) + SET NOCOUNT ON; DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME(); - UPDATE - [dbo].[Cipher] - SET - [ArchivedDate] = @UtcNow, - [RevisionDate] = @UtcNow - WHERE - [Id] IN (SELECT [Id] FROM #Temp) + + WITH CipherIdsToArchive AS + ( + SELECT DISTINCT C.Id + FROM [dbo].[Cipher] C + INNER JOIN @Ids I ON C.Id = I.[Id] + WHERE (C.[UserId] = @UserId) + ) + INSERT INTO [dbo].[CipherArchive] (CipherId, UserId, ArchivedDate) + SELECT Cta.Id, @UserId, @UtcNow + FROM CipherIdsToArchive Cta + WHERE NOT EXISTS + ( + SELECT 1 + FROM [dbo].[CipherArchive] Ca + WHERE Ca.CipherId = Cta.Id + AND Ca.UserId = @UserId + ); EXEC [dbo].[User_BumpAccountRevisionDate] @UserId - DROP TABLE #Temp - SELECT @UtcNow END diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql index c2b7b10619..4b9f30fdfa 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql @@ -1,39 +1,18 @@ CREATE PROCEDURE [dbo].[Cipher_Unarchive] - @Ids AS [dbo].[GuidIdArray] READONLY, - @UserId AS UNIQUEIDENTIFIER + @Ids dbo.GuidIdArray READONLY, + @UserId UNIQUEIDENTIFIER AS BEGIN - SET NOCOUNT ON - - CREATE TABLE #Temp - ( - [Id] UNIQUEIDENTIFIER NOT NULL, - [UserId] UNIQUEIDENTIFIER NULL - ) - - INSERT INTO #Temp - SELECT - [Id], - [UserId] - FROM - [dbo].[UserCipherDetails](@UserId) - WHERE - [Edit] = 1 - AND [ArchivedDate] IS NOT NULL - AND [Id] IN (SELECT * FROM @Ids) + SET NOCOUNT ON; DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME(); - UPDATE - [dbo].[Cipher] - SET - [ArchivedDate] = NULL, - [RevisionDate] = @UtcNow - WHERE - [Id] IN (SELECT [Id] FROM #Temp) + + DELETE Ca + FROM [dbo].[CipherArchive] Ca + INNER JOIN @Ids I ON Ca.CipherId = I.[Id] + WHERE Ca.UserId = @UserId EXEC [dbo].[User_BumpAccountRevisionDate] @UserId - DROP TABLE #Temp - - SELECT @UtcNow -END + SELECT @UtcNow; +END \ No newline at end of file diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql index 912badc906..8baf1b5f0f 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql @@ -11,8 +11,7 @@ @RevisionDate DATETIME2(7), @DeletedDate DATETIME2(7), @Reprompt TINYINT, - @Key VARCHAR(MAX) = NULL, - @ArchivedDate DATETIME2(7) = NULL + @Key VARCHAR(MAX) = NULL AS BEGIN SET NOCOUNT ON @@ -31,8 +30,7 @@ BEGIN [RevisionDate] = @RevisionDate, [DeletedDate] = @DeletedDate, [Reprompt] = @Reprompt, - [Key] = @Key, - [ArchivedDate] = @ArchivedDate + [Key] = @Key WHERE [Id] = @Id diff --git a/src/Sql/dbo/Vault/Tables/CipherArchive.sql b/src/Sql/dbo/Vault/Tables/CipherArchive.sql index aa41f224c2..c3e2c12476 100644 --- a/src/Sql/dbo/Vault/Tables/CipherArchive.sql +++ b/src/Sql/dbo/Vault/Tables/CipherArchive.sql @@ -22,6 +22,11 @@ ADD CONSTRAINT [FK_CipherArchive_User] ON DELETE CASCADE; GO +ALTER TABLE [dbo].[CipherArchive] +ADD CONSTRAINT [UX_CipherArchive_CipherId_UserId] + UNIQUE (CipherId, UserId); +GO + CREATE NONCLUSTERED INDEX [IX_CipherArchive_UserId] ON [dbo].[CipherArchive]([UserId]); GO