use crate::{error::ActorError, Response, Titan, UserAgent};
use openssl::{
nid::Nid,
ssl::{Ssl, SslConnector, SslFiletype, SslMethod, SslVerifyMode},
};
use std::{collections::HashMap, path::PathBuf, time::Duration};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpStream,
};
use tokio_openssl::SslStream;
use url::Url;
use wildmatch::WildMatch;
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 {
cert: Option<PathBuf>,
key: Option<PathBuf>,
user_agent: Option<UserAgent>,
timeout: Duration,
proxy: Option<(String, u16)>,
}
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),
proxy: None,
}
}
}
impl Actor {
pub fn cert_file(mut self, cert: impl Into<PathBuf>) -> Self {
self.cert = Some(cert.into());
self
}
pub fn key_file(mut self, key: impl Into<PathBuf>) -> Self {
self.key = Some(key.into());
self
}
pub fn user_agent(mut self, useragent: UserAgent) -> Self {
self.user_agent = Some(useragent);
self
}
pub fn timeout(mut self, timeout: impl Into<Duration>) -> Self {
self.timeout = timeout.into();
self
}
pub async fn get(&self, url: impl Into<String>) -> Result<Response> {
let url = self.build_url(url.into(), None, "gemini://")?;
self.obey_robots(&url).await?;
self.send_request(url, None).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), "gemini://")?;
self.obey_robots(&url).await?;
self.send_request(url, None).await
}
pub async fn upload(&self, url: impl Into<String>, titan: Titan) -> Result<Response> {
let url = self.build_url(url.into(), None, "titan://")?;
self.obey_robots(&url).await?;
self.send_request(url, Some(titan)).await
}
pub fn proxy(mut self, host: String, port: u16) -> Self {
self.proxy = Some((host, port));
self
}
async fn send_request(&self, url: Url, titan: Option<Titan>) -> 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(ActorError::KeyCertFileError)?;
}
if let Some(cert) = &self.cert {
connector
.set_certificate_file(cert, SslFiletype::PEM)
.map_err(ActorError::KeyCertFileError)?;
}
let (domain, port) = match self.proxy {
Some((ref proxy_host, proxy_port)) => (proxy_host.as_str(), proxy_port),
None => (
url.domain().ok_or(ActorError::DomainErr)?,
url.port().unwrap_or(1965),
),
};
let tcp = tokio::time::timeout(
self.timeout,
TcpStream::connect(&format!("{domain}:{port}")),
)
.await
.map_err(ActorError::Timeout)?
.map_err(ActorError::TcpError)?;
let mut ssl = Ssl::new(connector.build().context())?;
ssl.set_connect_state();
ssl.set_hostname(domain)?;
let mut stream = SslStream::new(ssl, tcp)?;
if let Some(titan) = titan {
stream
.write_all(
&format!(
"{url};{}mime={};size={}\r\n",
if let Some(token) = &titan.token {
format!("token={token};")
} else {
String::new()
},
titan.mimetype,
titan.content.len()
)
.into_bytes(),
)
.await?;
stream.write_all(&titan.content).await?;
} else {
stream.write_all(&format!("{url}\r\n").into_bytes()).await?;
}
let certificate = stream
.ssl()
.peer_certificate()
.ok_or(ActorError::NoCertificate)?;
let mut valid_domains: Vec<String> = Vec::new();
for x in certificate.subject_name().entries_by_nid(Nid::COMMONNAME) {
valid_domains.push(
x.data()
.as_utf8()
.map_err(ActorError::SubjectNameNotUtf8)?
.to_string(),
);
}
if let Some(names) = certificate.subject_alt_names() {
names.into_iter().for_each(|x| {
if let Some(name) = x.dnsname() {
valid_domains.push(name.to_string());
}
})
}
if valid_domains
.iter()
.filter(|x| WildMatch::new(x).matches(domain))
.count() == 0
{
return Err(ActorError::DomainUncerified(
format!("{valid_domains:?}"),
domain.to_string(),
))?;
}
let mut header: Vec<u8> = Vec::with_capacity(1024);
let mut p = b' ';
for _ in 0..=1026 {
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(ActorError::HeaderNotUtf8)?;
let (status, meta) = header.split_once(' ').ok_or(ActorError::MalformedHeader)?;
let status = status.parse::<u8>().map_err(ActorError::MalformedStatus)?;
let meta = meta.to_string();
let mut content: Vec<u8> = Vec::new();
stream.read_to_end(&mut content).await?;
Ok(Response {
content,
status,
meta,
certificate,
})
}
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)?
))?,
None,
)
.await
{
if let Ok(txt) = response.text() {
let txt = txt.lines().filter_map(|x| {
if !x.trim_start().starts_with('#') {
if let Some((x, _)) = x.split_once('#') {
Some(x)
} else {
Some(x)
}
} else {
None
}
});
let mut robots_map: HashMap<&str, Vec<&str>> = HashMap::new();
let mut active_agents: Vec<&str> = Vec::new();
let mut was_user = false; for line in txt {
if let Some((_, agent)) = line.trim().split_once("User-agent:") {
if !was_user {
active_agents.clear();
}
active_agents.push(agent.trim());
was_user = true;
} else if let Some((_, disallow)) = line.trim().split_once("Disallow:") {
for a in &active_agents {
if let Some(entry) = robots_map.get_mut(a) {
entry.push(disallow.trim());
} else {
robots_map.insert(a, vec![disallow.trim()]);
}
}
was_user = false;
}
}
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>, scheme: &str) -> Result<Url> {
if let Some(pos) = url.find(scheme) {
if pos != 0 {
url = format!("{scheme}{url}");
}
} else {
url = format!("{scheme}{url}");
}
let mut url = Url::parse(&url)?;
if url.path() == "" {
url.set_path("/");
}
if let Some(input) = input {
url.set_query(Some(input));
}
Ok(url)
}
}