1pub mod local;
13pub mod s3;
14pub mod sigv4;
15
16use rustlavel_core::{Config, Error, Result};
17
18pub use local::LocalStorage;
19pub use s3::{S3Config, S3Storage};
20
21#[derive(Debug, Clone, PartialEq)]
23pub struct Entry {
24 pub path: String,
25 pub size: u64,
26 pub is_directory: bool,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Visibility {
37 Private,
38 Public,
39}
40
41pub trait Storage: Send + Sync {
43 fn put(&self, path: &str, contents: Vec<u8>) -> impl Future<Output = Result<()>> + Send;
44
45 fn put_with(
46 &self,
47 path: &str,
48 contents: Vec<u8>,
49 visibility: Visibility,
50 ) -> impl Future<Output = Result<()>> + Send;
51
52 fn get(&self, path: &str) -> impl Future<Output = Result<Vec<u8>>> + Send;
53
54 fn exists(&self, path: &str) -> impl Future<Output = Result<bool>> + Send;
55
56 fn delete(&self, path: &str) -> impl Future<Output = Result<()>> + Send;
57
58 fn size(&self, path: &str) -> impl Future<Output = Result<u64>> + Send;
59
60 fn list(&self, prefix: &str) -> impl Future<Output = Result<Vec<Entry>>> + Send;
62
63 fn url(&self, path: &str) -> Option<String>;
65}
66
67pub fn normalize(path: &str) -> Result<String> {
72 let mut segments: Vec<&str> = Vec::new();
73
74 for segment in path.split('/') {
75 match segment {
76 "" | "." => continue,
77 ".." => {
78 if segments.pop().is_none() {
79 return Err(traversal(path));
80 }
81 }
82 s if s.contains('\\') || s.contains('\0') => return Err(traversal(path)),
83 s => segments.push(s),
84 }
85 }
86
87 if segments.is_empty() {
88 return Err(Error::msg("a storage path cannot be empty"));
89 }
90 Ok(segments.join("/"))
91}
92
93fn traversal(path: &str) -> Error {
94 Error::msg(format!(
95 "`{path}` escapes the storage root. Paths are relative and may not contain `..`."
96 ))
97}
98
99pub fn content_type(path: &str) -> &'static str {
102 match path.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_str() {
103 "html" | "htm" => "text/html; charset=utf-8",
104 "css" => "text/css; charset=utf-8",
105 "js" | "mjs" => "text/javascript; charset=utf-8",
106 "json" => "application/json",
107 "svg" => "image/svg+xml",
108 "png" => "image/png",
109 "jpg" | "jpeg" => "image/jpeg",
110 "gif" => "image/gif",
111 "webp" => "image/webp",
112 "avif" => "image/avif",
113 "pdf" => "application/pdf",
114 "csv" => "text/csv; charset=utf-8",
115 "txt" | "md" => "text/plain; charset=utf-8",
116 "zip" => "application/zip",
117 "mp4" => "video/mp4",
118 "webm" => "video/webm",
119 _ => "application/octet-stream",
120 }
121}
122
123pub enum AnyStorage {
129 Local(LocalStorage),
130 S3(Box<S3Storage>),
131}
132
133impl AnyStorage {
134 pub fn from_config(config: &Config, root: &std::path::Path) -> Result<AnyStorage> {
135 match config.string("storage.driver", "local").as_str() {
136 "local" => {
137 let path = config.string("storage.path", "storage/app");
138 Ok(AnyStorage::Local(LocalStorage::new(root.join(path))))
139 }
140 "s3" => Ok(AnyStorage::S3(Box::new(S3Storage::new(S3Config::from_config(config)?)))),
141 other => Err(Error::msg(format!(
142 "`{other}` is not a storage driver. Use `local` or `s3`."
143 ))),
144 }
145 }
146}
147
148impl Storage for AnyStorage {
149 async fn put(&self, path: &str, contents: Vec<u8>) -> Result<()> {
150 match self {
151 AnyStorage::Local(driver) => driver.put(path, contents).await,
152 AnyStorage::S3(driver) => driver.put(path, contents).await,
153 }
154 }
155
156 async fn put_with(&self, path: &str, contents: Vec<u8>, visibility: Visibility) -> Result<()> {
157 match self {
158 AnyStorage::Local(driver) => driver.put_with(path, contents, visibility).await,
159 AnyStorage::S3(driver) => driver.put_with(path, contents, visibility).await,
160 }
161 }
162
163 async fn get(&self, path: &str) -> Result<Vec<u8>> {
164 match self {
165 AnyStorage::Local(driver) => driver.get(path).await,
166 AnyStorage::S3(driver) => driver.get(path).await,
167 }
168 }
169
170 async fn exists(&self, path: &str) -> Result<bool> {
171 match self {
172 AnyStorage::Local(driver) => driver.exists(path).await,
173 AnyStorage::S3(driver) => driver.exists(path).await,
174 }
175 }
176
177 async fn delete(&self, path: &str) -> Result<()> {
178 match self {
179 AnyStorage::Local(driver) => driver.delete(path).await,
180 AnyStorage::S3(driver) => driver.delete(path).await,
181 }
182 }
183
184 async fn size(&self, path: &str) -> Result<u64> {
185 match self {
186 AnyStorage::Local(driver) => driver.size(path).await,
187 AnyStorage::S3(driver) => driver.size(path).await,
188 }
189 }
190
191 async fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
192 match self {
193 AnyStorage::Local(driver) => driver.list(prefix).await,
194 AnyStorage::S3(driver) => driver.list(prefix).await,
195 }
196 }
197
198 fn url(&self, path: &str) -> Option<String> {
199 match self {
200 AnyStorage::Local(driver) => driver.url(path),
201 AnyStorage::S3(driver) => driver.url(path),
202 }
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn normalizes_ordinary_paths() {
212 assert_eq!(normalize("avatars/7.png").unwrap(), "avatars/7.png");
213 assert_eq!(normalize("/avatars//7.png").unwrap(), "avatars/7.png");
214 assert_eq!(normalize("a/./b").unwrap(), "a/b");
215 assert_eq!(normalize("a/b/../c").unwrap(), "a/c");
216 }
217
218 #[test]
219 fn refuses_to_escape_the_root() {
220 for attempt in ["../secrets", "a/../../secrets", "..", "a\\b", "a\0b"] {
221 assert!(normalize(attempt).is_err(), "{attempt:?} should be rejected");
222 }
223 }
224
225 #[test]
226 fn an_empty_path_is_an_error() {
227 assert!(normalize("").is_err());
228 assert!(normalize("/").is_err());
229 }
230
231 #[test]
232 fn guesses_content_types_from_the_extension() {
233 assert_eq!(content_type("a/b/photo.PNG"), "image/png");
234 assert_eq!(content_type("report.pdf"), "application/pdf");
235 assert_eq!(content_type("noextension"), "application/octet-stream");
236 }
237}