use std::time::Duration;
use openssl::ssl::{Ssl, SslConnector, SslFiletype, SslMethod, SslVerifyMode};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpStream,
};
use tokio_openssl::SslStream;
use url::Url;
use crate::{error::ActorError, Response, UserAgent};
pub async fn trot(url: impl Into<String>) -> Result<Response> {
let url = url.into();
Actor::default().get(url).await
}
pub async fn trot_in(url: impl Into<String>, input: impl Into<String>) -> Result<Response> {
Actor::default().input(url.into(), input.into()).await
}
pub struct Actor {
pub cert: Option<String>,
pub key: Option<String>,
pub user_agent: Option<UserAgent>,
pub timeout: Duration,
}
type Result<T> = std::result::Result<T, ActorError>;
impl Default for Actor {
fn default() -> Self {
Self {
user_agent: None,
cert: None,
key: None,
timeout: Duration::from_secs(5),
}
}
}
impl Actor {
pub fn cert_file(mut self, cert: &str) -> Self {
self.cert = Some(cert.to_string());
self
}
pub fn key_file(mut self, key: &str) -> Self {
self.key = Some(key.to_string());
self
}
pub fn user_agent(mut self, useragent: UserAgent) -> Self {
self.user_agent = Some(useragent);
self
}
pub async fn get(&self, url: impl Into<String>) -> Result<Response> {
let url = self.build_url(url.into(), None)?;
self.obey_robots(&url).await?;
Ok(self.send_request(&url).await?)
}
pub async fn input(
&self,
url: impl Into<String>,
input: impl Into<String>,
) -> Result<Response> {
let input = input.into();
let input = urlencoding::encode(&input);
let url = self.build_url(url.into(), Some(&input))?;
self.obey_robots(&url).await?;
Ok(self.send_request(&url).await?)
}
async fn send_request(&self, url: &Url) -> Result<Response> {
let mut connector = SslConnector::builder(SslMethod::tls_client())?;
connector.set_verify_callback(SslVerifyMode::FAIL_IF_NO_PEER_CERT, |_, _| true);
if let Some(key) = &self.key {
connector
.set_private_key_file(key, SslFiletype::PEM)
.map_err(|e| ActorError::KeyCertFileError(e))?;
}
if let Some(cert) = &self.cert {
connector
.set_certificate_file(cert, SslFiletype::PEM)
.map_err(|e| ActorError::KeyCertFileError(e))?;
}
let domain = url.domain().ok_or(ActorError::DomainErr)?;
let port = url.port().unwrap_or(1965);
let tcp = tokio::time::timeout(
self.timeout,
TcpStream::connect(&format!("{domain}:{port}")),
)
.await
.map_err(|t| ActorError::Timeout(t))?
.map_err(|e| ActorError::TcpError(e))?;
let mut ssl = Ssl::new(connector.build().context())?;
ssl.set_connect_state();
ssl.set_hostname(domain)?;
let mut stream = SslStream::new(ssl, tcp)?;
stream
.write_all(&format!("{url}\r\n",).into_bytes())
.await?;
let mut header: Vec<u8> = Vec::new();
let mut p = b' ';
loop {
let c = stream.read_u8().await?;
if p == b'\r' && c == b'\n' {
let _ = header.pop();
break;
}
header.push(c);
p = c;
}
let header = std::str::from_utf8(&header).map_err(|e| ActorError::Utf8Header(e))?;
let (status, meta) = header.split_once(' ').ok_or(ActorError::MalformedHeader)?;
let status = status
.parse::<u8>()
.map_err(|e| ActorError::MalformedStatus(e))?;
let meta = meta.to_string();
let mut content: Vec<u8> = Vec::new();
stream.read_to_end(&mut content).await?;
Ok(Response {
content,
status,
meta,
})
}
async fn obey_robots(&self, url: &Url) -> Result<()> {
let Some(user_agent) = &self.user_agent else {
return Ok(());
};
if let Ok(response) = self
.send_request(&Url::parse(&format!(
"gemini://{}/robots.txt",
url.domain().ok_or(ActorError::DomainErr)?
))?)
.await
{
if let Ok(txt) = response.text() {
let mut robots_map = crate::utils::parse_robots(&txt);
let mut disallow_list: Vec<&str> = Vec::new();
if let Some(for_me) = robots_map.get_mut(user_agent.to_string().as_str()) {
disallow_list.append(for_me);
}
if let Some(for_everyone) = robots_map.get_mut("*") {
disallow_list.append(for_everyone);
}
for path in disallow_list {
if path == "/" || url.path().starts_with(&path) {
return Err(ActorError::RobotDenied(
path.to_string(),
user_agent.clone(),
));
}
}
}
}
Ok(())
}
fn build_url(&self, mut url: String, input: Option<&str>) -> Result<Url> {
if let Some(pos) = url.find("gemini://") {
if pos != 0 {
url = format!("gemini://{url}");
}
} else {
url = format!("gemini://{url}");
}
let mut url = Url::parse(&url)?;
if url.path() == "" {
url.set_path("/");
}
url.set_query(input);
Ok(url)
}
}