fix: OOM protection, lock-free thumbnail cache, OIDC JWKS TTL

- Streaming WebDAV PUT: body spooled to tempfile with incremental SHA-256,
  peak RAM ~64KB regardless of file size (Solution 2)
- RequestBodyLimitLayer (1MB) on CalDAV/CardDAV routers (Solution 3)
- All body::to_bytes(body, usize::MAX) replaced with explicit limits:
  PROPFIND/PROPPATCH/LOCK → 1MB, MKCOL → 4KB
- Added AppError::payload_too_large (HTTP 413)
- Added max_upload_size to StorageConfig (default 10GB, env override)
- New streaming update chain: FileWritePort::update_file_content_from_temp
  → FileUploadUseCase::update_file_streaming
- ThumbnailService: migrated from RwLock<LruCache> to moka::future::Cache
  with weight-based eviction — eliminates lock contention on read hot-path
- OIDC: discovery + JWKS caches now expire after 1 hour (Cached<T> wrapper)
  so IdP key rotation no longer requires server restart
This commit is contained in:
Diocrafts
2026-02-22 23:28:03 +01:00
parent b48f2867ac
commit 92e0364a60
12 changed files with 395 additions and 172 deletions
+47 -16
View File
@@ -8,11 +8,16 @@
use async_trait::async_trait;
use serde::Deserialize;
use std::sync::RwLock;
use std::time::{Duration, Instant};
use crate::application::ports::auth_ports::{OidcIdClaims, OidcServicePort, OidcTokenSet};
use crate::common::config::OidcConfig;
use crate::common::errors::{DomainError, ErrorKind};
/// How long discovery/JWKS documents stay cached before re-fetching.
/// 1 hour balances freshness against unnecessary network requests.
const OIDC_CACHE_TTL: Duration = Duration::from_secs(3600);
// ============================================================================
// OIDC Discovery Document
// ============================================================================
@@ -92,13 +97,33 @@ struct UserInfoResponse {
// OIDC Service
// ============================================================================
/// A cached value with a fetch timestamp for TTL-based expiry.
#[derive(Clone)]
struct Cached<T: Clone> {
value: T,
fetched_at: Instant,
}
impl<T: Clone> Cached<T> {
fn new(value: T) -> Self {
Self {
value,
fetched_at: Instant::now(),
}
}
fn is_expired(&self) -> bool {
self.fetched_at.elapsed() > OIDC_CACHE_TTL
}
}
pub struct OidcService {
config: OidcConfig,
http_client: reqwest::Client,
/// Cached discovery document
discovery: RwLock<Option<OidcDiscovery>>,
/// Cached JWKS (typed JWK keys)
jwks: RwLock<Option<JwksDocument>>,
/// Cached discovery document (expires after OIDC_CACHE_TTL)
discovery: RwLock<Option<Cached<OidcDiscovery>>>,
/// Cached JWKS (expires after OIDC_CACHE_TTL)
jwks: RwLock<Option<Cached<JwksDocument>>>,
}
impl OidcService {
@@ -116,16 +141,19 @@ impl OidcService {
}
}
/// Fetch and cache the OIDC discovery document
/// Fetch and cache the OIDC discovery document (TTL: 1 hour)
async fn get_discovery(&self) -> Result<OidcDiscovery, DomainError> {
// Check cache first
// Check cache first (return cached value only if not expired)
{
let cache = self
.discovery
.read()
.map_err(|_| DomainError::new(ErrorKind::InternalError, "OIDC", "Lock poisoned"))?;
if let Some(ref disc) = *cache {
return Ok(disc.clone());
if let Some(ref cached) = *cache {
if !cached.is_expired() {
return Ok(cached.value.clone());
}
tracing::debug!("OIDC discovery cache expired, re-fetching");
}
}
@@ -164,28 +192,31 @@ impl OidcService {
)
})?;
// Cache it
// Cache it with timestamp
{
let mut cache = self
.discovery
.write()
.map_err(|_| DomainError::new(ErrorKind::InternalError, "OIDC", "Lock poisoned"))?;
*cache = Some(discovery.clone());
*cache = Some(Cached::new(discovery.clone()));
}
Ok(discovery)
}
/// Fetch and cache JWKS document for ID token validation
/// Fetch and cache JWKS document for ID token validation (TTL: 1 hour)
async fn get_jwks(&self) -> Result<JwksDocument, DomainError> {
// Check cache first
// Check cache first (return cached value only if not expired)
{
let cache = self
.jwks
.read()
.map_err(|_| DomainError::new(ErrorKind::InternalError, "OIDC", "Lock poisoned"))?;
if let Some(ref jwks) = *cache {
return Ok(jwks.clone());
if let Some(ref cached) = *cache {
if !cached.is_expired() {
return Ok(cached.value.clone());
}
tracing::debug!("OIDC JWKS cache expired, re-fetching");
}
}
@@ -214,13 +245,13 @@ impl OidcService {
)
})?;
// Cache it
// Cache it with timestamp
{
let mut cache = self
.jwks
.write()
.map_err(|_| DomainError::new(ErrorKind::InternalError, "OIDC", "Lock poisoned"))?;
*cache = Some(jwks.clone());
*cache = Some(Cached::new(jwks.clone()));
}
Ok(jwks)