mirror of
https://github.com/beerpiss/saekawa.git
synced 2024-11-12 01:30:51 +01:00
feat: massive feature pack
* support for salt-less encrypted configs on paradise lost and older * json serializing to concrete types * poll imports for status updates * fire off imports in a separate thread
This commit is contained in:
parent
b989dd0ea1
commit
18a60e1811
@ -17,11 +17,18 @@ whitelist = []
|
||||
|
||||
[crypto]
|
||||
# Since CRYSTAL+, the game employs network encryption. If you do not wish to
|
||||
# patch your game to disable encryption, you will need to fill in these values.
|
||||
# patch out encryption from your game, you will need to fill in these values.
|
||||
#
|
||||
# All values are in hex strings.
|
||||
key = ""
|
||||
iv = ""
|
||||
|
||||
# Salt for deriving hashed API endpoint names. Not necessary on PARADISE LOST
|
||||
# and older. For CHUNITHM NEW and later versions, this must be set if encryption
|
||||
# is not patched out, otherwise the hook will be active but doing nothing, since
|
||||
# it cannot recognize the score upload endpoint.
|
||||
#
|
||||
# This must also be a hex string.
|
||||
salt = ""
|
||||
|
||||
# Number of PBKDF2 iterations to hash the endpoint URL. Set to 70 for CHUNITHM SUN
|
||||
|
157
src/helpers.rs
157
src/helpers.rs
@ -1,9 +1,9 @@
|
||||
use std::{fmt::Debug, io::Read};
|
||||
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;
|
||||
use log::{debug, error, info};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use widestring::U16CString;
|
||||
use winapi::{
|
||||
@ -11,11 +11,17 @@ use winapi::{
|
||||
shared::{minwindef::TRUE, winerror::ERROR_INSUFFICIENT_BUFFER},
|
||||
um::{
|
||||
errhandlingapi::GetLastError,
|
||||
winhttp::{WinHttpQueryOption, HINTERNET, WINHTTP_OPTION_URL},
|
||||
winhttp::{
|
||||
WinHttpQueryHeaders, WinHttpQueryOption, HINTERNET, WINHTTP_OPTION_URL,
|
||||
WINHTTP_QUERY_FLAG_REQUEST_HEADERS, WINHTTP_QUERY_USER_AGENT,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{CONFIGURATION, UPSERT_USER_ALL_API_ENCRYPTED};
|
||||
use crate::{
|
||||
types::tachi::{Import, ImportDocument, ImportPollStatus, TachiResponse, ImportResponse},
|
||||
CONFIGURATION, TACHI_IMPORT_URL, UPSERT_USER_ALL_API_ENCRYPTED,
|
||||
};
|
||||
|
||||
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
|
||||
|
||||
@ -55,17 +61,6 @@ where
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn call_tachi<T>(method: impl AsRef<str>, url: impl AsRef<str>, body: Option<T>) -> Result<()>
|
||||
where
|
||||
T: Serialize + Debug,
|
||||
{
|
||||
let response = request(method, url, body)?;
|
||||
let response: serde_json::Value = response.into_json()?;
|
||||
debug!("Tachi API response: {:#?}", response);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn request_tachi<T, R>(
|
||||
method: impl AsRef<str>,
|
||||
url: impl AsRef<str>,
|
||||
@ -129,6 +124,58 @@ pub fn read_hinternet_url(handle: HINTERNET) -> Result<String> {
|
||||
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);
|
||||
@ -204,6 +251,84 @@ pub fn decrypt_aes256_cbc(
|
||||
let cipher = Aes256CbcDec::new_from_slices(key.as_ref(), iv.as_ref())?;
|
||||
Ok(cipher
|
||||
.decrypt_padded_mut::<Pkcs7>(body)
|
||||
.map_err(|err| anyhow!("{:#}", err))?
|
||||
.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(())
|
||||
}
|
||||
|
282
src/saekawa.rs
282
src/saekawa.rs
@ -15,14 +15,17 @@ use winapi::{
|
||||
|
||||
use crate::{
|
||||
helpers::{
|
||||
call_tachi, decrypt_aes256_cbc, is_encrypted_endpoint, is_upsert_user_all_endpoint,
|
||||
read_hinternet_url, read_maybe_compressed_buffer, read_slice, request_tachi,
|
||||
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,
|
||||
},
|
||||
types::{
|
||||
game::UpsertUserAllRequest,
|
||||
tachi::{ClassEmblem, Difficulty, Import, ImportClasses, ImportScore},
|
||||
tachi::{
|
||||
ClassEmblem, Difficulty, Import, ImportClasses, ImportScore, StatusCheck, TachiResponse,
|
||||
},
|
||||
},
|
||||
CONFIGURATION, TACHI_IMPORT_URL, TACHI_STATUS_URL, UPSERT_USER_ALL_API_ENCRYPTED,
|
||||
CONFIGURATION, TACHI_STATUS_URL,
|
||||
};
|
||||
|
||||
type WinHttpWriteDataFunc = unsafe extern "system" fn(HINTERNET, LPCVOID, DWORD, LPDWORD) -> BOOL;
|
||||
@ -36,22 +39,28 @@ pub fn hook_init() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let resp: serde_json::Value = request_tachi("GET", TACHI_STATUS_URL.as_str(), None::<()>)?;
|
||||
let user_id = resp["body"]["whoami"]
|
||||
.as_u64()
|
||||
.ok_or(anyhow::anyhow!("Couldn't parse user from Tachi response"))?;
|
||||
let resp: TachiResponse<StatusCheck> =
|
||||
request_tachi("GET", TACHI_STATUS_URL.as_str(), None::<()>)?;
|
||||
let user_id = match resp {
|
||||
TachiResponse::Err(err) => {
|
||||
return Err(anyhow!("Tachi API returned an error: {}", err.description));
|
||||
}
|
||||
TachiResponse::Ok(resp) => {
|
||||
if !resp.body.permissions.iter().any(|v| v == "submit_score") {
|
||||
return Err(anyhow!(
|
||||
"API key has insufficient permission. The permission submit_score must be set."
|
||||
));
|
||||
}
|
||||
|
||||
let mut permissions = resp["body"]["permissions"]
|
||||
.as_array()
|
||||
.ok_or(anyhow!("Couldn't parse permissions from Tachi response"))?
|
||||
.into_iter()
|
||||
.filter_map(|v| v.as_str());
|
||||
let Some(user_id) = resp.body.whoami else {
|
||||
return Err(anyhow!(
|
||||
"Status check was successful, yet API returned userID null?"
|
||||
));
|
||||
};
|
||||
|
||||
if permissions.all(|v| v != "submit_score") {
|
||||
return Err(anyhow!(
|
||||
"API key has insufficient permission. The permission submit_score must be set."
|
||||
));
|
||||
}
|
||||
user_id
|
||||
}
|
||||
};
|
||||
|
||||
info!("Logged in to Tachi with userID {user_id}");
|
||||
|
||||
@ -106,26 +115,52 @@ fn winhttpwritedata_hook(
|
||||
return orig();
|
||||
}
|
||||
};
|
||||
debug!("winhttpwritedata URL: {url}");
|
||||
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}");
|
||||
|
||||
let Some(endpoint) = url.split('/').last() else {
|
||||
// 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 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 {
|
||||
let is_encrypted = is_encrypted_endpoint(maybe_endpoint);
|
||||
|
||||
let endpoint = if is_encrypted && user_agent.contains('#') {
|
||||
user_agent
|
||||
.split('#')
|
||||
.next()
|
||||
.expect("there should be at least one item in the split")
|
||||
} else {
|
||||
maybe_endpoint
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
let is_encrypted = is_encrypted_endpoint(endpoint);
|
||||
if is_encrypted
|
||||
&& (CONFIGURATION.crypto.key.is_empty()
|
||||
|| CONFIGURATION.crypto.iv.is_empty()
|
||||
|| UPSERT_USER_ALL_API_ENCRYPTED.is_none())
|
||||
{
|
||||
error!("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();
|
||||
}
|
||||
|
||||
@ -140,106 +175,115 @@ fn winhttpwritedata_hook(
|
||||
|
||||
debug!("raw request body: {:?}", raw_request_body);
|
||||
|
||||
let request_body_compressed = if is_encrypted {
|
||||
match decrypt_aes256_cbc(
|
||||
&mut raw_request_body,
|
||||
&CONFIGURATION.crypto.key,
|
||||
&CONFIGURATION.crypto.iv,
|
||||
) {
|
||||
Ok(res) => res,
|
||||
// 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 {
|
||||
match decrypt_aes256_cbc(
|
||||
&mut raw_request_body,
|
||||
&CONFIGURATION.crypto.key,
|
||||
&CONFIGURATION.crypto.iv,
|
||||
) {
|
||||
Ok(res) => res,
|
||||
Err(err) => {
|
||||
error!("Could not decrypt request: {:#}", err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
raw_request_body
|
||||
};
|
||||
|
||||
let request_body = match read_maybe_compressed_buffer(&request_body_compressed[..]) {
|
||||
Ok(data) => data,
|
||||
Err(err) => {
|
||||
error!("Could not decrypt request: {:#}", err);
|
||||
return orig();
|
||||
error!("There was an error decoding the request body: {:#}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
debug!("decoded request body: {request_body}");
|
||||
|
||||
// Reached in debug mode
|
||||
if !is_upsert_user_all {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
raw_request_body
|
||||
};
|
||||
|
||||
let request_body = match read_maybe_compressed_buffer(&request_body_compressed[..]) {
|
||||
Ok(data) => data,
|
||||
Err(err) => {
|
||||
error!("There was an error decoding the request body: {:#}", err);
|
||||
return orig();
|
||||
}
|
||||
};
|
||||
|
||||
debug!("decoded request body: {request_body}");
|
||||
|
||||
// Reached in debug mode
|
||||
if !is_upsert_user_all {
|
||||
return orig();
|
||||
}
|
||||
|
||||
let upsert_req = match serde_json::from_str::<UpsertUserAllRequest>(&request_body) {
|
||||
Ok(req) => req,
|
||||
Err(err) => {
|
||||
error!("Could not parse request body: {:#}", err);
|
||||
return orig();
|
||||
}
|
||||
};
|
||||
|
||||
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 orig();
|
||||
}
|
||||
|
||||
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
|
||||
let upsert_req = match serde_json::from_str::<UpsertUserAllRequest>(&request_body) {
|
||||
Ok(req) => req,
|
||||
Err(err) => {
|
||||
error!("Could not parse request body: {:#}", err);
|
||||
return;
|
||||
}
|
||||
})
|
||||
.collect::<Vec<ImportScore>>();
|
||||
};
|
||||
|
||||
if scores.is_empty() {
|
||||
if classes.is_none() {
|
||||
return orig();
|
||||
}
|
||||
debug!("parsed request body: {:#?}", upsert_req);
|
||||
|
||||
if classes
|
||||
.clone()
|
||||
.is_some_and(|v| v.dan.is_none() && v.emblem.is_none())
|
||||
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)
|
||||
{
|
||||
return orig();
|
||||
info!("Card {access_code} is not whitelisted, skipping score submission");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let import = Import {
|
||||
classes,
|
||||
scores,
|
||||
..Default::default()
|
||||
};
|
||||
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
|
||||
};
|
||||
|
||||
match call_tachi("POST", TACHI_IMPORT_URL.as_str(), Some(import)) {
|
||||
Ok(_) => info!("Successfully imported scores for card {access_code}"),
|
||||
Err(err) => error!("Could not import scores for card {access_code}: {:#}", err),
|
||||
};
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
orig()
|
||||
}
|
||||
|
@ -1,10 +1,119 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{FixedOffset, NaiveDateTime, TimeZone};
|
||||
use num_enum::TryFromPrimitive;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{de, Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::game::UserPlaylog;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TachiResponse<T> {
|
||||
Ok(TachiSuccessResponse<T>),
|
||||
Err(TachiErrorResponse),
|
||||
}
|
||||
|
||||
impl<'de, T> Deserialize<'de> for TachiResponse<T>
|
||||
where
|
||||
T: Deserialize<'de>,
|
||||
{
|
||||
fn deserialize<D>(deserializer: D) -> Result<TachiResponse<T>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let mut map = Map::deserialize(deserializer)?;
|
||||
|
||||
let success = map
|
||||
.remove("success")
|
||||
.ok_or_else(|| de::Error::missing_field("success"))
|
||||
.map(Deserialize::deserialize)?
|
||||
.map_err(de::Error::custom)?;
|
||||
let rest = Value::Object(map);
|
||||
|
||||
if success {
|
||||
TachiSuccessResponse::deserialize(rest)
|
||||
.map(TachiResponse::Ok)
|
||||
.map_err(de::Error::custom)
|
||||
} else {
|
||||
TachiErrorResponse::deserialize(rest)
|
||||
.map(TachiResponse::Err)
|
||||
.map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TachiSuccessResponse<T> {
|
||||
pub description: String,
|
||||
pub body: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TachiErrorResponse {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct StatusCheck {
|
||||
pub permissions: Vec<String>,
|
||||
pub whoami: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ImportResponse {
|
||||
Queued {
|
||||
url: String,
|
||||
|
||||
#[serde(rename = "importID")]
|
||||
import_id: String,
|
||||
},
|
||||
Finished(ImportDocument),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ImportDocument {
|
||||
#[serde(rename = "scoreIDs")]
|
||||
pub score_ids: Vec<String>,
|
||||
|
||||
pub errors: Vec<ImportErrContent>,
|
||||
|
||||
#[serde(rename = "createdSessions")]
|
||||
pub created_sessions: Vec<SessionInfoReturn>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ImportErrContent {
|
||||
#[serde(rename = "type")]
|
||||
pub error_type: String,
|
||||
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SessionInfoReturn {
|
||||
#[serde(rename = "type")]
|
||||
pub session_type: String,
|
||||
|
||||
#[serde(rename = "sessionID")]
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "importStatus")]
|
||||
pub enum ImportPollStatus {
|
||||
#[serde(rename = "ongoing")]
|
||||
Ongoing { progress: ImportProgress },
|
||||
|
||||
#[serde(rename = "completed")]
|
||||
Completed { import: ImportDocument },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ImportProgress {
|
||||
pub description: String,
|
||||
pub value: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Import {
|
||||
pub meta: ImportMeta,
|
||||
|
Loading…
Reference in New Issue
Block a user