Skip to main content

tablo_core/
outcome.rs

1//! What a write's response says and where it lands: the [`Notification`] it flashes, and the
2//! [`Outcome`] an [`Action`](crate::Action) or [`HeaderAction`](crate::HeaderAction) returns.
3
4use serde::{Deserialize, Serialize};
5
6/// The kind of notification (status).
7///
8/// The serde tokens are lowercase so the JSON cookie reads naturally.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11#[non_exhaustive]
12pub enum NotificationStatus {
13    Success,
14    Error,
15    Info,
16    Warning,
17}
18
19impl NotificationStatus {
20    /// The lowercase token, shared with the flash cookie and `data-type`.
21    pub fn as_str(&self) -> &'static str {
22        match self {
23            Self::Success => "success",
24            Self::Error => "error",
25            Self::Info => "info",
26            Self::Warning => "warning",
27        }
28    }
29}
30
31/// A message for a panel user: flashed as a toast after a write, or stored for the bell with
32/// [`Notifications::send`](crate::Notifications::send).
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct Notification {
35    pub status: NotificationStatus,
36    pub title: String,
37    /// Supporting line under the title; `None` keeps the cookie wire format so
38    /// older cookies still decode.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub description: Option<String>,
41    /// The links under the text, in the order they were added.
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub actions: Vec<NotificationAction>,
44}
45
46/// A link a [`Notification`] offers under its text, such as "Download" or "View order".
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct NotificationAction {
49    pub label: String,
50    /// A same-origin path such as `/admin/orders/42`, or an `http` or `https` URL. Anything else,
51    /// such as `javascript:…`, renders no link.
52    pub url: String,
53}
54
55impl NotificationAction {
56    /// The URL when a browser may follow it: a same-origin path, or an `http` or `https` URL.
57    pub(crate) fn safe_url(&self) -> Option<&str> {
58        let url = self.url.trim();
59        if same_origin_path(url).is_some() {
60            return Some(url);
61        }
62        let lower = url.get(..8).unwrap_or(url).to_ascii_lowercase();
63        let web = lower.starts_with("https://") || lower.starts_with("http://");
64        (web && !url.chars().any(|c| c.is_control() || c == '\\')).then_some(url)
65    }
66}
67
68impl Notification {
69    pub fn success(title: impl Into<String>) -> Self {
70        Self::new(NotificationStatus::Success, title)
71    }
72
73    pub fn error(title: impl Into<String>) -> Self {
74        Self::new(NotificationStatus::Error, title)
75    }
76
77    pub fn info(title: impl Into<String>) -> Self {
78        Self::new(NotificationStatus::Info, title)
79    }
80
81    pub fn warning(title: impl Into<String>) -> Self {
82        Self::new(NotificationStatus::Warning, title)
83    }
84
85    fn new(status: NotificationStatus, title: impl Into<String>) -> Self {
86        Self {
87            status,
88            title: title.into(),
89            description: None,
90            actions: Vec::new(),
91        }
92    }
93
94    /// Attach the supporting line under the title.
95    pub fn description(mut self, description: impl Into<String>) -> Self {
96        self.description = Some(description.into());
97        self
98    }
99
100    /// Adds a link under the text: `url` is a same-origin path such as `/admin/orders/42`, or an
101    /// `http` or `https` URL. A URL of any other scheme renders no link.
102    pub fn action(mut self, label: impl Into<String>, url: impl Into<String>) -> Self {
103        self.actions.push(NotificationAction {
104            label: label.into(),
105            url: url.into(),
106        });
107        self
108    }
109}
110
111/// What an [`Action`](crate::Action) or a [`HeaderAction`](crate::HeaderAction) asks of the
112/// response once its write commits: which notification it flashes and where it lands.
113///
114/// [`Outcome::done`] keeps the defaults: the action's [`success`](crate::Action::success)
115/// notification, landing back where the action ran from. A run builds on it:
116///
117/// ```rust
118/// # use tablo_core::{Notification, Outcome};
119/// # let (sent, bounced, id) = (3, 1, uuid::Uuid::nil());
120/// // Report what happened, and open the copy the action made.
121/// let outcome = Outcome::done()
122///     .notify(
123///         Notification::warning(format!("{sent} sent")).description(format!("{bounced} bounced")),
124///     )
125///     .redirect(format!("/admin/invoices/{id}"));
126/// # let _ = outcome;
127/// ```
128///
129/// A file to download is a redirect to an app route that serves it.
130#[derive(Debug, Clone, Default)]
131#[must_use]
132pub struct Outcome {
133    notification: Option<Notification>,
134    redirect: Option<String>,
135}
136
137impl Outcome {
138    /// The defaults: the action's success notification, landing where it ran from.
139    pub fn done() -> Self {
140        Self::default()
141    }
142
143    /// Flashes `notification` instead of the action's success notification. A bulk run that
144    /// skipped records appends their count to its title.
145    pub fn notify(mut self, notification: Notification) -> Self {
146        self.notification = Some(notification);
147        self
148    }
149
150    /// Lands on `path` instead of where the action ran from. `path` is a same-origin path such
151    /// as `/admin/invoices/42`; anything else, such as `https://…` or `//host`, lands where the
152    /// action ran from and logs an error, since the write has already committed.
153    pub fn redirect(mut self, path: impl Into<String>) -> Self {
154        self.redirect = Some(path.into());
155        self
156    }
157
158    /// The outcome with `skipped`, a bulk run's count of skipped records, appended to its own
159    /// notification's title.
160    pub(crate) fn skipping(mut self, skipped: &str) -> Self {
161        if let Some(notification) = &mut self.notification {
162            notification.title.push_str(skipped);
163        }
164        self
165    }
166
167    /// The notification to flash, if not the default.
168    pub(crate) fn notification(&self) -> Option<&Notification> {
169        self.notification.as_ref()
170    }
171
172    /// The landing path asked for, if any, as given.
173    pub(crate) fn redirect_path(&self) -> Option<&str> {
174        self.redirect.as_deref()
175    }
176}
177
178/// `path` when it is a same-origin relative path: it starts with one `/` and holds no
179/// backslash or control character, so a browser cannot read it as another host.
180pub(crate) fn same_origin_path(path: &str) -> Option<&str> {
181    let path = path.trim();
182    if !path.starts_with('/') || path.starts_with("//") {
183        return None;
184    }
185    if path.contains('\\') || path.chars().any(|c| c.is_control()) {
186        return None;
187    }
188    Some(path)
189}