diff --git a/plume-common/src/activity_pub/mod.rs b/plume-common/src/activity_pub/mod.rs index 34637399..28659e4b 100644 --- a/plume-common/src/activity_pub/mod.rs +++ b/plume-common/src/activity_pub/mod.rs @@ -1,3 +1,4 @@ +use ::anyhow::{self, anyhow}; use activitystreams::{ actor::{ApActor, Group, Person}, base::{AnyBase, Base, Extends}, @@ -18,6 +19,10 @@ use rocket::{ response::{Responder, Response}, Outcome, }; +use std::{ + convert::{TryFrom, TryInto}, + str::FromStr, fmt, +}; use tokio::{ runtime, time::{sleep, Duration}, @@ -241,6 +246,86 @@ pub trait IntoId { fn into_id(self) -> Id; } +#[repr(transparent)] +#[derive(Shrinkwrap, PartialEq, Eq, Clone, Serialize, Deserialize, Debug)] +pub struct PreferredUsername(String); + +// Mastodon allows only /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i for `preferredUsername` +impl PreferredUsername { + fn validate(name: &str) -> anyhow::Result<()> { + let len = name.len(); + if len < 3 { + return Err(anyhow!("FQN must be longer than 2 characters")); + } + match name.chars().enumerate().find(|(pos, c)| { + if pos == &0 || pos == &(len - 1) { + c != &'_' && !c.is_ascii_alphanumeric() + } else { + match c { + '_' | '\\' | '.' | '-' => false, + _ => !c.is_ascii_alphanumeric(), + } + } + }) { + Some((pos, c)) => Err(anyhow!("Invaliad character at {}: {}", pos, c)), + None => Ok(()), + } + } + + /// # Safety + /// + /// The given string must be match against /\A[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?\z/i in Ruby's RegExp which is required by Mastodon. + pub unsafe fn new_unchecked(name: String) -> Self { + Self(name) + } + + pub fn new(name: String) -> anyhow::Result { + Self::validate(&name).map(|_| unsafe { Self::new_unchecked(name) }) + } +} + +impl fmt::Display for PreferredUsername { + fn fmt(&self, f:&mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl TryFrom for PreferredUsername { + type Error = anyhow::Error; + + fn try_from(name: String) -> std::result::Result { + Self::new(name) + } +} + +impl TryFrom<&str> for PreferredUsername { + type Error = anyhow::Error; + + fn try_from(name: &str) -> std::result::Result { + Self::new(name.to_owned()) + } +} + +impl From for String { + fn from(preferred_username: PreferredUsername) -> Self { + preferred_username.0 + } +} + +impl AsRef for PreferredUsername { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl FromStr for PreferredUsername { + type Err = anyhow::Error; + + fn from_str(name: &str) -> std::result::Result { + name.try_into() + } +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ApSignature { @@ -524,6 +609,35 @@ mod tests { use assert_json_diff::assert_json_eq; use serde_json::{from_str, json, to_value}; + #[test] + fn preferred_username() { + assert!(PreferredUsername::new("".into()).is_err()); + assert!(PreferredUsername::new("a".into()).is_err()); + assert!(PreferredUsername::new("ab".into()).is_err()); + assert_eq!( + "abc", + PreferredUsername::new("abc".into()).unwrap().as_str() + ); + assert_eq!( + "abcd", + PreferredUsername::new("abcd".into()).unwrap().as_str() + ); + assert!(PreferredUsername::new("abc-".into()).is_err()); + assert!(PreferredUsername::new("日本語".into()).is_err()); + assert_eq!("abc", "abc".parse::().unwrap().as_str()); + assert!("abc-".parse::().is_err()); + assert_eq!( + PreferredUsername::new("admin".into()).unwrap(), + PreferredUsername("admin".into()) + ); + } + + #[test] + fn prefferred_username_to_string() { + let pu = PreferredUsername::new("admin".into()).unwrap(); + assert_eq!("admin".to_string(), pu.to_string()); + } + #[test] fn se_ap_signature() { let ap_signature = ApSignature {