Add support for storing multiple asset hashes

This commit is contained in:
Ethan O'Brien
2026-07-18 14:13:38 -05:00
parent 41c72919db
commit b7e63ab0e5
4 changed files with 164 additions and 116 deletions

View File

@@ -58,6 +58,16 @@ async fn webui_fallback(req: ServiceRequest, next: Next<impl MessageBody + 'stat
Ok(next.call(req).await?.map_into_boxed_body()) Ok(next.call(req).await?.map_into_boxed_body())
} }
async fn asset_gate(req: ServiceRequest, next: Next<impl MessageBody + 'static>) -> Result<ServiceResponse<BoxBody>, 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<JsonValue> { fn unhandled(req: &HttpRequest, body: String) -> Option<JsonValue> {
if body != String::new() { if body != String::new() {
println!("{}", encryption::decrypt_packet(&body).unwrap_or(body)); println!("{}", encryption::decrypt_packet(&body).unwrap_or(body));
@@ -135,6 +145,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
.service( .service(
actix_web::web::scope("") actix_web::web::scope("")
.wrap(from_fn(webui_fallback)) .wrap(from_fn(webui_fallback))
.wrap(from_fn(asset_gate))
// Split between user (claiming) and home (listing) // Split between user (claiming) and home (listing)
.service( .service(
actix_web::web::resource("/gift") actix_web::web::resource("/gift")

View File

@@ -81,17 +81,12 @@ pub fn disabled() -> bool {
args.hidden || !args.enable_custom_songs args.hidden || !args.enable_custom_songs
} }
// A client that understands custom songs advertises it with this exact header // Custom songs need protocol version 1 (global::PROTOCOL_HEADER). Clients
// on its API requests. Old / official clients don't send it, so we must NOT // below it don't understand the feature, so we must NOT inject custom-song
// inject custom-song data (custom master_music_ids) into the shared /api/user // data (custom master_music_ids) into the shared /api/user response for
// response for them - the unresolvable ids would break the account // them - the unresolvable ids would break the account
const SUPPORT_HEADER: &str = "X-Custom-Songs";
pub fn client_supports_custom_songs(req: &HttpRequest) -> bool { pub fn client_supports_custom_songs(req: &HttpRequest) -> bool {
req.headers() global::client_protocol_version(req) >= 1
.get(SUPPORT_HEADER)
.and_then(|v| v.to_str().ok())
== Some("1")
} }
// The catalog is filtered per requesting user: private songs only show for // 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 // 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 // for clients whose X-Protocol-Version is >= 1. An old/official client (no
// header, or a different value) gets its official unlock list untouched. // header, or a non-numeric value) gets its official unlock list untouched.
#[test] #[test]
fn unlock_ids_gated_on_support_header() { fn unlock_ids_gated_on_protocol_version() {
use actix_web::test::TestRequest; use actix_web::test::TestRequest;
let _lock = crate::runtime::lock_test_data_path(); 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(); let without = TestRequest::default().to_http_request();
assert!(!client_supports_custom_songs(&without)); assert!(!client_supports_custom_songs(&without));
assert!(appended(&without).is_empty()); assert!(appended(&without).is_empty());
// Correct header -> supported -> custom id appended // Version 1 or higher -> supported -> custom id appended
let with = TestRequest::default().insert_header(("X-Custom-Songs", "1")).to_http_request(); let with = TestRequest::default().insert_header(("X-Protocol-Version", "1")).to_http_request();
assert!(client_supports_custom_songs(&with)); assert!(client_supports_custom_songs(&with));
assert!(appended(&with).contains(music_id)); 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 // A non-numeric value is treated as version 0
let wrong = TestRequest::default().insert_header(("X-Custom-Songs", "true")).to_http_request(); let wrong = TestRequest::default().insert_header(("X-Protocol-Version", "true")).to_http_request();
assert!(!client_supports_custom_songs(&wrong)); assert!(!client_supports_custom_songs(&wrong));
assert!(appended(&wrong).is_empty()); assert!(appended(&wrong).is_empty());
} }

View File

@@ -13,82 +13,77 @@ use crate::router::{items, userdata};
use crate::database::gree; use crate::database::gree;
use crate::runtime::get_easter_mode; use crate::runtime::get_easter_mode;
struct AssetHashes { struct AssetVersion {
version_android: &'static str, region: &'static str,
version_ios: &'static str, platform: &'static str,
version_windows: &'static str, version: &'static str,
version_webgl: &'static str, hash: &'static str,
android: &'static str,
ios: &'static str,
windows: &'static str,
webgl: &'static str,
} }
static ASSET_TABLE: &[(&str, AssetHashes)] = &[ static ASSET_VERSIONS: &[AssetVersion] = &[
("JP", AssetHashes { AssetVersion { region: "JP", platform: "Android", version: "4c921d2443335e574a82e04ec9ea243c", hash: "67f8f261c16b3cca63e520a25aad6c1c" },
version_android: "4c921d2443335e574a82e04ec9ea243c", AssetVersion { region: "JP", platform: "iOS", version: "4c921d2443335e574a82e04ec9ea243c", hash: "b8975be8300013a168d061d3fdcd4a16" },
version_ios: "4c921d2443335e574a82e04ec9ea243c", AssetVersion { region: "JP", platform: "Windows", version: "4c921d2443335e574a82e04ec9ea243c", hash: "937554b736233aa44774f59c74649d7a" },
version_windows: "4c921d2443335e574a82e04ec9ea243c", AssetVersion { region: "JP", platform: "WebGL", version: "4c921d2443335e574a82e04ec9ea243c", hash: "e1ff7c74b20c8d216507972b6f24b9df" },
version_webgl: "4c921d2443335e574a82e04ec9ea243c", AssetVersion { region: "GL", platform: "Android", version: "5260ff15dff8ba0c00ad91400f515f55", hash: "d210b28037885f3ef56b8f8aa45ac95b" },
android: "67f8f261c16b3cca63e520a25aad6c1c", AssetVersion { region: "GL", platform: "iOS", version: "5260ff15dff8ba0c00ad91400f515f55", hash: "dd7175e4bcdab476f38c33c7f34b5e4d" },
ios: "b8975be8300013a168d061d3fdcd4a16", AssetVersion { region: "GL", platform: "Windows", version: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
windows: "fcc15c3dc02250d49c4c492c3b9d58fc", AssetVersion { region: "GL", platform: "WebGL", version: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
webgl: "e1ff7c74b20c8d216507972b6f24b9df",
}),
("GL", AssetHashes {
version_android: "5260ff15dff8ba0c00ad91400f515f55",
version_ios: "5260ff15dff8ba0c00ad91400f515f55",
version_windows: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
version_webgl: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
android: "d210b28037885f3ef56b8f8aa45ac95b",
ios: "dd7175e4bcdab476f38c33c7f34b5e4d",
windows: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
webgl: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
}),
]; ];
impl AssetHashes { impl AssetVersion {
fn resolve(&self, platform: &str, region: &str, easter_hash: Option<&str>) -> Option<String> { fn version(&self, current: bool) -> String {
let args = crate::get_args(); let args = crate::get_args();
let (base, override_hash) = match (region, platform) { let override_version = match (current, self.region, self.platform) {
("JP", "Android") => (self.android, args.jp_android_asset_hash.as_str()), (true, "JP", "Windows") => args.windows_asset_version.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,
}; };
Some(if !override_hash.is_empty() { if override_version.is_empty() {
override_hash self.version
} else if let Some(easter) = easter_hash {
easter
} else { } else {
base
}.to_string())
}
fn version_for(&self, platform: &str, region: &str) -> Option<String> {
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 override_version
} else { }.to_string()
base
}.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<String> {
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)] = &[ static EASTER_HASHES: &[(&str, &str)] = &[
@@ -96,15 +91,25 @@ static EASTER_HASHES: &[(&str, &str)] = &[
("GL", "da7ae831381c3f29337caa9891db7e6a"), ("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<String> { pub fn get_player_region(asset_version: &str) -> Option<String> {
ASSET_TABLE ASSET_VERSIONS
.iter() .iter()
.find(|(_, h)| { .find(|entry| entry.version == asset_version)
h.version_android == asset_version || .map(|entry| entry.region.to_string())
h.version_ios == asset_version ||
h.version_windows == asset_version
})
.map(|(region, _)| region.to_string())
} }
pub fn parse_platform(header: &str) -> &str { 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<String> { pub fn get_asset_hash(asset_version: &str, platform: &str) -> Option<String> {
let easter = get_easter_mode(); let rv = find_asset_hash(asset_version, platform);
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);
println!("Get asset hash: {platform}. {rv:?}"); println!("Get asset hash: {platform}. {rv:?}");
return rv; rv
}
pub fn check_asset_headers(headers: &HeaderMap, check_hash: bool) -> Option<i32> {
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 { pub fn create_token() -> String {
@@ -303,6 +326,15 @@ pub fn api(req: &HttpRequest, data: Option<JsonValue>) -> HttpResponse {
send(rv, uid, req.headers()) 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 { pub fn start_login_bonus(id: i64, bonus: &mut JsonValue) -> bool {
if crate::router::login::get_login_bonus_info(id).is_empty() { if crate::router::login::get_login_bonus_info(id).is_empty() {
return false; return false;

View File

@@ -9,11 +9,7 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
cfg.route("/start/assetHash", web::post().to(asset_hash)); cfg.route("/start/assetHash", web::post().to(asset_hash));
} }
fn get_asset_hash(req: &HttpRequest, body: &JsonValue) -> String { fn get_asset_hash(req: &HttpRequest, body: &JsonValue) -> Option<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?)");
}
let platform = req.headers() let platform = req.headers()
.get("aoharu-platform") .get("aoharu-platform")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
@@ -22,20 +18,32 @@ fn get_asset_hash(req: &HttpRequest, body: &JsonValue) -> String {
println!("Login on platform: {}", platform); 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 { async fn asset_hash(req: HttpRequest, body: String) -> impl Responder {
let body = jzon::parse(&encryption::decrypt_packet(&body).unwrap()).unwrap(); let body = jzon::parse(&encryption::decrypt_packet(&body).unwrap()).unwrap();
global::api(&req, Some(object!{ match get_asset_hash(&req, &body) {
"asset_hash": 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 { async fn start(req: HttpRequest, body: String) -> impl Responder {
let key = global::get_login(req.headers(), &body); let key = global::get_login(req.headers(), &body);
let body = jzon::parse(&encryption::decrypt_packet(&body).unwrap()).unwrap(); 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); let mut user = userdata::get_acc(&key);
println!("Signin from uid: {}", user["user"]["id"].clone()); println!("Signin from uid: {}", user["user"]["id"].clone());
@@ -45,7 +53,7 @@ async fn start(req: HttpRequest, body: String) -> impl Responder {
userdata::save_acc(&key, user); userdata::save_acc(&key, user);
global::api(&req, Some(object!{ global::api(&req, Some(object!{
"asset_hash": get_asset_hash(&req, &body), "asset_hash": asset_hash,
"token": hex::encode("Hello") //what is this? "token": hex::encode("Hello") //what is this?
})) }))
} }