indieweb 0.10.0

A collection of utilities for working with the IndieWeb.
Documentation
use async_trait::async_trait;
use url::Url;

/// Vouch protocol implementation for Webmention anti-spam
///
/// This module provides the core validation logic for the Vouch extension to Webmention,
/// which helps prevent spam by requiring unknown senders to provide a vouch URL from
/// an approved authority.
///
/// The Vouch protocol works by:
/// 1. Checking if the sender's authority is in an approved list
/// 2. If not approved, requiring a vouch URL that links to the sender's authority
/// 3. Validating that the vouch URL is from an approved authority
/// 4. Verifying that the vouch URL actually links to the sender's authority
///
/// HTTP Status Codes (per Vouch specification):
/// - 400: Bad Request (invalid URLs, unapproved vouch, validation failure)
/// - 449: Retry With (authority not approved and no vouch provided)
/// - 200: OK (successful validation)
///
/// Result of vouch validation
#[derive(Debug, Clone, PartialEq)]
pub enum VouchValidationResult {
    /// Vouch is valid
    Valid,
    /// Vouch is invalid with reason
    Invalid(String),
    /// Error occurred during validation
    Error(VouchError),
}

/// Errors that can occur during vouch validation
#[derive(Debug, Clone, PartialEq, thiserror::Error, miette::Diagnostic)]
pub enum VouchError {
    #[error("Network error: {0}")]
    #[diagnostic(code(vouch::network_error))]
    Network(String),

    #[error("Invalid URL: {0}")]
    #[diagnostic(code(vouch::invalid_url))]
    InvalidUrl(String),

    #[error("HTTP error: {0}")]
    #[diagnostic(code(vouch::http_error))]
    Http(String),
}

/// Configuration for vouch validation behavior
#[derive(Debug, Clone)]
pub struct VouchConfig {
    /// Whether vouch validation is required, optional, or disabled
    pub requirement: VouchRequirement,
    /// Maximum length of vouch chains to prevent infinite loops
    pub max_vouch_chain_length: usize,
    /// Timeout for vouch validation requests in seconds
    pub vouch_timeout_seconds: u64,
}

impl Default for VouchConfig {
    fn default() -> Self {
        Self {
            requirement: VouchRequirement::Optional,
            max_vouch_chain_length: 5,
            vouch_timeout_seconds: 30,
        }
    }
}

/// Whether vouch validation is required
#[derive(Debug, Clone, PartialEq)]
pub enum VouchRequirement {
    /// Reject webmentions without valid vouch when authority not approved
    Required,
    /// Accept webmentions but mark as unvouched when authority not approved
    Optional,
    /// Ignore vouch entirely
    Disabled,
}

/// Trait for validating vouch URLs against sender authorities
#[async_trait]
pub trait VouchValidator: Send + Sync {
    /// Validate that a vouch URL properly vouches for a sender authority
    ///
    /// This checks that:
    /// 1. The vouch URL is from an approved authority
    /// 2. The vouch URL actually links to the sender's authority
    async fn validate_vouch(&self, vouch_url: &Url, sender_authority: &str) -> Result<VouchValidationResult, VouchError>;
}

/// Trait for determining if an authority is approved for vouch purposes
#[async_trait]
pub trait DomainApprover: Send + Sync {
    /// Check if an authority is approved for vouch validation
    async fn is_authority_approved(&self, authority: &str) -> Result<bool, VouchError>;
}

/// Trait for discovering appropriate vouch URLs (interface only)
#[async_trait]
pub trait VouchDiscoverer: Send + Sync {
    /// Find a vouch URL that would validate for the given sender and receiver
    async fn find_vouch_for(&self, sender_authority: &str, receiver_authority: &str) -> Result<Option<Url>, VouchError>;
}

/// Simple implementation that approves all authorities
pub struct AlwaysAllow;

#[async_trait]
impl DomainApprover for AlwaysAllow {
    async fn is_authority_approved(&self, _authority: &str) -> Result<bool, VouchError> {
        Ok(true)
    }
}

/// Extract the authority from a URL (scheme://host:port)
pub fn extract_authority(url: &Url) -> String {
    format!("{}://{}{}",
        url.scheme(),
        url.host_str().unwrap_or(""),
        url.port().map(|p| format!(":{p}")).unwrap_or_default()
    )
}

/// Validate a vouch URL against a sender authority
pub async fn validate_vouch_url(
    validator: &impl VouchValidator,
    vouch_url: &Url,
    sender_authority: &str,
) -> Result<VouchValidationResult, VouchError> {
    validator.validate_vouch(vouch_url, sender_authority).await
}

/// Check if an authority is approved
pub async fn is_authority_approved(
    approver: &impl DomainApprover,
    authority: &str,
) -> Result<bool, VouchError> {
    approver.is_authority_approved(authority).await
}

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

    #[test]
    fn test_extract_authority() {
        let url = Url::parse("https://example.com:8080/path").unwrap();
        assert_eq!(extract_authority(&url), "https://example.com:8080");

        let url = Url::parse("http://example.com/path").unwrap();
        assert_eq!(extract_authority(&url), "http://example.com");
    }

    #[tokio::test]
    async fn test_always_allow() {
        let approver = AlwaysAllow;
        assert!(approver.is_authority_approved("https://example.com").await.unwrap());
        assert!(approver.is_authority_approved("http://unknown.com").await.unwrap());
    }
}