1use std::sync::Arc;
4
5use cookie::Cookie;
6use futures::{stream, StreamExt, TryFutureExt, TryStream, TryStreamExt};
7use hyper::{
8 body::{self, Bytes},
9 client::{connect::Connect, ResponseFuture},
10 header, Body, Client, HeaderMap, Request,
11};
12use thiserror::Error;
13
14use crate::{
15 ids::{Course, Semester},
16 Career,
17};
18
19const USER_AGENT: &str = "ubs";
20
21const FAKE1_URL: &str = "https://www.pub.hub.buffalo.edu/psc/csprdpub_1/EMPLOYEE/SA/c/SSR_STUDENT_FL.SSR_CLSRCH_MAIN_FL.GBL?Page=SSR_CLSRCH_MAIN_FL&pslnkid=CS_S201605302223124733554248&ICAJAXTrf=true&ICAJAX=1&ICMDTarget=start&ICPanelControlStyle=%20pst_side1-fixed%20pst_panel-mode%20";
23const FAKE2_URL: &str ="https://www.pub.hub.buffalo.edu/psc/csprdpub_1/EMPLOYEE/SA/c/SSR_STUDENT_FL.SSR_CLSRCH_ES_FL.GBL?Page=SSR_CLSRCH_ES_FL&SEARCH_GROUP=SSR_CLASS_SEARCH_LFF&SEARCH_TEXT=gly%20105&ES_INST=UBFLO&ES_STRM=2231&ES_ADV=N&INVOKE_SEARCHAGAIN=PTSF_GBLSRCH_FLUID";
24macro_rules! PAGE1_URL {
25 () => { "https://www.pub.hub.buffalo.edu/psc/csprdpub_3/EMPLOYEE/SA/c/SSR_STUDENT_FL.SSR_CRSE_INFO_FL.GBL?Page=SSR_CRSE_INFO_FL&Page=SSR_CS_WRAP_FL&CRSE_OFFER_NBR=1&INSTITUTION=UBFLO&CRSE_ID={}&STRM={}&ACAD_CAREER={}" };
26}
27
28const TOKEN1_URL: &str ="https://www.pub.hub.buffalo.edu/psc/csprdpub/EMPLOYEE/SA/c/NUI_FRAMEWORK.PT_LANDINGPAGE.GBL?tab=DEFAULT";
29const TOKEN2_URL: &str ="https://www.pub.hub.buffalo.edu/psc/csprdpub/EMPLOYEE/SA/c/NUI_FRAMEWORK.PT_LANDINGPAGE.GBL?tab=DEFAULT&";
30const TOKEN_COOKIE_NAME: &str = "psprd-8083-PORTAL-PSJSESSIONID";
31
32#[derive(Debug, Clone)]
34pub struct Query {
35 course: Course,
36 semester: Semester,
37 career: Career,
38}
39
40impl Query {
41 pub fn new(course: Course, semester: Semester, career: Career) -> Self {
43 Self {
44 course,
45 semester,
46 career,
47 }
48 }
49}
50
51pub struct Session<T> {
53 client: Client<T, Body>,
54 token: Token,
55}
56
57impl<T> Session<T> {
58 pub fn new(client: Client<T, Body>, token: Token) -> Self {
60 Self { client, token }
61 }
62}
63
64impl<T> Session<T>
65where
66 T: Connect + Clone + Send + Sync + 'static,
67{
68 pub fn schedule_iter(&self, query: Query) -> impl TryStream<Ok = Bytes, Error = SessionError> {
70 let client = self.client.clone();
71 let token = self.token.clone();
72 stream::iter(1..)
73 .then(move |page_num| {
74 let client = client.clone();
78 let token = token.clone();
79 let query = query.clone(); Box::pin(async move {
84 Ok(Self::get_page(client, token, query, page_num)
85 .await?
86 .await?)
87 })
88 })
89 .and_then(|response| Box::pin(body::to_bytes(response.into_body()).err_into()))
90 }
91
92 async fn get_page(
99 client: Client<T, Body>,
100 token: Token,
101 query: Query,
102 page_num: u32,
103 ) -> Result<ResponseFuture, SessionError> {
104 #[allow(clippy::never_loop)] loop {
106 match page_num {
107 1 => {
108 client
111 .request(
112 Request::builder()
113 .uri(FAKE1_URL)
114 .header(header::COOKIE, token.as_str())
115 .body(Body::empty())?,
116 )
117 .await?;
118 client
119 .request(
120 Request::builder()
121 .uri(FAKE2_URL)
122 .header(header::COOKIE, token.as_str())
123 .body(Body::empty())?,
124 )
125 .await?;
126 let page = client.request(
127 Request::builder()
128 .uri(format!(
129 PAGE1_URL!(),
130 query.course.id(),
131 query.semester.id(),
132 query.career.id()
133 ))
134 .header(header::USER_AGENT, USER_AGENT)
135 .header(header::COOKIE, token.as_str())
136 .header(header::COOKIE, "HttpOnly")
137 .header(header::COOKIE, "Path=/")
138 .body(Body::empty())?,
139 );
140 return Ok(page);
143 }
144 _ => {
145 let _page_num = page_num + 1;
147 return Err(SessionError::PagesNotImplemented);
157 }
158 }
159 }
160 }
161}
162
163#[derive(Debug, Clone)]
165pub struct Token(Arc<str>);
166
167impl Token {
168 pub async fn new<T>(client: &Client<T, Body>) -> Result<Self, SessionError>
170 where
171 T: Connect + Clone + Send + Sync + 'static,
172 {
173 let response = client
178 .request(
179 Request::builder()
180 .uri(TOKEN1_URL)
181 .header(header::USER_AGENT, USER_AGENT)
182 .body(Body::empty())?,
184 )
185 .await?;
186 let response = client
187 .request(
188 Request::builder()
189 .uri(TOKEN2_URL)
190 .header(header::USER_AGENT, USER_AGENT)
191 .header(
192 header::COOKIE,
193 Token::token_cookie(response.headers())
194 .ok_or(SessionError::TokenCookieNotFound)?
195 .to_string(),
196 )
197 .body(Body::empty())?,
199 )
200 .await?;
201
202 Ok(Self(Arc::from(
203 Token::token_cookie(response.headers())
204 .ok_or(SessionError::TokenCookieNotFound)?
205 .to_string(),
206 )))
207 }
208
209 fn as_str(&self) -> &str {
211 &self.0
212 }
213
214 fn token_cookie(headers: &HeaderMap) -> Option<Cookie<'_>> {
216 headers
217 .get_all(header::SET_COOKIE)
218 .iter()
219 .filter_map(|string| {
222 string
223 .to_str()
224 .ok()
225 .and_then(|raw_cookie| Cookie::parse(raw_cookie).ok())
226 })
227 .find(|cookie| cookie.name() == TOKEN_COOKIE_NAME)
228 }
229}
230
231#[derive(Debug, Error)]
233pub enum SessionError {
234 #[error("an argument while building an HTTP request was invalid")]
237 MalformedHttpArgs(#[from] hyper::http::Error),
238 #[error(transparent)]
240 HttpRequestFailed(#[from] hyper::Error),
241 #[error("could not parse cookie with an invalid format")]
243 MalformedCookie(#[from] cookie::ParseError),
244 #[error("could not find or parse the token cookie")]
247 TokenCookieNotFound,
248 #[error("more than one page has not been implemented")]
250 PagesNotImplemented,
251}