Skip to main content

ferro_rs/http/
action.rs

1//! Runtime types for the `#[action]` proc-macro.
2//!
3//! `#[action]` decorates POST-style handlers that mutate state and redirect.
4//! Per D-03 the user body returns `ActionResult = Result<(), ActionError>`.
5//! Success-side overrides (per D-02) are recorded via `Request::flash(...)` and
6//! `Request::redirect_to(...)` — see [`crate::http::Request`].
7//!
8//! # Killer-feature contract
9//!
10//! Inside an `#[action]`-decorated function body:
11//!
12//! - `Ok(())` is the success expression — no helper type to construct.
13//! - `?` works on `String`, `&'static str`, `FrameworkError`, and (with
14//!   `sea_orm` available) `sea_orm::DbErr` via concrete [`From`] impls below.
15//! - For any other error type implementing [`std::fmt::Display`], use the
16//!   [`ActionResultExt::action_err`] extension method on the `Result` to
17//!   convert into [`ActionError`] without a `.map_err` closure.
18//!
19//! # Security
20//!
21//! - **T-180-01** (flash message injection): [`ActionError::message`] is treated
22//!   as untrusted display text. Consumer templates MUST HTML-escape it.
23//! - **T-180-02** (open redirect): [`ActionError::redirect_override`] is
24//!   validated as same-origin (path starting with `/`) at use time;
25//!   external URLs are rejected and a `tracing::warn!` is emitted.
26//! - **T-180-03** (log injection): control characters are stripped from
27//!   `message` before any `tracing::error!` call.
28
29use form_urlencoded::byte_serialize;
30use serde::{Deserialize, Serialize};
31use thiserror::Error;
32
33/// Semantic kind of an action error. Surfaces in the back-compat query string
34/// (`?error=<kind_snake_case>`) and in tracing fields.
35#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "snake_case")]
37pub enum ActionKind {
38    /// General-purpose error with no specific HTTP semantic.
39    #[default]
40    Generic,
41    /// The requested resource was not found (404-shape).
42    NotFound,
43    /// The caller is authenticated but lacks permission (403-shape).
44    Forbidden,
45    /// The caller is not authenticated (401-shape).
46    Unauthorized,
47}
48
49impl ActionKind {
50    pub(crate) fn as_query_str(&self) -> &'static str {
51        match self {
52            Self::Generic => "generic",
53            Self::NotFound => "not_found",
54            Self::Forbidden => "forbidden",
55            Self::Unauthorized => "unauthorized",
56        }
57    }
58}
59
60/// Flash banner variant. Templates use this to choose the CSS class.
61#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
62#[serde(rename_all = "snake_case")]
63pub enum FlashVariant {
64    /// Error state — typically rendered with a red/destructive style.
65    #[default]
66    Error,
67    /// Warning state — typically rendered with a yellow/caution style.
68    Warning,
69    /// Informational state — typically rendered with a blue/neutral style.
70    Info,
71}
72
73/// Action-handler error type. Drives the 303 redirect, the session flash
74/// payload, the back-compat query string, and the `tracing::error!` log line.
75///
76/// # Security
77///
78/// `message` is rendered to users via the flash payload. Consumer templates
79/// MUST HTML-escape it (T-180-01). `redirect_override` is validated as
80/// same-origin at use time (T-180-02).
81#[derive(Debug, Clone, Error)]
82#[error("{message}")]
83pub struct ActionError {
84    /// User-facing error message. Treat as untrusted; templates must HTML-escape (T-180-01).
85    pub message: String,
86    /// Semantic kind used for routing and back-compat query strings.
87    pub kind: ActionKind,
88    /// Flash banner variant picked up by consumer templates.
89    pub flash_variant: FlashVariant,
90    /// Optional redirect override. Validated as same-origin when applied (T-180-02).
91    pub redirect_override: Option<String>,
92    /// When `true`, `handle_action_result` skips both the `?error=<kind>&msg=<pct>`
93    /// query envelope and the `_action` session flash write. Used by
94    /// `validation_failed` so per-field validation errors (already flashed via
95    /// `ValidationError::into_action_error`) are not duplicated by a generic
96    /// envelope toast.
97    pub(crate) suppress_url_envelope: bool,
98}
99
100impl ActionError {
101    /// Generic error — most common constructor.
102    pub fn msg(message: impl Into<String>) -> Self {
103        Self {
104            message: message.into(),
105            kind: ActionKind::Generic,
106            flash_variant: FlashVariant::Error,
107            redirect_override: None,
108            suppress_url_envelope: false,
109        }
110    }
111
112    /// Constructor for handlers that have already flashed per-field validation
113    /// errors via `ValidationError::with_old_input(&data).redirect_to(url)` (or
114    /// equivalently via `ValidationError::into_action_error(url)`).
115    ///
116    /// Drives the 303 redirect WITHOUT writing the URL `?error=...&msg=...`
117    /// envelope (which would render a redundant generic toast alongside the
118    /// per-field inline errors). Also skips the `_action` session flash write.
119    ///
120    /// The 303 status, the Location header, and the `tracing::error!` log line
121    /// still emit unconditionally.
122    pub fn validation_failed(redirect_to: impl Into<String>) -> Self {
123        Self {
124            message: String::new(),
125            kind: ActionKind::Generic,
126            flash_variant: FlashVariant::Error,
127            redirect_override: Some(redirect_to.into()),
128            suppress_url_envelope: true,
129        }
130    }
131
132    /// 404-shape error.
133    pub fn not_found(message: impl Into<String>) -> Self {
134        Self {
135            kind: ActionKind::NotFound,
136            ..Self::msg(message)
137        }
138    }
139
140    /// 403-shape error.
141    pub fn forbidden(message: impl Into<String>) -> Self {
142        Self {
143            kind: ActionKind::Forbidden,
144            ..Self::msg(message)
145        }
146    }
147
148    /// 401-shape error.
149    ///
150    /// `redirect_override` defaults to `None` — ferro is project-agnostic and
151    /// does not hardcode any consumer auth path (D-08). Callers configure
152    /// the redirect target explicitly:
153    /// `ActionError::unauthorized("...").redirect_to("/your-login-path")`.
154    pub fn unauthorized(message: impl Into<String>) -> Self {
155        Self {
156            kind: ActionKind::Unauthorized,
157            ..Self::msg(message)
158        }
159    }
160
161    /// Builder — set the flash variant.
162    #[must_use]
163    pub fn with_flash(mut self, variant: FlashVariant) -> Self {
164        self.flash_variant = variant;
165        self
166    }
167
168    /// Builder — set the redirect override. The override is validated as
169    /// same-origin (T-180-02) when applied by the action runtime; external
170    /// URLs are silently rejected and a `tracing::warn!` is emitted.
171    #[must_use]
172    pub fn redirect_to(mut self, url: impl Into<String>) -> Self {
173        self.redirect_override = Some(url.into());
174        self
175    }
176}
177
178impl From<String> for ActionError {
179    fn from(s: String) -> Self {
180        Self::msg(s)
181    }
182}
183
184impl From<&'static str> for ActionError {
185    fn from(s: &'static str) -> Self {
186        Self::msg(s)
187    }
188}
189
190impl From<crate::error::FrameworkError> for ActionError {
191    fn from(err: crate::error::FrameworkError) -> Self {
192        Self::msg(err.to_string())
193    }
194}
195
196impl From<sea_orm::DbErr> for ActionError {
197    fn from(err: sea_orm::DbErr) -> Self {
198        Self::msg(err.to_string())
199    }
200}
201
202/// Conversion trait for the long-tail Display types not covered by the concrete
203/// `From` impls. Use [`ActionResultExt::action_err`] for ergonomic `?`-style
204/// conversion at the call site.
205pub trait IntoActionError {
206    /// Convert this error into an [`ActionError`].
207    fn into_action_error(self) -> ActionError;
208}
209
210impl<E: std::fmt::Display> IntoActionError for E {
211    fn into_action_error(self) -> ActionError {
212        ActionError::msg(self.to_string())
213    }
214}
215
216/// Extension trait on `Result` for converting any `Display` error into an
217/// `ActionError` without a `.map_err` closure.
218pub trait ActionResultExt<T> {
219    /// Convert the error side of this `Result` into an [`ActionError`].
220    fn action_err(self) -> Result<T, ActionError>;
221}
222
223impl<T, E: IntoActionError> ActionResultExt<T> for Result<T, E> {
224    fn action_err(self) -> Result<T, ActionError> {
225        self.map_err(|e| e.into_action_error())
226    }
227}
228
229/// REVISED 2026-05-30 per CONTEXT D-03.
230///
231/// `()` on the Ok side — `Ok(())` is the success expression. Success-side
232/// overrides are recorded via [`crate::http::Request::flash`] and
233/// [`crate::http::Request::redirect_to`] (D-02).
234pub type ActionResult = Result<(), ActionError>;
235
236/// Internal carrier for success-side overrides recorded by
237/// [`crate::http::Request::flash`] / [`crate::http::Request::redirect_to`].
238/// Read by [`handle_action_result`] after the user body returns.
239#[derive(Debug, Default, Clone)]
240pub(crate) struct ActionOverrides {
241    pub flash: Option<String>,
242    pub redirect_override: Option<String>,
243}
244
245/// Same-origin check — replicates the pattern in
246/// `framework/src/validation/error.rs:172-179` and `response.rs::same_origin_path_from_referer`.
247/// Accepts only relative paths that start with `/` but NOT scheme-relative URLs (`//`),
248/// which could redirect to an attacker-controlled host. T-180-02 mitigation.
249pub(crate) fn is_same_origin(url: &str) -> bool {
250    url.starts_with('/') && !url.starts_with("//")
251}
252
253/// Strip control characters from the user-facing message before logging.
254/// T-180-03 mitigation.
255pub(crate) fn sanitize_for_log(s: &str) -> String {
256    s.chars()
257        .map(|c| if c.is_control() { ' ' } else { c })
258        .collect()
259}
260
261/// JSON payload written to the `_action` session flash slot. Read by consumer
262/// templates / shared Inertia props middleware.
263#[derive(Debug, Serialize, Deserialize)]
264struct ActionFlashPayload<'a> {
265    variant: &'a str,
266    message: &'a str,
267}
268
269/// Runtime helper called from macro-generated code. NOT a stable public API.
270///
271/// On `Ok(())`:
272///   - Reads `req.action_overrides()` — if `flash` is set, writes
273///     `{variant: "success", message: "<flash_key>"}` to the `_action` flash slot.
274///   - If `redirect_override` is set AND same-origin, redirects there;
275///     otherwise falls back to `redirect_to` (T-180-02).
276///   - Appends back-compat `?success=<flash_key_or_1>` to the redirect URL (D-06).
277///
278/// On `Err(err)`:
279///   - Writes `{variant: "<err.flash_variant>", message: "<err.message>"}` to the
280///     `_action` flash slot.
281///   - If `err.redirect_override` is set AND same-origin, redirects there;
282///     otherwise falls back to `redirect_to`.
283///   - Appends back-compat `?error=<err.kind>&msg=<pct(err.message)>`.
284///   - Emits `tracing::error!(handler=%name, msg=%sanitize, kind=?err.kind, ...)`.
285///
286/// # Stability
287///
288/// This function is `pub` only so proc-macro-generated code can call it from
289/// outside the framework crate. It is NOT part of the stable public API.
290/// Breaking changes may occur in any release.
291#[doc(hidden)]
292pub fn handle_action_result(
293    result: ActionResult,
294    redirect_to: &'static str,
295    handler_name: &'static str,
296    req: &mut crate::http::Request,
297) -> crate::http::Response {
298    match result {
299        Ok(()) => {
300            let overrides = req.action_overrides().clone();
301
302            let target = match overrides.redirect_override.as_deref() {
303                Some(url) if is_same_origin(url) => url.to_string(),
304                Some(rejected) => {
305                    tracing::warn!(
306                        handler = %handler_name,
307                        rejected_url = %sanitize_for_log(rejected),
308                        "redirect_override rejected: not same-origin (success path)"
309                    );
310                    redirect_to.to_string()
311                }
312                None => redirect_to.to_string(),
313            };
314
315            // Flash write (success).
316            if let Some(key) = overrides.flash.as_deref() {
317                let payload = ActionFlashPayload {
318                    variant: "success",
319                    message: key,
320                };
321                crate::session::session_mut(|s| s.flash("_action", &payload));
322            }
323
324            // Back-compat query string (D-06 fallback). Uses `&` when the
325            // user-supplied redirect target already contains a query string.
326            // The flash key is percent-encoded — flash keys may carry user
327            // input, and `&` / `=` / space in the key would otherwise break
328            // the URL.
329            let sep = if target.contains('?') { '&' } else { '?' };
330            let suffix = match overrides.flash.as_deref() {
331                Some(k) if !k.is_empty() => {
332                    let encoded_key: String = byte_serialize(k.as_bytes()).collect();
333                    format!("{sep}success={encoded_key}")
334                }
335                _ => format!("{sep}success=1"),
336            };
337            let location = format!("{target}{suffix}");
338
339            Ok(crate::http::HttpResponse::new()
340                .status(303)
341                .header("Location", &location))
342        }
343        Err(err) => {
344            let safe_msg = sanitize_for_log(&err.message);
345            tracing::error!(
346                handler = %handler_name,
347                msg = %safe_msg,
348                kind = ?err.kind,
349                "action handler error — redirecting"
350            );
351
352            let target = match err.redirect_override.as_deref() {
353                Some(url) if is_same_origin(url) => url.to_string(),
354                Some(rejected) => {
355                    tracing::warn!(
356                        handler = %handler_name,
357                        rejected_url = %sanitize_for_log(rejected),
358                        "redirect_override rejected: not same-origin (error path)"
359                    );
360                    redirect_to.to_string()
361                }
362                None => redirect_to.to_string(),
363            };
364
365            // Validation handlers (ActionError::validation_failed) suppress
366            // both the session flash write AND the `?error=...&msg=...` URL
367            // envelope so the per-field errors already flashed by
368            // `ValidationError::into_action_error` are not duplicated by a
369            // generic toast.
370            let location = if err.suppress_url_envelope {
371                target
372            } else {
373                // Flash write (error / warning / info).
374                let variant_str = match err.flash_variant {
375                    FlashVariant::Error => "error",
376                    FlashVariant::Warning => "warning",
377                    FlashVariant::Info => "info",
378                };
379                let payload = ActionFlashPayload {
380                    variant: variant_str,
381                    message: &err.message,
382                };
383                crate::session::session_mut(|s| s.flash("_action", &payload));
384
385                // Back-compat query string. Uses `&` when the user-supplied
386                // redirect target already contains a query string.
387                let sep = if target.contains('?') { '&' } else { '?' };
388                let encoded_msg: String = byte_serialize(err.message.as_bytes()).collect();
389                format!(
390                    "{target}{sep}error={kind}&msg={msg}",
391                    target = target,
392                    sep = sep,
393                    kind = err.kind.as_query_str(),
394                    msg = encoded_msg
395                )
396            };
397
398            Ok(crate::http::HttpResponse::new()
399                .status(303)
400                .header("Location", &location))
401        }
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn msg_constructor_defaults() {
411        let e = ActionError::msg("boom");
412        assert_eq!(e.message, "boom");
413        assert!(matches!(e.kind, ActionKind::Generic));
414        assert!(matches!(e.flash_variant, FlashVariant::Error));
415        assert!(e.redirect_override.is_none());
416    }
417
418    #[test]
419    fn not_found_constructor_sets_kind() {
420        let e = ActionError::not_found("missing");
421        assert!(matches!(e.kind, ActionKind::NotFound));
422    }
423
424    #[test]
425    fn forbidden_constructor_sets_kind() {
426        let e = ActionError::forbidden("nope");
427        assert!(matches!(e.kind, ActionKind::Forbidden));
428    }
429
430    #[test]
431    fn unauthorized_constructor_no_default_redirect() {
432        // D-08: ferro MUST NOT hardcode any auth path.
433        let e = ActionError::unauthorized("login first");
434        assert!(matches!(e.kind, ActionKind::Unauthorized));
435        assert!(
436            e.redirect_override.is_none(),
437            "ferro must not hardcode a default auth-redirect path (D-08)"
438        );
439    }
440
441    #[test]
442    fn builders_consume_self() {
443        let e = ActionError::msg("x")
444            .with_flash(FlashVariant::Warning)
445            .redirect_to("/login");
446        assert!(matches!(e.flash_variant, FlashVariant::Warning));
447        assert_eq!(e.redirect_override.as_deref(), Some("/login"));
448    }
449
450    #[test]
451    fn from_string_impl() {
452        let e: ActionError = "oops".to_string().into();
453        assert_eq!(e.message, "oops");
454    }
455
456    #[test]
457    fn from_static_str_impl() {
458        let e: ActionError = "static".into();
459        assert_eq!(e.message, "static");
460    }
461
462    #[test]
463    fn from_framework_error_impl() {
464        let fe = crate::error::FrameworkError::internal("framework boom");
465        let e: ActionError = fe.into();
466        assert!(e.message.contains("framework boom"));
467    }
468
469    #[test]
470    fn into_action_error_blanket_for_display_types() {
471        // Any Display type works through the trait.
472        let n: i32 = 42;
473        let e = n.into_action_error();
474        assert_eq!(e.message, "42");
475    }
476
477    #[test]
478    fn action_err_extension_on_result() {
479        let r: Result<(), i32> = Err(7);
480        let converted: Result<(), ActionError> = r.action_err();
481        assert!(converted.is_err());
482        assert_eq!(converted.unwrap_err().message, "7");
483    }
484
485    #[test]
486    fn sanitize_strips_control_chars() {
487        assert_eq!(sanitize_for_log("a\nb\tc\x00d"), "a b c d");
488    }
489
490    #[test]
491    fn is_same_origin_accepts_relative() {
492        assert!(is_same_origin("/dashboard"));
493        assert!(is_same_origin("/"));
494    }
495
496    #[test]
497    fn is_same_origin_rejects_absolute() {
498        assert!(!is_same_origin("https://evil.example/"));
499        assert!(!is_same_origin("//evil.example/"));
500        assert!(!is_same_origin("http://localhost/"));
501    }
502
503    #[test]
504    fn action_kind_query_strings() {
505        assert_eq!(ActionKind::Generic.as_query_str(), "generic");
506        assert_eq!(ActionKind::NotFound.as_query_str(), "not_found");
507        assert_eq!(ActionKind::Forbidden.as_query_str(), "forbidden");
508        assert_eq!(ActionKind::Unauthorized.as_query_str(), "unauthorized");
509    }
510}