From 37a009fc906c0c8a497a0db57a484a40af45b361 Mon Sep 17 00:00:00 2001 From: D3SOX Date: Sun, 6 Sep 2026 14:44:59 +0200 Subject: [PATCH 1/2] fix: allow legacy sync migration without playback speeds --- README.md | 4 +- src/handlers/encrypted_sync.rs | 123 +++++++++++++++++++++++++++++++-- 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7f56ec5..4e25db4 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,9 @@ readable data. The dedicated `/v1/channel_playback_speeds` endpoints and encrypted `playbackSpeeds` collection are deprecated. Current OpenTubeX clients store all saved channel preferences, including playback speeds, in the encrypted -`settings` collection. +`settings` collection. Current clients still read existing `playbackSpeeds` data +to migrate it into `settings`, but no longer upload the deprecated collection. +Its absence does not mark legacy encrypted migration as incomplete. The dedicated plaintext endpoints will be removed on 1 October 2026. Until then, their responses include the standard `Deprecation` and `Sunset` headers. diff --git a/src/handlers/encrypted_sync.rs b/src/handlers/encrypted_sync.rs index bb98e6b..24eff42 100644 --- a/src/handlers/encrypted_sync.rs +++ b/src/handlers/encrypted_sync.rs @@ -17,13 +17,12 @@ use crate::{WebData, get_db_conn}; const MEBIBYTE: usize = 1024 * 1024; const MAX_ENCRYPTED_SYNC_BYTES: usize = 64 * MEBIBYTE; const MAX_ENCRYPTED_SYNC_ACCOUNT_BYTES: usize = 128 * MEBIBYTE; -// `playbackSpeeds` is deprecated for new clients, but remains part of legacy -// document migration until older OpenTubeX versions have been phased out. -const LEGACY_ENCRYPTED_COLLECTIONS: [&str; 6] = [ +// Deprecated playbackSpeeds is read into settings by current clients and must +// not be required to finish legacy document migration. +const LEGACY_ENCRYPTED_COLLECTIONS: [&str; 5] = [ "subscriptions", "playlists", "history", - "playbackSpeeds", "profiles", "playlistBookmarks", ]; @@ -215,6 +214,7 @@ mod tests { fn encrypted_collection_limits_are_scoped_by_data_type() { assert_eq!(collection_limit("settings").unwrap(), 2 * MEBIBYTE); assert_eq!(collection_limit("profiles").unwrap(), 8 * MEBIBYTE); + assert_eq!(collection_limit("playbackSpeeds").unwrap(), 8 * MEBIBYTE); assert_eq!(collection_limit("sessions").unwrap(), 8 * MEBIBYTE); assert_eq!(collection_limit("sessionsV2").unwrap(), 8 * MEBIBYTE); assert_eq!(collection_limit("subscriptions").unwrap(), 16 * MEBIBYTE); @@ -222,3 +222,118 @@ mod tests { assert!(collection_limit("unknown").is_err()); } } + +#[cfg(all(test, feature = "sqlite"))] +mod migration_tests { + use actix_web::{App, HttpMessage, test, web}; + use diesel::connection::SimpleConnection; + use diesel_async::RunQueryDsl; + use diesel_async::pooled_connection::{AsyncDieselConnectionManager, bb8::Pool}; + use diesel_migrations::MigrationHarness; + + use crate::{DbConnection, MIGRATIONS, models::Account}; + + #[actix_rt::test] + async fn deleting_migrated_playback_speeds_does_not_restart_legacy_migration() { + let pool = Pool::builder() + .max_size(1) + .build(AsyncDieselConnectionManager::::new( + ":memory:", + )) + .await + .unwrap(); + let account = Account { + id: "owner".into(), + name_hash: "owner-hash".into(), + password_hash: None, + oidc_sub: None, + legacy_tokens_enabled: false, + session_generation: 0, + }; + { + let mut conn = pool.get().await.unwrap(); + conn.spawn_blocking(|conn| { + conn.run_pending_migrations(MIGRATIONS).unwrap(); + conn.batch_execute( + "INSERT INTO account (id, name_hash) VALUES ('owner', 'owner-hash'); + INSERT INTO encrypted_sync_single_document (account_id, revision, payload) + VALUES ('owner', 1, 'legacy-ciphertext');", + )?; + Ok(()) + }) + .await + .unwrap(); + } + let app = test::init_service( + App::new().app_data(web::Data::new(pool.clone())).service( + web::scope("/sync") + .service(super::get_encrypted_sync_manifest) + .service(super::get_legacy_encrypted_sync) + .service(super::get_encrypted_sync_collection) + .service(super::put_encrypted_sync_collection), + ), + ) + .await; + // Incomplete migrations must still expose the original document. + let request = test::TestRequest::get().uri("/sync").to_request(); + request.extensions_mut().insert(account.clone()); + let manifest: serde_json::Value = test::call_and_read_body_json(&app, request).await; + assert_eq!(manifest["legacy_encrypted_data"], true); + + // Use the actual PUT endpoint, including deprecated collection support. + for collection in [ + "subscriptions", + "playlists", + "history", + "profiles", + "playlistBookmarks", + "settings", + "playbackSpeeds", + ] { + let request = test::TestRequest::put() + .uri(&format!("/sync/{collection}")) + .set_json(serde_json::json!({ "revision": 0, "payload": "ciphertext" })) + .to_request(); + request.extensions_mut().insert(account.clone()); + assert!( + test::call_service(&app, request) + .await + .status() + .is_success() + ); + } + for deleted in [false, true] { + if deleted { + let mut conn = pool.get().await.unwrap(); + diesel::sql_query("DELETE FROM encrypted_sync WHERE account_id = 'owner' AND collection = 'playbackSpeeds'") + .execute(&mut conn).await.unwrap(); + } + let request = test::TestRequest::get().uri("/sync").to_request(); + request.extensions_mut().insert(account.clone()); + let manifest: serde_json::Value = test::call_and_read_body_json(&app, request).await; + assert_eq!(manifest["legacy_data"], false); + assert_eq!(manifest["legacy_encrypted_data"], false); + assert_eq!( + manifest["collections"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["collection"] == "playbackSpeeds"), + !deleted + ); + } + let request = test::TestRequest::get() + .uri("/sync/playbackSpeeds") + .to_request(); + request.extensions_mut().insert(account.clone()); + let collection: serde_json::Value = test::call_and_read_body_json(&app, request).await; + assert_eq!(collection["revision"], 0); + assert!(collection["payload"].is_null()); + + // The legacy document stays readable for older clients. + let request = test::TestRequest::get().uri("/sync/legacy").to_request(); + request.extensions_mut().insert(account); + let legacy: serde_json::Value = test::call_and_read_body_json(&app, request).await; + assert_eq!(legacy["payload"], "legacy-ciphertext"); + } +} From 77e7ed1efdd11494da7f4ef77aad2601bd0de346 Mon Sep 17 00:00:00 2001 From: D3SOX Date: Sun, 6 Sep 2026 14:58:44 +0200 Subject: [PATCH 2/2] fix: preserve unfinished playback-speed migrations for older clients --- README.md | 8 +++++++- src/handlers/encrypted_sync.rs | 36 ++++++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4e25db4..65d6682 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,13 @@ The dedicated `/v1/channel_playback_speeds` endpoints and encrypted saved channel preferences, including playback speeds, in the encrypted `settings` collection. Current clients still read existing `playbackSpeeds` data to migrate it into `settings`, but no longer upload the deprecated collection. -Its absence does not mark legacy encrypted migration as incomplete. +After successfully syncing speeds into `settings`, current clients acknowledge +this with `GET /v1/encrypted_sync?playback_speeds_in_settings=true`. For those +requests, the deprecated collection is no longer required for migration completion. +Requests without this acknowledgment retain the older completion rule so an +interrupted migration can still discover speeds in the original encrypted document. +The presence of opaque `settings` alone is not proof that speeds were migrated. +Older clients may still recreate a deleted `playbackSpeeds` collection. The dedicated plaintext endpoints will be removed on 1 October 2026. Until then, their responses include the standard `Deprecation` and `Sunset` headers. diff --git a/src/handlers/encrypted_sync.rs b/src/handlers/encrypted_sync.rs index 24eff42..8da5189 100644 --- a/src/handlers/encrypted_sync.rs +++ b/src/handlers/encrypted_sync.rs @@ -17,8 +17,8 @@ use crate::{WebData, get_db_conn}; const MEBIBYTE: usize = 1024 * 1024; const MAX_ENCRYPTED_SYNC_BYTES: usize = 64 * MEBIBYTE; const MAX_ENCRYPTED_SYNC_ACCOUNT_BYTES: usize = 128 * MEBIBYTE; -// Deprecated playbackSpeeds is read into settings by current clients and must -// not be required to finish legacy document migration. +// Current clients can acknowledge saved playback speeds in settings. Older +// clients still require the deprecated collection to resume partial migrations. const LEGACY_ENCRYPTED_COLLECTIONS: [&str; 5] = [ "subscriptions", "playlists", @@ -27,6 +27,14 @@ const LEGACY_ENCRYPTED_COLLECTIONS: [&str; 5] = [ "playlistBookmarks", ]; +#[derive(Debug, Default, serde::Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] +struct EncryptedSyncManifestQuery { + /// The requesting client has successfully synced playback speeds into settings. + #[serde(default)] + playback_speeds_in_settings: bool, +} + pub struct EncryptedSyncHandler {} impl ScopedHandler for EncryptedSyncHandler { @@ -74,11 +82,12 @@ fn collection_limit(collection: &str) -> HandlerResult { } } -#[utoipa::path(responses((status = OK, body = EncryptedSyncManifest)), security(("api_jwt_token" = [])))] +#[utoipa::path(params(EncryptedSyncManifestQuery), responses((status = OK, body = EncryptedSyncManifest)), security(("api_jwt_token" = [])))] #[get("")] async fn get_encrypted_sync_manifest( account: Account, pool: WebData, + query: web::Query, ) -> HandlerResult { let mut conn = get_db_conn!(pool); let documents = encrypted_sync::get_all(&mut conn, &account.id) @@ -92,7 +101,11 @@ async fn get_encrypted_sync_manifest( .iter() .any(|document| document.collection == *collection) }); - let legacy_encrypted_data = !has_all_migrated_collections + let has_migrated_playback_speeds = query.playback_speeds_in_settings + || documents + .iter() + .any(|document| document.collection == "playbackSpeeds"); + let legacy_encrypted_data = !(has_all_migrated_collections && has_migrated_playback_speeds) && encrypted_sync::get_legacy_encrypted(&mut conn, &account.id) .await .map_err(|_| HandlerError::InternalDatabaseError)? @@ -301,6 +314,15 @@ mod migration_tests { .status() .is_success() ); + // Even an existing settings ciphertext cannot prove that speeds + // were migrated: the user may have excluded that setting. + let request = test::TestRequest::get().uri("/sync").to_request(); + request.extensions_mut().insert(account.clone()); + let manifest: serde_json::Value = test::call_and_read_body_json(&app, request).await; + assert_eq!( + manifest["legacy_encrypted_data"], + collection != "playbackSpeeds" + ); } for deleted in [false, true] { if deleted { @@ -312,6 +334,12 @@ mod migration_tests { request.extensions_mut().insert(account.clone()); let manifest: serde_json::Value = test::call_and_read_body_json(&app, request).await; assert_eq!(manifest["legacy_data"], false); + assert_eq!(manifest["legacy_encrypted_data"], deleted); + let request = test::TestRequest::get() + .uri("/sync?playback_speeds_in_settings=true") + .to_request(); + request.extensions_mut().insert(account.clone()); + let manifest: serde_json::Value = test::call_and_read_body_json(&app, request).await; assert_eq!(manifest["legacy_encrypted_data"], false); assert_eq!( manifest["collections"]