use crate::embed::Embedder;
use crate::parsers::DocumentParsers;
use crate::store::VectorStore;
use crate::types::ChunkConfig;
use crate::vector::sync_corpus_with_progress;
use anyhow::{Context, Result, anyhow};
use std::fs;
use std::net::IpAddr;
use std::path::Path;
#[allow(dead_code)]
pub const DEFAULT_MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024;
pub async fn fetch_url(
http_client: &reqwest::Client,
url: &str,
max_download_bytes: Option<u64>,
) -> Result<(Vec<u8>, String, Option<String>)> {
let parsed = reqwest::Url::parse(url).with_context(|| format!("Invalid URL: '{url}'"))?;
let host_str = parsed
.host_str()
.ok_or_else(|| anyhow!("URL has no host: '{url}'"))?;
if host_str == "169.254.169.254" || host_str == "metadata.google.internal" {
return Err(anyhow!(
"URL resolves to a cloud metadata service ({}); download blocked for security",
host_str
));
}
let addrs: Vec<std::net::SocketAddr> = tokio::time::timeout(
std::time::Duration::from_secs(15),
tokio::net::lookup_host((host_str, 0u16)),
)
.await
.with_context(|| format!("DNS resolution timed out for '{host_str}'"))?
.with_context(|| format!("DNS resolution failed for '{host_str}'"))?
.collect();
for addr in &addrs {
if is_private_or_loopback(addr.ip()) {
return Err(anyhow!(
"URL '{}' resolves to {} which is a private/internal address; download blocked for security",
url,
addr.ip()
));
}
}
let response = http_client
.get(url)
.send()
.await
.with_context(|| format!("Download failed for '{url}'"))?;
if !response.status().is_success() {
return Err(anyhow!("HTTP {}: {}", response.status().as_u16(), url));
}
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let filename = response
.headers()
.get("content-disposition")
.and_then(|v| v.to_str().ok())
.and_then(|cd| {
cd.split("filename=")
.nth(1)
.map(|s| s.trim_matches('"').to_string())
})
.unwrap_or_else(|| url.split('/').next_back().unwrap_or("download").to_string());
let decoded =
urlencoding::decode(&filename).unwrap_or_else(|_| std::borrow::Cow::Borrowed(&filename));
let filename: String = decoded
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '.' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let bytes = read_body_with_limit(response, max_download_bytes, url).await?;
Ok((bytes, filename, content_type))
}
pub(crate) fn format_hint(filename: &str, content_type: Option<&str>) -> Option<String> {
let ext = filename
.rsplit('.')
.next()
.map(|e| e.to_lowercase())
.unwrap_or_default();
let from_ext = match ext.as_str() {
"pdf" => Some("pdf"),
"epub" => Some("epub"),
"html" | "htm" => Some("html"),
"docx" => Some("docx"),
"md" | "rmd" | "qmd" => Some("md"),
_ => None,
};
if from_ext.is_some() {
return from_ext.map(String::from);
}
let ct = content_type
.unwrap_or("")
.split(';')
.next()
.unwrap_or("")
.trim()
.to_lowercase();
match ct.as_str() {
"application/pdf" => Some("pdf".into()),
"application/epub+zip" => Some("epub".into()),
"text/html" => Some("html".into()),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => {
Some("docx".into())
}
"text/markdown" => Some("md".into()),
_ => None,
}
}
#[allow(clippy::too_many_arguments)]
pub async fn download_and_ingest_url(
embedder: &dyn Embedder,
parsers: &DocumentParsers,
folder: &Path,
config: &ChunkConfig,
http_client: &reqwest::Client,
store: &dyn VectorStore,
url: &str,
max_download_bytes: Option<u64>,
) -> Result<String> {
download_and_ingest_url_with_chunker(
embedder,
parsers,
folder,
config,
http_client,
store,
url,
max_download_bytes,
&crate::chunker::MarkdownChunker,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn download_and_ingest_url_with_chunker(
embedder: &dyn Embedder,
parsers: &DocumentParsers,
folder: &Path,
config: &ChunkConfig,
http_client: &reqwest::Client,
store: &dyn VectorStore,
url: &str,
max_download_bytes: Option<u64>,
chunker: &dyn crate::chunker::Chunker,
) -> Result<String> {
let (bytes, filename, _content_type) = fetch_url(http_client, url, max_download_bytes).await?;
if !filename.to_lowercase().ends_with(".pdf") && !filename.to_lowercase().ends_with(".epub") {
return Err(anyhow!(
"URL does not appear to point to a PDF or EPUB file: {}",
filename
));
}
let dest_path = folder.join(&filename);
fs::write(&dest_path, &bytes).context("Failed to save file")?;
log::info!(
"Downloaded: {} ({} bytes)",
dest_path.display(),
bytes.len()
);
let corpus = crate::corpora::FolderCorpus::named("folder", folder);
sync_corpus_with_progress(
&corpus, parsers, chunker, embedder, config, store, None, None,
)
.await?;
Ok(format!(
"Added '{}' to the document pool ({} bytes).",
filename,
bytes.len()
))
}
async fn read_body_with_limit(
mut response: reqwest::Response,
max_bytes: Option<u64>,
url: &str,
) -> Result<Vec<u8>> {
let limit = max_bytes.unwrap_or(u64::MAX);
if let Some(cl) = response.content_length()
&& cl > limit
{
return Err(anyhow!(crate::RagrigError::DownloadLimitExceeded {
url: url.to_string(),
limit_bytes: limit,
received_bytes: cl,
}));
}
let mut buf = Vec::with_capacity(8192);
while let Some(chunk) = response.chunk().await.transpose() {
let chunk = chunk.context("Failed to read response chunk")?;
let new_len = buf.len() as u64 + chunk.len() as u64;
if new_len > limit {
return Err(anyhow!(crate::RagrigError::DownloadLimitExceeded {
url: url.to_string(),
limit_bytes: limit,
received_bytes: new_len,
}));
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}
fn is_private_or_loopback(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.is_documentation()
}
IpAddr::V6(v6) => {
v6.is_loopback() || v6.is_unspecified() || v6.is_unique_local() || v6.is_multicast()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_hint_from_extension() {
assert_eq!(format_hint("a.PDF", None), Some("pdf".into()));
assert_eq!(format_hint("b.epub", None), Some("epub".into()));
assert_eq!(format_hint("c.html", None), Some("html".into()));
assert_eq!(format_hint("d.docx", None), Some("docx".into()));
assert_eq!(format_hint("e.md", None), Some("md".into()));
}
#[test]
fn format_hint_from_content_type() {
assert_eq!(
format_hint("paper", Some("application/pdf; charset=binary")),
Some("pdf".into())
);
assert_eq!(format_hint("page", Some("text/html")), Some("html".into()));
assert_eq!(format_hint("x", Some("text/plain")), None);
assert_eq!(format_hint("x", None), None);
}
#[test]
fn loopback_v4_is_private() {
assert!(is_private_or_loopback("127.0.0.1".parse().unwrap()));
}
#[test]
fn loopback_v6_is_private() {
assert!(is_private_or_loopback("::1".parse().unwrap()));
}
#[test]
fn private_v4_is_private() {
assert!(is_private_or_loopback("192.168.1.1".parse().unwrap()));
assert!(is_private_or_loopback("10.0.0.1".parse().unwrap()));
assert!(is_private_or_loopback("172.16.0.1".parse().unwrap()));
}
#[test]
fn link_local_is_private() {
assert!(is_private_or_loopback("169.254.100.1".parse().unwrap()));
}
#[test]
fn public_v4_is_not_private() {
assert!(!is_private_or_loopback("8.8.8.8".parse().unwrap()));
assert!(!is_private_or_loopback("1.1.1.1".parse().unwrap()));
}
#[test]
fn unspecified_is_private() {
assert!(is_private_or_loopback("0.0.0.0".parse().unwrap()));
assert!(is_private_or_loopback("::".parse().unwrap()));
}
#[test]
fn documentation_range_is_private() {
assert!(is_private_or_loopback("192.0.2.1".parse().unwrap()));
assert!(is_private_or_loopback("198.51.100.1".parse().unwrap()));
assert!(is_private_or_loopback("203.0.113.1".parse().unwrap()));
}
#[tokio::test]
async fn read_body_with_limit_respects_cap() {
let body_data: Vec<u8> = vec![b'A'; 1000];
let response = reqwest::Response::from(
http::Response::builder()
.status(200)
.body(body_data)
.unwrap(),
);
let result = read_body_with_limit(response, Some(500), "http://example.com").await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("exceeded"),
"expected limit-exceeded error, got: {err}"
);
}
#[tokio::test]
async fn read_body_with_limit_no_cap_reads_all() {
let body_data: Vec<u8> = vec![b'B'; 500];
let response = reqwest::Response::from(
http::Response::builder()
.status(200)
.body(body_data.clone())
.unwrap(),
);
let result = read_body_with_limit(response, None, "http://example.com").await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), body_data);
}
}