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    /// The address the daily report is posted to.
226    ///
227    /// Read by `kasl report --send --show`, so the preview names where the
228    /// payload is going rather than only what is in it. Where it goes is half
229    /// of "what leaves this machine".
230    pub fn daily_report_url(&self) -> String {
231        format!("{}/{}", self.config.api_url, REPORT_URL)
232    }
233
234    pub async fn send(&mut self, data: &str, date: &NaiveDate) -> Result<StatusCode> {
235        let mut local_retries = 0;
236        loop {
237            let session_id = self.get_session_id().await?;
238            let url = format!("{}/{}", self.config.api_url, REPORT_URL);
239            let date = date.format("%Y-%m-%d").to_string();
240
241            let mut form = multipart::Form::new();
242            for (name, value) in daily_report_fields(&date, data) {
243                form = form.text(name, value);
244            }
245
246            let mut headers = HeaderMap::new();
247            headers.insert(COOKIE, HeaderValue::from_str(&format!("{}{}", COOKIE_KEY, session_id))?);
248
249            let res = match self.client.post(url).headers(headers).multipart(form).send().await {
250                Ok(response) => response,
251                Err(_) => return Ok(StatusCode::BAD_REQUEST), // Network error fallback
252            };
253
254            match res.status() {
255                StatusCode::UNAUTHORIZED if local_retries < MAX_RETRY_COUNT => {
256                    // Session expired - clear cache and retry
257                    self.delete_session_id()?;
258                    tokio::time::sleep(Duration::seconds(1).to_std()?).await;
259                    local_retries += 1;
260                    continue;
261                }
262                _ => return Ok(res.status()),
263            }
264        }
265    }
266
267    /// Submits the monthly report for the month containing `date`, with
268    /// the same 401-retry and network-error behavior as [`Si::send`].
269    ///
270    /// ```rust,no_run
271    /// # use kasl::api::si::{Si, SiConfig};
272    /// # use chrono::Local;
273    /// # use anyhow::Result;
274    /// # async fn example() -> Result<()> {
275    /// # let config = SiConfig {
276    /// #     login: "username".to_string(),
277    /// #     auth_url: "https://auth.company.com".to_string(),
278    /// #     api_url: "https://api.company.com".to_string(),
279    /// # };
280    /// let mut si = Si::new(&config);
281    /// let today = Local::now().date_naive();
282    ///
283    /// if si.is_last_working_day_of_month(&today)? {
284    ///     let status = si.send_monthly(&today).await?;
285    ///     if status.is_success() {
286    ///         println!("Monthly report submitted");
287    ///     }
288    /// }
289    /// # Ok(())
290    /// # }
291    /// ```
292    pub async fn send_monthly(&mut self, date: &NaiveDate) -> Result<StatusCode> {
293        let mut local_retries = 0;
294        loop {
295            let session_id = self.get_session_id().await?;
296            let url = format!("{}/{}", self.config.api_url, MONTHLY_REPORT_URL);
297            let (year, month) = (date.year(), date.month());
298
299            let form = multipart::Form::new().text("month", month.to_string()).text("year", year.to_string());
300
301            let mut headers = HeaderMap::new();
302            headers.insert(COOKIE, HeaderValue::from_str(&format!("{}{}", COOKIE_KEY, session_id))?);
303
304            let res = match self.client.post(url).headers(headers).multipart(form).send().await {
305                Ok(response) => response,
306                Err(_) => return Ok(StatusCode::BAD_REQUEST), // Network error fallback
307            };
308
309            match res.status() {
310                StatusCode::UNAUTHORIZED if local_retries < MAX_RETRY_COUNT => {
311                    self.delete_session_id()?;
312                    tokio::time::sleep(Duration::seconds(1).to_std()?).await;
313                    local_retries += 1;
314                    continue;
315                }
316                _ => return Ok(res.status()),
317            }
318        }
319    }
320
321    /// Fetches the company rest-date calendar for the year of `date`.
322    ///
323    /// Every failure path - session, network, parsing - logs and returns
324    /// an empty set: the calendar makes reports nicer, and its absence
325    /// must not break them.
326    ///
327    /// ```rust,no_run
328    /// # use kasl::api::si::{Si, SiConfig};
329    /// # use chrono::Local;
330    /// # use anyhow::Result;
331    /// # async fn example() -> Result<()> {
332    /// # let config = SiConfig {
333    /// #     login: "username".to_string(),
334    /// #     auth_url: "https://auth.company.com".to_string(),
335    /// #     api_url: "https://api.company.com".to_string(),
336    /// # };
337    /// let mut si = Si::new(&config);
338    /// let this_year = Local::now().date_naive();
339    ///
340    /// let rest_dates = si.rest_dates(this_year).await?;
341    /// println!("Found {} rest dates this year", rest_dates.len());
342    ///
343    /// let today = Local::now().date_naive();
344    /// if rest_dates.contains(&today) {
345    ///     println!("Today is a company rest day");
346    /// }
347    /// # Ok(())
348    /// # }
349    /// ```
350    pub async fn rest_dates(&mut self, year: NaiveDate) -> Result<HashSet<NaiveDate>> {
351        let mut local_retries = 0;
352        loop {
353            let session_id = match self.get_session_id().await {
354                Ok(id) => id,
355                Err(e) => {
356                    msg_error!(Message::SiServerSessionFailed(e.to_string()));
357                    return Ok(HashSet::new());
358                }
359            };
360
361            let url = format!("{}/{}", self.config.api_url, REST_DATES_URL);
362            let form = multipart::Form::new().text("year", year.format("%Y").to_string());
363            let mut headers = HeaderMap::new();
364            headers.insert(COOKIE, HeaderValue::from_str(&format!("{}{}", COOKIE_KEY, session_id))?);
365
366            let res = match self.client.post(url).headers(headers).multipart(form).send().await {
367                Ok(resp) => resp,
368                Err(e) => {
369                    msg_error!(Message::SiServerRestDatesFailed(e.to_string()));
370                    return Ok(HashSet::new());
371                }
372            };
373
374            match res.status() {
375                StatusCode::UNAUTHORIZED if local_retries < MAX_RETRY_COUNT => {
376                    self.delete_session_id()?;
377                    local_retries += 1;
378                    continue;
379                }
380                _ => {
381                    return match res.json::<RestDatesResponse>().await {
382                        Ok(response) => Ok(response.unique_dates()?),
383                        Err(e) => {
384                            msg_error!(Message::SiServerRestDatesParsingFailed(e.to_string()));
385                            Ok(HashSet::new())
386                        }
387                    };
388                }
389            }
390        }
391    }
392
393    /// True when `date` is the month's last working day (weekends walked
394    /// back; company holidays are not consulted).
395    ///
396    /// ```rust,no_run
397    /// # use kasl::api::si::{Si, SiConfig};
398    /// # use chrono::NaiveDate;
399    /// # use anyhow::Result;
400    /// # fn example() -> Result<()> {
401    /// # let config = SiConfig {
402    /// #     login: "username".to_string(),
403    /// #     auth_url: "https://auth.company.com".to_string(),
404    /// #     api_url: "https://api.company.com".to_string(),
405    /// # };
406    /// let si = Si::new(&config);
407    /// let date = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(); // January 31st
408    ///
409    /// if si.is_last_working_day_of_month(&date)? {
410    ///     println!("Time to submit monthly report!");
411    /// }
412    /// # Ok(())
413    /// # }
414    /// ```
415    pub fn is_last_working_day_of_month(&self, date: &NaiveDate) -> Result<bool> {
416        let (year, month) = (date.year(), date.month());
417
418        let mut last_day_of_month = NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap().pred_opt().unwrap();
419
420        while matches!(last_day_of_month.weekday(), Weekday::Sat | Weekday::Sun) {
421            last_day_of_month -= Duration::days(1);
422        }
423
424        Ok(date == &last_day_of_month)
425    }
426}
427
428/// Every field of the daily report form, in the order it is sent.
429///
430/// One list rather than a literal inside [`Si::send`], because
431/// `kasl report --send --show` prints this and a payload preview assembled
432/// separately would eventually describe a request that is no longer the one
433/// made. The preview and the request read the same function, so they cannot
434/// disagree - only the transport differs.
435///
436/// The constants are the corporate form's, not kasl's: `day_type` 1 is a
437/// working day, `duty` 0 is not on call, `only_save` 0 means submit rather
438/// than keep as a draft. They are sent as text because the endpoint takes a
439/// multipart form.
440pub fn daily_report_fields(date: &str, tasks: &str) -> Vec<(&'static str, String)> {
441    vec![
442        ("date", date.to_string()),
443        ("tasks", tasks.to_string()),
444        ("comment", String::new()),
445        ("day_type", "1".to_string()),
446        ("duty", "0".to_string()),
447        ("only_save", "0".to_string()),
448    ]
449}
450
451/// SiServer connection settings; auth and API live on separate hosts.
452#[derive(Serialize, Deserialize, Clone, Debug)]
453pub struct SiConfig {
454    /// Corporate username for LDAP authentication.
455    pub login: String,
456
457    /// LDAP authentication endpoint.
458    pub auth_url: String,
459
460    /// Base URL for reports and calendar data.
461    pub api_url: String,
462}
463
464impl SiConfig {
465    /// Module metadata for the setup wizard.
466    pub fn module() -> ConfigModule {
467        ConfigModule {
468            key: "si".to_string(),
469            name: "SiServer".to_string(),
470        }
471    }
472
473    /// Interactive setup; existing values become the prompt defaults.
474    ///
475    /// ```rust,no_run
476    /// # use kasl::api::SiConfig;
477    /// # use anyhow::Result;
478    /// # fn example() -> Result<()> {
479    /// let existing_config = Some(SiConfig {
480    ///     login: "olduser".to_string(),
481    ///     auth_url: "https://old-auth.com".to_string(),
482    ///     api_url: "https://old-api.com".to_string(),
483    /// });
484    ///
485    /// let new_config = SiConfig::init(&existing_config)?;
486    /// # Ok(())
487    /// # }
488    /// ```
489    pub fn init(config: &Option<SiConfig>) -> Result<Self> {
490        let config = config.clone().unwrap_or(Self {
491            login: "".to_string(),
492            auth_url: "".to_string(),
493            api_url: "".to_string(),
494        });
495
496        msg_print!(Message::ConfigModuleSiServer);
497
498        Ok(Self {
499            login: Input::with_theme(&ColorfulTheme::default())
500                .with_prompt("Enter your SiServer login")
501                .default(config.login)
502                .interact_text()?,
503            auth_url: Input::with_theme(&ColorfulTheme::default())
504                .with_prompt("Enter your SiServer login URL")
505                .default(config.auth_url)
506                .interact_text()?,
507            api_url: Input::with_theme(&ColorfulTheme::default())
508                .with_prompt("Enter the SiServer API URL")
509                .default(config.api_url)
510                .interact_text()?,
511        })
512    }
513}