1038 lines
34 KiB
Rust
1038 lines
34 KiB
Rust
|
#![allow(dead_code)]
|
||
|
|
||
|
use std::path::PathBuf;
|
||
|
|
||
|
use anyhow::{anyhow, Result};
|
||
|
use chrono::naive::NaiveDate;
|
||
|
use chrono::Datelike;
|
||
|
use chrono::Local;
|
||
|
use std::cmp::min;
|
||
|
|
||
|
use clap::{Args, Parser, Subcommand};
|
||
|
|
||
|
use async_trait::async_trait;
|
||
|
use google_walletobjects1::api::{
|
||
|
FlightClass, FlightObject, FlightclasMethods, FlightobjectMethods,
|
||
|
};
|
||
|
use google_walletobjects1::oauth2::authenticator::{
|
||
|
Authenticator, DefaultHyperClient, HyperClientBuilder,
|
||
|
};
|
||
|
use google_walletobjects1::oauth2::{read_service_account_key, ServiceAccountAuthenticator};
|
||
|
use jwt_simple::algorithms::{RS256KeyPair, RSAKeyPairLike};
|
||
|
use jwt_simple::claims::JWTClaims;
|
||
|
use strum::IntoEnumIterator;
|
||
|
use strum_macros::EnumIter;
|
||
|
|
||
|
use serde::{Deserialize, Serialize};
|
||
|
|
||
|
use phf::phf_map;
|
||
|
use tokio::try_join;
|
||
|
|
||
|
#[derive(Parser)]
|
||
|
#[command(author, version, about, long_about = None, propagate_version = true, subcommand_required = true, arg_required_else_help = true)]
|
||
|
struct Cli {
|
||
|
#[arg(short, long)]
|
||
|
service_account_file: Option<PathBuf>,
|
||
|
|
||
|
#[arg(short, long, default_value = "3388000000022186420")]
|
||
|
issuer_id: String,
|
||
|
|
||
|
#[command(subcommand)]
|
||
|
command: Option<Commands>,
|
||
|
}
|
||
|
|
||
|
#[derive(Subcommand)]
|
||
|
enum Commands {
|
||
|
/// gets an existing pass
|
||
|
GetPass(GetPass),
|
||
|
|
||
|
/// gets an existing class
|
||
|
GetClass(GetClass),
|
||
|
|
||
|
/// uploads a class/pass pair, creating it if necessary. A Google Wallet Add link is printed.
|
||
|
Upload(Upload),
|
||
|
|
||
|
/// generates a class/pass pair from an image, uploads it, and then prints a Google Wallet Add
|
||
|
/// link.
|
||
|
UploadScan(UploadScan),
|
||
|
|
||
|
/// reads the aztec code out of an image
|
||
|
Scan {
|
||
|
/// Path to the image to read.
|
||
|
image: PathBuf,
|
||
|
},
|
||
|
}
|
||
|
|
||
|
#[derive(Args)]
|
||
|
struct GetClass {
|
||
|
#[arg(short, long)]
|
||
|
object_type: String,
|
||
|
|
||
|
#[arg(short, long)]
|
||
|
class_id: String,
|
||
|
}
|
||
|
|
||
|
#[derive(Args)]
|
||
|
struct GetPass {
|
||
|
#[arg(short, long)]
|
||
|
object_type: String,
|
||
|
|
||
|
#[arg(short, long)]
|
||
|
pass_id: String,
|
||
|
}
|
||
|
|
||
|
#[derive(Args)]
|
||
|
struct Upload {
|
||
|
#[arg(short, long)]
|
||
|
class_json: PathBuf,
|
||
|
|
||
|
#[arg(short, long)]
|
||
|
pass_json: PathBuf,
|
||
|
}
|
||
|
|
||
|
#[derive(Args)]
|
||
|
struct UploadScan {
|
||
|
image: PathBuf,
|
||
|
}
|
||
|
|
||
|
struct AirlineDefinition<'a> {
|
||
|
iata_code: &'a str,
|
||
|
name: &'a str,
|
||
|
boarding_policy: &'a str,
|
||
|
seat_class_policy: &'a str,
|
||
|
|
||
|
boarding_pass_background_colour: &'a str,
|
||
|
|
||
|
frequent_flyer_program_name: Option<&'a str>,
|
||
|
|
||
|
logo_url: &'a str,
|
||
|
alliance_logo_url: Option<&'a str>,
|
||
|
hero_image_logo_url: Option<&'a str>,
|
||
|
boarding_privilege_logo_url: Option<&'a str>,
|
||
|
}
|
||
|
|
||
|
const TSA_PRECHECK_LOGO: &str = "https://p.lukegb.com/raw/MiserablyDirectPiglet.jpg";
|
||
|
|
||
|
static AIRLINE_DATA: phf::Map<&'static str, AirlineDefinition<'static>> = phf_map! {
|
||
|
"VS" => AirlineDefinition{
|
||
|
iata_code: "VS",
|
||
|
name: "Virgin Atlantic",
|
||
|
boarding_policy: "GROUP_BASED",
|
||
|
seat_class_policy: "CABIN_BASED",
|
||
|
|
||
|
boarding_pass_background_colour: "#4f145b",
|
||
|
|
||
|
frequent_flyer_program_name: Some("Flying Club"),
|
||
|
|
||
|
logo_url: "https://p.lukegb.com/raw/VirtuallyCrispViper.png",
|
||
|
alliance_logo_url: None,
|
||
|
hero_image_logo_url: Some("https://p.lukegb.com/raw/FormerlyDistinctToad.png"),
|
||
|
boarding_privilege_logo_url: Some("https://p.lukegb.com/raw/DefinitelyVerifiedTitmouse.png"),
|
||
|
},
|
||
|
};
|
||
|
|
||
|
fn scan(image_path: &PathBuf) -> Result<String> {
|
||
|
let image_path_str = (*image_path).to_str().ok_or(anyhow!("invalid path"))?;
|
||
|
rxing::helpers::detect_in_file(image_path_str, Some(rxing::BarcodeFormat::AZTEC))
|
||
|
.map_err(|e| {
|
||
|
anyhow!(
|
||
|
"could not parse Aztec barcode from image {}: {}",
|
||
|
image_path_str,
|
||
|
e
|
||
|
)
|
||
|
})
|
||
|
.map(|r| r.getText().clone())
|
||
|
}
|
||
|
|
||
|
struct ServiceAccount {
|
||
|
service_account_name: String,
|
||
|
authenticator: Authenticator<<DefaultHyperClient as HyperClientBuilder>::Connector>,
|
||
|
key_pair: RS256KeyPair,
|
||
|
}
|
||
|
|
||
|
#[derive(Deserialize)]
|
||
|
struct ServiceAccountJSON {
|
||
|
// We omit a lot of fields we don't care about.
|
||
|
/// Service Account email
|
||
|
client_email: String,
|
||
|
|
||
|
/// RSA private key
|
||
|
private_key: String,
|
||
|
}
|
||
|
|
||
|
async fn load_service_account(path: &PathBuf) -> Result<ServiceAccount> {
|
||
|
let creds = read_service_account_key(path).await?;
|
||
|
let sa = ServiceAccountAuthenticator::builder(creds).build().await?;
|
||
|
|
||
|
let sa_data = tokio::fs::read(path).await?;
|
||
|
let sa_parsed: ServiceAccountJSON = serde_json::from_slice(&sa_data[..])?;
|
||
|
let key_pair = RS256KeyPair::from_pem(&sa_parsed.private_key)?;
|
||
|
|
||
|
Ok(ServiceAccount {
|
||
|
service_account_name: sa_parsed.client_email,
|
||
|
authenticator: sa,
|
||
|
key_pair: key_pair,
|
||
|
})
|
||
|
}
|
||
|
|
||
|
async fn load_thing<'a, T: Deserialize<'a>>(path: &PathBuf) -> Result<T> {
|
||
|
let data = tokio::fs::read(path).await?;
|
||
|
let mut deser = serde_json::Deserializer::from_reader(data.as_slice());
|
||
|
let parsed: T = T::deserialize(&mut deser)?;
|
||
|
return Ok(parsed);
|
||
|
}
|
||
|
|
||
|
#[async_trait]
|
||
|
trait InsertOrUpdate<T: Clone> {
|
||
|
async fn insert_now(&self, value: &T) -> Result<bool>;
|
||
|
async fn update_now(&self, value: &T) -> Result<()>;
|
||
|
}
|
||
|
|
||
|
fn is_already_exists(err: &google_walletobjects1::Error) -> bool {
|
||
|
match err {
|
||
|
google_walletobjects1::Error::BadRequest(v) => match v.pointer("/error/code") {
|
||
|
Some(num) => num == 409,
|
||
|
None => false,
|
||
|
},
|
||
|
_ => false,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#[async_trait]
|
||
|
impl<S> InsertOrUpdate<FlightClass> for FlightclasMethods<'_, S>
|
||
|
where
|
||
|
S: tower_service::Service<http::Uri> + Clone + Send + Sync + 'static,
|
||
|
S::Response: hyper::client::connect::Connection
|
||
|
+ tokio::io::AsyncRead
|
||
|
+ tokio::io::AsyncWrite
|
||
|
+ Send
|
||
|
+ Unpin
|
||
|
+ 'static,
|
||
|
S::Future: Send + Unpin + 'static,
|
||
|
S::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
|
||
|
{
|
||
|
async fn insert_now(&self, value: &FlightClass) -> Result<bool> {
|
||
|
self.insert(value.clone())
|
||
|
.doit()
|
||
|
.await
|
||
|
.and(Ok(true))
|
||
|
.or_else(|e| {
|
||
|
if is_already_exists(&e) {
|
||
|
Ok(false)
|
||
|
} else {
|
||
|
Err(anyhow!(e))
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
async fn update_now(&self, value: &FlightClass) -> Result<()> {
|
||
|
self.update(
|
||
|
value.clone(),
|
||
|
&value.id.clone().ok_or(anyhow!("no id on FlightClass"))?,
|
||
|
)
|
||
|
.doit()
|
||
|
.await?;
|
||
|
Ok(())
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#[async_trait]
|
||
|
impl<S> InsertOrUpdate<FlightObject> for FlightobjectMethods<'_, S>
|
||
|
where
|
||
|
S: tower_service::Service<http::Uri> + Clone + Send + Sync + 'static,
|
||
|
S::Response: hyper::client::connect::Connection
|
||
|
+ tokio::io::AsyncRead
|
||
|
+ tokio::io::AsyncWrite
|
||
|
+ Send
|
||
|
+ Unpin
|
||
|
+ 'static,
|
||
|
S::Future: Send + Unpin + 'static,
|
||
|
S::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
|
||
|
{
|
||
|
async fn insert_now(&self, value: &FlightObject) -> Result<bool> {
|
||
|
self.insert(value.clone())
|
||
|
.doit()
|
||
|
.await
|
||
|
.and(Ok(true))
|
||
|
.or_else(|e| {
|
||
|
if is_already_exists(&e) {
|
||
|
Ok(false)
|
||
|
} else {
|
||
|
Err(anyhow!(e))
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
async fn update_now(&self, value: &FlightObject) -> Result<()> {
|
||
|
self.update(
|
||
|
value.clone(),
|
||
|
&value.id.clone().ok_or(anyhow!("no id on FlightObject"))?,
|
||
|
)
|
||
|
.doit()
|
||
|
.await?;
|
||
|
Ok(())
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#[derive(Serialize, Deserialize)]
|
||
|
struct AddToWalletPayloadObject {
|
||
|
id: String,
|
||
|
|
||
|
#[serde(rename = "classId", skip_serializing_if = "Option::is_none")]
|
||
|
class_id: Option<String>,
|
||
|
}
|
||
|
|
||
|
#[derive(Serialize, Deserialize)]
|
||
|
struct AddToWalletPayload {
|
||
|
#[serde(skip_serializing_if = "Vec::is_empty", rename = "flightClasses")]
|
||
|
flight_classes: Vec<AddToWalletPayloadObject>,
|
||
|
|
||
|
#[serde(skip_serializing_if = "Vec::is_empty", rename = "flightObjects")]
|
||
|
flight_objects: Vec<AddToWalletPayloadObject>,
|
||
|
}
|
||
|
|
||
|
#[derive(Serialize, Deserialize)]
|
||
|
struct AddToWalletClaims {
|
||
|
origins: Vec<String>,
|
||
|
|
||
|
#[serde(rename = "typ")]
|
||
|
jwt_type: String,
|
||
|
|
||
|
payload: AddToWalletPayload,
|
||
|
}
|
||
|
|
||
|
#[derive(Clone, Copy, Debug, EnumIter)]
|
||
|
#[repr(u8)]
|
||
|
enum PassengerStatus {
|
||
|
NotCheckedIn = b'0',
|
||
|
CheckedIn = b'1',
|
||
|
BagsCheckedAndNotCheckedIn = b'2',
|
||
|
BagsCheckedAndCheckedIn = b'3',
|
||
|
PastSecurity = b'4',
|
||
|
PastGate = b'5',
|
||
|
Transit = b'6',
|
||
|
Standby = b'7',
|
||
|
BoardingDataRevalidated = b'8',
|
||
|
OriginalBoardingLineUsedWhenTicketIssued = b'9',
|
||
|
UpOrDownGradingPending = b'A',
|
||
|
}
|
||
|
|
||
|
impl PassengerStatus {
|
||
|
fn from(c: u8) -> Result<PassengerStatus> {
|
||
|
for v in PassengerStatus::iter() {
|
||
|
if v as u8 == c {
|
||
|
return Ok(v);
|
||
|
}
|
||
|
}
|
||
|
Err(anyhow!(
|
||
|
"{} ({}) is not a valid PassengerStatus value",
|
||
|
c,
|
||
|
c as char
|
||
|
))
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#[derive(Clone, Copy, Debug, EnumIter)]
|
||
|
#[repr(u8)]
|
||
|
enum DocumentationVerificationStatus {
|
||
|
NotRequired = b'0',
|
||
|
Required = b'1',
|
||
|
Performed = b'2',
|
||
|
}
|
||
|
|
||
|
impl DocumentationVerificationStatus {
|
||
|
fn from(c: u8) -> Result<DocumentationVerificationStatus> {
|
||
|
for v in DocumentationVerificationStatus::iter() {
|
||
|
if v as u8 == c {
|
||
|
return Ok(v);
|
||
|
}
|
||
|
}
|
||
|
Err(anyhow!(
|
||
|
"{} ({}) is not a valid DocumentationVerificationStatus value",
|
||
|
c,
|
||
|
c as char
|
||
|
))
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#[derive(Clone, Copy, Debug, EnumIter)]
|
||
|
#[repr(u8)]
|
||
|
enum PassengerDescription {
|
||
|
Adult = b'0',
|
||
|
Male = b'1',
|
||
|
Female = b'2',
|
||
|
Child = b'3',
|
||
|
Infant = b'4',
|
||
|
NoPassenger = b'5', // cabin baggage
|
||
|
AdultWithInfant = b'6',
|
||
|
UnaccompaniedMinor = b'7',
|
||
|
}
|
||
|
|
||
|
impl PassengerDescription {
|
||
|
fn from(c: u8) -> Result<PassengerDescription> {
|
||
|
for v in PassengerDescription::iter() {
|
||
|
if v as u8 == c {
|
||
|
return Ok(v);
|
||
|
}
|
||
|
}
|
||
|
Err(anyhow!(
|
||
|
"{} ({}) is not a valid PassengerDescription value",
|
||
|
c,
|
||
|
c as char
|
||
|
))
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#[derive(Clone, Copy, Debug, EnumIter)]
|
||
|
#[repr(u8)]
|
||
|
enum CheckInSource {
|
||
|
Web = b'W',
|
||
|
AirportKiosk = b'K',
|
||
|
RemoteKiosk = b'R',
|
||
|
MobileDevice = b'M',
|
||
|
AirportAgent = b'O',
|
||
|
TownAgent = b'T',
|
||
|
ThirdPartyVendor = b'V',
|
||
|
}
|
||
|
|
||
|
impl CheckInSource {
|
||
|
fn from(c: u8) -> Result<CheckInSource> {
|
||
|
for v in CheckInSource::iter() {
|
||
|
if v as u8 == c {
|
||
|
return Ok(v);
|
||
|
}
|
||
|
}
|
||
|
Err(anyhow!(
|
||
|
"{} ({}) is not a valid CheckInSource value",
|
||
|
c,
|
||
|
c as char
|
||
|
))
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#[derive(Debug)]
|
||
|
struct BoardingPass {
|
||
|
passenger_name: String,
|
||
|
eticket_indicator: char,
|
||
|
|
||
|
version_number: Option<char>,
|
||
|
passenger_description: Option<PassengerDescription>,
|
||
|
checkin_source: Option<CheckInSource>,
|
||
|
boarding_pass_source: Option<CheckInSource>,
|
||
|
date_of_issue: Option<NaiveDate>,
|
||
|
document_type: Option<char>,
|
||
|
boarding_pass_issuing_airline: Option<String>,
|
||
|
baggage_tag_plate_number: Option<String>,
|
||
|
|
||
|
legs: Vec<BoardingPassLeg>,
|
||
|
|
||
|
security_data_type: u8,
|
||
|
security_data: Vec<u8>,
|
||
|
}
|
||
|
|
||
|
fn parse_boarding_pass_date(
|
||
|
from_boarding_pass_str: &str,
|
||
|
nearby_date: &NaiveDate,
|
||
|
) -> Result<NaiveDate> {
|
||
|
// Range is 0 to 9999 (although not really).
|
||
|
let from_boarding_pass: u16 = from_boarding_pass_str.parse()?;
|
||
|
|
||
|
match from_boarding_pass_str.len() {
|
||
|
4 => Ok(NaiveDate::from_yo_opt(
|
||
|
(nearby_date.year() / 10 * 10) + ((from_boarding_pass / 1000) as i32),
|
||
|
(from_boarding_pass % 1000).into(),
|
||
|
)
|
||
|
.ok_or(anyhow!("could not parse date {}", from_boarding_pass_str))?),
|
||
|
3 => {
|
||
|
// Find the one that's the fewest days away.
|
||
|
let year = nearby_date.year();
|
||
|
let day: u32 = (from_boarding_pass % 1000).into();
|
||
|
let mut options = vec![
|
||
|
NaiveDate::from_yo_opt(year, day),
|
||
|
NaiveDate::from_yo_opt(year - 1, day),
|
||
|
NaiveDate::from_yo_opt(year + 1, day),
|
||
|
];
|
||
|
options.sort_by(|a, b| {
|
||
|
days_between(nearby_date, a)
|
||
|
.unwrap_or(9999)
|
||
|
.cmp(&days_between(nearby_date, b).unwrap_or(9999))
|
||
|
});
|
||
|
Ok(options[0].unwrap())
|
||
|
}
|
||
|
_ => Err(anyhow!(
|
||
|
"boarding pass date {} is wrong length",
|
||
|
from_boarding_pass_str
|
||
|
)),
|
||
|
}
|
||
|
}
|
||
|
|
||
|
fn days_between(nearby_date: &NaiveDate, maybe_date: &Option<NaiveDate>) -> Option<u64> {
|
||
|
if maybe_date.is_none() {
|
||
|
return None;
|
||
|
}
|
||
|
let is_date = maybe_date.as_ref().unwrap();
|
||
|
let days_between = (*nearby_date - *is_date).num_days();
|
||
|
Some(if days_between < 0 {
|
||
|
(-days_between) as u64
|
||
|
} else {
|
||
|
days_between as u64
|
||
|
})
|
||
|
}
|
||
|
|
||
|
impl BoardingPass {
|
||
|
fn parse(data: Vec<u8>, nearby_date: &NaiveDate) -> Result<BoardingPass> {
|
||
|
if data[0] != b'M' {
|
||
|
return Err(anyhow!(
|
||
|
"format code was {} ({}), not M",
|
||
|
data[0] as char,
|
||
|
data[0]
|
||
|
));
|
||
|
}
|
||
|
let leg_count = match data[1] {
|
||
|
b'1' => Ok(1),
|
||
|
b'2' => Ok(2),
|
||
|
b'3' => Ok(3),
|
||
|
b'4' => Ok(4),
|
||
|
_ => Err(anyhow!(
|
||
|
"leg count was {} ({}), should be 1 to 4",
|
||
|
data[1] as char,
|
||
|
data[1]
|
||
|
)),
|
||
|
}?;
|
||
|
|
||
|
let passenger_name: String = std::str::from_utf8(&data[2..22])?.trim_end().to_string();
|
||
|
let eticket_indicator: char = data[22] as char;
|
||
|
|
||
|
// First leg mandatory data:
|
||
|
let (first_leg, mut p) = BoardingPassLeg::parse_mandatory(&data[23..], nearby_date)?;
|
||
|
p += 23;
|
||
|
|
||
|
// We now have enough to start composing the boardingpass.
|
||
|
let mut boarding_pass = BoardingPass {
|
||
|
passenger_name,
|
||
|
eticket_indicator,
|
||
|
|
||
|
version_number: None,
|
||
|
passenger_description: None,
|
||
|
checkin_source: None,
|
||
|
boarding_pass_source: None,
|
||
|
date_of_issue: None,
|
||
|
document_type: None,
|
||
|
boarding_pass_issuing_airline: None,
|
||
|
baggage_tag_plate_number: None,
|
||
|
|
||
|
legs: vec![first_leg],
|
||
|
|
||
|
security_data_type: 0xff,
|
||
|
security_data: vec![],
|
||
|
};
|
||
|
|
||
|
// Header + first leg optional data (why, IATA, why)
|
||
|
let optional_data_size: usize =
|
||
|
usize::from_str_radix(std::str::from_utf8(&data[p..p + 2])?, 16)?;
|
||
|
let mut optional_data = &data[p + 2..p + 2 + optional_data_size];
|
||
|
p += 2 + optional_data_size;
|
||
|
if optional_data.len() >= 1 {
|
||
|
if optional_data[0] != b'>' {
|
||
|
return Err(anyhow!("expected Beginning of version number ('>') at beginning of conditional fields, got {} ({})", optional_data[0], optional_data[0] as char));
|
||
|
}
|
||
|
optional_data = &optional_data[1..];
|
||
|
}
|
||
|
if optional_data.len() >= 1 {
|
||
|
boarding_pass.version_number = Some(optional_data[0] as char);
|
||
|
optional_data = &optional_data[1..];
|
||
|
}
|
||
|
if optional_data.len() >= 1 {
|
||
|
let optional_data_unique_size: usize =
|
||
|
usize::from_str_radix(std::str::from_utf8(&optional_data[0..2])?, 16)?;
|
||
|
let mut optional_data_unique = &optional_data[2..optional_data_unique_size + 2];
|
||
|
if optional_data_unique.len() >= 1 {
|
||
|
boarding_pass.passenger_description = if optional_data_unique[0] == b' ' {
|
||
|
None
|
||
|
} else {
|
||
|
Some(PassengerDescription::from(optional_data_unique[0])?)
|
||
|
};
|
||
|
optional_data_unique = &optional_data_unique[1..];
|
||
|
}
|
||
|
if optional_data_unique.len() >= 1 {
|
||
|
boarding_pass.checkin_source = if optional_data_unique[0] == b' ' {
|
||
|
None
|
||
|
} else {
|
||
|
Some(CheckInSource::from(optional_data_unique[0])?)
|
||
|
};
|
||
|
optional_data_unique = &optional_data_unique[1..];
|
||
|
}
|
||
|
if optional_data_unique.len() >= 1 {
|
||
|
boarding_pass.boarding_pass_source = if optional_data_unique[0] == b' ' {
|
||
|
None
|
||
|
} else {
|
||
|
Some(CheckInSource::from(optional_data_unique[0])?)
|
||
|
};
|
||
|
optional_data_unique = &optional_data_unique[1..];
|
||
|
}
|
||
|
if optional_data_unique.len() >= 4 {
|
||
|
let date_of_issue_str = std::str::from_utf8(&optional_data_unique[0..4])?;
|
||
|
boarding_pass.date_of_issue = if date_of_issue_str == " " {
|
||
|
None
|
||
|
} else {
|
||
|
Some(parse_boarding_pass_date(date_of_issue_str, nearby_date)?)
|
||
|
};
|
||
|
optional_data_unique = &optional_data_unique[4..];
|
||
|
}
|
||
|
// TODO: the above might fall through if we don't have 4 bytes of data
|
||
|
if optional_data_unique.len() >= 1 {
|
||
|
boarding_pass.document_type = if optional_data_unique[0] == b' ' {
|
||
|
None
|
||
|
} else {
|
||
|
Some(optional_data_unique[0] as char)
|
||
|
};
|
||
|
optional_data_unique = &optional_data_unique[1..];
|
||
|
}
|
||
|
if optional_data_unique.len() >= 3 {
|
||
|
let issuing_airline_str =
|
||
|
std::str::from_utf8(&optional_data_unique[0..3])?.trim_end();
|
||
|
boarding_pass.boarding_pass_issuing_airline = if issuing_airline_str == "" {
|
||
|
None
|
||
|
} else {
|
||
|
Some(issuing_airline_str.to_string())
|
||
|
};
|
||
|
optional_data_unique = &optional_data_unique[3..];
|
||
|
}
|
||
|
// TODO: the above might fall through if we don't have 3 bytes of data
|
||
|
let bag_tags_str_len = min(13, optional_data_unique.len());
|
||
|
let bag_tags_str =
|
||
|
std::str::from_utf8(&optional_data_unique[0..bag_tags_str_len])?.trim_end();
|
||
|
boarding_pass.baggage_tag_plate_number = if bag_tags_str == "" {
|
||
|
None
|
||
|
} else {
|
||
|
Some(bag_tags_str.to_string())
|
||
|
};
|
||
|
optional_data_unique = &optional_data_unique[bag_tags_str_len..];
|
||
|
if optional_data_unique.len() != 0 {
|
||
|
return Err(anyhow!(
|
||
|
"trailing unique optional data ({} bytes)",
|
||
|
optional_data_unique.len()
|
||
|
));
|
||
|
}
|
||
|
optional_data = &optional_data[optional_data_unique_size + 2..];
|
||
|
}
|
||
|
|
||
|
// Now for the repeated optional data for the first leg.
|
||
|
boarding_pass.legs[0].add_optional(optional_data)?;
|
||
|
|
||
|
// Now any additional legs(!)
|
||
|
for _legnum in 1..leg_count {
|
||
|
let (mut leg, leg_bytes) = BoardingPassLeg::parse_mandatory(&data[p..], nearby_date)?;
|
||
|
p += leg_bytes;
|
||
|
|
||
|
let optional_data_size: usize =
|
||
|
usize::from_str_radix(std::str::from_utf8(&data[p..p + 2])?, 16)?;
|
||
|
let optional_data = &data[p + 2..p + 2 + optional_data_size];
|
||
|
p += 2 + optional_data_size;
|
||
|
|
||
|
leg.add_optional(optional_data)?;
|
||
|
boarding_pass.legs.push(leg);
|
||
|
}
|
||
|
|
||
|
// What's left is the security data.
|
||
|
if data[p] != b'^' {
|
||
|
return Err(anyhow!(
|
||
|
"expected security data beginning indicator (^) but got {} ({})",
|
||
|
data[p],
|
||
|
data[p] as char
|
||
|
));
|
||
|
}
|
||
|
boarding_pass.security_data_type = data[p + 1];
|
||
|
let security_data_size: usize =
|
||
|
usize::from_str_radix(std::str::from_utf8(&data[p + 2..p + 4])?, 16)?;
|
||
|
boarding_pass.security_data = data[p + 4..p + 4 + security_data_size].to_vec();
|
||
|
if data.len() > p + 4 + security_data_size {
|
||
|
return Err(anyhow!(
|
||
|
"trailing bytes after security data (expected {} bytes of security data)",
|
||
|
security_data_size
|
||
|
));
|
||
|
}
|
||
|
|
||
|
Ok(boarding_pass)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#[derive(Debug)]
|
||
|
struct BoardingPassLeg {
|
||
|
operating_carrier_pnr: String, // 7, left justified w/ trailing blanks
|
||
|
origin_airport: String, // 3
|
||
|
destination_airport: String, // 3
|
||
|
operating_carrier: String, // 3, left justified w/ trailing blanks
|
||
|
flight_number: String, // 5, NNNN[a] (leading zeros + alpha/blank on last digit)
|
||
|
date_of_flight: NaiveDate, // 3, leading zeros (this is the day in the year e.g. 001 = Jan 1st,
|
||
|
// 365 == Dec 31st)
|
||
|
compartment_code: char,
|
||
|
seat_number: String, // 4, NNNa (leading zeros)
|
||
|
check_in_sequence: String, // NNNN[f] (leading zeros + alpha/blank on last digit)
|
||
|
passenger_status: PassengerStatus,
|
||
|
|
||
|
airline_numeric_code: Option<String>,
|
||
|
document_serial_number: Option<String>,
|
||
|
selectee_indicator: Option<char>,
|
||
|
documentation_verification: Option<DocumentationVerificationStatus>,
|
||
|
marketing_carrier: Option<String>,
|
||
|
frequent_flyer_airline: Option<String>,
|
||
|
frequent_flyer_number: Option<String>,
|
||
|
id_ad_indicator: Option<char>,
|
||
|
free_baggage_allowance: Option<String>,
|
||
|
airline_data: Option<Vec<u8>>,
|
||
|
}
|
||
|
|
||
|
impl BoardingPassLeg {
|
||
|
fn parse_mandatory(data: &[u8], nearby_date: &NaiveDate) -> Result<(BoardingPassLeg, usize)> {
|
||
|
let operating_carrier_pnr: String =
|
||
|
std::str::from_utf8(&data[0..7])?.trim_end().to_string();
|
||
|
let origin_airport: String = std::str::from_utf8(&data[7..10])?.to_string();
|
||
|
let destination_airport: String = std::str::from_utf8(&data[10..13])?.to_string();
|
||
|
let operating_carrier: String = std::str::from_utf8(&data[13..16])?.trim_end().to_string();
|
||
|
let flight_number: String = std::str::from_utf8(&data[16..21])?
|
||
|
.trim_end()
|
||
|
.trim_start_matches('0')
|
||
|
.to_string();
|
||
|
let date_of_flight_str: &str = std::str::from_utf8(&data[21..24])?;
|
||
|
let date_of_flight: NaiveDate = parse_boarding_pass_date(date_of_flight_str, nearby_date)?;
|
||
|
let compartment_code: char = data[24] as char;
|
||
|
let seat_number: String = std::str::from_utf8(&data[25..29])?
|
||
|
.trim_start_matches('0')
|
||
|
.to_string();
|
||
|
let check_in_sequence: String = std::str::from_utf8(&data[29..34])?
|
||
|
.trim_end()
|
||
|
.trim_start_matches('0')
|
||
|
.to_string();
|
||
|
let passenger_status: PassengerStatus = PassengerStatus::from(data[34])?;
|
||
|
Ok((
|
||
|
BoardingPassLeg {
|
||
|
operating_carrier_pnr,
|
||
|
origin_airport,
|
||
|
destination_airport,
|
||
|
operating_carrier,
|
||
|
flight_number,
|
||
|
date_of_flight,
|
||
|
compartment_code,
|
||
|
seat_number,
|
||
|
check_in_sequence,
|
||
|
passenger_status,
|
||
|
|
||
|
airline_numeric_code: None,
|
||
|
document_serial_number: None,
|
||
|
selectee_indicator: None,
|
||
|
documentation_verification: None,
|
||
|
marketing_carrier: None,
|
||
|
frequent_flyer_airline: None,
|
||
|
frequent_flyer_number: None,
|
||
|
id_ad_indicator: None,
|
||
|
free_baggage_allowance: None,
|
||
|
airline_data: None,
|
||
|
},
|
||
|
35,
|
||
|
))
|
||
|
}
|
||
|
|
||
|
fn add_optional(&mut self, data: &[u8]) -> Result<()> {
|
||
|
let structured_data_size: usize =
|
||
|
usize::from_str_radix(std::str::from_utf8(&data[0..2])?, 16)?;
|
||
|
let mut structured_data = &data[2..structured_data_size + 2];
|
||
|
|
||
|
if structured_data.len() >= 3 {
|
||
|
let airline_code_str = std::str::from_utf8(&structured_data[0..3])?.trim_end();
|
||
|
self.airline_numeric_code = if airline_code_str == "" {
|
||
|
None
|
||
|
} else {
|
||
|
Some(airline_code_str.to_string())
|
||
|
};
|
||
|
structured_data = &structured_data[3..];
|
||
|
}
|
||
|
if structured_data.len() >= 10 {
|
||
|
let document_serial_number_str =
|
||
|
std::str::from_utf8(&structured_data[0..10])?.trim_end();
|
||
|
self.document_serial_number = if document_serial_number_str == "" {
|
||
|
None
|
||
|
} else {
|
||
|
Some(document_serial_number_str.to_string())
|
||
|
};
|
||
|
structured_data = &structured_data[10..];
|
||
|
}
|
||
|
if structured_data.len() >= 1 {
|
||
|
self.selectee_indicator = if structured_data[0] == b' ' {
|
||
|
None
|
||
|
} else {
|
||
|
Some(structured_data[0] as char)
|
||
|
};
|
||
|
structured_data = &structured_data[1..];
|
||
|
}
|
||
|
if structured_data.len() >= 1 {
|
||
|
self.documentation_verification = if structured_data[0] == b' ' {
|
||
|
None
|
||
|
} else {
|
||
|
Some(DocumentationVerificationStatus::from(structured_data[0])?)
|
||
|
};
|
||
|
structured_data = &structured_data[1..];
|
||
|
}
|
||
|
if structured_data.len() >= 3 {
|
||
|
let marketing_carrier_str = std::str::from_utf8(&structured_data[0..3])?.trim_end();
|
||
|
self.marketing_carrier = if marketing_carrier_str == "" {
|
||
|
None
|
||
|
} else {
|
||
|
Some(marketing_carrier_str.to_string())
|
||
|
};
|
||
|
structured_data = &structured_data[3..];
|
||
|
}
|
||
|
if structured_data.len() >= 3 {
|
||
|
let frequent_flyer_airline_str =
|
||
|
std::str::from_utf8(&structured_data[0..3])?.trim_end();
|
||
|
self.frequent_flyer_airline = if frequent_flyer_airline_str == "" {
|
||
|
None
|
||
|
} else {
|
||
|
Some(frequent_flyer_airline_str.to_string())
|
||
|
};
|
||
|
structured_data = &structured_data[3..];
|
||
|
}
|
||
|
if structured_data.len() >= 16 {
|
||
|
let frequent_flyer_number_str =
|
||
|
std::str::from_utf8(&structured_data[0..16])?.trim_end();
|
||
|
self.frequent_flyer_number = if frequent_flyer_number_str == "" {
|
||
|
None
|
||
|
} else {
|
||
|
Some(frequent_flyer_number_str.to_string())
|
||
|
};
|
||
|
structured_data = &structured_data[16..];
|
||
|
}
|
||
|
if structured_data.len() >= 1 {
|
||
|
self.id_ad_indicator = if structured_data[0] == b' ' {
|
||
|
None
|
||
|
} else {
|
||
|
Some(structured_data[0] as char)
|
||
|
};
|
||
|
structured_data = &structured_data[1..];
|
||
|
}
|
||
|
if structured_data.len() >= 3 {
|
||
|
let free_baggage_allowance_str =
|
||
|
std::str::from_utf8(&structured_data[0..3])?.trim_end();
|
||
|
self.free_baggage_allowance = if free_baggage_allowance_str == "" {
|
||
|
None
|
||
|
} else {
|
||
|
Some(free_baggage_allowance_str.to_string())
|
||
|
};
|
||
|
structured_data = &structured_data[3..];
|
||
|
}
|
||
|
if structured_data.len() != 0 {
|
||
|
return Err(anyhow!(
|
||
|
"trailing data in structured data section ({} bytes)",
|
||
|
structured_data.len()
|
||
|
));
|
||
|
}
|
||
|
|
||
|
let unstructured_data = &data[structured_data_size + 2..];
|
||
|
if unstructured_data.len() > 0 {
|
||
|
self.airline_data = Some(unstructured_data.to_vec());
|
||
|
}
|
||
|
Ok(())
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#[tokio::main]
|
||
|
async fn main() -> Result<()> {
|
||
|
let cli = Cli::parse();
|
||
|
|
||
|
let command = cli.command.unwrap();
|
||
|
|
||
|
match &command {
|
||
|
Commands::Scan { image } => {
|
||
|
println!("{}", scan(&image)?);
|
||
|
return Ok(());
|
||
|
}
|
||
|
_ => {}
|
||
|
};
|
||
|
|
||
|
let service_account_file = cli
|
||
|
.service_account_file
|
||
|
.ok_or(anyhow!("--service-account-file must be set"))?;
|
||
|
let sa = load_service_account(&service_account_file).await?;
|
||
|
|
||
|
let hub = google_walletobjects1::Walletobjects::new(
|
||
|
hyper::Client::builder().build(
|
||
|
hyper_rustls::HttpsConnectorBuilder::new()
|
||
|
.with_native_roots()
|
||
|
.https_or_http()
|
||
|
.enable_http1()
|
||
|
.enable_http2()
|
||
|
.build(),
|
||
|
),
|
||
|
sa.authenticator,
|
||
|
);
|
||
|
|
||
|
match &command {
|
||
|
Commands::Scan { image: _ } => unreachable!(),
|
||
|
Commands::GetClass(get) => {
|
||
|
let class_id: String = if get.class_id.contains('.') {
|
||
|
get.class_id.clone()
|
||
|
} else {
|
||
|
format!("{}.{}", cli.issuer_id, get.class_id)
|
||
|
};
|
||
|
|
||
|
let (_, class) = hub.flightclass().get(&class_id).doit().await?;
|
||
|
println!("{}", serde_json::to_string(&class)?);
|
||
|
|
||
|
Ok(())
|
||
|
}
|
||
|
Commands::GetPass(get) => {
|
||
|
let pass_id: String = if get.pass_id.contains('.') {
|
||
|
get.pass_id.clone()
|
||
|
} else {
|
||
|
format!("{}.{}", cli.issuer_id, get.pass_id)
|
||
|
};
|
||
|
|
||
|
let (_, pass) = hub.flightobject().get(&pass_id).doit().await?;
|
||
|
println!("{}", serde_json::to_string(&pass)?);
|
||
|
|
||
|
Ok(())
|
||
|
}
|
||
|
Commands::Upload(upload) => {
|
||
|
let (pass, mut class): (FlightObject, FlightClass) = try_join!(
|
||
|
load_thing(&upload.pass_json),
|
||
|
load_thing(&upload.class_json)
|
||
|
)?;
|
||
|
|
||
|
// Always reset the class state to "UNDER_REVIEW".
|
||
|
class.review_status = Some("UNDER_REVIEW".to_string());
|
||
|
|
||
|
// Try to create the class first...
|
||
|
let created_class: bool = hub.flightclass().insert_now(&class).await?;
|
||
|
if !created_class {
|
||
|
// Already exists.
|
||
|
hub.flightclass().update_now(&class).await?;
|
||
|
}
|
||
|
|
||
|
// Then the pass...
|
||
|
let created_pass: bool = hub.flightobject().insert_now(&pass).await?;
|
||
|
if !created_pass {
|
||
|
// Already exists.
|
||
|
hub.flightobject().update_now(&pass).await?;
|
||
|
}
|
||
|
|
||
|
// Now generate the URL.
|
||
|
let claims: JWTClaims<AddToWalletClaims> = JWTClaims {
|
||
|
issued_at: None,
|
||
|
expires_at: None,
|
||
|
invalid_before: None,
|
||
|
audiences: Some(jwt_simple::claims::Audiences::AsString(
|
||
|
"google".to_string(),
|
||
|
)),
|
||
|
issuer: Some(sa.service_account_name),
|
||
|
jwt_id: None,
|
||
|
subject: None,
|
||
|
nonce: None,
|
||
|
custom: AddToWalletClaims {
|
||
|
jwt_type: "savetowallet".to_string(),
|
||
|
origins: vec!["www.lukegb.com".to_string()],
|
||
|
payload: AddToWalletPayload {
|
||
|
flight_classes: vec![AddToWalletPayloadObject {
|
||
|
id: class.id.clone().unwrap(),
|
||
|
class_id: None,
|
||
|
}],
|
||
|
flight_objects: vec![AddToWalletPayloadObject {
|
||
|
id: pass.id.unwrap(),
|
||
|
class_id: Some(class.id.unwrap()),
|
||
|
}],
|
||
|
},
|
||
|
},
|
||
|
};
|
||
|
let token = sa.key_pair.sign(claims)?;
|
||
|
|
||
|
println!("https://pay.google.com/gp/v/save/{}", token);
|
||
|
|
||
|
Ok(())
|
||
|
}
|
||
|
Commands::UploadScan(upload_scan) => {
|
||
|
let scanned = scan(&upload_scan.image)?;
|
||
|
let boarding_pass =
|
||
|
BoardingPass::parse(scanned.as_bytes().to_vec(), &Local::now().date_naive())?;
|
||
|
if boarding_pass.legs.len() > 1 {
|
||
|
return Err(anyhow!("multi-leg trips aren't supported yet"));
|
||
|
}
|
||
|
let leg = &boarding_pass.legs[0];
|
||
|
let airline_data = AIRLINE_DATA.get(&leg.operating_carrier).ok_or(anyhow!(
|
||
|
"no AIRLINE_DATA has been provided for {} yet",
|
||
|
leg.operating_carrier
|
||
|
))?;
|
||
|
|
||
|
let id_prefix = format!(
|
||
|
"{}.{}{}-{}-{}{}",
|
||
|
cli.issuer_id,
|
||
|
leg.operating_carrier,
|
||
|
leg.flight_number,
|
||
|
leg.date_of_flight,
|
||
|
leg.origin_airport,
|
||
|
leg.destination_airport
|
||
|
);
|
||
|
let class_id = &id_prefix;
|
||
|
let pass_id = format!("{}-{}", &id_prefix, leg.check_in_sequence);
|
||
|
|
||
|
let mut class = FlightClass::default();
|
||
|
class.id = Some(class_id.clone());
|
||
|
class.issuer_name = Some(airline_data.name.to_string());
|
||
|
class.hex_background_color =
|
||
|
Some(airline_data.boarding_pass_background_colour.to_string());
|
||
|
class.review_status = Some("UNDER_REVIEW".to_string());
|
||
|
class.boarding_and_seating_policy =
|
||
|
Some(google_walletobjects1::api::BoardingAndSeatingPolicy {
|
||
|
boarding_policy: Some(airline_data.boarding_policy.to_string()),
|
||
|
seat_class_policy: Some(airline_data.seat_class_policy.to_string()),
|
||
|
kind: None,
|
||
|
});
|
||
|
class.origin = Some(airport(&leg.origin_airport));
|
||
|
class.destination = Some(airport(&leg.destination_airport));
|
||
|
let mut flight_header = google_walletobjects1::api::FlightHeader::default();
|
||
|
let mut flight_header_carrier = google_walletobjects1::api::FlightCarrier::default();
|
||
|
flight_header_carrier.airline_alliance_logo =
|
||
|
airline_data.alliance_logo_url.map(url_to_image);
|
||
|
flight_header_carrier.airline_logo = Some(url_to_image(airline_data.logo_url));
|
||
|
flight_header_carrier.airline_name =
|
||
|
Some(to_localized_string("en-GB", airline_data.name));
|
||
|
flight_header_carrier.carrier_iata_code = Some(leg.operating_carrier.to_string());
|
||
|
flight_header.carrier = Some(flight_header_carrier);
|
||
|
flight_header.flight_number = Some(leg.flight_number.to_string());
|
||
|
class.flight_header = Some(flight_header);
|
||
|
|
||
|
let mut pass = FlightObject::default();
|
||
|
pass.id = Some(pass_id.to_string());
|
||
|
pass.class_id = Some(class_id.clone());
|
||
|
|
||
|
Ok(())
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
fn airport(iata_code: &str) -> google_walletobjects1::api::AirportInfo {
|
||
|
let mut info = google_walletobjects1::api::AirportInfo::default();
|
||
|
info.airport_iata_code = Some(iata_code.to_string());
|
||
|
info
|
||
|
}
|
||
|
|
||
|
fn to_localized_string(locale: &str, s: &str) -> google_walletobjects1::api::LocalizedString {
|
||
|
google_walletobjects1::api::LocalizedString {
|
||
|
default_value: Some(google_walletobjects1::api::TranslatedString {
|
||
|
kind: None,
|
||
|
language: Some(locale.to_string()),
|
||
|
value: Some(s.to_string()),
|
||
|
}),
|
||
|
kind: None,
|
||
|
translated_values: None,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
fn url_to_image(url: &str) -> google_walletobjects1::api::Image {
|
||
|
google_walletobjects1::api::Image {
|
||
|
kind: None,
|
||
|
content_description: None,
|
||
|
source_uri: Some(google_walletobjects1::api::ImageUri {
|
||
|
description: None,
|
||
|
localized_description: None,
|
||
|
uri: Some(url.to_string()),
|
||
|
}),
|
||
|
}
|
||
|
}
|