use async_trait::async_trait; use fluffer::{Client, GemBytes}; use std::fmt::Display; use url::Url; use crate::error::GementionError; use super::verification::VerificationStatus; macro_rules! titan { ($self:expr) => { $self.client.titan.as_ref().unwrap() }; } fn parse_content_body(raw_body: &[u8]) -> Result<&str, GementionError> { let content = std::str::from_utf8(raw_body)?; // Drop 1st line if it is a mention type. Otherwise return whole thing. let mention_type = MentionType::try_from(raw_body); let content: &str = if let Ok(_) = mention_type { let amt_to_chop = content.lines().next().map(|line| line.len()).unwrap(); &content[amt_to_chop..] } else { content }; Ok(content.trim()) } /// Wraps an incoming Titan request and provides handy methods for /// extracting the expected information for a client-to-server /// gemention. pub(crate) struct C2SMentionRequest<'a> { client: &'a Client, accessed_url: &'a Url, } impl<'a> C2SMentionRequest<'a> { pub fn from_client(client: &'a Client) -> Result { if let Some(_) = client.titan { Ok(Self { client: &client, accessed_url: &client.url, }) } else { Err(GementionError::NotTitanResource) } } pub fn raw_body(&self) -> &[u8] { titan!(self).content.as_slice() } pub fn mime(&self) -> &str { titan!(self).mime.as_ref() } pub fn token(&self) -> Option<&str> { titan!(self).token.as_deref() } pub fn size(&self) -> usize { titan!(self).size } pub fn certificate(&self) -> Option { self.client.certificate() } pub fn fingerprint(&self) -> Option { self.client.fingerprint() } pub fn username(&self) -> Option { self.client.name() } pub fn target(&self) -> Option<&str> { self.client.parameter("target") } pub fn content(&self) -> Result<&str, GementionError> { parse_content_body(self.raw_body()) } /// The full URL that was accessed for the incoming request. pub fn accessed_url(&self) -> &Url { self.accessed_url } } impl<'a> TryFrom<&'a Client> for C2SMentionRequest<'a> { type Error = GementionError; fn try_from(client: &'a Client) -> Result { C2SMentionRequest::from_client(client) } } #[derive(Debug)] pub(crate) enum MentionType { Reply, Like, } #[derive(Debug)] pub(crate) struct Mention { receiving_endpoint: Url, mention_type: MentionType, target: String, user: String, content: Option, verification_status: VerificationStatus, } impl Mention { pub fn target(&self) -> &str { &self.target } pub fn receiving_endpoint(&self) -> &Url { &self.receiving_endpoint } pub fn target_url(&self) -> Url { Url::parse(&format!("gemini://{}", &self.target)).unwrap() } pub fn mention_type(&self) -> &MentionType { &self.mention_type } pub fn user(&self) -> &str { &self.user } pub fn content(&self) -> Option<&str> { self.content.as_deref() } pub fn verify_status(&self) -> &VerificationStatus { &self.verification_status } pub fn set_verify_status(&mut self, status: VerificationStatus) { self.verification_status = status; } fn valid_gemtext(&self) -> String { let headline = format!("## {} from {}", self.mention_type, self.user); let header = format!("=> {} In response to this page", self.target_url()); let content = match &self.mention_type { MentionType::Reply => self.content.clone().unwrap_or(String::from("[no content]")), MentionType::Like => format!("{} liked this.", self.user), }; format!("{}\n\n{}\n\n### Content\n{}", headline, header, content) } fn invalid_gemtext(&self) -> String { let headline = String::from("## Invalid Mention"); let failure_reason = match &self.verification_status { VerificationStatus::Invalid(failure) => failure.to_string(), VerificationStatus::NotYetVerified => String::from("verification not executed"), _ => String::from("not invalid"), }; format!("{}\n\n{}", headline, failure_reason) } pub fn as_gemtext(&self) -> String { match self.verification_status { VerificationStatus::Verified { .. } => self.valid_gemtext(), _ => self.invalid_gemtext(), } } } impl Display for Mention { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let body = self.as_gemtext(); write!(f, "{}", body) } } // This is for converting an incoming Titan request into a mention // type. It will attempt to parse the first line to identify the type. // If the type cannot be identified, or if the string is not valid // UTF-8, an error is returned. impl TryFrom<&[u8]> for MentionType { type Error = GementionError; fn try_from(content: &[u8]) -> Result { let content = std::str::from_utf8(&content)?; let content_type = content .lines() .next() .map(|line| line.trim()) .unwrap_or("[none provided]"); match content_type.to_lowercase() { value if value == "reply" => Ok(Self::Reply), value if value == "like" => Ok(Self::Like), _ => Err(GementionError::InvalidMentionType(content_type.to_owned())), } } } impl<'a> TryFrom> for Mention { type Error = GementionError; fn try_from(resource: C2SMentionRequest<'a>) -> Result { if resource.mime() == "text/plain" { // Be flexible on mention type: if first line isn't a // mention type, just assume reply. let mention_type = match MentionType::try_from(resource.raw_body()) { Ok(mention_type) => mention_type, Err(GementionError::InvalidMentionType(_)) => MentionType::Reply, Err(e) => return Err(e), }; let target = resource .target() .ok_or(GementionError::TargetNotProvided)? .to_owned(); let user = resource .username() .ok_or(GementionError::UsernameNotProvided)?; let receiving_endpoint = Url::parse( resource .accessed_url() .as_str() .split(&target) .next() .ok_or(GementionError::InvalidBody)?, )?; let content = resource.content()?.to_owned(); let content = if !content.is_empty() { Some(content) } else { None }; Ok(Mention { user, mention_type, content, target, receiving_endpoint, verification_status: VerificationStatus::NotYetVerified, }) } else { Err(GementionError::InvalidBody) } } } impl Display for MentionType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Reply => write!(f, "Reply"), Self::Like => write!(f, "Like"), } } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_comment_body_with_mention_type() -> Result<(), GementionError> { let body = "reply\nthis is my comment body, which is a reply.".as_bytes(); let expected = "this is my comment body, which is a reply."; let parsed = parse_content_body(body)?; assert_eq!(expected, parsed); Ok(()) } #[test] fn parse_comment_body_without_mention_type() -> Result<(), GementionError> { let expected = "this is my comment body, which has no mention type."; let body = expected.as_bytes(); let parsed = parse_content_body(body)?; assert_eq!(expected, parsed); Ok(()) } }