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        same_origin_path(url).or_else(|| web_url(url))
60    }
61}
62
63impl Notification {
64    pub fn success(title: impl Into<String>) -> Self {
65        Self::new(NotificationStatus::Success, title)
66    }
67
68    pub fn error(title: impl Into<String>) -> Self {
69        Self::new(NotificationStatus::Error, title)
70    }
71
72    pub fn info(title: impl Into<String>) -> Self {
73        Self::new(NotificationStatus::Info, title)
74    }
75
76    pub fn warning(title: impl Into<String>) -> Self {
77        Self::new(NotificationStatus::Warning, title)
78    }
79
80    fn new(status: NotificationStatus, title: impl Into<String>) -> Self {
81        Self {
82            status,
83            title: title.into(),
84            description: None,
85            actions: Vec::new(),
86        }
87    }
88
89    /// Attach the supporting line under the title.
90    pub fn description(mut self, description: impl Into<String>) -> Self {
91        self.description = Some(description.into());
92        self
93    }
94
95    /// Adds a link under the text: `url` is a same-origin path such as `/admin/orders/42`, or an
96    /// `http` or `https` URL. A URL of any other scheme renders no link.
97    pub fn action(mut self, label: impl Into<String>, url: impl Into<String>) -> Self {
98        self.actions.push(NotificationAction {
99            label: label.into(),
100            url: url.into(),
101        });
102        self
103    }
104}
105
106/// What an [`Action`](crate::Action) or a [`HeaderAction`](crate::HeaderAction) asks of the
107/// response once its write commits: which notification it flashes and where it lands.
108///
109/// [`Outcome::done`] keeps the defaults: the action's [`success`](crate::Action::success)
110/// notification, landing back where the action ran from. A run builds on it:
111///
112/// ```rust
113/// # use tablo_core::{Notification, Outcome};
114/// # let (sent, bounced, id) = (3, 1, uuid::Uuid::nil());
115/// // Report what happened, and open the copy the action made.
116/// let outcome = Outcome::done()
117///     .notify(
118///         Notification::warning(format!("{sent} sent")).description(format!("{bounced} bounced")),
119///     )
120///     .redirect(format!("/admin/invoices/{id}"));
121/// # let _ = outcome;
122/// ```
123///
124/// A file to download is a redirect to an app route that serves it.
125#[derive(Debug, Clone, Default)]
126#[must_use]
127pub struct Outcome {
128    notification: Option<Notification>,
129    redirect: Option<Landing>,
130}
131
132/// Where an [`Outcome`] asks to land.
133#[derive(Debug, Clone)]
134pub(crate) enum Landing {
135    /// [`Outcome::redirect`]'s same-origin path.
136    Path(String),
137    /// [`Outcome::redirect_off_site`]'s `http` or `https` URL.
138    OffSite(String),
139}
140
141impl Landing {
142    /// The URL a browser may land on, if the landing is what its builder accepts.
143    pub(crate) fn url(&self) -> Option<&str> {
144        match self {
145            Self::Path(path) => same_origin_path(path),
146            Self::OffSite(url) => web_url(url.trim()),
147        }
148    }
149}
150
151impl Outcome {
152    /// The defaults: the action's success notification, landing where it ran from.
153    pub fn done() -> Self {
154        Self::default()
155    }
156
157    /// Flashes `notification` instead of the action's success notification. A bulk run that
158    /// skipped records appends their count to its title.
159    pub fn notify(mut self, notification: Notification) -> Self {
160        self.notification = Some(notification);
161        self
162    }
163
164    /// Lands on `path` instead of where the action ran from. `path` is a same-origin path such
165    /// as `/admin/invoices/42`; anything else, such as `https://…` or `//host`, lands where the
166    /// action ran from and logs an error, since the write has already committed.
167    pub fn redirect(mut self, path: impl Into<String>) -> Self {
168        self.redirect = Some(Landing::Path(path.into()));
169        self
170    }
171
172    /// Lands on `url` on another site, such as a payment provider's checkout page the action
173    /// just opened a session for. `url` is an `http` or `https` URL the app builds, never one a
174    /// request carries: anything else lands where the action ran from and logs an error, since
175    /// the write has already committed.
176    ///
177    /// The browser follows the redirect of a form it posted only to an origin the panel's
178    /// `form-action` allows, so add the site's origin with
179    /// [`Panel::content_security_policy_source`](crate::Panel::content_security_policy_source).
180    pub fn redirect_off_site(mut self, url: impl Into<String>) -> Self {
181        self.redirect = Some(Landing::OffSite(url.into()));
182        self
183    }
184
185    /// The outcome with `skipped`, a bulk run's count of skipped records, appended to its own
186    /// notification's title.
187    pub(crate) fn skipping(mut self, skipped: &str) -> Self {
188        if let Some(notification) = &mut self.notification {
189            notification.title.push_str(skipped);
190        }
191        self
192    }
193
194    /// The notification to flash, if not the default.
195    pub(crate) fn notification(&self) -> Option<&Notification> {
196        self.notification.as_ref()
197    }
198
199    /// The landing asked for, if any, as given.
200    pub(crate) fn landing(&self) -> Option<&Landing> {
201        self.redirect.as_ref()
202    }
203}
204
205/// `path` when it is a same-origin relative path: it starts with one `/` and holds no
206/// backslash or control character, so a browser cannot read it as another host.
207pub(crate) fn same_origin_path(path: &str) -> Option<&str> {
208    let path = path.trim();
209    if !path.starts_with('/') || path.starts_with("//") {
210        return None;
211    }
212    if path.contains('\\') || path.chars().any(|c| c.is_control()) {
213        return None;
214    }
215    Some(path)
216}
217
218/// `url` when it is an absolute `http` or `https` URL with a host and holds no backslash or
219/// control character.
220pub(crate) fn web_url(url: &str) -> Option<&str> {
221    let lower = url.get(..8).unwrap_or(url).to_ascii_lowercase();
222    let rest = if lower.starts_with("https://") {
223        &url[8..]
224    } else if lower.starts_with("http://") {
225        &url[7..]
226    } else {
227        return None;
228    };
229    let host = !rest.is_empty() && !rest.starts_with(['/', '?', '#']);
230    (host && !url.chars().any(|c| c.is_control() || c == '\\')).then_some(url)
231}