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}