apiplant-server 0.2.0

apiplant HTTP server: CRUD routing, function endpoints and TLS on ntex
Documentation
//! The messages the framework itself sends, and the links inside them.
//!
//! Three flows reach a person through their mailbox — [an invitation to an
//! organisation](invitation), [confirming an address](verification), [resetting
//! a password](password_reset) — and all three are the same shape: a sentence
//! saying what is being asked, a URL carrying a single-use token, and a note of
//! when it stops working.
//!
//! They are deliberately plain. An app that wants its own wording and its own
//! letterhead should send its own message from a hook — `after_register` and
//! `before_api_key` already exist for exactly that, and a function has the
//! whole `send_email` API. What lives here is the version that has to work in
//! an app which has configured nothing but a provider and a `from` address, so
//! it is a paragraph of text and a link, in both plain text and the least
//! surprising HTML that renders in a dark mailbox as well as a light one.
//!
//! ## Where the links point
//!
//! At the **admin dashboard**, not at the API: the URL in the message is opened
//! by a person in a browser, and the endpoint that spends the token is a
//! `POST` that wants a password typed into a form first. The dashboard's
//! hash-routed screens (`#/accept-invite`, `#/verify-email`, `#/reset-password`)
//! are that form. An app that serves its own front end sets
//! [`links_base`](Links::from_app) through `[server] public_url` and can point
//! its own page at the same three endpoints.

use apiplant_core::App;

/// Where the links in an outgoing message point.
///
/// Resolved once per message from the app's configuration rather than from the
/// request that triggered it: a `Host:` header describes the hop that arrived,
/// and the message is read somewhere else entirely, possibly days later.
#[derive(Debug, Clone)]
pub struct Links {
    /// Origin plus dashboard path, e.g. `https://example.com/admin`.
    base: String,
    /// What the app calls itself, for the subject line.
    pub app_name: String,
}

impl Links {
    pub fn from_app(app: &App) -> Links {
        let origin = app.config.server.public_origin();
        // The dashboard is where the forms live. With it switched off there is
        // nowhere of ours to send anyone, so the link falls back to the origin
        // and the app is expected to serve its own page there.
        let base = match app.config.admin.enabled {
            true => format!("{origin}{}", app.config.admin.path.trim_end_matches('/')),
            false => origin,
        };
        Links {
            base,
            app_name: app.display_name(),
        }
    }

    /// A dashboard link for `screen`, carrying `token`.
    fn to(&self, screen: &str, token: &str) -> String {
        format!(
            "{}/#/{screen}?token={}",
            self.base,
            urlencode(token)
        )
    }
}

/// Percent-encode a token for a query string.
///
/// Our own tokens are `prefix_<hex>` and need no encoding at all; this exists
/// so that a link is still correct if the token format ever grows a character
/// that does.
fn urlencode(value: &str) -> String {
    let mut out = String::with_capacity(value.len());
    for byte in value.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                out.push(byte as char)
            }
            other => out.push_str(&format!("%{other:02X}")),
        }
    }
    out
}

/// A composed message, ready to hand to the mailer.
pub struct Composed {
    pub subject: String,
    pub text: String,
    pub html: String,
}

impl Composed {
    /// Turn this into an [`apiplant_email::Message`] addressed to `recipient`.
    pub fn to(self, recipient: &str) -> apiplant_email::Message {
        apiplant_email::Message::to(recipient)
            .subject(self.subject)
            .text(self.text)
            .html(self.html)
    }
}

/// "You have been invited to <organisation>".
///
/// Names the person who sent it when we know who that is: an unexpected
/// invitation from a colleague is a different message from an unexpected
/// invitation from nobody, and the second one is what a phishing attempt looks
/// like.
pub fn invitation(
    links: &Links,
    organization: &str,
    inviter: Option<&str>,
    token: &str,
    expires_in: &str,
) -> Composed {
    let url = links.to("accept-invite", token);
    let who = match inviter {
        Some(name) if !name.is_empty() => format!("{name} has invited you"),
        _ => "You have been invited".to_string(),
    };
    let lead = format!("{who} to join {organization} on {}.", links.app_name);
    let note = format!(
        "Opening the link lets you choose a password and join. \
         It stops working in {expires_in}."
    );
    Composed {
        subject: format!("You're invited to join {organization}"),
        text: plain(&lead, "Accept the invitation:", &url, &note),
        html: html(&lead, "Accept the invitation", &url, &note),
    }
}

/// "Confirm your email address" — sent on registration when
/// `[auth] require_email_verification` is on.
pub fn verification(links: &Links, token: &str, expires_in: &str) -> Composed {
    let url = links.to("verify-email", token);
    let lead = format!(
        "Confirm this address to finish setting up your {} account.",
        links.app_name
    );
    let note = format!("The link stops working in {expires_in}.");
    Composed {
        subject: format!("Confirm your email for {}", links.app_name),
        text: plain(&lead, "Confirm your address:", &url, &note),
        html: html(&lead, "Confirm my address", &url, &note),
    }
}

/// "Reset your password".
///
/// Says plainly that an unrequested one can be ignored, because it can: the
/// existing password keeps working until this link is actually used.
pub fn password_reset(links: &Links, token: &str, expires_in: &str) -> Composed {
    let url = links.to("reset-password", token);
    let lead = format!(
        "Somebody asked to reset the password for this {} account.",
        links.app_name
    );
    let note = format!(
        "The link stops working in {expires_in}. If this wasn't you, ignore this \
         message — your password has not changed."
    );
    Composed {
        subject: format!("Reset your {} password", links.app_name),
        text: plain(&lead, "Choose a new password:", &url, &note),
        html: html(&lead, "Choose a new password", &url, &note),
    }
}

/// The plain-text half. The URL sits on its own line so that every mail client
/// linkifies it and every human can copy it.
fn plain(lead: &str, call: &str, url: &str, note: &str) -> String {
    format!("{lead}\n\n{call}\n{url}\n\n{note}\n")
}

/// The HTML half: one column, system fonts, no images, no external anything.
///
/// The URL is repeated as text under the button. A button is a link somebody's
/// client may decide not to render, and a link nobody can read is a support
/// ticket.
fn html(lead: &str, call: &str, url: &str, note: &str) -> String {
    let lead = escape(lead);
    let call = escape(call);
    let note = escape(note);
    let href = escape(url);
    format!(
        r#"<!doctype html>
<html><body style="margin:0;padding:24px;background:#f6f7f9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1a1c1f;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:12px;border:1px solid #e6e8ec;">
<tr><td style="padding:28px 28px 8px;font-size:15px;line-height:1.55;">{lead}</td></tr>
<tr><td style="padding:12px 28px 8px;">
<a href="{href}" style="display:inline-block;padding:10px 18px;border-radius:8px;background:#1a1c1f;color:#ffffff;text-decoration:none;font-size:14px;font-weight:600;">{call}</a>
</td></tr>
<tr><td style="padding:8px 28px 4px;font-size:12px;line-height:1.5;color:#6b7280;word-break:break-all;">{href}</td></tr>
<tr><td style="padding:4px 28px 28px;font-size:12px;line-height:1.5;color:#6b7280;">{note}</td></tr>
</table></body></html>"#
    )
}

/// Escape the five characters that would otherwise close a tag or an attribute.
fn escape(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}

/// "7 days", "24 hours", "1 hour" — a duration written the way the sentence
/// "it stops working in ___" needs it.
pub fn humanise(secs: u64) -> String {
    let plural = |n: u64, unit: &str| {
        if n == 1 {
            format!("1 {unit}")
        } else {
            format!("{n} {unit}s")
        }
    };
    match secs {
        0..=90 => plural(secs.max(1), "second"),
        91..=3599 => plural((secs + 30) / 60, "minute"),
        3600..=172_800 => plural((secs + 1800) / 3600, "hour"),
        _ => plural((secs + 43200) / 86400, "day"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn links() -> Links {
        Links {
            base: "https://example.com/admin".into(),
            app_name: "Acme".into(),
        }
    }

    #[test]
    fn a_link_points_at_the_dashboard_screen_that_spends_the_token() {
        let message = invitation(&links(), "Acme Ltd", Some("Ann"), "inv_abc", "7 days");
        assert!(message.text.contains("https://example.com/admin/#/accept-invite?token=inv_abc"));
        assert!(message.html.contains("accept-invite?token=inv_abc"));
        // The person who sent it is named when we know them.
        assert!(message.text.contains("Ann has invited you"));

        let anonymous = invitation(&links(), "Acme Ltd", None, "inv_abc", "7 days");
        assert!(anonymous.text.contains("You have been invited"));
    }

    #[test]
    fn every_message_carries_the_url_as_readable_text_too() {
        // A button is a link a mail client may refuse to render; the URL under
        // it is what makes the message recoverable when that happens.
        for message in [
            verification(&links(), "verify_abc", "24 hours"),
            password_reset(&links(), "reset_abc", "1 hour"),
        ] {
            let url = message
                .text
                .lines()
                .find(|line| line.starts_with("https://"))
                .expect("a bare URL on its own line");
            assert!(message.html.contains(url));
        }
    }

    #[test]
    fn markup_in_a_name_cannot_escape_into_the_message() {
        let hostile = invitation(
            &links(),
            "<script>alert(1)</script>",
            None,
            "inv_abc",
            "7 days",
        );
        assert!(!hostile.html.contains("<script>"));
        assert!(hostile.html.contains("&lt;script&gt;"));
    }

    #[test]
    fn durations_read_like_a_sentence() {
        assert_eq!(humanise(60 * 60), "1 hour");
        assert_eq!(humanise(60 * 60 * 24), "24 hours");
        assert_eq!(humanise(60 * 60 * 24 * 7), "7 days");
        assert_eq!(humanise(60 * 30), "30 minutes");
    }

    #[test]
    fn the_dashboard_path_is_where_links_go_and_the_origin_is_the_fallback() {
        let mut app = apiplant_core::App::load(std::env::temp_dir().join("apiplant-no-such-app"))
            .expect("an empty directory is a valid app");
        app.config.server.public_url = "https://api.example.com/".into();
        assert_eq!(
            Links::from_app(&app).to("verify-email", "t"),
            "https://api.example.com/admin/#/verify-email?token=t"
        );

        // With no dashboard there is no form of ours to send anyone to, so the
        // app's own origin is the best we can do.
        app.config.admin.enabled = false;
        assert_eq!(
            Links::from_app(&app).to("verify-email", "t"),
            "https://api.example.com/#/verify-email?token=t"
        );
    }
}