Skip to main content

actix_admin/routes/
helpers.rs

1use actix_session::Session;
2use serde_derive::Deserialize;
3use tera::Context;
4
5use crate::{prelude::*, ActixAdminNotification};
6use actix_web::{error, Error, HttpRequest, HttpResponse};
7
8use super::{Params, DEFAULT_ENTITIES_PER_PAGE};
9
10/// The set of gated actions on an entity view. Used by [`user_can_perform`].
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum AdminAction {
13    /// Access the list, show and (read-only) detail routes.
14    View,
15    Create,
16    Edit,
17    Delete,
18    /// Export the list as CSV / other formats.
19    Export,
20    /// Trigger a custom bulk action.
21    BulkAction,
22}
23
24/// Bundle of state every entity-scoped admin route needs: the parent
25/// [`ActixAdmin`] registry, the resolved [`ActixAdminViewModel`], the
26/// entity name (owned to avoid borrow gymnastics) and the tenant reference
27/// resolved from the current session.
28pub struct RouteCtx<'a> {
29    pub actix_admin: &'a ActixAdmin,
30    pub view_model: &'a ActixAdminViewModel,
31    pub entity_name: String,
32    pub tenant_ref: Option<i32>,
33}
34
35/// Options controlling the standard route prologue behavior.
36#[derive(Clone, Copy)]
37pub struct RoutePrelude {
38    pub action: AdminAction,
39    /// Verify the CSRF token before proceeding. Enable on
40    /// state-changing routes (POST/DELETE/PUT).
41    pub verify_csrf: bool,
42    /// When rendering the unauthorized template, set `render_partial = true`
43    /// so HTMX call sites swap only the content block.
44    pub partial_unauth: bool,
45    /// Populate auth context on the unauthorized-template render.
46    pub with_auth_context: bool,
47}
48
49impl RoutePrelude {
50    pub const fn view() -> Self {
51        Self {
52            action: AdminAction::View,
53            verify_csrf: false,
54            partial_unauth: false,
55            with_auth_context: true,
56        }
57    }
58    pub const fn export() -> Self {
59        Self {
60            action: AdminAction::Export,
61            verify_csrf: false,
62            partial_unauth: false,
63            with_auth_context: false,
64        }
65    }
66    pub const fn create() -> Self {
67        Self {
68            action: AdminAction::Create,
69            verify_csrf: false,
70            partial_unauth: false,
71            with_auth_context: true,
72        }
73    }
74    pub const fn edit() -> Self {
75        Self {
76            action: AdminAction::Edit,
77            verify_csrf: false,
78            partial_unauth: false,
79            with_auth_context: true,
80        }
81    }
82    pub const fn write(action: AdminAction) -> Self {
83        Self {
84            action,
85            verify_csrf: true,
86            partial_unauth: true,
87            with_auth_context: false,
88        }
89    }
90    pub const fn bulk() -> Self {
91        Self {
92            action: AdminAction::BulkAction,
93            verify_csrf: true,
94            partial_unauth: false,
95            with_auth_context: false,
96        }
97    }
98}
99
100/// Run the standard prologue for a route handler generic over an
101/// [`ActixAdminViewModelTrait`] entity `E`. Resolves the view model,
102/// checks permissions, verifies CSRF (if requested), computes the tenant
103/// reference, and returns a [`RouteCtx`] ready to be used by the handler.
104///
105/// Returns:
106/// * `Ok(Ok(ctx))`   — proceed with `ctx`
107/// * `Ok(Err(resp))` — return `resp` directly (unauthorized rendered
108///   template)
109/// * `Err(err)`      — propagate `err` (CSRF violation, missing view model)
110pub fn begin_route<'a, E: ActixAdminViewModelTrait>(
111    session: &Session,
112    req: &HttpRequest,
113    actix_admin: &'a ActixAdmin,
114    opts: RoutePrelude,
115) -> Result<Result<RouteCtx<'a>, HttpResponse>, Error> {
116    let entity_name = E::get_entity_name();
117    let view_model = view_model_or_500(actix_admin, &entity_name)?;
118
119    if !user_can_perform(session, actix_admin, view_model, opts.action) {
120        let mut ctx = Context::new();
121        if opts.with_auth_context {
122            add_auth_context(session, actix_admin, &mut ctx);
123        }
124        if opts.partial_unauth {
125            ctx.insert("render_partial", &true);
126        }
127        // render_unauthorized only fails when the response builder itself
128        // fails, which cannot happen with this small body.
129        let resp = render_unauthorized(&ctx, actix_admin)?;
130        return Ok(Err(resp));
131    }
132
133    if opts.verify_csrf {
134        crate::csrf::verify_csrf(actix_admin, session, req)?;
135    }
136
137    let tenant_ref = actix_admin
138        .configuration
139        .user_tenant_ref
140        .and_then(|f| f(session));
141
142    Ok(Ok(RouteCtx {
143        actix_admin,
144        view_model,
145        entity_name,
146        tenant_ref,
147    }))
148}
149
150/// Convenience macro: unwrap the double-Result returned by [`begin_route`],
151/// returning early on either the propagated error or the pre-built response.
152#[macro_export]
153macro_rules! admin_prelude {
154    ($session:expr, $req:expr, $actix_admin:expr, $opts:expr, $entity:ty) => {{
155        match $crate::routes::begin_route::<$entity>($session, $req, $actix_admin, $opts)? {
156            Ok(ctx) => ctx,
157            Err(resp) => return Ok(resp),
158        }
159    }};
160}
161
162pub fn add_auth_context(session: &Session, actix_admin: &ActixAdmin, ctx: &mut Context) {
163    let cfg = &actix_admin.configuration;
164    ctx.insert("enable_auth", &cfg.enable_auth);
165    ctx.insert("custom_css_paths", &cfg.custom_css_paths);
166    ctx.insert("custom_js_paths", &cfg.custom_js_paths);
167    ctx.insert("navbar_title", &cfg.navbar_title);
168    ctx.insert("base_path", &cfg.base_path);
169    ctx.insert("support_path", &actix_admin.support_path.as_ref());
170    ctx.insert("enable_csrf", &cfg.enable_csrf);
171    // Always insert a (possibly empty) csrf_token so templates can reference
172    // it unconditionally without checking `enable_csrf`.
173    let mut token_value = String::new();
174    if cfg.enable_csrf {
175        token_value = csrf_token_for(session).unwrap_or_default();
176    }
177    ctx.insert("csrf_token", &token_value);
178    if cfg.enable_auth {
179        let func = cfg.user_is_logged_in.unwrap();
180        ctx.insert("user_is_logged_in", &func(session));
181        ctx.insert("login_link", cfg.login_link.as_deref().unwrap_or(""));
182        ctx.insert("logout_link", cfg.logout_link.as_deref().unwrap_or(""));
183    }
184}
185
186pub fn user_can_access_page(
187    session: &Session,
188    actix_admin: &ActixAdmin,
189    view_model: &ActixAdminViewModel,
190) -> bool {
191    let cfg = &actix_admin.configuration;
192    match (
193        cfg.enable_auth,
194        cfg.user_is_logged_in,
195        view_model.user_can_access,
196    ) {
197        (true, Some(auth), Some(vm_access)) => auth(session) && vm_access(session),
198        (true, Some(auth), None) => auth(session),
199        _ => !cfg.enable_auth,
200    }
201}
202
203/// True iff the user can perform `action` on `view_model`. Always requires
204/// top-level page access via [`user_can_access_page`] first.
205pub fn user_can_perform(
206    session: &Session,
207    actix_admin: &ActixAdmin,
208    view_model: &ActixAdminViewModel,
209    action: AdminAction,
210) -> bool {
211    if !user_can_access_page(session, actix_admin, view_model) {
212        return false;
213    }
214    let hook = match action {
215        AdminAction::View => view_model.user_can_view_details,
216        AdminAction::Create => view_model.user_can_create,
217        AdminAction::Edit => view_model.user_can_edit,
218        AdminAction::Delete => view_model.user_can_delete,
219        AdminAction::Export => view_model.user_can_export,
220        // Bulk actions inherit the top-level page permission by default;
221        // fine-grained gating happens inside individual action handlers.
222        AdminAction::BulkAction => return true,
223    };
224    match hook {
225        Some(f) => f(session),
226        None => true,
227    }
228}
229
230/// Same as [`user_can_perform`] but returns a ready-made 403 response when
231/// the user is denied. Convenience for route handlers.
232pub fn forbid_if_denied(
233    session: &Session,
234    actix_admin: &ActixAdmin,
235    view_model: &ActixAdminViewModel,
236    action: AdminAction,
237) -> Option<HttpResponse> {
238    if user_can_perform(session, actix_admin, view_model, action) {
239        None
240    } else {
241        Some(HttpResponse::Forbidden().finish())
242    }
243}
244
245pub fn render_unauthorized(ctx: &Context, actix_admin: &ActixAdmin) -> Result<HttpResponse, Error> {
246    // Fall back to a short plain-text body if the template render fails
247    // (e.g. the caller only supplied a partial context). Returning 500
248    // here would leak an internal error to a user who simply lacks a
249    // permission.
250    let body = actix_admin
251        .tera
252        .render("unauthorized.html", ctx)
253        .unwrap_or_else(|_| String::from("Forbidden"));
254    Ok(HttpResponse::Forbidden()
255        .content_type("text/html")
256        .body(body))
257}
258
259/// Render `template_name` with `ctx`, falling back to rendering only the
260/// `content` block when the context has `render_partial == true`.
261///
262/// Tera 2 no longer allows `{% block %}` inside `{% if %}`, so the partial
263/// vs. full page decision is made here in Rust instead of inside `base.html`.
264pub fn render_template(
265    tera: &tera::Tera,
266    template_name: &str,
267    ctx: &Context,
268) -> Result<String, tera::Error> {
269    let render_partial = ctx
270        .get("render_partial")
271        .and_then(|v| v.as_bool())
272        .unwrap_or(false);
273    if render_partial {
274        tera.render_block(template_name, "content", ctx)
275    } else {
276        tera.render(template_name, ctx)
277    }
278}
279
280/// Look up the view model for an entity name. Returns 500 rather than panicking
281/// if it is missing (should be impossible in normal operation).
282pub fn view_model_or_500<'a>(
283    actix_admin: &'a ActixAdmin,
284    entity_name: &str,
285) -> Result<&'a ActixAdminViewModel, Error> {
286    actix_admin.view_models.get(entity_name).ok_or_else(|| {
287        error::ErrorInternalServerError(format!(
288            "View model for entity '{entity_name}' is not registered"
289        ))
290    })
291}
292
293/// Shared renderer for the create-and-edit form pages. Called from three
294/// places: create_get, edit_get, and create_or_edit_post (on validation
295/// error or DB failure). Picks the inline template when `is_inline` is set
296/// and the model has a primary key (i.e. we're editing an existing row).
297#[allow(clippy::too_many_arguments)]
298pub async fn render_create_or_edit_form<E: ActixAdminViewModelTrait>(
299    session: &Session,
300    req: HttpRequest,
301    actix_admin: &ActixAdmin,
302    view_model: &ActixAdminViewModel,
303    db: &sea_orm::DatabaseConnection,
304    entity_name: String,
305    model: &ActixAdminModel,
306    tenant_ref: Option<i32>,
307    notifications: Vec<ActixAdminNotification>,
308    is_inline: bool,
309    status: actix_web::http::StatusCode,
310) -> Result<HttpResponse, Error> {
311    let mut ctx = Context::new();
312    add_auth_context(session, actix_admin, &mut ctx);
313
314    let params = Params::from_query(req.query_string());
315    let search_params = SearchParams::from_params(&params, view_model);
316
317    ctx.insert(
318        "select_lists",
319        &E::get_select_lists(db, tenant_ref)
320            .await
321            .map_err(actix_web::error::ErrorInternalServerError)?,
322    );
323    ctx.insert("model", model);
324
325    add_default_context_with_session(
326        &mut ctx,
327        req,
328        view_model,
329        entity_name,
330        actix_admin,
331        notifications,
332        &search_params,
333        Some(session),
334    );
335
336    let template_path = if is_inline && model.primary_key.is_some() {
337        "create_or_edit/inline.html"
338    } else {
339        "create_or_edit.html"
340    };
341    let body = render_template(&actix_admin.tera, template_path, &ctx)
342        .map_err(actix_web::error::ErrorInternalServerError)?;
343    Ok(actix_web::HttpResponse::build(status)
344        .content_type("text/html")
345        .body(body))
346}
347
348/// Validate that `sort_by` refers to a real, non-hidden field on the view model.
349/// Returns Ok(sort_by) or a 400 error.
350pub fn validate_sort_by(view_model: &ActixAdminViewModel, sort_by: &str) -> Result<(), Error> {
351    if sort_by == view_model.primary_key {
352        return Ok(());
353    }
354    if view_model.fields.iter().any(|f| f.field_name == sort_by) {
355        Ok(())
356    } else {
357        Err(error::ErrorBadRequest(format!(
358            "Unknown sort column: {sort_by}"
359        )))
360    }
361}
362
363#[derive(Debug, Deserialize)]
364pub struct SearchParams {
365    pub page: u64,
366    pub entities_per_page: u64,
367    pub search: String,
368    pub sort_by: String,
369    pub sort_order: SortOrder,
370}
371
372impl SearchParams {
373    pub fn to_query_string(&self) -> String {
374        use urlencoding::encode;
375        format!(
376            "page={0}&search={1}&sort_by={2}&sort_order={3}&entities_per_page={4}",
377            self.page,
378            encode(&self.search),
379            encode(&self.sort_by),
380            self.sort_order,
381            self.entities_per_page,
382        )
383    }
384
385    pub fn from_params(params: &Params, view_model: &ActixAdminViewModel) -> Self {
386        SearchParams {
387            page: params.page.unwrap_or(1).max(1),
388            entities_per_page: params
389                .entities_per_page
390                .unwrap_or(DEFAULT_ENTITIES_PER_PAGE)
391                .max(1),
392            search: params.search.clone().unwrap_or_default(),
393            sort_by: params
394                .sort_by
395                .clone()
396                .unwrap_or_else(|| view_model.primary_key.clone()),
397            sort_order: params.sort_order.clone().unwrap_or(SortOrder::Asc),
398        }
399    }
400
401    /// Adapter: `SearchParams` was the pre-`ListQuery` shape, kept so that
402    /// external callers keep compiling. New code should use
403    /// [`crate::routes::ListQuery`] directly.
404    pub fn from_list_query(q: &crate::routes::query::ListQuery) -> Self {
405        SearchParams {
406            page: q.page,
407            entities_per_page: q.entities_per_page,
408            search: q.search.clone(),
409            sort_by: q.sort_by.clone(),
410            sort_order: q.sort_order.clone(),
411        }
412    }
413}
414
415#[allow(dead_code)]
416pub fn add_default_context(
417    ctx: &mut Context,
418    req: HttpRequest,
419    view_model: &ActixAdminViewModel,
420    entity_name: String,
421    actix_admin: &ActixAdmin,
422    notifications: Vec<ActixAdminNotification>,
423    search_params: &SearchParams,
424) {
425    add_default_context_with_session(
426        ctx,
427        req,
428        view_model,
429        entity_name,
430        actix_admin,
431        notifications,
432        search_params,
433        None,
434    )
435}
436
437/// Variant that also resolves per-view permission hooks against `session`
438/// and pushes them into the template context as `view_model.can_*` booleans.
439#[allow(clippy::too_many_arguments)]
440pub fn add_default_context_with_session(
441    ctx: &mut Context,
442    req: HttpRequest,
443    view_model: &ActixAdminViewModel,
444    entity_name: String,
445    actix_admin: &ActixAdmin,
446    notifications: Vec<ActixAdminNotification>,
447    search_params: &SearchParams,
448    session: Option<&Session>,
449) {
450    let render_partial = req.headers().contains_key("HX-Target");
451
452    let mut serializable = ActixAdminViewModelSerializable::from(view_model.clone());
453    if let Some(session) = session {
454        serializable.can_create =
455            user_can_perform(session, actix_admin, view_model, AdminAction::Create);
456        serializable.can_edit =
457            user_can_perform(session, actix_admin, view_model, AdminAction::Edit);
458        serializable.can_delete =
459            user_can_perform(session, actix_admin, view_model, AdminAction::Delete);
460        serializable.can_view_details =
461            user_can_perform(session, actix_admin, view_model, AdminAction::View);
462        serializable.can_export =
463            user_can_perform(session, actix_admin, view_model, AdminAction::Export);
464    }
465
466    ctx.insert("view_model", &serializable);
467    ctx.insert("entity_name", &entity_name);
468    ctx.insert("entity_names", &actix_admin.entity_names);
469    ctx.insert("notifications", &notifications);
470    ctx.insert("entities_per_page", &search_params.entities_per_page);
471    ctx.insert("render_partial", &render_partial);
472    ctx.insert("search", &search_params.search);
473    ctx.insert("sort_by", &search_params.sort_by);
474    ctx.insert("sort_order", &search_params.sort_order);
475    ctx.insert("page", &search_params.page);
476}