Files
Oxicloud/src/infrastructure/services/nextcloud_chunked_upload_service.rs
T
2026-06-09 09:55:27 +02:00

347 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<PathBuf> {
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<PathBuf> {
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<String> = 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<Option<SessionListing>> {
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<ChunkInfo> = 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 `<d:getlastmodified>`) plus the list of stored
/// chunks.
#[derive(Debug, Clone)]
pub struct SessionListing {
pub session_mtime: u64,
pub chunks: Vec<ChunkInfo>,
}
#[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();
}
}