feat: overhaul + pb import

This commit is contained in:
beerpiss 2023-11-18 23:10:50 +07:00
parent 96253e13bc
commit 8bcb664378
18 changed files with 1189 additions and 521 deletions

24
Cargo.lock generated
View File

@ -61,6 +61,15 @@ version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9"
[[package]]
name = "binary-reader"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d173c51941d642588ed6a13d464617e3a9176b8fe00dc2de182434c36812a5e"
dependencies = [
"byteorder",
]
[[package]]
name = "bitflags"
version = "1.3.2"
@ -97,6 +106,12 @@ version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cbc"
version = "0.1.2"
@ -312,6 +327,12 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7"
[[package]]
name = "hex-literal"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46"
[[package]]
name = "hmac"
version = "0.12.1"
@ -702,12 +723,15 @@ version = "0.2.0"
dependencies = [
"aes",
"anyhow",
"binary-reader",
"cbc",
"chrono",
"confy",
"crc32fast",
"env_logger",
"faster-hex",
"flate2",
"hex-literal",
"lazy_static",
"log",
"num_enum",

View File

@ -18,12 +18,15 @@ panic = "abort"
[dependencies]
aes = "0.8.3"
anyhow = "1.0.75"
binary-reader = "0.4.5"
cbc = "0.1.2"
chrono = "0.4.31"
confy = "0.5.1"
crc32fast = "1.3.2"
env_logger = "0.10.1"
faster-hex = "0.8.1"
flate2 = "1.0.28"
hex-literal = "0.4.1"
lazy_static = "1.4.0"
log = "0.4.20"
num_enum = "0.7.1"

View File

@ -3,15 +3,23 @@
enable = true
# Whether the hook should export your class medals and emblems or not.
export_class = true
# Whether the hook should export PBs. This should be used as a last resort, if
# you cannot import scores from your network, since this provides less data
# and sends only one pre-joined score per chart. Will only work once every session; you'll
# need to restart the game to do it again.
export_pbs = false
# Whether FAILED should override FULL COMBO and ALL JUSTICE.
fail_over_lamp = false
# Timeout for web requests, in milliseconds
timeout = 3000
[cards]
# **DOES NOT WORK FOR WHITELISTING PBS!!**
#
# Access codes that should be whitelisted
# If this is empty, all cards will be whitelisted
# There should be no whitespace between the digits
#
# example: whitelist = ["00001111222233334444"]
whitelist = []

View File

@ -27,6 +27,11 @@ impl Configuration {
confy::load_path("saekawa.toml")
.map_err(|err| anyhow::anyhow!("Could not load config: {}", err))
}
pub fn update(cfg: Configuration) -> Result<()> {
confy::store_path("saekawa.toml", cfg)
.map_err(|err| anyhow::anyhow!("Could not update config: {}", err))
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@ -37,6 +42,9 @@ pub struct GeneralConfiguration {
#[serde(default = "default_true")]
pub export_class: bool,
#[serde(default = "default_false")]
pub export_pbs: bool,
#[serde(default = "default_false")]
pub fail_over_lamp: bool,

59
src/handlers.rs Normal file
View File

@ -0,0 +1,59 @@
use std::{fmt::Debug, sync::atomic::Ordering};
use log::{debug, error, info};
use serde::de::DeserializeOwned;
use crate::{
helpers::execute_tachi_import, saekawa::GAME_MAJOR_VERSION, types::tachi::ToTachiImport,
CONFIGURATION,
};
pub fn score_handler<T>(body: String, guard: impl Fn(&T) -> bool)
where
T: Debug + DeserializeOwned + ToTachiImport,
{
let data = match serde_json::from_str::<T>(body.as_ref()) {
Ok(req) => req,
Err(err) => {
error!("Could not parse request body: {:#}", err);
return;
}
};
debug!("parsed request body: {:#?}", data);
if !guard(&data) {
return;
}
let import = data.to_tachi_import(
GAME_MAJOR_VERSION.load(Ordering::SeqCst),
CONFIGURATION.general.export_class,
CONFIGURATION.general.fail_over_lamp,
);
if import.scores.is_empty() {
if import.classes.is_none() {
return;
}
if import
.classes
.clone()
.is_some_and(|v| v.dan.is_none() && v.emblem.is_none())
{
return;
}
}
info!(
"Submitting {} scores from {} {}",
import.scores.len(),
data.displayed_id_type(),
data.displayed_id(),
);
if let Err(err) = execute_tachi_import(import) {
error!("{:#}", err);
}
}

View File

@ -1,334 +0,0 @@
use std::{fmt::Debug, io::Read, ptr};
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
use anyhow::{anyhow, Result};
use flate2::read::ZlibDecoder;
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
use widestring::U16CString;
use winapi::{
ctypes::c_void,
shared::{minwindef::TRUE, winerror::ERROR_INSUFFICIENT_BUFFER},
um::{
errhandlingapi::GetLastError,
winhttp::{
WinHttpQueryHeaders, WinHttpQueryOption, HINTERNET, WINHTTP_OPTION_URL,
WINHTTP_QUERY_FLAG_REQUEST_HEADERS, WINHTTP_QUERY_USER_AGENT,
},
},
};
use crate::{
types::tachi::{Import, ImportDocument, ImportPollStatus, TachiResponse, ImportResponse},
CONFIGURATION, TACHI_IMPORT_URL, UPSERT_USER_ALL_API_ENCRYPTED,
};
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
pub fn request_agent() -> ureq::Agent {
let timeout = CONFIGURATION.general.timeout;
let timeout = if timeout > 10000 { 10000 } else { timeout };
ureq::builder()
.timeout(std::time::Duration::from_millis(timeout))
.build()
}
fn request<T>(
method: impl AsRef<str>,
url: impl AsRef<str>,
body: Option<T>,
) -> Result<ureq::Response>
where
T: Serialize + Debug,
{
let agent = request_agent();
let method = method.as_ref();
let url = url.as_ref();
debug!("{} request to {} with body: {:#?}", method, url, body);
let authorization = format!("Bearer {}", CONFIGURATION.tachi.api_key);
let request = agent
.request(method, url)
.set("Authorization", authorization.as_str());
let response = match body {
Some(body) => request.send_json(body),
None => request.call(),
}
.map_err(|err| anyhow::anyhow!("Could not reach Tachi API: {:#}", err))?;
Ok(response)
}
pub fn request_tachi<T, R>(
method: impl AsRef<str>,
url: impl AsRef<str>,
body: Option<T>,
) -> Result<R>
where
T: Serialize + Debug,
R: for<'de> Deserialize<'de> + Debug,
{
let response = request(method, url, body)?;
let response = response.into_json()?;
debug!("Tachi API response: {:#?}", response);
Ok(response)
}
/// Queries a HINTERNET handle for its URL, then return the result.
pub fn read_hinternet_url(handle: HINTERNET) -> Result<String> {
let mut buf_length = 255;
let mut buffer = [0u16; 255];
let result = unsafe {
WinHttpQueryOption(
handle,
WINHTTP_OPTION_URL,
buffer.as_mut_ptr() as *mut c_void,
&mut buf_length,
)
};
if result == TRUE {
let url_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]);
return url_str
.to_string()
.map_err(|err| anyhow!("Could not decode wide string: {:#}", err));
}
let ec = unsafe { GetLastError() };
if ec == ERROR_INSUFFICIENT_BUFFER {
let mut buffer = vec![0u16; buf_length as usize];
let result = unsafe {
WinHttpQueryOption(
handle,
WINHTTP_OPTION_URL,
buffer.as_mut_ptr() as *mut c_void,
&mut buf_length,
)
};
if result != TRUE {
let ec = unsafe { GetLastError() };
return Err(anyhow!("Could not get URL from HINTERNET handle: {ec}"));
}
let url_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]);
return url_str
.to_string()
.map_err(|err| anyhow!("Could not decode wide string: {:#}", err));
}
let ec = unsafe { GetLastError() };
Err(anyhow!("Could not get URL from HINTERNET handle: {ec}"))
}
pub fn read_hinternet_user_agent(handle: HINTERNET) -> Result<String> {
let mut buf_length = 255;
let mut buffer = [0u16; 255];
let result = unsafe {
WinHttpQueryHeaders(
handle,
WINHTTP_QUERY_USER_AGENT | WINHTTP_QUERY_FLAG_REQUEST_HEADERS,
ptr::null(),
buffer.as_mut_ptr() as *mut c_void,
&mut buf_length,
ptr::null_mut(),
)
};
if result == TRUE {
let user_agent_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]);
return user_agent_str
.to_string()
.map_err(|err| anyhow!("Could not decode wide string: {:#}", err));
}
let ec = unsafe { GetLastError() };
if ec == ERROR_INSUFFICIENT_BUFFER {
let mut buffer = vec![0u16; buf_length as usize];
let result = unsafe {
WinHttpQueryHeaders(
handle,
WINHTTP_QUERY_USER_AGENT | WINHTTP_QUERY_FLAG_REQUEST_HEADERS,
ptr::null(),
buffer.as_mut_ptr() as *mut c_void,
&mut buf_length,
ptr::null_mut(),
)
};
if result != TRUE {
let ec = unsafe { GetLastError() };
return Err(anyhow!("Could not get URL from HINTERNET handle: {ec}"));
}
let user_agent_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]);
return user_agent_str
.to_string()
.map_err(|err| anyhow!("Could not decode wide string: {:#}", err));
}
let ec = unsafe { GetLastError() };
Err(anyhow!(
"Could not get User-Agent from HINTERNET handle: {ec}"
))
}
pub fn read_slice(buf: *const u8, len: usize) -> Result<Vec<u8>> {
let mut slice = unsafe { std::slice::from_raw_parts(buf, len) };
let mut ret = Vec::with_capacity(len);
slice.read_to_end(&mut ret)?;
Ok(ret)
}
pub fn read_maybe_compressed_buffer(buf: impl AsRef<[u8]>) -> Result<String> {
let mut ret = String::new();
let mut decoder = ZlibDecoder::new(buf.as_ref());
let zlib_result = decoder.read_to_string(&mut ret);
if zlib_result.is_ok() {
return Ok(ret);
}
ret.clear();
let result = buf.as_ref().read_to_string(&mut ret);
if result.is_ok() {
return Ok(ret);
}
Err(anyhow!(
"Could not decode contents of buffer as both DEFLATE-compressed ({:#}) and plaintext ({:#}) UTF-8 string.",
zlib_result.expect_err("This shouldn't happen, if Result was Ok the string should have been returned earlier."),
result.expect_err("This shouldn't happen, if Result was Ok the string should have been returned earlier."),
))
}
/// Determine if we're looking at the UpsertUserAllApi endpoint,
/// which is the endpoint that contains our scores.
pub fn is_upsert_user_all_endpoint(endpoint: &str) -> bool {
if endpoint == "UpsertUserAllApi" {
return true;
}
if UPSERT_USER_ALL_API_ENCRYPTED
.as_ref()
.is_some_and(|v| v == endpoint)
{
return true;
}
false
}
/// Determine if it is an encrypted endpoint by checking if the endpoint
/// is exactly 32 characters long, and consists of all hex characters.
///
/// While this may trigger false positives, this should not happen as long
/// as CHUNITHM title APIs keep their `{method}{object}Api` endpoint
/// convention.
pub fn is_encrypted_endpoint(endpoint: &str) -> bool {
if endpoint.len() != 32 {
return false;
}
// Lazy way to check if all digits are hexadecimal
if u128::from_str_radix(endpoint, 16).is_err() {
return false;
}
true
}
pub fn decrypt_aes256_cbc(
body: &mut [u8],
key: impl AsRef<[u8]>,
iv: impl AsRef<[u8]>,
) -> Result<Vec<u8>> {
let cipher = Aes256CbcDec::new_from_slices(key.as_ref(), iv.as_ref())?;
Ok(cipher
.decrypt_padded_mut::<Pkcs7>(body)
.map_err(|err| anyhow!(err))?
.to_owned())
}
pub fn log_import(description: &str, import: ImportDocument) {
info!(
"{description} {} scores, {} sessions, {} errors",
import.score_ids.len(),
import.created_sessions.len(),
import.errors.len()
);
for err in import.errors {
error!("{}: {}", err.error_type, err.message);
}
}
/// Executes a DIRECT-MANUAL import and logs some information on success.
///
/// ## Important
/// This function blocks until import has fully finished! It is best to call this in a separate thread.
pub fn execute_tachi_import(import: Import) -> Result<()> {
let resp: TachiResponse<ImportResponse> =
match request_tachi("POST", TACHI_IMPORT_URL.as_str(), Some(import)) {
Err(err) => {
return Err(anyhow!("Could not send scores to Tachi: {:#}", err));
}
Ok(resp) => resp,
};
let (body, description) = match resp {
TachiResponse::Err(err) => {
return Err(anyhow!(
"Tachi API returned an error: {:#}",
err.description
));
}
TachiResponse::Ok(resp) => (resp.body, resp.description),
};
let poll_url = match body {
ImportResponse::Queued { url, import_id: _ } => {
info!("Queued import for processing. Status URL: {}", url);
url
}
ImportResponse::Finished(import) => {
log_import(&description, import);
return Ok(());
}
};
loop {
let resp: TachiResponse<ImportPollStatus> =
match request_tachi("GET", &poll_url, None::<()>) {
Ok(resp) => resp,
Err(err) => {
error!("Could not poll import status: {:#}", err);
break;
}
};
let (body, description) = match resp {
TachiResponse::Ok(resp) => (resp.body, resp.description),
TachiResponse::Err(err) => {
return Err(anyhow!("Tachi API returned an error: {}", err.description));
}
};
match body {
ImportPollStatus::Completed { import } => {
log_import(&description, import);
return Ok(());
}
_ => {}
}
std::thread::sleep(std::time::Duration::from_secs(1));
}
Ok(())
}

16
src/helpers/crypto.rs Normal file
View File

@ -0,0 +1,16 @@
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
use anyhow::{anyhow, Result};
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
pub fn decrypt_aes256_cbc(
body: &mut [u8],
key: impl AsRef<[u8]>,
iv: impl AsRef<[u8]>,
) -> Result<Vec<u8>> {
let cipher = Aes256CbcDec::new_from_slices(key.as_ref(), iv.as_ref())?;
Ok(cipher
.decrypt_padded_mut::<Pkcs7>(body)
.map_err(|err| anyhow!(err))?
.to_owned())
}

58
src/helpers/endpoint.rs Normal file
View File

@ -0,0 +1,58 @@
use log::debug;
use pbkdf2::pbkdf2_hmac_array;
use sha1::Sha1;
use crate::CONFIGURATION;
pub fn is_endpoint(
endpoint: &str,
unencrypted_variant: &str,
encrypted_variant: &Option<String>,
) -> bool {
if endpoint == unencrypted_variant {
return true;
}
if encrypted_variant.as_ref().is_some_and(|v| v == endpoint) {
return true;
}
return false;
}
/// Determine if it is an encrypted endpoint by checking if the endpoint
/// is exactly 32 characters long, and consists of all hex characters.
///
/// While this may trigger false positives, this should not happen as long
/// as CHUNITHM title APIs keep their `{method}{object}Api` endpoint
/// convention.
pub fn is_encrypted_endpoint(endpoint: &str) -> bool {
if endpoint.len() != 32 {
return false;
}
// Lazy way to check if all digits are hexadecimal
if u128::from_str_radix(endpoint, 16).is_err() {
return false;
}
true
}
pub fn hash_endpoint(endpoint: &str) -> Option<String> {
if CONFIGURATION.crypto.salt.is_empty() {
return None;
}
let key_bytes = pbkdf2_hmac_array::<Sha1, 16>(
endpoint.as_bytes(),
&CONFIGURATION.crypto.salt,
CONFIGURATION.crypto.iterations,
);
let key = faster_hex::hex_string(&key_bytes);
debug!("Running with encryption support: {endpoint} maps to {key}");
Some(key)
}

114
src/helpers/hinternet.rs Normal file
View File

@ -0,0 +1,114 @@
use std::ptr;
use anyhow::{anyhow, Result};
use widestring::U16CString;
use winapi::{
ctypes::c_void,
shared::{minwindef::TRUE, winerror::ERROR_INSUFFICIENT_BUFFER},
um::{
errhandlingapi::GetLastError,
winhttp::{
WinHttpQueryHeaders, WinHttpQueryOption, HINTERNET, WINHTTP_OPTION_URL,
WINHTTP_QUERY_FLAG_REQUEST_HEADERS, WINHTTP_QUERY_USER_AGENT,
},
},
};
/// Queries a HINTERNET handle for its URL, then return the result.
pub fn read_hinternet_url(handle: HINTERNET) -> Result<String> {
let mut buf_length = 255;
let mut buffer = [0u16; 255];
let result = unsafe {
WinHttpQueryOption(
handle,
WINHTTP_OPTION_URL,
buffer.as_mut_ptr() as *mut c_void,
&mut buf_length,
)
};
if result == TRUE {
let url_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]);
return url_str
.to_string()
.map_err(|err| anyhow!("Could not decode wide string: {:#}", err));
}
let ec = unsafe { GetLastError() };
if ec == ERROR_INSUFFICIENT_BUFFER {
let mut buffer = vec![0u16; buf_length as usize];
let result = unsafe {
WinHttpQueryOption(
handle,
WINHTTP_OPTION_URL,
buffer.as_mut_ptr() as *mut c_void,
&mut buf_length,
)
};
if result != TRUE {
let ec = unsafe { GetLastError() };
return Err(anyhow!("Could not get URL from HINTERNET handle: {ec}"));
}
let url_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]);
return url_str
.to_string()
.map_err(|err| anyhow!("Could not decode wide string: {:#}", err));
}
let ec = unsafe { GetLastError() };
Err(anyhow!("Could not get URL from HINTERNET handle: {ec}"))
}
pub fn read_hinternet_user_agent(handle: HINTERNET) -> Result<String> {
let mut buf_length = 255;
let mut buffer = [0u16; 255];
let result = unsafe {
WinHttpQueryHeaders(
handle,
WINHTTP_QUERY_USER_AGENT | WINHTTP_QUERY_FLAG_REQUEST_HEADERS,
ptr::null(),
buffer.as_mut_ptr() as *mut c_void,
&mut buf_length,
ptr::null_mut(),
)
};
if result == TRUE {
let user_agent_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]);
return user_agent_str
.to_string()
.map_err(|err| anyhow!("Could not decode wide string: {:#}", err));
}
let ec = unsafe { GetLastError() };
if ec == ERROR_INSUFFICIENT_BUFFER {
let mut buffer = vec![0u16; buf_length as usize];
let result = unsafe {
WinHttpQueryHeaders(
handle,
WINHTTP_QUERY_USER_AGENT | WINHTTP_QUERY_FLAG_REQUEST_HEADERS,
ptr::null(),
buffer.as_mut_ptr() as *mut c_void,
&mut buf_length,
ptr::null_mut(),
)
};
if result != TRUE {
let ec = unsafe { GetLastError() };
return Err(anyhow!("Could not get URL from HINTERNET handle: {ec}"));
}
let user_agent_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]);
return user_agent_str
.to_string()
.map_err(|err| anyhow!("Could not decode wide string: {:#}", err));
}
let ec = unsafe { GetLastError() };
Err(anyhow!(
"Could not get User-Agent from HINTERNET handle: {ec}"
))
}

35
src/helpers/io.rs Normal file
View File

@ -0,0 +1,35 @@
use std::io::Read;
use anyhow::{anyhow, Result};
use flate2::read::ZlibDecoder;
pub fn read_slice(buf: *const u8, len: usize) -> Result<Vec<u8>> {
let mut slice = unsafe { std::slice::from_raw_parts(buf, len) };
let mut ret = Vec::with_capacity(len);
slice.read_to_end(&mut ret)?;
Ok(ret)
}
pub fn read_maybe_compressed_buffer(buf: impl AsRef<[u8]>) -> Result<String> {
let mut ret = String::new();
let mut decoder = ZlibDecoder::new(buf.as_ref());
let zlib_result = decoder.read_to_string(&mut ret);
if zlib_result.is_ok() {
return Ok(ret);
}
ret.clear();
let result = buf.as_ref().read_to_string(&mut ret);
if result.is_ok() {
return Ok(ret);
}
Err(anyhow!(
"Could not decode contents of buffer as both DEFLATE-compressed ({:#}) and plaintext ({:#}) UTF-8 string.",
zlib_result.expect_err("This shouldn't happen, if Result was Ok the string should have been returned earlier."),
result.expect_err("This shouldn't happen, if Result was Ok the string should have been returned earlier."),
))
}

11
src/helpers/mod.rs Normal file
View File

@ -0,0 +1,11 @@
mod crypto;
mod endpoint;
mod hinternet;
mod io;
mod net;
pub use crypto::*;
pub use endpoint::*;
pub use hinternet::*;
pub use io::*;
pub use net::*;

140
src/helpers/net.rs Normal file
View File

@ -0,0 +1,140 @@
use std::fmt::Debug;
use anyhow::{anyhow, Result};
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
use crate::{
types::tachi::{Import, ImportDocument, ImportPollStatus, ImportResponse, TachiResponse},
CONFIGURATION, TACHI_IMPORT_URL,
};
pub fn request_agent() -> ureq::Agent {
let timeout = CONFIGURATION.general.timeout;
let timeout = if timeout > 10000 { 10000 } else { timeout };
ureq::builder()
.timeout(std::time::Duration::from_millis(timeout))
.build()
}
fn request<T>(
method: impl AsRef<str>,
url: impl AsRef<str>,
body: Option<T>,
) -> Result<ureq::Response>
where
T: Serialize + Debug,
{
let agent = request_agent();
let method = method.as_ref();
let url = url.as_ref();
debug!("{} request to {} with body: {:#?}", method, url, body);
let authorization = format!("Bearer {}", CONFIGURATION.tachi.api_key);
let request = agent
.request(method, url)
.set("Authorization", authorization.as_str());
let response = match body {
Some(body) => request.send_json(body),
None => request.call(),
}
.map_err(|err| anyhow::anyhow!("Could not reach Tachi API: {:#}", err))?;
Ok(response)
}
pub fn request_tachi<T, R>(
method: impl AsRef<str>,
url: impl AsRef<str>,
body: Option<T>,
) -> Result<R>
where
T: Serialize + Debug,
R: for<'de> Deserialize<'de> + Debug,
{
let response = request(method, url, body)?;
let response = response.into_json()?;
debug!("Tachi API response: {:#?}", response);
Ok(response)
}
fn log_import(description: &str, import: ImportDocument) {
info!(
"{description} {} scores, {} sessions, {} errors",
import.score_ids.len(),
import.created_sessions.len(),
import.errors.len()
);
for err in import.errors {
error!("{}: {}", err.error_type, err.message);
}
}
/// Executes a DIRECT-MANUAL import and logs some information on success.
///
/// ## Important
/// This function blocks until import has fully finished! It is best to call this in a separate thread.
pub fn execute_tachi_import(import: Import) -> Result<()> {
let resp: TachiResponse<ImportResponse> =
match request_tachi("POST", TACHI_IMPORT_URL.as_str(), Some(import)) {
Err(err) => {
return Err(anyhow!("Could not send scores to Tachi: {:#}", err));
}
Ok(resp) => resp,
};
let (body, description) = match resp {
TachiResponse::Err(err) => {
return Err(anyhow!(
"Tachi API returned an error: {:#}",
err.description
));
}
TachiResponse::Ok(resp) => (resp.body, resp.description),
};
let poll_url = match body {
ImportResponse::Queued { url, import_id: _ } => {
info!("Queued import for processing. Status URL: {}", url);
url
}
ImportResponse::Finished(import) => {
log_import(&description, import);
return Ok(());
}
};
loop {
let resp: TachiResponse<ImportPollStatus> =
match request_tachi("GET", &poll_url, None::<()>) {
Ok(resp) => resp,
Err(err) => {
error!("Could not poll import status: {:#}", err);
break;
}
};
let (body, description) = match resp {
TachiResponse::Ok(resp) => (resp.body, resp.description),
TachiResponse::Err(err) => {
return Err(anyhow!("Tachi API returned an error: {}", err.description));
}
};
match body {
ImportPollStatus::Completed { import } => {
log_import(&description, import);
return Ok(());
}
_ => {}
}
std::thread::sleep(std::time::Duration::from_secs(1));
}
Ok(())
}

234
src/icf.rs Normal file
View File

@ -0,0 +1,234 @@
use std::fmt::Display;
use aes::cipher::{block_padding::NoPadding, BlockDecryptMut, KeyIvInit};
use anyhow::{anyhow, Result};
use binary_reader::{BinaryReader, Endian};
use chrono::{NaiveDate, NaiveDateTime};
use log::warn;
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
const ICF_KEY: [u8; 16] = hex_literal::decode(&[env!("SAEKAWA_ICF_KEY").as_bytes()]);
const ICF_IV: [u8; 16] = hex_literal::decode(&[env!("SAEKAWA_ICF_IV").as_bytes()]);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Version {
pub major: u16,
pub minor: u8,
pub build: u8,
}
impl Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{:0>2}.{:0>2}", self.major, self.minor, self.build)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IcfInnerData {
pub id: String,
pub version: Version,
pub required_system_version: Version,
pub datetime: NaiveDateTime,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IcfPatchData {
pub id: String,
pub source_version: Version,
pub target_version: Version,
pub required_system_version: Version,
pub datetime: NaiveDateTime,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IcfData {
System(IcfInnerData),
App(IcfInnerData),
Patch(IcfPatchData),
Option(IcfInnerData),
}
fn decrypt_icf(data: &mut [u8], key: impl AsRef<[u8]>, iv: impl AsRef<[u8]>) -> Result<Vec<u8>> {
let size = data.len();
let mut decrypted = Vec::with_capacity(size);
for i in (0..size).step_by(4096) {
let from_start = i;
let bufsz = std::cmp::min(4096, size - from_start);
let buf = &data[i..i + bufsz];
let mut decbuf = vec![0; bufsz];
let cipher = Aes128CbcDec::new_from_slices(key.as_ref(), iv.as_ref())?;
cipher
.decrypt_padded_b2b_mut::<NoPadding>(buf, &mut decbuf)
.map_err(|err| anyhow!(err))?;
let xor1 = u64::from_le_bytes(decbuf[0..8].try_into()?) ^ (from_start as u64);
let xor2 = u64::from_le_bytes(decbuf[8..16].try_into()?) ^ (from_start as u64);
decrypted.extend(xor1.to_le_bytes());
decrypted.extend(xor2.to_le_bytes());
decrypted.extend(&decbuf[16..]);
}
Ok(decrypted)
}
pub fn decode_icf_container_data(
rd: &mut BinaryReader,
) -> Result<(Version, NaiveDateTime, Version)> {
let version = Version {
build: rd.read_u8()?,
minor: rd.read_u8()?,
major: rd.read_u16()?,
};
let datetime = NaiveDate::from_ymd_opt(
rd.read_i16()? as i32,
rd.read_u8()? as u32,
rd.read_u8()? as u32,
)
.ok_or(anyhow!("Invalid date"))?
.and_hms_milli_opt(
rd.read_u8()? as u32,
rd.read_u8()? as u32,
rd.read_u8()? as u32,
rd.read_u8()? as u32,
)
.ok_or(anyhow!("Invalid time"))?;
let required_system_version = Version {
build: rd.read_u8()?,
minor: rd.read_u8()?,
major: rd.read_u16()?,
};
Ok((version, datetime, required_system_version))
}
pub fn decode_icf(data: &mut [u8]) -> Result<Vec<IcfData>> {
let decrypted = decrypt_icf(data, ICF_KEY, ICF_IV)?;
let mut rd = BinaryReader::from_vec(&decrypted);
rd.endian = Endian::Little;
let checksum = crc32fast::hash(&decrypted[4..]);
let reported_crc = rd.read_u32()?;
if reported_crc != checksum {
return Err(anyhow!(
"Reported CRC32 ({reported_crc:02X}) does not match actual checksum ({checksum:02X})"
));
}
let reported_size = rd.read_u32()? as usize;
let actual_size = decrypted.len();
if actual_size != reported_size {
return Err(anyhow!(
"Reported size {reported_size} does not match actual size {actual_size}"
));
}
let padding = rd.read_u64()?;
if padding != 0 {
return Err(anyhow!("Padding error. Expected 8 NULL bytes."));
}
let entry_count: usize = rd.read_u64()?.try_into()?;
let expected_size = 0x40 * (entry_count + 1);
if actual_size != expected_size {
return Err(anyhow!("Expected size {expected_size} ({entry_count} entries) does not match actual size {actual_size}"));
}
let app_id = String::from_utf8(rd.read_bytes(4)?.to_vec())?;
let platform_id = String::from_utf8(rd.read_bytes(3)?.to_vec())?;
let _platform_generation = rd.read_u8()?;
let reported_crc = rd.read_u32()?;
let mut checksum = 0;
for i in 1..=entry_count {
let container = &decrypted[0x40 * i..0x40 * (i + 1)];
if container[0] == 2 && container[1] == 1 {
checksum ^= crc32fast::hash(container);
}
}
if reported_crc != checksum {
return Err(anyhow!("Reported container CRC32 ({reported_crc:02X}) does not match actual checksum ({checksum:02X})"));
}
for _ in 0..7 {
if rd.read_u32()? != 0 {
return Err(anyhow!("Padding error. Expected 28 NULL bytes."));
}
}
let mut entries: Vec<IcfData> = Vec::with_capacity(entry_count);
for _ in 0..entry_count {
let sig = rd.read_bytes(4)?;
if sig[0] != 2 || sig[1] != 1 {
return Err(anyhow!("Container does not start with signature (0x0102)"));
}
let container_type = rd.read_u32()?;
for _ in 0..3 {
if rd.read_u64()? != 0 {
return Err(anyhow!("Padding error. Expected 24 NULL bytes."));
}
}
let (version, datetime, required_system_version) = decode_icf_container_data(&mut rd)?;
let data: IcfData = match container_type {
0x0000 | 0x0001 | 0x0002 => {
for _ in 0..2 {
if rd.read_u64()? != 0 {
return Err(anyhow!("Padding error. Expected 16 NULL bytes."));
}
}
match container_type {
0x0000 => IcfData::System(IcfInnerData {
id: platform_id.clone(),
version,
datetime,
required_system_version,
}),
0x0001 => IcfData::App(IcfInnerData {
id: app_id.clone(),
version,
datetime,
required_system_version,
}),
0x0002 => IcfData::Option(IcfInnerData {
id: app_id.clone(),
version,
datetime,
required_system_version,
}),
_ => unreachable!(),
}
}
0x0101 => {
let (target_version, _, _) = decode_icf_container_data(&mut rd)?;
IcfData::Patch(IcfPatchData {
id: app_id.clone(),
source_version: version,
target_version,
required_system_version,
datetime,
})
}
_ => {
warn!("Unknown ICF container type {container_type}");
continue;
}
};
entries.push(data);
}
Ok(entries)
}

View File

@ -1,18 +1,19 @@
mod configuration;
mod handlers;
mod helpers;
mod icf;
mod log;
mod saekawa;
mod types;
use ::log::{debug, error};
use ::log::error;
use lazy_static::lazy_static;
use pbkdf2::pbkdf2_hmac_array;
use sha1::Sha1;
use url::Url;
use winapi::shared::minwindef::{BOOL, DWORD, HINSTANCE, LPVOID, TRUE};
use winapi::um::winnt::{DLL_PROCESS_ATTACH, DLL_PROCESS_DETACH};
use crate::configuration::Configuration;
use crate::helpers::hash_endpoint;
use crate::log::Logger;
use crate::saekawa::{hook_init, hook_release};
@ -46,23 +47,9 @@ lazy_static! {
result.unwrap().to_string()
};
pub static ref UPSERT_USER_ALL_API_ENCRYPTED: Option<String> = {
if CONFIGURATION.crypto.salt.is_empty() {
return None;
}
let key_bytes = pbkdf2_hmac_array::<Sha1, 16>(
b"UpsertUserAllApi",
&CONFIGURATION.crypto.salt,
CONFIGURATION.crypto.iterations,
);
let key = faster_hex::hex_string(&key_bytes);
debug!("Running with encryption support: UpsertUserAllApi maps to {key}");
Some(key)
};
pub static ref UPSERT_USER_ALL_API_ENCRYPTED: Option<String> =
hash_endpoint("UpsertUserAllApi");
pub static ref GET_USER_MUSIC_API_ENCRYPTED: Option<String> = hash_endpoint("GetUserMusicApi");
}
fn init_logger() {

View File

@ -22,7 +22,9 @@ impl Logger {
impl Write for Logger {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if let Ok(c_str) = CString::new(buf) {
unsafe { OutputDebugStringA(c_str.as_ptr()); }
unsafe {
OutputDebugStringA(c_str.as_ptr());
}
}
let _ = std::io::stdout().write(buf);

View File

@ -1,37 +1,53 @@
use std::ffi::CString;
use std::{
ffi::CString,
fmt::Debug,
fs::File,
io::Read,
path::Path,
sync::atomic::{AtomicBool, AtomicU16, Ordering},
};
use ::log::{debug, error, info};
use anyhow::{anyhow, Result};
use log::warn;
use retour::static_detour;
use serde::de::DeserializeOwned;
use widestring::U16CString;
use winapi::{
ctypes::c_void,
shared::minwindef::{__some_function, BOOL, DWORD, LPCVOID, LPDWORD},
shared::minwindef::{__some_function, BOOL, DWORD, FALSE, LPCVOID, LPDWORD, LPVOID, MAX_PATH},
um::{
errhandlingapi::GetLastError,
libloaderapi::{GetModuleHandleA, GetProcAddress},
winbase::GetPrivateProfileStringW,
winhttp::HINTERNET,
},
};
use crate::{
configuration::{Configuration, GeneralConfiguration},
handlers::score_handler,
helpers::{
decrypt_aes256_cbc, execute_tachi_import, is_encrypted_endpoint,
is_upsert_user_all_endpoint, read_hinternet_url, read_hinternet_user_agent,
read_maybe_compressed_buffer, read_slice, request_tachi,
decrypt_aes256_cbc, is_encrypted_endpoint, is_endpoint, read_hinternet_url,
read_hinternet_user_agent, read_maybe_compressed_buffer, read_slice, request_tachi,
},
icf::{decode_icf, IcfData},
types::{
game::UpsertUserAllRequest,
tachi::{
ClassEmblem, Difficulty, Import, ImportClasses, ImportScore, StatusCheck, TachiResponse,
},
game::{UpsertUserAllRequest, UserMusicResponse},
tachi::{StatusCheck, TachiResponse, ToTachiImport},
},
CONFIGURATION, TACHI_STATUS_URL,
CONFIGURATION, GET_USER_MUSIC_API_ENCRYPTED, TACHI_STATUS_URL, UPSERT_USER_ALL_API_ENCRYPTED,
};
pub static GAME_MAJOR_VERSION: AtomicU16 = AtomicU16::new(0);
pub static PB_IMPORTED: AtomicBool = AtomicBool::new(true);
type WinHttpWriteDataFunc = unsafe extern "system" fn(HINTERNET, LPCVOID, DWORD, LPDWORD) -> BOOL;
type WinHttpReadDataFunc = unsafe extern "system" fn(HINTERNET, LPVOID, DWORD, LPDWORD) -> BOOL;
static_detour! {
static DetourWriteData: unsafe extern "system" fn (HINTERNET, LPCVOID, DWORD, LPDWORD) -> BOOL;
static DetourReadData: unsafe extern "system" fn(HINTERNET, LPVOID, DWORD, LPDWORD) -> BOOL;
}
pub fn hook_init() -> Result<()> {
@ -39,6 +55,75 @@ pub fn hook_init() -> Result<()> {
return Ok(());
}
if CONFIGURATION.general.export_pbs {
warn!("===============================================================================");
warn!("Exporting PBs is enabled. This should only be used once to sync up your scores!");
warn!("Leaving it on can make your profile messy! This will be automatically be turned off after exporting is finished.");
warn!("You can check when it's done by searching for the message 'Submitting x scores from user ID xxxxx'.");
warn!("===============================================================================");
PB_IMPORTED.store(false, Ordering::SeqCst);
}
debug!("Retrieving AMFS path from segatools.ini");
let mut buf = [0u16; MAX_PATH];
let amfs_cfg = unsafe {
let sz = GetPrivateProfileStringW(
U16CString::from_str_unchecked("vfs").as_ptr(),
U16CString::from_str_unchecked("amfs").as_ptr(),
U16CString::new().as_ptr(),
buf.as_mut_ptr(),
MAX_PATH as u32,
U16CString::from_str(".\\segatools.ini").unwrap().as_ptr(),
);
if sz == 0 {
let ec = GetLastError();
return Err(anyhow!(
"AMFS path not specified in segatools.ini, error code {ec}"
));
}
match U16CString::from_ptr(buf.as_ptr(), sz as usize) {
Ok(data) => data.to_string_lossy(),
Err(err) => {
return Err(anyhow!(
"could not read AMFS path from segatools.ini: {:#}",
err
));
}
}
};
let amfs_path = Path::new(&amfs_cfg);
let icf1_path = amfs_path.join("ICF1");
if !icf1_path.exists() {
return Err(anyhow!("Could not find ICF1 inside AMFS path. You will probably not be able to network without this file, so this hook will also be disabled."));
}
debug!("Reading ICF1 located at {:?}", icf1_path);
let mut icf1_buf = {
let mut icf1_file = File::open(icf1_path)?;
let mut icf1_buf = Vec::new();
icf1_file.read_to_end(&mut icf1_buf)?;
icf1_buf
};
let icf = decode_icf(&mut icf1_buf).map_err(|err| anyhow!("Reading ICF failed: {:#}", err))?;
for entry in icf {
match entry {
IcfData::App(app) => {
info!("Running on {} {}", app.id, app.version);
GAME_MAJOR_VERSION.store(app.version.major, Ordering::Relaxed);
}
_ => {}
}
}
debug!("Pinging Tachi API for status check and token verification");
let resp: TachiResponse<StatusCheck> =
request_tachi("GET", TACHI_STATUS_URL.as_str(), None::<()>)?;
let user_id = match resp {
@ -64,15 +149,37 @@ pub fn hook_init() -> Result<()> {
info!("Logged in to Tachi with userID {user_id}");
debug!("Acquring addresses");
let winhttpwritedata = unsafe {
let addr = get_proc_address("winhttp.dll", "WinHttpWriteData")
.map_err(|err| anyhow!("{:#}", err))?;
debug!("WinHttpWriteData: winhttp.dll!{:p}", addr);
std::mem::transmute::<_, WinHttpWriteDataFunc>(addr)
};
let winhttpreaddata = unsafe {
let addr = get_proc_address("winhttp.dll", "WinHttpReadData")
.map_err(|err| anyhow!("{:#}", err))?;
debug!("WinHttpReadData: winhttp.dll!{:p}", addr);
std::mem::transmute::<_, WinHttpReadDataFunc>(addr)
};
debug!("Initializing detours");
unsafe {
debug!("Initializing WinHttpWriteData detour");
DetourWriteData
.initialize(winhttpwritedata, winhttpwritedata_hook)?
.initialize(winhttpwritedata, winhttpwritedata_hook_wrapper)?
.enable()?;
debug!("Initializing WinHttpReadData detour");
DetourReadData
.initialize(winhttpreaddata, winhttpreaddata_hook_wrapper)?
.enable()?;
};
@ -86,12 +193,79 @@ pub fn hook_release() -> Result<()> {
return Ok(());
}
unsafe { DetourWriteData.disable()? };
if DetourWriteData.is_enabled() {
unsafe { DetourWriteData.disable()? };
}
if DetourReadData.is_enabled() {
unsafe { DetourReadData.disable()? };
}
Ok(())
}
fn winhttpwritedata_hook(
fn winhttpreaddata_hook_wrapper(
h_request: HINTERNET,
lp_buffer: LPVOID,
dw_number_of_bytes_to_read: DWORD,
lpdw_number_of_bytes_read: LPDWORD,
) -> BOOL {
debug!("hit winhttpreaddata");
let result = unsafe {
DetourReadData.call(
h_request,
lp_buffer,
dw_number_of_bytes_to_read,
lpdw_number_of_bytes_read,
)
};
if result == FALSE {
let ec = unsafe { GetLastError() };
error!("Calling original WinHttpReadData function failed: {ec}");
return result;
}
let pb_imported = PB_IMPORTED.load(Ordering::SeqCst);
if cfg!(not(debug_assertions)) && pb_imported {
return result;
}
if let Err(err) = winhttprwdata_hook::<UserMusicResponse>(
h_request,
lp_buffer,
dw_number_of_bytes_to_read,
"GetUserMusicApi",
&GET_USER_MUSIC_API_ENCRYPTED,
move |_| {
if pb_imported {
return false;
}
PB_IMPORTED.store(true, Ordering::Relaxed);
if let Err(err) = Configuration::update(Configuration {
general: GeneralConfiguration {
export_pbs: false,
..CONFIGURATION.general
},
cards: CONFIGURATION.cards.clone(),
crypto: CONFIGURATION.crypto.clone(),
tachi: CONFIGURATION.tachi.clone(),
}) {
error!("Could not update configuration to disable exporting PBs: {err:?}");
}
true
},
) {
error!("{err:?}");
}
result
}
fn winhttpwritedata_hook_wrapper(
h_request: HINTERNET,
lp_buffer: LPCVOID,
dw_number_of_bytes_to_write: DWORD,
@ -99,48 +273,62 @@ fn winhttpwritedata_hook(
) -> BOOL {
debug!("hit winhttpwritedata");
let orig = || unsafe {
if let Err(err) = winhttprwdata_hook::<UpsertUserAllRequest>(
h_request,
lp_buffer,
dw_number_of_bytes_to_write,
"UpsertUserAllApi",
&UPSERT_USER_ALL_API_ENCRYPTED,
|upsert_req| {
let user_data = &upsert_req.upsert_user_all.user_data[0];
let access_code = &user_data.access_code;
if !CONFIGURATION.cards.whitelist.is_empty()
&& !CONFIGURATION.cards.whitelist.contains(access_code)
{
info!("Card {access_code} is not whitelisted, skipping score submission");
return false;
}
true
},
) {
error!("{err:?}");
}
unsafe {
DetourWriteData.call(
h_request,
lp_buffer,
dw_number_of_bytes_to_write,
lpdw_number_of_bytes_written,
)
};
}
}
let url = match read_hinternet_url(h_request) {
Ok(url) => url,
Err(err) => {
error!("There was an error reading the request URL: {:#}", err);
return orig();
}
};
let user_agent = match read_hinternet_user_agent(h_request) {
Ok(ua) => ua,
Err(err) => {
error!(
"There was an error reading the request's User-Agent: {:#}",
err
);
return orig();
}
};
debug!("request from user-agent {user_agent} with URL: {url}");
/// Common hook for WinHttpWriteData/WinHttpReadData. The flow is similar for both
/// hooks:
/// - Read URL and User-Agent from the handle
/// - Extract the API method from the URL, and exit if it's not the method we're
/// looking for
/// - Determine if the API is encrypted, and exit if it is and we don't have keys
/// - Parse the body and convert it to Tachi's BATCH-MANUAL
/// - Submit it off to Tachi, if our guard function (which takes the parsed body) allows so.
fn winhttprwdata_hook<'a, T: Debug + DeserializeOwned + ToTachiImport + 'static>(
handle: HINTERNET,
buffer: *const c_void,
bufsz: DWORD,
unencrypted_endpoint: &str,
encrypted_endpoint: &Option<String>,
guard_fn: impl Fn(&T) -> bool + Send + 'static,
) -> Result<()> {
let url = read_hinternet_url(handle)?;
let user_agent = read_hinternet_user_agent(handle)?;
debug!("user-agent {user_agent}, URL: {url}");
// Quick explanation for this rodeo:
//
// Since CRYSTAL+, requests are encrypted with a hardcoded key/IV pair, and endpoints
// are supposed to be HMAC'd with a salt, and then stored in the User-Agent in the format
// of `{hashed_endpoint}#{numbers}`, as well as mangling the URL.
//
// However, there was an oversight in the implementation for versions PARADISE LOST
// and older: the endpoint stored in the User-Agent was not hashed. A mangled URL
// still indicates encryption was used, however, so you still need to provide key/IV. Only
// the salt is not needed.
let Some(maybe_endpoint) = url.split('/').last() else {
error!("Could not get name of endpoint");
return orig();
};
let maybe_endpoint = url
.split('/')
.last()
.ok_or(anyhow!("Could not extract last part of a split URL"))?;
let is_encrypted = is_encrypted_endpoint(maybe_endpoint);
@ -148,53 +336,50 @@ fn winhttpwritedata_hook(
user_agent
.split('#')
.next()
.expect("there should be at least one item in the split")
.ok_or(anyhow!("there should be at least one item in the split"))?
} else {
maybe_endpoint
};
let is_correct_endpoint = is_endpoint(endpoint, unencrypted_endpoint, encrypted_endpoint);
if cfg!(not(debug_assertions)) && !is_correct_endpoint {
return Ok(());
}
if is_encrypted && (CONFIGURATION.crypto.key.is_empty() || CONFIGURATION.crypto.iv.is_empty()) {
error!("Communications with the server is encrypted, but no keys were provided. Fill in the keys by editing 'saekawa.toml'.");
return orig();
return Err(anyhow!("Communications with the server is encrypted, but no keys were provided. Fill in the keys by editing 'saekawa.toml'."));
}
let is_upsert_user_all = is_upsert_user_all_endpoint(endpoint);
// Exit early if release mode and the endpoint is not what we're looking for
if cfg!(not(debug_assertions)) && !is_upsert_user_all {
return orig();
}
let mut raw_body = match read_slice(buffer as *const u8, bufsz as usize) {
Ok(data) => data,
Err(err) => {
return Err(anyhow!(
"There was an error reading the response body: {:#}",
err
));
}
};
let mut raw_request_body =
match read_slice(lp_buffer as *const u8, dw_number_of_bytes_to_write as usize) {
Ok(data) => data,
Err(err) => {
error!("There was an error reading the request body: {:#}", err);
return orig();
}
};
debug!("raw body: {}", faster_hex::hex_string(&raw_body));
debug!("raw request body: {}", faster_hex::hex_string(&raw_request_body));
// Rest of the processing can be safely moved to a different thread, since we're not dealing
// with the hooked function's stuff anymore, probably.
std::thread::spawn(move || {
let request_body_compressed = if is_encrypted {
let compressed_body = if is_encrypted {
match decrypt_aes256_cbc(
&mut raw_request_body,
&mut raw_body,
&CONFIGURATION.crypto.key,
&CONFIGURATION.crypto.iv,
) {
Ok(res) => res,
Err(err) => {
error!("Could not decrypt request: {:#}", err);
error!("Could not decrypt response: {:#}", err);
return;
}
}
} else {
raw_request_body
raw_body
};
let request_body = match read_maybe_compressed_buffer(&request_body_compressed[..]) {
let body = match read_maybe_compressed_buffer(&compressed_body[..]) {
Ok(data) => data,
Err(err) => {
error!("There was an error decoding the request body: {:#}", err);
@ -202,90 +387,17 @@ fn winhttpwritedata_hook(
}
};
debug!("decoded request body: {request_body}");
debug!("decoded response body: {body}");
// Reached in debug mode
if !is_upsert_user_all {
// Hit in debug build
if !is_correct_endpoint {
return;
}
let upsert_req = match serde_json::from_str::<UpsertUserAllRequest>(&request_body) {
Ok(req) => req,
Err(err) => {
error!("Could not parse request body: {:#}", err);
return;
}
};
debug!("parsed request body: {:#?}", upsert_req);
let user_data = &upsert_req.upsert_user_all.user_data[0];
let access_code = &user_data.access_code;
if !CONFIGURATION.cards.whitelist.is_empty()
&& !CONFIGURATION.cards.whitelist.contains(access_code)
{
info!("Card {access_code} is not whitelisted, skipping score submission");
return;
}
let classes = if CONFIGURATION.general.export_class {
Some(ImportClasses {
dan: ClassEmblem::try_from(user_data.class_emblem_medal).ok(),
emblem: ClassEmblem::try_from(user_data.class_emblem_base).ok(),
})
} else {
None
};
let scores = upsert_req
.upsert_user_all
.user_playlog_list
.into_iter()
.filter_map(|playlog| {
let result =
ImportScore::try_from_playlog(playlog, CONFIGURATION.general.fail_over_lamp);
if result
.as_ref()
.is_ok_and(|v| v.difficulty != Difficulty::WorldsEnd)
{
result.ok()
} else {
None
}
})
.collect::<Vec<ImportScore>>();
if scores.is_empty() {
if classes.is_none() {
return;
}
if classes
.clone()
.is_some_and(|v| v.dan.is_none() && v.emblem.is_none())
{
return;
}
}
let import = Import {
classes,
scores,
..Default::default()
};
info!(
"Submitting {} scores from access code {}",
import.scores.len(),
user_data.access_code
);
if let Err(err) = execute_tachi_import(import) {
error!("{:#}", err);
}
score_handler::<T>(body, guard_fn)
});
orig()
Ok(())
}
fn get_proc_address(module: &str, function: &str) -> Result<*mut __some_function> {

View File

@ -5,12 +5,22 @@ fn deserialize_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: de::Deserializer<'de>,
{
let s: &str = de::Deserialize::deserialize(deserializer)?;
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrBoolean {
String(String),
Bool(bool),
}
let s: StringOrBoolean = de::Deserialize::deserialize(deserializer)?;
match s {
"true" => Ok(true),
"false" => Ok(false),
_ => Err(de::Error::unknown_variant(s, &["true", "false"])),
StringOrBoolean::String(s) => match s.as_str() {
"true" => Ok(true),
"false" => Ok(false),
_ => Err(de::Error::unknown_variant(&s, &["true", "false"])),
},
StringOrBoolean::Bool(b) => Ok(b),
}
}
@ -80,6 +90,43 @@ fn default_judge_heaven() -> u32 {
0
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserMusicDetail {
#[serde(deserialize_with = "deserialize_number_from_string")]
pub music_id: u32,
#[serde(deserialize_with = "deserialize_number_from_string")]
pub level: u32,
#[serde(deserialize_with = "deserialize_number_from_string")]
pub score_max: u32,
#[serde(deserialize_with = "deserialize_bool")]
pub is_all_justice: bool,
#[serde(deserialize_with = "deserialize_bool")]
pub is_full_combo: bool,
#[serde(deserialize_with = "deserialize_bool")]
pub is_success: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserMusicItem {
pub length: u32,
pub user_music_detail_list: Vec<UserMusicDetail>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserMusicResponse {
pub user_id: String,
pub length: u32,
pub user_music_list: Vec<UserMusicItem>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpsertUserAllBody {

View File

@ -4,7 +4,7 @@ use num_enum::TryFromPrimitive;
use serde::{de, Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
use super::game::UserPlaylog;
use super::game::{UpsertUserAllRequest, UserMusicDetail, UserMusicResponse, UserPlaylog};
#[derive(Debug, Clone)]
pub enum TachiResponse<T> {
@ -178,9 +178,15 @@ pub struct ImportScore {
pub match_type: String,
pub identifier: String,
pub difficulty: Difficulty,
pub time_achieved: u128,
pub judgements: Judgements,
pub optional: OptionalMetrics,
#[serde(skip_serializing_if = "Option::is_none")]
pub time_achieved: Option<u128>,
#[serde(skip_serializing_if = "Option::is_none")]
pub judgements: Option<Judgements>,
#[serde(skip_serializing_if = "Option::is_none")]
pub optional: Option<OptionalMetrics>,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)]
@ -239,7 +245,11 @@ pub struct OptionalMetrics {
}
impl ImportScore {
pub fn try_from_playlog(p: UserPlaylog, fail_over_lamp: bool) -> Result<ImportScore> {
pub fn try_from_playlog(
p: &UserPlaylog,
major_version: u16,
fail_over_lamp: bool,
) -> Result<ImportScore> {
let lamp = if !p.is_clear && fail_over_lamp {
TachiLamp::Failed
} else if p.is_all_justice {
@ -263,8 +273,7 @@ impl ImportScore {
miss: p.judge_guilty,
};
let rom_major_version = p.rom_version.split('.').next().unwrap_or("2");
let difficulty = if rom_major_version == "1" && p.level == 4 {
let difficulty = if major_version == 1 && p.level == 4 {
Difficulty::WorldsEnd
} else {
Difficulty::try_from(p.level)?
@ -279,13 +288,148 @@ impl ImportScore {
score: p.score,
lamp,
match_type: "inGameID".to_string(),
identifier: p.music_id,
identifier: p.music_id.clone(),
difficulty,
time_achieved: jst_time.timestamp_millis() as u128,
judgements,
optional: OptionalMetrics {
time_achieved: Some(jst_time.timestamp_millis() as u128),
judgements: Some(judgements),
optional: Some(OptionalMetrics {
max_combo: p.max_combo,
},
}),
})
}
fn try_from_music_detail(
d: &UserMusicDetail,
major_version: u16,
fail_over_lamp: bool,
) -> Result<ImportScore> {
let lamp = if !d.is_success && fail_over_lamp {
TachiLamp::Failed
} else if d.is_all_justice {
TachiLamp::AllJustice
} else if d.is_full_combo {
TachiLamp::FullCombo
} else if d.is_success {
TachiLamp::Clear
} else {
TachiLamp::Failed
};
let difficulty = if major_version == 1 && d.level == 4 {
Difficulty::WorldsEnd
} else {
Difficulty::try_from(d.level)?
};
Ok(ImportScore {
score: d.score_max,
lamp,
match_type: "inGameID".to_string(),
identifier: d.music_id.to_string(),
difficulty,
time_achieved: None,
judgements: None,
optional: None,
})
}
}
pub trait ToTachiImport {
fn displayed_id(&self) -> &str;
fn displayed_id_type(&self) -> &str;
fn to_tachi_import(
&self,
major_version: u16,
export_class: bool,
fail_over_lamp: bool,
) -> Import;
}
impl ToTachiImport for UserMusicResponse {
fn displayed_id(&self) -> &str {
&self.user_id
}
fn displayed_id_type(&self) -> &str {
"user ID"
}
fn to_tachi_import(&self, major_version: u16, _: bool, fail_over_lamp: bool) -> Import {
let scores = self
.user_music_list
.iter()
.flat_map(|item| {
item.user_music_detail_list.iter().filter_map(|d| {
let result =
ImportScore::try_from_music_detail(d, major_version, fail_over_lamp);
if result
.as_ref()
.is_ok_and(|v| v.difficulty != Difficulty::WorldsEnd)
{
result.ok()
} else {
None
}
})
})
.collect::<Vec<_>>();
Import {
scores,
..Default::default()
}
}
}
impl ToTachiImport for UpsertUserAllRequest {
fn displayed_id(&self) -> &str {
let user_data = &self.upsert_user_all.user_data[0];
&user_data.access_code
}
fn displayed_id_type(&self) -> &str {
"access code"
}
fn to_tachi_import(
&self,
major_version: u16,
export_class: bool,
fail_over_lamp: bool,
) -> Import {
let user_data = &self.upsert_user_all.user_data[0];
let classes = if export_class {
Some(ImportClasses {
dan: ClassEmblem::try_from(user_data.class_emblem_medal).ok(),
emblem: ClassEmblem::try_from(user_data.class_emblem_base).ok(),
})
} else {
None
};
let scores = self
.upsert_user_all
.user_playlog_list
.iter()
.filter_map(|playlog| {
let result = ImportScore::try_from_playlog(playlog, major_version, fail_over_lamp);
if result
.as_ref()
.is_ok_and(|v| v.difficulty != Difficulty::WorldsEnd)
{
result.ok()
} else {
None
}
})
.collect::<Vec<ImportScore>>();
Import {
classes,
scores,
..Default::default()
}
}
}