diff --git a/Cargo.lock b/Cargo.lock index ef60e73..dd28c71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -183,6 +183,8 @@ dependencies = [ "chrono", "chrono-tz", "compile-time", + "cookie", + "dashmap 6.1.0", "dotenvy", "figment", "fundu", @@ -192,6 +194,7 @@ dependencies = [ "redis", "regex", "reqwest 0.12.23", + "reqwest-middleware", "serde", "serde_json", "serenity", @@ -2307,6 +2310,21 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reqwest-middleware" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" +dependencies = [ + "anyhow", + "async-trait", + "http 1.3.1", + "reqwest 0.12.23", + "serde", + "thiserror 1.0.69", + "tower-service", +] + [[package]] name = "ring" version = "0.17.14" diff --git a/Cargo.toml b/Cargo.toml index 3c091ce..83b8747 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "banner" version = "0.1.0" edition = "2024" +default-run = "banner" [dependencies] anyhow = "1.0.99" @@ -11,6 +12,8 @@ bitflags = { version = "2.9.3", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] } chrono-tz = "0.10.4" compile-time = "0.2.0" +cookie = "0.18.1" +dashmap = "6.1.0" dotenvy = "0.15.7" figment = { version = "0.10.19", features = ["toml", "env"] } fundu = "2.0.1" @@ -20,6 +23,7 @@ rand = "0.9.2" redis = { version = "0.32.5", features = ["tokio-comp", "r2d2"] } regex = "1.10" reqwest = { version = "0.12.23", features = ["json", "cookies"] } +reqwest-middleware = { version = "0.4.2", features = ["json"] } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.143" serenity = { version = "0.12.4", features = ["rustls_backend"] } diff --git a/src/app_state.rs b/src/app_state.rs index c4228fa..138917d 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -7,7 +7,7 @@ use redis::AsyncCommands; use redis::Client; use std::sync::Arc; -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct AppState { pub banner_api: Arc, pub redis: Arc, diff --git a/src/banner/api.rs b/src/banner/api.rs index 604d4af..162761b 100644 --- a/src/banner/api.rs +++ b/src/banner/api.rs @@ -1,89 +1,98 @@ //! Main Banner API client implementation. -use crate::banner::{models::*, query::SearchQuery, session::SessionManager, util::user_agent}; -use anyhow::{Context, Result}; -use axum::http::HeaderValue; -use reqwest::Client; -use serde_json; +use std::{ + collections::{HashMap, VecDeque}, + sync::{Arc, Mutex}, + time::Instant, +}; -use tracing::{error, info}; +use crate::banner::{ + BannerSession, SessionPool, models::*, nonce, query::SearchQuery, util::user_agent, +}; +use anyhow::{Context, Result, anyhow}; +use axum::http::{Extensions, HeaderValue}; +use cookie::Cookie; +use dashmap::DashMap; +use reqwest::{Client, Request, Response}; +use reqwest_middleware::{ClientBuilder, ClientWithMiddleware, Middleware, Next}; +use serde_json; +use tracing::{Level, Metadata, Span, debug, error, field::ValueSet, info, span}; + +#[derive(Debug, thiserror::Error)] +pub enum BannerApiError { + #[error("Banner session is invalid or expired")] + InvalidSession, + #[error(transparent)] + RequestFailed(#[from] anyhow::Error), +} /// Main Banner API client. -#[derive(Debug)] pub struct BannerApi { - sessions: SessionManager, - http: Client, + pub sessions: SessionPool, + http: ClientWithMiddleware, base_url: String, } +pub struct TransparentMiddleware; + +#[async_trait::async_trait] +impl Middleware for TransparentMiddleware { + async fn handle( + &self, + req: Request, + extensions: &mut Extensions, + next: Next<'_>, + ) -> std::result::Result { + debug!( + domain = req.url().domain(), + "{method} {path}", + method = req.method().to_string(), + path = req.url().path(), + ); + let response = next.run(req, extensions).await; + + match &response { + Ok(response) => { + debug!( + "{code} {reason} {path}", + code = response.status().as_u16(), + reason = response.status().canonical_reason().unwrap_or("??"), + path = response.url().path(), + ); + } + Err(error) => { + debug!("!!! {error}"); + } + } + + response + } +} + impl BannerApi { /// Creates a new Banner API client. pub fn new(base_url: String) -> Result { - let http = Client::builder() - .cookie_store(true) - .user_agent(user_agent()) - .tcp_keepalive(Some(std::time::Duration::from_secs(60 * 5))) - .read_timeout(std::time::Duration::from_secs(10)) - .connect_timeout(std::time::Duration::from_secs(10)) - .timeout(std::time::Duration::from_secs(30)) - .build() - .context("Failed to create HTTP client")?; - - let session_manager = SessionManager::new(base_url.clone(), http.clone()); + let http = ClientBuilder::new( + Client::builder() + .cookie_store(true) + .user_agent(user_agent()) + .tcp_keepalive(Some(std::time::Duration::from_secs(60 * 5))) + .read_timeout(std::time::Duration::from_secs(10)) + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("Failed to create HTTP client")?, + ) + .with(TransparentMiddleware) + .build(); Ok(Self { - sessions: session_manager, + sessions: SessionPool::new(http.clone(), base_url.clone()), http, base_url, }) } - /// Sets up the API client by initializing session cookies. - pub async fn setup(&self) -> Result<()> { - info!(base_url = self.base_url, "setting up banner api client"); - let result = self.sessions.setup().await; - match &result { - Ok(()) => info!("banner api client setup completed successfully"), - Err(e) => error!(error = ?e, "banner api client setup failed"), - } - result - } - - /// Retrieves a list of terms from the Banner API. - pub async fn get_terms( - &self, - search: &str, - page: i32, - max_results: i32, - ) -> Result> { - if page <= 0 { - return Err(anyhow::anyhow!("Page must be greater than 0")); - } - - let url = format!("{}/classSearch/getTerms", self.base_url); - let params = [ - ("searchTerm", search), - ("offset", &page.to_string()), - ("max", &max_results.to_string()), - ("_", &SessionManager::nonce()), - ]; - - let response = self - .http - .get(&url) - .query(¶ms) - .send() - .await - .context("Failed to get terms")?; - - let terms: Vec = response - .json() - .await - .context("Failed to parse terms response")?; - - Ok(terms) - } - /// Retrieves a list of subjects from the Banner API. pub async fn get_subjects( &self, @@ -96,15 +105,15 @@ impl BannerApi { return Err(anyhow::anyhow!("Offset must be greater than 0")); } - let session_id = self.sessions.ensure_session()?; + let session = self.sessions.acquire(term.parse()?).await?; let url = format!("{}/classSearch/get_subject", self.base_url); let params = [ ("searchTerm", search), ("term", term), ("offset", &offset.to_string()), ("max", &max_results.to_string()), - ("uniqueSessionId", &session_id), - ("_", &SessionManager::nonce()), + ("uniqueSessionId", &session.id()), + ("_", &nonce()), ]; let response = self @@ -135,15 +144,15 @@ impl BannerApi { return Err(anyhow::anyhow!("Offset must be greater than 0")); } - let session_id = self.sessions.ensure_session()?; + let session = self.sessions.acquire(term.parse()?).await?; let url = format!("{}/classSearch/get_instructor", self.base_url); let params = [ ("searchTerm", search), ("term", term), ("offset", &offset.to_string()), ("max", &max_results.to_string()), - ("uniqueSessionId", &session_id), - ("_", &SessionManager::nonce()), + ("uniqueSessionId", &session.id()), + ("_", &nonce()), ]; let response = self @@ -174,15 +183,15 @@ impl BannerApi { return Err(anyhow::anyhow!("Offset must be greater than 0")); } - let session_id = self.sessions.ensure_session()?; + let session = self.sessions.acquire(term.parse()?).await?; let url = format!("{}/classSearch/get_campus", self.base_url); let params = [ ("searchTerm", search), ("term", term), ("offset", &offset.to_string()), ("max", &max_results.to_string()), - ("uniqueSessionId", &session_id), - ("_", &SessionManager::nonce()), + ("uniqueSessionId", &session.id()), + ("_", &nonce()), ]; let response = self @@ -259,15 +268,15 @@ impl BannerApi { query: &SearchQuery, sort: &str, sort_descending: bool, - ) -> Result { - self.sessions.reset_data_form().await?; + ) -> Result { + // self.sessions.reset_data_form().await?; - let session_id = self.sessions.ensure_session()?; + let session = self.sessions.acquire(term.parse()?).await?; let mut params = query.to_params(); // Add additional parameters params.insert("txt_term".to_string(), term.to_string()); - params.insert("uniqueSessionId".to_string(), session_id); + params.insert("uniqueSessionId".to_string(), session.id()); params.insert("sortColumn".to_string(), sort.to_string()); params.insert( "sortDirection".to_string(), @@ -280,37 +289,50 @@ impl BannerApi { let response = self .http .get(&url) + .header("Cookie", session.cookie()) .query(¶ms) .send() .await .context("Failed to search courses")?; - let search_result: SearchResult = response - .json() + let status = response.status(); + let url = response.url().clone(); + let body = response + .text() .await - .context("Failed to parse search response")?; + .with_context(|| format!("Failed to read body (status={status})"))?; + + let search_result: SearchResult = parse_json_with_context(&body).map_err(|e| { + BannerApiError::RequestFailed(anyhow!( + "Failed to parse search response (status={status}, url={url}): {e}\nBody: {body}" + )) + })?; + + // Check for signs of an invalid session, based on docs/Sessions.md + if search_result.path_mode.is_none() || search_result.data.is_none() { + return Err(BannerApiError::InvalidSession); + } if !search_result.success { - return Err(anyhow::anyhow!( + return Err(BannerApiError::RequestFailed(anyhow!( "Search marked as unsuccessful by Banner API" - )); + ))); } Ok(search_result) } - /// Selects a term for the current session. - pub async fn select_term(&self, term: &str) -> Result<()> { - self.sessions.select_term(term).await - } - /// Retrieves a single course by CRN by issuing a minimal search - pub async fn get_course_by_crn(&self, term: &str, crn: &str) -> Result> { - self.sessions.reset_data_form().await?; + pub async fn get_course_by_crn( + &self, + term: &str, + crn: &str, + ) -> Result, BannerApiError> { + // self.sessions.reset_data_form().await?; // Ensure session is configured for this term - self.select_term(term).await?; + // self.select_term(term).await?; - let session_id = self.sessions.ensure_session()?; + let session = self.sessions.acquire(term.parse()?).await?; let query = SearchQuery::new() .course_reference_number(crn) @@ -318,7 +340,7 @@ impl BannerApi { let mut params = query.to_params(); params.insert("txt_term".to_string(), term.to_string()); - params.insert("uniqueSessionId".to_string(), session_id); + params.insert("uniqueSessionId".to_string(), session.id()); params.insert("sortColumn".to_string(), "subjectDescription".to_string()); params.insert("sortDirection".to_string(), "asc".to_string()); params.insert("startDatepicker".to_string(), String::new()); @@ -328,27 +350,36 @@ impl BannerApi { let response = self .http .get(&url) + .header("Cookie", session.cookie()) .query(¶ms) .send() .await .context("Failed to search course by CRN")?; let status = response.status(); + let url = response.url().clone(); let body = response .text() .await .with_context(|| format!("Failed to read body (status={status})"))?; let search_result: SearchResult = parse_json_with_context(&body).map_err(|e| { - anyhow::anyhow!( - "Failed to parse search response for CRN (status={status}, url={url}): {e}", - ) + BannerApiError::RequestFailed(anyhow!( + "Failed to parse search response for CRN (status={status}, url={url}): {e}" + )) })?; + // Check for signs of an invalid session, based on docs/Sessions.md + if search_result.path_mode == Some("registration".to_string()) + && search_result.data.is_none() + { + return Err(BannerApiError::InvalidSession); + } + if !search_result.success { - return Err(anyhow::anyhow!( + return Err(BannerApiError::RequestFailed(anyhow!( "Search marked as unsuccessful by Banner API" - )); + ))); } Ok(search_result @@ -382,13 +413,14 @@ impl BannerApi { } } -/// Attempt to parse JSON and, on failure, include a contextual snippet around the error location +/// Attempt to parse JSON and, on failure, include a contextual snippet of the +/// line where the error occurred. This prevents dumping huge JSON bodies to logs. fn parse_json_with_context(body: &str) -> Result { match serde_json::from_str::(body) { Ok(value) => Ok(value), Err(err) => { let (line, column) = (err.line(), err.column()); - let snippet = build_error_snippet(body, line, column, 120); + let snippet = build_error_snippet(body, line, column, 80); Err(anyhow::anyhow!( "{err} at line {line}, column {column}\nSnippet:\n{snippet}", )) @@ -396,21 +428,23 @@ fn parse_json_with_context(body: &str) -> Result } } -fn build_error_snippet(body: &str, line: usize, column: usize, max_len: usize) -> String { +fn build_error_snippet(body: &str, line: usize, column: usize, context_len: usize) -> String { let target_line = body.lines().nth(line.saturating_sub(1)).unwrap_or(""); if target_line.is_empty() { - return String::new(); + return "(empty line)".to_string(); } - let start = column.saturating_sub(max_len.min(column)); - let end = (column + max_len).min(target_line.len()); + // column is 1-based, convert to 0-based for slicing + let error_idx = column.saturating_sub(1); + + let half_len = context_len / 2; + let start = error_idx.saturating_sub(half_len); + let end = (error_idx + half_len).min(target_line.len()); + let slice = &target_line[start..end]; + let indicator_pos = error_idx - start; - let mut indicator = String::new(); - if column > start { - indicator.push_str(&" ".repeat(column - start - 1)); - indicator.push('^'); - } + let indicator = " ".repeat(indicator_pos) + "^"; - format!("{slice}\n{indicator}") + format!("...{slice}...\n {indicator}") } diff --git a/src/banner/models/meetings.rs b/src/banner/models/meetings.rs index 139d777..517a079 100644 --- a/src/banner/models/meetings.rs +++ b/src/banner/models/meetings.rs @@ -42,11 +42,11 @@ pub struct FacultyItem { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MeetingTime { - pub start_date: String, // MM/DD/YYYY, e.g 08/26/2025 - pub end_date: String, // MM/DD/YYYY, e.g 08/26/2025 - pub begin_time: String, // HHMM, e.g 1000 - pub end_time: String, // HHMM, e.g 1100 - pub category: String, // unknown meaning, e.g. 01, 02, etc + pub start_date: String, // MM/DD/YYYY, e.g 08/26/2025 + pub end_date: String, // MM/DD/YYYY, e.g 08/26/2025 + pub begin_time: Option, // HHMM, e.g 1000 + pub end_time: Option, // HHMM, e.g 1100 + pub category: String, // unknown meaning, e.g. 01, 02, etc pub class: String, // internal class name, e.g. net.hedtech.banner.general.overallMeetingTimeDecorator pub monday: bool, // true if the meeting time occurs on Monday pub tuesday: bool, // true if the meeting time occurs on Tuesday @@ -55,13 +55,13 @@ pub struct MeetingTime { pub friday: bool, // true if the meeting time occurs on Friday pub saturday: bool, // true if the meeting time occurs on Saturday pub sunday: bool, // true if the meeting time occurs on Sunday - pub room: String, // e.g. 1238 + pub room: Option, // e.g. 1.238 #[serde(deserialize_with = "deserialize_string_to_term")] pub term: Term, // e.g 202510 - pub building: String, // e.g NPB - pub building_description: String, // e.g North Paseo Building - pub campus: String, // campus code, e.g 11 - pub campus_description: String, // name of campus, e.g Main Campus + pub building: Option, // e.g NPB + pub building_description: Option, // e.g North Paseo Building + pub campus: Option, // campus code, e.g 11 + pub campus_description: Option, // name of campus, e.g Main Campus pub course_reference_number: String, // CRN, e.g 27294 pub credit_hour_session: f64, // e.g. 30 pub hours_week: f64, // e.g. 30 @@ -347,42 +347,58 @@ impl MeetingType { /// Meeting location information #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MeetingLocation { - pub campus: String, - pub building: String, - pub building_description: String, - pub room: String, - pub is_online: bool, +pub enum MeetingLocation { + Online, + InPerson { + campus: String, + campus_description: String, + building: String, + building_description: String, + room: String, + }, } impl MeetingLocation { /// Create from raw MeetingTime data pub fn from_meeting_time(meeting_time: &MeetingTime) -> Self { - let is_online = meeting_time.room.is_empty(); + if meeting_time.campus.is_none() + || meeting_time.building.is_none() + || meeting_time.building_description.is_none() + || meeting_time.room.is_none() + || meeting_time.campus_description.is_none() + || meeting_time + .campus_description + .eq(&Some("Internet".to_string())) + { + return MeetingLocation::Online; + } - MeetingLocation { - campus: meeting_time.campus_description.clone(), - building: meeting_time.building.clone(), - building_description: meeting_time.building_description.clone(), - room: meeting_time.room.clone(), - is_online, + MeetingLocation::InPerson { + campus: meeting_time.campus.as_ref().unwrap().clone(), + campus_description: meeting_time.campus_description.as_ref().unwrap().clone(), + building: meeting_time.building.as_ref().unwrap().clone(), + building_description: meeting_time.building_description.as_ref().unwrap().clone(), + room: meeting_time.room.as_ref().unwrap().clone(), } } } impl Display for MeetingLocation { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if self.is_online { - write!(f, "Online") - } else { - write!( + match self { + MeetingLocation::Online => write!(f, "Online"), + MeetingLocation::InPerson { + campus, + building, + building_description, + room, + .. + } => write!( f, "{campus} | {building_name} | {building_code} {room}", - campus = self.campus, - building_name = self.building_description, - building_code = self.building, - room = self.room - ) + building_name = building_description, + building_code = building, + ), } } } @@ -402,7 +418,11 @@ impl MeetingScheduleInfo { /// Create from raw MeetingTime data pub fn from_meeting_time(meeting_time: &MeetingTime) -> Self { let days = MeetingDays::from_meeting_time(meeting_time); - let time_range = TimeRange::from_hhmm(&meeting_time.begin_time, &meeting_time.end_time); + let time_range = match (&meeting_time.begin_time, &meeting_time.end_time) { + (Some(begin), Some(end)) => TimeRange::from_hhmm(&begin, &end), + _ => None, + }; + let date_range = DateRange::from_mm_dd_yyyy(&meeting_time.start_date, &meeting_time.end_date) .unwrap_or_else(|| { @@ -470,16 +490,18 @@ impl MeetingScheduleInfo { /// Returns a formatted string representing the location of the meeting pub fn place_string(&self) -> String { - if self.location.room.is_empty() { - "Online".to_string() - } else { - format!( + match &self.location { + MeetingLocation::Online => "Online".to_string(), + MeetingLocation::InPerson { + campus, + building, + building_description, + room, + .. + } => format!( "{} | {} | {} {}", - self.location.campus, - self.location.building_description, - self.location.building, - self.location.room - ) + campus, building_description, building, room + ), } } diff --git a/src/banner/models/search.rs b/src/banner/models/search.rs index 18b8fd5..cb9b27b 100644 --- a/src/banner/models/search.rs +++ b/src/banner/models/search.rs @@ -10,8 +10,8 @@ pub struct SearchResult { pub total_count: i32, pub page_offset: i32, pub page_max_size: i32, - pub path_mode: String, - pub search_results_config: Vec, + pub path_mode: Option, + pub search_results_config: Option>, pub data: Option>, } diff --git a/src/banner/models/terms.rs b/src/banner/models/terms.rs index aec869b..2b44cd3 100644 --- a/src/banner/models/terms.rs +++ b/src/banner/models/terms.rs @@ -13,7 +13,7 @@ const CURRENT_YEAR: u32 = compile_time::date!().year() as u32; const VALID_YEARS: RangeInclusive = 2007..=(CURRENT_YEAR + 10); /// Represents a term in the Banner system -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct Term { pub year: u32, // 2024, 2025, etc pub season: Season, @@ -29,7 +29,7 @@ pub enum TermPoint { } /// Represents a season within a term -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] pub enum Season { Fall, Spring, diff --git a/src/banner/session.rs b/src/banner/session.rs index f3d7d5a..cdd0f61 100644 --- a/src/banner/session.rs +++ b/src/banner/session.rs @@ -1,133 +1,308 @@ //! Session management for Banner API. -use crate::banner::util::user_agent; -use anyhow::Result; -use rand::distributions::{Alphanumeric, DistString}; +use crate::banner::BannerTerm; +use crate::banner::models::Term; +use anyhow::{Context, Result}; +use cookie::Cookie; +use dashmap::DashMap; +use rand::distr::{Alphanumeric, SampleString}; use reqwest::Client; -use std::sync::Mutex; +use reqwest_middleware::ClientWithMiddleware; +use std::collections::{HashMap, VecDeque}; +use std::ops::{Deref, DerefMut}; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tracing::{debug, info}; +use url::Url; -/// Session manager for Banner API interactions -#[derive(Debug)] -pub struct SessionManager { - current_session: Mutex>, - base_url: String, - client: Client, -} +const SESSION_EXPIRY: Duration = Duration::from_secs(25 * 60); // 25 minutes +/// Represents an active anonymous session within the Banner API. +/// Identified by multiple persistent cookies, as well as a client-generated "unique session ID". #[derive(Debug, Clone)] -struct SessionData { - session_id: String, +pub struct BannerSession { + // Randomly generated + unique_session_id: String, + // Timestamp of creation created_at: Instant, + // Timestamp of last activity + last_activity: Option, + // Cookie values from initial registration page + jsessionid: String, + ssb_cookie: String, } -impl SessionManager { - const SESSION_EXPIRY: Duration = Duration::from_secs(25 * 60); // 25 minutes +/// Generates a new session ID mimicking Banner's format +fn generate_session_id() -> String { + let random_part = Alphanumeric.sample_string(&mut rand::rng(), 5); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(); + format!("{}{}", random_part, timestamp) +} - /// Creates a new session manager - pub fn new(base_url: String, client: Client) -> Self { - Self { - current_session: Mutex::new(None), - base_url, - client, - } +/// Generates a timestamp-based nonce +pub fn nonce() -> String { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + .to_string() +} + +impl BannerSession { + /// Creates a new session + pub async fn new(unique_session_id: &str, jsessionid: &str, ssb_cookie: &str) -> Result { + let now = Instant::now(); + + Ok(Self { + created_at: now, + last_activity: None, + unique_session_id: unique_session_id.to_string(), + jsessionid: jsessionid.to_string(), + ssb_cookie: ssb_cookie.to_string(), + }) } - /// Ensures a valid session is available, creating one if necessary - pub fn ensure_session(&self) -> Result { - let start_time = std::time::Instant::now(); - let mut session_guard = self.current_session.lock().unwrap(); + /// Returns the unique session ID + pub fn id(&self) -> String { + self.unique_session_id.clone() + } - if let Some(ref session) = *session_guard - && session.created_at.elapsed() < Self::SESSION_EXPIRY - { - let elapsed = start_time.elapsed(); + /// Updates the last activity timestamp + pub fn touch(&mut self) { + debug!("Session {} is being used", self.unique_session_id); + self.last_activity = Some(Instant::now()); + } + + /// Returns true if the session is expired + pub fn is_expired(&self) -> bool { + self.last_activity.unwrap_or(self.created_at).elapsed() > SESSION_EXPIRY + } + + /// Returns a string used to for the "Cookie" header + pub fn cookie(&self) -> String { + format!( + "JSESSIONID={}; SSB_COOKIE={}", + self.jsessionid, self.ssb_cookie + ) + } +} + +/// A smart pointer that returns a BannerSession to the pool when dropped. +pub struct PooledSession { + session: Option, + // This Arc points directly to the queue the session belongs to. + pool: Arc>>, +} + +impl Deref for PooledSession { + type Target = BannerSession; + fn deref(&self) -> &Self::Target { + // The option is only ever None after drop is called, so this is safe. + self.session.as_ref().unwrap() + } +} + +impl DerefMut for PooledSession { + fn deref_mut(&mut self) -> &mut Self::Target { + self.session.as_mut().unwrap() + } +} + +/// The magic happens here: when the guard goes out of scope, this is called. +impl Drop for PooledSession { + fn drop(&mut self) { + if let Some(session) = self.session.take() { + // Don't return expired sessions to the pool. + if session.is_expired() { + debug!("Session {} expired, dropping.", session.unique_session_id); + return; + } + + // This is a synchronous lock, so it's allowed in drop(). + // It blocks the current thread briefly to return the session. + let mut queue = self.pool.lock().unwrap(); + queue.push_back(session); debug!( - session_id = session.session_id, - elapsed = format!("{:.2?}", elapsed), - "reusing existing banner session" + "Session returned to pool. Queue size is now {}.", + queue.len() ); - return Ok(session.session_id.clone()); } + } +} - // Generate new session - let session_id = self.generate_session_id(); - *session_guard = Some(SessionData { - session_id: session_id.clone(), - created_at: Instant::now(), - }); +pub struct SessionPool { + sessions: DashMap>>>, + http: ClientWithMiddleware, + base_url: String, +} - let elapsed = start_time.elapsed(); - debug!( - session_id = session_id, - elapsed = format!("{:.2?}", elapsed), - "generated new banner session" - ); - Ok(session_id) +impl SessionPool { + pub fn new(http: ClientWithMiddleware, base_url: String) -> Self { + Self { + sessions: DashMap::new(), + http, + base_url, + } } - /// Generates a new session ID mimicking Banner's format - fn generate_session_id(&self) -> String { - let random_part = Alphanumeric.sample_string(&mut rand::thread_rng(), 5); - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis(); - format!("{}{}", random_part, timestamp) + /// Acquires a session from the pool. + /// If no sessions are available, a new one is created on demand. + pub async fn acquire(&self, term: Term) -> Result { + // Get the queue for the given term, or insert a new empty one. + let pool_entry = self + .sessions + .entry(term.clone()) + .or_insert_with(|| Arc::new(Mutex::new(VecDeque::new()))) + .clone(); + + loop { + // Lock the specific queue for this term + let session_option = { + let mut queue = pool_entry.lock().unwrap(); + queue.pop_front() // Try to get a session + }; + + if let Some(mut session) = session_option { + // We got a session, check if it's expired. + if !session.is_expired() { + debug!("Reusing session {}", session.unique_session_id); + + session.touch(); + return Ok(PooledSession { + session: Some(session), + pool: pool_entry, + }); + } else { + debug!( + "Popped an expired session {}, discarding.", + session.unique_session_id + ); + // The session is expired, so we loop again to try and get another one. + } + } else { + // Queue was empty, so we create a new session. + let mut new_session = self.create_session(&term).await?; + new_session.touch(); + + return Ok(PooledSession { + session: Some(new_session), + pool: pool_entry, + }); + } + } } /// Sets up initial session cookies by making required Banner API requests - pub async fn setup(&self) -> Result<()> { + pub async fn create_session(&self, term: &Term) -> Result { info!("setting up banner session..."); - let request_paths = ["/registration/registration", "/selfServiceMenu/data"]; + // The 'register' or 'search' registration page + let initial_registration = self + .http + .get(format!("{}/registration", self.base_url)) + .send() + .await?; + // TODO: Validate success - for path in &request_paths { - let url = format!("{}{}", self.base_url, path); - let response = self - .client - .get(&url) - .query(&[("_", Self::nonce())]) - .header("User-Agent", user_agent()) - .send() - .await?; + let cookies = initial_registration + .headers() + .get_all("Set-Cookie") + .iter() + .filter_map(|header_value| { + if let Ok(cookie) = Cookie::parse(header_value.to_str().unwrap()) { + Some((cookie.name().to_string(), cookie.value().to_string())) + } else { + None + } + }) + .collect::>(); - if !response.status().is_success() { - return Err(anyhow::anyhow!( - "Failed to setup session, request to {} returned {}", - path, - response.status() - )); - } + let jsessionid = cookies.get("JSESSIONID").unwrap(); + let ssb_cookie = cookies.get("SSB_COOKIE").unwrap(); + + let data_page_response = self + .http + .get(format!("{}/selfServiceMenu/data", self.base_url)) + .send() + .await?; + // TODO: Validate success + + let term_selection_page_response = self + .http + .get(format!("{}/term/termSelection", self.base_url)) + .query(&[("mode", "search")]) + .send() + .await?; + // TOOD: Validate success + + let term_search_response = self.get_terms("", 1, 10).await?; + // TODO: Validate that the term search response contains the term we want + + let specific_term_search_response = self.get_terms(&term.to_string(), 1, 10).await?; + // TODO: Validate that the term response contains the term we want + + let unique_session_id = generate_session_id(); + self.select_term(&term.to_string(), &unique_session_id) + .await?; + + BannerSession::new(&unique_session_id, jsessionid, ssb_cookie).await + } + + /// Retrieves a list of terms from the Banner API. + pub async fn get_terms( + &self, + search: &str, + page: i32, + max_results: i32, + ) -> Result> { + if page <= 0 { + return Err(anyhow::anyhow!("Page must be greater than 0")); } - // Note: Cookie validation would require additional setup in a real implementation - debug!("session setup complete"); - Ok(()) + let url = format!("{}/classSearch/getTerms", self.base_url); + let params = [ + ("searchTerm", search), + ("offset", &page.to_string()), + ("max", &max_results.to_string()), + ("_", &nonce()), + ]; + + let response = self + .http + .get(&url) + .query(¶ms) + .send() + .await + .with_context(|| format!("Failed to get terms"))?; + + let terms: Vec = response + .json() + .await + .context("Failed to parse terms response")?; + + Ok(terms) } /// Selects a term for the current session - pub async fn select_term(&self, term: &str) -> Result<()> { - let session_id = self.ensure_session()?; - + pub async fn select_term(&self, term: &str, unique_session_id: &str) -> Result<()> { let form_data = [ ("term", term), ("studyPath", ""), ("studyPathText", ""), ("startDatepicker", ""), ("endDatepicker", ""), - ("uniqueSessionId", &session_id), + ("uniqueSessionId", unique_session_id), ]; let url = format!("{}/term/search", self.base_url); let response = self - .client + .http .post(&url) .query(&[("mode", "search")]) .form(&form_data) - .header("User-Agent", user_agent()) - .header("Content-Type", "application/x-www-form-urlencoded") .send() .await?; @@ -141,20 +316,18 @@ impl SessionManager { #[derive(serde::Deserialize)] struct RedirectResponse { - #[serde(rename = "fwdUrl")] + #[serde(rename = "fwdURL")] fwd_url: String, } let redirect: RedirectResponse = response.json().await?; + let base_url_path = self.base_url.parse::().unwrap().path().to_string(); + let non_overlap_redirect = redirect.fwd_url.strip_prefix(&base_url_path).unwrap(); + // Follow the redirect - let redirect_url = format!("{}{}", self.base_url, redirect.fwd_url); - let redirect_response = self - .client - .get(&redirect_url) - .header("User-Agent", user_agent()) - .send() - .await?; + let redirect_url = format!("{}{}", self.base_url, non_overlap_redirect); + let redirect_response = self.http.get(&redirect_url).send().await?; if !redirect_response.status().is_success() { return Err(anyhow::anyhow!( @@ -166,33 +339,4 @@ impl SessionManager { debug!("successfully selected term: {}", term); Ok(()) } - - /// Resets the data form (required before new searches) - pub async fn reset_data_form(&self) -> Result<()> { - let url = format!("{}/classSearch/resetDataForm", self.base_url); - let response = self - .client - .post(&url) - .header("User-Agent", user_agent()) - .send() - .await?; - - if !response.status().is_success() { - return Err(anyhow::anyhow!( - "Failed to reset data form: {}", - response.status() - )); - } - - Ok(()) - } - - /// Generates a timestamp-based nonce - pub fn nonce() -> String { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() - .to_string() - } } diff --git a/src/bot/commands/terms.rs b/src/bot/commands/terms.rs index a423d19..c910b18 100644 --- a/src/bot/commands/terms.rs +++ b/src/bot/commands/terms.rs @@ -21,6 +21,7 @@ pub async fn terms( .data() .app_state .banner_api + .sessions .get_terms(&search_term, page_number, max_results) .await?; @@ -46,7 +47,11 @@ fn format_term(term: &BannerTerm, current_term_code: &str) -> String { } else { "" }; - let is_archived = if term.is_archived() { " (archived)" } else { "" }; + let is_archived = if term.is_archived() { + " (archived)" + } else { + "" + }; format!( "- `{}`: {}{}{}", term.code, term.description, is_current, is_archived diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 252b69d..9ae8f87 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -4,7 +4,6 @@ use crate::error::Error; pub mod commands; pub mod utils; -#[derive(Debug)] pub struct Data { pub app_state: AppState, } // User data, which is stored and accessible in all command invocations diff --git a/src/lib.rs b/src/lib.rs index d096e02..382ce16 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod app_state; pub mod banner; pub mod bot; +pub mod config; pub mod data; pub mod error; pub mod scraper;