Something went wrong. Try again.
A downstream fork of Tranquil PDS for use in margin.cafe
Something went wrong. Try again.
19 kB · 561 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562use crate::types::Handle;use std::fmt;
pub const MAX_EMAIL_LENGTH: usize = 254;pub const MAX_LOCAL_PART_LENGTH: usize = 64;pub const MAX_DOMAIN_LENGTH: usize = 253;pub const MAX_DOMAIN_LABEL_LENGTH: usize = 63;const EMAIL_LOCAL_SPECIAL_CHARS: &str = ".!#$%&'*+/=?^_`{|}~-";
pub const MIN_HANDLE_LENGTH: usize = 3;pub const MAX_HANDLE_LENGTH: usize = 253;pub const MAX_SERVICE_HANDLE_LOCAL_PART: usize = 18;
#[derive(Debug, Clone, PartialEq, Eq)]pub enum EmailValidationError { Empty, TooLong, MissingAtSign, EmptyLocalPart, LocalPartTooLong, InvalidLocalPart, EmptyDomain, DomainTooLong, MissingDomainDot, InvalidDomainLabel,}
impl fmt::Display for EmailValidationError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Empty => write!(f, "Email cannot be empty"), Self::TooLong => write!( f, "Email exceeds maximum length of {} characters", MAX_EMAIL_LENGTH ), Self::MissingAtSign => write!(f, "Email must contain @"), Self::EmptyLocalPart => write!(f, "Email local part cannot be empty"), Self::LocalPartTooLong => write!(f, "Email local part exceeds maximum length"), Self::InvalidLocalPart => write!(f, "Email local part contains invalid characters"), Self::EmptyDomain => write!(f, "Email domain cannot be empty"), Self::DomainTooLong => write!(f, "Email domain exceeds maximum length"), Self::MissingDomainDot => write!(f, "Email domain must contain a dot"), Self::InvalidDomainLabel => write!(f, "Email domain contains invalid label"), } }}
impl std::error::Error for EmailValidationError {}
fn validate_email_detailed(email: &str) -> Result<(), EmailValidationError> { if email.is_empty() { return Err(EmailValidationError::Empty); } if email.len() > MAX_EMAIL_LENGTH { return Err(EmailValidationError::TooLong); } let parts: Vec<&str> = email.rsplitn(2, '@').collect(); if parts.len() != 2 { return Err(EmailValidationError::MissingAtSign); } let domain = parts[0]; let local = parts[1]; if local.is_empty() { return Err(EmailValidationError::EmptyLocalPart); } if local.len() > MAX_LOCAL_PART_LENGTH { return Err(EmailValidationError::LocalPartTooLong); } if local.starts_with('.') || local.ends_with('.') || local.contains("..") { return Err(EmailValidationError::InvalidLocalPart); } if !local .chars() .all(|c| c.is_ascii_alphanumeric() || EMAIL_LOCAL_SPECIAL_CHARS.contains(c)) { return Err(EmailValidationError::InvalidLocalPart); } if domain.is_empty() { return Err(EmailValidationError::EmptyDomain); } if domain.len() > MAX_DOMAIN_LENGTH { return Err(EmailValidationError::DomainTooLong); } if !domain.contains('.') { return Err(EmailValidationError::MissingDomainDot); } if !domain.split('.').all(|label| { !label.is_empty() && label.len() <= MAX_DOMAIN_LABEL_LENGTH && !label.starts_with('-') && !label.ends_with('-') && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') }) { return Err(EmailValidationError::InvalidDomainLabel); } Ok(())}
#[derive(Debug, PartialEq)]pub enum HandleValidationError { Empty, TooShort, TooLong { max: usize }, InvalidCharacters, StartsWithInvalidChar, EndsWithInvalidChar, ContainsSpaces, BannedWord, Reserved, InvalidSyntax, DisallowedTld, UnusableHandleDomain, NoHandleDomains,}
impl std::fmt::Display for HandleValidationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Empty => write!(f, "Handle cannot be empty"), Self::TooShort => write!( f, "Handle must be at least {} characters", MIN_HANDLE_LENGTH ), Self::TooLong { max } => { write!(f, "Handle exceeds maximum length of {} characters", max) } Self::InvalidCharacters => write!( f, "Handle contains invalid characters. Only alphanumeric characters and hyphens are allowed" ), Self::StartsWithInvalidChar => { write!(f, "Handle cannot start with a hyphen") } Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen"), Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"), Self::BannedWord => write!(f, "Inappropriate language in handle"), Self::Reserved => write!(f, "Reserved handle"), Self::InvalidSyntax => write!(f, "Handle does not match atproto handle syntax"), Self::DisallowedTld => write!(f, "Handle uses a reserved TLD and cannot resolve"), Self::UnusableHandleDomain => write!( f, "This server's handle domain has a reserved TLD, so no handle under it is a valid atproto handle" ), Self::NoHandleDomains => { write!(f, "No handle domains are configured on this server") } } }}
impl std::error::Error for HandleValidationError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum ReservedHandlePolicy { Allow, Reject,}
pub fn validate_full_domain_handle(handle: &str) -> Result<Handle, HandleValidationError> { let handle = handle.trim();
if handle.is_empty() { return Err(HandleValidationError::Empty); }
if handle.contains(' ') || handle.contains('\t') || handle.contains('\n') { return Err(HandleValidationError::ContainsSpaces); }
if handle.len() > MAX_HANDLE_LENGTH { return Err(HandleValidationError::TooLong { max: MAX_HANDLE_LENGTH, }); }
if handle .chars() .any(|c| !c.is_ascii_alphanumeric() && c != '.' && c != '-') { return Err(HandleValidationError::InvalidCharacters); }
if !handle.contains('.') { return Err(HandleValidationError::InvalidCharacters); }
let labels: Vec<&str> = handle.split('.').collect(); let has_invalid_label = labels.iter().any(|label| { label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH || label.starts_with('-') || label.ends_with('-') }); if has_invalid_label { return Err(HandleValidationError::InvalidCharacters); }
let handle_lower = handle.to_lowercase();
if crate::moderation::has_explicit_slur(&handle_lower) { return Err(HandleValidationError::BannedWord); }
let handle = Handle::new(handle_lower).map_err(|_| HandleValidationError::InvalidSyntax)?; match handle.has_disallowed_tld() { true => Err(HandleValidationError::DisallowedTld), false => Ok(handle), }}
pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> { validate_service_handle(handle, ReservedHandlePolicy::Reject)}
pub fn resolve_handle_input(input: &str) -> Result<Handle, HandleValidationError> { let available_domains = tranquil_config::get().server.user_handle_domain_list(); let matched_domain = available_domains .iter() .filter(|d| input.ends_with(&format!(".{}", d))) .max_by_key(|d| d.len());
if !input.contains('.') || matched_domain.is_some() { let handle_to_validate = match matched_domain { Some(domain) => input.strip_suffix(&format!(".{}", domain)).unwrap_or(input), None => input, }; let validated = validate_short_handle(handle_to_validate)?; let domain = matched_domain .or_else(|| available_domains.first()) .ok_or(HandleValidationError::NoHandleDomains)?; let handle = Handle::new(format!("{}.{}", validated, domain)) .map_err(|_| HandleValidationError::InvalidSyntax)?; match handle.has_disallowed_tld() { true => Err(HandleValidationError::UnusableHandleDomain), false => Ok(handle), } } else { validate_full_domain_handle(input) }}
pub fn domain_forms_valid_handles(domain: &str) -> bool { Handle::new(format!("whelk.{domain}")).is_ok_and(|h| !h.has_disallowed_tld())}
pub fn warn_unusable_handle_domains() { tranquil_config::get() .server .user_handle_domain_list() .iter() .filter(|domain| !domain_forms_valid_handles(domain)) .for_each(|domain| { tracing::error!( domain = %domain, "configured handle domain can't form a valid atproto handle, so every account \ creation under it will be rejected. Set server.user_handle_domains to a domain \ whose TLD isn't reserved." ); });}
pub fn validate_service_handle( handle: &str, reserved_policy: ReservedHandlePolicy,) -> Result<String, HandleValidationError> { let handle = handle.trim();
if handle.is_empty() { return Err(HandleValidationError::Empty); }
if handle.contains(' ') || handle.contains('\t') || handle.contains('\n') { return Err(HandleValidationError::ContainsSpaces); }
if handle.len() < MIN_HANDLE_LENGTH { return Err(HandleValidationError::TooShort); }
if handle.len() > MAX_SERVICE_HANDLE_LOCAL_PART { return Err(HandleValidationError::TooLong { max: MAX_SERVICE_HANDLE_LOCAL_PART, }); }
if let Some(first_char) = handle.chars().next() && first_char == '-' { return Err(HandleValidationError::StartsWithInvalidChar); }
if let Some(last_char) = handle.chars().last() && last_char == '-' { return Err(HandleValidationError::EndsWithInvalidChar); }
if !handle .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-') { return Err(HandleValidationError::InvalidCharacters); }
if crate::moderation::has_explicit_slur(handle) { return Err(HandleValidationError::BannedWord); }
if reserved_policy == ReservedHandlePolicy::Reject && crate::handle::reserved::is_reserved_subdomain(handle) { return Err(HandleValidationError::Reserved); }
Ok(handle.to_lowercase())}
pub fn is_valid_email(email: &str) -> bool { validate_email_detailed(email.trim()).is_ok()}
pub fn is_valid_telegram_username(username: &str) -> bool { let clean = username.strip_prefix('@').unwrap_or(username); (5..=32).contains(&clean.len()) && clean.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')}
pub fn is_valid_discord_username(username: &str) -> bool { (2..=32).contains(&username.len()) && username .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.') && !username.contains("..")}
#[cfg(test)]mod tests { use super::*;
#[test] fn test_valid_handles() { assert_eq!(validate_short_handle("alice"), Ok("alice".to_string())); assert_eq!(validate_short_handle("bob123"), Ok("bob123".to_string())); assert_eq!( validate_short_handle("user-name"), Ok("user-name".to_string()) ); assert_eq!( validate_short_handle("UPPERCASE"), Ok("uppercase".to_string()) ); assert_eq!( validate_short_handle("MixedCase123"), Ok("mixedcase123".to_string()) ); assert_eq!(validate_short_handle("abc"), Ok("abc".to_string())); }
#[test] fn full_domain_handles_with_reserved_tlds_are_rejected() { assert!(validate_full_domain_handle("whelk.oyster.cafe").is_ok()); assert_eq!( validate_full_domain_handle("whelk.pds.internal"), Err(HandleValidationError::DisallowedTld) ); assert_eq!( validate_full_domain_handle("handle.invalid"), Err(HandleValidationError::DisallowedTld) ); }
#[test] fn test_invalid_handles() { assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty)); assert_eq!( validate_short_handle(" "), Err(HandleValidationError::Empty) ); assert_eq!( validate_short_handle("ab"), Err(HandleValidationError::TooShort) ); assert_eq!( validate_short_handle("a"), Err(HandleValidationError::TooShort) ); assert_eq!( validate_short_handle("test spaces"), Err(HandleValidationError::ContainsSpaces) ); assert_eq!( validate_short_handle("test\ttab"), Err(HandleValidationError::ContainsSpaces) ); assert_eq!( validate_short_handle("-starts"), Err(HandleValidationError::StartsWithInvalidChar) ); assert_eq!( validate_short_handle("_starts"), Err(HandleValidationError::InvalidCharacters) ); assert_eq!( validate_short_handle("ends-"), Err(HandleValidationError::EndsWithInvalidChar) ); assert_eq!( validate_short_handle("ends_"), Err(HandleValidationError::InvalidCharacters) ); assert_eq!( validate_short_handle("user_name"), Err(HandleValidationError::InvalidCharacters) ); assert_eq!( validate_short_handle("test@user"), Err(HandleValidationError::InvalidCharacters) ); assert_eq!( validate_short_handle("test!user"), Err(HandleValidationError::InvalidCharacters) ); assert_eq!( validate_short_handle("test.user"), Err(HandleValidationError::InvalidCharacters) ); }
#[test] fn test_handle_trimming() { assert_eq!(validate_short_handle(" alice "), Ok("alice".to_string())); }
#[test] fn test_handle_max_length() { assert_eq!( validate_short_handle("exactly18charslol"), Ok("exactly18charslol".to_string()) ); assert_eq!( validate_short_handle("exactly18charslol1"), Ok("exactly18charslol1".to_string()) ); assert_eq!( validate_short_handle("exactly19characters"), Err(HandleValidationError::TooLong { max: MAX_SERVICE_HANDLE_LOCAL_PART }) ); assert_eq!( validate_short_handle("waytoolongusername123456789"), Err(HandleValidationError::TooLong { max: MAX_SERVICE_HANDLE_LOCAL_PART }) ); }
#[test] fn test_reserved_subdomains() { assert_eq!( validate_short_handle("admin"), Err(HandleValidationError::Reserved) ); assert_eq!( validate_short_handle("api"), Err(HandleValidationError::Reserved) ); assert_eq!( validate_short_handle("bsky"), Err(HandleValidationError::Reserved) ); assert_eq!( validate_short_handle("barackobama"), Err(HandleValidationError::Reserved) ); assert_eq!( validate_short_handle("ADMIN"), Err(HandleValidationError::Reserved) ); assert_eq!(validate_short_handle("alice"), Ok("alice".to_string())); assert_eq!( validate_short_handle("notreserved"), Ok("notreserved".to_string()) ); }
#[test] fn test_allow_reserved() { assert_eq!( validate_service_handle("admin", ReservedHandlePolicy::Allow), Ok("admin".to_string()) ); assert_eq!( validate_service_handle("api", ReservedHandlePolicy::Allow), Ok("api".to_string()) ); assert_eq!( validate_service_handle("admin", ReservedHandlePolicy::Reject), Err(HandleValidationError::Reserved) ); }
#[test] fn test_valid_emails() { assert!(is_valid_email("user@example.com")); assert!(is_valid_email("user.name@example.com")); assert!(is_valid_email("user+tag@example.com")); assert!(is_valid_email("user@sub.example.com")); assert!(is_valid_email("USER@EXAMPLE.COM")); assert!(is_valid_email("user123@example123.com")); assert!(is_valid_email("a@b.co")); } #[test] fn test_invalid_emails() { assert!(!is_valid_email("")); assert!(!is_valid_email("user")); assert!(!is_valid_email("user@")); assert!(!is_valid_email("@example.com")); assert!(!is_valid_email("user@example")); assert!(!is_valid_email("user@@example.com")); assert!(!is_valid_email("user@.example.com")); assert!(!is_valid_email("user@example..com")); assert!(!is_valid_email(".user@example.com")); assert!(!is_valid_email("user.@example.com")); assert!(!is_valid_email("user..name@example.com")); assert!(!is_valid_email("user@-example.com")); assert!(!is_valid_email("user@example-.com")); } #[test] fn test_trimmed_whitespace() { assert!(is_valid_email(" user@example.com ")); }
#[test] fn test_valid_discord_usernames() { assert!(is_valid_discord_username("ab")); assert!(is_valid_discord_username("alice")); assert!(is_valid_discord_username("user_name")); assert!(is_valid_discord_username("user.name")); assert!(is_valid_discord_username("user123")); assert!(is_valid_discord_username("a_b.c_d")); assert!(is_valid_discord_username( "12345678901234567890123456789012" )); }
#[test] fn test_invalid_discord_usernames() { assert!(!is_valid_discord_username("")); assert!(!is_valid_discord_username("a")); assert!(!is_valid_discord_username("Alice")); assert!(!is_valid_discord_username("ALICE")); assert!(!is_valid_discord_username("user-name")); assert!(!is_valid_discord_username("user..name")); assert!(!is_valid_discord_username("user name")); assert!(!is_valid_discord_username( "123456789012345678901234567890123" )); }}