From c949b8491fbf19f79784ac7f4c180bd2d5db269d Mon Sep 17 00:00:00 2001 From: Matt Aaron <13080357+matt-aaron@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:52:55 -0400 Subject: [PATCH] Rename migration folders, separate logic based on PR threads --- .../down.sql | 0 .../up.sql | 0 .../down.sql | 0 .../up.sql | 0 src/api/core/ciphers.rs | 105 +++++++++++++----- src/db/models/archive.rs | 76 ++++++------- src/db/models/cipher.rs | 13 +-- 7 files changed, 117 insertions(+), 77 deletions(-) rename migrations/postgresql/{2026-03-09-005927-add_archives => 2026-03-09-005927_add_archives}/down.sql (100%) rename migrations/postgresql/{2026-03-09-005927-add_archives => 2026-03-09-005927_add_archives}/up.sql (100%) rename migrations/sqlite/{2026-03-09-005927-add_archives => 2026-03-09-005927_add_archives}/down.sql (100%) rename migrations/sqlite/{2026-03-09-005927-add_archives => 2026-03-09-005927_add_archives}/up.sql (100%) diff --git a/migrations/postgresql/2026-03-09-005927-add_archives/down.sql b/migrations/postgresql/2026-03-09-005927_add_archives/down.sql similarity index 100% rename from migrations/postgresql/2026-03-09-005927-add_archives/down.sql rename to migrations/postgresql/2026-03-09-005927_add_archives/down.sql diff --git a/migrations/postgresql/2026-03-09-005927-add_archives/up.sql b/migrations/postgresql/2026-03-09-005927_add_archives/up.sql similarity index 100% rename from migrations/postgresql/2026-03-09-005927-add_archives/up.sql rename to migrations/postgresql/2026-03-09-005927_add_archives/up.sql diff --git a/migrations/sqlite/2026-03-09-005927-add_archives/down.sql b/migrations/sqlite/2026-03-09-005927_add_archives/down.sql similarity index 100% rename from migrations/sqlite/2026-03-09-005927-add_archives/down.sql rename to migrations/sqlite/2026-03-09-005927_add_archives/down.sql diff --git a/migrations/sqlite/2026-03-09-005927-add_archives/up.sql b/migrations/sqlite/2026-03-09-005927_add_archives/up.sql similarity index 100% rename from migrations/sqlite/2026-03-09-005927-add_archives/up.sql rename to migrations/sqlite/2026-03-09-005927_add_archives/up.sql diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index e893adbd..935ca7a1 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -538,17 +538,13 @@ pub async fn update_cipher_from_data( cipher.save(conn).await?; cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?; cipher.set_favorite(data.favorite, &headers.user.uuid, conn).await?; - let archived_at = match data.archived_date { - Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") { - Ok(dt) => Some(dt), - Err(err) => { - warn!("Error parsing ArchivedDate '{dt_str}': {err}"); - None - } - }, - None => None, - }; - cipher.set_archived_at(archived_at, &headers.user.uuid, conn).await?; + + if let Some(dt_str) = data.archived_date { + match NaiveDateTime::parse_from_str(&dt_str, "%+") { + Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?, + Err(err) => warn!("Error parsing ArchivedDate '{dt_str}': {err}"), + } + } if ut != UpdateType::None { // Only log events for organizational ciphers @@ -1733,7 +1729,7 @@ async fn purge_personal_vault( #[put("/ciphers//archive")] async fn archive_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - set_archived_cipher_by_uuid(&cipher_id, &headers, true, false, &conn, &nt).await + archive_cipher(&cipher_id, &headers, false, &conn, &nt).await } #[put("/ciphers/archive", data = "")] @@ -1743,12 +1739,12 @@ async fn archive_cipher_selected( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - set_archived_multiple_ciphers(data, &headers, true, &conn, &nt).await + archive_multiple_ciphers(data, &headers, &conn, &nt).await } #[put("/ciphers//unarchive")] async fn unarchive_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - set_archived_cipher_by_uuid(&cipher_id, &headers, false, false, &conn, &nt).await + unarchive_cipher(&cipher_id, &headers, false, &conn, &nt).await } #[put("/ciphers/unarchive", data = "")] @@ -1758,7 +1754,7 @@ async fn unarchive_cipher_selected( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - set_archived_multiple_ciphers(data, &headers, false, &conn, &nt).await + unarchive_multiple_ciphers(data, &headers, &conn, &nt).await } #[derive(PartialEq)] @@ -1979,10 +1975,9 @@ async fn _delete_cipher_attachment_by_id( Ok(Json(json!({"cipher":cipher_json}))) } -async fn set_archived_cipher_by_uuid( +async fn archive_cipher( cipher_id: &CipherId, headers: &Headers, - archived: bool, multi_archive: bool, conn: &DbConn, nt: &Notify<'_>, @@ -1995,12 +1990,7 @@ async fn set_archived_cipher_by_uuid( err!("Cipher is not accessible for the current user") } - let archived_at = if archived { - Some(Utc::now().naive_utc()) - } else { - None - }; - cipher.set_archived_at(archived_at, &headers.user.uuid, conn).await?; + cipher.set_archived_at(Utc::now().naive_utc(), &headers.user.uuid, conn).await?; if !multi_archive { nt.send_cipher_update( @@ -2017,10 +2007,67 @@ async fn set_archived_cipher_by_uuid( Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) } -async fn set_archived_multiple_ciphers( +async fn unarchive_cipher( + cipher_id: &CipherId, + headers: &Headers, + multi_unarchive: bool, + conn: &DbConn, + nt: &Notify<'_>, +) -> JsonResult { + let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await else { + err!("Cipher doesn't exist") + }; + + if !cipher.is_accessible_to_user(&headers.user.uuid, conn).await { + err!("Cipher is not accessible for the current user") + } + + cipher.unarchive(&headers.user.uuid, conn).await?; + + if !multi_unarchive { + nt.send_cipher_update( + UpdateType::SyncCipherUpdate, + &cipher, + &cipher.update_users_revision(conn).await, + &headers.device, + None, + conn, + ) + .await; + } + + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) +} + +async fn archive_multiple_ciphers( + data: Json, + headers: &Headers, + conn: &DbConn, + nt: &Notify<'_>, +) -> JsonResult { + let data = data.into_inner(); + + let mut ciphers: Vec = Vec::new(); + for cipher_id in data.ids { + match archive_cipher(&cipher_id, headers, true, conn, nt).await { + Ok(json) => ciphers.push(json.into_inner()), + err => return err, + } + } + + // Multi archive does not send out a push for each cipher, we need to send a general sync here + nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, conn).await; + + Ok(Json(json!({ + "data": ciphers, + "object": "list", + "continuationToken": null + }))) +} + +async fn unarchive_multiple_ciphers( data: Json, headers: &Headers, - archived: bool, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { @@ -2028,13 +2075,13 @@ async fn set_archived_multiple_ciphers( let mut ciphers: Vec = Vec::new(); for cipher_id in data.ids { - match set_archived_cipher_by_uuid(&cipher_id, headers, archived, true, conn, nt).await { + match unarchive_cipher(&cipher_id, headers, true, conn, nt).await { Ok(json) => ciphers.push(json.into_inner()), err => return err, } } - // Multi archive actions do not send out a push for each cipher, we need to send a general sync here + // Multi unarchive does not send out a push for each cipher, we need to send a general sync here nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, conn).await; Ok(Json(json!({ @@ -2072,7 +2119,7 @@ impl CipherSyncData { let cipher_favorites: HashSet; let cipher_archives: HashMap; match sync_type { - // User Sync supports Folders and Favorites + // User Sync supports Folders, Favorites, and Archives CipherSyncType::User => { // Generate a HashMap with the Cipher UUID as key and the Folder UUID as value cipher_folders = FolderCipher::find_by_user(user_id, conn).await.into_iter().collect(); @@ -2083,7 +2130,7 @@ impl CipherSyncData { // Generate a HashMap with the Cipher UUID as key and the archived date time as value cipher_archives = Archive::find_by_user(user_id, conn).await.into_iter().collect(); } - // Organization Sync does not support Folders and Favorites. + // Organization Sync does not support Folders, Favorites, or Archives. // If these are set, it will cause issues in the web-vault. CipherSyncType::Organization => { cipher_folders = HashMap::with_capacity(0); diff --git a/src/db/models/archive.rs b/src/db/models/archive.rs index eb05ec7e..f576e7ed 100644 --- a/src/db/models/archive.rs +++ b/src/db/models/archive.rs @@ -28,61 +28,55 @@ impl Archive { }} } - // Sets the specified cipher to be archived or unarchived - pub async fn set_archived_at( - archived_at: Option, - cipher_uuid: &CipherId, + // Saves (inserts or updates) an archive record with the provided timestamp + pub async fn save( user_uuid: &UserId, + cipher_uuid: &CipherId, + archived_at: NaiveDateTime, conn: &DbConn, ) -> EmptyResult { - let existing = Self::get_archived_at(cipher_uuid, user_uuid, conn).await; - - match (existing, archived_at) { - // Not archived - archive at the provided timestamp - (None, Some(dt)) => { - User::update_uuid_revision(user_uuid, conn).await; - db_run! { conn: { - diesel::insert_into(archives::table) + User::update_uuid_revision(user_uuid, conn).await; + db_run! { conn: + sqlite, mysql { + diesel::replace_into(archives::table) .values(( archives::user_uuid.eq(user_uuid), archives::cipher_uuid.eq(cipher_uuid), - archives::archived_at.eq(dt), + archives::archived_at.eq(archived_at), )) .execute(conn) - .map_res("Error archiving") - }} + .map_res("Error saving archive") } - // Already archived - update with the provided timestamp - (Some(_), Some(dt)) => { - User::update_uuid_revision(user_uuid, conn).await; - db_run! { conn: { - diesel::update( - archives::table - .filter(archives::user_uuid.eq(user_uuid)) - .filter(archives::cipher_uuid.eq(cipher_uuid)) - ) - .set(archives::archived_at.eq(dt)) - .execute(conn) - .map_res("Error updating archive date") - }} - } - (Some(_), None) => { - User::update_uuid_revision(user_uuid, conn).await; - db_run! { conn: { - diesel::delete( - archives::table - .filter(archives::user_uuid.eq(user_uuid)) - .filter(archives::cipher_uuid.eq(cipher_uuid)) - ) + postgresql { + diesel::insert_into(archives::table) + .values(( + archives::user_uuid.eq(user_uuid), + archives::cipher_uuid.eq(cipher_uuid), + archives::archived_at.eq(archived_at), + )) + .on_conflict((archives::user_uuid, archives::cipher_uuid)) + .do_update() + .set(archives::archived_at.eq(archived_at)) .execute(conn) - .map_res("Error unarchiving") - }} + .map_res("Error saving archive") } - // Otherwise, the archived status is already what it should be - _ => Ok(()), } } + // Deletes an archive record for a specific cipher + pub async fn delete_by_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult { + User::update_uuid_revision(user_uuid, conn).await; + db_run! { conn: { + diesel::delete( + archives::table + .filter(archives::user_uuid.eq(user_uuid)) + .filter(archives::cipher_uuid.eq(cipher_uuid)) + ) + .execute(conn) + .map_res("Error deleting archive") + }} + } + /// Return a vec with (cipher_uuid, archived_at) /// This is used during a full sync so we only need one query for all archive matches pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<(CipherId, NaiveDateTime)> { diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index ff47c268..87f3e415 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -751,13 +751,12 @@ impl Cipher { Archive::get_archived_at(&self.uuid, user_uuid, conn).await } - pub async fn set_archived_at( - &self, - archived_at: Option, - user_uuid: &UserId, - conn: &DbConn, - ) -> EmptyResult { - Archive::set_archived_at(archived_at, &self.uuid, user_uuid, conn).await + pub async fn set_archived_at(&self, archived_at: NaiveDateTime, user_uuid: &UserId, conn: &DbConn) -> EmptyResult { + Archive::save(user_uuid, &self.uuid, archived_at, conn).await + } + + pub async fn unarchive(&self, user_uuid: &UserId, conn: &DbConn) -> EmptyResult { + Archive::delete_by_cipher(user_uuid, &self.uuid, conn).await } pub async fn get_folder_uuid(&self, user_uuid: &UserId, conn: &DbConn) -> Option {