You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

242 lines
7.9 KiB

use std::io::{Read, Write};
static BUNDLE_ID: &str = "de.heinekingmedia.inhouse.dsbmobile.web";
static LOGIN_DATA_APITAG: &[u8] = b"__LASTFOCUS=&__VIEWSTATE=%2FwEPDwULLTEyMzYwN\
jY5NTAPZBYCAgMPFgIeBXN0eWxlBS1iYWNrZ3JvdW5kLWltYWdlOnVybCgnaW1nL2JnX21vcm5pb\
mcuanBnJyk7KTsWAgIBD2QWAgIJDxYCHgRUZXh0BQRXRUIxZGSN%2BjtYE9gRM9l4lHibtfPe4LZ\
eEk7Ux%2Fcq1E43S%2B08Lg%3D%3D&__VIEWSTATEGENERATOR=C2EE9ABB&__EVENTTARGET=&\
__EVENTARGUMENT=&__EVENTVALIDATION=%2FwEdAATQ7TzOYK6kE0lH%2Bc499SptDFTzKcXJq\
Lg%2BOeJ6QAEa2kPTPkdPWl%2B8YN2NtDCtxic2kbNk7CH1EMXf%2B0xXlqLzXXr8CpBQh17r6W3\
v5y0TGLi7T6d707LeFt4rJdxONTI%3D&ctl03=Anmelden";
// we should try "https://app.dsbcontrol.de/JsonHandler.ashx/GetData"...
static API_URL_GET_DATA: &str =
"https://www.dsbmobile.de/jhw-ecd92528-a4b9-425f-89ee-c7038c72b9a6.ashx/GetData";
static API_URL_LOGIN: &str = "https://www.dsbmobile.de/Login.aspx";
static API_URL_DEFAULT: &str = "https://www.dsbmobile.de/default.aspx";
static SUFFIX_PREVIEW: &str = "/preview.png";
#[derive(Debug, thiserror::Error)]
pub enum ApiProtocolErrorKind {
#[error("API endpoint rejected request due to invalid parameters (invalid UTF-8)")]
InvalidParams(#[from] std::str::Utf8Error),
#[error("unable to parse JSON: {0}")]
Json(#[from] json::Error),
#[error("(unmarshal) unable to decode base64 value: {0}")]
Base64(#[from] base64::DecodeError),
#[error("gzip error: {0}")]
Gzip(#[from] std::io::Error),
#[error("JSON key '{0}' not found in JHW API response")]
JsonKeyNotFound(std::borrow::Cow<'static, str>),
}
#[derive(Debug)]
pub enum ApiDirection {
Request,
Response,
}
#[derive(Debug, thiserror::Error)]
#[error("API protocol error ({direction:?} {}marshalled): {kind}", if *.marshalled { "" } else { "un" })]
pub struct ApiProtocolError {
marshalled: bool,
direction: ApiDirection,
#[source]
kind: ApiProtocolErrorKind,
}
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("curl: {0}")]
Curl(#[from] curl::Error),
#[error("dsb path not found in response JSON")]
DsbPathNotFound,
#[error(transparent)]
ApiProtocol(#[from] ApiProtocolError),
}
fn curl_postrd_helper<'a>(
mut data: &'a [u8],
) -> impl FnMut(&mut [u8]) -> Result<usize, curl::easy::ReadError> + 'a {
move |into| Ok(data.read(into).unwrap())
}
fn jhw_marshal(data: &json::JsonValue) -> Result<String, ApiProtocolError> {
use flate2::Compression;
let mut e = flate2::write::GzEncoder::new(Vec::new(), Compression::default());
e.write_all(data.dump().as_bytes())
.map_err(|e| ApiProtocolError {
marshalled: false,
direction: ApiDirection::Request,
kind: e.into(),
})?;
let rqd = base64::encode(e.finish().map_err(|e| ApiProtocolError {
marshalled: true,
direction: ApiDirection::Request,
kind: e.into(),
})?);
Ok(json::object!(
"req" => json::object!(
"Data" => rqd,
"DataType" => 1,
),
)
.dump())
}
fn jhw_unmarshal(data: &[u8]) -> Result<json::JsonValue, ApiProtocolError> {
fn map_err_marshalled(e: impl Into<ApiProtocolErrorKind>) -> ApiProtocolError {
ApiProtocolError {
marshalled: true,
direction: ApiDirection::Response,
kind: e.into(),
}
}
// response unmarshalling
let dat_res = {
let json_r = json::parse(std::str::from_utf8(data).map_err(map_err_marshalled)?)
.map_err(map_err_marshalled)?;
if let json::JsonValue::String(ref x) = &json_r["d"] {
let buf2 = base64::decode(x.as_bytes()).map_err(map_err_marshalled)?;
let mut d = flate2::bufread::GzDecoder::new(&buf2[..]);
let mut s = String::new();
d.read_to_string(&mut s).map_err(map_err_marshalled)?;
s
} else {
return Err(ApiProtocolError {
marshalled: true,
direction: ApiDirection::Response,
kind: ApiProtocolErrorKind::JsonKeyNotFound("d".into()),
});
}
};
// parse json in response
json::parse(&dat_res).map_err(|e| ApiProtocolError {
marshalled: false,
direction: ApiDirection::Response,
kind: e.into(),
})
}
fn query_jhw(progbar: &indicatif::ProgressBar, user: &str, pass: &str) -> Result<json::JsonValue, ApiError> {
use curl::easy::Easy;
let mut h = Easy::new();
h.cookie_file("")?;
// 1. login
{
h.url(API_URL_LOGIN)?;
h.post(true)?;
let mut logdat = Vec::new();
logdat.extend(LOGIN_DATA_APITAG.iter());
logdat.extend(format!("&txtUser={}&txtPass={}", user, pass).as_bytes());
let mut htr = h.transfer();
htr.read_function(curl_postrd_helper(&logdat[..]))?;
htr.perform()?;
}
progbar.inc(1);
// 2. JHW API call
let dat_res = {
use chrono::prelude::*;
h.url(API_URL_GET_DATA)?;
h.post(true)?;
let mut hdrs = curl::easy::List::new();
hdrs.append("Accept: application/json, text/javascript, */*; q=0.01")?;
hdrs.append("Accept-Encoding: gzip, deflate, br")?;
hdrs.append(&("Referer: ".to_string() + API_URL_DEFAULT))?;
hdrs.append("Content-Type: application/json;charset=utf-8")?;
hdrs.append(&("Bundle_ID: ".to_string() + BUNDLE_ID))?;
h.http_headers(hdrs)?;
let curdate = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let dat_req = json::object!(
"UserId" => "",
"UserPw" => "",
"Abos" => json::array![],
"AppVersion" => "2.3",
"Language" => "de",
"OsVersion" => "Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0",
"AppId" => "",
"Device" => "WebApp",
"PushId" => "",
"BundleId" => BUNDLE_ID,
"Date" => curdate.clone(),
"LastUpdate" => curdate.clone(),
);
let dat_req = jhw_marshal(&dat_req)?;
progbar.inc(1);
let mut buf = Vec::new();
{
let mut htr = h.transfer();
htr.read_function(curl_postrd_helper(dat_req.as_bytes()))?;
htr.write_function(|data| {
buf.extend_from_slice(data);
Ok(data.len())
})?;
htr.perform()?;
}
progbar.inc(1);
jhw_unmarshal(&buf[..])?
};
progbar.inc(1);
h.reset();
// 3. logout
{
h.url("https://www.dsbmobile.de/Login.aspx?logout")?;
h.get(true)?;
h.perform()?;
}
progbar.inc(1);
Ok(dat_res)
}
fn map_select_title<'a>(
title: &'static str,
i: &'a json::JsonValue,
) -> Option<&'a json::JsonValue> {
if i["Title"] == title {
Some(i)
} else {
None
}
}
pub fn query_dsbpath(user: &str, pass: &str) -> Result<String, ApiError> {
let progbar = indicatif::ProgressBar::new(6).with_style(crate::PROGBAR_STYLE.clone());
let res = query_jhw(&progbar, user, pass)?;
// .ResultMenuItems | map(select(.Title == "Inhalte")) | .[].Childs
// | map(select(.Title == "Pläne")) | .[].Root.Childs
// | map(select(.Title == "Schüler Stundenplan")) | .[].Id
if let Some(mut x) = res["ResultMenuItems"]
.members()
.filter_map(|i| map_select_title("Inhalte", i))
.map(|i| &i["Childs"])
.flat_map(|i| i.members())
.filter_map(|i| map_select_title("Pläne", i))
.map(|i| &i["Root"]["Childs"])
.flat_map(|i| i.members())
.filter_map(|i| map_select_title("Schüler Stundenplan", i))
.map(|i| &i["Childs"][0]["Preview"])
.next()
.and_then(|i| i.clone().take_string())
{
progbar.inc(1);
if x.ends_with(SUFFIX_PREVIEW) {
x.truncate(x.len() - SUFFIX_PREVIEW.len());
}
progbar.finish_and_clear();
Ok(x)
} else {
Err(ApiError::DsbPathNotFound)
}
}