/* This file is part of gnix (https://codeberg.org/metamuffin/gnix) which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin */ //! Response caching module //! //! Considerations: //! - check cache header //! - ignore responses that get too large //! - ignore requests with body (or too large body) //! - LRU cache pruning //! - different backends: //! - in memory (HashMap) //! - on disk (redb? filesystem?) //! - external db? use super::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse}; use crate::{config::DynNode, error::ServiceError}; use anyhow::Result; use bytes::Bytes; use headers::{CacheControl, HeaderMapExt}; use http::Response; use http_body_util::{BodyExt, Full}; use log::debug; use serde::Deserialize; use serde_yml::Value; use sha2::{Digest, Sha256}; use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc, time::Duration}; use tokio::sync::RwLock; pub struct CacheKind; #[derive(Deserialize)] struct CacheConfig { next: DynNode, } struct Cache { entries: RwLock>>, config: CacheConfig, } impl NodeKind for CacheKind { fn name(&self) -> &'static str { "cache" } fn instanciate(&self, config: Value) -> Result> { let config = serde_yml::from_value::(config)?; Ok(Arc::new(Cache { config, entries: HashMap::new().into(), })) } } impl Node for Cache { fn handle<'a>( &'a self, context: &'a mut NodeContext, request: NodeRequest, ) -> Pin> + Send + Sync + 'a>> { Box::pin(async move { let allow_cache = request.method().is_safe(); if !allow_cache { return self.config.next.handle(context, request).await; } // not very fast let mut hasher = Sha256::new(); hasher.update(request.method().as_str().len().to_be_bytes()); hasher.update(request.method().as_str()); hasher.update(request.uri().path().len().to_be_bytes()); hasher.update(request.uri().path()); hasher.update([request.uri().query().is_some() as u8]); if let Some(q) = request.uri().query() { hasher.update(q.len().to_be_bytes()); hasher.update(q); } // TODO which headers are important to caching? hasher.update(request.headers().len().to_be_bytes()); for (k, v) in request.headers() { hasher.update(k.as_str().len().to_be_bytes()); hasher.update(v.as_bytes().len().to_be_bytes()); hasher.update(k); hasher.update(v); } let key: [u8; 32] = hasher.finalize().into(); if let Some(resp) = self.entries.read().await.get(&key) { debug!("hit"); return Ok(resp .to_owned() .map(|b| Full::new(b).map_err(|e| match e {}).boxed())); } debug!("miss"); let response = self.config.next.handle(context, request).await?; let cache_control = response .headers() .typed_get::() .unwrap_or(CacheControl::new().with_max_age(Duration::from_secs(120))); // TODO what is the correct default? let allow_store = !cache_control.no_store() && !cache_control.no_cache(); let response = if allow_store { debug!("store"); let h = response.headers().to_owned(); let s = response.status().to_owned(); let body = response.collect().await?.to_bytes(); let mut r1 = Response::new(Full::new(body.clone()).map_err(|e| match e {}).boxed()); *r1.headers_mut() = h.clone(); *r1.status_mut() = s; let mut r2 = Response::new(body); *r2.headers_mut() = h; *r2.status_mut() = s; self.entries.write().await.insert(key, r2); r1 } else { debug!("no store"); response }; Ok(response) }) } }