use axum::response::{IntoResponse, IntoResponseParts, Response, ResponseParts};
use serde::{Deserialize, Serialize};
pub(crate) const SESSION_KEY: &str = "_toasts";
pub(crate) const EVENT: &str = "renox:toast";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum ToastKind {
Success,
Info,
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Toast {
pub kind: ToastKind,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub actions: Vec<ToastAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ToastAction {
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub event: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub new_tab: bool,
}
impl ToastAction {
pub fn link(label: impl Into<String>, url: impl Into<String>) -> Self {
Self {
label: label.into(),
url: Some(url.into()),
event: None,
method: None,
new_tab: false,
}
}
pub fn post(label: impl Into<String>, url: impl Into<String>) -> Self {
Self::request("POST", label, url)
}
pub fn put(label: impl Into<String>, url: impl Into<String>) -> Self {
Self::request("PUT", label, url)
}
pub fn patch(label: impl Into<String>, url: impl Into<String>) -> Self {
Self::request("PATCH", label, url)
}
pub fn delete(label: impl Into<String>, url: impl Into<String>) -> Self {
Self::request("DELETE", label, url)
}
fn request(method: &str, label: impl Into<String>, url: impl Into<String>) -> Self {
Self {
method: Some(method.to_owned()),
..Self::link(label, url)
}
}
pub(crate) fn is_safe(&self) -> bool {
match (&self.method, self.url.as_deref()) {
(Some(method), Some(url)) => request_method(method).is_some() && local_url(url),
(Some(_), None) => false,
(None, Some(url)) => safe_url(url),
(None, None) => self.event.is_some(),
}
}
pub fn event(label: impl Into<String>, name: impl Into<String>) -> Self {
Self {
label: label.into(),
url: None,
event: Some(name.into()),
method: None,
new_tab: false,
}
}
pub fn new_tab(mut self) -> Self {
self.new_tab = true;
self
}
}
impl Toast {
pub fn new(kind: ToastKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
body: None,
actions: Vec::new(),
duration: None,
id: None,
}
}
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into());
self
}
pub fn action(mut self, action: ToastAction) -> Self {
self.actions.push(action);
self
}
pub fn link(self, label: impl Into<String>, url: impl Into<String>) -> Self {
self.action(ToastAction::link(label, url))
}
pub fn persistent(mut self) -> Self {
self.duration = Some(0);
self
}
pub fn seconds(mut self, seconds: u64) -> Self {
self.duration = Some(seconds.saturating_mul(1000).max(1));
self
}
pub fn id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
pub fn success(message: impl Into<String>) -> Self {
Self::new(ToastKind::Success, message)
}
pub fn info(message: impl Into<String>) -> Self {
Self::new(ToastKind::Info, message)
}
pub fn warning(message: impl Into<String>) -> Self {
Self::new(ToastKind::Warning, message)
}
pub fn error(message: impl Into<String>) -> Self {
Self::new(ToastKind::Error, message)
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct PendingToasts(pub Vec<Toast>);
impl IntoResponseParts for Toast {
type Error = std::convert::Infallible;
fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
let pending = res.extensions_mut().get_mut::<PendingToasts>();
match pending {
Some(pending) => pending.0.push(self),
None => {
res.extensions_mut().insert(PendingToasts(vec![self]));
}
}
Ok(res)
}
}
impl IntoResponse for Toast {
fn into_response(self) -> Response {
(self, axum::http::StatusCode::NO_CONTENT).into_response()
}
}
pub(crate) const POSITIONS: &[&str] = &[
"top",
"top-start",
"top-end",
"bottom",
"bottom-start",
"bottom-end",
];
pub(crate) struct RegionTexts {
pub(crate) dismiss: String,
pub(crate) failed: String,
}
pub(crate) fn region(waiting: &[Toast], texts: &RegionTexts, position: &str) -> String {
let position = if POSITIONS.contains(&position) {
position
} else {
"top"
};
let dismiss = texts.dismiss.as_str();
let mut out = format!(
r#"<div class="rx-toasts rx-toasts--{position}" data-renox-toasts data-dismiss-label="{}" data-failed-label="{}" aria-live="polite" aria-relevant="additions">"#,
escape(dismiss),
escape(&texts.failed)
);
for toast in waiting {
out.push_str(&toast_html(toast, dismiss));
}
out.push_str("</div>");
out
}
pub(crate) fn toast_html(toast: &Toast, dismiss: &str) -> String {
let (kind, role, icon) = match toast.kind {
ToastKind::Success => ("success", "status", ICON_CHECK),
ToastKind::Info => ("info", "status", ICON_INFO),
ToastKind::Warning => ("warning", "status", ICON_WARNING),
ToastKind::Error => ("error", "alert", ICON_ERROR),
};
let sticky = match toast.duration {
Some(0) => " data-sticky".to_owned(),
Some(ms) => format!(" data-duration=\"{ms}\""),
None if toast.kind == ToastKind::Error => " data-sticky".to_owned(),
None => String::new(),
};
let id = toast
.id
.as_deref()
.map(|id| format!(" data-toast-id=\"{}\"", escape(id)))
.unwrap_or_default();
let body = toast
.body
.as_deref()
.map(|body| format!(r#"<p class="rx-toast__body">{}</p>"#, escape(body)))
.unwrap_or_default();
let mut actions = String::new();
for action in &toast.actions {
actions.push_str(&action_html(action));
}
if !actions.is_empty() {
actions = format!(r#"<div class="rx-toast__actions">{actions}</div>"#);
}
format!(
r#"<div class="rx-toast rx-toast--{kind}" role="{role}" data-renox-toast{sticky}{id}><span class="rx-toast__icon" aria-hidden="true">{icon}</span><div class="rx-toast__content"><p class="rx-toast__message">{message}</p>{body}{actions}</div><button type="button" class="rx-toast__close" data-renox-dismiss aria-label="{dismiss}">{ICON_CLOSE}</button></div>"#,
message = escape(&toast.message),
dismiss = escape(dismiss),
)
}
fn action_html(action: &ToastAction) -> String {
let label = escape(&action.label);
if !action.is_safe() {
return String::new();
}
if let (Some(method), Some(url)) = (action.method.as_deref(), action.url.as_deref()) {
let method = request_method(method).unwrap_or("POST");
format!(
r#"<button type="button" class="rx-toast__action" data-rx-request="{}" data-rx-method="{method}" data-renox-dismiss>{label}</button>"#,
escape(url)
)
} else if let Some(url) = action.url.as_deref() {
let target = if action.new_tab {
r#" target="_blank" rel="noopener""#
} else {
""
};
format!(
r#"<a class="rx-toast__action" href="{}"{target} data-renox-dismiss>{label}</a>"#,
escape(url)
)
} else if let Some(event) = &action.event {
format!(
r#"<button type="button" class="rx-toast__action" data-rx-toast-event="{}" data-renox-dismiss>{label}</button>"#,
escape(event)
)
} else {
String::new()
}
}
pub(crate) fn request_method(method: &str) -> Option<&'static str> {
["POST", "PUT", "PATCH", "DELETE"]
.into_iter()
.find(|known| known.eq_ignore_ascii_case(method))
}
pub(crate) fn local_url(url: &str) -> bool {
let cleaned: String = url
.chars()
.filter(|c| !c.is_ascii_control() && !c.is_whitespace())
.collect();
cleaned.starts_with('/') && !cleaned.starts_with("//") && !cleaned.starts_with("/\\")
}
pub(crate) fn safe_url(url: &str) -> bool {
let cleaned: String = url
.chars()
.filter(|c| !c.is_ascii_control() && !c.is_whitespace())
.collect::<String>()
.to_ascii_lowercase();
let before_path = cleaned.find(['/', '?', '#']).unwrap_or(usize::MAX);
match cleaned.find(':') {
Some(end) if end < before_path => {
matches!(&cleaned[..end], "http" | "https" | "mailto" | "tel")
}
_ => true,
}
}
pub(crate) fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
out
}
const ICON_CHECK: &str = r##"<svg viewBox="0 0 20 20" width="20" height="20"><circle cx="10" cy="10" r="9" fill="currentColor"/><path d="M6 10.5l2.5 2.5L14 7.5" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>"##;
const ICON_INFO: &str = r##"<svg viewBox="0 0 20 20" width="20" height="20"><circle cx="10" cy="10" r="9" fill="currentColor"/><path d="M10 9v5M10 6h.01" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>"##;
const ICON_WARNING: &str = r##"<svg viewBox="0 0 20 20" width="20" height="20"><path d="M10 2l8.5 15h-17z" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M10 8v4M10 14.5h.01" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>"##;
const ICON_ERROR: &str = r##"<svg viewBox="0 0 20 20" width="20" height="20"><circle cx="10" cy="10" r="9" fill="currentColor"/><path d="M7 7l6 6M13 7l-6 6" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>"##;
const ICON_CLOSE: &str = r##"<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path d="M5 5l10 10M15 5L5 15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>"##;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toasts_carry_a_body_actions_and_a_duration() {
let toast = Toast::success("Order <7> placed")
.body("Thanks & bye")
.link("View", "/orders/7")
.action(ToastAction::link("Docs", "https://renox.dev").new_tab())
.action(ToastAction::link("Bad", "javascript:alert(1)"))
.action(ToastAction::event("Undo", "order-undo"))
.seconds(8)
.id("order-7");
let html = toast_html(&toast, "Dismiss");
assert!(
html.contains(r#"<p class="rx-toast__message">Order <7> placed</p>"#),
"{html}"
);
assert!(html.contains(r#"<p class="rx-toast__body">Thanks & bye</p>"#));
assert!(html.contains(r#"href="/orders/7" data-renox-dismiss>View</a>"#));
assert!(html.contains(r#"target="_blank" rel="noopener""#));
assert!(!html.contains("javascript") && !html.contains(">Bad<"));
assert!(html.contains(r#"data-rx-toast-event="order-undo""#));
let mut nowhere = ToastAction::link("Send", "/x");
nowhere.url = None;
nowhere.method = Some("POST".into());
assert!(!nowhere.is_safe());
let mut nothing = ToastAction::event("Nothing", "x");
nothing.event = None;
assert!(!nothing.is_safe());
assert_eq!(action_html(¬hing), "");
assert!(
html.contains(r#"data-duration="8000""#) && html.contains(r#"data-toast-id="order-7""#)
);
let error = toast_html(&Toast::error("No"), "x");
assert!(error.contains("data-sticky") && !error.contains("rx-toast__actions"));
assert!(toast_html(&Toast::info("x").persistent(), "x").contains("data-sticky"));
assert!(toast_html(&Toast::error("x").seconds(3), "x").contains(r#"data-duration="3000""#));
let old: Toast = serde_json::from_str(r#"{"kind":"info","message":"Hi"}"#).unwrap();
assert_eq!(old, Toast::info("Hi"));
assert_eq!(
serde_json::to_string(&old).unwrap(),
r#"{"kind":"info","message":"Hi"}"#
);
let texts = RegionTexts {
dismiss: "x".into(),
failed: "Didn't \"work\"".into(),
};
assert!(region(&[], &texts, "bottom-end").contains("rx-toasts--bottom-end"));
let html = region(&[], &texts, "nowhere");
assert!(html.contains("rx-toasts--top"), "{html}");
assert!(
html.contains(r#"data-failed-label="Didn't "work"""#),
"{html}"
);
}
#[test]
fn request_actions_go_to_this_site_only() {
let toast = Toast::info("Archived")
.action(ToastAction::delete("Undo", "/orders/7/archive"))
.action(ToastAction::post("Retry", "/orders/7/retry"))
.action(ToastAction::put("Elsewhere", "https://evil.example/x"))
.action(ToastAction::patch("Sneaky", "//evil.example/x"))
.action(ToastAction::post("Backslash", "/\\evil.example/x"));
let html = toast_html(&toast, "x");
assert!(
html.contains(r#"<button type="button" class="rx-toast__action" data-rx-request="/orders/7/archive" data-rx-method="DELETE" data-renox-dismiss>Undo</button>"#),
"{html}"
);
assert!(html.contains(r#"data-rx-request="/orders/7/retry" data-rx-method="POST""#));
for bad in ["Elsewhere", "Sneaky", "Backslash", "evil"] {
assert!(!html.contains(bad), "{bad}: {html}");
}
let mut odd = ToastAction::post("Odd", "/x");
odd.method = Some("TRACE".into());
assert!(!odd.is_safe());
assert_eq!(request_method("delete"), Some("DELETE"));
assert!(ToastAction::link("Out", "https://renox.dev").is_safe());
assert_eq!(
serde_json::to_string(&ToastAction::delete("Undo", "/a")).unwrap(),
r#"{"label":"Undo","url":"/a","method":"DELETE"}"#
);
}
#[test]
fn only_harmless_urls_are_links() {
for ok in [
"/a",
"https://x.y",
"mailto:a@b.c",
"tel:+62",
"?q=1",
"#top",
"a/b:c",
] {
assert!(safe_url(ok), "{ok}");
}
for bad in [
"javascript:x",
" JaVa\tscript:x",
"data:text/html,x",
"vbscript:x",
] {
assert!(!safe_url(bad), "{bad}");
}
}
}