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
17pub const DEFAULT_MAX_FIELD_SIZE_BYTES: usize = 25 * 1024 * 1024;
22
23pub fn sanitize_upload_filename(raw: &str) -> String {
27 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
72pub struct ActixAdminModelFilter<E: EntityTrait> {
78 pub name: String,
79 pub filter_type: ActixAdminModelFilterType,
80 pub filter: FilterFn<E>,
82 pub values: Option<Vec<(String, String)>>,
83 pub foreign_key: Option<String>,
84 pub operators: Vec<ActixAdminFilterOperator>,
87}
88
89#[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 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 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 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 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 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 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 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 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 #[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 assert!(!out.contains(".."));
443 assert!(!out.starts_with('.'));
444 assert!(!out.is_empty());
445 }
446
447 #[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 #[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 #[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 #[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}