ubs_lib/
session.rs

1//! Low-level access to the host connection.
2
3use 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
21// TODO: remove excess queries from url
22const 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/// Information about the course query.
33#[derive(Debug, Clone)]
34pub struct Query {
35    course: Course,
36    semester: Semester,
37    career: Career,
38}
39
40impl Query {
41    /// Construct a new [`Query`](Query).
42    pub fn new(course: Course, semester: Semester, career: Career) -> Self {
43        Self {
44            course,
45            semester,
46            career,
47        }
48    }
49}
50
51/// Manages the session to the host server.
52pub struct Session<T> {
53    client: Client<T, Body>,
54    token: Token,
55}
56
57impl<T> Session<T> {
58    /// Construct a new [`Session`](Session).
59    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    /// Iterate over pages of schedules with the specified [`Query`](Query).
69    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                // Cloning `client` and `token` above is to avoid having the closure live as long
75                // as `self`. Cloning again is necessary because new ownership is needed for each
76                // step in the iteration.
77                let client = client.clone();
78                let token = token.clone();
79                let query = query.clone(); // TODO: take query as an Arc?
80
81                // `async move` doesn't implement `Unpin`, thus it is necessary to manually pin it.
82                // TODO: simplify this
83                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 load_fakes() {}
93
94    // TODO: you MUST go page-by-page, otherwise it won't return the correct result?
95    /// Get specific page for query.
96    ///
97    /// Note that this must be called incrementally, page-by-page.
98    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)] // TODO: tem
105        loop {
106            match page_num {
107                1 => {
108                    // TODO: I can separate the "get_page" functionality from the fake url
109                    // TODO: fix boilerplate
110                    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                    // TODO: do I need to send the fake result here (with ICState=2) for the next
141                    // pages to load?
142                    return Ok(page);
143                }
144                _ => {
145                    // The second page has an `ICState` of 3.
146                    let _page_num = page_num + 1;
147                    // TODO: Multiple things to know about >1 pages:
148                    //  1. Each page holds 50 groups max.
149                    //  2. They are all POST requests with a slightly differing body (ICState and
150                    //     ICAction).
151                    //  3. How I currently have it set up is not how it may actually work. Meaning,
152                    //     I know there is a second "phony" request, though invoking it does not
153                    //     seem to enable the next page to return the correct result. I'm either
154                    //     missing some minute detail in the request or I need to send more phony
155                    //     requests prior.
156                    return Err(SessionError::PagesNotImplemented);
157                }
158            }
159        }
160    }
161}
162
163/// Contains a unique identifier for the current session.
164#[derive(Debug, Clone)]
165pub struct Token(Arc<str>);
166
167impl Token {
168    /// Construct a new [`Token`](Token) with the specified [`Client`](Client).
169    pub async fn new<T>(client: &Client<T, Body>) -> Result<Self, SessionError>
170    where
171        T: Connect + Clone + Send + Sync + 'static,
172    {
173        // TODO: need to follow redirect returned by this URL, two ways to do this:
174        //  1. Make a loop and do some magic, hopefully it works.
175        //  2. Go to 1st redirect.
176        //  3. Just use reqwest.
177        let response = client
178            .request(
179                Request::builder()
180                    .uri(TOKEN1_URL)
181                    .header(header::USER_AGENT, USER_AGENT)
182                    // TODO: may or may not need the httponly and path cookies
183                    .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                    // TODO: may or may not need the httponly and path cookies
198                    .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    /// Convert the token to its string form.
210    fn as_str(&self) -> &str {
211        &self.0
212    }
213
214    /// Fetch the [`Cookie`](Cookie) object from the specified headers.
215    fn token_cookie(headers: &HeaderMap) -> Option<Cookie<'_>> {
216        headers
217            .get_all(header::SET_COOKIE)
218            .iter()
219            // TODO: collect errors and return them if no cookie was found
220            // If it can't be parsed then skip it
221            .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/// Error while fetching course data.
232#[derive(Debug, Error)]
233pub enum SessionError {
234    /// An argument to build the HTTP request was invalid.
235    /// See more [here](https://docs.rs/http/0.2.8/http/request/struct.Builder.html#errors)
236    #[error("an argument while building an HTTP request was invalid")]
237    MalformedHttpArgs(#[from] hyper::http::Error),
238    /// Failed to send HTTP request.
239    #[error(transparent)]
240    HttpRequestFailed(#[from] hyper::Error),
241    /// Attempt to parse a cookie with an invalid format.
242    #[error("could not parse cookie with an invalid format")]
243    MalformedCookie(#[from] cookie::ParseError),
244    // TODO: provide cookie parsing errors
245    /// Could not find or parse the token cookie.
246    #[error("could not find or parse the token cookie")]
247    TokenCookieNotFound,
248    /// More than one page has not yet been implemented.
249    #[error("more than one page has not been implemented")]
250    PagesNotImplemented,
251}