Skip to content

Commit 37a009f

Browse files
committed
fix: allow legacy sync migration without playback speeds
1 parent b0c503e commit 37a009f

2 files changed

Lines changed: 122 additions & 5 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ readable data.
1919
The dedicated `/v1/channel_playback_speeds` endpoints and encrypted
2020
`playbackSpeeds` collection are deprecated. Current OpenTubeX clients store all
2121
saved channel preferences, including playback speeds, in the encrypted
22-
`settings` collection.
22+
`settings` collection. Current clients still read existing `playbackSpeeds` data
23+
to migrate it into `settings`, but no longer upload the deprecated collection.
24+
Its absence does not mark legacy encrypted migration as incomplete.
2325

2426
The dedicated plaintext endpoints will be removed on 1 October 2026. Until
2527
then, their responses include the standard `Deprecation` and `Sunset` headers.

src/handlers/encrypted_sync.rs

Lines changed: 119 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,12 @@ use crate::{WebData, get_db_conn};
1717
const MEBIBYTE: usize = 1024 * 1024;
1818
const MAX_ENCRYPTED_SYNC_BYTES: usize = 64 * MEBIBYTE;
1919
const MAX_ENCRYPTED_SYNC_ACCOUNT_BYTES: usize = 128 * MEBIBYTE;
20-
// `playbackSpeeds` is deprecated for new clients, but remains part of legacy
21-
// document migration until older OpenTubeX versions have been phased out.
22-
const LEGACY_ENCRYPTED_COLLECTIONS: [&str; 6] = [
20+
// Deprecated playbackSpeeds is read into settings by current clients and must
21+
// not be required to finish legacy document migration.
22+
const LEGACY_ENCRYPTED_COLLECTIONS: [&str; 5] = [
2323
"subscriptions",
2424
"playlists",
2525
"history",
26-
"playbackSpeeds",
2726
"profiles",
2827
"playlistBookmarks",
2928
];
@@ -215,10 +214,126 @@ mod tests {
215214
fn encrypted_collection_limits_are_scoped_by_data_type() {
216215
assert_eq!(collection_limit("settings").unwrap(), 2 * MEBIBYTE);
217216
assert_eq!(collection_limit("profiles").unwrap(), 8 * MEBIBYTE);
217+
assert_eq!(collection_limit("playbackSpeeds").unwrap(), 8 * MEBIBYTE);
218218
assert_eq!(collection_limit("sessions").unwrap(), 8 * MEBIBYTE);
219219
assert_eq!(collection_limit("sessionsV2").unwrap(), 8 * MEBIBYTE);
220220
assert_eq!(collection_limit("subscriptions").unwrap(), 16 * MEBIBYTE);
221221
assert_eq!(collection_limit("history").unwrap(), 64 * MEBIBYTE);
222222
assert!(collection_limit("unknown").is_err());
223223
}
224224
}
225+
226+
#[cfg(all(test, feature = "sqlite"))]
227+
mod migration_tests {
228+
use actix_web::{App, HttpMessage, test, web};
229+
use diesel::connection::SimpleConnection;
230+
use diesel_async::RunQueryDsl;
231+
use diesel_async::pooled_connection::{AsyncDieselConnectionManager, bb8::Pool};
232+
use diesel_migrations::MigrationHarness;
233+
234+
use crate::{DbConnection, MIGRATIONS, models::Account};
235+
236+
#[actix_rt::test]
237+
async fn deleting_migrated_playback_speeds_does_not_restart_legacy_migration() {
238+
let pool = Pool::builder()
239+
.max_size(1)
240+
.build(AsyncDieselConnectionManager::<DbConnection>::new(
241+
":memory:",
242+
))
243+
.await
244+
.unwrap();
245+
let account = Account {
246+
id: "owner".into(),
247+
name_hash: "owner-hash".into(),
248+
password_hash: None,
249+
oidc_sub: None,
250+
legacy_tokens_enabled: false,
251+
session_generation: 0,
252+
};
253+
{
254+
let mut conn = pool.get().await.unwrap();
255+
conn.spawn_blocking(|conn| {
256+
conn.run_pending_migrations(MIGRATIONS).unwrap();
257+
conn.batch_execute(
258+
"INSERT INTO account (id, name_hash) VALUES ('owner', 'owner-hash');
259+
INSERT INTO encrypted_sync_single_document (account_id, revision, payload)
260+
VALUES ('owner', 1, 'legacy-ciphertext');",
261+
)?;
262+
Ok(())
263+
})
264+
.await
265+
.unwrap();
266+
}
267+
let app = test::init_service(
268+
App::new().app_data(web::Data::new(pool.clone())).service(
269+
web::scope("/sync")
270+
.service(super::get_encrypted_sync_manifest)
271+
.service(super::get_legacy_encrypted_sync)
272+
.service(super::get_encrypted_sync_collection)
273+
.service(super::put_encrypted_sync_collection),
274+
),
275+
)
276+
.await;
277+
// Incomplete migrations must still expose the original document.
278+
let request = test::TestRequest::get().uri("/sync").to_request();
279+
request.extensions_mut().insert(account.clone());
280+
let manifest: serde_json::Value = test::call_and_read_body_json(&app, request).await;
281+
assert_eq!(manifest["legacy_encrypted_data"], true);
282+
283+
// Use the actual PUT endpoint, including deprecated collection support.
284+
for collection in [
285+
"subscriptions",
286+
"playlists",
287+
"history",
288+
"profiles",
289+
"playlistBookmarks",
290+
"settings",
291+
"playbackSpeeds",
292+
] {
293+
let request = test::TestRequest::put()
294+
.uri(&format!("/sync/{collection}"))
295+
.set_json(serde_json::json!({ "revision": 0, "payload": "ciphertext" }))
296+
.to_request();
297+
request.extensions_mut().insert(account.clone());
298+
assert!(
299+
test::call_service(&app, request)
300+
.await
301+
.status()
302+
.is_success()
303+
);
304+
}
305+
for deleted in [false, true] {
306+
if deleted {
307+
let mut conn = pool.get().await.unwrap();
308+
diesel::sql_query("DELETE FROM encrypted_sync WHERE account_id = 'owner' AND collection = 'playbackSpeeds'")
309+
.execute(&mut conn).await.unwrap();
310+
}
311+
let request = test::TestRequest::get().uri("/sync").to_request();
312+
request.extensions_mut().insert(account.clone());
313+
let manifest: serde_json::Value = test::call_and_read_body_json(&app, request).await;
314+
assert_eq!(manifest["legacy_data"], false);
315+
assert_eq!(manifest["legacy_encrypted_data"], false);
316+
assert_eq!(
317+
manifest["collections"]
318+
.as_array()
319+
.unwrap()
320+
.iter()
321+
.any(|entry| entry["collection"] == "playbackSpeeds"),
322+
!deleted
323+
);
324+
}
325+
let request = test::TestRequest::get()
326+
.uri("/sync/playbackSpeeds")
327+
.to_request();
328+
request.extensions_mut().insert(account.clone());
329+
let collection: serde_json::Value = test::call_and_read_body_json(&app, request).await;
330+
assert_eq!(collection["revision"], 0);
331+
assert!(collection["payload"].is_null());
332+
333+
// The legacy document stays readable for older clients.
334+
let request = test::TestRequest::get().uri("/sync/legacy").to_request();
335+
request.extensions_mut().insert(account);
336+
let legacy: serde_json::Value = test::call_and_read_body_json(&app, request).await;
337+
assert_eq!(legacy["payload"], "legacy-ciphertext");
338+
}
339+
}

0 commit comments

Comments
 (0)