Something went wrong. Try again.
A downstream fork of Tranquil PDS for use in margin.cafe
Something went wrong. Try again.
11 kB · 353 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354use crate::types::{Nsid, Rkey};use serde_json::Value;use thiserror::Error;use tranquil_lexicon::LexValidationError;
#[derive(Debug, Error)]pub enum ValidationError { #[error("No $type provided")] MissingType, #[error("Invalid $type: expected {expected}, got {actual}")] TypeMismatch { expected: String, actual: String }, #[error("Missing required field: {0}")] MissingField(String), #[error("Invalid field value at {path}: {message}")] InvalidField { path: String, message: String }, #[error("Invalid datetime format at {path}: must be RFC-3339/ISO-8601")] InvalidDatetime { path: String }, #[error("Invalid record: {0}")] InvalidRecord(String), #[error("Unknown record type: {0}")] UnknownType(String), #[error("Unacceptable slur in record at {path}")] BannedContent { path: String },}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]#[serde(rename_all = "lowercase")]pub enum ValidationStatus { Valid, Unknown, Invalid,}
impl std::fmt::Display for ValidationStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Valid => write!(f, "valid"), Self::Unknown => write!(f, "unknown"), Self::Invalid => write!(f, "invalid"), } }}
pub struct RecordValidator { require_lexicon: bool,}
impl Default for RecordValidator { fn default() -> Self { Self::new() }}
impl RecordValidator { pub fn new() -> Self { Self { require_lexicon: false, } }
pub fn require_lexicon(mut self, require: bool) -> Self { self.require_lexicon = require; self }
pub fn validate( &self, record: &Value, collection: &Nsid, ) -> Result<ValidationStatus, ValidationError> { self.validate_with_rkey(record, collection, None) }
pub fn validate_with_rkey( &self, record: &Value, collection: &Nsid, rkey: Option<&Rkey>, ) -> Result<ValidationStatus, ValidationError> { let (record_type, obj) = validate_preamble(record, collection)?; let registry = tranquil_lexicon::LexiconRegistry::global();
match tranquil_lexicon::validate_record(registry, collection, record) { Ok(()) => { check_banned_content(record_type, obj, rkey)?; Ok(ValidationStatus::Valid) } Err(LexValidationError::LexiconNotFound(_)) => { if self.require_lexicon { Err(ValidationError::UnknownType(record_type.to_string())) } else { check_banned_content(record_type, obj, rkey)?; Ok(ValidationStatus::Unknown) } } Err(LexValidationError::MissingRequired { path }) => { Err(ValidationError::MissingField(path)) } Err(LexValidationError::InvalidField { path, message }) => { Err(ValidationError::InvalidField { path, message }) } Err(LexValidationError::RecursionDepthExceeded { path }) => { Err(ValidationError::InvalidField { path, message: "recursion depth exceeded".to_string(), }) } } }}
fn validate_preamble<'a>( record: &'a Value, collection: &Nsid,) -> Result<(&'a str, &'a serde_json::Map<String, Value>), ValidationError> { let obj = record .as_object() .ok_or_else(|| ValidationError::InvalidRecord("Record must be an object".to_string()))?; let record_type = obj .get("$type") .and_then(|v| v.as_str()) .ok_or(ValidationError::MissingType)?; if record_type != *collection { return Err(ValidationError::TypeMismatch { expected: collection.to_string(), actual: record_type.to_string(), }); } if let Some(created_at) = obj.get("createdAt").and_then(|v| v.as_str()) { validate_datetime(created_at, "createdAt")?; } Ok((record_type, obj))}
fn check_banned_content( record_type: &str, obj: &serde_json::Map<String, Value>, rkey: Option<&Rkey>,) -> Result<(), ValidationError> { match record_type { #[cfg(feature = "bsky")] "app.bsky.feed.post" => { check_post_banned_content(obj)?; } #[cfg(feature = "bsky")] "app.bsky.actor.profile" => { check_string_field(obj, "displayName")?; check_string_field(obj, "description")?; } #[cfg(feature = "bsky")] "app.bsky.graph.list" => { check_string_field(obj, "name")?; } #[cfg(feature = "bsky")] "app.bsky.graph.starterpack" => { check_string_field(obj, "name")?; check_string_field(obj, "description")?; } #[cfg(feature = "bsky")] "app.bsky.feed.generator" => { if let Some(rkey) = rkey && crate::moderation::has_explicit_slur(rkey) { return Err(ValidationError::BannedContent { path: "rkey".to_string(), }); } check_string_field(obj, "displayName")?; } _ => {} } Ok(())}
#[cfg(feature = "bsky")]fn check_post_banned_content(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> { if let Some(tags) = obj.get("tags").and_then(|v| v.as_array()) { tags.iter().enumerate().try_for_each(|(i, tag)| { if let Some(tag_str) = tag.as_str() && crate::moderation::has_explicit_slur(tag_str) { return Err(ValidationError::BannedContent { path: format!("tags/{}", i), }); } Ok(()) })?; } if let Some(facets) = obj.get("facets").and_then(|v| v.as_array()) { facets.iter().enumerate().try_for_each(|(i, facet)| { if let Some(features) = facet.get("features").and_then(|v| v.as_array()) { features.iter().enumerate().try_for_each(|(j, feature)| { let is_tag = feature .get("$type") .and_then(|v| v.as_str()) .is_some_and(|t| t == "app.bsky.richtext.facet#tag"); if is_tag && let Some(tag) = feature.get("tag").and_then(|v| v.as_str()) && crate::moderation::has_explicit_slur(tag) { return Err(ValidationError::BannedContent { path: format!("facets/{}/features/{}/tag", i, j), }); } Ok(()) })?; } Ok(()) })?; } Ok(())}
fn check_string_field( obj: &serde_json::Map<String, Value>, field: &str,) -> Result<(), ValidationError> { if let Some(value) = obj.get(field).and_then(|v| v.as_str()) && crate::moderation::has_explicit_slur(value) { return Err(ValidationError::BannedContent { path: field.to_string(), }); } Ok(())}
fn validate_datetime(value: &str, path: &str) -> Result<(), ValidationError> { if !tranquil_lexicon::is_valid_datetime(value) { return Err(ValidationError::InvalidDatetime { path: path.to_string(), }); } Ok(())}
pub fn validate_record_key(rkey: &str) -> Result<(), ValidationError> { if !tranquil_lexicon::is_valid_record_key(rkey) { return Err(ValidationError::InvalidRecord(format!( "Invalid record key: '{}'", rkey ))); } Ok(())}
pub fn validate_collection_nsid(collection: &str) -> Result<(), ValidationError> { if !tranquil_lexicon::is_valid_nsid(collection) { return Err(ValidationError::InvalidRecord(format!( "Invalid collection NSID: '{}'", collection ))); } Ok(())}
#[derive(Debug)]pub struct PasswordValidationError { pub errors: Vec<String>,}
impl std::fmt::Display for PasswordValidationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.errors.join("; ")) }}
impl std::error::Error for PasswordValidationError {}
pub fn validate_password(password: &str) -> Result<(), PasswordValidationError> { let errors: Vec<&'static str> = [ (password.len() < 8).then_some("Password must be at least 8 characters"), (password.len() > 256).then_some("Password must be at most 256 characters"), (!password.chars().any(|c| c.is_ascii_lowercase())) .then_some("Password must contain at least one lowercase letter"), (!password.chars().any(|c| c.is_ascii_uppercase())) .then_some("Password must contain at least one uppercase letter"), (!password.chars().any(|c| c.is_ascii_digit())) .then_some("Password must contain at least one number"), is_common_password(password) .then_some("Password is too common, please choose a different one"), ] .into_iter() .flatten() .collect();
if errors.is_empty() { Ok(()) } else { Err(PasswordValidationError { errors: errors.iter().map(|s| (*s).to_string()).collect(), }) }}
fn is_common_password(password: &str) -> bool { const COMMON_PASSWORDS: &[&str] = &[ "password", "Password1", "Password123", "Passw0rd", "Passw0rd!", "12345678", "123456789", "1234567890", "qwerty123", "Qwerty123", "qwertyui", "Qwertyui", "letmein1", "Letmein1", "welcome1", "Welcome1", "admin123", "Admin123", "password1", "Password1!", "iloveyou", "Iloveyou1", "monkey123", "Monkey123", "dragon12", "Dragon123", "master12", "Master123", "login123", "Login123", "abc12345", "Abc12345", "football", "Football1", "baseball", "Baseball1", "trustno1", "Trustno1", "sunshine", "Sunshine1", "princess", "Princess1", "computer", "Computer1", "whatever", "Whatever1", "nintendo", "Nintendo1", "bluesky1", "Bluesky1", "Bluesky123", ];
let lower = password.to_lowercase(); COMMON_PASSWORDS.iter().any(|p| p.to_lowercase() == lower)}