From c491f90e09d6468829f004605fe14121a01c6674 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Tue, 4 Jul 2023 09:37:22 +0100 Subject: [PATCH] Parse expiry timestamp from spotifycdn.com CDN URLs (Fixes #1182) (#1183) The CDN URLs list now includes spotifycdn.com which has a different format. It was being erroneously interpreted using the scdn.co format and trying to parse non-digit characters as a timestamp. Also ignore expiry timestamps we can't parse for future new URLs. --- core/src/cdn_url.rs | 88 +++++++++++++++++++++++++++++++++------------ 1 file changed, 66 insertions(+), 22 deletions(-) diff --git a/core/src/cdn_url.rs b/core/src/cdn_url.rs index 39e596a6..417a3c73 100644 --- a/core/src/cdn_url.rs +++ b/core/src/cdn_url.rs @@ -5,6 +5,7 @@ use std::{ use protobuf::Message; use thiserror::Error; +use time::Duration; use url::Url; use super::{date::Date, Error, FileId, Session}; @@ -16,6 +17,8 @@ use protocol::storage_resolve::StorageResolveResponse as CdnUrlMessage; #[derive(Debug, Clone)] pub struct MaybeExpiringUrl(pub String, pub Option); +const CDN_URL_EXPIRY_MARGIN: Duration = Duration::seconds(5 * 60); + #[derive(Debug, Clone)] pub struct MaybeExpiringUrls(pub Vec); @@ -114,55 +117,96 @@ impl TryFrom for MaybeExpiringUrls { .iter() .map(|cdn_url| { let url = Url::parse(cdn_url)?; + let mut expiry: Option = None; if is_expiring { - let expiry_str = if let Some(token) = url + let mut expiry_str: Option = None; + if let Some(token) = url .query_pairs() .into_iter() .find(|(key, _value)| key == "__token__") { + //"https://audio-ak-spotify-com.akamaized.net/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?__token__=exp=1688165560~hmac=4e661527574fab5793adb99cf04e1c2ce12294c71fe1d39ffbfabdcfe8ce3b41", if let Some(mut start) = token.1.find("exp=") { start += 4; if token.1.len() >= start { let slice = &token.1[start..]; if let Some(end) = slice.find('~') { // this is the only valid invariant for akamaized.net - String::from(&slice[..end]) + expiry_str = Some(String::from(&slice[..end])); } else { - String::from(slice) + expiry_str = Some(String::from(slice)); } - } else { - String::new() } - } else { - String::new() + } + } else if let Some(token) = url + .query_pairs() + .into_iter() + .find(|(key, _value)| key == "Expires") + { + //"https://audio-gm-off.spotifycdn.com/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?Expires=1688165560~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM=", + if let Some(end) = token.1.find('~') { + // this is the only valid invariant for spotifycdn.com + let slice = &token.1[..end]; + expiry_str = Some(String::from(&slice[..end])); } } else if let Some(query) = url.query() { + //"https://audio4-fa.scdn.co/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?1688165560_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4=", let mut items = query.split('_'); if let Some(first) = items.next() { // this is the only valid invariant for scdn.co - String::from(first) + expiry_str = Some(String::from(first)); + } + } + + if let Some(exp_str) = expiry_str { + if let Ok(expiry_parsed) = exp_str.parse::() { + if let Ok(expiry_at) = Date::from_timestamp_ms(expiry_parsed * 1_000) { + let with_margin = expiry_at.saturating_sub(CDN_URL_EXPIRY_MARGIN); + expiry = Some(Date::from(with_margin)); + } } else { - String::new() + warn!("Cannot parse CDN URL expiry timestamp '{exp_str}' from '{cdn_url}'"); } } else { - String::new() - }; - - let mut expiry: i64 = expiry_str.parse()?; - - expiry -= 5 * 60; // seconds - - Ok(MaybeExpiringUrl( - cdn_url.to_owned(), - Some(Date::from_timestamp_ms(expiry * 1000)?), - )) - } else { - Ok(MaybeExpiringUrl(cdn_url.to_owned(), None)) + warn!("Unknown CDN URL format: {cdn_url}"); + } } + Ok(MaybeExpiringUrl(cdn_url.to_owned(), expiry)) }) .collect::, Error>>()?; Ok(Self(result)) } } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_maybe_expiring_urls() { + let timestamp = 1688165560; + let mut msg = CdnUrlMessage::new(); + msg.result = StorageResolveResponse_Result::CDN.into(); + msg.cdnurl = vec![ + format!("https://audio-ak-spotify-com.akamaized.net/audio/foo?__token__=exp={timestamp}~hmac=4e661527574fab5793adb99cf04e1c2ce12294c71fe1d39ffbfabdcfe8ce3b41"), + format!("https://audio-gm-off.spotifycdn.com/audio/foo?Expires={timestamp}~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM="), + format!("https://audio4-fa.scdn.co/audio/foo?{timestamp}_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4="), + "https://audio4-fa.scdn.co/foo?baz".to_string(), + ]; + msg.fileid = vec![0]; + + let urls = MaybeExpiringUrls::try_from(msg).expect("valid urls"); + assert_eq!(urls.len(), 4); + assert!(urls[0].1.is_some()); + assert!(urls[1].1.is_some()); + assert!(urls[2].1.is_some()); + assert!(urls[3].1.is_none()); + let timestamp_margin = Duration::seconds(timestamp) - CDN_URL_EXPIRY_MARGIN; + assert_eq!( + urls[0].1.unwrap().as_timestamp_ms() as i128, + timestamp_margin.whole_milliseconds() + ); + } +}