Skip to main content

modo_upload/storage/
local.rs

1use super::{FileStorage, StoredFile, ensure_within, generate_filename};
2use crate::file::UploadedFile;
3use crate::stream::BufferedUpload;
4use std::path::{Path, PathBuf};
5use tokio::io::AsyncWriteExt;
6
7/// Local filesystem storage backend.
8///
9/// Files are written under `base_dir/<prefix>/<ulid>.<ext>`.  Path traversal
10/// is rejected at the storage layer — `..` components and absolute paths
11/// return an error before any filesystem operation is attempted.
12///
13/// Requires the `local` feature (enabled by default).
14pub struct LocalStorage {
15    base_dir: PathBuf,
16}
17
18impl LocalStorage {
19    /// Create a new `LocalStorage` rooted at `base_dir`.
20    ///
21    /// The directory does not need to exist at construction time; it is
22    /// created on the first [`store`](FileStorage::store) call.
23    pub fn new(base_dir: impl Into<PathBuf>) -> Self {
24        Self {
25            base_dir: base_dir.into(),
26        }
27    }
28}
29
30#[async_trait::async_trait]
31impl FileStorage for LocalStorage {
32    async fn store(&self, prefix: &str, file: &UploadedFile) -> Result<StoredFile, modo::Error> {
33        let filename = generate_filename(file.file_name());
34        let rel_path = format!("{prefix}/{filename}");
35        let full_path = ensure_within(&self.base_dir, Path::new(&rel_path))?;
36
37        if let Some(parent) = full_path.parent() {
38            tokio::fs::create_dir_all(parent)
39                .await
40                .map_err(|e| modo::Error::internal(format!("Failed to create directory: {e}")))?;
41        }
42
43        tokio::fs::write(&full_path, file.data())
44            .await
45            .map_err(|e| modo::Error::internal(format!("Failed to write file: {e}")))?;
46
47        Ok(StoredFile {
48            path: rel_path,
49            size: file.size() as u64,
50        })
51    }
52
53    async fn store_stream(
54        &self,
55        prefix: &str,
56        stream: &mut BufferedUpload,
57    ) -> Result<StoredFile, modo::Error> {
58        let filename = generate_filename(stream.file_name());
59        let rel_path = format!("{prefix}/{filename}");
60        let full_path = ensure_within(&self.base_dir, Path::new(&rel_path))?;
61
62        if let Some(parent) = full_path.parent() {
63            tokio::fs::create_dir_all(parent)
64                .await
65                .map_err(|e| modo::Error::internal(format!("Failed to create directory: {e}")))?;
66        }
67
68        let mut file = tokio::fs::File::create(&full_path)
69            .await
70            .map_err(|e| modo::Error::internal(format!("Failed to create file: {e}")))?;
71
72        let mut total_size: u64 = 0;
73        while let Some(chunk) = stream.chunk().await {
74            let chunk =
75                chunk.map_err(|e| modo::Error::internal(format!("Failed to read chunk: {e}")))?;
76            total_size += chunk.len() as u64;
77            file.write_all(&chunk)
78                .await
79                .map_err(|e| modo::Error::internal(format!("Failed to write chunk: {e}")))?;
80        }
81        file.flush()
82            .await
83            .map_err(|e| modo::Error::internal(format!("Failed to flush file: {e}")))?;
84
85        Ok(StoredFile {
86            path: rel_path,
87            size: total_size,
88        })
89    }
90
91    async fn delete(&self, path: &str) -> Result<(), modo::Error> {
92        let full_path = ensure_within(&self.base_dir, Path::new(path))?;
93        tokio::fs::remove_file(&full_path)
94            .await
95            .map_err(|e| modo::Error::internal(format!("Failed to delete file: {e}")))?;
96        Ok(())
97    }
98
99    async fn exists(&self, path: &str) -> Result<bool, modo::Error> {
100        let full_path = ensure_within(&self.base_dir, Path::new(path))?;
101        Ok(tokio::fs::try_exists(&full_path)
102            .await
103            .map_err(|e| modo::Error::internal(format!("Failed to check file: {e}")))?)
104    }
105}