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())
}
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> {
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")

View File

@@ -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());
}

View File

@@ -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<String> {
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<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
} 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<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)] = &[
@@ -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<String> {
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<String> {
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<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 {
@@ -303,6 +326,15 @@ pub fn api(req: &HttpRequest, data: Option<JsonValue>) -> 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;

View File

@@ -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<String> {
let platform = req.headers()
.get("aoharu-platform")
.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);
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());
@@ -45,7 +53,7 @@ async fn start(req: HttpRequest, body: String) -> impl Responder {
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?
}))
}