Skip to main content

actix_admin/
model.rs

1use crate::view_model::{
2    ActixAdminFilterOperator, ActixAdminViewModelFilter, ActixAdminViewModelParams,
3};
4use crate::{ActixAdminError, ActixAdminErrorType, ActixAdminViewModelField};
5use actix_multipart::Multipart;
6use async_trait::async_trait;
7use chrono::{NaiveDate, NaiveDateTime};
8use futures_util::stream::StreamExt as _;
9use sea_orm::{DatabaseConnection, EntityTrait};
10use serde_derive::Serialize;
11use std::collections::HashMap;
12use std::fs::File;
13use std::io::Write;
14use std::path::PathBuf;
15use std::time::{SystemTime, UNIX_EPOCH};
16
17/// Maximum total upload size (default 25MB). Individual deployments should
18/// enforce their own limits via `actix_multipart::form::MultipartFormConfig`
19/// but this provides a defensive per-field cap so that a single field cannot
20/// exhaust memory in `create_from_payload`.
21pub const DEFAULT_MAX_FIELD_SIZE_BYTES: usize = 25 * 1024 * 1024;
22
23/// Sanitize a user-provided filename so that it can never traverse outside the
24/// upload directory. Strips path separators, `..`, control chars and NULs, and
25/// falls back to a timestamp-based name if the result would be empty.
26pub fn sanitize_upload_filename(raw: &str) -> String {
27    // Take just the last path component. Split manually on both '/' and '\\'
28    // so we correctly reject Windows-style traversal even on unix hosts.
29    let last = raw.rsplit(['/', '\\']).next().unwrap_or("");
30
31    let cleaned: String = sanitize_filename::sanitize_with_options(
32        last,
33        sanitize_filename::Options {
34            windows: true,
35            truncate: true,
36            replacement: "_",
37        },
38    )
39    .chars()
40    .filter(|c| !c.is_control() && *c != '\0')
41    .collect();
42    let cleaned = cleaned.trim_matches(|c: char| c == '.' || c.is_whitespace());
43    if cleaned.is_empty() {
44        let now = SystemTime::now()
45            .duration_since(UNIX_EPOCH)
46            .map(|d| d.as_millis())
47            .unwrap_or(0);
48        format!("upload_{now}")
49    } else {
50        cleaned.to_string()
51    }
52}
53
54#[async_trait]
55pub trait ActixAdminModelTrait {
56    async fn list_model(
57        db: &DatabaseConnection,
58        params: &ActixAdminViewModelParams,
59        filter_values: HashMap<String, Option<String>>,
60    ) -> Result<(Option<u64>, Vec<ActixAdminModel>), ActixAdminError>;
61    fn get_fields() -> &'static [ActixAdminViewModelField];
62    fn validate_model(model: &mut ActixAdminModel);
63    async fn load_foreign_keys(models: &mut [ActixAdminModel], db: &DatabaseConnection);
64}
65
66pub trait ActixAdminModelValidationTrait<T> {
67    fn validate(_model: &T) -> HashMap<String, String> {
68        HashMap::new()
69    }
70}
71
72/// A single filter registered on an entity via `ActixAdminModelFilterTrait`.
73///
74/// The `filter` closure receives the current query, the user-provided
75/// value (a plain `Option<String>`) and — when the filter opted into
76/// operator selection — the operator picked in the UI.
77pub struct ActixAdminModelFilter<E: EntityTrait> {
78    pub name: String,
79    pub filter_type: ActixAdminModelFilterType,
80    /// Underlying filter callback (value-only or operator-aware).
81    pub filter: FilterFn<E>,
82    pub values: Option<Vec<(String, String)>>,
83    pub foreign_key: Option<String>,
84    /// Operators the user may pick from. When empty, no operator selector is
85    /// rendered and operator-aware filters receive `None`.
86    pub operators: Vec<ActixAdminFilterOperator>,
87}
88
89/// Storage for the filter callback. Two shapes are supported so that simple
90/// value-only filters remain a one-liner, while operator-aware filters can
91/// react to the user's chosen comparator.
92#[allow(clippy::type_complexity)]
93pub enum FilterFn<E: EntityTrait> {
94    ValueOnly(fn(sea_orm::Select<E>, Option<String>) -> sea_orm::Select<E>),
95    WithOp(
96        fn(
97            sea_orm::Select<E>,
98            Option<String>,
99            Option<ActixAdminFilterOperator>,
100        ) -> sea_orm::Select<E>,
101    ),
102}
103
104impl<E: EntityTrait> FilterFn<E> {
105    /// Apply the underlying callback to `query`. Operator-only closures
106    /// receive `operator`; value-only closures ignore it.
107    pub fn apply(
108        &self,
109        query: sea_orm::Select<E>,
110        value: Option<String>,
111        operator: Option<ActixAdminFilterOperator>,
112    ) -> sea_orm::Select<E> {
113        match self {
114            FilterFn::ValueOnly(f) => f(query, value),
115            FilterFn::WithOp(f) => f(query, value, operator),
116        }
117    }
118}
119
120#[derive(Clone, Debug, Serialize)]
121pub enum ActixAdminModelFilterType {
122    Text,
123    SelectList,
124    Date,
125    DateTime,
126    Checkbox,
127    TomSelectSearch,
128}
129
130impl<E: EntityTrait> ActixAdminModelFilter<E> {
131    /// Build a value-only filter. The closure receives the current query
132    /// plus the user-typed value; operator information is not surfaced.
133    pub fn new(
134        name: impl Into<String>,
135        filter_type: ActixAdminModelFilterType,
136        filter: fn(sea_orm::Select<E>, Option<String>) -> sea_orm::Select<E>,
137    ) -> Self {
138        Self {
139            name: name.into(),
140            filter_type,
141            filter: FilterFn::ValueOnly(filter),
142            values: None,
143            foreign_key: None,
144            operators: Vec::new(),
145        }
146    }
147
148    /// Build an operator-aware filter. The closure additionally receives
149    /// the operator the user picked in the UI (if any).
150    pub fn with_op(
151        name: impl Into<String>,
152        filter_type: ActixAdminModelFilterType,
153        filter: fn(
154            sea_orm::Select<E>,
155            Option<String>,
156            Option<ActixAdminFilterOperator>,
157        ) -> sea_orm::Select<E>,
158    ) -> Self {
159        Self {
160            name: name.into(),
161            filter_type,
162            filter: FilterFn::WithOp(filter),
163            values: None,
164            foreign_key: None,
165            operators: Vec::new(),
166        }
167    }
168
169    pub fn with_operators(mut self, operators: Vec<ActixAdminFilterOperator>) -> Self {
170        self.operators = operators;
171        self
172    }
173
174    /// Replace the filter callback with an operator-aware one.
175    ///
176    /// Kept for backwards compatibility — new code should use
177    /// [`Self::with_op`] directly.
178    pub fn with_operator_filter(
179        mut self,
180        f: fn(
181            sea_orm::Select<E>,
182            Option<String>,
183            Option<ActixAdminFilterOperator>,
184        ) -> sea_orm::Select<E>,
185    ) -> Self {
186        self.filter = FilterFn::WithOp(f);
187        self
188    }
189
190    pub fn with_foreign_key(mut self, fk: impl Into<String>) -> Self {
191        self.foreign_key = Some(fk.into());
192        self
193    }
194
195    pub fn with_values(mut self, values: Vec<(String, String)>) -> Self {
196        self.values = Some(values);
197        self
198    }
199}
200
201#[async_trait]
202pub trait ActixAdminModelFilterTrait<E: EntityTrait> {
203    fn get_filter() -> Vec<ActixAdminModelFilter<E>> {
204        Vec::new()
205    }
206    async fn get_filter_values(
207        _filter: &ActixAdminModelFilter<E>,
208        _db: &DatabaseConnection,
209    ) -> Option<Vec<(String, String)>> {
210        None
211    }
212}
213
214impl<T: EntityTrait> From<ActixAdminModelFilter<T>> for ActixAdminViewModelFilter {
215    fn from(filter: ActixAdminModelFilter<T>) -> Self {
216        ActixAdminViewModelFilter {
217            name: filter.name,
218            value: None,
219            values: None,
220            filter_type: Some(filter.filter_type),
221            foreign_key: None,
222            operators: filter.operators,
223            operator: None,
224        }
225    }
226}
227
228#[derive(Clone, Debug, Serialize)]
229pub struct ActixAdminModel {
230    pub primary_key: Option<String>,
231    pub values: HashMap<String, String>,
232    pub fk_values: HashMap<String, String>,
233    pub errors: HashMap<String, String>,
234    pub custom_errors: HashMap<String, String>,
235    pub display_name: Option<String>,
236}
237
238impl ActixAdminModel {
239    pub fn create_empty() -> ActixAdminModel {
240        ActixAdminModel {
241            primary_key: None,
242            values: HashMap::new(),
243            errors: HashMap::new(),
244            custom_errors: HashMap::new(),
245            fk_values: HashMap::new(),
246            display_name: None,
247        }
248    }
249
250    pub async fn create_from_payload(
251        id: Option<String>,
252        mut payload: Multipart,
253        file_upload_folder: &str,
254    ) -> Result<ActixAdminModel, ActixAdminError> {
255        let mut hashmap = HashMap::<String, String>::new();
256
257        while let Some(item) = payload.next().await {
258            let mut field = item?;
259
260            let mut binary_data: Vec<u8> = Vec::new();
261            while let Some(chunk) = field.next().await {
262                let chunk = chunk?;
263                if binary_data.len().saturating_add(chunk.len()) > DEFAULT_MAX_FIELD_SIZE_BYTES {
264                    return Err(ActixAdminError::new(
265                        ActixAdminErrorType::UploadError,
266                        "Uploaded field exceeds maximum size",
267                    ));
268                }
269                binary_data.extend_from_slice(&chunk);
270            }
271
272            let content_disposition = match field.content_disposition() {
273                Some(cd) => cd.clone(),
274                None => continue,
275            };
276            let field_name = match content_disposition.get_name() {
277                Some(name) => name.to_string(),
278                None => continue,
279            };
280
281            if let Some(raw_filename) = content_disposition.get_filename() {
282                // Skip empty file uploads silently (browsers submit empty file fields).
283                if raw_filename.is_empty() && binary_data.is_empty() {
284                    continue;
285                }
286
287                let mut filename = sanitize_upload_filename(raw_filename);
288
289                let base = PathBuf::from(file_upload_folder);
290                let mut file_path = base.join(&filename);
291
292                // Avoid overwriting existing files by prefixing a timestamp.
293                if file_path.exists() {
294                    let ts = SystemTime::now()
295                        .duration_since(UNIX_EPOCH)
296                        .map(|d| d.as_millis())
297                        .unwrap_or(0);
298                    filename = format!("{ts}_{filename}");
299                    file_path = base.join(&filename);
300                }
301
302                // Defense in depth: reject any joined path that escapes the base.
303                let canonical_base = base.canonicalize().unwrap_or_else(|_| base.clone());
304                let parent = file_path.parent().unwrap_or(&base);
305                let canonical_parent = parent
306                    .canonicalize()
307                    .unwrap_or_else(|_| parent.to_path_buf());
308                if !canonical_parent.starts_with(&canonical_base) {
309                    return Err(ActixAdminError::new(
310                        ActixAdminErrorType::UploadError,
311                        "Uploaded filename resolves outside the upload directory",
312                    ));
313                }
314
315                let mut f = File::create(&file_path)?;
316                f.write_all(&binary_data)?;
317
318                hashmap.insert(field_name, filename);
319            } else if let Ok(res_string) = String::from_utf8(binary_data) {
320                hashmap.insert(field_name, res_string);
321            }
322        }
323
324        Ok(ActixAdminModel {
325            primary_key: id,
326            values: hashmap,
327            ..ActixAdminModel::create_empty()
328        })
329    }
330
331    pub fn get_value<T: std::str::FromStr>(
332        &self,
333        key: &str,
334        is_option_or_string: bool,
335        is_allowed_to_be_empty: bool,
336    ) -> Result<Option<T>, String> {
337        self.get_value_by_closure(key, is_option_or_string, is_allowed_to_be_empty, |val| {
338            val.parse::<T>()
339        })
340    }
341
342    pub fn get_datetime(
343        &self,
344        key: &str,
345        is_option_or_string: bool,
346        is_allowed_to_be_empty: bool,
347    ) -> Result<Option<NaiveDateTime>, String> {
348        self.get_value_by_closure(key, is_option_or_string, is_allowed_to_be_empty, |val| {
349            NaiveDateTime::parse_from_str(val, "%Y-%m-%dT%H:%M")
350        })
351    }
352
353    pub fn get_date(
354        &self,
355        key: &str,
356        is_option_or_string: bool,
357        is_allowed_to_be_empty: bool,
358    ) -> Result<Option<NaiveDate>, String> {
359        self.get_value_by_closure(key, is_option_or_string, is_allowed_to_be_empty, |val| {
360            NaiveDate::parse_from_str(val, "%Y-%m-%d")
361        })
362    }
363
364    pub fn get_bool(
365        &self,
366        key: &str,
367        is_option_or_string: bool,
368        is_allowed_to_be_empty: bool,
369    ) -> Result<Option<bool>, String> {
370        // A missing/invalid bool from a checkbox means "unchecked".
371        let val =
372            self.get_value_by_closure(key, is_option_or_string, is_allowed_to_be_empty, |val| {
373                Ok::<bool, std::str::ParseBoolError>(matches!(val.as_str(), "true" | "yes"))
374            });
375        Ok(val.unwrap_or(Some(false)))
376    }
377
378    fn get_value_by_closure<T: std::str::FromStr>(
379        &self,
380        key: &str,
381        is_option_or_string: bool,
382        is_allowed_to_be_empty: bool,
383        f: impl Fn(&String) -> Result<T, <T as std::str::FromStr>::Err>,
384    ) -> Result<Option<T>, String> {
385        match self.values.get(key) {
386            Some(val) => {
387                if val.is_empty() && is_option_or_string {
388                    return if is_allowed_to_be_empty {
389                        Ok(None)
390                    } else {
391                        Err("Cannot be empty".to_string())
392                    };
393                }
394                f(val).map(Some).map_err(|_| "Invalid Value".to_string())
395            }
396            None => match (is_option_or_string, is_allowed_to_be_empty) {
397                (true, true) => Ok(None),
398                (true, false) => Err("Cannot be empty".to_string()),
399                (false, _) => Err("Invalid Value".to_string()),
400            },
401        }
402    }
403
404    pub fn has_errors(&self) -> bool {
405        !self.errors.is_empty() || !self.custom_errors.is_empty()
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    fn model_with(key: &str, value: &str) -> ActixAdminModel {
414        let mut m = ActixAdminModel::create_empty();
415        m.values.insert(key.to_string(), value.to_string());
416        m
417    }
418
419    // ---- sanitize_upload_filename ----
420
421    #[test]
422    fn sanitize_strips_traversal() {
423        assert_eq!(sanitize_upload_filename("../../etc/passwd"), "passwd");
424        assert_eq!(sanitize_upload_filename("..\\..\\evil.exe"), "evil.exe");
425        assert_eq!(sanitize_upload_filename("/absolute/path/x.txt"), "x.txt");
426    }
427
428    #[test]
429    fn sanitize_removes_control_chars_and_nul() {
430        let out = sanitize_upload_filename("foo\0bar\nbaz.txt");
431        assert!(!out.contains('\0'));
432        assert!(!out.contains('\n'));
433        assert!(out.ends_with(".txt"));
434    }
435
436    #[test]
437    fn sanitize_empty_or_dotfile_falls_back() {
438        let out = sanitize_upload_filename("");
439        assert!(out.starts_with("upload_"));
440        let out = sanitize_upload_filename("...");
441        // Result must not resolve to a traversal or hidden dotfile.
442        assert!(!out.contains(".."));
443        assert!(!out.starts_with('.'));
444        assert!(!out.is_empty());
445    }
446
447    // ---- get_value matrix ----
448
449    #[test]
450    fn get_value_present_parses() {
451        let m = model_with("n", "42");
452        let v: Option<i32> = m.get_value("n", false, false).unwrap();
453        assert_eq!(v, Some(42));
454    }
455
456    #[test]
457    fn get_value_invalid_returns_err() {
458        let m = model_with("n", "abc");
459        let r: Result<Option<i32>, _> = m.get_value("n", false, false);
460        assert!(r.is_err());
461    }
462
463    #[test]
464    fn get_value_empty_string_option_allowed_returns_none() {
465        let m = model_with("s", "");
466        let r: Result<Option<String>, _> = m.get_value("s", true, true);
467        assert_eq!(r.unwrap(), None);
468    }
469
470    #[test]
471    fn get_value_empty_string_option_not_allowed_returns_err() {
472        let m = model_with("s", "");
473        let r: Result<Option<String>, _> = m.get_value("s", true, false);
474        assert!(r.is_err());
475    }
476
477    #[test]
478    fn get_value_missing_option_allowed_returns_none() {
479        let m = ActixAdminModel::create_empty();
480        let r: Result<Option<String>, _> = m.get_value("missing", true, true);
481        assert_eq!(r.unwrap(), None);
482    }
483
484    #[test]
485    fn get_value_missing_option_not_allowed_returns_err() {
486        let m = ActixAdminModel::create_empty();
487        let r: Result<Option<String>, _> = m.get_value("missing", true, false);
488        assert!(r.is_err());
489    }
490
491    #[test]
492    fn get_value_missing_non_option_returns_err() {
493        let m = ActixAdminModel::create_empty();
494        let r: Result<Option<i32>, _> = m.get_value("missing", false, true);
495        assert!(r.is_err());
496    }
497
498    // ---- get_bool ----
499
500    #[test]
501    fn get_bool_true_yes() {
502        let m = model_with("b", "true");
503        assert_eq!(m.get_bool("b", false, true).unwrap(), Some(true));
504        let m = model_with("b", "yes");
505        assert_eq!(m.get_bool("b", false, true).unwrap(), Some(true));
506    }
507
508    #[test]
509    fn get_bool_other_values_are_false() {
510        let m = model_with("b", "off");
511        assert_eq!(m.get_bool("b", false, true).unwrap(), Some(false));
512    }
513
514    #[test]
515    fn get_bool_missing_falls_back_to_false() {
516        let m = ActixAdminModel::create_empty();
517        assert_eq!(m.get_bool("b", false, false).unwrap(), Some(false));
518    }
519
520    // ---- get_date / get_datetime ----
521
522    #[test]
523    fn get_date_valid() {
524        let m = model_with("d", "2024-01-02");
525        let d = m.get_date("d", false, false).unwrap().unwrap();
526        assert_eq!(d, chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap());
527    }
528
529    #[test]
530    fn get_date_invalid() {
531        let m = model_with("d", "nope");
532        assert!(m.get_date("d", false, false).is_err());
533    }
534
535    #[test]
536    fn get_datetime_valid_local_form() {
537        let m = model_with("d", "2024-01-02T03:04");
538        let dt = m.get_datetime("d", false, false).unwrap().unwrap();
539        assert_eq!(
540            dt,
541            chrono::NaiveDate::from_ymd_opt(2024, 1, 2)
542                .unwrap()
543                .and_hms_opt(3, 4, 0)
544                .unwrap()
545        );
546    }
547
548    // ---- has_errors ----
549
550    #[test]
551    fn has_errors_reports_both_maps() {
552        let mut m = ActixAdminModel::create_empty();
553        assert!(!m.has_errors());
554        m.errors.insert("a".into(), "b".into());
555        assert!(m.has_errors());
556        m.errors.clear();
557        m.custom_errors.insert("a".into(), "b".into());
558        assert!(m.has_errors());
559    }
560}