modo_upload/storage/
mod.rs1#[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
20pub struct StoredFile {
22 pub path: String,
24 pub size: u64,
26}
27
28#[async_trait::async_trait]
34pub trait FileStorage: Send + Sync + 'static {
35 async fn store(&self, prefix: &str, file: &UploadedFile) -> Result<StoredFile, modo::Error>;
40
41 async fn store_stream(
46 &self,
47 prefix: &str,
48 stream: &mut BufferedUpload,
49 ) -> Result<StoredFile, modo::Error>;
50
51 async fn delete(&self, path: &str) -> Result<(), modo::Error>;
53
54 async fn exists(&self, path: &str) -> Result<bool, modo::Error>;
56}
57
58pub(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 Component::CurDir => {}
68 _ => return Err(modo::Error::internal("Invalid storage path")),
69 }
70 }
71 Ok(result)
72}
73
74#[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
88pub(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
97pub 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 #[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 #[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 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); }
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 #[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}