use std::path::PathBuf; use tokio::fs; use tokio::io::AsyncWriteExt; use crate::common::errors::{DomainError, Result}; #[derive(Clone)] pub struct NextcloudChunkedUploadService { pub base_dir: PathBuf, } impl NextcloudChunkedUploadService { pub fn new(base_dir: PathBuf) -> Self { Self { base_dir } } pub fn new_stub() -> Self { Self { base_dir: PathBuf::from("./storage/.uploads/nextcloud"), } } /// Validate that a path component contains no traversal characters. fn validate_path_component(name: &str, label: &str) -> Result<()> { if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") || name == "." { return Err(DomainError::validation_error(format!( "ChunkedUpload: invalid {}: contains path traversal characters", label ))); } Ok(()) } /// Build a session directory path and verify it's inside base_dir. fn safe_session_dir(&self, user: &str, upload_id: &str) -> Result { Self::validate_path_component(user, "username")?; Self::validate_path_component(upload_id, "upload_id")?; Ok(self.base_dir.join(user).join(upload_id)) } /// Create a new upload session directory. pub async fn create_session(&self, user: &str, upload_id: &str) -> Result<()> { let session_dir = self.safe_session_dir(user, upload_id)?; fs::create_dir_all(&session_dir) .await .map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?; Ok(()) } /// Resolve and validate the filesystem path for a chunk file. /// /// Public so the interface layer can stream an HTTP body straight into /// the chunk file without copying through the service. The service /// retains responsibility for path-component validation; the caller /// owns the I/O (open, write, fsync, size enforcement, cleanup on /// failure). All three `validate_path_component` calls run before the /// path is constructed, so a returned `PathBuf` is always inside /// `base_dir/{user}/{upload_id}`. pub fn safe_chunk_path( &self, user: &str, upload_id: &str, chunk_name: &str, ) -> Result { Self::validate_path_component(chunk_name, "chunk_name")?; Ok(self.safe_session_dir(user, upload_id)?.join(chunk_name)) } /// Store a chunk in the session directory. Buffers `data` in memory — /// use [`safe_chunk_path`](Self::safe_chunk_path) + the /// `interfaces/upload_spool::stream_body_to_path` helper to stream the /// HTTP body directly to disk and avoid materialising the whole chunk /// in RAM. pub async fn store_chunk( &self, user: &str, upload_id: &str, chunk_name: &str, data: &[u8], ) -> Result<()> { let chunk_path = self.safe_chunk_path(user, upload_id, chunk_name)?; let mut file = fs::File::create(&chunk_path) .await .map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?; file.write_all(data) .await .map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?; Ok(()) } /// Assemble all chunks in numeric order into a temp file. /// /// Returns `(temp_path, total_size)`. The caller is responsible for /// cleaning up the temp file after use. pub async fn assemble(&self, user: &str, upload_id: &str) -> Result<(PathBuf, u64)> { let session_dir = self.safe_session_dir(user, upload_id)?; let mut entries: Vec = Vec::new(); let mut dir = fs::read_dir(&session_dir) .await .map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?; while let Some(entry) = dir .next_entry() .await .map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))? { let name = entry.file_name().to_string_lossy().to_string(); if name == ".file" { continue; // Skip the assembly marker. } entries.push(name); } // Sort chunks numerically (Nextcloud sends them as "00001", "00002", ...). entries.sort(); // Stream chunks to a temp file instead of buffering in memory. let temp_path = session_dir.join(".assembled"); let mut out = fs::File::create(&temp_path) .await .map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?; let mut total_size: u64 = 0; for chunk_name in &entries { let mut chunk_file = fs::File::open(session_dir.join(chunk_name)) .await .map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?; let copied = tokio::io::copy(&mut chunk_file, &mut out) .await .map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?; total_size += copied; } out.flush() .await .map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?; // ── Durability boundary ─────────────────────────────────────── // `flush()` only drains tokio's userspace buffer; the bytes // still sit in the kernel page cache until the OS writeback // timer fires (~5 s on default Linux). A power loss in that // window would lose acknowledged data — the metadata row // referencing this blob would survive in PG (synchronous_commit // is on), the blob file would not. `sync_all` issues `fsync(2)` // and only returns once the bytes are on the storage medium // (modulo macOS's well-known `F_FULLFSYNC` caveat — `fsync` there // flushes to the disk controller, not the platter). Cost is // ~1–5 ms on SSD, ~5–50 ms on HDD — invisible against the // assembly I/O we already did. out.sync_all() .await .map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?; Ok((temp_path, total_size)) } /// Delete the upload session directory. pub async fn cleanup(&self, user: &str, upload_id: &str) -> Result<()> { let session_dir = self.safe_session_dir(user, upload_id)?; if session_dir.exists() { fs::remove_dir_all(&session_dir) .await .map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?; } Ok(()) } /// Check if a session directory exists. pub async fn session_exists(&self, user: &str, upload_id: &str) -> bool { self.safe_session_dir(user, upload_id) .map(|p| p.exists()) .unwrap_or(false) } /// Enumerate the chunks already stored in a session, plus the /// session directory's own mtime. Used by the PROPFIND handler /// to drive NextCloud's resume-upload flow — the Android client /// (and several mobile clients) issue PROPFIND on the session /// URL to discover which chunks are already uploaded, then only /// PUT the missing ones. /// /// Returns `None` when the session directory doesn't exist /// (handler maps to 404). The `.file` and `.assembled` markers /// are filtered out — they're internal bookkeeping, not real /// chunks the client uploaded. pub async fn list_chunks(&self, user: &str, upload_id: &str) -> Result> { let session_dir = self.safe_session_dir(user, upload_id)?; if !session_dir.exists() { return Ok(None); } let session_meta = fs::metadata(&session_dir) .await .map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?; let session_mtime = session_meta .modified() .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); let mut chunks: Vec = Vec::new(); let mut dir = fs::read_dir(&session_dir) .await .map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?; while let Some(entry) = dir .next_entry() .await .map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))? { let name = entry.file_name().to_string_lossy().to_string(); // Filter internal markers — `.file` is the NC-protocol // assembly trigger target (it never reaches the disk // because MOVE redirects it), `.assembled` is our own // staging file from `assemble()`. Surfacing either to // the client would confuse its chunk-count check. if name == ".file" || name == ".assembled" { continue; } let meta = entry .metadata() .await .map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?; let size = meta.len(); let mtime = meta .modified() .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); chunks.push(ChunkInfo { name, size, mtime }); } // Sort by chunk name so PROPFIND output is deterministic // (clients don't strictly require this, but reproducible // listings make debugging from logs much easier). chunks.sort_by(|a, b| a.name.cmp(&b.name)); Ok(Some(SessionListing { session_mtime, chunks, })) } } /// One chunk file inside an upload session. #[derive(Debug, Clone)] pub struct ChunkInfo { pub name: String, pub size: u64, pub mtime: u64, } /// What `list_chunks` returns: the session's own mtime (for the /// collection's ``) plus the list of stored /// chunks. #[derive(Debug, Clone)] pub struct SessionListing { pub session_mtime: u64, pub chunks: Vec, } #[cfg(test)] mod tests { use super::*; fn test_service() -> (NextcloudChunkedUploadService, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create temp dir"); let svc = NextcloudChunkedUploadService::new(dir.path().to_path_buf()); (svc, dir) } #[tokio::test] async fn test_create_session() { let (svc, _dir) = test_service(); svc.create_session("alice", "upload-001").await.unwrap(); assert!(svc.session_exists("alice", "upload-001").await); } #[tokio::test] async fn test_session_not_exists_before_create() { let (svc, _dir) = test_service(); assert!(!svc.session_exists("alice", "upload-999").await); } #[tokio::test] async fn test_store_and_assemble_chunks() { let (svc, _dir) = test_service(); svc.create_session("alice", "upload-002").await.unwrap(); svc.store_chunk("alice", "upload-002", "00001", b"Hello, ") .await .unwrap(); svc.store_chunk("alice", "upload-002", "00002", b"World!") .await .unwrap(); let (temp_path, size) = svc.assemble("alice", "upload-002").await.unwrap(); let assembled = fs::read(&temp_path).await.unwrap(); assert_eq!(assembled, b"Hello, World!"); assert_eq!(size, 13); } #[tokio::test] async fn test_assemble_chunks_in_sorted_order() { let (svc, _dir) = test_service(); svc.create_session("alice", "upload-003").await.unwrap(); // Store out of order. svc.store_chunk("alice", "upload-003", "00003", b"C") .await .unwrap(); svc.store_chunk("alice", "upload-003", "00001", b"A") .await .unwrap(); svc.store_chunk("alice", "upload-003", "00002", b"B") .await .unwrap(); let (temp_path, size) = svc.assemble("alice", "upload-003").await.unwrap(); let assembled = fs::read(&temp_path).await.unwrap(); assert_eq!(assembled, b"ABC"); assert_eq!(size, 3); } #[tokio::test] async fn test_cleanup_removes_session() { let (svc, _dir) = test_service(); svc.create_session("alice", "upload-004").await.unwrap(); assert!(svc.session_exists("alice", "upload-004").await); svc.cleanup("alice", "upload-004").await.unwrap(); assert!(!svc.session_exists("alice", "upload-004").await); } #[tokio::test] async fn test_cleanup_nonexistent_session_is_ok() { let (svc, _dir) = test_service(); // Should not error. svc.cleanup("alice", "nonexistent").await.unwrap(); } }