#![forbid(unsafe_code)]
#![warn(missing_docs)]
use document::{determine_best_favicon, get_website_metadata};
use download_image::{download_image_href, resolve_full_url};
use mime::Mime;
use reqwest::{Proxy, redirect::Policy};
use serde::{Deserialize, Serialize};
use std::{str::FromStr, time::Duration};
use thiserror::Error;
use url_validation::TokioDomainResolver;
mod data_uri;
mod document;
mod download_image;
mod request;
mod url_validation;
pub use document::Favicon;
pub use reqwest::Url;
use crate::{document::is_allowed_robots_txt, download_image::ImageStream};
#[derive(Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct WebsiteMetaServiceConfig {
pub http_proxy: Option<String>,
pub https_proxy: Option<String>,
pub metadata_connect_timeout: Duration,
pub metadata_read_timeout: Duration,
}
#[derive(Debug, Error)]
pub enum WebsiteMetaServiceConfigError {
#[error("DOCBOX_WEB_SCRAPE_METADATA_CONNECT_TIMEOUT must be a number in seconds: {0}")]
InvalidMetadataConnectTimeout(<u64 as FromStr>::Err),
#[error("DOCBOX_WEB_SCRAPE_METADATA_READ_TIMEOUT must be a number in seconds")]
InvalidMetadataReadTimeout(<u64 as FromStr>::Err),
}
impl Default for WebsiteMetaServiceConfig {
fn default() -> Self {
Self {
http_proxy: None,
https_proxy: None,
metadata_connect_timeout: Duration::from_secs(5),
metadata_read_timeout: Duration::from_secs(10),
}
}
}
impl WebsiteMetaServiceConfig {
pub fn from_env() -> Result<WebsiteMetaServiceConfig, WebsiteMetaServiceConfigError> {
let mut config = WebsiteMetaServiceConfig {
http_proxy: std::env::var("DOCBOX_WEB_SCRAPE_HTTP_PROXY").ok(),
https_proxy: std::env::var("DOCBOX_WEB_SCRAPE_HTTPS_PROXY").ok(),
..Default::default()
};
if let Ok(metadata_connect_timeout) =
std::env::var("DOCBOX_WEB_SCRAPE_METADATA_CONNECT_TIMEOUT")
{
let metadata_connect_timeout = metadata_connect_timeout
.parse::<u64>()
.map_err(WebsiteMetaServiceConfigError::InvalidMetadataConnectTimeout)?;
config.metadata_connect_timeout = Duration::from_secs(metadata_connect_timeout);
}
if let Ok(metadata_read_timeout) = std::env::var("DOCBOX_WEB_SCRAPE_METADATA_READ_TIMEOUT")
{
let metadata_read_timeout = metadata_read_timeout
.parse::<u64>()
.map_err(WebsiteMetaServiceConfigError::InvalidMetadataReadTimeout)?;
config.metadata_read_timeout = Duration::from_secs(metadata_read_timeout);
}
Ok(config)
}
}
pub struct WebsiteMetaService {
client: reqwest::Client,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedWebsiteMetadata {
pub title: Option<String>,
pub og_title: Option<String>,
pub og_description: Option<String>,
#[serde(skip)]
pub og_image: Option<String>,
#[serde(skip)]
pub best_favicon: Option<String>,
}
#[derive(Debug)]
pub struct ResolvedImage {
pub content_type: Mime,
pub stream: ImageStream,
}
impl WebsiteMetaService {
pub fn new() -> reqwest::Result<Self> {
Self::from_config(Default::default())
}
#[deprecated]
pub fn from_client(client: reqwest::Client) -> Self {
Self { client }
}
pub fn from_config(config: WebsiteMetaServiceConfig) -> reqwest::Result<Self> {
let mut builder = reqwest::Client::builder();
if let Some(http_proxy) = config.http_proxy.clone() {
builder = builder.proxy(Proxy::http(http_proxy)?);
}
if let Some(https_proxy) = config.https_proxy.clone() {
builder = builder.proxy(Proxy::https(https_proxy)?);
}
let client = builder
.user_agent("DocboxLinkBot")
.connect_timeout(config.metadata_connect_timeout)
.read_timeout(config.metadata_read_timeout)
.redirect(Policy::none())
.dns_resolver(TokioDomainResolver)
.build()?;
Ok(Self { client })
}
pub async fn resolve_website(&self, url: &Url) -> Option<ResolvedWebsiteMetadata> {
let is_allowed_scraping = is_allowed_robots_txt::<TokioDomainResolver>(&self.client, url)
.await
.unwrap_or(false);
if !is_allowed_scraping {
return None;
}
let res = match get_website_metadata::<TokioDomainResolver>(&self.client, url).await {
Ok(value) => value,
Err(error) => {
tracing::error!(?error, "failed to get website metadata");
return None;
}
};
let best_favicon = determine_best_favicon(&res.favicons).cloned();
Some(ResolvedWebsiteMetadata {
title: res.title,
og_title: res.og_title,
og_description: res.og_description,
og_image: res.og_image,
best_favicon: best_favicon.map(|value| value.href),
})
}
pub async fn resolve_website_favicon(&self, url: &Url) -> Option<ResolvedImage> {
let website = self.resolve_website(url).await?;
self.resolve_favicon(url, website.best_favicon).await
}
pub async fn resolve_website_image(&self, url: &Url) -> Option<ResolvedImage> {
let website = self.resolve_website(url).await?;
let og_image = website.og_image?;
self.resolve_image(url, &og_image).await
}
pub async fn resolve_favicon(
&self,
url: &Url,
best_favicon: Option<String>,
) -> Option<ResolvedImage> {
let favicon = match best_favicon {
Some(best) => best,
None => {
let mut url = url.clone();
url.set_path("/favicon.ico");
url.to_string()
}
};
self.resolve_image(url, &favicon).await
}
pub async fn resolve_image(&self, url: &Url, image: &str) -> Option<ResolvedImage> {
let image_url = resolve_full_url(url, image).ok()?;
let (stream, content_type) =
download_image_href::<TokioDomainResolver>(&self.client, image_url)
.await
.ok()?;
Some(ResolvedImage {
content_type,
stream,
})
}
}