1use std::collections::BTreeMap;
4use std::io;
5use std::sync::Arc;
6
7use axum::body::Bytes;
8use axum::extract::{DefaultBodyLimit, Path, Query, Request, State};
9use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
10use axum::middleware::{self, Next};
11use axum::response::{IntoResponse, Response};
12use axum::routing::{get, post};
13use axum::{Json, Router};
14use jiff::Timestamp;
15use jiff::tz::TimeZone;
16use ravenrs_report::Report;
17use serde::Deserialize;
18use serde_json::{Value as JsonValue, json};
19
20use crate::config::Config;
21use crate::cron::Schedule;
22use crate::job::{Delivery, Format, Job, ParameterValue};
23use crate::render::{local_date_time, parameter_values, render, resolve_data};
24use crate::scheduler::Runner;
25use crate::store::{JobSummary, Store, valid_name};
26
27const RUNS_DEFAULT: usize = 50;
29const RUNS_MAX: usize = 500;
30
31#[derive(Clone)]
33pub struct AppState {
34 store: Store,
35 runner: Runner,
36 config: Arc<Config>,
37 token: Arc<str>,
38}
39
40impl std::fmt::Debug for AppState {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 f.debug_struct("AppState").field("store", &self.store).finish_non_exhaustive()
43 }
44}
45
46impl AppState {
47 pub fn new(store: Store, runner: Runner, config: Arc<Config>, token: String) -> Self {
49 Self { store, runner, config, token: token.into() }
50 }
51}
52
53enum ApiError {
55 BadRequest(String),
56 NotFound,
57 Conflict(String),
58 Internal(String),
59}
60
61impl From<io::Error> for ApiError {
62 fn from(e: io::Error) -> Self {
63 if e.kind() == io::ErrorKind::InvalidInput {
64 Self::BadRequest(e.to_string())
65 } else {
66 Self::Internal(e.to_string())
67 }
68 }
69}
70
71impl IntoResponse for ApiError {
72 fn into_response(self) -> Response {
73 let (status, message) = match self {
74 Self::BadRequest(message) => (StatusCode::BAD_REQUEST, message),
75 Self::NotFound => (StatusCode::NOT_FOUND, "not found".to_owned()),
76 Self::Conflict(message) => (StatusCode::CONFLICT, message),
77 Self::Internal(detail) => {
78 tracing::error!(error = %detail, "request failed");
79 (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_owned())
80 }
81 };
82 (status, Json(json!({ "error": message }))).into_response()
83 }
84}
85
86pub fn router(state: AppState) -> Router {
88 let limit = state.config.max_body_megabytes.saturating_mul(1024 * 1024);
89 let protected = Router::new()
90 .route("/render", post(render_now))
91 .route("/layouts", get(list_layouts))
92 .route("/layouts/{name}", get(get_layout).put(put_layout).delete(delete_layout))
93 .route("/layouts/{name}/versions", get(layout_versions))
94 .route("/jobs", get(list_jobs))
95 .route("/jobs/{id}", get(get_job).put(put_job).delete(delete_job))
96 .route("/jobs/{id}/run", post(run_job))
97 .route("/jobs/{id}/runs", get(list_runs))
98 .route("/jobs/{id}/runs/{run}", get(get_run))
99 .route("/jobs/{id}/runs/{run}/documents/{file}", get(get_document))
100 .route_layer(middleware::from_fn_with_state(state.clone(), require_token));
101 Router::new()
102 .route("/health", get(|| async { Json(json!({ "status": "ok" })) }))
103 .merge(protected)
104 .layer(DefaultBodyLimit::max(limit))
105 .with_state(state)
106}
107
108fn same_secret(a: &[u8], b: &[u8]) -> bool {
111 if a.len() != b.len() {
112 return false;
113 }
114 a.iter().zip(b).fold(0_u8, |difference, (x, y)| difference | (x ^ y)) == 0
115}
116
117async fn require_token(
118 State(state): State<AppState>,
119 headers: HeaderMap,
120 request: Request,
121 next: Next,
122) -> Response {
123 let presented = headers
124 .get(header::AUTHORIZATION)
125 .and_then(|value| value.to_str().ok())
126 .and_then(|value| value.strip_prefix("Bearer "));
127 if presented.is_some_and(|token| same_secret(token.as_bytes(), state.token.as_bytes())) {
128 return next.run(request).await;
129 }
130 tracing::warn!(path = %request.uri().path(), "request without a valid token");
131 (
132 StatusCode::UNAUTHORIZED,
133 [(header::WWW_AUTHENTICATE, "Bearer")],
134 Json(json!({ "error": "a valid API token is required" })),
135 )
136 .into_response()
137}
138
139fn parse_report(text: &str) -> Result<Report, ApiError> {
140 Report::from_json(text).map_err(|e| ApiError::BadRequest(format!("not a valid layout: {e}")))
141}
142
143async fn list_layouts(State(state): State<AppState>) -> Result<Json<Vec<String>>, ApiError> {
144 Ok(Json(state.store.layouts()?))
145}
146
147async fn put_layout(
148 State(state): State<AppState>,
149 Path(name): Path<String>,
150 body: String,
151) -> Result<Json<JsonValue>, ApiError> {
152 parse_report(&body)?;
153 let version = state.store.save_layout(&name, &body)?;
154 Ok(Json(json!({ "name": name, "version": version })))
155}
156
157#[derive(Deserialize)]
158struct VersionQuery {
159 version: Option<u32>,
160}
161
162async fn get_layout(
163 State(state): State<AppState>,
164 Path(name): Path<String>,
165 Query(query): Query<VersionQuery>,
166) -> Result<Response, ApiError> {
167 let (version, text) = state.store.layout(&name, query.version)?.ok_or(ApiError::NotFound)?;
168 Ok((
169 [(header::CONTENT_TYPE, "application/json".to_owned()), (header::ETAG, format!("\"{version}\""))],
170 text,
171 )
172 .into_response())
173}
174
175async fn layout_versions(
176 State(state): State<AppState>,
177 Path(name): Path<String>,
178) -> Result<Response, ApiError> {
179 let versions = state.store.layout_versions(&name)?;
180 if versions.is_empty() {
181 return Err(ApiError::NotFound);
182 }
183 Ok(Json(versions).into_response())
184}
185
186async fn delete_layout(
187 State(state): State<AppState>,
188 Path(name): Path<String>,
189) -> Result<StatusCode, ApiError> {
190 for id in state.store.job_ids()? {
191 if state.store.job(&id)?.is_some_and(|job| job.layout == name) {
192 return Err(ApiError::Conflict(format!("the job '{id}' uses this layout")));
193 }
194 }
195 if state.store.delete_layout(&name)? { Ok(StatusCode::NO_CONTENT) } else { Err(ApiError::NotFound) }
196}
197
198#[derive(Deserialize)]
200#[serde(rename_all = "camelCase", deny_unknown_fields)]
201struct RenderRequest {
202 name: Option<String>,
204 version: Option<u32>,
206 layout: Option<JsonValue>,
208 data: Option<JsonValue>,
210 #[serde(default)]
211 parameters: BTreeMap<String, ParameterValue>,
212 format: Format,
213 timezone: Option<String>,
215}
216
217async fn render_now(State(state): State<AppState>, body: Bytes) -> Result<Response, ApiError> {
218 let request: RenderRequest =
219 serde_json::from_slice(&body).map_err(|e| ApiError::BadRequest(format!("bad request: {e}")))?;
220 let report = match (&request.name, &request.layout) {
221 (Some(name), None) => {
222 let (_, text) = state.store.layout(name, request.version)?.ok_or(ApiError::NotFound)?;
223 parse_report(&text)?
224 }
225 (None, Some(layout)) => serde_json::from_value(layout.clone())
226 .map_err(|e| ApiError::BadRequest(format!("not a valid layout: {e}")))?,
227 _ => return Err(ApiError::BadRequest("send either 'name' or 'layout'".to_owned())),
228 };
229 let zone = TimeZone::get(request.timezone.as_deref().unwrap_or("UTC"))
230 .map_err(|_| ApiError::BadRequest("unknown time zone".to_owned()))?;
231 let local = Timestamp::now().to_zoned(zone);
232 let now = local_date_time(&local).ok_or_else(|| ApiError::Internal("date out of range".to_owned()))?;
233 let date = local.strftime("%Y-%m-%d").to_string();
234 let parameters = parameter_values(&request.parameters, now).map_err(ApiError::BadRequest)?;
235 let format = request.format;
236 let config = state.config.clone();
237 let permit =
238 state.runner.permits().acquire_owned().await.map_err(|e| ApiError::Internal(e.to_string()))?;
239 let rendered = tokio::task::spawn_blocking(move || {
240 let _permit = permit;
241 let data = match request.data {
242 Some(data) => data,
243 None => resolve_data(&report, ¶meters, &config)?,
244 };
245 render(&report, &data, parameters, now, &[format], &date)
246 })
247 .await
248 .map_err(|e| ApiError::Internal(e.to_string()))?
249 .map_err(ApiError::BadRequest)?;
250 let warnings = rendered.warnings.len();
251 let file = rendered.files.into_iter().next().ok_or_else(|| ApiError::Internal("no file".to_owned()))?;
252 Ok(file_response(&file.name, file.mime, file.bytes, Some(warnings)))
253}
254
255fn file_response(name: &str, mime: &str, bytes: Vec<u8>, warnings: Option<usize>) -> Response {
258 let ascii: String =
259 name.chars().map(|c| if c.is_ascii_alphanumeric() || " .-_".contains(c) { c } else { '_' }).collect();
260 let encoded: String = name
261 .bytes()
262 .map(|b| {
263 if b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_') {
264 (b as char).to_string()
265 } else {
266 format!("%{b:02X}")
267 }
268 })
269 .collect();
270 let disposition = format!("attachment; filename=\"{ascii}\"; filename*=UTF-8''{encoded}");
271 let mut response = bytes.into_response();
272 let headers = response.headers_mut();
273 if let Ok(value) = HeaderValue::from_str(mime) {
274 headers.insert(header::CONTENT_TYPE, value);
275 }
276 if let Ok(value) = HeaderValue::from_str(&disposition) {
277 headers.insert(header::CONTENT_DISPOSITION, value);
278 }
279 headers.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
280 if let Some(warnings) = warnings {
281 headers.insert("x-report-warnings", HeaderValue::from(warnings));
282 }
283 response
284}
285
286async fn list_jobs(State(state): State<AppState>) -> Result<Json<Vec<JobSummary>>, ApiError> {
287 let mut jobs = Vec::new();
288 for id in state.store.job_ids()? {
289 if let Some(job) = state.store.job(&id)? {
290 let next_run = state.store.job_state(&id)?.next_run;
291 jobs.push(JobSummary { id, job, next_run });
292 }
293 }
294 Ok(Json(jobs))
295}
296
297async fn get_job(
298 State(state): State<AppState>,
299 Path(id): Path<String>,
300) -> Result<Json<JobSummary>, ApiError> {
301 let job = state.store.job(&id)?.ok_or(ApiError::NotFound)?;
302 let next_run = state.store.job_state(&id)?.next_run;
303 Ok(Json(JobSummary { id, job, next_run }))
304}
305
306async fn put_job(
307 State(state): State<AppState>,
308 Path(id): Path<String>,
309 body: Bytes,
310) -> Result<Json<JsonValue>, ApiError> {
311 if !valid_name(&id) {
312 return Err(ApiError::BadRequest(format!("'{id}' is not a valid name")));
313 }
314 let job: Job =
315 serde_json::from_slice(&body).map_err(|e| ApiError::BadRequest(format!("bad job: {e}")))?;
316 job.validate().map_err(ApiError::BadRequest)?;
317 if state.store.layout(&job.layout, None)?.is_none() {
318 return Err(ApiError::BadRequest(format!("the layout '{}' does not exist", job.layout)));
319 }
320 for delivery in &job.deliveries {
321 match delivery {
322 Delivery::Email { .. } if state.config.smtp.is_none() => {
323 return Err(ApiError::BadRequest("the server has no mail server configured".to_owned()));
324 }
325 Delivery::Folder { .. } if state.config.output_dir.is_none() => {
326 return Err(ApiError::BadRequest("the server has no outputDir configured".to_owned()));
327 }
328 _ => {}
329 }
330 }
331 state.store.save_job(&id, &job)?;
332 let schedule = Schedule::parse(&job.schedule).map_err(ApiError::BadRequest)?;
333 let zone = TimeZone::get(&job.timezone).map_err(|e| ApiError::BadRequest(e.to_string()))?;
334 let next_run = schedule.next_after(&Timestamp::now().to_zoned(zone)).map(|z| z.timestamp());
335 state.store.save_job_state(&id, &crate::store::JobState { next_run })?;
336 Ok(Json(json!({ "id": id, "nextRun": next_run })))
337}
338
339async fn delete_job(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
340 if state.store.delete_job(&id)? { Ok(StatusCode::NO_CONTENT) } else { Err(ApiError::NotFound) }
341}
342
343async fn run_job(State(state): State<AppState>, Path(id): Path<String>) -> Result<Response, ApiError> {
344 let job = state.store.job(&id)?.ok_or(ApiError::NotFound)?;
345 let runner = state.runner.clone();
346 let job_id = id.clone();
347 tokio::spawn(async move { runner.run_now(&job_id, &job).await });
348 Ok((StatusCode::ACCEPTED, Json(json!({ "job": id, "status": "started" }))).into_response())
349}
350
351#[derive(Deserialize)]
352struct RunsQuery {
353 limit: Option<usize>,
354}
355
356async fn list_runs(
357 State(state): State<AppState>,
358 Path(id): Path<String>,
359 Query(query): Query<RunsQuery>,
360) -> Result<Response, ApiError> {
361 if state.store.job(&id)?.is_none() {
362 return Err(ApiError::NotFound);
363 }
364 let limit = query.limit.unwrap_or(RUNS_DEFAULT).min(RUNS_MAX);
365 Ok(Json(state.store.runs(&id, limit)?).into_response())
366}
367
368async fn get_run(
369 State(state): State<AppState>,
370 Path((id, run)): Path<(String, String)>,
371) -> Result<Response, ApiError> {
372 let record = state.store.run(&id, &run)?.ok_or(ApiError::NotFound)?;
373 Ok(Json(record).into_response())
374}
375
376async fn get_document(
377 State(state): State<AppState>,
378 Path((id, run, file)): Path<(String, String, String)>,
379) -> Result<Response, ApiError> {
380 let bytes = state.store.document(&id, &run, &file)?.ok_or(ApiError::NotFound)?;
381 let format = match file.rsplit('.').next() {
382 Some("pdf") => Format::Pdf,
383 Some("xlsx") => Format::Xlsx,
384 _ => Format::Csv,
385 };
386 Ok(file_response(&file, format.mime(), bytes, None))
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn secrets_compare_exactly() {
395 assert!(same_secret(b"abc", b"abc"));
396 assert!(!same_secret(b"abc", b"abd"));
397 assert!(!same_secret(b"abc", b"abcd"));
398 assert!(!same_secret(b"", b"a"));
399 }
400
401 #[test]
402 fn download_names_cannot_break_the_header() {
403 let response = file_response("Relatório \"a\"\r\nX: y.pdf", "application/pdf", vec![], None);
404 let disposition = response.headers()[header::CONTENT_DISPOSITION].to_str().unwrap_or_default();
405 assert!(disposition.contains("filename=\"Relat_rio _a___X_ y.pdf\""), "{disposition}");
406 assert!(
407 disposition.contains("filename*=UTF-8''Relat%C3%B3rio%20%22a%22%0D%0AX%3A%20y.pdf"),
408 "{disposition}"
409 );
410 }
411}