kasl/api/si.rs
1//! SiServer client: daily/monthly report submission and the company
2//! rest-date calendar.
3//!
4//! ```rust,no_run
5//! # use kasl::api::si::{Si, SiConfig};
6//! # use chrono::Local;
7//! # async fn f() -> anyhow::Result<()> {
8//! let config = SiConfig {
9//! login: "username".to_string(),
10//! auth_url: "https://auth.company.com".to_string(),
11//! api_url: "https://api.company.com".to_string(),
12//! };
13//!
14//! let mut si = Si::new(&config);
15//! let today = Local::now().date_naive();
16//! let rest_dates = si.rest_dates(today).await?;
17//! # Ok(())
18//! # }
19//! ```
20
21use crate::{
22 api::Session,
23 libs::{config::ConfigModule, messages::Message, secret::Secret},
24 msg_error, msg_print,
25};
26use anyhow::Result;
27use base64::prelude::*;
28use chrono::{Datelike, Duration, NaiveDate, Weekday};
29use dialoguer::{Input, theme::ColorfulTheme};
30use reqwest::{
31 Client, StatusCode,
32 header::{self, COOKIE, HeaderMap, HeaderValue},
33 multipart,
34};
35use serde::{Deserialize, Serialize};
36use std::collections::HashSet;
37
38const MAX_RETRY_COUNT: i32 = 3;
39const COOKIE_KEY: &str = "PORTALSESSID=";
40const SESSION_ID_FILE: &str = ".si_session_id";
41const SECRET_FILE: &str = ".si_secret";
42const AUTH_URL: &str = "auth/ldap";
43const LOGIN_URL: &str = "auth/login-by-token";
44const REPORT_URL: &str = "report-card/send-daily-report";
45const MONTHLY_REPORT_URL: &str = "report-card/send-monthly-report";
46const REST_DATES_URL: &str = "report-card/get-rest-dates";
47
48/// Login and the double-base64-encoded password SiServer expects.
49#[derive(Serialize, Clone, Debug)]
50pub struct LoginCredentials {
51 login: String,
52 password: String,
53}
54
55/// First-stage (LDAP) response carrying the temporary token.
56#[derive(Deserialize)]
57pub struct AuthSession {
58 payload: AuthPayload,
59}
60
61#[derive(Deserialize)]
62pub struct AuthPayload {
63 token: String,
64}
65
66/// Rest-date calendar response: three categories of non-working days.
67#[derive(Debug, Deserialize)]
68pub struct RestDatesResponse {
69 /// Regular rest dates (general holidays)
70 dates: Vec<String>,
71 /// Vacation dates (company-specific holidays)
72 v_dates: Vec<String>,
73 /// Weekend dates (extended weekend periods)
74 w_dates: Vec<String>,
75}
76
77impl RestDatesResponse {
78 /// Merges all three categories into one deduplicated set; date strings
79 /// that fail to parse are skipped rather than failing the calendar.
80 pub fn unique_dates(&self) -> Result<HashSet<NaiveDate>> {
81 let mut date_set = HashSet::new();
82
83 self.process_dates(&self.dates, &mut date_set)?;
84 self.process_dates(&self.v_dates, &mut date_set)?;
85 self.process_dates(&self.w_dates, &mut date_set)?;
86
87 Ok(date_set)
88 }
89
90 fn process_dates(&self, dates: &[String], date_set: &mut HashSet<NaiveDate>) -> Result<()> {
91 dates
92 .iter()
93 .filter_map(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok())
94 .for_each(|date| {
95 date_set.insert(date);
96 });
97 Ok(())
98 }
99}
100
101/// SiServer client. Authentication is two-stage: LDAP login yields a
102/// token, the token is exchanged for a `PORTALSESSID` cookie, and the
103/// cookie rides on every API call.
104#[derive(Debug)]
105pub struct Si {
106 client: Client,
107 config: SiConfig,
108 /// Held in memory only while authenticating.
109 credentials: Option<LoginCredentials>,
110 retries: i32,
111}
112
113impl Session for Si {
114 /// Runs the two-stage login and returns the session id extracted from
115 /// the `Set-Cookie` header.
116 async fn login(&self) -> Result<String> {
117 let credentials = self.credentials.clone().expect("Credentials not set!");
118
119 // Stage 1: LDAP authentication yields a bearer token.
120 let auth_url = format!("{}/{}", self.config.auth_url, AUTH_URL);
121 let auth_res = self.client.post(auth_url).json(&credentials).send().await?;
122 let auth_body = auth_res.text().await?;
123 let auth_session: AuthSession = serde_json::from_str(&auth_body)?;
124
125 // Stage 2: the token buys a session cookie.
126 let login_url = format!("{}/{}", self.config.api_url, LOGIN_URL);
127 let login_res = self
128 .client
129 .post(login_url)
130 .header(header::AUTHORIZATION, format!("Bearer {}", auth_session.payload.token))
131 .send()
132 .await?;
133
134 if let Some(cookie) = login_res.headers().get("Set-Cookie")
135 && let Ok(cookie_val) = cookie.to_str()
136 && let Some(portalsessid) = cookie_val.split(";").find(|c| c.starts_with(COOKIE_KEY))
137 {
138 let session_id = portalsessid.trim_start_matches(COOKIE_KEY);
139 return Ok(session_id.to_string());
140 }
141
142 anyhow::bail!("Login failed")
143 }
144
145 /// Stores the credentials, double-base64-encoding the password as
146 /// SiServer requires.
147 fn set_credentials(&mut self, password: &str) -> Result<()> {
148 let encoded_password = BASE64_STANDARD.encode(BASE64_STANDARD.encode(password));
149
150 self.credentials = Some(LoginCredentials {
151 login: self.config.login.to_string(),
152 password: encoded_password,
153 });
154 Ok(())
155 }
156
157 fn session_id_file(&self) -> &str {
158 SESSION_ID_FILE
159 }
160
161 fn secret(&self) -> Secret {
162 Secret::new(SECRET_FILE, "Enter your SiServer password")
163 }
164
165 fn retry(&self) -> i32 {
166 self.retries
167 }
168
169 fn inc_retry(&mut self) {
170 self.retries += 1;
171 }
172
173 fn reset_retry(&mut self) {
174 self.retries = 0;
175 }
176}
177
178impl Si {
179 /// Builds a client from the config; no network activity yet.
180 ///
181 /// ```rust,no_run
182 /// use kasl::api::si::{Si, SiConfig};
183 ///
184 /// let config = SiConfig {
185 /// login: "username".to_string(),
186 /// auth_url: "https://auth.company.com".to_string(),
187 /// api_url: "https://api.company.com".to_string(),
188 /// };
189 /// let si = Si::new(&config);
190 /// ```
191 pub fn new(config: &SiConfig) -> Self {
192 Self {
193 client: Client::new(),
194 config: config.clone(),
195 credentials: None,
196 retries: 0,
197 }
198 }
199
200 /// Submits the daily report (tasks as JSON) for the date. On a 401 the
201 /// cached session is dropped and the call retried up to the limit; a
202 /// network error maps to `BAD_REQUEST` so a scheduled send fails soft.
203 ///
204 /// ```rust,no_run
205 /// # use kasl::api::si::{Si, SiConfig};
206 /// # use chrono::Local;
207 /// # use anyhow::Result;
208 /// # async fn example() -> Result<()> {
209 /// # let config = SiConfig {
210 /// # login: "username".to_string(),
211 /// # auth_url: "https://auth.company.com".to_string(),
212 /// # api_url: "https://api.company.com".to_string(),
213 /// # };
214 /// let mut si = Si::new(&config);
215 /// let report_data = r#"{"hours": 8, "tasks": 5}"#.to_string();
216 /// let today = Local::now().date_naive();
217 ///
218 /// let status = si.send(&report_data, &today).await?;
219 /// if status.is_success() {
220 /// println!("Report submitted successfully");
221 /// }
222 /// # Ok(())
223 /// # }
224 /// ```
225 pub async fn send(&mut self, data: &str, date: &NaiveDate) -> Result<StatusCode> {
226 let mut local_retries = 0;
227 loop {
228 let session_id = self.get_session_id().await?;
229 let url = format!("{}/{}", self.config.api_url, REPORT_URL);
230 let date = date.format("%Y-%m-%d").to_string();
231
232 let form = multipart::Form::new()
233 .text("date", date)
234 .text("tasks", data.to_owned())
235 .text("comment", "")
236 .text("day_type", "1")
237 .text("duty", "0")
238 .text("only_save", "0");
239
240 let mut headers = HeaderMap::new();
241 headers.insert(COOKIE, HeaderValue::from_str(&format!("{}{}", COOKIE_KEY, session_id))?);
242
243 let res = match self.client.post(url).headers(headers).multipart(form).send().await {
244 Ok(response) => response,
245 Err(_) => return Ok(StatusCode::BAD_REQUEST), // Network error fallback
246 };
247
248 match res.status() {
249 StatusCode::UNAUTHORIZED if local_retries < MAX_RETRY_COUNT => {
250 // Session expired - clear cache and retry
251 self.delete_session_id()?;
252 tokio::time::sleep(Duration::seconds(1).to_std()?).await;
253 local_retries += 1;
254 continue;
255 }
256 _ => return Ok(res.status()),
257 }
258 }
259 }
260
261 /// Submits the monthly report for the month containing `date`, with
262 /// the same 401-retry and network-error behavior as [`Si::send`].
263 ///
264 /// ```rust,no_run
265 /// # use kasl::api::si::{Si, SiConfig};
266 /// # use chrono::Local;
267 /// # use anyhow::Result;
268 /// # async fn example() -> Result<()> {
269 /// # let config = SiConfig {
270 /// # login: "username".to_string(),
271 /// # auth_url: "https://auth.company.com".to_string(),
272 /// # api_url: "https://api.company.com".to_string(),
273 /// # };
274 /// let mut si = Si::new(&config);
275 /// let today = Local::now().date_naive();
276 ///
277 /// if si.is_last_working_day_of_month(&today)? {
278 /// let status = si.send_monthly(&today).await?;
279 /// if status.is_success() {
280 /// println!("Monthly report submitted");
281 /// }
282 /// }
283 /// # Ok(())
284 /// # }
285 /// ```
286 pub async fn send_monthly(&mut self, date: &NaiveDate) -> Result<StatusCode> {
287 let mut local_retries = 0;
288 loop {
289 let session_id = self.get_session_id().await?;
290 let url = format!("{}/{}", self.config.api_url, MONTHLY_REPORT_URL);
291 let (year, month) = (date.year(), date.month());
292
293 let form = multipart::Form::new().text("month", month.to_string()).text("year", year.to_string());
294
295 let mut headers = HeaderMap::new();
296 headers.insert(COOKIE, HeaderValue::from_str(&format!("{}{}", COOKIE_KEY, session_id))?);
297
298 let res = match self.client.post(url).headers(headers).multipart(form).send().await {
299 Ok(response) => response,
300 Err(_) => return Ok(StatusCode::BAD_REQUEST), // Network error fallback
301 };
302
303 match res.status() {
304 StatusCode::UNAUTHORIZED if local_retries < MAX_RETRY_COUNT => {
305 self.delete_session_id()?;
306 tokio::time::sleep(Duration::seconds(1).to_std()?).await;
307 local_retries += 1;
308 continue;
309 }
310 _ => return Ok(res.status()),
311 }
312 }
313 }
314
315 /// Fetches the company rest-date calendar for the year of `date`.
316 ///
317 /// Every failure path - session, network, parsing - logs and returns
318 /// an empty set: the calendar makes reports nicer, and its absence
319 /// must not break them.
320 ///
321 /// ```rust,no_run
322 /// # use kasl::api::si::{Si, SiConfig};
323 /// # use chrono::Local;
324 /// # use anyhow::Result;
325 /// # async fn example() -> Result<()> {
326 /// # let config = SiConfig {
327 /// # login: "username".to_string(),
328 /// # auth_url: "https://auth.company.com".to_string(),
329 /// # api_url: "https://api.company.com".to_string(),
330 /// # };
331 /// let mut si = Si::new(&config);
332 /// let this_year = Local::now().date_naive();
333 ///
334 /// let rest_dates = si.rest_dates(this_year).await?;
335 /// println!("Found {} rest dates this year", rest_dates.len());
336 ///
337 /// let today = Local::now().date_naive();
338 /// if rest_dates.contains(&today) {
339 /// println!("Today is a company rest day");
340 /// }
341 /// # Ok(())
342 /// # }
343 /// ```
344 pub async fn rest_dates(&mut self, year: NaiveDate) -> Result<HashSet<NaiveDate>> {
345 let mut local_retries = 0;
346 loop {
347 let session_id = match self.get_session_id().await {
348 Ok(id) => id,
349 Err(e) => {
350 msg_error!(Message::SiServerSessionFailed(e.to_string()));
351 return Ok(HashSet::new());
352 }
353 };
354
355 let url = format!("{}/{}", self.config.api_url, REST_DATES_URL);
356 let form = multipart::Form::new().text("year", year.format("%Y").to_string());
357 let mut headers = HeaderMap::new();
358 headers.insert(COOKIE, HeaderValue::from_str(&format!("{}{}", COOKIE_KEY, session_id))?);
359
360 let res = match self.client.post(url).headers(headers).multipart(form).send().await {
361 Ok(resp) => resp,
362 Err(e) => {
363 msg_error!(Message::SiServerRestDatesFailed(e.to_string()));
364 return Ok(HashSet::new());
365 }
366 };
367
368 match res.status() {
369 StatusCode::UNAUTHORIZED if local_retries < MAX_RETRY_COUNT => {
370 self.delete_session_id()?;
371 local_retries += 1;
372 continue;
373 }
374 _ => {
375 return match res.json::<RestDatesResponse>().await {
376 Ok(response) => Ok(response.unique_dates()?),
377 Err(e) => {
378 msg_error!(Message::SiServerRestDatesParsingFailed(e.to_string()));
379 Ok(HashSet::new())
380 }
381 };
382 }
383 }
384 }
385 }
386
387 /// True when `date` is the month's last working day (weekends walked
388 /// back; company holidays are not consulted).
389 ///
390 /// ```rust,no_run
391 /// # use kasl::api::si::{Si, SiConfig};
392 /// # use chrono::NaiveDate;
393 /// # use anyhow::Result;
394 /// # fn example() -> Result<()> {
395 /// # let config = SiConfig {
396 /// # login: "username".to_string(),
397 /// # auth_url: "https://auth.company.com".to_string(),
398 /// # api_url: "https://api.company.com".to_string(),
399 /// # };
400 /// let si = Si::new(&config);
401 /// let date = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(); // January 31st
402 ///
403 /// if si.is_last_working_day_of_month(&date)? {
404 /// println!("Time to submit monthly report!");
405 /// }
406 /// # Ok(())
407 /// # }
408 /// ```
409 pub fn is_last_working_day_of_month(&self, date: &NaiveDate) -> Result<bool> {
410 let (year, month) = (date.year(), date.month());
411
412 let mut last_day_of_month = NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap().pred_opt().unwrap();
413
414 while matches!(last_day_of_month.weekday(), Weekday::Sat | Weekday::Sun) {
415 last_day_of_month -= Duration::days(1);
416 }
417
418 Ok(date == &last_day_of_month)
419 }
420}
421
422/// SiServer connection settings; auth and API live on separate hosts.
423#[derive(Serialize, Deserialize, Clone, Debug)]
424pub struct SiConfig {
425 /// Corporate username for LDAP authentication.
426 pub login: String,
427
428 /// LDAP authentication endpoint.
429 pub auth_url: String,
430
431 /// Base URL for reports and calendar data.
432 pub api_url: String,
433}
434
435impl SiConfig {
436 /// Module metadata for the setup wizard.
437 pub fn module() -> ConfigModule {
438 ConfigModule {
439 key: "si".to_string(),
440 name: "SiServer".to_string(),
441 }
442 }
443
444 /// Interactive setup; existing values become the prompt defaults.
445 ///
446 /// ```rust,no_run
447 /// # use kasl::api::SiConfig;
448 /// # use anyhow::Result;
449 /// # fn example() -> Result<()> {
450 /// let existing_config = Some(SiConfig {
451 /// login: "olduser".to_string(),
452 /// auth_url: "https://old-auth.com".to_string(),
453 /// api_url: "https://old-api.com".to_string(),
454 /// });
455 ///
456 /// let new_config = SiConfig::init(&existing_config)?;
457 /// # Ok(())
458 /// # }
459 /// ```
460 pub fn init(config: &Option<SiConfig>) -> Result<Self> {
461 let config = config.clone().unwrap_or(Self {
462 login: "".to_string(),
463 auth_url: "".to_string(),
464 api_url: "".to_string(),
465 });
466
467 msg_print!(Message::ConfigModuleSiServer);
468
469 Ok(Self {
470 login: Input::with_theme(&ColorfulTheme::default())
471 .with_prompt("Enter your SiServer login")
472 .default(config.login)
473 .interact_text()?,
474 auth_url: Input::with_theme(&ColorfulTheme::default())
475 .with_prompt("Enter your SiServer login URL")
476 .default(config.auth_url)
477 .interact_text()?,
478 api_url: Input::with_theme(&ColorfulTheme::default())
479 .with_prompt("Enter the SiServer API URL")
480 .default(config.api_url)
481 .interact_text()?,
482 })
483 }
484}