tablo-core 0.10.0

The core toolkit types for Tablo, a server-rendered admin toolkit on Topcoat and Toasty.
Documentation
//! What a write's response says and where it lands: the [`Notification`] it flashes, and the
//! [`Outcome`] an [`Action`](crate::Action) or [`HeaderAction`](crate::HeaderAction) returns.

use serde::{Deserialize, Serialize};

/// The kind of notification (status).
///
/// The serde tokens are lowercase so the JSON cookie reads naturally.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum NotificationStatus {
    Success,
    Error,
    Info,
    Warning,
}

impl NotificationStatus {
    /// The lowercase token, shared with the flash cookie and `data-type`.
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Success => "success",
            Self::Error => "error",
            Self::Info => "info",
            Self::Warning => "warning",
        }
    }
}

/// A message for a panel user: flashed as a toast after a write, or stored for the bell with
/// [`Notifications::send`](crate::Notifications::send).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Notification {
    pub status: NotificationStatus,
    pub title: String,
    /// Supporting line under the title; `None` keeps the cookie wire format so
    /// older cookies still decode.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// The links under the text, in the order they were added.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub actions: Vec<NotificationAction>,
}

/// A link a [`Notification`] offers under its text, such as "Download" or "View order".
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NotificationAction {
    pub label: String,
    /// A same-origin path such as `/admin/orders/42`, or an `http` or `https` URL. Anything else,
    /// such as `javascript:…`, renders no link.
    pub url: String,
}

impl NotificationAction {
    /// The URL when a browser may follow it: a same-origin path, or an `http` or `https` URL.
    pub(crate) fn safe_url(&self) -> Option<&str> {
        let url = self.url.trim();
        same_origin_path(url).or_else(|| web_url(url))
    }
}

impl Notification {
    pub fn success(title: impl Into<String>) -> Self {
        Self::new(NotificationStatus::Success, title)
    }

    pub fn error(title: impl Into<String>) -> Self {
        Self::new(NotificationStatus::Error, title)
    }

    pub fn info(title: impl Into<String>) -> Self {
        Self::new(NotificationStatus::Info, title)
    }

    pub fn warning(title: impl Into<String>) -> Self {
        Self::new(NotificationStatus::Warning, title)
    }

    fn new(status: NotificationStatus, title: impl Into<String>) -> Self {
        Self {
            status,
            title: title.into(),
            description: None,
            actions: Vec::new(),
        }
    }

    /// Attach the supporting line under the title.
    pub fn description(mut self, description: impl Into<String>) -> Self {
        self.description = Some(description.into());
        self
    }

    /// Adds a link under the text: `url` is a same-origin path such as `/admin/orders/42`, or an
    /// `http` or `https` URL. A URL of any other scheme renders no link.
    pub fn action(mut self, label: impl Into<String>, url: impl Into<String>) -> Self {
        self.actions.push(NotificationAction {
            label: label.into(),
            url: url.into(),
        });
        self
    }
}

/// What an [`Action`](crate::Action) or a [`HeaderAction`](crate::HeaderAction) asks of the
/// response once its write commits: which notification it flashes and where it lands.
///
/// [`Outcome::done`] keeps the defaults: the action's [`success`](crate::Action::success)
/// notification, landing back where the action ran from. A run builds on it:
///
/// ```rust
/// # use tablo_core::{Notification, Outcome};
/// # let (sent, bounced, id) = (3, 1, uuid::Uuid::nil());
/// // Report what happened, and open the copy the action made.
/// let outcome = Outcome::done()
///     .notify(
///         Notification::warning(format!("{sent} sent")).description(format!("{bounced} bounced")),
///     )
///     .redirect(format!("/admin/invoices/{id}"));
/// # let _ = outcome;
/// ```
///
/// A file to download is a redirect to an app route that serves it.
#[derive(Debug, Clone, Default)]
#[must_use]
pub struct Outcome {
    notification: Option<Notification>,
    redirect: Option<Landing>,
}

/// Where an [`Outcome`] asks to land.
#[derive(Debug, Clone)]
pub(crate) enum Landing {
    /// [`Outcome::redirect`]'s same-origin path.
    Path(String),
    /// [`Outcome::redirect_off_site`]'s `http` or `https` URL.
    OffSite(String),
}

impl Landing {
    /// The URL a browser may land on, if the landing is what its builder accepts.
    pub(crate) fn url(&self) -> Option<&str> {
        match self {
            Self::Path(path) => same_origin_path(path),
            Self::OffSite(url) => web_url(url.trim()),
        }
    }
}

impl Outcome {
    /// The defaults: the action's success notification, landing where it ran from.
    pub fn done() -> Self {
        Self::default()
    }

    /// Flashes `notification` instead of the action's success notification. A bulk run that
    /// skipped records appends their count to its title.
    pub fn notify(mut self, notification: Notification) -> Self {
        self.notification = Some(notification);
        self
    }

    /// Lands on `path` instead of where the action ran from. `path` is a same-origin path such
    /// as `/admin/invoices/42`; anything else, such as `https://…` or `//host`, lands where the
    /// action ran from and logs an error, since the write has already committed.
    pub fn redirect(mut self, path: impl Into<String>) -> Self {
        self.redirect = Some(Landing::Path(path.into()));
        self
    }

    /// Lands on `url` on another site, such as a payment provider's checkout page the action
    /// just opened a session for. `url` is an `http` or `https` URL the app builds, never one a
    /// request carries: anything else lands where the action ran from and logs an error, since
    /// the write has already committed.
    ///
    /// The browser follows the redirect of a form it posted only to an origin the panel's
    /// `form-action` allows, so add the site's origin with
    /// [`Panel::content_security_policy_source`](crate::Panel::content_security_policy_source).
    pub fn redirect_off_site(mut self, url: impl Into<String>) -> Self {
        self.redirect = Some(Landing::OffSite(url.into()));
        self
    }

    /// The outcome with `skipped`, a bulk run's count of skipped records, appended to its own
    /// notification's title.
    pub(crate) fn skipping(mut self, skipped: &str) -> Self {
        if let Some(notification) = &mut self.notification {
            notification.title.push_str(skipped);
        }
        self
    }

    /// The notification to flash, if not the default.
    pub(crate) fn notification(&self) -> Option<&Notification> {
        self.notification.as_ref()
    }

    /// The landing asked for, if any, as given.
    pub(crate) fn landing(&self) -> Option<&Landing> {
        self.redirect.as_ref()
    }
}

/// `path` when it is a same-origin relative path: it starts with one `/` and holds no
/// backslash or control character, so a browser cannot read it as another host.
pub(crate) fn same_origin_path(path: &str) -> Option<&str> {
    let path = path.trim();
    if !path.starts_with('/') || path.starts_with("//") {
        return None;
    }
    if path.contains('\\') || path.chars().any(|c| c.is_control()) {
        return None;
    }
    Some(path)
}

/// `url` when it is an absolute `http` or `https` URL with a host and holds no backslash or
/// control character.
pub(crate) fn web_url(url: &str) -> Option<&str> {
    let lower = url.get(..8).unwrap_or(url).to_ascii_lowercase();
    let rest = if lower.starts_with("https://") {
        &url[8..]
    } else if lower.starts_with("http://") {
        &url[7..]
    } else {
        return None;
    };
    let host = !rest.is_empty() && !rest.starts_with(['/', '?', '#']);
    (host && !url.chars().any(|c| c.is_control() || c == '\\')).then_some(url)
}