diff --git a/src/router.rs b/src/router.rs index e71869a..8b4fa8c 100644 --- a/src/router.rs +++ b/src/router.rs @@ -58,6 +58,16 @@ async fn webui_fallback(req: ServiceRequest, next: Next) -> Result, actix_web::Error> { + let check_hash = !matches!(req.path(), "/api/start" | "/api/start/assetHash"); + if let Some(code) = global::check_asset_headers(req.headers(), check_hash) { + let req = req.into_parts().0; + let resp = global::api_error(&req, code); + return Ok(ServiceResponse::new(req, resp)); + } + Ok(next.call(req).await?.map_into_boxed_body()) +} + fn unhandled(req: &HttpRequest, body: String) -> Option { if body != String::new() { println!("{}", encryption::decrypt_packet(&body).unwrap_or(body)); @@ -135,6 +145,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) { .service( actix_web::web::scope("") .wrap(from_fn(webui_fallback)) + .wrap(from_fn(asset_gate)) // Split between user (claiming) and home (listing) .service( actix_web::web::resource("/gift") diff --git a/src/router/custom_song.rs b/src/router/custom_song.rs index c04cdc7..76426c4 100644 --- a/src/router/custom_song.rs +++ b/src/router/custom_song.rs @@ -81,17 +81,12 @@ pub fn disabled() -> bool { args.hidden || !args.enable_custom_songs } -// A client that understands custom songs advertises it with this exact header -// on its API requests. Old / official clients don't send it, so we must NOT -// inject custom-song data (custom master_music_ids) into the shared /api/user -// response for them - the unresolvable ids would break the account -const SUPPORT_HEADER: &str = "X-Custom-Songs"; - +// Custom songs need protocol version 1 (global::PROTOCOL_HEADER). Clients +// below it don't understand the feature, so we must NOT inject custom-song +// data (custom master_music_ids) into the shared /api/user response for +// them - the unresolvable ids would break the account pub fn client_supports_custom_songs(req: &HttpRequest) -> bool { - req.headers() - .get(SUPPORT_HEADER) - .and_then(|v| v.to_str().ok()) - == Some("1") + global::client_protocol_version(req) >= 1 } // The catalog is filtered per requesting user: private songs only show for @@ -1337,10 +1332,10 @@ mod tests { } // With the feature enabled, custom unlock ids are appended to /api/user ONLY - // for clients that send X-Custom-Songs: 1. An old/official client (no - // header, or a different value) gets its official unlock list untouched. + // for clients whose X-Protocol-Version is >= 1. An old/official client (no + // header, or a non-numeric value) gets its official unlock list untouched. #[test] - fn unlock_ids_gated_on_support_header() { + fn unlock_ids_gated_on_protocol_version() { use actix_web::test::TestRequest; let _lock = crate::runtime::lock_test_data_path(); @@ -1359,18 +1354,20 @@ mod tests { } }; - // No header -> unsupported -> nothing appended + // No header -> protocol version 0 -> nothing appended let without = TestRequest::default().to_http_request(); assert!(!client_supports_custom_songs(&without)); assert!(appended(&without).is_empty()); - // Correct header -> supported -> custom id appended - let with = TestRequest::default().insert_header(("X-Custom-Songs", "1")).to_http_request(); + // Version 1 or higher -> supported -> custom id appended + let with = TestRequest::default().insert_header(("X-Protocol-Version", "1")).to_http_request(); assert!(client_supports_custom_songs(&with)); assert!(appended(&with).contains(music_id)); + let newer = TestRequest::default().insert_header(("X-Protocol-Version", "7")).to_http_request(); + assert!(client_supports_custom_songs(&newer)); - // Any other value is treated as an unsupporting client - let wrong = TestRequest::default().insert_header(("X-Custom-Songs", "true")).to_http_request(); + // A non-numeric value is treated as version 0 + let wrong = TestRequest::default().insert_header(("X-Protocol-Version", "true")).to_http_request(); assert!(!client_supports_custom_songs(&wrong)); assert!(appended(&wrong).is_empty()); } diff --git a/src/router/global.rs b/src/router/global.rs index efc2e8b..c8c619d 100644 --- a/src/router/global.rs +++ b/src/router/global.rs @@ -13,82 +13,77 @@ use crate::router::{items, userdata}; use crate::database::gree; use crate::runtime::get_easter_mode; -struct AssetHashes { - version_android: &'static str, - version_ios: &'static str, - version_windows: &'static str, - version_webgl: &'static str, - android: &'static str, - ios: &'static str, - windows: &'static str, - webgl: &'static str, +struct AssetVersion { + region: &'static str, + platform: &'static str, + version: &'static str, + hash: &'static str, } -static ASSET_TABLE: &[(&str, AssetHashes)] = &[ - ("JP", AssetHashes { - version_android: "4c921d2443335e574a82e04ec9ea243c", - version_ios: "4c921d2443335e574a82e04ec9ea243c", - version_windows: "4c921d2443335e574a82e04ec9ea243c", - version_webgl: "4c921d2443335e574a82e04ec9ea243c", - android: "67f8f261c16b3cca63e520a25aad6c1c", - ios: "b8975be8300013a168d061d3fdcd4a16", - windows: "fcc15c3dc02250d49c4c492c3b9d58fc", - webgl: "e1ff7c74b20c8d216507972b6f24b9df", - }), - ("GL", AssetHashes { - version_android: "5260ff15dff8ba0c00ad91400f515f55", - version_ios: "5260ff15dff8ba0c00ad91400f515f55", - version_windows: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - version_webgl: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - android: "d210b28037885f3ef56b8f8aa45ac95b", - ios: "dd7175e4bcdab476f38c33c7f34b5e4d", - windows: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - webgl: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - }), +static ASSET_VERSIONS: &[AssetVersion] = &[ + AssetVersion { region: "JP", platform: "Android", version: "4c921d2443335e574a82e04ec9ea243c", hash: "67f8f261c16b3cca63e520a25aad6c1c" }, + AssetVersion { region: "JP", platform: "iOS", version: "4c921d2443335e574a82e04ec9ea243c", hash: "b8975be8300013a168d061d3fdcd4a16" }, + AssetVersion { region: "JP", platform: "Windows", version: "4c921d2443335e574a82e04ec9ea243c", hash: "937554b736233aa44774f59c74649d7a" }, + AssetVersion { region: "JP", platform: "WebGL", version: "4c921d2443335e574a82e04ec9ea243c", hash: "e1ff7c74b20c8d216507972b6f24b9df" }, + AssetVersion { region: "GL", platform: "Android", version: "5260ff15dff8ba0c00ad91400f515f55", hash: "d210b28037885f3ef56b8f8aa45ac95b" }, + AssetVersion { region: "GL", platform: "iOS", version: "5260ff15dff8ba0c00ad91400f515f55", hash: "dd7175e4bcdab476f38c33c7f34b5e4d" }, + AssetVersion { region: "GL", platform: "Windows", version: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + AssetVersion { region: "GL", platform: "WebGL", version: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, ]; -impl AssetHashes { - fn resolve(&self, platform: &str, region: &str, easter_hash: Option<&str>) -> Option { +impl AssetVersion { + fn version(&self, current: bool) -> String { let args = crate::get_args(); - let (base, override_hash) = match (region, platform) { - ("JP", "Android") => (self.android, args.jp_android_asset_hash.as_str()), - ("JP", "iOS") => (self.ios, args.jp_ios_asset_hash.as_str()), - ("JP", "Windows") => (self.windows, args.windows_asset_hash.as_str()), - ("JP", "WebGL") => (self.webgl, ""), - ("GL", "Android") => (self.android, args.en_android_asset_hash.as_str()), - ("GL", "iOS") => (self.ios, args.en_ios_asset_hash.as_str()), - _ => return None, + let override_version = match (current, self.region, self.platform) { + (true, "JP", "Windows") => args.windows_asset_version.as_str(), + _ => "", }; - Some(if !override_hash.is_empty() { - override_hash - } else if let Some(easter) = easter_hash { - easter + if override_version.is_empty() { + self.version } else { - base - }.to_string()) - } - fn version_for(&self, platform: &str, region: &str) -> Option { - let args = crate::get_args(); - - let (base, override_version) = match (region, platform) { - ("JP", "Android") => (self.version_android, ""), - ("JP", "iOS") => (self.version_ios, ""), - ("JP", "Windows") => (self.version_windows, args.windows_asset_version.as_str()), - ("JP", "WebGL") => (self.version_webgl, ""), - ("GL", "Android") => (self.version_android, ""), - ("GL", "iOS") => (self.version_ios, ""), - ("GL", "Windows") => (self.version_windows, ""), - _ => return None, - }; - - Some(if !override_version.is_empty() { override_version - } else { - base - }.to_string()) + }.to_string() } + fn hash(&self, current: bool) -> String { + let args = crate::get_args(); + + let override_hash = match (current, self.region, self.platform) { + (true, "JP", "Android") => args.jp_android_asset_hash.as_str(), + (true, "JP", "iOS") => args.jp_ios_asset_hash.as_str(), + (true, "JP", "Windows") => args.windows_asset_hash.as_str(), + (true, "GL", "Android") => args.en_android_asset_hash.as_str(), + (true, "GL", "iOS") => args.en_ios_asset_hash.as_str(), + _ => "", + }; + if !override_hash.is_empty() { + return override_hash.to_string(); + } + + if current && self.platform == "Android" && get_easter_mode() { + if let Some((_, easter)) = EASTER_HASHES.iter().find(|(r, _)| *r == self.region) { + return easter.to_string(); + } + } + + self.hash.to_string() + } +} + +fn find_asset_hash(asset_version: &str, platform: &str) -> Option { + let mut seen_regions: Vec<&str> = Vec::new(); + for entry in ASSET_VERSIONS { + if entry.platform != platform { + continue; + } + let current = !seen_regions.contains(&entry.region); + seen_regions.push(entry.region); + if entry.version(current) == asset_version { + return Some(entry.hash(current)); + } + } + None } static EASTER_HASHES: &[(&str, &str)] = &[ @@ -96,15 +91,25 @@ static EASTER_HASHES: &[(&str, &str)] = &[ ("GL", "da7ae831381c3f29337caa9891db7e6a"), ]; +pub const RESULT_GAME_VERSION_UPDATED: i32 = 12; + +pub const RESULT_RESOURCE_UPDATED: i32 = 13; + +pub const PROTOCOL_HEADER: &str = "X-Protocol-Version"; + +pub fn client_protocol_version(req: &HttpRequest) -> u32 { + req.headers() + .get(PROTOCOL_HEADER) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(0) +} + pub fn get_player_region(asset_version: &str) -> Option { - ASSET_TABLE + ASSET_VERSIONS .iter() - .find(|(_, h)| { - h.version_android == asset_version || - h.version_ios == asset_version || - h.version_windows == asset_version - }) - .map(|(region, _)| region.to_string()) + .find(|entry| entry.version == asset_version) + .map(|entry| entry.region.to_string()) } pub fn parse_platform(header: &str) -> &str { @@ -122,19 +127,37 @@ pub fn parse_platform(header: &str) -> &str { } pub fn get_asset_hash(asset_version: &str, platform: &str) -> Option { - let easter = get_easter_mode(); - - let (region, hashes) = ASSET_TABLE - .iter() - .find(|(region, h)| h.version_for(platform, region).as_deref() == Some(asset_version))?; - - let easter_hash = (easter && platform == "Android") - .then(|| EASTER_HASHES.iter().find(|(r, _)| r == region).map(|(_, h)| *h)) - .flatten(); - - let rv = hashes.resolve(platform, region, easter_hash); + let rv = find_asset_hash(asset_version, platform); println!("Get asset hash: {platform}. {rv:?}"); - return rv; + rv +} + +pub fn check_asset_headers(headers: &HeaderMap, check_hash: bool) -> Option { + let blank_header = HeaderValue::from_static(""); + let asset_version = headers.get("aoharu-asset-version").unwrap_or(&blank_header).to_str().unwrap_or(""); + if asset_version.is_empty() { + return None; + } + + let platform = match headers.get("aoharu-platform").and_then(|v| v.to_str().ok()) { + Some(header) => parse_platform(header), + None => return None, + }; + + let current = match find_asset_hash(asset_version, platform) { + Some(hash) => hash, + None => return Some(RESULT_GAME_VERSION_UPDATED), + }; + if !check_hash { + return None; + } + + let asset_hash = headers.get("aoharu-asset-hash").unwrap_or(&blank_header).to_str().unwrap_or(""); + if asset_hash.is_empty() || asset_hash == current { + None + } else { + Some(RESULT_RESOURCE_UPDATED) + } } pub fn create_token() -> String { @@ -303,6 +326,15 @@ pub fn api(req: &HttpRequest, data: Option) -> HttpResponse { send(rv, uid, req.headers()) } +pub fn api_error(req: &HttpRequest, code: i32) -> HttpResponse { + let uid = get_uid(req.headers()); + send(object!{ + "code": code, + "server_time": timestamp(), + "message": "" + }, uid, req.headers()) +} + pub fn start_login_bonus(id: i64, bonus: &mut JsonValue) -> bool { if crate::router::login::get_login_bonus_info(id).is_empty() { return false; diff --git a/src/router/start.rs b/src/router/start.rs index fa19ef3..0e1d651 100644 --- a/src/router/start.rs +++ b/src/router/start.rs @@ -9,11 +9,7 @@ pub fn routes(cfg: &mut web::ServiceConfig) { cfg.route("/start/assetHash", web::post().to(asset_hash)); } -fn get_asset_hash(req: &HttpRequest, body: &JsonValue) -> String { - if global::get_player_region(&body["asset_version"].to_string()).is_none() { - println!("Warning! Asset version is not what was expected. (Did the app update?)"); - } - +fn get_asset_hash(req: &HttpRequest, body: &JsonValue) -> Option { let platform = req.headers() .get("aoharu-platform") .and_then(|v| v.to_str().ok()) @@ -22,30 +18,42 @@ fn get_asset_hash(req: &HttpRequest, body: &JsonValue) -> String { println!("Login on platform: {}", platform); - global::get_asset_hash(&body["asset_version"].to_string(), platform).unwrap() + let rv = global::get_asset_hash(&body["asset_version"].to_string(), platform); + if rv.is_none() { + println!("Unknown asset version {}; telling the client to update.", body["asset_version"]); + } + rv } async fn asset_hash(req: HttpRequest, body: String) -> impl Responder { let body = jzon::parse(&encryption::decrypt_packet(&body).unwrap()).unwrap(); - global::api(&req, Some(object!{ - "asset_hash": get_asset_hash(&req, &body) - })) + match get_asset_hash(&req, &body) { + Some(hash) => global::api(&req, Some(object!{ + "asset_hash": hash + })), + None => global::api_error(&req, global::RESULT_GAME_VERSION_UPDATED), + } } async fn start(req: HttpRequest, body: String) -> impl Responder { let key = global::get_login(req.headers(), &body); let body = jzon::parse(&encryption::decrypt_packet(&body).unwrap()).unwrap(); + + let Some(asset_hash) = get_asset_hash(&req, &body) else { + return global::api_error(&req, global::RESULT_GAME_VERSION_UPDATED); + }; + let mut user = userdata::get_acc(&key); - + println!("Signin from uid: {}", user["user"]["id"].clone()); - + user["user"]["last_login_time"] = global::timestamp().into(); - + userdata::save_acc(&key, user); - + global::api(&req, Some(object!{ - "asset_hash": get_asset_hash(&req, &body), + "asset_hash": asset_hash, "token": hex::encode("Hello") //what is this? })) }