Browse Source

+dsbfnr-wlog (get dsbpath via dsbmobile.de API + session via login)

master
Erik Zscheile 7 months ago
parent
commit
3c6699d005
6 changed files with 460 additions and 22 deletions
  1. +109
    -15
      Cargo.lock
  2. +6
    -1
      Cargo.toml
  3. +91
    -0
      src/bin/dsbfnr-wlog.rs
  4. +2
    -6
      src/bin/dsbfnr.rs
  5. +10
    -0
      src/lib.rs
  6. +242
    -0
      src/query_dsb.rs

+ 109
- 15
Cargo.lock View File

@@ -1,5 +1,11 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
[[package]]
name = "adler32"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d2e7343e7fc9de883d1b0341e0b13970f764c14101234857d2ddafa1cb1cac2"

[[package]]
name = "ansi_term"
version = "0.11.0"
@@ -26,6 +32,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d"

[[package]]
name = "base64"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5ca2cd0adc3f48f9e9ea5a6bbdf9ccc0bfade884847e484d452414c7ccffb3"

[[package]]
name = "bitflags"
version = "1.2.1"
@@ -44,6 +56,17 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"

[[package]]
name = "chrono"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80094f509cf8b5ae86a4966a39b3ff66cd7e2a3e594accec3743ff3fabeab5b2"
dependencies = [
"num-integer",
"num-traits",
"time",
]

[[package]]
name = "clap"
version = "2.33.0"
@@ -73,9 +96,9 @@ dependencies = [

[[package]]
name = "console"
version = "0.9.2"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45e0f3986890b3acbc782009e2629dfe2baa430ac091519ce3be26164a2ae6c0"
checksum = "6728a28023f207181b193262711102bfbaf47cc9d13bc71d0736607ef8efe88c"
dependencies = [
"clicolors-control",
"encode_unicode",
@@ -87,11 +110,20 @@ dependencies = [
"winapi",
]

[[package]]
name = "crc32fast"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba125de2af0df55319f41944744ad91c71113bf74a4646efff39afe1f6842db1"
dependencies = [
"cfg-if",
]

[[package]]
name = "curl"
version = "0.4.25"
version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06aa71e9208a54def20792d877bc663d6aae0732b9852e612c4a933177c31283"
checksum = "eda1c0c03cacf3365d84818a40293f0e3f3953db8759c9c565a3b434edf0b52e"
dependencies = [
"curl-sys",
"libc",
@@ -104,9 +136,9 @@ dependencies = [

[[package]]
name = "curl-sys"
version = "0.4.26"
version = "0.4.30+curl-7.69.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0853fe2a575bb381b1f173610372c7722d9fa9bc4056512ed99fe6a644c388c6"
checksum = "923b38e423a8f47a4058e96f2a1fa2865a6231097ee860debd678d244277d50c"
dependencies = [
"cc",
"libc",
@@ -119,12 +151,17 @@ dependencies = [

[[package]]
name = "dsbfnr"
version = "0.0.0"
version = "0.1.0"
dependencies = [
"base64",
"chrono",
"clap",
"curl",
"encoding",
"flate2",
"indicatif",
"json",
"lazy_static",
"thiserror",
"url",
]
@@ -199,6 +236,18 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569"

[[package]]
name = "flate2"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bd6d6f4752952feb71363cffc9ebac9411b75b87c6ab6058c40c8900cf43c0f"
dependencies = [
"cfg-if",
"crc32fast",
"libc",
"miniz_oxide",
]

[[package]]
name = "hermit-abi"
version = "0.1.8"
@@ -231,6 +280,12 @@ dependencies = [
"regex",
]

[[package]]
name = "json"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53cbc99268c271e1b373440fb5e59cd231c86805d4923c144697898d827edc61"

[[package]]
name = "lazy_static"
version = "1.4.0"
@@ -261,6 +316,34 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08"

[[package]]
name = "miniz_oxide"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa679ff6578b1cddee93d7e82e263b94a575e0bfced07284eb0c037c1d2416a5"
dependencies = [
"adler32",
]

[[package]]
name = "num-integer"
version = "0.1.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6ea62e9d81a77cd3ee9a2a5b9b609447857f3d358704331e4ef39eb247fcba"
dependencies = [
"autocfg",
"num-traits",
]

[[package]]
name = "num-traits"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62be47e61d1842b9170f0fdeec8eba98e60e90e5446449a0545e5152acd7096"
dependencies = [
"autocfg",
]

[[package]]
name = "number_prefix"
version = "0.3.0"
@@ -300,18 +383,18 @@ checksum = "05da548ad6865900e60eaba7f589cc0783590a92e940c26953ff81ddbab2d677"

[[package]]
name = "proc-macro2"
version = "1.0.8"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3acb317c6ff86a4e579dfa00fc5e6cca91ecbb4e7eb2df0468805b674eb88548"
checksum = "6c09721c6781493a2a492a96b5a5bf19b65917fe6728884e7c44dd0c60ca3435"
dependencies = [
"unicode-xid",
]

[[package]]
name = "quote"
version = "1.0.2"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053a8c8bcc71fcce321828dc897a98ab9760bef03a4fc36693c231e5b3216cfe"
checksum = "2bdc6c187c65bca4260c9011c9e3132efe4909da44726bad24cf7572ae338d7f"
dependencies = [
"proc-macro2",
]
@@ -333,9 +416,9 @@ dependencies = [

[[package]]
name = "regex-syntax"
version = "0.6.14"
version = "0.6.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b28dfe3fe9badec5dbf0a79a9cccad2cfc2ab5484bdb3e44cbd1ae8b3ba2be06"
checksum = "1132f845907680735a84409c3bebc64d1364a5683ffbce899550cd09d5eaefc1"

[[package]]
name = "schannel"
@@ -373,9 +456,9 @@ checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"

[[package]]
name = "syn"
version = "1.0.15"
version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0294dc449adc58bb6592fff1a23d3e5e6e235afc6a0ffca2657d19e7bbffe5"
checksum = "123bd9499cfb380418d509322d7a6d52e5315f064fe4b3ad18a53d6b92c07859"
dependencies = [
"proc-macro2",
"quote",
@@ -420,6 +503,17 @@ dependencies = [
"syn",
]

[[package]]
name = "time"
version = "0.1.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db8dcfca086c1143c9270ac42a2bbd8a7ee477b78ac8e45b19abfb0cbede4b6f"
dependencies = [
"libc",
"redox_syscall",
"winapi",
]

[[package]]
name = "unicode-bidi"
version = "0.3.4"


+ 6
- 1
Cargo.toml View File

@@ -1,16 +1,21 @@
[package]
name = "dsbfnr"
version = "0.0.0"
version = "0.1.0"
authors = ["Erik Zscheile <erik.zscheile@gmail.com>"]
edition = "2018"
license = "MIT OR Apache-2.0"
repository = "https://gitlab.com/zserik/dsbfnr"

[dependencies]
base64 = "0.12"
clap = "2.33"
chrono = "0.4"
curl = "0.4"
encoding = "0.2"
flate2 = "1.0"
indicatif = "0.14"
json = "0.12"
lazy_static = "1.4"
thiserror = "1.0"
url = "2.1"



+ 91
- 0
src/bin/dsbfnr-wlog.rs View File

@@ -0,0 +1,91 @@
fn main() {
use clap::Arg;
use dsbfnr::fetch;
use std::sync::Arc;

let matches = clap::App::new("dsbfnr-wlog")
.version(clap::crate_version!())
.author("Erik Zscheile <erik.zscheile@gmail.com>")
.about("a dsbcontrol.de CMS data fetcher + re-encoder for personal purposes")
.arg(
Arg::with_name("destdir")
.short("d")
.long("destdir")
.takes_value(true)
.help("sets the destination dir (defaults to '.')"),
)
.arg(
Arg::with_name("user")
.short("u")
.long("user")
.takes_value(true)
.required(true)
.help("sets the DSB username"),
)
.arg(
Arg::with_name("pass")
.short("p")
.long("pass")
.takes_value(true)
.required(true)
.help("sets the DSB password"),
)
.arg(
Arg::with_name("week")
.short("w")
.long("week")
.takes_value(true)
.required(true)
.help("sets the selected week"),
)
.arg(
Arg::with_name("categories")
.short("c")
.long("categories")
.takes_value(true)
.min_values(1)
.help("select categories to fetch (format: either 'CATEGORY' or 'CATEGORY EXPECTED_MAX_ELEMS')"),
)
.get_matches();

let categories: Vec<_> = matches.values_of("categories").unwrap().collect();

curl::init();

let dsbpath = dsbfnr::query_dsbpath(
matches.value_of("user").unwrap(),
matches.value_of("pass").unwrap(),
)
.expect("unable to query DSB for dsbpath");
println!("using dsbpath = {}", dsbpath);

let progbar_master = indicatif::MultiProgress::new();

let shared = Arc::new(fetch::SharedWorkerData {
destination: std::path::PathBuf::from(
matches.value_of("destdir").unwrap_or(".").to_string() + "/",
),
urlbase: url::Url::parse(&format!(
"https://app.dsbcontrol.de/data/{}/{}/",
dsbpath, matches.value_of("week").unwrap()
))
.expect("invalid dsbpath"),
progbar_sum: progbar_master.add(indicatif::ProgressBar::new(0)),
cooldown_dur: std::time::Duration::from_micros(200),
});

shared.progbar_sum.set_style(dsbfnr::PROGBAR_STYLE.clone());

let ths: Vec<_> = categories
.iter()
.map(|cat| fetch::spawn_fetch_folder(shared.clone(), cat, &progbar_master))
.collect();

std::mem::drop(shared);

progbar_master.join().unwrap();

for i in ths.into_iter() {
i.join().unwrap();
}
}

src/main.rs → src/bin/dsbfnr.rs View File

@@ -1,4 +1,4 @@
mod fetch;
use dsbfnr::fetch;

fn main() {
use clap::Arg;
@@ -51,11 +51,7 @@ fn main() {
cooldown_dur: std::time::Duration::from_micros(200),
});

shared.progbar_sum.set_style(
indicatif::ProgressStyle::default_bar()
.template("{bar:80.bold.green/blue} {pos:>4}/{len:4} [{elapsed} | ETA:{eta}]")
.progress_chars("═╸┄"),
);
shared.progbar_sum.set_style(dsbfnr::PROGBAR_STYLE.clone());

let ths: Vec<_> = categories
.iter()

+ 10
- 0
src/lib.rs View File

@@ -0,0 +1,10 @@
pub mod fetch;

mod query_dsb;
pub use query_dsb::query_dsbpath;

lazy_static::lazy_static! {
pub static ref PROGBAR_STYLE: indicatif::ProgressStyle = indicatif::ProgressStyle::default_bar()
.template("{bar:80.bold.green/blue} {pos:>4}/{len:4} [{elapsed} | ETA:{eta}]")
.progress_chars("═╸┄");
}

+ 242
- 0
src/query_dsb.rs View File

@@ -0,0 +1,242 @@
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)
}
}

Loading…
Cancel
Save