Skip to main content

adminx_core/
resource.rs

1// adminx-core/src/resource.rs
2//
3// The framework-neutral Resource trait. Default CRUD is expressed purely in
4// terms of `ReqCtx` -> `ApiResponse` and the global `Storage`, so a single
5// implementation serves Actix, Axum, or any future adapter, over SQL or Mongo.
6
7use 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    // ===== REQUIRED =====
25    fn resource_name(&self) -> &'static str;
26    fn base_path(&self) -> &'static str;
27    /// Backing table (SQL) or collection (Mongo) name.
28    fn table_name(&self) -> &'static str;
29    fn clone_box(&self) -> Box<dyn Resource>;
30
31    // ===== CONFIG (defaults) =====
32    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    /// Extra id-scoped operations beyond CRUD, exposed at
49    /// `POST /{base}/{id}/action/{name}` and as buttons on the detail page.
50    fn custom_actions(&self) -> Vec<CustomAction> {
51        vec![]
52    }
53    /// Mass-assignment allow-list for create/update.
54    fn permit_keys(&self) -> Vec<&'static str> {
55        vec![]
56    }
57    /// Columns that must never be client-set.
58    fn readonly_keys(&self) -> Vec<&'static str> {
59        vec!["id", "created_at", "updated_at"]
60    }
61    /// Whether delete should soft-delete (set `deleted = true`).
62    fn soft_delete(&self) -> bool {
63        self.permit_keys().iter().any(|k| *k == "deleted")
64    }
65
66    /// Custom form layout for create/edit pages. Return `None` to derive fields
67    /// from `permit_keys()`. Shape: `{ "groups": [{ "fields": [...] }] }`.
68    fn form_structure(&self) -> Option<Value> {
69        None
70    }
71
72    /// Columns exposed as filters on the list page. Empty (the default) means no
73    /// filter bar is shown. Build entries with `FilterField::text/select/boolean`.
74    fn filterable_fields(&self) -> Vec<crate::filters::FilterField> {
75        Vec::new()
76    }
77
78    // ===== DEFAULT CRUD =====
79
80    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    // ===== HTML UI PAGES (served identically by every web adapter) =====
168
169    /// The form fields to render on create/edit. Derived from an explicit
170    /// `form_structure()` when present, otherwise from `permit_keys()`.
171    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        // `?download=json|csv` exports instead of rendering the table.
184        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        // Raw input values for repopulating the form (handles date-range
195        // from/to keys, which the clause list can't represent one-to-one).
196        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", &current_filters);
215        c.insert("has_filters", &(!filter_fields.is_empty()));
216        c.insert("has_active_filters", &(!opts.filters.is_empty()));
217        // Each row carries a delete form, so the page needs a CSRF token.
218        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        // The detail page renders a POST form per custom action.
277        ui::render_with_csrf(ctx, c, "view.html")
278    }
279
280    /// Handle a submitted create form; redirects to the list on success.
281    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    /// Handle a submitted edit form; redirects to the item view on success.
298    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    /// Handle a delete from the list UI; redirects back to the list. `csrf` is
320    /// the submitted hidden field, checked against the cookie before anything
321    /// is removed.
322    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    // ===== CUSTOM ACTIONS =====
338
339    /// Look up a custom action by name and run it (after auth + CSRF checks).
340    /// `csrf` is the submitted hidden field; the action button posts a form, so
341    /// it's guarded like the other mutating form handlers.
342    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    // ===== EXPORT =====
365
366    /// Export the resource's rows as `json` or `csv` (used by `?download=`).
367    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            // Export honours the active filters from the list query.
378            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    // ===== HELPERS =====
422
423    /// Whether the principal in `ctx` may perform `action` on this resource.
424    /// Delegates to the authorization seam: always allowed when auth is not
425    /// configured; a registered [`Authorizer`](crate::authz::Authorizer) decides
426    /// per action; otherwise the principal must hold one of `allowed_roles()`.
427    fn authorize(&self, ctx: &ReqCtx, action: Action<'_>) -> bool {
428        crate::authz::authorize(ctx, &self.allowed_roles(), self.base_path(), action)
429    }
430
431    /// Apply the permit/readonly/primary-key rules to an incoming JSON body,
432    /// returning the writable column map or a ready-made error response.
433    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                // Deny-list wins over allow-list; the primary key is never client-set.
442                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    // ===== MENU =====
457    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
474/// CSRF check shared by every mutating form handler. Returns `Some(reject)` when
475/// the submitted `_csrf` field is missing or doesn't match the cookie, and
476/// `None` when the post may proceed. Taking the token by value lets callers hand
477/// over the value they lifted out of the form map with `remove`.
478fn csrf_guard(ctx: &ReqCtx, submitted: Option<String>) -> Option<ApiResponse> {
479    // Mirrors `is_authorized`: with auth unconfigured the whole panel is public
480    // by design, so form posts stay frictionless too. Once auth is on, so is this.
481    if !crate::auth::is_configured() {
482        return None;
483    }
484    if crate::csrf::verify(ctx, submitted.as_deref()) {
485        None
486    } else {
487        // A 403 the browser can read. These posts are already SameSite-protected,
488        // so this fires mainly on a token that lapsed with the browser session —
489        // reloading the page mints a fresh one.
490        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}