Skip to main content

ravenrs_report_server/
job.rs

1//! Scheduled jobs: what to render, when, and where it goes.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use jiff::tz::TimeZone;
6use ravenrs_report::Criteria;
7pub use ravenrs_report_export::ExportFormat as Format;
8use serde::{Deserialize, Serialize};
9
10use crate::cron::Schedule;
11use crate::store::valid_name;
12
13/// The most recipients one delivery may have.
14const MAX_RECIPIENTS: usize = 500;
15/// The most retries a job may ask for.
16const MAX_RETRIES: u32 = 10;
17
18/// A scheduled report.
19#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase", deny_unknown_fields)]
21pub struct Job {
22    /// The stored layout to render, by name; its latest version at each run.
23    pub layout: String,
24    /// When to run, as five cron fields.
25    pub schedule: String,
26    /// The time zone of the schedule and of `Today()`, such as `America/Recife`.
27    #[serde(default = "utc")]
28    pub timezone: String,
29    /// Parameter values, fixed or computed at each run.
30    #[serde(default)]
31    pub parameters: BTreeMap<String, ParameterValue>,
32    /// The files to produce.
33    pub formats: Vec<Format>,
34    /// Where they go.
35    pub deliveries: Vec<Delivery>,
36    /// Sends nothing when the report has no rows.
37    #[serde(default)]
38    pub skip_if_empty: bool,
39    /// How many times to try again after a failure.
40    #[serde(default = "default_retries")]
41    pub retries: u32,
42    /// Keeps each run's files on the server for this many days; zero keeps none.
43    #[serde(default)]
44    pub retention_days: u32,
45    /// A disabled job runs only when asked.
46    #[serde(default = "enabled")]
47    pub enabled: bool,
48}
49
50fn utc() -> String {
51    "UTC".to_owned()
52}
53
54fn default_retries() -> u32 {
55    2
56}
57
58fn enabled() -> bool {
59    true
60}
61
62/// A parameter's value in a job.
63#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
64#[serde(untagged)]
65pub enum ParameterValue {
66    /// Computed when the job runs, such as `AddMonths(Today(), -1)`.
67    Expression {
68        /// The expression.
69        expression: String,
70    },
71    /// A fixed value.
72    Fixed(serde_json::Value),
73}
74
75/// Where a job's files go.
76#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
77#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase", deny_unknown_fields)]
78pub enum Delivery {
79    /// As email attachments.
80    Email {
81        /// The recipients.
82        to: Vec<String>,
83        /// Sends one message per recipient, who then do not see each other.
84        #[serde(default)]
85        separately: bool,
86        /// The subject; `{report}` and `{date}` are filled in.
87        #[serde(default)]
88        subject: Option<String>,
89        /// The text of the message; `{report}` and `{date}` are filled in.
90        #[serde(default)]
91        body: Option<String>,
92    },
93    /// As files in a folder under the server's `outputDir`.
94    Folder {
95        /// The folder, relative to `outputDir`.
96        path: String,
97    },
98}
99
100impl Job {
101    /// Checks everything that can be checked before the job runs.
102    pub fn validate(&self) -> Result<(), String> {
103        if !valid_name(&self.layout) {
104            return Err(format!("'{}' is not a valid layout name", self.layout));
105        }
106        Schedule::parse(&self.schedule)?;
107        TimeZone::get(&self.timezone).map_err(|_| format!("unknown time zone '{}'", self.timezone))?;
108        if self.formats.is_empty() {
109            return Err("a job needs at least one format".to_owned());
110        }
111        if self.formats.iter().collect::<BTreeSet<_>>().len() != self.formats.len() {
112            return Err("a format is listed twice".to_owned());
113        }
114        if self.deliveries.is_empty() {
115            return Err("a job needs at least one delivery".to_owned());
116        }
117        if self.retries > MAX_RETRIES {
118            return Err(format!("a job can retry at most {MAX_RETRIES} times"));
119        }
120        for (name, value) in &self.parameters {
121            if let ParameterValue::Expression { expression } = value {
122                Criteria::parse(expression).map_err(|e| format!("parameter {name}: {e}"))?;
123            }
124        }
125        for delivery in &self.deliveries {
126            match delivery {
127                Delivery::Email { to, .. } => {
128                    if to.is_empty() || to.len() > MAX_RECIPIENTS {
129                        return Err(format!("an email delivery needs 1 to {MAX_RECIPIENTS} recipients"));
130                    }
131                    for address in to {
132                        address
133                            .parse::<lettre::message::Mailbox>()
134                            .map_err(|_| format!("'{address}' is not a valid email address"))?;
135                    }
136                }
137                Delivery::Folder { path } => {
138                    safe_relative_path(path)?;
139                }
140            }
141        }
142        Ok(())
143    }
144}
145
146/// The parts of a relative path, refusing anything that could leave its base.
147pub(crate) fn safe_relative_path(path: &str) -> Result<Vec<&str>, String> {
148    let parts: Vec<&str> = path.split(['/', '\\']).filter(|p| !p.is_empty() && *p != ".").collect();
149    let bad = path.starts_with(['/', '\\'])
150        || path.contains('\0')
151        || path.contains(':')
152        || parts.is_empty()
153        || parts.iter().any(|p| *p == ".." || p.chars().any(char::is_control));
154    if bad {
155        return Err(format!("'{path}' must be a relative folder without '..'"));
156    }
157    Ok(parts)
158}
159
160#[cfg(test)]
161mod tests {
162    use serde_json::json;
163
164    use super::*;
165
166    fn job() -> serde_json::Value {
167        json!({
168            "layout": "sales",
169            "schedule": "0 8 * * MON",
170            "timezone": "America/Recife",
171            "parameters": { "Region": "Sul", "From": { "expression": "AddMonths(Today(), -1)" } },
172            "formats": ["pdf", "xlsx"],
173            "deliveries": [
174                { "type": "email", "to": ["Ana <ana@example.com>"], "separately": true, "subject": "{report} {date}" },
175                { "type": "folder", "path": "vendas/semanal" }
176            ],
177            "skipIfEmpty": true,
178            "retentionDays": 30
179        })
180    }
181
182    fn with(key: &str, value: serde_json::Value) -> Result<(), String> {
183        let mut json = job();
184        json[key] = value;
185        let job: Job = serde_json::from_value(json).map_err(|e| e.to_string())?;
186        job.validate()
187    }
188
189    #[test]
190    fn a_complete_job_reads_and_validates() -> Result<(), String> {
191        let parsed: Job = serde_json::from_value(job()).map_err(|e| e.to_string())?;
192        parsed.validate()?;
193        assert_eq!(parsed.retries, 2);
194        assert!(parsed.enabled);
195        assert_eq!(parsed.parameters["Region"], ParameterValue::Fixed(json!("Sul")));
196        assert!(matches!(parsed.parameters["From"], ParameterValue::Expression { .. }));
197        let again: Job = serde_json::from_str(&serde_json::to_string(&parsed).map_err(|e| e.to_string())?)
198            .map_err(|e| e.to_string())?;
199        assert_eq!(again, parsed);
200        Ok(())
201    }
202
203    #[test]
204    fn mistakes_are_refused() {
205        assert!(with("schedule", json!("every monday")).is_err());
206        assert!(with("timezone", json!("Mars/Olympus")).is_err());
207        assert!(with("layout", json!("../secret")).is_err());
208        assert!(with("formats", json!([])).is_err());
209        assert!(with("formats", json!(["pdf", "pdf"])).is_err());
210        assert!(with("formats", json!(["docx"])).is_err());
211        assert!(with("deliveries", json!([])).is_err());
212        assert!(with("deliveries", json!([{ "type": "email", "to": ["not an address"] }])).is_err());
213        assert!(with("deliveries", json!([{ "type": "email", "to": [] }])).is_err());
214        assert!(with("retries", json!(99)).is_err());
215        assert!(with("parameters", json!({ "A": { "expression": "1 +" } })).is_err());
216        assert!(with("typo", json!(1)).is_err(), "unknown fields are refused");
217    }
218
219    #[test]
220    fn folders_cannot_leave_the_output_folder() {
221        for bad in ["../x", "a/../../x", "/etc", "\\x", "C:\\x", "a\\..\\..\\x", "", ".", "a\0b"] {
222            assert!(safe_relative_path(bad).is_err(), "{bad}");
223        }
224        assert_eq!(safe_relative_path("a/./b/"), Ok(vec!["a", "b"]));
225    }
226}