use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Instant;
use axum::extract::Path;
use axum::http::header;
use axum::response::{IntoResponse, Response};
use crate::error::{ApiError, ErrorCode};
use crate::types::Document;
struct Entry {
data: Arc<[u8]>,
mime: &'static str,
expires: Instant,
}
#[derive(Default)]
struct Store {
map: HashMap<String, Entry>,
order: BTreeMap<(Instant, u64), String>,
seq: u64,
bytes: usize,
}
fn store() -> &'static Mutex<Store> {
static STORE: OnceLock<Mutex<Store>> = OnceLock::new();
STORE.get_or_init(Default::default)
}
fn cap() -> usize {
let delta = 0.05;
let fallback = 64 * 1024 * 1024;
pagesift_net::resources::budget()
.map_or(fallback, |b| (b as f64 * delta) as usize)
.max(1)
}
pub fn keep(doc: &Document) {
if doc.attachments.iter().all(|a| a.data.is_none()) {
return;
}
let expires = Instant::now() + crate::jobs::JOB_TTL;
let cap = cap();
let mut st = store().lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
while let Some((&key, _)) = st.order.first_key_value() {
if key.0 > now {
break;
}
drop_oldest(&mut st);
}
for a in &doc.attachments {
let Some(data) = &a.data else { continue };
if data.len() > cap {
continue;
}
if let Some(old) = st.map.remove(&a.id) {
st.bytes -= old.data.len();
let id = a.id.clone();
st.order.retain(|_, v| *v != id);
}
while st.bytes + data.len() > cap && !st.order.is_empty() {
drop_oldest(&mut st);
}
st.seq += 1;
let seq = st.seq;
st.order.insert((expires, seq), a.id.clone());
st.bytes += data.len();
st.map.insert(
a.id.clone(),
Entry {
data: data.clone(),
mime: a.mime(),
expires,
},
);
}
}
fn drop_oldest(st: &mut Store) {
if let Some((_, id)) = st.order.pop_first()
&& let Some(e) = st.map.remove(&id)
{
st.bytes -= e.data.len();
}
}
pub fn get(id: &str) -> Option<(Arc<[u8]>, &'static str)> {
let st = store().lock().unwrap_or_else(|e| e.into_inner());
st.map
.get(id)
.filter(|e| e.expires > Instant::now())
.map(|e| (e.data.clone(), e.mime))
}
pub(crate) async fn download(Path(id): Path<String>) -> Result<Response, ApiError> {
let Some((data, mime)) = get(&id) else {
return Err(ApiError::new(
ErrorCode::NotFound,
format!("attachment {id} not found or expired"),
));
};
Ok((
[
(header::CONTENT_TYPE, mime),
(header::CACHE_CONTROL, "private, max-age=86400, immutable"),
],
data.to_vec(),
)
.into_response())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Attachment;
#[test]
fn kept_attachments_can_be_fetched_by_id() {
let mut doc = Document::default();
doc.attachments
.push(Attachment::new(b"GIF89a-test".to_vec(), "gif", None));
keep(&doc);
let id = &doc.attachments[0].id;
let (data, mime) = get(id).unwrap();
assert_eq!(&*data, b"GIF89a-test");
assert_eq!(mime, "image/gif");
assert!(get("0000.png").is_none());
}
}