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}