fluffer 4.0.2

🦊 Fluffer is a fun and experimental gemini server framework.
Documentation
use crate::err::ClientErr;
use matchit::Params;
use openssl::{asn1::Asn1Time, hash::MessageDigest, x509::X509};
use std::{collections::HashMap, net::SocketAddr};
use url::Url;

#[derive(Clone, Debug)]
pub struct TitanResource {
	pub mime: String,
	pub size: usize,
	pub token: Option<String>,
	pub content: Vec<u8>,
}

/// Information about a client's request.
#[derive(Clone)]
pub struct Client<S = ()> {
	pub state: S,
	pub url: Url,
	pub params: HashMap<String, String>,
	/// # Note
	/// The current route **must** be inserted with [`crate::App::titan`] for this to work.
	pub titan: Option<TitanResource>,
	/// Raw openssl certificate for advanced uses.
	pub raw_cert: Option<X509>,
	pub ip: SocketAddr,
}

impl<S: Clone> Client<S> {
	pub fn new(
		state: S,
		url: Url,
		cert: Option<X509>,
		params: &Params<'_, '_>,
		ip: SocketAddr,
		titan: Option<TitanResource>,
	) -> Result<Self, ClientErr> {
		if let Some(cert) = &cert {
			if cert
				.not_after()
				.compare(Asn1Time::days_from_now(0)?.as_ref())?
				.is_le()
			{
				return Err(ClientErr::ExpiredCert);
			}
		}

		Ok(Self {
			state,
			url,
			raw_cert: cert,
			params: params
				.iter()
				.map(|(k, v)| (k.to_string(), v.to_string()))
				.collect(),
			ip,
			titan,
		})
	}

	/// Returns the value of a route parameter.
	///
	/// **Panics:** if the parameter isn't defined.
	pub fn parameter(&self, key: &str) -> Option<&str> {
		self.params.get(key).map(|x| x.as_str())
	}

	/// Returns the value of the query at `key` (if any)
	pub fn query(&self, key: &str) -> Option<String> {
		self.url
			.query_pairs()
			.find(|x| x.0 == key)
			.and_then(|x| urlencoding::decode(&x.1).ok().map(|x| x.to_string()))
	}

	/// Returns optional user input.
	pub fn input(&self) -> Option<String> {
		self.url
			.query()
			.and_then(|x| urlencoding::decode(x).ok())
			.map(|x| x.into_owned())
	}

	/// Returns the client certificate in the PEM format.
	///
	/// More info: [Privacy-Enhanced Mail - Wikipedia](https://en.wikipedia.org/wiki/Privacy-Enhanced_Mail)
	pub fn certificate(&self) -> Option<String> {
		if let Some(cert) = &self.raw_cert {
			match cert.to_pem() {
				Ok(bytes) => match std::str::from_utf8(&bytes) {
					Ok(s) => return Some(s.to_string()),
					Err(e) => trace!("Client cert: invalid utf-8 in pem :: {e}"),
				},
				Err(e) => trace!("Client cert: failed to encode as pem :: {e}"),
			}
		}
		None
	}

	/// Returns the client certificate as a sha256 fingerprint.
	pub fn fingerprint(&self) -> Option<String> {
		use std::fmt::Write;
		if let Some(cert) = &self.raw_cert {
			// Get digest
			match &cert.digest(MessageDigest::sha256()) {
				Ok(digest_bytes) => {
					// Map digest into hex string
					let digest: String = digest_bytes.iter().fold(String::new(), |mut out, x| {
						let _ = write!(out, "{:02x}", x);
						out
					});

					return Some(digest);
				}
				Err(e) => trace!("Client cert: failed to get digest/fingerprint :: {e}"),
			}
		}
		None
	}

	/// Returns the certificate's `subject_name` field. Useful for providing temporary
	/// usernames, or just saying hello.
	pub fn name(&self) -> Option<String> {
		if let Some(cert) = &self.raw_cert {
			if let Some(entry) = cert.subject_name().entries().next() {
				match entry.data().as_utf8() {
					Ok(name) => return Some(name.to_string()),
					Err(e) => trace!("Couldn't parse name into utf8 :: {e}"),
				}
			}
		}
		None
	}

	/// Takes a certificate as PEM, and returns true if it was signed by the
	/// client.
	pub fn verify(&self, other_cert: &str) -> bool {
		match X509::from_pem(other_cert.as_bytes()) {
			Ok(other_cert) => {
				if let Some(cert) = &self.raw_cert {
					if let Ok(other_cert) = other_cert.public_key() {
						if let Ok(is_verified) = cert.verify(&other_cert) {
							return is_verified;
						}
					}
				}
			}
			Err(e) => trace!("Deserializing certificate string :: {e}"),
		}
		false
	}

	/// Returns the response of another route as gemtext.
	///
	/// # Info
	///
	/// - The client struct from this route is copied.
	///
	/// - If the response of the route function isn't valid gemtext, then the
	///   status is formatted into a code block.
	pub async fn render(&self, route: impl crate::GemCall<S>) -> String {
		let bytes = route.gem_call(self.clone()).await;

		// Not utf-8 :(
		let Ok(b) = std::str::from_utf8(&bytes) else {
			return String::from("```\nInvalid gemini response.\n```");
		};

		// Can't split header :(
		let Some((header, content)) = b.split_once("\r\n") else {
			return String::from("```\nInvalid gemini response.\n```");
		};

		// Can't split status :(
		let Some((status, meta)) = header.split_once(' ') else {
			return String::from("```\nInvalid gemini response.\n```");
		};

		// Can't parse status into u8 :(
		let Ok(status) = status.parse::<u8>() else {
			return String::from("```\nInvalid gemini response.\n```");
		};

		// Return the content of gemtext responses.
		if let Some(pos) = header.find("20 text/gemini") {
			if pos == 0 {
				return content.to_string();
			}
		}

		let status: trotter::Status = status.into();
		format!("```\n{status}\n{meta}\n```")
	}
}