Skip to main content

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}