Skip to main content

modo_upload/storage/
mod.rs

1//! Pluggable storage backends for persisted file uploads.
2//!
3//! Use [`storage()`] to construct the appropriate backend from
4//! [`UploadConfig`](crate::UploadConfig), or instantiate a backend directly:
5//!
6//! - [`local::LocalStorage`] — writes files to the local filesystem
7//!   (requires the `local` feature, enabled by default).
8//! - `opendal::OpendalStorage` — delegates to any Apache OpenDAL operator,
9//!   including S3-compatible services (requires the `opendal` feature).
10
11#[cfg(feature = "local")]
12pub mod local;
13#[cfg(feature = "opendal")]
14pub mod opendal;
15
16use crate::file::UploadedFile;
17use crate::stream::BufferedUpload;
18use std::path::{Component, Path, PathBuf};
19
20/// Metadata returned after a file has been successfully stored.
21pub struct StoredFile {
22    /// Relative path within the storage backend (e.g. `avatars/01HXK3Q1A2B3.jpg`).
23    pub path: String,
24    /// File size in bytes.
25    pub size: u64,
26}
27
28/// Trait for persisting uploaded files to a storage backend.
29///
30/// Both in-memory ([`UploadedFile`]) and chunked ([`BufferedUpload`]) uploads
31/// are supported.  Implementors must be `Send + Sync + 'static` so they can be
32/// shared across async tasks.
33#[async_trait::async_trait]
34pub trait FileStorage: Send + Sync + 'static {
35    /// Store a buffered in-memory file under `prefix/`.
36    ///
37    /// A ULID-based unique filename is generated automatically.
38    /// Returns the stored path and size on success.
39    async fn store(&self, prefix: &str, file: &UploadedFile) -> Result<StoredFile, modo::Error>;
40
41    /// Store a chunked upload under `prefix/`.
42    ///
43    /// Chunks are consumed from `stream` sequentially.
44    /// Returns the stored path and size on success.
45    async fn store_stream(
46        &self,
47        prefix: &str,
48        stream: &mut BufferedUpload,
49    ) -> Result<StoredFile, modo::Error>;
50
51    /// Delete a file by its storage path (as returned by [`store`](Self::store)).
52    async fn delete(&self, path: &str) -> Result<(), modo::Error>;
53
54    /// Return `true` if a file exists at the given storage path.
55    async fn exists(&self, path: &str) -> Result<bool, modo::Error>;
56}
57
58/// Validate that `path` stays within `base` by rejecting `..`, absolute paths, and other
59/// non-normal components. Returns the resolved path under `base`.
60pub(crate) fn ensure_within(base: &Path, path: &Path) -> Result<PathBuf, modo::Error> {
61    let mut result = base.to_path_buf();
62    for component in path.components() {
63        match component {
64            Component::Normal(c) => result.push(c),
65            // `.` is harmless in filesystem paths — silently stripped.
66            // (Object-store keys must be canonical, so `validate_logical_path` rejects `.`.)
67            Component::CurDir => {}
68            _ => return Err(modo::Error::internal("Invalid storage path")),
69        }
70    }
71    Ok(result)
72}
73
74/// Validate that a logical path (for object stores) contains no `..` or leading `/`.
75#[cfg(feature = "opendal")]
76pub(crate) fn validate_logical_path(path: &str) -> Result<(), modo::Error> {
77    if path.starts_with('/') {
78        return Err(modo::Error::internal("Invalid storage path"));
79    }
80    for segment in path.split('/') {
81        if segment == ".." || segment == "." {
82            return Err(modo::Error::internal("Invalid storage path"));
83        }
84    }
85    Ok(())
86}
87
88/// Generate a unique filename: `{ulid}.{ext}`.
89pub(crate) fn generate_filename(original: &str) -> String {
90    let id = ulid::Ulid::new().to_string().to_lowercase();
91    match crate::file::extract_extension(original) {
92        Some(ext) => format!("{id}.{}", ext.to_ascii_lowercase()),
93        None => id,
94    }
95}
96
97/// Construct a [`FileStorage`] backend from [`UploadConfig`](crate::UploadConfig).
98///
99/// The backend is chosen based on `config.backend`:
100///
101/// - [`StorageBackend::Local`](crate::StorageBackend::Local) — requires the
102///   `local` feature (default).
103/// - [`StorageBackend::S3`](crate::StorageBackend::S3) — requires the
104///   `opendal` feature.
105///
106/// Returns an error if the required feature is not enabled for the selected
107/// backend, or if the S3 operator cannot be configured.
108pub fn storage(config: &crate::config::UploadConfig) -> Result<Box<dyn FileStorage>, modo::Error> {
109    match config.backend {
110        #[cfg(feature = "local")]
111        crate::config::StorageBackend::Local => {
112            Ok(Box::new(local::LocalStorage::new(&config.path)))
113        }
114        #[cfg(not(feature = "local"))]
115        crate::config::StorageBackend::Local => Err(modo::Error::internal(
116            "Local storage backend requires the `local` feature",
117        )),
118
119        #[cfg(feature = "opendal")]
120        crate::config::StorageBackend::S3 => {
121            let s3 = &config.s3;
122            let mut builder = ::opendal::services::S3::default()
123                .bucket(&s3.bucket)
124                .region(&s3.region);
125            if !s3.endpoint.is_empty() {
126                builder = builder.endpoint(&s3.endpoint);
127            }
128            if !s3.access_key_id.is_empty() {
129                builder = builder.access_key_id(&s3.access_key_id);
130            }
131            if !s3.secret_access_key.is_empty() {
132                builder = builder.secret_access_key(&s3.secret_access_key);
133            }
134            let op = ::opendal::Operator::new(builder)
135                .map_err(|e| modo::Error::internal(format!("Failed to configure S3 storage: {e}")))?
136                .finish();
137            Ok(Box::new(self::opendal::OpendalStorage::new(op)))
138        }
139        #[cfg(not(feature = "opendal"))]
140        crate::config::StorageBackend::S3 => Err(modo::Error::internal(
141            "S3 storage backend requires the `opendal` feature",
142        )),
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use std::path::Path;
150
151    // -- ensure_within --
152
153    #[test]
154    fn ensure_within_normal_path() {
155        let result = ensure_within(Path::new("base"), Path::new("sub/file.txt")).unwrap();
156        assert_eq!(result, PathBuf::from("base/sub/file.txt"));
157    }
158
159    #[test]
160    fn ensure_within_curdir_stripped() {
161        let result = ensure_within(Path::new("base"), Path::new("./sub/file.txt")).unwrap();
162        assert_eq!(result, PathBuf::from("base/sub/file.txt"));
163    }
164
165    #[test]
166    fn ensure_within_rejects_parent() {
167        let result = ensure_within(Path::new("base"), Path::new("../escape"));
168        assert!(result.is_err());
169    }
170
171    #[test]
172    fn ensure_within_rejects_absolute() {
173        let result = ensure_within(Path::new("base"), Path::new("/etc/passwd"));
174        assert!(result.is_err());
175    }
176
177    #[test]
178    fn ensure_within_empty_path() {
179        let result = ensure_within(Path::new("base"), Path::new("")).unwrap();
180        assert_eq!(result, PathBuf::from("base"));
181    }
182
183    // -- generate_filename --
184
185    #[test]
186    fn generate_filename_with_ext() {
187        let name = generate_filename("photo.JPG");
188        assert!(name.ends_with(".jpg"), "expected .jpg suffix, got: {name}");
189        // ULID is 26 chars + dot + extension
190        assert!(name.len() > 26);
191    }
192
193    #[test]
194    fn generate_filename_without_ext() {
195        let name = generate_filename("noext");
196        assert!(!name.contains('.'), "expected no dot, got: {name}");
197        assert_eq!(name.len(), 26); // lowercase ULID
198    }
199
200    #[test]
201    fn generate_filename_compound_ext() {
202        let name = generate_filename("archive.tar.gz");
203        assert!(name.ends_with(".gz"), "expected .gz suffix, got: {name}");
204    }
205
206    #[test]
207    fn generate_filename_unique() {
208        let a = generate_filename("test.txt");
209        let b = generate_filename("test.txt");
210        assert_ne!(a, b);
211    }
212
213    // -- validate_logical_path (opendal only) --
214
215    #[cfg(feature = "opendal")]
216    mod opendal_tests {
217        use super::super::validate_logical_path;
218
219        #[test]
220        fn validate_logical_path_ok() {
221            assert!(validate_logical_path("prefix/file.txt").is_ok());
222        }
223
224        #[test]
225        fn validate_logical_path_rejects_leading_slash() {
226            assert!(validate_logical_path("/absolute").is_err());
227        }
228
229        #[test]
230        fn validate_logical_path_rejects_dotdot() {
231            assert!(validate_logical_path("a/../escape").is_err());
232        }
233
234        #[test]
235        fn validate_logical_path_rejects_dot() {
236            assert!(validate_logical_path("a/./b").is_err());
237        }
238    }
239}