1use crate::actions::CustomAction;
8use crate::authz::Action;
9use crate::error::CoreError;
10use crate::export::{rows_to_csv, EXPORT_CAP};
11use crate::filters::parse_query;
12use crate::menu::{MenuAction, MenuItem};
13use crate::request::ReqCtx;
14use crate::response::{ApiBody, ApiResponse};
15use crate::storage::{storage, CreateOutcome, QueryOptions};
16use crate::ui;
17use async_trait::async_trait;
18use serde_json::{json, Map, Value};
19use std::collections::HashMap;
20use std::collections::HashSet;
21
22#[async_trait]
23pub trait Resource: Send + Sync {
24 fn resource_name(&self) -> &'static str;
26 fn base_path(&self) -> &'static str;
27 fn table_name(&self) -> &'static str;
29 fn clone_box(&self) -> Box<dyn Resource>;
30
31 fn primary_key(&self) -> &'static str {
33 "id"
34 }
35 fn menu_group(&self) -> Option<&'static str> {
36 None
37 }
38 fn menu(&self) -> &'static str {
39 self.resource_name()
40 }
41 fn allowed_roles(&self) -> Vec<String> {
42 vec!["admin".to_string()]
43 }
44 fn allowed_actions(&self) -> Option<Vec<MenuAction>> {
45 None
46 }
47
48 fn custom_actions(&self) -> Vec<CustomAction> {
51 vec![]
52 }
53 fn permit_keys(&self) -> Vec<&'static str> {
55 vec![]
56 }
57 fn readonly_keys(&self) -> Vec<&'static str> {
59 vec!["id", "created_at", "updated_at"]
60 }
61 fn soft_delete(&self) -> bool {
63 self.permit_keys().contains(&"deleted")
64 }
65
66 fn form_structure(&self) -> Option<Value> {
69 None
70 }
71
72 fn filterable_fields(&self) -> Vec<crate::filters::FilterField> {
75 Vec::new()
76 }
77
78 async fn list(&self, ctx: &ReqCtx) -> ApiResponse {
81 if !self.authorize(ctx, Action::List) {
82 return CoreError::Unauthorized.into();
83 }
84 let opts = parse_query(&ctx.query);
85 match storage().list(self.table_name(), &opts).await {
86 Ok(page) => ApiResponse::ok(json!({
87 "data": page.rows,
88 "total": page.total,
89 "page": opts.page,
90 "per_page": opts.per_page,
91 })),
92 Err(e) => CoreError::from(e).into(),
93 }
94 }
95
96 async fn get(&self, ctx: &ReqCtx, id: &str) -> ApiResponse {
97 if !self.authorize(ctx, Action::Read) {
98 return CoreError::Unauthorized.into();
99 }
100 match storage().get(self.table_name(), self.primary_key(), id).await {
101 Ok(Some(row)) => ApiResponse::ok(row),
102 Ok(None) => CoreError::NotFound.into(),
103 Err(e) => CoreError::from(e).into(),
104 }
105 }
106
107 async fn create(&self, ctx: &ReqCtx, body: Value) -> ApiResponse {
108 if !self.authorize(ctx, Action::Create) {
109 return CoreError::Unauthorized.into();
110 }
111 let data = match self.filter_writable(body) {
112 Ok(d) => d,
113 Err(resp) => return resp,
114 };
115 match storage().create(self.table_name(), data).await {
116 Ok(CreateOutcome { last_insert_id }) => ApiResponse::created(json!({
117 "success": true,
118 "message": format!("{} created successfully", self.resource_name()),
119 "last_insert_id": last_insert_id,
120 })),
121 Err(e) => CoreError::from(e).into(),
122 }
123 }
124
125 async fn update(&self, ctx: &ReqCtx, id: &str, body: Value) -> ApiResponse {
126 if !self.authorize(ctx, Action::Update) {
127 return CoreError::Unauthorized.into();
128 }
129 let data = match self.filter_writable(body) {
130 Ok(d) => d,
131 Err(resp) => return resp,
132 };
133 match storage()
134 .update(self.table_name(), self.primary_key(), id, data)
135 .await
136 {
137 Ok(n) if n > 0 => ApiResponse::ok(json!({
138 "success": true,
139 "message": format!("{} updated successfully", self.resource_name()),
140 "modified_count": n,
141 })),
142 Ok(_) => CoreError::NotFound.into(),
143 Err(e) => CoreError::from(e).into(),
144 }
145 }
146
147 async fn delete(&self, ctx: &ReqCtx, id: &str) -> ApiResponse {
148 if !self.authorize(ctx, Action::Delete) {
149 return CoreError::Unauthorized.into();
150 }
151 let soft = self.soft_delete();
152 match storage()
153 .delete(self.table_name(), self.primary_key(), id, soft)
154 .await
155 {
156 Ok(n) if n > 0 => ApiResponse::ok(json!({
157 "success": true,
158 "message": format!("{} deleted successfully", self.resource_name()),
159 "soft_delete": soft,
160 "affected": n,
161 })),
162 Ok(_) => CoreError::NotFound.into(),
163 Err(e) => CoreError::from(e).into(),
164 }
165 }
166
167 fn form_fields(&self) -> Vec<Value> {
172 match self.form_structure() {
173 Some(structure) => ui::fields_from_structure(&structure),
174 None => ui::default_fields(&self.permit_keys()),
175 }
176 }
177
178 async fn list_page(&self, ctx: &ReqCtx) -> ApiResponse {
179 if !self.authorize(ctx, Action::List) {
180 return crate::auth::login_redirect(ctx);
181 }
182
183 let params: HashMap<String, String> =
185 serde_urlencoded::from_str(&ctx.query).unwrap_or_default();
186 if let Some(format) = params.get("download") {
187 return self.export(ctx, format).await;
188 }
189
190 let mut opts = parse_query(&ctx.query);
191 let filter_fields = self.filterable_fields();
192 opts.filters = crate::filters::parse_filters(&ctx.query, &filter_fields);
193
194 let current_filters = crate::filters::filter_values(&ctx.query, &filter_fields);
197
198 let page = match storage().list(self.table_name(), &opts).await {
199 Ok(p) => p,
200 Err(e) => return CoreError::from(e).into(),
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 ui::render_with_csrf(ctx, c, "list.html")
219 }
220
221 async fn new_page(&self, ctx: &ReqCtx) -> ApiResponse {
222 if !self.authorize(ctx, Action::Create) {
223 return crate::auth::login_redirect(ctx);
224 }
225 let mut c = ui::base_context(ctx, self.resource_name());
226 c.insert("resource_name", self.resource_name());
227 c.insert("base_path", self.base_path());
228 c.insert("fields", &self.form_fields());
229 c.insert("is_edit", &false);
230 c.insert("record", &json!({}));
231 ui::render_with_csrf(ctx, c, "form.html")
232 }
233
234 async fn edit_page(&self, ctx: &ReqCtx, id: &str) -> ApiResponse {
235 if !self.authorize(ctx, Action::Update) {
236 return crate::auth::login_redirect(ctx);
237 }
238 let record = match storage().get(self.table_name(), self.primary_key(), id).await {
239 Ok(Some(r)) => r,
240 Ok(None) => return CoreError::NotFound.into(),
241 Err(e) => return CoreError::from(e).into(),
242 };
243 let mut c = ui::base_context(ctx, self.resource_name());
244 c.insert("resource_name", self.resource_name());
245 c.insert("base_path", self.base_path());
246 c.insert("fields", &self.form_fields());
247 c.insert("is_edit", &true);
248 c.insert("item_id", &id);
249 c.insert("record", &record);
250 ui::render_with_csrf(ctx, c, "form.html")
251 }
252
253 async fn view_page(&self, ctx: &ReqCtx, id: &str) -> ApiResponse {
254 if !self.authorize(ctx, Action::Read) {
255 return crate::auth::login_redirect(ctx);
256 }
257 let record = match storage().get(self.table_name(), self.primary_key(), id).await {
258 Ok(Some(r)) => r,
259 Ok(None) => return CoreError::NotFound.into(),
260 Err(e) => return CoreError::from(e).into(),
261 };
262 let headers = ui::derive_headers(std::slice::from_ref(&record), self.primary_key());
263 let actions: Vec<Value> = self
264 .custom_actions()
265 .iter()
266 .map(|a| json!({ "name": a.name, "label": a.display_label() }))
267 .collect();
268
269 let mut c = ui::base_context(ctx, self.resource_name());
270 c.insert("resource_name", self.resource_name());
271 c.insert("base_path", self.base_path());
272 c.insert("item_id", &id);
273 c.insert("headers", &headers);
274 c.insert("record", &record);
275 c.insert("actions", &actions);
276 ui::render_with_csrf(ctx, c, "view.html")
278 }
279
280 async fn create_form(&self, ctx: &ReqCtx, mut form: HashMap<String, String>) -> ApiResponse {
282 if !self.authorize(ctx, Action::Create) {
283 return crate::auth::login_redirect(ctx);
284 }
285 if let Some(reject) = csrf_guard(ctx, form.remove(crate::csrf::FIELD_NAME)) {
286 return reject;
287 }
288 let body = ui::form_to_json(form);
289 let resp = self.create(ctx, body).await;
290 if resp.status < 300 {
291 ApiResponse::redirect(format!("{}/{}/list", ctx.mount, self.base_path()))
292 } else {
293 resp
294 }
295 }
296
297 async fn update_form(
299 &self,
300 ctx: &ReqCtx,
301 id: &str,
302 mut form: HashMap<String, String>,
303 ) -> ApiResponse {
304 if !self.authorize(ctx, Action::Update) {
305 return crate::auth::login_redirect(ctx);
306 }
307 if let Some(reject) = csrf_guard(ctx, form.remove(crate::csrf::FIELD_NAME)) {
308 return reject;
309 }
310 let body = ui::form_to_json(form);
311 let resp = self.update(ctx, id, body).await;
312 if resp.status < 300 {
313 ApiResponse::redirect(format!("{}/{}/view/{}", ctx.mount, self.base_path(), id))
314 } else {
315 resp
316 }
317 }
318
319 async fn delete_form(&self, ctx: &ReqCtx, id: &str, csrf: Option<String>) -> ApiResponse {
323 if !self.authorize(ctx, Action::Delete) {
324 return crate::auth::login_redirect(ctx);
325 }
326 if let Some(reject) = csrf_guard(ctx, csrf) {
327 return reject;
328 }
329 let resp = self.delete(ctx, id).await;
330 if resp.status < 300 {
331 ApiResponse::redirect(format!("{}/{}/list", ctx.mount, self.base_path()))
332 } else {
333 resp
334 }
335 }
336
337 async fn run_action(
343 &self,
344 ctx: &ReqCtx,
345 name: &str,
346 id: String,
347 body: Value,
348 csrf: Option<String>,
349 ) -> ApiResponse {
350 if !self.authorize(ctx, Action::Custom(name)) {
351 return CoreError::Unauthorized.into();
352 }
353 if let Some(reject) = csrf_guard(ctx, csrf) {
354 return reject;
355 }
356 for action in self.custom_actions() {
357 if action.name == name {
358 return (action.handler)(ctx.clone(), id, body).await;
359 }
360 }
361 CoreError::NotFound.into()
362 }
363
364 async fn export(&self, ctx: &ReqCtx, format: &str) -> ApiResponse {
368 if !self.authorize(ctx, Action::Export) {
369 return crate::auth::login_redirect(ctx);
370 }
371
372 let opts = QueryOptions {
373 page: 1,
374 per_page: EXPORT_CAP,
375 sort_by: None,
376 sort_desc: false,
377 filters: crate::filters::parse_filters(&ctx.query, &self.filterable_fields()),
379 };
380 let page = match storage().list(self.table_name(), &opts).await {
381 Ok(p) => p,
382 Err(e) => return CoreError::from(e).into(),
383 };
384
385 match format {
386 "json" => {
387 let data = serde_json::to_vec_pretty(&page.rows).unwrap_or_default();
388 ApiResponse::new(
389 200,
390 ApiBody::Bytes {
391 content_type: "application/json".to_string(),
392 data,
393 },
394 )
395 .with_header(
396 "Content-Disposition",
397 format!("attachment; filename=\"{}.json\"", self.base_path()),
398 )
399 }
400 "csv" => {
401 let headers = ui::derive_headers(&page.rows, self.primary_key());
402 let data = rows_to_csv(&headers, &page.rows).into_bytes();
403 ApiResponse::new(
404 200,
405 ApiBody::Bytes {
406 content_type: "text/csv".to_string(),
407 data,
408 },
409 )
410 .with_header(
411 "Content-Disposition",
412 format!("attachment; filename=\"{}.csv\"", self.base_path()),
413 )
414 }
415 other => {
416 CoreError::BadRequest(format!("unsupported export format: {other}")).into()
417 }
418 }
419 }
420
421 fn authorize(&self, ctx: &ReqCtx, action: Action<'_>) -> bool {
428 crate::authz::authorize(ctx, &self.allowed_roles(), self.base_path(), action)
429 }
430
431 fn filter_writable(&self, body: Value) -> Result<Map<String, Value>, ApiResponse> {
434 let permitted: HashSet<&str> = self.permit_keys().into_iter().collect();
435 let readonly: HashSet<&str> = self.readonly_keys().into_iter().collect();
436 let pk = self.primary_key();
437
438 let mut out = Map::new();
439 if let Value::Object(map) = body {
440 for (k, v) in map {
441 if permitted.contains(k.as_str()) && !readonly.contains(k.as_str()) && k != pk {
443 out.insert(k, v);
444 }
445 }
446 }
447
448 if out.is_empty() {
449 return Err(ApiResponse::error(CoreError::BadRequest(
450 "No permitted fields in payload".into(),
451 )));
452 }
453 Ok(out)
454 }
455
456 fn generate_menu(&self) -> Option<MenuItem> {
458 Some(MenuItem {
459 title: self.menu().to_string(),
460 path: self.base_path().to_string(),
461 icon: Some("table".to_string()),
462 order: Some(10),
463 children: None,
464 })
465 }
466}
467
468impl Clone for Box<dyn Resource> {
469 fn clone(&self) -> Self {
470 self.clone_box()
471 }
472}
473
474fn csrf_guard(ctx: &ReqCtx, submitted: Option<String>) -> Option<ApiResponse> {
479 if !crate::auth::is_configured() {
482 return None;
483 }
484 if crate::csrf::verify(ctx, submitted.as_deref()) {
485 None
486 } else {
487 Some(ApiResponse::html(
491 403,
492 "<h1>403 Forbidden</h1><p>Your session expired or the request could \
493 not be verified. Please reload the page and try again.</p>"
494 .to_string(),
495 ))
496 }
497}