use std::time::Duration;
use reqwest::blocking::Client;
use reqwest::header::{HeaderMap, HeaderValue, IF_MODIFIED_SINCE, IF_NONE_MATCH};
use sha2::{Digest, Sha256};
use url::Url;
use super::error::CacheError;
use super::meta::CacheMeta;
use super::storage::{CacheEntry, CacheStorage, FsStorage};
#[derive(Debug, Clone)]
pub struct CacheOptions {
pub cache_dir: Option<std::path::PathBuf>,
pub offline: bool,
pub refresh: bool,
pub max_age: Option<Duration>,
pub allow_http: bool,
pub max_file_size: u64,
pub timeout: Duration,
}
impl Default for CacheOptions {
fn default() -> Self {
Self {
cache_dir: None,
offline: false,
refresh: false,
max_age: None,
allow_http: false,
max_file_size: 8 * 1024 * 1024, timeout: Duration::from_secs(30),
}
}
}
#[derive(Debug)]
pub struct FetchResult {
pub content: String,
pub from_cache: bool,
pub cache_path: Option<std::path::PathBuf>,
}
pub fn base_cache_dir() -> std::path::PathBuf {
std::env::var("EURE_CACHE_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| {
directories::ProjectDirs::from("dev", "eure", "eure")
.map(|p| p.cache_dir().to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from(".cache/eure"))
})
}
pub fn https_cache_dir() -> std::path::PathBuf {
base_cache_dir().join("https")
}
pub fn fetch(url: &Url, opts: &CacheOptions) -> Result<FetchResult, CacheError> {
if url.scheme() == "http" && !opts.allow_http {
return Err(CacheError::HttpsRequired(url.to_string()));
}
let cache_dir = opts.cache_dir.clone().unwrap_or_else(https_cache_dir);
let storage = FsStorage::new(cache_dir);
if !opts.refresh {
if let Some(entry) = storage.get(url)? {
if let Some(max_age) = opts.max_age {
let age = chrono::Utc::now()
.signed_duration_since(entry.meta.last_used_at)
.to_std()
.unwrap_or(Duration::MAX);
if age < max_age {
storage.update_last_used(url)?;
return Ok(FetchResult {
content: entry.content,
from_cache: true,
cache_path: Some(entry.path),
});
}
}
if entry.meta.etag.is_some() || entry.meta.last_modified.is_some() {
match try_revalidate(url, &entry, opts) {
Ok(RevalidateResult::NotModified) => {
storage.update_last_used(url)?;
return Ok(FetchResult {
content: entry.content,
from_cache: true,
cache_path: Some(entry.path),
});
}
Ok(RevalidateResult::Modified(content, meta)) => {
let path = storage.put(url, content.as_bytes(), &meta)?;
return Ok(FetchResult {
content,
from_cache: false,
cache_path: Some(path),
});
}
Err(_) if opts.offline => {
storage.update_last_used(url)?;
return Ok(FetchResult {
content: entry.content,
from_cache: true,
cache_path: Some(entry.path),
});
}
Err(e) => return Err(e),
}
}
if opts.offline {
storage.update_last_used(url)?;
return Ok(FetchResult {
content: entry.content,
from_cache: true,
cache_path: Some(entry.path),
});
}
} else if opts.offline {
return Err(CacheError::OfflineCacheMiss(url.to_string()));
}
} else if opts.offline {
return Err(CacheError::OfflineCacheMiss(url.to_string()));
}
let (content, meta) = fetch_fresh(url, opts)?;
let path = storage.put(url, content.as_bytes(), &meta)?;
Ok(FetchResult {
content,
from_cache: false,
cache_path: Some(path),
})
}
enum RevalidateResult {
NotModified,
Modified(String, CacheMeta),
}
fn try_revalidate(
url: &Url,
entry: &CacheEntry,
opts: &CacheOptions,
) -> Result<RevalidateResult, CacheError> {
let client = Client::builder().timeout(opts.timeout).build()?;
let mut headers = HeaderMap::new();
if let Some(ref etag) = entry.meta.etag
&& let Ok(value) = HeaderValue::from_str(etag)
{
headers.insert(IF_NONE_MATCH, value);
}
if let Some(ref last_modified) = entry.meta.last_modified
&& let Ok(value) = HeaderValue::from_str(last_modified)
{
headers.insert(IF_MODIFIED_SINCE, value);
}
let response = client.get(url.as_str()).headers(headers).send()?;
if response.status() == reqwest::StatusCode::NOT_MODIFIED {
return Ok(RevalidateResult::NotModified);
}
if !response.status().is_success() {
return Err(CacheError::Http(response.error_for_status().unwrap_err()));
}
if let Some(content_length) = response.content_length()
&& content_length > opts.max_file_size
{
return Err(CacheError::FileTooLarge {
size: content_length,
limit: opts.max_file_size,
});
}
let etag = response
.headers()
.get("etag")
.and_then(|v| v.to_str().ok())
.map(String::from);
let last_modified = response
.headers()
.get("last-modified")
.and_then(|v| v.to_str().ok())
.map(String::from);
let content = response.text()?;
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
let hash_bytes = hasher.finalize();
let content_sha256 = hex::encode(&hash_bytes);
let meta = CacheMeta::new(
url.to_string(),
etag,
last_modified,
content_sha256,
content.len() as u64,
);
Ok(RevalidateResult::Modified(content, meta))
}
fn fetch_fresh(url: &Url, opts: &CacheOptions) -> Result<(String, CacheMeta), CacheError> {
let client = Client::builder()
.timeout(opts.timeout)
.redirect(reqwest::redirect::Policy::limited(5))
.build()?;
let response = client.get(url.as_str()).send()?;
if !response.status().is_success() {
return Err(CacheError::Http(response.error_for_status().unwrap_err()));
}
if let Some(content_length) = response.content_length()
&& content_length > opts.max_file_size
{
return Err(CacheError::FileTooLarge {
size: content_length,
limit: opts.max_file_size,
});
}
let etag = response
.headers()
.get("etag")
.and_then(|v| v.to_str().ok())
.map(String::from);
let last_modified = response
.headers()
.get("last-modified")
.and_then(|v| v.to_str().ok())
.map(String::from);
let content = response.text()?;
if content.len() as u64 > opts.max_file_size {
return Err(CacheError::FileTooLarge {
size: content.len() as u64,
limit: opts.max_file_size,
});
}
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
let hash_bytes = hasher.finalize();
let content_sha256 = hex::encode(&hash_bytes);
let meta = CacheMeta::new(
url.to_string(),
etag,
last_modified,
content_sha256,
content.len() as u64,
);
Ok((content, meta))
}
mod hex {
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
pub fn encode(bytes: &[u8]) -> String {
let mut result = String::with_capacity(bytes.len() * 2);
for &byte in bytes {
result.push(HEX_CHARS[(byte >> 4) as usize] as char);
result.push(HEX_CHARS[(byte & 0x0f) as usize] as char);
}
result
}
}