Skip to main content

ravenrs_report_server/
render.rs

1//! Resolves a layout's data and parameters, and writes its output files.
2
3use std::collections::{BTreeMap, HashMap};
4use std::io::Read as _;
5use std::time::Duration;
6
7use jiff::Zoned;
8use ravenrs_report::{Criteria, DataSourceKind, DateTime, RenderOptions, Report, ReportEngine, Value};
9use serde_json::{Value as Json, json};
10
11use crate::config::{Config, DataHost};
12use crate::job::{Format, ParameterValue};
13
14/// The largest response a `url` data source may return.
15const MAX_DATA_BYTES: u64 = 64 * 1024 * 1024;
16/// How long a `url` data source may take.
17const DATA_TIMEOUT: Duration = Duration::from_secs(60);
18
19/// A produced file.
20pub use ravenrs_report_export::ExportedFile as OutputFile;
21
22/// What a render produced.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct Rendered {
25    /// The report's title.
26    pub title: String,
27    /// The files, one per format.
28    pub files: Vec<OutputFile>,
29    /// How many rows the report printed.
30    pub rows: usize,
31    /// Problems in the report; the files are still produced.
32    pub warnings: Vec<String>,
33}
34
35/// A date as a report parameter value.
36fn date_text(d: DateTime) -> String {
37    let (year, month, day) = d.ymd();
38    let (hour, minute, second) = d.hms();
39    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}")
40}
41
42/// A moment as the local date and time reports see.
43pub fn local_date_time(now: &Zoned) -> Option<DateTime> {
44    let mut date = DateTime::from_ymd(i32::from(now.year()), now.month() as u32, now.day() as u32)?;
45    date.seconds = now.hour() as u32 * 3600 + now.minute() as u32 * 60 + now.second() as u32;
46    Some(date)
47}
48
49/// Computes a job's parameter values for a run at `now`.
50pub fn parameter_values(
51    parameters: &BTreeMap<String, ParameterValue>,
52    now: DateTime,
53) -> Result<HashMap<String, Json>, String> {
54    let env = ravenrs_report::criteria::Environment::new().at(now);
55    let mut values = HashMap::new();
56    for (name, value) in parameters {
57        let json = match value {
58            ParameterValue::Fixed(json) => json.clone(),
59            ParameterValue::Expression { expression } => {
60                let criteria = Criteria::parse(expression).map_err(|e| format!("parameter {name}: {e}"))?;
61                let scope = ravenrs_report::criteria::Scope::<Json>::new(&env);
62                match scope.evaluate(&criteria).map_err(|e| format!("parameter {name}: {e}"))? {
63                    Value::Null => Json::Null,
64                    Value::Bool(b) => json!(b),
65                    Value::Number(n) => json!(n),
66                    Value::Date(d) => json!(date_text(d)),
67                    other => json!(other.to_text()),
68                }
69            }
70        };
71        values.insert(name.clone(), json);
72    }
73    Ok(values)
74}
75
76/// The data for a layout: stored in it, or fetched from an allowed host.
77pub fn resolve_data(
78    report: &Report,
79    parameters: &HashMap<String, Json>,
80    config: &Config,
81) -> Result<Json, String> {
82    let source = match &report.data_source {
83        Some(name) => report
84            .data_sources
85            .iter()
86            .find(|s| &s.name == name)
87            .ok_or_else(|| format!("the data source '{name}' is not in the layout"))?,
88        None => match report.data_sources.first() {
89            Some(source) => source,
90            None => return Ok(Json::Null),
91        },
92    };
93    match &source.kind {
94        DataSourceKind::Json { data } => Ok(data.clone()),
95        DataSourceKind::Url { url, headers } => fetch(url, headers, parameters, &config.data_hosts),
96        DataSourceKind::Sql { connection, .. } => {
97            Err(format!("SQL data sources are not supported yet (connection '{connection}')"))
98        }
99        _ => Err("this kind of data source is not supported".to_owned()),
100    }
101}
102
103/// Percent-encodes everything but unreserved characters, so a value cannot
104/// change which host or path a URL points at.
105fn encode_component(text: &str) -> String {
106    let mut out = String::new();
107    for byte in text.bytes() {
108        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
109            out.push(byte as char);
110        } else {
111            out.push_str(&format!("%{byte:02X}"));
112        }
113    }
114    out
115}
116
117/// Fills in `{?Name}` with encoded parameter values.
118fn fill_url(url: &str, parameters: &HashMap<String, Json>) -> String {
119    let mut out = url.to_owned();
120    for (name, value) in parameters {
121        let text = match value {
122            Json::String(s) => s.clone(),
123            other => other.to_string(),
124        };
125        out = out.replace(&format!("{{?{name}}}"), &encode_component(&text));
126    }
127    out
128}
129
130/// The allowed host a URL points at. Layouts can come from anyone with API
131/// access, so only hosts in the server's configuration can be reached.
132fn allowed_host<'c>(url: &str, hosts: &'c [DataHost]) -> Result<&'c DataHost, String> {
133    let uri: ureq::http::Uri = url.parse().map_err(|_| format!("'{url}' is not a valid URL"))?;
134    if !matches!(uri.scheme_str(), Some("http" | "https")) {
135        return Err(format!("'{url}' must use http or https"));
136    }
137    let authority = uri.authority().map_or("", |a| a.as_str());
138    if authority.contains('@') {
139        return Err(format!("'{url}' must not contain credentials"));
140    }
141    let host = uri.host().unwrap_or_default();
142    hosts
143        .iter()
144        .find(|allowed| allowed.host.eq_ignore_ascii_case(host))
145        .ok_or_else(|| format!("the host '{host}' is not in the server's dataHosts"))
146}
147
148fn fetch(
149    url: &str,
150    headers: &BTreeMap<String, String>,
151    parameters: &HashMap<String, Json>,
152    hosts: &[DataHost],
153) -> Result<Json, String> {
154    let url = fill_url(url, parameters);
155    let host = allowed_host(&url, hosts)?;
156    // A redirect could lead away from the allowed host, so none is followed.
157    let agent: ureq::Agent = ureq::Agent::config_builder()
158        .max_redirects(0)
159        .timeout_global(Some(DATA_TIMEOUT))
160        .http_status_as_error(false)
161        .build()
162        .into();
163    let mut request = agent.get(&url).header("Accept", "application/json");
164    for (name, value) in headers {
165        request = request.header(name, value);
166    }
167    for (name, variable) in &host.headers_env {
168        let value =
169            std::env::var(variable).map_err(|_| format!("the environment variable {variable} is not set"))?;
170        request = request.header(name, &value);
171    }
172    let mut response = request.call().map_err(|e| format!("could not read {}: {e}", host.host))?;
173    let status = response.status();
174    if !status.is_success() {
175        return Err(format!("{} answered {status}", host.host));
176    }
177    let mut bytes = Vec::new();
178    response
179        .body_mut()
180        .as_reader()
181        .take(MAX_DATA_BYTES + 1)
182        .read_to_end(&mut bytes)
183        .map_err(|e| format!("could not read {}: {e}", host.host))?;
184    if bytes.len() as u64 > MAX_DATA_BYTES {
185        return Err(format!("{} returned more than {} MB", host.host, MAX_DATA_BYTES / (1024 * 1024)));
186    }
187    serde_json::from_slice(&bytes).map_err(|e| format!("{} did not return JSON: {e}", host.host))
188}
189
190/// Renders `report` over `data` into one file per format. CPU-bound: run it off the async threads.
191pub fn render(
192    report: &Report,
193    data: &Json,
194    parameters: HashMap<String, Json>,
195    now: DateTime,
196    formats: &[Format],
197    date_label: &str,
198) -> Result<Rendered, String> {
199    let options = RenderOptions { parameters, now: Some(now) };
200    let mut engine = ReportEngine::new();
201    let rows = engine.count_rows(report, data, &options);
202    let document = engine.render_with(report, data, &options);
203    let files = formats
204        .iter()
205        .map(|format| ravenrs_report_export::export(&document, *format, Some(date_label)))
206        .collect::<Result<Vec<_>, _>>()?;
207    Ok(Rendered { title: document.title, files, rows, warnings: document.errors })
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn hosts() -> Vec<DataHost> {
215        vec![DataHost { host: "api.example.com".to_owned(), headers_env: BTreeMap::new() }]
216    }
217
218    #[test]
219    fn only_configured_hosts_can_be_reached() {
220        let hosts = hosts();
221        assert!(allowed_host("https://api.example.com/x", &hosts).is_ok());
222        assert!(allowed_host("https://API.example.com/x", &hosts).is_ok());
223        for bad in [
224            "https://evil.example.com/x",
225            "http://169.254.169.254/latest/meta-data",
226            "http://localhost/",
227            "file:///etc/passwd",
228            "https://api.example.com@evil.com/",
229            "https://evil.com/api.example.com",
230            "api.example.com/x",
231        ] {
232            assert!(allowed_host(bad, &hosts).is_err(), "{bad}");
233        }
234    }
235
236    #[test]
237    fn parameter_values_cannot_change_the_host() {
238        let values =
239            HashMap::from([("Id".to_owned(), json!("x@evil.com/../a b")), ("N".to_owned(), json!(5))]);
240        let url = fill_url("https://{?Id}/items?n={?N}&id={?Id}", &values);
241        assert_eq!(url, "https://x%40evil.com%2F..%2Fa%20b/items?n=5&id=x%40evil.com%2F..%2Fa%20b");
242        assert!(allowed_host(&url, &hosts()).is_err());
243    }
244
245    #[test]
246    fn expressions_compute_values_at_the_run_time() -> Result<(), String> {
247        let now = DateTime::from_ymd(2026, 3, 31).ok_or("date")?;
248        let parameters = BTreeMap::from([
249            ("Fixed".to_owned(), ParameterValue::Fixed(json!("Sul"))),
250            (
251                "From".to_owned(),
252                ParameterValue::Expression { expression: "AddMonths(Today(), -1)".to_owned() },
253            ),
254            ("Sum".to_owned(), ParameterValue::Expression { expression: "1 + 2".to_owned() }),
255        ]);
256        let values = parameter_values(&parameters, now)?;
257        assert_eq!(values["Fixed"], json!("Sul"));
258        assert_eq!(values["From"], json!("2026-02-28T00:00:00"));
259        assert_eq!(values["Sum"], json!(3.0));
260        Ok(())
261    }
262}