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
+88 -37
View File
@@ -29,6 +29,13 @@ const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
const HEADER_LOCK_TOKEN: HeaderName = HeaderName::from_static("lock-token");
// const HEADER_IF: HeaderName = HeaderName::from_static("if");
/// Maximum body size for XML-based WebDAV requests (PROPFIND, PROPPATCH, LOCK).
/// 1 MB is generous — a typical PROPFIND body is < 1 KB.
const MAX_XML_BODY: usize = 1_048_576;
/// Maximum body size for MKCOL requests (RFC 4918: body must be empty).
const MAX_MKCOL_BODY: usize = 4096;
/**
* Creates and returns the WebDAV router with all required endpoints.
*
@@ -167,8 +174,8 @@ async fn handle_propfind(
// Convert the request into a body
let body = req.into_body();
// Read request body
body::to_bytes(body, usize::MAX)
// Read request body (PROPFIND is XML, 1 MB is more than enough)
body::to_bytes(body, MAX_XML_BODY)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?
};
@@ -340,12 +347,10 @@ async fn handle_proppatch(
.get::<CurrentUser>()
.ok_or_else(|| AppError::unauthorized("Authentication required"))?;
// Read request body
let body_bytes = body::to_bytes(req.into_body(), usize::MAX)
// Read request body (XML — bounded to 1 MB)
let body_bytes = body::to_bytes(req.into_body(), MAX_XML_BODY)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
// Parse PROPPATCH request
.map_err(|e| AppError::payload_too_large(format!("PROPPATCH body too large or unreadable: {}", e)))?;
let (props_to_set, props_to_remove) = WebDavAdapter::parse_proppatch(body_bytes.reader())
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH request: {}", e)))?;
@@ -486,10 +491,12 @@ async fn handle_head(
/**
* Handles PUT requests to create or update files.
*
* This handler creates a new file or updates an existing file at the specified path.
* **Streaming implementation**: the request body is spooled to a temp file
* with incremental SHA-256 hashing. Peak RAM usage is ~256 KB regardless
* of file size. The temp file is then atomically moved into blob storage
* via `update_file_streaming`.
*
* @param state The application state containing service dependencies
* @param user The authenticated user information
* @param path The requested resource path
* @param req The HTTP request containing the file contents
* @return HTTP response indicating success
@@ -499,46 +506,90 @@ async fn handle_put(
req: Request<Body>,
path: String,
) -> Result<Response<Body>, AppError> {
use http_body_util::BodyStream;
use sha2::{Digest, Sha256};
use tokio::io::AsyncWriteExt;
use tokio_stream::StreamExt;
// Get file service from state
let file_upload_service = &state.applications.file_upload_service;
// Check if path is empty (root folder)
if path.is_empty() || path == "/" {
return Err(AppError::bad_request("Cannot PUT to root folder"));
if path.is_empty() || path == \"/\" {
return Err(AppError::bad_request(\"Cannot PUT to root folder\"));
}
// Hard upload size limit from config
let max_upload = state.core.config.storage.max_upload_size;
// Extract content type before consuming the request
let _content_type = req
let content_type = req
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.unwrap_or(\"application/octet-stream\")
.to_string();
// Read request body
let body_bytes = {
// Convert the request into a body
let body = req.into_body();
// ── Streaming spool: body → temp file + incremental hash ──
let temp_file = tempfile::NamedTempFile::new()
.map_err(|e| AppError::internal_error(format!(\"Failed to create temp file: {}\", e)))?;
let temp_path = temp_file.path().to_path_buf();
// Read request body
body::to_bytes(body, usize::MAX)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?
};
let mut file = tokio::fs::File::create(&temp_path)
.await
.map_err(|e| AppError::internal_error(format!(\"Failed to open temp file: {}\", e)))?;
// Check if file exists
let file_exists = file_upload_service.update_file(&path, &body_bytes).await;
let mut hasher = Sha256::new();
let mut total_bytes: usize = 0;
let mut stream = BodyStream::new(req.into_body());
match file_exists {
Ok(_) => {
// update_file handles both update and create-if-not-found
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap())
while let Some(frame_result) = stream.next().await {
let frame = frame_result
.map_err(|e| AppError::bad_request(format!(\"Failed to read request body: {}\", e)))?;
if let Some(chunk) = frame.data_ref() {
total_bytes += chunk.len();
if total_bytes > max_upload {
// Abort early — stop reading, delete temp file
drop(file);
let _ = tokio::fs::remove_file(&temp_path).await;
return Err(AppError::payload_too_large(format!(
\"Upload exceeds maximum size of {} bytes\",
max_upload
)));
}
hasher.update(chunk);
file.write_all(chunk)
.await
.map_err(|e| AppError::internal_error(format!(\"Failed to write to temp file: {}\", e)))?;
}
}
file.flush().await
.map_err(|e| AppError::internal_error(format!(\"Failed to flush temp file: {}\", e)))?;
drop(file);
let hash = hex::encode(hasher.finalize());
// ── Atomic store: temp file → dedup blob + DB metadata update ──
let result = file_upload_service
.update_file_streaming(
&path,
&temp_path,
total_bytes as u64,
&content_type,
Some(hash),
)
.await;
// Clean up temp file (may already be moved by dedup, ignore error)
let _ = tokio::fs::remove_file(&temp_path).await;
match result {
Ok(_) => Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap()),
Err(e) => Err(AppError::internal_error(format!(
"Failed to put file: {}",
\"Failed to put file: {}\",
e
))),
}
@@ -572,10 +623,10 @@ async fn handle_mkcol(
// Convert the request into a body
let body = req.into_body();
// Read request body
body::to_bytes(body, usize::MAX)
// Read request body (MKCOL — must be empty per RFC 4918)
body::to_bytes(body, MAX_MKCOL_BODY)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?
.map_err(|e| AppError::payload_too_large(format!("MKCOL body too large: {}", e)))?
};
if !body_bytes.is_empty() {
@@ -1055,8 +1106,8 @@ async fn handle_lock(
// Convert the request into a body
let body = req.into_body();
// Read request body
body::to_bytes(body, usize::MAX)
// Read request body (LOCK is XML, 1 MB is more than enough)
body::to_bytes(body, MAX_XML_BODY)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?
};