initial commit

This commit is contained in:
jane400 2024-08-16 19:40:14 +02:00
commit bc6eb2047b
35 changed files with 6921 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
private.rs

5
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"languageToolLinter.languageTool.ignoredWordsInWorkspace": [
"libpaket"
]
}

3752
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

14
Cargo.toml Normal file
View file

@ -0,0 +1,14 @@
[workspace]
resolver = "2"
members = [
"libpaket",
"advices",
]
[workspace.package]
authors = ["Jane Rachinger <libpaket@j4ne.de>"]
edition = "2021"
license = "AGPL-3.0-only"
version = "0.1.0"

76
libpaket/Cargo.toml Normal file
View file

@ -0,0 +1,76 @@
[package]
name = "libpaket"
authors.workspace = true
edition.workspace = true
license.workspace = true
version.workspace = true
[dependencies]
aes-gcm = { version = "0.10.3", optional = true }
ed25519-dalek = { version = "2.1.0", optional = true }
hmac = { version = "0.12.1", optional = true }
num_enum = { version = "0.7", optional = true }
# TODO: Consolidate?
rand = "0.8.5"
random-string = "1.1.0"
reqwest = { version = "0.12", features = ["json", "cookies", "gzip", "http2"] }
secrecy = { version = "0.8.0", features = ["serde"] }
serde = { version = "1.0.195", features = ["derive"] }
serde_json = "1.0.111"
serde_repr = { version = "0.1.18", optional = true }
url = "2.5.0"
base64 = "0.22"
# TODO: consider splitting login.rs refresh_token and authorization_token
# (sha2 and urlencoding only used with authorization_token)
# sha2 also used in briefankuendigung and packstation_register_regtoken
sha2 = "0.10.8"
urlencoding = "2.1.3"
uuid = { version = "1.7.0", features = ["v4", "serde"], optional = true }
serde_newtype = "0.1.1"
thiserror = "1.0.56"
[features]
default = [
"advices",
"locker_all"
]
advices = [
#"dep:sha2",
"dep:uuid",
"dep:aes-gcm",
]
locker_all = [
"locker_register_all",
"locker_ble",
]
locker_base = [
"dep:uuid",
"dep:serde_repr",
"dep:ed25519-dalek",
]
locker_ble = [
"locker_base",
"dep:num_enum",
]
locker_register_all = [
"locker_register_regtoken",
]
locker_register_base = [
"locker_base",
"dep:hmac",
#"dep:sha2",
]
locker_register_regtoken = [
"locker_register_base"
]

31
libpaket/README.md Normal file
View file

@ -0,0 +1,31 @@
# libpaket
This is an unofficial client to various DHL APIs, more specific the ones that the proprietary app `Post & DHL` uses.
## Features
- Mail Notification (Briefankündigung)
## Goals
- app-driven parcel lockers (App-gesteuerte Packstation)
## Examples
In the examples error-handling is ignored for simplicity. You dont want to do that.
### Getting mail notifications (Briefankündigung)
```rust
// Requires a logged-in user.
let token: libpaket::login::DHLIdToken;
let response = libpaket::WebClient::new().advices(&token).await.unwrap();
let client = libpaket::AdviceClient::new();
if let Some(current_advice) = response.get_current_advice() {
let advices_token = client.access_token(&response),unwrap();
let bin: Vec<u8> = client.fetch_advice_image(&current_advice.list[0], advice_token).await.unwrap();
}
```

View file

@ -0,0 +1,154 @@
pub use super::Advice;
use super::AdvicesResponse;
use reqwest::header::HeaderMap;
use serde::Serialize;
use crate::constants::webview_user_agent;
use crate::LibraryResult;
pub struct AdviceClient {
client: reqwest::Client,
}
#[derive(Debug, Clone)]
pub struct UatToken(String);
impl AdviceClient {
pub fn new() -> Self {
Self {
client: reqwest::ClientBuilder::new()
.default_headers(headers())
.user_agent(webview_user_agent())
.build()
.unwrap(),
}
}
pub async fn access_token<'t>(&self, advices: &AdvicesResponse) -> LibraryResult<UatToken> {
mini_assert_inval!(advices.has_any_advices());
mini_assert_api_eq!(advices.access_token_url.as_ref().unwrap().as_str(), endpoint_access_tokens());
let req = self
.client
.post(endpoint_access_tokens())
.json(&GrantToken {
grant_token: advices.grant_token.as_ref().unwrap().clone(),
})
.build()
.unwrap();
let res = self.client.execute(req).await;
if let Err(err) = res {
return Err(err.into());
}
let res = res.unwrap();
for cookie in res.cookies() {
if cookie.name() == "UAT" {
return Ok(UatToken(cookie.value().to_string()));
}
}
// FIXME: Parse errors here better (checking if we're unauthorized,...)
panic!("NO UAT Token in access_token");
}
pub async fn fetch_advice_image(
&self,
advice: &Advice,
uat: &UatToken,
) -> LibraryResult<Vec<u8>> {
println!("URL: {}", &advice.image_url);
// get key to "deobfuscate" the data
// match behaviour from javascript
let uuid = {
let uuid_str = advice.image_url.split("/").last().unwrap();
let uuid_vec = uuid_str.split("?").collect::<Vec<&str>>();
*uuid_vec.first().unwrap()
};
let (secret_key, iv, aad) = {
use sha2::{Digest, Sha512};
let hash = {
let mut hasher = Sha512::new();
hasher.update(uuid.as_bytes());
hasher.finalize()
};
//secretKey: e.subarray(0, 32),
//iv: e.subarray(32, 44),
//aad: e.subarray(44, 56)
(
hash[0..32].to_vec(),
hash[32..44].to_vec(),
hash[44..56].to_vec(),
)
};
let req = self.client
.get(&advice.image_url)
.header("Cookie", format!("UAT={}", uat.0))
.build()
.unwrap();
let res = self.client.execute(req).await;
if let Err(err) = res {
return Err(err.into());
}
let res = res.unwrap();
let bytes = res.bytes().await.unwrap();
let bytes = {
use aes_gcm::{
aead::Payload,
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
};
let aes = Aes256Gcm::new_from_slice(&secret_key[..]).unwrap();
let nonce = Nonce::from_slice(&iv[..]);
let payload = Payload {
msg: &bytes[..],
aad: &aad[..],
};
aes.decrypt(nonce, payload).unwrap()
};
Ok(bytes)
}
}
#[derive(Serialize)]
struct GrantToken {
grant_token: String,
}
fn headers() -> HeaderMap {
let aaa = vec![
("Host", "briefankuendigung.dhl.de"),
("Connection", "keep-alive"),
("Connect-Type", "application/json"),
("Pragma", "no-cache"),
("Cache-Control", "no-cache,no-store,must-revalidate"),
("Expires", "0"),
("Accept", "*/*"),
("Origin", "https://www.dhl.de"),
("X-Requested-With", "de.dhl.paket"),
("Sec-Fetch-Site", "same-site"),
("Sec-Fetch-Mode", "cors"),
("Sec-Fetch-Dest", "empty"),
("Referer", "https://www.dhl.de/"),
("Accept-Language", "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7"),
];
let mut map = HeaderMap::new();
for bbb in aaa {
map.append(bbb.0, bbb.1.parse().unwrap());
}
map
}
pub fn endpoint_access_tokens() -> &'static str {
"https://briefankuendigung.dhl.de/pdapp-web/access-tokens"
}

View file

@ -0,0 +1,5 @@
mod www;
mod briefankuendigung;
pub use www::*;
pub use briefankuendigung::*;

113
libpaket/src/advices/www.rs Normal file
View file

@ -0,0 +1,113 @@
use serde::Deserialize;
use serde_newtype::newtype;
use crate::utils::{as_url, CookieHeaderValueBuilder};
#[derive(Deserialize, Debug, Clone)]
pub struct Advice {
pub campaign_link_text: Option<String>,
pub campaign_url: Option<String>,
pub image_url: String,
pub thumbnail_url: String,
}
#[derive(Deserialize, Debug)]
pub struct AdvicesList {
#[serde(rename = "advices")]
pub list: Vec<Advice>,
pub date: String,
}
newtype! {
#[derive(Debug)]
pub AdviceAccessTokenUrl: String[
|string| {
let res = url::Url::try_from(string.as_str());
res.is_ok()
}, ""
] = "".to_string();
}
#[derive(Deserialize, Debug)]
pub struct AdvicesResponse {
// access_token_url, basic_auth, grant_token is null if no advices are available
#[serde(rename = "accessTokenUrl")]
pub(super) access_token_url: Option<AdviceAccessTokenUrl>,
#[serde(rename = "basicAuth")]
pub(super) basic_auth: Option<String>,
#[serde(rename = "currentAdvice")]
current_advice: Option<AdvicesList>,
#[serde(rename = "grantToken")]
pub(super) grant_token: Option<String>,
#[serde(rename = "oldAdvices")]
old_advices: Vec<AdvicesList>,
}
impl AdvicesResponse {
pub fn has_any_advices(&self) -> bool {
self.has_current_advice() || self.has_old_advices()
}
pub fn has_current_advice(&self) -> bool {
self.current_advice.is_some()
}
pub fn has_old_advices(&self) -> bool {
self.old_advices.len() > 0
}
pub fn get_current_advice(&self) -> Option<&AdvicesList> {
self.current_advice.as_ref()
}
pub fn get_old_advices(&self) -> &Vec<AdvicesList> {
self.old_advices.as_ref()
}
}
fn endpoint_advices() -> url::Url {
as_url("https://www.dhl.de/int-aviseanzeigen/advices?width=400")
}
impl crate::www::WebClient {
// FIXME: more error parsing
pub async fn advices(&self, dhli: &crate::login::DHLIdToken) -> crate::LibraryResult<AdvicesResponse> {
let cookie_headervalue = CookieHeaderValueBuilder::new()
.add_dhli(dhli)
.add_dhlcs(dhli)
.build_string();
let req = self.web_client
.get(endpoint_advices().clone())
.header("Cookie", cookie_headervalue)
.build()
.unwrap();
let res = self.web_client.execute(req).await;
if let Err(err) = res {
return Err(err.into())
}
let res = res.unwrap();
let res_text = res.text().await.unwrap();
let res = serde_json::from_str::<AdvicesResponse>(&res_text);
let res = match res {
Ok(res) => res,
Err(err) => {
return Err(err.into())
}
};
if res.access_token_url.is_some() {
if res.access_token_url.as_ref().unwrap().as_str() != crate::advices::endpoint_access_tokens() {
return Err(crate::LibraryError::APIChange);
}
}
Ok(res)
}
}

35
libpaket/src/common.rs Normal file
View file

@ -0,0 +1,35 @@
use crate::{LibraryError, LibraryResult};
use serde::Deserialize;
#[derive(serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum APIErrorType {
InvalidGrant,
}
#[derive(Deserialize)]
pub(crate) struct APIError {
pub error: APIErrorType,
pub error_description: String,
}
// mostly used in the aggregated endpoint
#[derive(serde::Deserialize)]
#[serde(untagged)]
pub(crate) enum APIResult<T> {
APIError(APIError),
Okay(T),
}
impl<T> Into<LibraryResult<T>> for APIResult<T> {
fn into(self) -> LibraryResult<T> {
match self {
APIResult::APIError(err) => {
return Err(LibraryError::from(err));
}
APIResult::Okay(res) => {
return Ok(res);
}
}
}
}

21
libpaket/src/constants.rs Normal file
View file

@ -0,0 +1,21 @@
pub fn app_version() -> &'static str {
"9.9.1.95 (a72fec7be)"
}
pub fn linux_android_version() -> &'static str {
"Linux; Android 11"
}
pub fn webview_user_agent() -> String {
format!("{} [DHL Paket Android App/{}]", web_user_agent(), app_version())
}
pub fn device_string() -> &'static str {
"OnePlus 6T Build/RQ3A.211001.001"
}
pub fn web_user_agent() -> String {
format!("Mozilla/5.0 ({}; {}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/117.0.0.0 Mobile Safari/537.36",
linux_android_version(), device_string())
}

66
libpaket/src/lib.rs Normal file
View file

@ -0,0 +1,66 @@
mod www;
pub use www::WebClient;
pub mod login;
pub use login::OpenIdClient;
pub mod stammdaten;
pub use stammdaten::StammdatenClient;
mod common;
#[macro_use]
mod utils;
pub mod constants;
#[cfg(feature = "locker_base")]
pub mod locker;
#[cfg(feature = "advices")]
pub mod advices;
#[cfg(feature = "advices")]
pub use advices::AdviceClient;
/*#[cfg(test)]
pub(crate) mod private;*/
use thiserror::Error;
#[derive(Error, Debug, PartialEq)]
pub enum LibraryError {
#[error("network error: unable to fetch resource")]
NetworkFetch,
#[error("invalid credentials, unauthorized")]
Unauthorized,
#[error("invalid argument: {0}")]
InvalidArgument(String),
#[error("internal error, unable to decode: {0}")]
DecodeError(String),
#[error("upstream api was changed. not continuing")]
APIChange,
}
pub type LibraryResult<T> = Result<T, LibraryError>;
impl From<reqwest::Error> for LibraryError {
fn from(item: reqwest::Error) -> Self {
if item.is_timeout() || item.is_request() || item.is_connect() || item.is_decode() {
Self::NetworkFetch
} else {
panic!("FIXME: unknown reqwest error kind: {:?}", item)
}
}
}
impl From<common::APIError> for LibraryError {
fn from(value: common::APIError) -> Self {
match value.error {
common::APIErrorType::InvalidGrant => Self::Unauthorized
}
}
}
impl From<serde_json::Error> for LibraryError {
fn from(value: serde_json::Error) -> Self {
Self::DecodeError(value.to_string())
}
}

View file

@ -0,0 +1,40 @@
use reqwest::{header::HeaderMap, Request, RequestBuilder};
use crate::constants::{app_version, web_user_agent};
pub struct Client {
client: reqwest::Client,
}
impl Client {
pub fn new() -> Self {
Client {
client: reqwest::ClientBuilder::new()
.default_headers(headers())
.user_agent(user_agent())
.build()
.unwrap(),
}
}
}
fn user_agent() -> String {
format!("LPS Consumer SDK/2.1.0 okhttp/4.9.1 {}", web_user_agent())
}
fn headers() -> HeaderMap {
let aaa = vec![
/* ("accept", "application/json") */
("app-version", app_version()),
("device-os", "Android"),
("device-key", "") /* is the android id... */
];
let mut map = HeaderMap::new();
for bbb in aaa {
map.append(bbb.0, bbb.1.parse().unwrap());
}
map
}

View file

@ -0,0 +1,330 @@
use num_enum::TryFromPrimitive;
use uuid::Uuid;
use crate::{LibraryError, LibraryResult};
use super::LockerServiceUUID;
#[derive(Clone, Copy, TryFromPrimitive, PartialEq, Eq, PartialOrd, Ord)]
#[repr(u8)]
pub enum CommandType {
InitSessionRequest = 1,
InitSessionResponse = 2,
OpenRequest = 3,
OpenedRespone = 4,
AlreadyOpenResponse = 5,
OpenAutoignoreRequest = 6,
CloseCompartmentsResponse = 7,
PingRequest = 8,
CheckFirmwareVersionRequest = 9,
CheckFirmwareVersionResponse = 10,
UpdateFirmwareRequest = 11,
UpdateFirmwareResponse = 12,
GetLogCounterRequest = 13,
GetLogCounterResponse = 14,
GetLogFileRequest = 15,
GetLogFileResponse = 16,
CheckOpenedComparmentsRequest = 17,
CheckOpenedCompartmentsResponse = 18,
TransferFirmwareRequest = 19,
TransferFirmwareResponse = 20,
}
const REQUESTS: [CommandType; 10] = [
CommandType::InitSessionRequest,
CommandType::OpenRequest,
CommandType::OpenAutoignoreRequest,
CommandType::PingRequest,
CommandType::CheckFirmwareVersionRequest,
CommandType::UpdateFirmwareRequest,
CommandType::GetLogCounterRequest,
CommandType::GetLogFileRequest,
CommandType::CheckOpenedComparmentsRequest,
CommandType::TransferFirmwareRequest,
];
struct ResponseInitSession {
raw_command: Command
}
enum Response {
InitSession(ResponseInitSession),
}
impl TryFrom<Command> for Response {
type Error = LibraryError;
fn try_from(value: Command) -> Result<Self, Self::Error> {
if REQUESTS.binary_search(&value.r#type).is_ok() {
return Err(LibraryError::InvalidArgument("TryFrom<Command> for Response: CommandType is a Request (expected Response)".to_string()))
}
todo!()
}
}
// Message(
// command (byte)
// length in bytes (int)
// payload (arr)
// initVector (arr)
// metadata (arr)
// checksum (short)
//
// Checksum function
pub struct Command {
r#type: CommandType,
payload: Vec<u8>,
init_vector: Vec<u8>,
metadata: Vec<u8>
}
struct PrimitiveBuilder {
bin: Vec<u8>,
}
impl PrimitiveBuilder {
fn new() -> Self {
PrimitiveBuilder {
bin: Vec::new()
}
}
fn write_u8(self, number: u8) -> Self {
self.write_array(&[number])
}
fn write_u16(self, number: u16) -> Self {
self.write_array(&number.to_be_bytes())
}
fn write_u32(self, number: u32) -> Self {
self.write_array(&number.to_be_bytes())
}
fn write_array(mut self, new: &[u8]) -> Self {
for b in new {
self.bin.push(*b);
}
self
}
fn write_array_with_len(self, bin: &[u8]) -> Self {
self
.write_u32(bin.len() as u32)
.write_array(bin)
}
fn finish(self) -> Vec<u8> {
self.bin
}
}
struct PrimitiveReader<'a> {
offset: usize,
vec: &'a [u8],
}
// Yes, I know Cursor exists and eio exists. from_be_bytes should be a stdlib trait tho.
impl<'a> PrimitiveReader<'a> {
fn read_u8(&mut self) -> u8 {
let number = self.vec[self.offset];
self.offset += 1;
number
}
fn read_u16(&mut self) -> u16 {
let arr: [u8; 2] = self.read_arr_const();
u16::from_be_bytes(arr)
}
fn read_u32(&mut self) -> u32 {
let arr: [u8; 4] = self.read_arr_const();
u32::from_be_bytes(arr)
}
fn read_u64(&mut self) -> u64 {
let arr: [u8; 8] = self.read_arr_const();
u64::from_be_bytes(arr)
}
fn read_arr_const<const N: usize>(&mut self) -> [u8; N] {
let mut arr = [0u8; N];
for n in 0..N {
arr[n] = self.read_u8();
}
return arr;
}
fn read_arr(&mut self, n: usize) -> Vec<u8> {
let mut arr: Vec<u8> = vec![];
for _ in 0..n {
arr.push(self.read_u8());
}
arr
}
fn read_arr_from_len(&mut self) -> Vec<u8> {
let size = self.read_u32() as usize;
self.read_arr(size)
}
fn left_to_process(&self) -> usize {
self.vec.len() - self.offset
}
}
impl Command {
fn checksum(bin: &[u8]) -> u16 {
// CRC16 of some kind...
// i'm too eepy to use the crc crate for this
let mut result: u16 = 0;
for byte in bin {
result = result ^ ((*byte as u16) << 8);
let mut i = 0;
while i < 8 {
let mut a = result << 1;
if result & 0x8000 != 0 {
a ^= 0x1021;
}
result = a;
i += 1;
}
}
result
}
pub fn parse(bin: Vec<u8>) -> LibraryResult<Self> {
// command byte + message length + 3 empty message arguments (array with size 0) + 2 checksum bytes
let to_few_bytes = LibraryError::InvalidArgument("Command::parse: Invalid vec.len() (to few bytes)".to_string());
if bin.len() < 1 + 4 + (4 * 3) + 2 {
return Err(to_few_bytes)
}
{
let checksum = {
PrimitiveReader {
offset: bin.len() - 2,
vec: bin.as_slice(),
}.read_u16()
};
if checksum != Self::checksum(&bin[0..bin.len() - 2]) {
return Err(LibraryError::InvalidArgument("Command::parse: Invalid checksum".to_string()));
}
}
let mut reader = PrimitiveReader {
offset: 0,
vec: bin.as_slice(),
};
let r#type = reader.read_u8();
let Ok(r#type) = CommandType::try_from_primitive(r#type) else {
return Err(LibraryError::DecodeError("unable determine CommandType".to_string()));
};
let size_of_message = reader.read_u32() as usize;
if reader.left_to_process() < size_of_message {
return Err(to_few_bytes);
}
let payload: Vec<u8> = reader.read_arr_from_len();
let init_vector = reader.read_arr_from_len();
let metadata = reader.read_arr_from_len();
Ok(Command {
r#type,
payload: payload.clone(),
init_vector: init_vector.clone(),
metadata: metadata.clone(),
})
}
pub fn finish(self) -> Vec<u8> {
let vec1 = PrimitiveBuilder::new()
.write_array_with_len(&self.payload)
.write_array_with_len(&self.init_vector)
.write_array_with_len(&self.metadata)
.finish();
let vec2 = PrimitiveBuilder::new()
.write_u8(self.r#type as u8)
.write_u32(vec1.len() as u32 + 2)
.write_array(&vec1)
.finish();
PrimitiveBuilder::new()
.write_array(&vec2)
.write_u16(Self::checksum(vec2.as_slice()))
.finish()
}
fn assert_only_decimal(str: &String) {
for c in str.chars() {
assert!(c.is_digit(10));
}
}
pub fn init_session_request(uuid: LockerServiceUUID) -> LibraryResult<Self> {
let uuid: Uuid = uuid.into();
let fields = uuid.as_fields();
// For some reason the code make sure that the heximal string only contains digits from base10...
// Interesting findings: first 5 digits are PLZ, last three packstation id (end-user), then uid?
// de.dhl.paket does some kinky string conversion
let vec = Vec::<u8>::new().into_iter()
.chain(fields.0.to_be_bytes().into_iter())
.chain(fields.1.to_be_bytes().into_iter())
.chain(fields.2.to_be_bytes().into_iter())
.chain(fields.3.to_vec().into_iter())
.collect::<Vec<u8>>();
let new_vec = PrimitiveBuilder::new()
.write_array_with_len(&vec).finish();
Ok(Command {
r#type: CommandType::InitSessionRequest,
payload: Vec::new(),
init_vector: Vec::new(),
metadata: new_vec,
})
}
}
/*#[cfg(test)]
mod test {
use crate::private::sample_packstation::{INIT_SESSION_REQUEST, INIT_SESSION_RESPONSE, INIT_SESSION_SERVICE_UUID};
use super::{Command, CommandType};
#[test]
fn command_init_request() {
let mut corrected = Vec::<u8>::new();
for b in INIT_SESSION_REQUEST {
corrected.push(b as u8);
}
let command = Command::init_session_request(uuid::uuid!(INIT_SESSION_SERVICE_UUID).try_into().unwrap()).unwrap();
assert_eq!(command.finish().as_slice(), corrected.as_slice())
}
#[test]
fn command_init_session_response() {
let mut corrected = Vec::<u8>::new();
for b in INIT_SESSION_RESPONSE {
corrected.push(b as u8);
}
let command = Command::parse(corrected);
}
}*/

View file

@ -0,0 +1,75 @@
use base64::{engine::general_purpose, Engine as _};
use rand::prelude::*;
use uuid::Uuid;
use ed25519_dalek::SigningKey;
use ed25519_dalek::Signer;
pub struct CustomerKeySeed {
postnumber: String,
seed: Seed,
uuid: Uuid,
}
pub struct Seed {
bytes: Vec<u8>,
}
impl Seed {
pub(self) fn from_bytes(bytes: Vec<u8>) -> Self {
assert_eq!(bytes.len(), 32);
Seed {
bytes: bytes
}
}
pub fn random() -> Self {
let mut rng = rand::thread_rng();
let mut bytes: Vec<u8> = vec![0; 32];
rng.fill_bytes(bytes.as_mut_slice());
Seed { bytes: bytes }
}
pub fn as_bytes(&self) -> &[u8; 32] {
(&self.bytes[..]).try_into().unwrap()
}
}
impl CustomerKeySeed {
pub fn new(postnumber: String) -> Self {
CustomerKeySeed {
postnumber,
seed: Seed::random(),
uuid: uuid::Uuid::new_v4(),
}
}
pub fn from(postnumber: &String, seed: Vec<u8>, uuid: &Uuid) -> Self {
CustomerKeySeed {
postnumber: postnumber.clone(),
seed: Seed::from_bytes(seed),
uuid: uuid.clone()
}
}
pub(crate) fn sign(&self, message: &[u8]) -> String {
let signing_key = SigningKey::from_bytes(self.seed.as_bytes());
let signature = signing_key.sign(message);
let sig_str = general_purpose::STANDARD.encode(signature.to_bytes());
format!("{}.{}", self.uuid.to_string(), sig_str)
}
pub(crate) fn get_public_key(&self) -> String {
let signing_key = SigningKey::from_bytes(self.seed.as_bytes());
let public_bytes = signing_key.verifying_key().to_bytes();
let public_str = general_purpose::STANDARD.encode(public_bytes);
format!("{}.{}", self.uuid.to_string(), public_str)
}
}

View file

@ -0,0 +1,23 @@
#[cfg(feature = "locker_ble")]
mod command;
#[cfg(feature = "locker_ble")]
mod types;
#[cfg(feature = "locker_ble")]
pub use types::*;
mod api;
pub mod crypto;
#[cfg(feature = "locker_register_base")]
mod register_base;
#[cfg(feature = "locker_register_regtoken")]
mod register_regtoken;
#[cfg(feature = "locker_register_regtoken")]
mod regtoken;
#[cfg(feature = "locker_register_base")]
pub mod register {
pub use super::register_base::*;
#[cfg(feature = "locker_register_regtoken")]
pub use super::regtoken::*;
}

View file

@ -0,0 +1,113 @@
use serde::Deserialize;
use serde::Serialize;
use serde_repr::Deserialize_repr;
use crate::common::APIResult;
use crate::login::DHLIdToken;
use crate::LibraryResult;
#[derive(Deserialize)]
pub struct RegistrationPayload {
pub(crate) challenge: String,
#[serde(rename = "registrationOptions")]
pub registration_options: Vec<RegistrationOption>,
}
#[derive(Deserialize, PartialEq, Eq)]
pub enum RegistrationOption {
#[serde(rename = "registerDirect")]
Direct,
#[serde(rename = "registerByRegToken")]
ByRegToken,
#[serde(rename = "registerByPeerDevice")]
ByPeerDevice,
#[serde(rename = "registerByVerifyToken")]
ByVerifyToken,
#[serde(rename = "registerByCardNumber")]
ByCardNumber,
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct DeviceMetadata {
pub manufacturer_model: String,
pub manufacturer_name: String,
pub manufacturer_operating_system: String,
pub name: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Device {
#[serde(flatten)]
pub metadata: DeviceMetadata,
pub id: String,
pub registered_at: String,
}
#[derive(Deserialize)]
#[serde(untagged)]
pub enum DeviceRegistrationResponse {
Error {
id: APIRegisterError,
description: String,
},
Okay(Device),
}
#[derive(Deserialize_repr, PartialEq, Debug)]
#[repr(u8)]
pub enum APIRegisterError {
MaxDevices = 7,
ScanInvalidCustomerCard = 14,
AlreadyRegisteredOneDevice = 16,
ScanInvalidRegToken = 17,
ScanExpiredAuthToken = 19,
ScanInvalidAuthToken = 20,
UserCanNotYetRegister = 21,
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub(super) struct RegistrationCommonDevice {
#[serde(flatten)]
pub(super) metadata: DeviceMetadata,
pub(super) public_key: String,
}
impl crate::StammdatenClient {
pub async fn begin_registration(
&self,
dhli: &DHLIdToken,
) -> LibraryResult<RegistrationPayload> {
let body = "{\"hasVerifyToken\": false}";
let req = self.base_request(
self.client
.put(endpoint_devices_begin_registration())
.body(body)
.header("content-length", body.len())
.header("content-type", "application/json; charset=UTF-8"),
dhli,
);
let res = self.client.execute(req).await;
if let Err(err) = res {
return Err(err.into());
};
let res = res.unwrap().text().await.unwrap();
let res = serde_json::from_str::<APIResult<RegistrationPayload>>(&res);
match res {
Ok(res) => res.into(),
Err(err) => Err(err.into()),
}
}
}
fn endpoint_devices_begin_registration() -> &'static str {
"https://www.dhl.de/int-stammdaten/public/devices/beginRegistration"
}

View file

@ -0,0 +1,152 @@
use serde::{Deserialize, Serialize};
use hmac::{digest::CtOutput, Mac, SimpleHmac};
use sha2::Sha256;
use crate::common::APIResult;
use crate::locker::register::{DeviceMetadata, DeviceRegistrationResponse, RegToken, RegistrationCommonDevice, RegistrationOption, RegistrationPayload};
use crate::locker::crypto::CustomerKeySeed;
use crate::login::DHLIdToken;
use crate::LibraryResult;
use base64::{engine::general_purpose, Engine as _};
#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RegistrationRegToken {
pub(super) challenge: String,
pub(super) customer_password: String,
pub(super) device: RegistrationCommonDevice,
pub(super) verifier: String,
pub(super) verifier_signature: String,
}
impl RegistrationRegToken {
pub fn new(
customer_key_seed: &CustomerKeySeed,
begin_registration: RegistrationPayload,
reg_token: &RegToken,
device_metadata: DeviceMetadata,
) -> Self {
let challange_bin = general_purpose::STANDARD.decode(begin_registration.challenge.as_str()).unwrap();
let mut mac = reg_token.hmac();
mac.update(challange_bin.as_slice());
let verifier_bin: CtOutput<SimpleHmac<Sha256>> = mac.finalize();
let verifier_bin = verifier_bin.into_bytes();
let verifier = general_purpose::STANDARD.encode(&verifier_bin);
let verifier_signature = customer_key_seed.sign(&verifier_bin);
Self {
challenge: begin_registration.challenge,
customer_password: reg_token.customer_password(),
device: RegistrationCommonDevice {
metadata: device_metadata,
public_key: customer_key_seed.get_public_key(),
},
verifier: verifier,
verifier_signature: verifier_signature,
}
}
}
impl crate::StammdatenClient {
pub async fn register_by_regtoken(
&self,
dhli: &DHLIdToken,
customer_key_seed: &CustomerKeySeed,
registration_payload: RegistrationPayload,
device_metadata: DeviceMetadata,
reg_token: &crate::locker::register::RegToken,
) -> LibraryResult<DeviceRegistrationResponse> {
let mut valid = false;
for option in registration_payload.registration_options.iter() {
if *option == RegistrationOption::ByRegToken {
valid = true;
}
}
assert!(valid);
let body = RegistrationRegToken::new(
customer_key_seed,
registration_payload,
reg_token,
device_metadata
);
let body = serde_json::to_string(&body).unwrap();
let req = self.base_request(
self.client
.post(endpoint_devices_register_by_regtoken())
.header("content-length", body.len())
.body(body)
.header("content-type", "application/json; charset=UTF-8"),
dhli,
);
let res = self.client.execute(req).await;
if let Err(err) = res {
return Err(err.into());
};
let res = res.unwrap().text().await.unwrap();
let res = serde_json::from_str::<APIResult<DeviceRegistrationResponse>>(&res);
match res {
Ok(res) => res.into(),
Err(err) => Err(err.into()),
}
}
}
fn endpoint_devices_register_by_regtoken() -> &'static str {
"https://www.dhl.de/int-stammdaten/public/devices/registerByRegToken"
}
/*#[cfg(test)]
mod registration_api {
use std::str::FromStr as _;
use base64::Engine as _;
use crate::private::init as private;
#[test]
fn regtoken_customer_password() {
let regtoken = super::RegToken::parse_from_qrcode_uri(&private::IN_REGTOKEN).unwrap();
assert!(regtoken.customer_password() == private::OUT_CUSTOMER_PASSWORD);
}
#[test]
fn register_via_regtoken(){
let regtoken = super::RegToken::parse_from_qrcode_uri(&private::IN_REGTOKEN).unwrap();
let customer_key_seed = crate::locker::crypto::CustomerKeySeed::from(&private::IN_POSTNUMBER.to_string(),
base64::engine::general_purpose::STANDARD.decode(&private::IN_SEED_BASE64).unwrap(),
&uuid::Uuid::from_str(&private::IN_KEY_ID).unwrap());
let registration_payload = super::RegistrationPayload {
challenge: private::IN_CHALLENGE.to_string(),
registration_options: Vec::new(),
};
let reg_reg_token = super::RegistrationRegToken::new(
&customer_key_seed,
registration_payload,
&regtoken,
super::DeviceMetadata { manufacturer_model: "".to_string(), manufacturer_name: "".to_string(), manufacturer_operating_system: "".to_string(), name: "".to_string() }
);
assert!(reg_reg_token.verifier == private::OUT_VERIFIER);
assert!(reg_reg_token.verifier_signature == private::OUT_VERIFIERSIGNATURE);
assert!(reg_reg_token.device.public_key == private::OUT_DEVICE_PUBKEY);
}
}
*/

View file

@ -0,0 +1,48 @@
use base64::{engine::general_purpose, Engine as _};
use hmac::{Mac, SimpleHmac};
use sha2::Sha256;
use crate::{LibraryResult, LibraryError};
pub struct RegToken {
token: Vec<u8>,
}
impl RegToken {
pub fn parse_from_qrcode_uri(uri: &str) -> LibraryResult<RegToken> {
if uri.len() <= 23 {
return Err(LibraryError::DecodeError("RegTokenUri too short".to_string()));
}
if !uri.starts_with("urn:dhl.de:regtoken:v1:") {
return Err(LibraryError::DecodeError("RegTokenUri has invalid magic".to_string()));
}
let token = &uri[23..];
let token = general_purpose::STANDARD.decode(token);
let Ok(mut token) = token else {
return Err(LibraryError::DecodeError("RegTokenUri not decodeable (base64)".to_string()));
};
if token.len() > 64 {
return Err(LibraryError::DecodeError("RegToken longer than expected".to_string()));
}
if token.len() < 32 {
token.extend(vec![0; 32 - token.len()].iter())
}
Ok(RegToken { token })
}
pub fn customer_password(&self) -> String {
general_purpose::STANDARD.encode(&self.token[32..])
}
pub fn hmac(&self) -> SimpleHmac<Sha256> {
let mac = SimpleHmac::<Sha256>::new_from_slice(&self.token[0..32])
.expect("HMAC can take key of any size");
mac
}
}

View file

@ -0,0 +1,29 @@
use crate::LibraryError;
// 601e7028-0565-
pub static LOCKER_SERVICE_UUID_PREFIX: (u32, u16) = (0x601e7028, 0x0565);
pub struct LockerServiceUUID {
service_uuid: uuid::Uuid,
}
impl TryFrom<uuid::Uuid> for LockerServiceUUID {
type Error = crate::LibraryError;
fn try_from(value: uuid::Uuid) -> Result<Self, Self::Error> {
let fields = value.as_fields();
if fields.0 != LOCKER_SERVICE_UUID_PREFIX.0 || fields.1 != LOCKER_SERVICE_UUID_PREFIX.1 {
return Err(LibraryError::InvalidArgument("TryFrom<Uuid> for LockerServiceUUID: prefix mismatch (expected 601e7028-0565-)".to_string()))
}
Ok(LockerServiceUUID {
service_uuid: value
})
}
}
impl Into<uuid::Uuid> for LockerServiceUUID {
fn into(self) -> uuid::Uuid {
self.service_uuid
}
}

View file

@ -0,0 +1,130 @@
use super::utils::CodeVerfier;
use super::dhl_claims::DHLClaimsOptional;
use serde::Serialize;
pub fn client_id() -> &'static str {
"42ec7de4-e357-4c5d-aa63-f6aae5ca4d8f"
}
pub fn redirect_uri() -> &'static str {
"dhllogin://de.dhl.paket/login"
}
pub mod token {
use super::*;
pub fn refresh_token_form(refresh_token: &str) -> Vec<(&str, &str)> {
vec![
("refresh_token", refresh_token),
("grant_type", "refresh_token"),
("client_id", client_id()),
]
}
pub fn authorization_code_form(
authorization_code: String,
code_verfier: &CodeVerfier,
) -> Vec<(String, String)> {
vec![
("code".to_string(), authorization_code),
("grant_type".to_string(), "authorization_code".to_string()),
("redirect_uri".to_string(), redirect_uri().to_string()),
("code_verifier".to_string(), code_verfier.code_verfier()),
("client_id".to_string(), client_id().to_string()),
]
}
pub fn user_agent() -> &'static str {
"Dalvik/2.1.0 (Linux; U; Android 11; OnePlus 6T Build/RQ3A.211001.001)"
}
pub fn endpoint() -> &'static str {
"https://login.dhl.de/af5f9bb6-27ad-4af4-9445-008e7a5cddb8/login/token"
}
pub fn headers() -> reqwest::header::HeaderMap {
let aaa = vec![
("Content-Type", "application/x-www-form-urlencoded"),
("Accept", "application/json"),
("Content-Length", "150"),
("Host", "login.dhl.de"),
("Connection", "Keep-Alive"),
("Accept-Encoding", "gzip"),
];
let mut map = reqwest::header::HeaderMap::new();
for bbb in aaa {
map.append(bbb.0, bbb.1.parse().unwrap());
}
map
}
}
pub mod webbrowser_authorize {
use crate::constants::web_user_agent;
use super::*;
pub fn user_agent() -> String {
web_user_agent()
}
pub fn build_endpoint(nonce: &String, code_verfier: &CodeVerfier) -> String {
endpoint().to_string() + authorize_query_string(nonce, code_verfier).as_str()
}
fn endpoint() -> &'static str {
"https://login.dhl.de/af5f9bb6-27ad-4af4-9445-008e7a5cddb8/login/authorize"
}
// copying an app state
fn state_json() -> &'static str {
"ewogICJmaWQiOiAiYXBwLWxvZ2luLW1laHItZm9vdGVyIiwKICAiaGlkIjogImFwcC1sb2dpbi1tZWhyLWhlYWRlciIsCiAgIm1yIjogZmFsc2UsCiAgInJwIjogZmFsc2UsCiAgInJzIjogdHJ1ZSwKICAicnYiOiBmYWxzZQp9"
}
fn authorize_query_string(nonce: &String, code_verfier: &CodeVerfier) -> String {
let mut out = "?".to_string();
let mut iter = authorize_query(nonce, code_verfier).into_iter();
let mut not_first_run = false;
while let Some(val) = iter.next() {
if not_first_run {
out = out + "&";
} else {
not_first_run = true;
}
out = out
+ urlencoding::encode(&val.0).into_owned().as_str()
+ "="
+ urlencoding::encode(&val.1).into_owned().as_str();
}
println!("{}", out);
out
}
#[derive(Serialize, Default)]
struct Claims {
pub id_token: DHLClaimsOptional,
}
pub fn authorize_query(nonce: &String, code_verfier: &CodeVerfier) -> Vec<(String, String)> {
vec![
("redirect_uri".to_string(), redirect_uri().to_string()),
("client_id".to_string(), client_id().to_string()),
("response_type".to_string(), "code".to_string()),
("prompt".to_string(), "login".to_string()),
("state".to_string(), state_json().to_string()),
("nonce".to_string(), nonce.clone()),
("scope".to_string(), "openid offline_access".to_string()),
("code_challenge".to_string(), code_verfier.code_challenge()),
("code_challenge_method".to_string(), "S256".to_string()),
(
"claims".to_string(),
serde_json::to_string(&Claims::default()).unwrap(),
),
]
}
}

View file

@ -0,0 +1,58 @@
use super::openid_token::OpenIDToken;
use serde::{Deserialize, Serialize};
use serde_newtype::newtype;
// used to generate scopes, copy of DHLClaims
#[derive(Serialize, Default, Debug)]
pub(super) struct DHLClaimsOptional {
customer_type: Option<u64>,
data_confirmation_required: Option<String>,
deactivate_account: Option<String>,
display_name: Option<String>,
email: Option<String>,
last_login: Option<String>,
post_number: Option<String>,
service_mask: Option<String>,
twofa: Option<u64>, // probably an enum
}
// FIXME: made everything optional again, as those weren't initally there
#[derive(Deserialize, Debug, Clone)]
pub struct DHLClaims {
customer_type: u64,
data_confirmation_required: Option<String>,
deactivate_account: Option<String>,
display_name: Option<String>,
email: Option<String>,
last_login: Option<String>,
post_number: PostNumber,
service_mask: Option<String>,
twofa: Option<u64>, // FIXME: probably an enum
}
// TODO: Sanity checks
newtype! {
#[derive(Debug, Clone)]
pub PostNumber: String[|_k| true,
"",
];
}
newtype! {
#[derive(Debug)]
pub DHLCs: String[|_k| true,
"",
];
}
impl OpenIDToken<DHLClaims> {
pub fn get_post_number(&self) -> &PostNumber {
&self.claims.custom.post_number
}
pub fn get_dhlcs(&self) -> DHLCs {
DHLCs::new(self.claims.sub.clone()).unwrap()
}
}
pub type DHLIdToken = OpenIDToken<DHLClaims>;

100
libpaket/src/login/mod.rs Normal file
View file

@ -0,0 +1,100 @@
pub mod constants;
mod dhl_claims;
mod openid_response;
pub mod openid_token;
mod utils;
pub use self::dhl_claims::{DHLIdToken, DHLCs};
pub use self::openid_response::{RefreshToken, TokenResponse};
pub use self::utils::{CodeVerfier, create_nonce};
use super::common::APIResult;
use crate::{LibraryError, LibraryResult};
pub struct OpenIdClient {
client: reqwest::Client,
}
impl OpenIdClient {
pub fn new() -> Self {
OpenIdClient {
client: reqwest::ClientBuilder::new()
.default_headers(constants::token::headers())
.user_agent(constants::token::user_agent())
.build()
.unwrap(),
}
}
pub async fn token_refresh(
&self,
refresh_token: &RefreshToken,
) -> LibraryResult<TokenResponse> {
let req = self
.client
.post(constants::token::endpoint())
.form(constants::token::refresh_token_form(refresh_token.as_str()).as_slice())
.build()
.unwrap();
let res = self.client.execute(req).await;
match res {
Ok(res) => {
let res = res.text().await.unwrap();
let res = serde_json::from_str::<APIResult<TokenResponse>>(&res);
match res {
Ok(res) => return res.into(),
Err(err) => Err(LibraryError::from(err)),
}
}
Err(err) => Err(LibraryError::from(err)),
}
}
pub async fn token_authorization(
&self,
authorization_code: String,
code_verfier: &CodeVerfier,
) -> LibraryResult<TokenResponse> {
let mut req = self
.client
.post(constants::token::endpoint())
.form(
constants::token::authorization_code_form(authorization_code, code_verfier)
.as_slice(),
)
.header("Host", "login.dhl.de")
.header("Accept", "application/json")
.header("Accept-Encoding", "gzip")
.header("User-Agent", constants::token::user_agent())
.build()
.unwrap();
{
let len = req.body().unwrap().as_bytes().unwrap().len();
let headermap = req.headers_mut();
headermap.append("Content-Length", len.into());
}
let res = self.client.execute(req).await;
println!("auth_code: {:?}", res);
match res {
Ok(res) => {
let res = res.text().await.unwrap();
println!("auth_code reply: {:?}", res);
let res = serde_json::from_str::<APIResult<TokenResponse>>(&res);
match res {
Ok(res) => res.into(),
Err(err) => Err(LibraryError::from(err)),
}
}
Err(err) => Err(LibraryError::from(err)),
}
}
}

View file

@ -0,0 +1,23 @@
use serde::Deserialize;
use serde_newtype::newtype;
use super::DHLIdToken;
#[derive(Deserialize, Debug)]
pub struct TokenResponse {
access_token: String,
expires_in: u64,
pub id_token: DHLIdToken,
pub refresh_token: RefreshToken,
scope: String,
token_type: String,
}
// TODO: sanity checks
newtype! {
#[derive(Debug, Clone)]
pub RefreshToken: String[|_k| true,
"",
];
}
impl secrecy::SerializableSecret for RefreshToken {}

View file

@ -0,0 +1,121 @@
use std::marker::PhantomData;
use base64::{engine::general_purpose, Engine as _};
use serde::{
de::{DeserializeOwned, Visitor},
Deserialize, Serialize,
};
#[derive(Deserialize, Debug, Clone)]
pub struct Claims<CustomClaims> {
// only on first time login
pub nonce: Option<String>,
auth_time: u64,
pub iat: u64,
pub exp: u64,
pub aud: Vec<String>,
pub sub: String,
pub iss: String,
#[serde(flatten)]
pub custom: CustomClaims,
}
#[derive(Debug, Clone)]
pub struct OpenIDToken<CustomClaims> {
token: String,
pub claims: Claims<CustomClaims>,
}
impl<T> secrecy::SerializableSecret for OpenIDToken<T> {}
impl<T> OpenIDToken<T> {
pub fn has_nonce(&self) -> bool {
self.claims.nonce.is_some()
}
pub fn verify_nonce(&self, nonce: String) -> bool {
if let Some(claims_nonce) = &self.claims.nonce {
return claims_nonce.as_str() == nonce.as_str();
}
return false;
}
pub fn to_string(&self) -> String {
self.token.clone()
}
pub fn as_str(&self) -> &str {
self.token.as_str()
}
// requires valid system time
pub fn is_expired(&self) -> bool {
let duration: Result<std::time::Duration, std::time::SystemTimeError> = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH);
let Ok(duration) = duration else {
return true;
};
// we artificaly shorten the expiary period, as we don't want it to happen during an api call
duration.as_secs() > self.claims.exp - 30
}
pub fn expire_time(&self) -> u64 {
self.claims.exp - 30
}
}
impl<T> Serialize for OpenIDToken<T> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.token.as_str())
}
}
impl<'de, T: DeserializeOwned> Deserialize<'de> for OpenIDToken<T> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_string(OpenIDTokenVisitor {
phantom_data: PhantomData,
})
}
}
struct OpenIDTokenVisitor<T> {
phantom_data: PhantomData<T>,
}
impl<'de, T> Visitor<'de> for OpenIDTokenVisitor<T>
where
T: DeserializeOwned,
{
type Value = OpenIDToken<T>;
fn expecting(&self, _: &mut std::fmt::Formatter) -> std::fmt::Result {
todo!()
}
fn visit_str<'a, E>(self, s: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let splits = s.split_terminator(".").collect::<Vec<&str>>();
let claims = serde_json::from_slice::<Claims<T>>(
general_purpose::URL_SAFE_NO_PAD
.decode(splits[1])
.unwrap()
.as_slice(),
).unwrap();
Ok(OpenIDToken {
token: s.to_string(),
claims: claims,
})
}
}

View file

@ -0,0 +1,41 @@
use base64::{engine::general_purpose, Engine as _};
use sha2::{Digest, Sha256};
pub fn create_nonce() -> String {
let charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-";
random_string::generate(22, charset)
}
#[derive(PartialEq, Eq, Clone)]
pub struct CodeVerfier {
client_secret: String,
}
impl CodeVerfier {
pub fn new() -> Self {
let charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-";
CodeVerfier {
client_secret: random_string::generate(86, charset),
}
}
fn sha256(&self) -> Vec<u8> {
let hash = {
let mut hasher = Sha256::new();
hasher.update(self.client_secret.as_bytes());
hasher.finalize()
};
hash.to_vec()
}
pub fn code_challenge(&self) -> String {
general_purpose::URL_SAFE_NO_PAD.encode(self.sha256())
}
pub fn code_verfier(&self) -> String {
self.client_secret.clone()
}
}

152
libpaket/src/stammdaten.rs Normal file
View file

@ -0,0 +1,152 @@
use reqwest::{header::HeaderMap, Request, RequestBuilder};
use crate::www::authorized_credentials;
use crate::constants::{app_version, linux_android_version};
use crate::common::APIResult;
use crate::{login::DHLIdToken, LibraryResult};
pub struct StammdatenClient {
pub(crate) client: reqwest::Client,
}
// FIXME: Parse more status codes and return LibraryErrors
impl StammdatenClient {
pub fn new() -> Self {
StammdatenClient {
client: reqwest::ClientBuilder::new()
.default_headers(headers())
.user_agent(user_agent())
.build()
.unwrap(),
}
}
pub(crate) fn base_request(&self, request_builder: RequestBuilder, dhli: &DHLIdToken) -> Request {
request_builder
.basic_auth(
authorized_credentials().0,
Some(authorized_credentials().1),
)
.headers(headers())
.header("cookie", format!("dhli={}", dhli.as_str()))
.build()
.unwrap()
}
pub async fn customer_data(&self, dhli: &DHLIdToken) -> LibraryResult<CustomerData> {
let req = self.base_request(self.client.get(endpoint_customer_data()), dhli);
let res = self.client.execute(req).await;
if let Err(err) = res {
return Err(err.into());
};
let res = res.unwrap().text().await.unwrap();
let res = serde_json::from_str::<APIResult<CustomerData>>(&res);
match res {
Ok(res) => res.into(),
Err(err) => Err(err.into()),
}
}
pub async fn customer_data_full(&self, dhli: &DHLIdToken) -> LibraryResult<CustomerDataFull> {
let req = self.base_request(self.client.get(endpoint_customer_master_data()), dhli);
let res = self.client.execute(req).await;
if let Err(err) = res {
return Err(err.into());
};
let res = res.unwrap().text().await.unwrap();
let res = serde_json::from_str::<APIResult<CustomerDataFull>>(&res);
match res {
Ok(res) => res.into(),
Err(err) => Err(err.into()),
}
}
}
fn user_agent() -> String {
format!(
"okhttp/4.11.0 Post & DHL/{} ({})",
app_version(),
linux_android_version()
)
}
fn headers() -> HeaderMap {
let aaa = vec![
("x-api-key", "zAuoC3%7*qbRVmiXdNGyYz9iJ7N@Ph3Cw4zV"),
/* authorization: Basic base dhl.de http credentials */
/* cookie: dhli= */
];
let mut map = HeaderMap::new();
for bbb in aaa {
map.append(bbb.0, bbb.1.parse().unwrap());
}
map
}
// needs authorized
fn endpoint_customer_data() -> &'static str {
"https://www.dhl.de/int-stammdaten/public/customerData"
}
fn endpoint_customer_master_data() -> &'static str {
"https://www.dhl.de/int-stammdaten/public/customerMasterData"
}
#[derive(serde::Deserialize, Debug)]
pub struct CustomerData {
//dataConfirmationRequired
#[serde(rename = "displayName")]
pub display_name: String,
#[serde(rename = "emailAddress")]
pub email_address: String,
#[serde(rename = "postNumber")]
pub post_number: String,
pub services: Vec<CustomerDataService>,
}
#[derive(serde::Deserialize, Debug)]
pub struct CustomerAddress {
pub city: String,
pub country: Option<String>,
#[serde(rename = "houseNumber")]
pub house_number: String,
#[serde(rename = "postalCode")]
pub postal_code: String,
pub street: String,
}
#[derive(serde::Deserialize, Debug)]
pub enum CustomerDataService {
#[serde(rename = "PACKSTATION")]
Packstation,
#[serde(rename = "PAKETANKUENDIGUNG")]
Paketankuendigung,
#[serde(rename = "POSTFILIALE_DIREKT")]
PostfilialeDirekt,
#[serde(rename = "DIGIBEN")]
Digiben,
#[serde(rename = "GERAET_AKTIVIERT")]
GeraetAktiviert,
#[serde(rename = "BRIEFANKUENDIGUNG")]
Briefankuendigung,
}
#[derive(serde::Deserialize, Debug)]
pub struct CustomerDataFull {
#[serde(flatten)]
pub common: CustomerData,
pub address: CustomerAddress,
}

75
libpaket/src/utils.rs Normal file
View file

@ -0,0 +1,75 @@
use crate::login::DHLIdToken;
pub(crate) fn as_url(input: &str) -> url::Url {
url::Url::parse(input).unwrap()
}
pub(crate) struct CookieHeaderValueBuilder {
list: Vec<(String, String)>,
}
impl CookieHeaderValueBuilder {
pub fn new() -> Self {
CookieHeaderValueBuilder { list: Vec::new() }
}
pub fn add_dhli(mut self, dhli: &DHLIdToken) -> Self {
self.list.push(("dhli".to_string(), dhli.to_string()));
self
}
pub fn add_dhlcs(mut self, dhli: &DHLIdToken) -> Self {
self.list.push(("dhlcs".to_string(), dhli.get_dhlcs().to_string()));
self
}
pub fn build_string(self) -> String {
let name_value = self
.list
.iter()
.map(|(name, value)| name.clone().to_owned() + "=" + value);
name_value.collect::<Vec<String>>().join("; ")
}
pub fn build_headermap(self) -> reqwest::header::HeaderMap {
let mut map = reqwest::header::HeaderMap::new();
self.list.iter().for_each(|(name, value)| {
map.append(
"cookie",
reqwest::header::HeaderValue::from_str(format!("{}={}", name, value).as_str())
.unwrap(),
);
});
map
}
}
// TODO: use crate `tracing`
macro_rules! mini_assert_api_eq {
($a:expr, $b:expr) => {
if $a != $b {
return Err(crate::LibraryError::APIChange);
}
}
}
macro_rules! mini_assert_api {
($a: expr) => {
if !$a {
return Err(crate::LibraryError::APIChange);
}
}
}
macro_rules! mini_assert_inval {
($a: expr) => {
if !$a {
return Err(crate::LibraryError::InvalidArgument(format!("MiniAssert failed: $a")));
}
}
}

50
libpaket/src/www.rs Normal file
View file

@ -0,0 +1,50 @@
use reqwest::header::HeaderMap;
pub struct WebClient {
pub(crate) web_client: reqwest::Client,
}
impl WebClient {
pub fn new() -> Self {
WebClient {
web_client: reqwest::ClientBuilder::new()
.default_headers(web_headers())
.user_agent(crate::constants::webview_user_agent())
.build()
.unwrap(),
}
}
}
pub fn web_headers() -> HeaderMap {
let aaa = vec![
("accept", "application/json"),
("pragma", "no-cache"),
("cache-control", "no-cache,no-store,must-revalidate"),
("accept-language", "de"),
("expires", "0"),
("x-requested-with", "de.dhl.paket"),
("sec-fetch-site", "same-origin"),
("sec-fetch-mode", "cors"),
(
"referer",
"https://www.dhl.de/int-static/pdapp/spa/prod/ver5-SPA-VERFOLGEN.html",
),
];
let mut map = HeaderMap::new();
for bbb in aaa {
map.append(bbb.0, bbb.1.parse().unwrap());
}
map
}
pub(crate) fn authorized_credentials() -> (&'static str, &'static str) {
("erkennen", "8XRUfutM8PTvUz3A")
}
// "/int-push/", "X-APIKey", "5{@8*nB=?\\.@t,XwWK>Y[=yY^*Y8&myzDE7_"
// /int-stammdaten/", null, "zAuoC3%7*qbRVmiXdNGyYz9iJ7N@Ph3Cw4zV"
// "/int-verfolgen/data/packstation/v2/", null, "a0d5b9049ba8918871e6e20bd5c49974",
// "/", null, "a0d5b9049ba8918871e6e20bd5c49974",

18
paket/Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "paket"
authors.workspace = true
edition.workspace = true
license.workspace = true
version.workspace = true
[dependencies]
relm4 = { version = "0.9" , features = [ "libadwaita", "macros" ], path = "/home/jane400/sources/alpine/Relm4/relm4" }
relm4-components = { version = "0.9", path = "/home/jane400/sources/alpine/Relm4/relm4-components" }
relm4-macros = { version = "0.9", path = "/home/jane400/sources/alpine/Relm4/relm4-macros" }
tracker = "0.2"
adw = {package = "libadwaita", version = "0.7", features = [ "v1_5" ]}
webkit = { package = "webkit6", version = "0.4" }
reqwest = "0.12"
libpaket = { path = "../libpaket" }
glycin = { version = "2.0.0-beta", features = ["gdk4"] }
oo7 = { version = "0.3" }

106
paket/src/advices.rs Normal file
View file

@ -0,0 +1,106 @@
use libpaket::advices::UatToken;
use libpaket::LibraryError;
use relm4::gtk;
use gtk::gdk;
use adw::{gio, glib};
use relm4::prelude::*;
use adw::prelude::*;
use glib::prelude::*;
use gio::prelude::*;
#[derive(Debug)]
pub struct AppAdviceMetadata {
pub date: String,
pub advice: libpaket::advices::Advice,
}
#[tracker::track]
pub struct AppAdvice {
#[do_not_track]
metadata: AppAdviceMetadata,
texture: Option<gdk::Texture>,
}
#[derive(Debug)]
pub enum AppAdviceCmds {
GotTexture(gdk::Texture),
Error(LibraryError),
}
#[relm4::factory(pub)]
impl FactoryComponent for AppAdvice {
type Init = (AppAdviceMetadata, UatToken);
type Input = ();
type Output = ();
type CommandOutput = AppAdviceCmds;
type ParentWidget = adw::Carousel;
view! {
#[root]
gtk::Overlay {
add_overlay = &gtk::Spinner {
start: (),
set_align: gtk::Align::Center,
#[track(self.changed_texture())]
set_visible: self.texture.is_none(),
},
add_overlay = &gtk::Label {
set_halign: gtk::Align::Center,
set_valign: gtk::Align::Center,
set_label: self.metadata.date.as_str(),
},
#[wrap(Some)]
set_child = &gtk::Picture {
#[track(self.changed_texture())]
set_paintable: self.texture.as_ref()
}
}
}
fn init_model(value: Self::Init, _index: &DynamicIndex, sender: FactorySender<Self>) -> Self {
let _self = Self {
metadata: value.0,
texture: None,
tracker: 0,
};
let advice = _self.metadata.advice.clone();
let uat = value.1;
sender.oneshot_command(async move {
let res = libpaket::advices::AdviceClient::new().fetch_advice_image(&advice, &uat).await;
let res = match res {
Ok(res) => res,
Err(err) => return AppAdviceCmds::Error(err),
};
let file = {
let (file, io_stream) = gio::File::new_tmp(None::<&std::path::Path>).unwrap();
let output_stream = io_stream.output_stream();
output_stream.write(res.as_slice(), None::<&gio::Cancellable>).unwrap();
file
};
let image = glycin::Loader::new(file).load().await.expect("Image decoding failed");
let frame = image.next_frame().await.expect("Image frame decoding failed");
AppAdviceCmds::GotTexture(frame.texture())
});
_self
}
fn update_cmd(&mut self, message: Self::CommandOutput, sender: FactorySender<Self>) {
match message {
AppAdviceCmds::GotTexture(texture) => self.set_texture(Some(texture)),
AppAdviceCmds::Error(err) => todo!()
};
}
}

1
paket/src/constants.rs Normal file
View file

@ -0,0 +1 @@
pub const APP_ID: &str = "de.j4ne.Paket";

384
paket/src/login.rs Normal file
View file

@ -0,0 +1,384 @@
use std::{
cell::RefCell,
collections::HashMap,
sync::{Arc, OnceLock},
time::Duration,
};
use adw::prelude::*;
use gtk::prelude::*;
use libpaket::{
login::{create_nonce, CodeVerfier, DHLIdToken, RefreshToken, TokenResponse},
LibraryError, LibraryResult, OpenIdClient,
};
use relm4::{
adw, gtk,
prelude::*,
tokio::{sync::Mutex, time::sleep},
AsyncComponentSender, SharedState,
};
use webkit::{prelude::WebViewExt, URIRequest, WebContext, WebView};
static KEYRING: OnceLock<oo7::Keyring> = OnceLock::new();
#[derive(Debug)]
pub enum LoginInput {
NeedsLogin,
NeedsRefresh,
ReceivedAuthCode(String),
BreakWorld,
}
#[derive(Debug, PartialEq)]
pub enum LoginState {
InFlow,
Offline,
}
pub struct LoginFlowModel {
code_verifier: CodeVerfier,
nonce: String,
}
pub type LoginSharedState = Arc<Mutex<Arc<SharedState<Option<DHLIdToken>>>>>;
pub async fn get_id_token(value: &LoginSharedState) -> Option<DHLIdToken> {
let mutex_guard = value.lock().await;
let shared_state_guard = mutex_guard.read();
shared_state_guard.as_ref().cloned()
}
#[tracker::track]
pub struct Login {
#[do_not_track]
shared_id_token: LoginSharedState,
#[do_not_track]
refresh_token: Option<RefreshToken>,
#[do_not_track]
flow_model: RefCell<Option<LoginFlowModel>>,
state: LoginState,
}
#[derive(Debug)]
pub enum LoginOutput {
RequiresLogin,
RequiresLoading,
NetworkFail,
Error(libpaket::LibraryError),
KeyringError(oo7::Error),
}
#[derive(Debug)]
pub enum LoginCommand {
Token(LibraryResult<TokenResponse>),
NeedsRefresh,
}
const KEYRING_ATTRIBUTES: [(&str, &str); 2] =
[("app", crate::constants::APP_ID), ("type", "refresh_token")];
#[relm4::component(async, pub)]
impl AsyncComponent for Login {
type Init = LoginSharedState;
type Input = LoginInput;
type Output = LoginOutput;
type CommandOutput = LoginCommand;
view! {
#[root]
adw::Bin {
#[wrap(Some)]
#[name = "webview"]
set_child = &WebView::builder().web_context(&webcontext).build() {
#[track(model.changed(Self::state()))]
set_visible: model.state == LoginState::InFlow,
#[track(model.changed_state() && model.state == LoginState::InFlow)]
load_request?: &model.construct_request_uri(),
}
}
}
async fn init(
init: Self::Init,
root: Self::Root,
sender: AsyncComponentSender<Self>,
) -> AsyncComponentParts<Self> {
// TODO: Make keyring creation failure less fatal :(
let mut model: Login = Login {
shared_id_token: init,
flow_model: RefCell::new(None),
refresh_token: None,
state: LoginState::Offline,
tracker: 0,
};
{
let result = oo7::Keyring::new().await;
match result {
Ok(keyring) => {
KEYRING.set(keyring).unwrap();
{
let keyring = KEYRING.get().unwrap();
if let Err(err) = keyring.unlock().await {
sender
.output(LoginOutput::KeyringError(err))
.expect("sender not worky");
} else {
match keyring
.search_items(&HashMap::from(KEYRING_ATTRIBUTES))
.await {
Ok(res) => {
if res.len() > 0 {
let item = &res[0];
let refresh_token = item.secret().await.unwrap();
let refresh_token = std::str::from_utf8(refresh_token.as_slice()).unwrap();
model.refresh_token = Some(RefreshToken::new(refresh_token.to_string()).unwrap());
sender.input(LoginInput::NeedsRefresh);
} else {
sender.input(LoginInput::NeedsLogin);
}
},
Err(err) => {
sender
.output(LoginOutput::KeyringError(err))
.expect("sender not worky");
},
};
}
}
}
Err(err) => {
sender
.output(LoginOutput::KeyringError(err))
.expect("sender not worky");
}
};
}
let webcontext = WebContext::builder().build();
{
let sender = sender.clone();
webcontext.register_uri_scheme("dhllogin", move |req| {
let uri = req.uri().unwrap();
let uri = reqwest::Url::parse(uri.as_str()).unwrap();
for (name, value) in uri.query_pairs() {
if name == "code" {
sender.input(LoginInput::ReceivedAuthCode(value.to_string()));
return;
}
}
sender.input(LoginInput::BreakWorld);
});
}
let widgets = view_output!();
let settings = WebViewExt::settings(&widgets.webview).unwrap();
settings.set_enable_developer_extras(true);
settings.set_user_agent(Some(
libpaket::login::constants::webbrowser_authorize::user_agent().as_str(),
));
AsyncComponentParts { model, widgets }
}
async fn update(
&mut self,
message: Self::Input,
sender: AsyncComponentSender<Self>,
_: &Self::Root,
) {
self.reset();
match message {
LoginInput::NeedsRefresh => {
let refresh_token = self.refresh_token.as_ref().unwrap().clone();
sender.oneshot_command(async { use_refresh_token(refresh_token).await })
}
LoginInput::ReceivedAuthCode(auth_code) => {
self.set_state(LoginState::Offline);
sender.output(LoginOutput::RequiresLoading).unwrap();
let model = self.flow_model.borrow();
let code_verifier = model.as_ref().unwrap().code_verifier.clone();
sender
.oneshot_command(async { received_auth_code(auth_code, code_verifier).await });
}
LoginInput::BreakWorld => {
self.set_state(LoginState::Offline);
sender.output(LoginOutput::Error(libpaket::LibraryError::APIChange)).unwrap();
{
let shared_id_token = self.shared_id_token.lock().await;
let mut shared_id_token = shared_id_token.write();
*shared_id_token = None;
}
self.flow_model.replace(None);
}
LoginInput::NeedsLogin => {
self.set_state(LoginState::InFlow);
sender.output(LoginOutput::RequiresLogin).unwrap();
self.flow_model.replace(Some(LoginFlowModel {
code_verifier: CodeVerfier::new(),
nonce: create_nonce(),
}));
}
};
}
async fn update_cmd(
&mut self,
message: Self::CommandOutput,
sender: AsyncComponentSender<Self>,
_: &Self::Root,
) {
match message {
LoginCommand::Token(res) => {
self.send_library_response(res, sender).await;
}
LoginCommand::NeedsRefresh => {
sender.input(LoginInput::NeedsRefresh);
}
};
}
}
#[derive(PartialEq)]
enum ResponseType {
Retry,
Okay,
}
impl Login {
fn construct_request_uri(&self) -> Option<URIRequest> {
if self.state != LoginState::InFlow {
return None;
};
let borrow = self.flow_model.borrow();
let model = borrow.as_ref().unwrap();
let uri = libpaket::login::constants::webbrowser_authorize::build_endpoint(
&model.nonce,
&model.code_verifier,
);
Some(URIRequest::new(uri.as_str()))
}
async fn send_library_response(
&mut self,
res: LibraryResult<TokenResponse>,
sender: AsyncComponentSender<Login>,
) {
match res {
Ok(res) => {
{
let id_token = res.id_token.clone();
sender.command(|out, shutdown| {
shutdown
.register(async move {
let unix_target_time = id_token.expire_time();
let duration = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap();
let duration = unix_target_time - duration.as_secs();
if duration > 0 {
let duration = Duration::from_secs(duration);
sleep(duration).await;
}
out.emit(LoginCommand::NeedsRefresh)
})
.drop_on_shutdown()
});
}
let future = async {
self.refresh_token = Some(res.refresh_token);
let keyring = KEYRING.get().unwrap();
keyring.create_item("Refresh Token", &HashMap::from(KEYRING_ATTRIBUTES), self.refresh_token.as_ref().unwrap().to_string(), true).await.unwrap();
};
if !res.id_token.is_expired() {
let credentials_model = self.shared_id_token.lock().await;
let mut credentials_model = credentials_model.write();
*credentials_model = Some(res.id_token);
}
future.await;
}
Err(res) => {
// We disarm the webkit flow/aka breaking the application. We want to reduce invalid requests
match res {
libpaket::LibraryError::APIChange => {
sender.input(LoginInput::BreakWorld);
}
libpaket::LibraryError::InvalidArgument(_) => {
panic!("{}", res);
}
libpaket::LibraryError::NetworkFetch => {
sender.output(LoginOutput::NetworkFail).unwrap();
}
libpaket::LibraryError::DecodeError(_) => {
sender.output(LoginOutput::Error(res)).unwrap();
}
libpaket::LibraryError::Unauthorized => {
sender.input(LoginInput::NeedsLogin);
}
}
}
}
}
}
fn convert_library_error_to_response_type(err: &LibraryError) -> ResponseType {
match err {
LibraryError::NetworkFetch => ResponseType::Retry,
LibraryError::Unauthorized => ResponseType::Okay,
LibraryError::InvalidArgument(_) => ResponseType::Okay,
LibraryError::DecodeError(_) => ResponseType::Okay,
LibraryError::APIChange => ResponseType::Okay,
}
}
async fn received_auth_code(auth_code: String, code_verifier: CodeVerfier) -> LoginCommand {
let client = OpenIdClient::new();
let mut err = LibraryError::NetworkFetch;
for _ in 0..6 {
let result: Result<TokenResponse, LibraryError> = client
.token_authorization(auth_code.clone(), &code_verifier)
.await;
if let Ok(result) = result {
return LoginCommand::Token(Ok(result))
}
err = result.unwrap_err();
let response_type = convert_library_error_to_response_type(&err);
if response_type == ResponseType::Retry {
continue;
} else {
return LoginCommand::Token(Err(err));
}
}
LoginCommand::Token(Err(err))
}
async fn use_refresh_token(refresh_token: RefreshToken) -> LoginCommand {
let client = OpenIdClient::new();
let mut err = LibraryError::NetworkFetch;
for _ in 0..6 {
let result: Result<TokenResponse, LibraryError> =
client.token_refresh(&refresh_token).await;
err = match result {
Ok(result) => return LoginCommand::Token(Ok(result)),
Err(err) => err,
};
let response_type = convert_library_error_to_response_type(&err);
if response_type == ResponseType::Retry {
continue;
} else {
return LoginCommand::Token(Err(err));
}
}
LoginCommand::Token(Err(err))
}

292
paket/src/main.rs Normal file
View file

@ -0,0 +1,292 @@
use std::sync::Arc;
use login::{Login, LoginOutput, LoginSharedState};
use ready::{Ready, ReadyOutput};
use relm4::{
adw, gtk, main_adw_application, prelude::*, tokio::sync::Mutex,
AsyncComponentSender, SharedState,
};
use gtk::prelude::*;
use adw::{glib, prelude::*};
mod advices;
mod constants;
mod login;
mod ready;
#[derive(Debug, PartialEq)]
enum AppState {
Loading,
RequiresLogIn,
FatalError,
Ready,
}
#[derive(Debug)]
struct AppError {
short: String,
long: String,
}
#[derive(Debug)]
enum AppInput {
ErrorOccoured(AppError),
FatalErrorOccoured(AppError),
SwitchToLogin,
SwitchToLoading,
SwitchToReady,
NetworkFail,
Notification(String, u32),
}
#[tracker::track]
struct App {
state: AppState,
_network_fail: bool,
#[do_not_track]
login: AsyncController<Login>,
#[do_not_track]
ready: Controller<Ready>,
}
#[relm4::component(async)]
impl AsyncComponent for App {
type Input = AppInput;
type Output = ();
type Init = ();
type CommandOutput = ();
view! {
#[root]
main_window = adw::ApplicationWindow::new(&main_adw_application()) {
add_breakpoint = adw::Breakpoint::new(
adw::BreakpointCondition::new_length(adw::BreakpointConditionLengthType::MaxWidth, 550.0, adw::LengthUnit::Sp)
) {
add_setter: (&ready_headerbar, "show-title", Some(&glib::Value::from(false))),
add_setter: (&ready_switcherbar, "reveal", Some(&glib::Value::from(true)))
},
set_default_height: 600,
set_default_width: 800,
set_width_request: 300,
set_height_request: 300,
#[wrap(Some)]
set_content = &adw::ViewStack {
#[name = "page_prepare"]
add = &adw::ToolbarView {
add_top_bar = &adw::HeaderBar {},
#[wrap(Some)]
set_content = prepare_toast_overlay = &adw::ToastOverlay {
#[wrap(Some)]
set_child = &adw::ViewStack {
#[name = "page_loading"]
add = &adw::Bin {
#[wrap(Some)]
set_child = &gtk::Box {
set_orientation: gtk::Orientation::Vertical,
gtk::Spinner {
start: (),
}
}
},
/* Will be filled in init with a webkit */
#[local_ref]
add = page_login -> adw::Bin {},
#[name = "page_fatal"]
add = &adw::Bin {
#[wrap(Some)]
set_child = fatal_status_page = &adw::StatusPage {
},
},
#[track(model.changed(App::state()) && model.state != AppState::Ready)]
set_visible_child: {
let page: &adw::Bin = match model.state {
AppState::Loading => page_loading.as_ref(),
AppState::RequiresLogIn => page_login.as_ref(),
AppState::FatalError => page_fatal.as_ref(),
AppState::Ready => panic!(),
};
page
}
},
},
},
#[name = "page_ready"]
add = &adw::Bin {
#[wrap(Some)]
set_child = &adw::NavigationView {
add = &adw::NavigationPage {
#[wrap(Some)]
set_child = &adw::ToolbarView {
add_top_bar = ready_headerbar = &adw::HeaderBar {
#[wrap(Some)]
set_title_widget = ready_switchertop = &adw::ViewSwitcher{
set_stack: Some(ready_view_stack),
}
},
#[wrap(Some)]
set_content = ready_toast_overlay = &adw::ToastOverlay {
set_child: Some(ready_view_stack),
},
add_bottom_bar = ready_switcherbar = &adw::ViewSwitcherBar {
set_stack: Some(ready_view_stack),
}
}
},
}
},
#[track(model.changed(App::state()) && model.state == AppState::Ready)]
set_visible_child: {
let page: &adw::Bin = page_ready.as_ref();
page
},
#[track(model.changed(App::state()) && model.state != AppState::Ready)]
set_visible_child: {
let page: &adw::ToolbarView = page_prepare.as_ref();
page
},
}
},
}
async fn init(
_: Self::Init,
root: Self::Root,
sender: AsyncComponentSender<Self>,
) -> AsyncComponentParts<Self> {
let login_shared_state = Arc::new(Mutex::new(Arc::new(SharedState::new())));
let ready = Ready::builder()
.launch(login_shared_state.clone())
.forward(sender.input_sender(), convert_ready_response);
let login = Login::builder()
.launch(login_shared_state.clone())
.forward(sender.input_sender(), convert_login_response);
let model = App {
_network_fail: false,
login,
ready,
state: AppState::Loading,
tracker: 0,
};
let ready_view_stack = model.ready.widget();
let page_login = model.login.widget();
let widgets = view_output!();
AsyncComponentParts { model, widgets }
}
async fn update_with_view(
&mut self,
widgets: &mut Self::Widgets,
message: Self::Input,
sender: AsyncComponentSender<Self>,
root: &Self::Root,
) -> Self::Output {
self.reset();
match message {
AppInput::ErrorOccoured(error) => {
let dialog: adw::AlertDialog = adw::AlertDialog::builder()
.title(error.short)
.body(error.long)
.build();
dialog.present(Some(root));
}
AppInput::SwitchToLoading => {
self.set_state(AppState::Loading);
}
AppInput::SwitchToLogin => {
self.set_state(AppState::RequiresLogIn);
}
AppInput::NetworkFail => {
self.set__network_fail(true);
if self.changed__network_fail() {
sender.input(AppInput::Notification(
"The internet connection is unstable.".to_string(),
10,
));
}
}
AppInput::Notification(notification, timeout) => {
let toast_overlay = match self.state {
AppState::Loading => &widgets.prepare_toast_overlay,
AppState::RequiresLogIn => &widgets.prepare_toast_overlay,
AppState::Ready => &widgets.ready_toast_overlay,
AppState::FatalError => &widgets.prepare_toast_overlay,
};
toast_overlay.add_toast(
adw::Toast::builder()
.title(notification.as_str())
.timeout(timeout)
.build(),
);
}
AppInput::FatalErrorOccoured(error) => {
widgets.fatal_status_page.set_title(&error.short);
widgets.fatal_status_page.set_description(Some(format!("{}\nThis error is fatal, the app can't continue.", &error.long).as_str()));
self.set_state(AppState::FatalError);
}
AppInput::SwitchToReady => {
self.set_state(AppState::Ready);
}
}
self.update_view(widgets, sender);
}
}
fn convert_login_response(response: LoginOutput) -> AppInput {
match response {
LoginOutput::RequiresLogin => AppInput::SwitchToLogin,
LoginOutput::RequiresLoading => AppInput::SwitchToLoading,
LoginOutput::Error(err) => AppInput::ErrorOccoured(AppError {
short: "An authorization error occured :(".to_string(),
long: err.to_string(),
}),
LoginOutput::NetworkFail => AppInput::NetworkFail,
LoginOutput::KeyringError(err) => AppInput::FatalErrorOccoured(AppError {
short: "Unable to operate on the keyring :(".to_string(),
long: err.to_string(),
}),
}
}
fn convert_ready_response(response: ReadyOutput) -> AppInput {
match response {
ReadyOutput::FatalError(err) => AppInput::FatalErrorOccoured(AppError {
short: "Unexpted error occured.".to_string(),
long: err.to_string(),
}),
ReadyOutput::NoServicesEnabled => AppInput::FatalErrorOccoured(AppError {
short: "You can't use this app".to_string(),
long: "There is no feature on your account which is supported by this app. You need the offical app and register for one or more of:\n\"Briefasnkündigung\"".to_string(),
}),
ReadyOutput::Error(err) => AppInput::ErrorOccoured(AppError { short: "meow".to_string(), long: err.to_string() }),
ReadyOutput::Ready => AppInput::SwitchToReady,
}
}
fn main() {
let app = RelmApp::new(constants::APP_ID);
app.run_async::<App>(());
}

286
paket/src/ready.rs Normal file
View file

@ -0,0 +1,286 @@
// managed the various pages...
use std::time::Duration;
use adw::prelude::*;
use libpaket::{
self,
advices::{AdvicesList, UatToken},
LibraryError, LibraryResult,
};
use relm4::{adw, factory::FactoryVecDeque, prelude::*};
use crate::advices::AppAdviceMetadata;
#[derive(Debug, PartialEq)]
pub enum ReadyAdvicesState {
Loading,
HaveNone,
HaveSome,
}
#[tracker::track]
pub struct Ready {
#[do_not_track]
login: crate::LoginSharedState,
activate: bool,
have_service_advices: bool,
#[do_not_track]
advices_factory: FactoryVecDeque<crate::advices::AppAdvice>,
advices_state: ReadyAdvicesState,
}
#[derive(Debug)]
pub enum ReadyOutput {
Ready,
Error(LibraryError),
FatalError(LibraryError),
NoServicesEnabled,
}
#[derive(Debug)]
pub enum ReadyCmds {
LoggedIn,
LoggedOut,
GotCustomerDataFull(LibraryResult<libpaket::stammdaten::CustomerDataFull>),
RetryAdvices,
GotAdvices((LibraryResult<Vec<AppAdviceMetadata>>, Option<UatToken>)),
}
#[derive(Debug)]
pub enum ReadyInput {
Activate,
Deactivate,
HaveAdvicesService,
}
#[relm4::component(pub)]
impl Component for Ready {
type Input = ReadyInput;
type Output = ReadyOutput;
type Init = crate::LoginSharedState;
type CommandOutput = ReadyCmds;
view! {
#[root]
adw::ViewStack {
add = &adw::Bin {
#[wrap(Some)]
set_child = &adw::ViewStack {
#[name = "advices_page_loading"]
add = &adw::StatusPage {
set_title: "Loading mail notifications...",
},
#[name = "advices_page_no_available"]
add = &adw::StatusPage {
set_title: "No mail notifications available."
},
#[name = "advices_page_have_some"]
add = &gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
#[local_ref]
advices_carousel -> adw::Carousel {
set_orientation: gtk::Orientation::Vertical,
},
adw::CarouselIndicatorDots {
set_carousel: Some(advices_carousel),
}
},
#[track(model.changed_advices_state())]
set_visible_child: {
let page: &gtk::Widget = match model.advices_state {
ReadyAdvicesState::Loading => advices_page_loading.upcast_ref::<gtk::Widget>(),
ReadyAdvicesState::HaveNone => advices_page_no_available.upcast_ref::<gtk::Widget>(),
ReadyAdvicesState::HaveSome => advices_page_have_some.upcast_ref::<gtk::Widget>(),
};
page
},
},
} -> page_advices: adw::ViewStackPage {
set_title: Some("Mail notification"),
#[track(model.changed_have_service_advices())]
set_visible: model.have_service_advices,
}
}
}
fn init(
init: Self::Init,
root: Self::Root,
sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let advices_factory = FactoryVecDeque::builder()
.launch(adw::Carousel::new())
.detach();
let model = Ready {
have_service_advices: false,
advices_factory,
advices_state: ReadyAdvicesState::Loading,
login: init.clone(),
activate: false,
tracker: 0,
};
let advices_carousel = model.advices_factory.widget();
let widgets = view_output!();
{
let login = model.login.clone();
sender.command(move |out, shutdown| {
shutdown
.register(async move {
let login = { login.clone().as_ref().lock().await.clone() };
let (sender, receiver) = relm4::channel::<ReadyCmds>();
login.subscribe(&sender, |model| match model {
Some(_) => ReadyCmds::LoggedIn,
None => ReadyCmds::LoggedOut,
});
loop {
out.send(receiver.recv().await.unwrap()).unwrap();
}
})
.drop_on_shutdown()
});
}
ComponentParts { model, widgets }
}
fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>, _: &Self::Root) {
self.reset();
let token = self.login.clone();
match message {
ReadyInput::Activate => {
self.set_activate(true);
if self.changed_activate() {
sender.oneshot_command(async move {
let token = crate::login::get_id_token(&token).await.unwrap();
let client = libpaket::StammdatenClient::new();
ReadyCmds::GotCustomerDataFull(client.customer_data_full(&token).await)
});
}
}
ReadyInput::Deactivate => {
self.set_activate(false);
}
ReadyInput::HaveAdvicesService => {
sender.oneshot_command(async move {
// fetching advices
let dhli_token = crate::login::get_id_token(&token).await.unwrap();
let advices = libpaket::WebClient::new().advices(&dhli_token).await;
let advices = match advices {
Ok(res) => res,
Err(err) => return ReadyCmds::GotAdvices((Err(err), None)),
};
let mut advices_vec = Vec::new();
if let Some(current) = advices.get_current_advice() {
push_advice_from_libpaket_advice(&mut advices_vec, current);
}
for old_n in advices.get_old_advices() {
push_advice_from_libpaket_advice(&mut advices_vec, old_n);
}
if advices_vec.len() == 0 {
return ReadyCmds::GotAdvices((Ok(advices_vec), None));
}
match libpaket::advices::AdviceClient::new()
.access_token(&advices)
.await
{
Ok(uat_token) => ReadyCmds::GotAdvices((Ok(advices_vec), Some(uat_token))),
Err(err) => ReadyCmds::GotAdvices((Err(err), None)),
}
})
}
}
}
fn update_cmd(
&mut self,
message: Self::CommandOutput,
sender: ComponentSender<Self>,
_: &Self::Root,
) {
match message {
ReadyCmds::LoggedIn => sender.input(ReadyInput::Activate),
ReadyCmds::LoggedOut => sender.input(ReadyInput::Deactivate),
ReadyCmds::GotCustomerDataFull(res) => match res {
Ok(res) => {
let mut a_service_was_activated = false;
for service in &res.common.services {
match service {
libpaket::stammdaten::CustomerDataService::Packstation => (),
libpaket::stammdaten::CustomerDataService::Paketankuendigung => (),
libpaket::stammdaten::CustomerDataService::PostfilialeDirekt => (),
libpaket::stammdaten::CustomerDataService::Digiben => (),
libpaket::stammdaten::CustomerDataService::GeraetAktiviert => (),
libpaket::stammdaten::CustomerDataService::Briefankuendigung => {
a_service_was_activated = true;
sender.input(ReadyInput::HaveAdvicesService);
}
}
}
if a_service_was_activated {
sender.output(ReadyOutput::Ready).unwrap()
} else {
sender.output(ReadyOutput::NoServicesEnabled).unwrap();
}
}
Err(err) => sender.output(ReadyOutput::FatalError(err)).unwrap(),
},
ReadyCmds::GotAdvices(res) => match res.0 {
Ok(advices_vec) => {
{
let mut guard = self.advices_factory.guard();
guard.clear();
}
if advices_vec.len() == 0 {
self.set_advices_state(ReadyAdvicesState::HaveNone);
} else {
self.set_advices_state(ReadyAdvicesState::HaveSome);
let uat_token = res.1.unwrap();
let mut guard = self.advices_factory.guard();
guard.clear();
for i in advices_vec {
guard.push_back((i, uat_token.clone()));
}
}
}
Err(err) => {
sender.output(ReadyOutput::Error(err)).unwrap();
sender.oneshot_command(async {
relm4::tokio::time::sleep(Duration::from_secs(30)).await;
ReadyCmds::RetryAdvices
});
}
},
ReadyCmds::RetryAdvices => {
sender.input(ReadyInput::HaveAdvicesService);
}
}
}
}
fn push_advice_from_libpaket_advice(
vec: &mut Vec<AppAdviceMetadata>,
libpaket_advice: &AdvicesList,
) {
for i in &libpaket_advice.list {
vec.push(AppAdviceMetadata {
date: libpaket_advice.date.clone(),
advice: i.clone(),
});
}
}