1use crate::actions::CustomAction;
8use crate::authz::Action;
9use crate::crud;
10use crate::error::CoreError;
11use crate::export::{rows_to_csv, EXPORT_CAP};
12use crate::filters::parse_query;
13use crate::menu::{MenuAction, MenuItem};
14use crate::request::ReqCtx;
15use crate::response::{ApiBody, ApiResponse};
16use crate::storage::{storage, QueryOptions};
17use crate::ui;
18use async_trait::async_trait;
19use serde_json::{json, Map, Value};
20use std::collections::HashMap;
21use std::collections::HashSet;
22
23#[async_trait]
24pub trait Resource: Send + Sync {
25 fn resource_name(&self) -> &'static str;
27 fn base_path(&self) -> &'static str;
28 fn table_name(&self) -> &'static str;
30 fn clone_box(&self) -> Box<dyn Resource>;
31
32 fn primary_key(&self) -> &'static str {
34 "id"
35 }
36 fn menu_group(&self) -> Option<&'static str> {
37 None
38 }
39 fn menu(&self) -> &'static str {
40 self.resource_name()
41 }
42 fn allowed_roles(&self) -> Vec<String> {
43 vec!["admin".to_string()]
44 }
45 fn allowed_actions(&self) -> Option<Vec<MenuAction>> {
46 None
47 }
48
49 fn custom_actions(&self) -> Vec<CustomAction> {
52 vec![]
53 }
54 fn permit_keys(&self) -> Vec<&'static str> {
56 vec![]
57 }
58 fn readonly_keys(&self) -> Vec<&'static str> {
60 vec!["id", "created_at", "updated_at"]
61 }
62 fn soft_delete(&self) -> bool {
64 self.permit_keys().contains(&"deleted")
65 }
66
67 fn form_structure(&self) -> Option<Value> {
70 None
71 }
72
73 fn filterable_fields(&self) -> Vec<crate::filters::FilterField> {
76 Vec::new()
77 }
78
79 fn file_fields(&self) -> Vec<crate::attach::FileField> {
85 Vec::new()
86 }
87
88 fn search_fields(&self) -> Vec<&'static str> {
94 Vec::new()
95 }
96
97 async fn list(&self, ctx: &ReqCtx) -> ApiResponse {
100 if !self.authorize(ctx, Action::List) {
101 return CoreError::Unauthorized.into();
102 }
103 let opts = parse_query(&ctx.query);
104 match storage().list(self.table_name(), &opts).await {
105 Ok(page) => ApiResponse::ok(json!({
106 "data": page.rows,
107 "total": page.total,
108 "page": opts.page,
109 "per_page": opts.per_page,
110 })),
111 Err(e) => CoreError::from(e).into(),
112 }
113 }
114
115 async fn get(&self, ctx: &ReqCtx, id: &str) -> ApiResponse {
116 if !self.authorize(ctx, Action::Read) {
117 return CoreError::Unauthorized.into();
118 }
119 match storage().get(self.table_name(), self.primary_key(), id).await {
120 Ok(Some(row)) => ApiResponse::ok(row),
121 Ok(None) => CoreError::NotFound.into(),
122 Err(e) => CoreError::from(e).into(),
123 }
124 }
125
126 async fn create(&self, ctx: &ReqCtx, body: Value) -> ApiResponse {
130 crud::create(self, ctx, body).await
131 }
132
133 async fn update(&self, ctx: &ReqCtx, id: &str, body: Value) -> ApiResponse {
135 crud::update(self, ctx, id, body).await
136 }
137
138 async fn delete(&self, ctx: &ReqCtx, id: &str) -> ApiResponse {
140 crud::delete(self, ctx, id).await
141 }
142
143 fn form_fields(&self) -> Vec<Value> {
148 match self.form_structure() {
149 Some(structure) => ui::fields_from_structure(&structure),
150 None => ui::default_fields(&self.permit_keys()),
151 }
152 }
153
154 async fn list_page(&self, ctx: &ReqCtx) -> ApiResponse {
155 if !self.authorize(ctx, Action::List) {
156 return crate::auth::login_redirect(ctx);
157 }
158
159 let params: HashMap<String, String> =
161 serde_urlencoded::from_str(&ctx.query).unwrap_or_default();
162 if let Some(format) = params.get("download") {
163 return self.export(ctx, format).await;
164 }
165
166 let mut opts = parse_query(&ctx.query);
167 let filter_fields = self.filterable_fields();
168 opts.filters = crate::filters::parse_filters(&ctx.query, &filter_fields);
169
170 let current_filters = crate::filters::filter_values(&ctx.query, &filter_fields);
173
174 let searchable = crate::search::is_enabled() && !self.search_fields().is_empty();
178 let search_term = if searchable {
179 crate::search::query_term(ctx)
180 } else {
181 None
182 };
183
184 let page = if let Some(q) = &search_term {
185 let ids = crate::search::search_ids(self.base_path(), q, opts.per_page as usize).await;
186 let mut rows = Vec::new();
187 for id in &ids {
188 if let Ok(Some(row)) =
189 storage().get(self.table_name(), self.primary_key(), id).await
190 {
191 rows.push(row);
192 }
193 }
194 let total = rows.len() as u64;
195 crate::storage::ListPage { rows, total }
196 } else {
197 match storage().list(self.table_name(), &opts).await {
198 Ok(p) => p,
199 Err(e) => return CoreError::from(e).into(),
200 }
201 };
202 let headers = ui::derive_headers(&page.rows, self.primary_key());
203
204 let mut c = ui::base_context(ctx, self.resource_name());
205 c.insert("resource_name", self.resource_name());
206 c.insert("base_path", self.base_path());
207 c.insert("pk", self.primary_key());
208 c.insert("headers", &headers);
209 c.insert("rows", &page.rows);
210 c.insert("total", &page.total);
211 c.insert("page", &opts.page);
212 c.insert("per_page", &opts.per_page);
213 c.insert("filter_fields", &filter_fields);
214 c.insert("current_filters", ¤t_filters);
215 c.insert("has_filters", &(!filter_fields.is_empty()));
216 c.insert("has_active_filters", &(!opts.filters.is_empty()));
217 c.insert("searchable", &searchable);
218 c.insert("search_term", &search_term.clone().unwrap_or_default());
219 ui::render_with_csrf(ctx, c, "list.html")
221 }
222
223 async fn new_page(&self, ctx: &ReqCtx) -> ApiResponse {
224 if !self.authorize(ctx, Action::Create) {
225 return crate::auth::login_redirect(ctx);
226 }
227 let mut c = ui::base_context(ctx, self.resource_name());
228 c.insert("resource_name", self.resource_name());
229 c.insert("base_path", self.base_path());
230 c.insert("fields", &self.form_fields());
231 c.insert("is_edit", &false);
232 c.insert("record", &json!({}));
233 ui::render_with_csrf(ctx, c, "form.html")
234 }
235
236 async fn edit_page(&self, ctx: &ReqCtx, id: &str) -> ApiResponse {
237 if !self.authorize(ctx, Action::Update) {
238 return crate::auth::login_redirect(ctx);
239 }
240 let record = match storage().get(self.table_name(), self.primary_key(), id).await {
241 Ok(Some(r)) => r,
242 Ok(None) => return CoreError::NotFound.into(),
243 Err(e) => return CoreError::from(e).into(),
244 };
245 let mut c = ui::base_context(ctx, self.resource_name());
246 c.insert("resource_name", self.resource_name());
247 c.insert("base_path", self.base_path());
248 c.insert("fields", &self.form_fields());
249 c.insert("is_edit", &true);
250 c.insert("item_id", &id);
251 c.insert("record", &record);
252 ui::render_with_csrf(ctx, c, "form.html")
253 }
254
255 async fn view_page(&self, ctx: &ReqCtx, id: &str) -> ApiResponse {
256 if !self.authorize(ctx, Action::Read) {
257 return crate::auth::login_redirect(ctx);
258 }
259 let record = match storage().get(self.table_name(), self.primary_key(), id).await {
260 Ok(Some(r)) => r,
261 Ok(None) => return CoreError::NotFound.into(),
262 Err(e) => return CoreError::from(e).into(),
263 };
264 let headers = ui::derive_headers(std::slice::from_ref(&record), self.primary_key());
265 let actions: Vec<Value> = self
266 .custom_actions()
267 .iter()
268 .map(|a| json!({ "name": a.name, "label": a.display_label() }))
269 .collect();
270
271 let mut c = ui::base_context(ctx, self.resource_name());
272 c.insert("resource_name", self.resource_name());
273 c.insert("base_path", self.base_path());
274 c.insert("item_id", &id);
275 c.insert("headers", &headers);
276 c.insert("record", &record);
277 c.insert("actions", &actions);
278 c.insert("audit_enabled", &crate::audit::is_enabled());
280
281 let file_fields = self.file_fields();
285 let show_files = crate::attach::is_enabled() && !file_fields.is_empty();
286 if show_files {
287 let attached = crate::attach::list(self.base_path(), id).await;
288 let widgets: Vec<Value> = file_fields
289 .iter()
290 .map(|f| {
291 let current = attached.iter().find(|a| a.field == f.name);
292 json!({
293 "name": f.name,
294 "label": f.label,
295 "accept": f.accept,
296 "filename": current.map(|a| a.filename.clone()),
297 "byte_size": current.map(|a| a.byte_size),
298 "content_type": current.map(|a| a.content_type.clone()),
299 })
300 })
301 .collect();
302 c.insert("file_fields", &widgets);
303 }
304 c.insert("show_files", &show_files);
305
306 ui::render_with_csrf(ctx, c, "view.html")
308 }
309
310 async fn attach_file(
315 &self,
316 ctx: &ReqCtx,
317 id: &str,
318 field: &str,
319 csrf: Option<String>,
320 file: crate::attach::UploadedFile,
321 ) -> ApiResponse {
322 if !self.authorize(ctx, Action::Update) {
323 return crate::auth::login_redirect(ctx);
324 }
325 if let Some(reject) = csrf_guard(ctx, csrf) {
326 return reject;
327 }
328 if !self.file_fields().iter().any(|f| f.name == field) {
331 return CoreError::NotFound.into();
332 }
333 match crate::attach::store(self.base_path(), id, field, file).await {
334 Ok(_) => ApiResponse::redirect(format!(
335 "{}/{}/view/{}",
336 ctx.mount,
337 self.base_path(),
338 id
339 )),
340 Err(resp) => resp,
341 }
342 }
343
344 async fn serve_file(&self, ctx: &ReqCtx, id: &str, field: &str) -> ApiResponse {
348 if !self.authorize(ctx, Action::Read) {
349 return crate::auth::login_redirect(ctx);
350 }
351 let backend = match crate::attach::attachments() {
352 Some(b) => b,
353 None => return CoreError::NotFound.into(),
354 };
355 let meta = match backend.get(self.base_path(), id, field).await {
356 Ok(Some(m)) => m,
357 Ok(None) => return CoreError::NotFound.into(),
358 Err(e) => return CoreError::from(e).into(),
359 };
360 let bytes = match backend.read(&meta.storage_key).await {
361 Ok(b) => b,
362 Err(e) => return CoreError::from(e).into(),
363 };
364 ApiResponse::new(
365 200,
366 crate::response::ApiBody::Bytes {
367 content_type: meta.content_type,
368 data: bytes,
369 },
370 )
371 .with_header(
374 "Content-Disposition",
375 format!("inline; filename=\"{}\"", sanitize_filename(&meta.filename)),
376 )
377 }
378
379 async fn detach_file(
382 &self,
383 ctx: &ReqCtx,
384 id: &str,
385 field: &str,
386 csrf: Option<String>,
387 ) -> ApiResponse {
388 if !self.authorize(ctx, Action::Update) {
389 return crate::auth::login_redirect(ctx);
390 }
391 if let Some(reject) = csrf_guard(ctx, csrf) {
392 return reject;
393 }
394 if let Some(backend) = crate::attach::attachments() {
395 if let Err(e) = backend.delete(self.base_path(), id, field).await {
396 return CoreError::from(e).into();
397 }
398 }
399 ApiResponse::redirect(format!("{}/{}/view/{}", ctx.mount, self.base_path(), id))
400 }
401
402 async fn history_page(&self, ctx: &ReqCtx, id: &str) -> ApiResponse {
406 if !self.authorize(ctx, Action::Read) {
409 return crate::auth::login_redirect(ctx);
410 }
411 let versions = crate::audit::history(self.base_path(), id).await;
412
413 let mut c = ui::base_context(ctx, self.resource_name());
414 c.insert("resource_name", self.resource_name());
415 c.insert("base_path", self.base_path());
416 c.insert("item_id", &id);
417 c.insert("versions", &versions);
418 c.insert("audit_enabled", &crate::audit::is_enabled());
419 c.insert("limit", &crate::audit::HISTORY_LIMIT);
420 ui::render("history.html", &c)
422 }
423
424 async fn create_form(&self, ctx: &ReqCtx, mut form: HashMap<String, String>) -> ApiResponse {
426 if !self.authorize(ctx, Action::Create) {
427 return crate::auth::login_redirect(ctx);
428 }
429 if let Some(reject) = csrf_guard(ctx, form.remove(crate::csrf::FIELD_NAME)) {
430 return reject;
431 }
432 let body = ui::form_to_json(form);
433 let resp = self.create(ctx, body).await;
434 if resp.status < 300 {
435 ApiResponse::redirect(format!("{}/{}/list", ctx.mount, self.base_path()))
436 } else {
437 resp
438 }
439 }
440
441 async fn update_form(
443 &self,
444 ctx: &ReqCtx,
445 id: &str,
446 mut form: HashMap<String, String>,
447 ) -> ApiResponse {
448 if !self.authorize(ctx, Action::Update) {
449 return crate::auth::login_redirect(ctx);
450 }
451 if let Some(reject) = csrf_guard(ctx, form.remove(crate::csrf::FIELD_NAME)) {
452 return reject;
453 }
454 let body = ui::form_to_json(form);
455 let resp = self.update(ctx, id, body).await;
456 if resp.status < 300 {
457 ApiResponse::redirect(format!("{}/{}/view/{}", ctx.mount, self.base_path(), id))
458 } else {
459 resp
460 }
461 }
462
463 async fn delete_form(&self, ctx: &ReqCtx, id: &str, csrf: Option<String>) -> ApiResponse {
467 if !self.authorize(ctx, Action::Delete) {
468 return crate::auth::login_redirect(ctx);
469 }
470 if let Some(reject) = csrf_guard(ctx, csrf) {
471 return reject;
472 }
473 let resp = self.delete(ctx, id).await;
474 if resp.status < 300 {
475 ApiResponse::redirect(format!("{}/{}/list", ctx.mount, self.base_path()))
476 } else {
477 resp
478 }
479 }
480
481 async fn run_action(
487 &self,
488 ctx: &ReqCtx,
489 name: &str,
490 id: String,
491 body: Value,
492 csrf: Option<String>,
493 ) -> ApiResponse {
494 if !self.authorize(ctx, Action::Custom(name)) {
495 return CoreError::Unauthorized.into();
496 }
497 if let Some(reject) = csrf_guard(ctx, csrf) {
498 return reject;
499 }
500 for action in self.custom_actions() {
501 if action.name == name {
502 return (action.handler)(ctx.clone(), id, body).await;
503 }
504 }
505 CoreError::NotFound.into()
506 }
507
508 async fn export(&self, ctx: &ReqCtx, format: &str) -> ApiResponse {
512 if !self.authorize(ctx, Action::Export) {
513 return crate::auth::login_redirect(ctx);
514 }
515
516 let opts = QueryOptions {
517 page: 1,
518 per_page: EXPORT_CAP,
519 sort_by: None,
520 sort_desc: false,
521 filters: crate::filters::parse_filters(&ctx.query, &self.filterable_fields()),
523 };
524 let page = match storage().list(self.table_name(), &opts).await {
525 Ok(p) => p,
526 Err(e) => return CoreError::from(e).into(),
527 };
528
529 match format {
530 "json" => {
531 let data = serde_json::to_vec_pretty(&page.rows).unwrap_or_default();
532 ApiResponse::new(
533 200,
534 ApiBody::Bytes {
535 content_type: "application/json".to_string(),
536 data,
537 },
538 )
539 .with_header(
540 "Content-Disposition",
541 format!("attachment; filename=\"{}.json\"", self.base_path()),
542 )
543 }
544 "csv" => {
545 let headers = ui::derive_headers(&page.rows, self.primary_key());
546 let data = rows_to_csv(&headers, &page.rows).into_bytes();
547 ApiResponse::new(
548 200,
549 ApiBody::Bytes {
550 content_type: "text/csv".to_string(),
551 data,
552 },
553 )
554 .with_header(
555 "Content-Disposition",
556 format!("attachment; filename=\"{}.csv\"", self.base_path()),
557 )
558 }
559 other => {
560 CoreError::BadRequest(format!("unsupported export format: {other}")).into()
561 }
562 }
563 }
564
565 fn authorize(&self, ctx: &ReqCtx, action: Action<'_>) -> bool {
572 crate::authz::authorize(ctx, &self.allowed_roles(), self.base_path(), action)
573 }
574
575 fn filter_writable(&self, body: Value) -> Result<Map<String, Value>, ApiResponse> {
578 let permitted: HashSet<&str> = self.permit_keys().into_iter().collect();
579 let readonly: HashSet<&str> = self.readonly_keys().into_iter().collect();
580 let pk = self.primary_key();
581
582 let mut out = Map::new();
583 if let Value::Object(map) = body {
584 for (k, v) in map {
585 if permitted.contains(k.as_str()) && !readonly.contains(k.as_str()) && k != pk {
587 out.insert(k, v);
588 }
589 }
590 }
591
592 if out.is_empty() {
593 return Err(ApiResponse::error(CoreError::BadRequest(
594 "No permitted fields in payload".into(),
595 )));
596 }
597 Ok(out)
598 }
599
600 fn generate_menu(&self) -> Option<MenuItem> {
602 Some(MenuItem {
603 title: self.menu().to_string(),
604 path: self.base_path().to_string(),
605 icon: Some("table".to_string()),
606 order: Some(10),
607 children: None,
608 })
609 }
610}
611
612impl Clone for Box<dyn Resource> {
613 fn clone(&self) -> Self {
614 self.clone_box()
615 }
616}
617
618fn csrf_guard(ctx: &ReqCtx, submitted: Option<String>) -> Option<ApiResponse> {
623 if !crate::auth::is_configured() {
626 return None;
627 }
628 if crate::csrf::verify(ctx, submitted.as_deref()) {
629 None
630 } else {
631 Some(ApiResponse::html(
635 403,
636 "<h1>403 Forbidden</h1><p>Your session expired or the request could \
637 not be verified. Please reload the page and try again.</p>"
638 .to_string(),
639 ))
640 }
641}
642
643fn sanitize_filename(name: &str) -> String {
647 name.rsplit(['/', '\\'])
648 .next()
649 .unwrap_or(name)
650 .chars()
651 .filter(|c| !c.is_control() && *c != '"')
652 .take(255)
653 .collect()
654}
655
656#[cfg(test)]
657mod tests {
658 use super::sanitize_filename;
659
660 #[test]
661 fn filename_is_reduced_to_a_safe_basename() {
662 assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
663 assert_eq!(sanitize_filename(r"C:\Windows\evil.exe"), "evil.exe");
664 assert_eq!(sanitize_filename("photo.png"), "photo.png");
665 }
666
667 #[test]
668 fn header_breaking_characters_are_dropped() {
669 assert_eq!(
670 sanitize_filename("a\"b\r\nContent-Length: 0.png"),
671 "abContent-Length: 0.png"
672 );
673 }
674}